1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18namespace TYPO3\CMS\Extensionmanager\Parser;
19
20use TYPO3\CMS\Extensionmanager\Exception\ExtensionManagerException;
21
22/**
23 * Parser for TYPO3's extension.xml file.
24 *
25 * Depends on PHP ext/xml which is a required composer php extension
26 * and enabled in PHP by default since a long time.
27 *
28 * @internal This class is a specific ExtensionManager implementation and is not part of the Public TYPO3 API.
29 */
30class ExtensionXmlParser implements \SplSubject
31{
32    /**
33     * Keeps list of attached observers.
34     *
35     * @var \SplObserver[]
36     */
37    protected array $observers = [];
38
39    /**
40     * Keeps current data of element to process.
41     */
42    protected string $elementData = '';
43
44    /**
45     * Parsed property data
46     */
47    protected string $authorcompany = '';
48    protected string $authoremail = '';
49    protected string $authorname = '';
50    protected string $category = '';
51    protected string $dependencies = '';
52    protected string $description = '';
53    protected int $extensionDownloadCounter = 0;
54    protected string $extensionKey = '';
55    protected int $lastuploaddate = 0;
56    protected string $ownerusername = '';
57    protected int $reviewstate = 0;
58    protected string $state = '';
59    protected string $t3xfilemd5 = '';
60    protected string $title = '';
61    protected string $uploadcomment = '';
62    protected string $version = '';
63    protected int $versionDownloadCounter = 0;
64    protected string $documentationLink = '';
65    protected string $distributionImage = '';
66    protected string $distributionWelcomeImage = '';
67
68    public function __construct()
69    {
70        if (!extension_loaded('xml')) {
71            throw new \RuntimeException('PHP extension "xml" not loaded', 1622148496);
72        }
73    }
74
75    /**
76     * Method parses an extensions.xml file.
77     *
78     * @param string $file GZIP stream resource
79     * @throws ExtensionManagerException in case of parse errors
80     */
81    public function parseXml($file): void
82    {
83        if (PHP_MAJOR_VERSION < 8) {
84            // @deprecated will be removed as soon as the minimum version of TYPO3 is 8.0
85            $this->parseWithLegacyResource($file);
86            return;
87        }
88
89        /** @var \XMLParser $parser */
90        $parser = xml_parser_create();
91        xml_set_object($parser, $this);
92
93        // keep original character case of XML document
94        xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
95        xml_parser_set_option($parser, XML_OPTION_SKIP_WHITE, 0);
96        xml_parser_set_option($parser, XML_OPTION_TARGET_ENCODING, 'utf-8');
97        xml_set_element_handler($parser, [$this, 'startElement'], [$this, 'endElement']);
98        xml_set_character_data_handler($parser, [$this, 'characterData']);
99        if (!($fp = fopen($file, 'r'))) {
100            throw $this->createUnableToOpenFileResourceException($file);
101        }
102        while ($data = fread($fp, 4096)) {
103            if (!xml_parse($parser, $data, feof($fp))) {
104                throw $this->createXmlErrorException($parser, $file);
105            }
106        }
107        xml_parser_free($parser);
108    }
109
110    /**
111     * @throws ExtensionManagerException
112     * @internal
113     */
114    private function parseWithLegacyResource(string $file): void
115    {
116        // Store the xml parser resource in when run with PHP <= 7.4
117        // @deprecated will be removed as soon as the minimum version of TYPO3 is 8.0
118        $legacyXmlParserResource = xml_parser_create();
119        xml_set_object($legacyXmlParserResource, $this);
120        if ($legacyXmlParserResource === null) {
121            throw new ExtensionManagerException('Unable to create XML parser.', 1342640663);
122        }
123        /** @var resource $parser */
124        $parser = $legacyXmlParserResource;
125
126        // Disables the functionality to allow external entities to be loaded when parsing the XML, must be kept
127        $previousValueOfEntityLoader = libxml_disable_entity_loader(true);
128
129        // keep original character case of XML document
130        xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
131        xml_parser_set_option($parser, XML_OPTION_SKIP_WHITE, 0);
132        xml_parser_set_option($parser, XML_OPTION_TARGET_ENCODING, 'utf-8');
133        xml_set_element_handler($parser, [$this, 'startElement'], [$this, 'endElement']);
134        xml_set_character_data_handler($parser, [$this, 'characterData']);
135        if (!($fp = fopen($file, 'r'))) {
136            throw $this->createUnableToOpenFileResourceException($file);
137        }
138        while ($data = fread($fp, 4096)) {
139            if (!xml_parse($parser, $data, feof($fp))) {
140                throw $this->createXmlErrorException($parser, $file);
141            }
142        }
143
144        libxml_disable_entity_loader($previousValueOfEntityLoader);
145
146        xml_parser_free($parser);
147    }
148
149    private function createUnableToOpenFileResourceException(string $file): ExtensionManagerException
150    {
151        return new ExtensionManagerException(sprintf('Unable to open file resource %s.', $file), 1342640689);
152    }
153
154    private function createXmlErrorException($parser, string $file): ExtensionManagerException
155    {
156        return new ExtensionManagerException(
157            sprintf(
158                'XML error %s in line %u of file resource %s.',
159                xml_error_string(xml_get_error_code($parser)),
160                xml_get_current_line_number($parser),
161                $file
162            ),
163            1342640703
164        );
165    }
166
167    /**
168     * Method is invoked when parser accesses start tag of an element.
169     *
170     * @param resource $parser parser resource
171     * @param string $elementName element name at parser's current position
172     * @param array $attrs array of an element's attributes if available
173     */
174    protected function startElement($parser, $elementName, $attrs)
175    {
176        switch ($elementName) {
177            case 'extension':
178                $this->extensionKey = $attrs['extensionkey'];
179                break;
180            case 'version':
181                $this->version = $attrs['version'];
182                break;
183            default:
184                $this->elementData = '';
185        }
186    }
187
188    /**
189     * Method is invoked when parser accesses end tag of an element.
190     *
191     * @param resource $parser parser resource
192     * @param string $elementName Element name at parser's current position
193     */
194    protected function endElement($parser, $elementName)
195    {
196        switch ($elementName) {
197            case 'extension':
198                $this->resetProperties(true);
199                break;
200            case 'version':
201                $this->notify();
202                $this->resetProperties();
203                break;
204            case 'downloadcounter':
205                // downloadcounter can be a child node of extension or version
206                if ($this->version === '') {
207                    $this->extensionDownloadCounter = (int)$this->elementData;
208                } else {
209                    $this->versionDownloadCounter = (int)$this->elementData;
210                }
211                break;
212            case 'title':
213                $this->title = $this->elementData;
214                break;
215            case 'description':
216                $this->description = $this->elementData;
217                break;
218            case 'state':
219                $this->state = $this->elementData;
220                break;
221            case 'reviewstate':
222                $this->reviewstate = (int)$this->elementData;
223                break;
224            case 'category':
225                $this->category = $this->elementData;
226                break;
227            case 'lastuploaddate':
228                $this->lastuploaddate = (int)$this->elementData;
229                break;
230            case 'uploadcomment':
231                $this->uploadcomment = $this->elementData;
232                break;
233            case 'dependencies':
234                $newDependencies = [];
235                $dependenciesArray = unserialize($this->elementData, ['allowed_classes' => false]);
236                if (is_array($dependenciesArray)) {
237                    foreach ($dependenciesArray as $version) {
238                        if (!empty($version['kind']) && !empty($version['extensionKey'])) {
239                            $newDependencies[$version['kind']][$version['extensionKey']] = $version['versionRange'];
240                        }
241                    }
242                }
243                $this->dependencies = serialize($newDependencies);
244                break;
245            case 'authorname':
246                $this->authorname = $this->elementData;
247                break;
248            case 'authoremail':
249                $this->authoremail = $this->elementData;
250                break;
251            case 'authorcompany':
252                $this->authorcompany = $this->elementData;
253                break;
254            case 'ownerusername':
255                $this->ownerusername = $this->elementData;
256                break;
257            case 't3xfilemd5':
258                $this->t3xfilemd5 = $this->elementData;
259                break;
260            case 'documentation_link':
261                $this->documentationLink = $this->elementData;
262                break;
263            case 'distributionImage':
264                if (preg_match('/^https:\/\/extensions\.typo3\.org[a-zA-Z0-9._\/]+Distribution\.png$/', $this->elementData)) {
265                    $this->distributionImage = $this->elementData;
266                }
267                break;
268            case 'distributionImageWelcome':
269                if (preg_match('/^https:\/\/extensions\.typo3\.org[a-zA-Z0-9._\/]+DistributionWelcome\.png$/', $this->elementData)) {
270                    $this->distributionWelcomeImage = $this->elementData;
271                }
272                break;
273        }
274    }
275
276    /**
277     * Method resets version class properties.
278     *
279     * @param bool $resetAll If TRUE, additionally extension properties are reset
280     */
281    protected function resetProperties($resetAll = false): void
282    {
283        // Resetting at least class property "version" is mandatory as we need to do some magic in
284        // regards to an extension's and version's child node "downloadcounter"
285        $this->version = $this->authorcompany = $this->authorname = $this->authoremail = $this->category = $this->dependencies = $this->state = '';
286        $this->description = $this->ownerusername = $this->t3xfilemd5 = $this->title = $this->uploadcomment = $this->documentationLink = $this->distributionImage = $this->distributionWelcomeImage = '';
287        $this->lastuploaddate = $this->reviewstate = $this->versionDownloadCounter = 0;
288        if ($resetAll) {
289            $this->extensionKey = '';
290            $this->extensionDownloadCounter = 0;
291        }
292    }
293
294    /**
295     * Method is invoked when parser accesses any character other than elements.
296     *
297     * @param resource|\XmlParser $parser XmlParser with PHP >= 8
298     * @param string $data An element's value
299     */
300    protected function characterData($parser, string $data)
301    {
302        $this->elementData .= $data;
303    }
304
305    /**
306     * Method attaches an observer.
307     *
308     * @param \SplObserver $observer an observer to attach
309     * @see detach()
310     * @see notify()
311     */
312    public function attach(\SplObserver $observer): void
313    {
314        $this->observers[] = $observer;
315    }
316
317    /**
318     * Method detaches an attached observer
319     *
320     * @param \SplObserver $observer an observer to detach
321     */
322    public function detach(\SplObserver $observer): void
323    {
324        $key = array_search($observer, $this->observers, true);
325        if ($key !== false) {
326            unset($this->observers[$key]);
327        }
328    }
329
330    /**
331     * Method notifies attached observers.
332     */
333    public function notify(): void
334    {
335        foreach ($this->observers as $observer) {
336            $observer->update($this);
337        }
338    }
339
340    /**
341     * Returns download number sum of all extension's versions.
342     */
343    public function getAlldownloadcounter(): int
344    {
345        return $this->extensionDownloadCounter;
346    }
347
348    /**
349     * Returns company name of extension author.
350     */
351    public function getAuthorcompany(): string
352    {
353        return $this->authorcompany;
354    }
355
356    /**
357     * Returns e-mail address of extension author.
358     */
359    public function getAuthoremail(): string
360    {
361        return $this->authoremail;
362    }
363
364    /**
365     * Returns name of extension author.
366     */
367    public function getAuthorname(): string
368    {
369        return $this->authorname;
370    }
371
372    /**
373     * Returns category of an extension.
374     */
375    public function getCategory(): string
376    {
377        return $this->category;
378    }
379
380    /**
381     * Returns dependencies of an extension's version as a serialized string
382     */
383    public function getDependencies(): string
384    {
385        return $this->dependencies;
386    }
387
388    /**
389     * Returns description of an extension's version.
390     */
391    public function getDescription(): string
392    {
393        return $this->description;
394    }
395
396    /**
397     * Returns download number of an extension's version.
398     */
399    public function getDownloadcounter(): int
400    {
401        return $this->versionDownloadCounter;
402    }
403
404    /**
405     * Returns key of an extension.
406     */
407    public function getExtkey(): string
408    {
409        return $this->extensionKey;
410    }
411
412    /**
413     * Returns last uploaddate of an extension's version.
414     */
415    public function getLastuploaddate(): int
416    {
417        return $this->lastuploaddate;
418    }
419
420    /**
421     * Returns username of extension owner.
422     */
423    public function getOwnerusername(): string
424    {
425        return $this->ownerusername;
426    }
427
428    /**
429     * Returns review state of an extension's version.
430     */
431    public function getReviewstate(): int
432    {
433        return $this->reviewstate;
434    }
435
436    /**
437     * Returns state of an extension's version.
438     */
439    public function getState(): string
440    {
441        return $this->state;
442    }
443
444    /**
445     * Returns t3x file hash of an extension's version.
446     */
447    public function getT3xfilemd5(): string
448    {
449        return $this->t3xfilemd5;
450    }
451
452    /**
453     * Returns title of an extension's version.
454     */
455    public function getTitle(): string
456    {
457        return $this->title;
458    }
459
460    /**
461     * Returns extension upload comment.
462     */
463    public function getUploadcomment(): string
464    {
465        return $this->uploadcomment;
466    }
467
468    /**
469     * Returns version number as unparsed string.
470     */
471    public function getVersion(): string
472    {
473        return $this->version;
474    }
475
476    /**
477     * Whether the current version number is valid
478     */
479    public function isValidVersionNumber(): bool
480    {
481        // Validate the version number, see `isValidVersionNumber` in TER API
482        return (bool)preg_match('/^(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})$/', $this->version);
483    }
484
485    /**
486     * Returns documentation link.
487     */
488    public function getDocumentationLink(): string
489    {
490        return $this->documentationLink;
491    }
492
493    /**
494     * Returns distribution image url.
495     */
496    public function getDistributionImage(): string
497    {
498        return $this->distributionImage;
499    }
500
501    /**
502     * Returns distribution welcome image url.
503     */
504    public function getDistributionWelcomeImage(): string
505    {
506        return $this->distributionWelcomeImage;
507    }
508}
509