1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Extensionmanager\Domain\Repository;
17
18use Doctrine\DBAL\Exception as DBALException;
19use TYPO3\CMS\Core\Database\Connection;
20use TYPO3\CMS\Core\Database\ConnectionPool;
21use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
22use TYPO3\CMS\Core\Utility\VersionNumberUtility;
23use TYPO3\CMS\Extensionmanager\Domain\Model\Extension;
24use TYPO3\CMS\Extensionmanager\Parser\ExtensionXmlParser;
25
26/**
27 * Importer object for extension list, which handles the XML parser and writes directly into the database.
28 *
29 * @internal This class is a specific domain repository implementation and is not part of the Public TYPO3 API.
30 */
31class BulkExtensionRepositoryWriter implements \SplObserver
32{
33    /**
34     * @var string
35     */
36    private const TABLE_NAME = 'tx_extensionmanager_domain_model_extension';
37
38    protected ExtensionXmlParser $parser;
39
40    /**
41     * Keeps number of processed version records.
42     *
43     * @var int
44     */
45    protected $sumRecords = 0;
46
47    /**
48     * Keeps record values to be inserted into database.
49     *
50     * @var array
51     */
52    protected $arrRows = [];
53
54    /**
55     * Keeps fieldnames of tx_extensionmanager_domain_model_extension table.
56     *
57     * @var array
58     */
59    protected static $fieldNames = [
60        'extension_key',
61        'version',
62        'integer_version',
63        'current_version',
64        'alldownloadcounter',
65        'downloadcounter',
66        'title',
67        'ownerusername',
68        'author_name',
69        'author_email',
70        'authorcompany',
71        'last_updated',
72        'md5hash',
73        'remote',
74        'state',
75        'review_state',
76        'category',
77        'description',
78        'serialized_dependencies',
79        'update_comment',
80        'documentation_link',
81        'distribution_image',
82        'distribution_welcome_image',
83    ];
84
85    /**
86     * Maximum of rows that can be used in a bulk insert for the current
87     * database platform.
88     *
89     * @var int
90     */
91    protected $maxRowsPerChunk = 50;
92
93    /**
94     * Keeps the information from which remote the extension list was fetched.
95     *
96     * @var string
97     */
98    protected $remoteIdentifier;
99
100    /**
101     * @var ExtensionRepository
102     */
103    protected $extensionRepository;
104
105    /**
106     * @var Extension
107     */
108    protected $extensionModel;
109
110    /**
111     * @var ConnectionPool
112     */
113    protected $connectionPool;
114
115    /**
116     * Only import extensions newer than this date (timestamp),
117     * see constructor
118     *
119     * @var int
120     */
121    protected $minimumDateToImport;
122
123    /**
124     * Method retrieves and initializes extension XML parser instance.
125     *
126     * @param ExtensionRepository $repository
127     * @param Extension $extension
128     * @param ConnectionPool $connectionPool
129     * @param ExtensionXmlParser $parser
130     * @throws DBALException
131     */
132    public function __construct(
133        ExtensionRepository $repository,
134        Extension $extension,
135        ConnectionPool $connectionPool,
136        ExtensionXmlParser $parser
137    ) {
138        $this->extensionRepository = $repository;
139        $this->extensionModel = $extension;
140        $this->connectionPool = $connectionPool;
141        $this->parser = $parser;
142        $this->parser->attach($this);
143
144        $connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME);
145        $maxBindParameters = PlatformInformation::getMaxBindParameters(
146            $connection->getDatabasePlatform()
147        );
148        $countOfBindParamsPerRow = count(self::$fieldNames);
149        // flush at least chunks of 50 elements - in case the currently used
150        // database platform does not support that, the threshold is lowered
151        $this->maxRowsPerChunk = (int)min(
152            $this->maxRowsPerChunk,
153            floor($maxBindParameters / $countOfBindParamsPerRow)
154        );
155        // Only import extensions that are compatible with 7.6 or higher.
156        // TER only allows to publish extensions with compatibility if the TYPO3 version has been released
157        // And 7.6 was released on 10th of November 2015.
158        // This effectively reduces the number of extensions imported into this TYPO3 installation
159        // by more than 70%. As long as the extensions.xml from TER includes these files, we need to "hack" this
160        // within TYPO3 Core.
161        // For TYPO3 v11.0, this date could be set to 2018-10-02 (v9 LTS release).
162        // Also see https://decisions.typo3.org/t/reduce-size-of-extension-manager-db-table/329/
163        $this->minimumDateToImport = strtotime('2017-04-04T00:00:00+00:00');
164    }
165
166    /**
167     * Method initializes parsing of extension.xml.gz file.
168     *
169     * @param string $localExtensionListFile absolute path to extension list xml.gz
170     * @param string $remoteIdentifier identifier of the remote when inserting records into DB
171     */
172    public function import(string $localExtensionListFile, string $remoteIdentifier): void
173    {
174        // Remove all existing entries of this remote from the database
175        $this->connectionPool
176            ->getConnectionForTable(self::TABLE_NAME)
177            ->delete(
178                self::TABLE_NAME,
179                ['remote' => $remoteIdentifier],
180                [\PDO::PARAM_STR]
181            );
182        $this->remoteIdentifier = $remoteIdentifier;
183        $zlibStream = 'compress.zlib://';
184        $this->sumRecords = 0;
185        $this->parser->parseXml($zlibStream . $localExtensionListFile);
186        // flush last rows to database if existing
187        if (!empty($this->arrRows)) {
188            $this->connectionPool
189                ->getConnectionForTable(self::TABLE_NAME)
190                ->bulkInsert(
191                    'tx_extensionmanager_domain_model_extension',
192                    $this->arrRows,
193                    self::$fieldNames
194                );
195        }
196        $this->markExtensionWithMaximumVersionAsCurrent($remoteIdentifier);
197    }
198
199    /**
200     * Method collects and stores extension version details into the database.
201     *
202     * @param ExtensionXmlParser $subject a subject notifying this observer
203     */
204    protected function loadIntoDatabase(ExtensionXmlParser $subject): void
205    {
206        if ($this->sumRecords !== 0 && $this->sumRecords % $this->maxRowsPerChunk === 0) {
207            $this->connectionPool
208                ->getConnectionForTable(self::TABLE_NAME)
209                ->bulkInsert(
210                    self::TABLE_NAME,
211                    $this->arrRows,
212                    self::$fieldNames
213                );
214            $this->arrRows = [];
215        }
216        if (!$subject->isValidVersionNumber()) {
217            // Skip in case extension version is not valid
218            return;
219        }
220        $versionRepresentations = VersionNumberUtility::convertVersionStringToArray($subject->getVersion());
221        // order must match that of self::$fieldNames!
222        $this->arrRows[] = [
223            $subject->getExtkey(),
224            $subject->getVersion(),
225            $versionRepresentations['version_int'],
226            // initialize current_version, correct value computed later:
227            0,
228            $subject->getAlldownloadcounter(),
229            $subject->getDownloadcounter(),
230            $subject->getTitle() ?? '',
231            $subject->getOwnerusername(),
232            $subject->getAuthorname() ?? '',
233            $subject->getAuthoremail() ?? '',
234            $subject->getAuthorcompany() ?? '',
235            $subject->getLastuploaddate(),
236            $subject->getT3xfilemd5(),
237            $this->remoteIdentifier,
238            $this->extensionModel->getDefaultState($subject->getState() ?: ''),
239            $subject->getReviewstate(),
240            $this->extensionModel->getCategoryIndexFromStringOrNumber($subject->getCategory() ?: ''),
241            $subject->getDescription() ?: '',
242            $subject->getDependencies() ?: '',
243            $subject->getUploadcomment() ?: '',
244            $subject->getDocumentationLink() ?: '',
245            $subject->getDistributionImage() ?: '',
246            $subject->getDistributionWelcomeImage() ?: '',
247        ];
248        ++$this->sumRecords;
249    }
250
251    /**
252     * Method receives an update from a subject.
253     *
254     * @param \SplSubject $subject a subject notifying this observer
255     */
256    public function update(\SplSubject $subject): void
257    {
258        if ($subject instanceof ExtensionXmlParser) {
259            if ((int)$subject->getLastuploaddate() > $this->minimumDateToImport) {
260                $this->loadIntoDatabase($subject);
261            }
262        }
263    }
264
265    /**
266     * Sets current_version = 1 for all extensions where the extension version is maximal.
267     *
268     * For performance reasons, the "native" database connection is used here directly.
269     *
270     * @param string $remoteIdentifier
271     */
272    protected function markExtensionWithMaximumVersionAsCurrent(string $remoteIdentifier): void
273    {
274        $uidsOfCurrentVersion = $this->fetchMaximalVersionsForAllExtensions($remoteIdentifier);
275        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
276        $connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME);
277        $maxBindParameters = PlatformInformation::getMaxBindParameters(
278            $connection->getDatabasePlatform()
279        );
280
281        foreach (array_chunk($uidsOfCurrentVersion, $maxBindParameters - 10) as $chunk) {
282            $queryBuilder
283                ->update(self::TABLE_NAME)
284                ->where(
285                    $queryBuilder->expr()->in(
286                        'uid',
287                        $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
288                    )
289                )
290                ->set('current_version', 1)
291                ->executeStatement();
292        }
293    }
294
295    /**
296     * Fetches the UIDs of all maximal versions for all extensions.
297     * This is done by doing a LEFT JOIN to itself ("a" and "b") and comparing
298     * both integer_version fields.
299     *
300     * @param string $remoteIdentifier
301     * @return array
302     */
303    protected function fetchMaximalVersionsForAllExtensions(string $remoteIdentifier): array
304    {
305        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
306
307        $queryResult = $queryBuilder
308            ->select('a.uid AS uid')
309            ->from(self::TABLE_NAME, 'a')
310            ->leftJoin(
311                'a',
312                self::TABLE_NAME,
313                'b',
314                $queryBuilder->expr()->andX(
315                    $queryBuilder->expr()->eq('a.remote', $queryBuilder->quoteIdentifier('b.remote')),
316                    $queryBuilder->expr()->eq('a.extension_key', $queryBuilder->quoteIdentifier('b.extension_key')),
317                    $queryBuilder->expr()->lt('a.integer_version', $queryBuilder->quoteIdentifier('b.integer_version'))
318                )
319            )
320            ->where(
321                $queryBuilder->expr()->eq(
322                    'a.remote',
323                    $queryBuilder->createNamedParameter($remoteIdentifier)
324                ),
325                $queryBuilder->expr()->isNull('b.extension_key')
326            )
327            ->orderBy('a.uid')
328            ->executeQuery();
329
330        $extensionUids = [];
331        while ($row = $queryResult->fetchAssociative()) {
332            $extensionUids[] = $row['uid'];
333        }
334
335        return $extensionUids;
336    }
337}
338