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\Utility;
17
18use TYPO3\CMS\Core\SingletonInterface;
19use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
20use TYPO3\CMS\Core\Utility\GeneralUtility;
21use TYPO3\CMS\Core\Utility\VersionNumberUtility;
22use TYPO3\CMS\Extensionmanager\Domain\Model\Dependency;
23use TYPO3\CMS\Extensionmanager\Domain\Model\Extension;
24use TYPO3\CMS\Extensionmanager\Domain\Repository\ExtensionRepository;
25use TYPO3\CMS\Extensionmanager\Exception;
26use TYPO3\CMS\Extensionmanager\Exception\MissingExtensionDependencyException;
27use TYPO3\CMS\Extensionmanager\Exception\MissingVersionDependencyException;
28use TYPO3\CMS\Extensionmanager\Exception\UnresolvedDependencyException;
29use TYPO3\CMS\Extensionmanager\Exception\UnresolvedPhpDependencyException;
30use TYPO3\CMS\Extensionmanager\Exception\UnresolvedTypo3DependencyException;
31use TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService;
32
33/**
34 * Utility for dealing with dependencies
35 * @internal This class is a specific ExtensionManager implementation and is not part of the Public TYPO3 API.
36 */
37class DependencyUtility implements SingletonInterface
38{
39    /**
40     * @var ExtensionRepository
41     */
42    protected $extensionRepository;
43
44    /**
45     * @var ListUtility
46     */
47    protected $listUtility;
48
49    /**
50     * @var EmConfUtility
51     */
52    protected $emConfUtility;
53
54    /**
55     * @var ExtensionManagementService
56     */
57    protected $managementService;
58
59    /**
60     * @var array
61     */
62    protected $availableExtensions = [];
63
64    /**
65     * @var string
66     */
67    protected $localExtensionStorage = '';
68
69    /**
70     * @var array
71     */
72    protected $dependencyErrors = [];
73
74    /**
75     * @var bool
76     */
77    protected $skipDependencyCheck = false;
78
79    /**
80     * @param ExtensionRepository $extensionRepository
81     */
82    public function injectExtensionRepository(ExtensionRepository $extensionRepository)
83    {
84        $this->extensionRepository = $extensionRepository;
85    }
86
87    /**
88     * @param ListUtility $listUtility
89     */
90    public function injectListUtility(ListUtility $listUtility)
91    {
92        $this->listUtility = $listUtility;
93    }
94
95    /**
96     * @param EmConfUtility $emConfUtility
97     */
98    public function injectEmConfUtility(EmConfUtility $emConfUtility)
99    {
100        $this->emConfUtility = $emConfUtility;
101    }
102
103    /**
104     * @param ExtensionManagementService $managementService
105     */
106    public function injectManagementService(ExtensionManagementService $managementService)
107    {
108        $this->managementService = $managementService;
109    }
110
111    /**
112     * @param string $localExtensionStorage
113     */
114    public function setLocalExtensionStorage($localExtensionStorage)
115    {
116        $this->localExtensionStorage = $localExtensionStorage;
117    }
118
119    /**
120     * Setter for available extensions
121     * gets available extensions from list utility if not already done
122     */
123    protected function setAvailableExtensions()
124    {
125        $this->availableExtensions = $this->listUtility->getAvailableExtensions();
126    }
127
128    /**
129     * @param bool $skipDependencyCheck
130     */
131    public function setSkipDependencyCheck($skipDependencyCheck)
132    {
133        $this->skipDependencyCheck = $skipDependencyCheck;
134    }
135
136    /**
137     * Checks dependencies for special cases (currently typo3 and php)
138     *
139     * @param Extension $extension
140     */
141    public function checkDependencies(Extension $extension)
142    {
143        $this->dependencyErrors = [];
144        $dependencies = $extension->getDependencies();
145        foreach ($dependencies as $dependency) {
146            /** @var Dependency $dependency */
147            $identifier = strtolower($dependency->getIdentifier());
148            try {
149                if (in_array($identifier, Dependency::$specialDependencies)) {
150                    if (!$this->skipDependencyCheck) {
151                        $methodName = 'check' . ucfirst($identifier) . 'Dependency';
152                        $this->{$methodName}($dependency);
153                    }
154                } else {
155                    if ($dependency->getType() === 'depends') {
156                        $this->checkExtensionDependency($dependency);
157                    }
158                }
159            } catch (UnresolvedDependencyException $e) {
160                if (in_array($identifier, Dependency::$specialDependencies)) {
161                    $extensionKey = $extension->getExtensionKey();
162                } else {
163                    $extensionKey = $identifier;
164                }
165                if (!isset($this->dependencyErrors[$extensionKey])) {
166                    $this->dependencyErrors[$extensionKey] = [];
167                }
168                $this->dependencyErrors[$extensionKey][] = [
169                    'code' => $e->getCode(),
170                    'message' => $e->getMessage()
171                ];
172            }
173        }
174    }
175
176    /**
177     * Returns TRUE if a dependency error was found
178     *
179     * @return bool
180     */
181    public function hasDependencyErrors()
182    {
183        return !empty($this->dependencyErrors);
184    }
185
186    /**
187     * Return the dependency errors
188     *
189     * @return array
190     */
191    public function getDependencyErrors()
192    {
193        return $this->dependencyErrors;
194    }
195
196    /**
197     * Returns true if current TYPO3 version fulfills extension requirements
198     *
199     * @param Dependency $dependency
200     * @throws Exception\UnresolvedTypo3DependencyException
201     * @return bool
202     */
203    protected function checkTypo3Dependency(Dependency $dependency)
204    {
205        $lowerCaseIdentifier = strtolower($dependency->getIdentifier());
206        if ($lowerCaseIdentifier === 'typo3') {
207            if (!($dependency->getLowestVersion() === '') && version_compare(VersionNumberUtility::getNumericTypo3Version(), $dependency->getLowestVersion()) === -1) {
208                throw new UnresolvedTypo3DependencyException(
209                    'Your TYPO3 version is lower than this extension requires. It requires TYPO3 versions ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
210                    1399144499
211                );
212            }
213            if (!($dependency->getHighestVersion() === '') && version_compare($dependency->getHighestVersion(), VersionNumberUtility::getNumericTypo3Version()) === -1) {
214                throw new UnresolvedTypo3DependencyException(
215                    'Your TYPO3 version is higher than this extension requires. It requires TYPO3 versions ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
216                    1399144521
217                );
218            }
219        } else {
220            throw new UnresolvedTypo3DependencyException(
221                'checkTypo3Dependency can only check TYPO3 dependencies. Found dependency with identifier "' . $dependency->getIdentifier() . '"',
222                1399144551
223            );
224        }
225        return true;
226    }
227
228    /**
229     * Returns true if current php version fulfills extension requirements
230     *
231     * @param Dependency $dependency
232     * @throws Exception\UnresolvedPhpDependencyException
233     * @return bool
234     */
235    protected function checkPhpDependency(Dependency $dependency)
236    {
237        $lowerCaseIdentifier = strtolower($dependency->getIdentifier());
238        if ($lowerCaseIdentifier === 'php') {
239            if (!($dependency->getLowestVersion() === '') && version_compare(PHP_VERSION, $dependency->getLowestVersion()) === -1) {
240                throw new UnresolvedPhpDependencyException(
241                    'Your PHP version is lower than necessary. You need at least PHP version ' . $dependency->getLowestVersion(),
242                    1377977857
243                );
244            }
245            if (!($dependency->getHighestVersion() === '') && version_compare($dependency->getHighestVersion(), PHP_VERSION) === -1) {
246                throw new UnresolvedPhpDependencyException(
247                    'Your PHP version is higher than allowed. You can use PHP versions ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
248                    1377977856
249                );
250            }
251        } else {
252            throw new UnresolvedPhpDependencyException(
253                'checkPhpDependency can only check PHP dependencies. Found dependency with identifier "' . $dependency->getIdentifier() . '"',
254                1377977858
255            );
256        }
257        return true;
258    }
259
260    /**
261     * Main controlling function for checking dependencies
262     * Dependency check is done in the following way:
263     * - installed extension in matching version ? - return true
264     * - available extension in matching version ? - mark for installation
265     * - remote (TER) extension in matching version? - mark for download
266     *
267     * @todo handle exceptions / markForUpload
268     * @param Dependency $dependency
269     * @throws Exception\MissingVersionDependencyException
270     * @return bool
271     */
272    protected function checkExtensionDependency(Dependency $dependency)
273    {
274        $extensionKey = $dependency->getIdentifier();
275        $extensionIsLoaded = $this->isDependentExtensionLoaded($extensionKey);
276        if ($extensionIsLoaded === true) {
277            $isLoadedVersionCompatible = $this->isLoadedVersionCompatible($dependency);
278            if ($isLoadedVersionCompatible === true || $this->skipDependencyCheck) {
279                return true;
280            }
281            $extension = $this->listUtility->getExtension($extensionKey);
282            $loadedVersion = $extension->getPackageMetaData()->getVersion();
283            if (version_compare($loadedVersion, $dependency->getHighestVersion()) === -1) {
284                try {
285                    $this->getExtensionFromRepository($extensionKey, $dependency);
286                } catch (UnresolvedDependencyException $e) {
287                    throw new MissingVersionDependencyException(
288                        'The extension ' . $extensionKey . ' is installed in version ' . $loadedVersion
289                            . ' but needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion() . ' and could not be fetched from TER',
290                        1396302624
291                    );
292                }
293            } else {
294                throw new MissingVersionDependencyException(
295                    'The extension ' . $extensionKey . ' is installed in version ' . $loadedVersion .
296                    ' but needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
297                    1430561927
298                );
299            }
300        } else {
301            $extensionIsAvailable = $this->isDependentExtensionAvailable($extensionKey);
302            if ($extensionIsAvailable === true) {
303                $isAvailableVersionCompatible = $this->isAvailableVersionCompatible($dependency);
304                if ($isAvailableVersionCompatible) {
305                    $unresolvedDependencyErrors = $this->dependencyErrors;
306                    $this->managementService->markExtensionForInstallation($extensionKey);
307                    $this->dependencyErrors = array_merge($unresolvedDependencyErrors, $this->dependencyErrors);
308                } else {
309                    $extension = $this->listUtility->getExtension($extensionKey);
310                    $availableVersion = $extension->getPackageMetaData()->getVersion();
311                    if (version_compare($availableVersion, $dependency->getHighestVersion()) === -1) {
312                        try {
313                            $this->getExtensionFromRepository($extensionKey, $dependency);
314                        } catch (MissingExtensionDependencyException $e) {
315                            if (!$this->skipDependencyCheck) {
316                                throw new MissingVersionDependencyException(
317                                    'The extension ' . $extensionKey . ' is available in version ' . $availableVersion
318                                    . ' but is needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion() . ' and could not be fetched from TER',
319                                    1430560390
320                                );
321                            }
322                        }
323                    } else {
324                        if (!$this->skipDependencyCheck) {
325                            throw new MissingVersionDependencyException(
326                                'The extension ' . $extensionKey . ' is available in version ' . $availableVersion
327                                . ' but is needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
328                                1430562374
329                            );
330                        }
331                        // Dependency check is skipped and the local version has to be installed
332                        $this->managementService->markExtensionForInstallation($extensionKey);
333                    }
334                }
335            } else {
336                $unresolvedDependencyErrors = $this->dependencyErrors;
337                $this->getExtensionFromRepository($extensionKey, $dependency);
338                $this->dependencyErrors = array_merge($unresolvedDependencyErrors, $this->dependencyErrors);
339            }
340        }
341
342        return false;
343    }
344
345    /**
346     * Get an extension from a repository
347     * (might be in the extension itself or the TER)
348     *
349     * @param string $extensionKey
350     * @param Dependency $dependency
351     * @throws Exception\UnresolvedDependencyException
352     */
353    protected function getExtensionFromRepository($extensionKey, Dependency $dependency)
354    {
355        if (!$this->getExtensionFromInExtensionRepository($extensionKey)) {
356            $this->getExtensionFromTer($extensionKey, $dependency);
357        }
358    }
359
360    /**
361     * Gets an extension from the in extension repository
362     * (the local extension storage)
363     *
364     * @param string $extensionKey
365     * @return bool
366     */
367    protected function getExtensionFromInExtensionRepository($extensionKey)
368    {
369        if ($this->localExtensionStorage !== '' && is_dir($this->localExtensionStorage)) {
370            $extList = GeneralUtility::get_dirs($this->localExtensionStorage);
371            $extList = is_array($extList) ? $extList : [];
372            if (in_array($extensionKey, $extList)) {
373                $this->managementService->markExtensionForCopy($extensionKey, $this->localExtensionStorage);
374                return true;
375            }
376        }
377        return false;
378    }
379
380    /**
381     * Handles checks to find a compatible extension version from TER to fulfill given dependency
382     *
383     * @todo unit tests
384     * @param string $extensionKey
385     * @param Dependency $dependency
386     * @throws Exception\UnresolvedDependencyException
387     */
388    protected function getExtensionFromTer($extensionKey, Dependency $dependency)
389    {
390        $isExtensionDownloadableFromTer = $this->isExtensionDownloadableFromTer($extensionKey);
391        if (!$isExtensionDownloadableFromTer) {
392            if (!$this->skipDependencyCheck) {
393                if ($this->extensionRepository->countAll() > 0) {
394                    throw new MissingExtensionDependencyException(
395                        'The extension ' . $extensionKey . ' is not available from TER.',
396                        1399161266
397                    );
398                }
399                throw new MissingExtensionDependencyException(
400                    'The extension ' . $extensionKey . ' could not be checked. Please update your Extension-List from TYPO3 Extension Repository (TER).',
401                    1430580308
402                );
403            }
404            return;
405        }
406
407        $isDownloadableVersionCompatible = $this->isDownloadableVersionCompatible($dependency);
408        if (!$isDownloadableVersionCompatible) {
409            if (!$this->skipDependencyCheck) {
410                throw new MissingVersionDependencyException(
411                    'No compatible version found for extension ' . $extensionKey,
412                    1399161284
413                );
414            }
415            return;
416        }
417
418        $latestCompatibleExtensionByIntegerVersionDependency = $this->getLatestCompatibleExtensionByIntegerVersionDependency($dependency);
419        if (!$latestCompatibleExtensionByIntegerVersionDependency instanceof Extension) {
420            if (!$this->skipDependencyCheck) {
421                throw new MissingExtensionDependencyException(
422                    'Could not resolve dependency for "' . $dependency->getIdentifier() . '"',
423                    1399161302
424                );
425            }
426            return;
427        }
428
429        if ($this->isDependentExtensionLoaded($extensionKey)) {
430            $this->managementService->markExtensionForUpdate($latestCompatibleExtensionByIntegerVersionDependency);
431        } else {
432            $this->managementService->markExtensionForDownload($latestCompatibleExtensionByIntegerVersionDependency);
433        }
434    }
435
436    /**
437     * @param string $extensionKey
438     * @return bool
439     */
440    protected function isDependentExtensionLoaded($extensionKey)
441    {
442        return ExtensionManagementUtility::isLoaded($extensionKey);
443    }
444
445    /**
446     * @param Dependency $dependency
447     * @return bool
448     */
449    protected function isLoadedVersionCompatible(Dependency $dependency)
450    {
451        $extensionVersion = ExtensionManagementUtility::getExtensionVersion($dependency->getIdentifier());
452        return $this->isVersionCompatible($extensionVersion, $dependency);
453    }
454
455    /**
456     * @param string $version
457     * @param Dependency $dependency
458     * @return bool
459     */
460    protected function isVersionCompatible($version, Dependency $dependency)
461    {
462        if (!($dependency->getLowestVersion() === '') && version_compare($version, $dependency->getLowestVersion()) === -1) {
463            return false;
464        }
465        if (!($dependency->getHighestVersion() === '') && version_compare($dependency->getHighestVersion(), $version) === -1) {
466            return false;
467        }
468        return true;
469    }
470
471    /**
472     * Checks whether the needed extension is available
473     * (not necessarily installed, but present in system)
474     *
475     * @param string $extensionKey
476     * @return bool
477     */
478    protected function isDependentExtensionAvailable($extensionKey)
479    {
480        $this->setAvailableExtensions();
481        return array_key_exists($extensionKey, $this->availableExtensions);
482    }
483
484    /**
485     * Checks whether the available version is compatible
486     *
487     * @param Dependency $dependency
488     * @return bool
489     */
490    protected function isAvailableVersionCompatible(Dependency $dependency)
491    {
492        $this->setAvailableExtensions();
493        $extensionData = $this->emConfUtility->includeEmConf(
494            $dependency->getIdentifier(),
495            $this->availableExtensions[$dependency->getIdentifier()]
496        );
497        return $this->isVersionCompatible($extensionData['version'], $dependency);
498    }
499
500    /**
501     * Checks whether a ter extension with $extensionKey exists
502     *
503     * @param string $extensionKey
504     * @return bool
505     */
506    protected function isExtensionDownloadableFromTer($extensionKey)
507    {
508        return $this->extensionRepository->countByExtensionKey($extensionKey) > 0;
509    }
510
511    /**
512     * Checks whether a compatible version of the extension exists in TER
513     *
514     * @param Dependency $dependency
515     * @return bool
516     */
517    protected function isDownloadableVersionCompatible(Dependency $dependency)
518    {
519        $versions = $this->getLowestAndHighestIntegerVersions($dependency);
520        $count = $this->extensionRepository->countByVersionRangeAndExtensionKey(
521            $dependency->getIdentifier(),
522            $versions['lowestIntegerVersion'],
523            $versions['highestIntegerVersion']
524        );
525        return !empty($count);
526    }
527
528    /**
529     * Get the latest compatible version of an extension that's
530     * compatible with the current core and PHP version.
531     *
532     * @param iterable $extensions
533     * @return Extension|null
534     */
535    protected function getCompatibleExtension(iterable $extensions): ?Extension
536    {
537        foreach ($extensions as $extension) {
538            /** @var Extension $extension */
539            $this->checkDependencies($extension);
540            $extensionKey = $extension->getExtensionKey();
541
542            if (isset($this->dependencyErrors[$extensionKey])) {
543                // reset dependencyErrors and continue with next version
544                unset($this->dependencyErrors[$extensionKey]);
545                continue;
546            }
547
548            return $extension;
549        }
550
551        return null;
552    }
553
554    /**
555     * Get the latest compatible version of an extension that
556     * fulfills the given dependency from TER
557     *
558     * @param Dependency $dependency
559     * @return Extension
560     */
561    protected function getLatestCompatibleExtensionByIntegerVersionDependency(Dependency $dependency)
562    {
563        $versions = $this->getLowestAndHighestIntegerVersions($dependency);
564        $compatibleDataSets = $this->extensionRepository->findByVersionRangeAndExtensionKeyOrderedByVersion(
565            $dependency->getIdentifier(),
566            $versions['lowestIntegerVersion'],
567            $versions['highestIntegerVersion']
568        );
569        return $this->getCompatibleExtension($compatibleDataSets);
570    }
571
572    /**
573     * Return array of lowest and highest version of dependency as integer
574     *
575     * @param Dependency $dependency
576     * @return array
577     */
578    protected function getLowestAndHighestIntegerVersions(Dependency $dependency)
579    {
580        $lowestVersion = $dependency->getLowestVersion();
581        $lowestVersionInteger = $lowestVersion ? VersionNumberUtility::convertVersionNumberToInteger($lowestVersion) : 0;
582        $highestVersion = $dependency->getHighestVersion();
583        $highestVersionInteger = $highestVersion ? VersionNumberUtility::convertVersionNumberToInteger($highestVersion) : 0;
584        return [
585            'lowestIntegerVersion' => $lowestVersionInteger,
586            'highestIntegerVersion' => $highestVersionInteger
587        ];
588    }
589
590    /**
591     * @param string $extensionKey
592     * @return array
593     */
594    public function findInstalledExtensionsThatDependOnMe($extensionKey)
595    {
596        $availableAndInstalledExtensions = $this->listUtility->getAvailableAndInstalledExtensionsWithAdditionalInformation();
597        $dependentExtensions = [];
598        foreach ($availableAndInstalledExtensions as $availableAndInstalledExtensionKey => $availableAndInstalledExtension) {
599            if (isset($availableAndInstalledExtension['installed']) && $availableAndInstalledExtension['installed'] === true) {
600                if (is_array($availableAndInstalledExtension['constraints']) && is_array($availableAndInstalledExtension['constraints']['depends']) && array_key_exists($extensionKey, $availableAndInstalledExtension['constraints']['depends'])) {
601                    $dependentExtensions[] = $availableAndInstalledExtensionKey;
602                }
603            }
604        }
605        return $dependentExtensions;
606    }
607
608    /**
609     * Get extensions (out of a given list) that are suitable for the current TYPO3 version
610     *
611     * @param \TYPO3\CMS\Extbase\Persistence\QueryResultInterface|array $extensions List of extensions to check
612     * @return array List of extensions suitable for current TYPO3 version
613     */
614    public function getExtensionsSuitableForTypo3Version($extensions)
615    {
616        $suitableExtensions = [];
617        /** @var Extension $extension */
618        foreach ($extensions as $extension) {
619            /** @var Dependency $dependency */
620            foreach ($extension->getDependencies() as $dependency) {
621                if ($dependency->getIdentifier() === 'typo3') {
622                    try {
623                        if ($this->checkTypo3Dependency($dependency)) {
624                            $suitableExtensions[] = $extension;
625                        }
626                    } catch (UnresolvedTypo3DependencyException $e) {
627                    }
628                    break;
629                }
630            }
631        }
632        return $suitableExtensions;
633    }
634
635    /**
636     * Gets a list of various extensions in various versions and returns
637     * a filtered list containing the extension-version combination with
638     * the highest version number.
639     *
640     * @param Extension[] $extensions
641     * @param bool $showUnsuitable
642     *
643     * @return Extension[]
644     */
645    public function filterYoungestVersionOfExtensionList(array $extensions, $showUnsuitable)
646    {
647        if (!$showUnsuitable) {
648            $extensions = $this->getExtensionsSuitableForTypo3Version($extensions);
649        }
650        $filteredExtensions = [];
651        foreach ($extensions as $extension) {
652            $extensionKey = $extension->getExtensionKey();
653            if (!array_key_exists($extensionKey, $filteredExtensions)) {
654                $filteredExtensions[$extensionKey] = $extension;
655                continue;
656            }
657            $currentVersion = $filteredExtensions[$extensionKey]->getVersion();
658            $newVersion = $extension->getVersion();
659            if (version_compare($newVersion, $currentVersion, '>')) {
660                $filteredExtensions[$extensionKey] = $extension;
661            }
662        }
663        return $filteredExtensions;
664    }
665}
666