[!!!][TASK] Remove deprecated class Package\DependencyResolver
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Package / PackageManager.php
1 <?php
2 namespace TYPO3\CMS\Core\Package;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use Symfony\Component\Finder\Finder;
18 use Symfony\Component\Finder\SplFileInfo;
19 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
20 use TYPO3\CMS\Core\Core\ClassLoadingInformation;
21 use TYPO3\CMS\Core\Core\Environment;
22 use TYPO3\CMS\Core\Service\DependencyOrderingService;
23 use TYPO3\CMS\Core\Service\OpcodeCacheService;
24 use TYPO3\CMS\Core\SingletonInterface;
25 use TYPO3\CMS\Core\Utility\ArrayUtility;
26 use TYPO3\CMS\Core\Utility\GeneralUtility;
27 use TYPO3\CMS\Core\Utility\PathUtility;
28
29 /**
30 * The default TYPO3 Package Manager
31 */
32 class PackageManager implements SingletonInterface
33 {
34 /**
35 * @var DependencyOrderingService
36 */
37 protected $dependencyOrderingService;
38
39 /**
40 * @var FrontendInterface
41 */
42 protected $coreCache;
43
44 /**
45 * @var string
46 */
47 protected $cacheIdentifier;
48
49 /**
50 * @var array
51 */
52 protected $packagesBasePaths = [];
53
54 /**
55 * @var array
56 */
57 protected $packageAliasMap = [];
58
59 /**
60 * @var array
61 */
62 protected $runtimeActivatedPackages = [];
63
64 /**
65 * Absolute path leading to the various package directories
66 * @var string
67 */
68 protected $packagesBasePath;
69
70 /**
71 * Array of available packages, indexed by package key
72 * @var PackageInterface[]
73 */
74 protected $packages = [];
75
76 /**
77 * @var bool
78 */
79 protected $availablePackagesScanned = false;
80
81 /**
82 * A map between ComposerName and PackageKey, only available when scanAvailablePackages is run
83 * @var array
84 */
85 protected $composerNameToPackageKeyMap = [];
86
87 /**
88 * List of active packages as package key => package object
89 * @var array
90 */
91 protected $activePackages = [];
92
93 /**
94 * @var string
95 */
96 protected $packageStatesPathAndFilename;
97
98 /**
99 * Package states configuration as stored in the PackageStates.php file
100 * @var array
101 */
102 protected $packageStatesConfiguration = [];
103
104 /**
105 * @param DependencyOrderingService $dependencyOrderingService
106 */
107 public function __construct(DependencyOrderingService $dependencyOrderingService)
108 {
109 $this->packagesBasePath = Environment::getPublicPath() . '/';
110 $this->packageStatesPathAndFilename = Environment::getLegacyConfigPath() . '/PackageStates.php';
111 $this->dependencyOrderingService = $dependencyOrderingService;
112 }
113
114 /**
115 * @param FrontendInterface $coreCache
116 * @internal
117 */
118 public function injectCoreCache(FrontendInterface $coreCache)
119 {
120 $this->coreCache = $coreCache;
121 }
122
123 /**
124 * Initializes the package manager
125 * @internal
126 */
127 public function initialize()
128 {
129 try {
130 $this->loadPackageManagerStatesFromCache();
131 } catch (Exception\PackageManagerCacheUnavailableException $exception) {
132 $this->loadPackageStates();
133 $this->initializePackageObjects();
134 $this->saveToPackageCache();
135 }
136 }
137
138 /**
139 * @return string
140 */
141 protected function getCacheIdentifier()
142 {
143 if ($this->cacheIdentifier === null) {
144 $mTime = @filemtime($this->packageStatesPathAndFilename);
145 if ($mTime !== false) {
146 $this->cacheIdentifier = md5(TYPO3_version . $this->packageStatesPathAndFilename . $mTime);
147 } else {
148 $this->cacheIdentifier = null;
149 }
150 }
151 return $this->cacheIdentifier;
152 }
153
154 /**
155 * @return string
156 */
157 protected function getCacheEntryIdentifier()
158 {
159 $cacheIdentifier = $this->getCacheIdentifier();
160 return $cacheIdentifier !== null ? 'PackageManager_' . $cacheIdentifier : null;
161 }
162
163 /**
164 * Saves the current state of all relevant information to the TYPO3 Core Cache
165 */
166 protected function saveToPackageCache()
167 {
168 $cacheEntryIdentifier = $this->getCacheEntryIdentifier();
169 if ($cacheEntryIdentifier !== null && !$this->coreCache->has($cacheEntryIdentifier)) {
170 // Build cache file
171 $packageCache = [
172 'packageStatesConfiguration' => $this->packageStatesConfiguration,
173 'packageAliasMap' => $this->packageAliasMap,
174 'composerNameToPackageKeyMap' => $this->composerNameToPackageKeyMap,
175 'packageObjects' => serialize($this->packages),
176 ];
177 $this->coreCache->set(
178 $cacheEntryIdentifier,
179 'return ' . PHP_EOL . var_export($packageCache, true) . ';'
180 );
181 }
182 }
183
184 /**
185 * Attempts to load the package manager states from cache
186 *
187 * @throws Exception\PackageManagerCacheUnavailableException
188 */
189 protected function loadPackageManagerStatesFromCache()
190 {
191 $cacheEntryIdentifier = $this->getCacheEntryIdentifier();
192 if ($cacheEntryIdentifier === null || !$this->coreCache->has($cacheEntryIdentifier) || !($packageCache = $this->coreCache->require($cacheEntryIdentifier))) {
193 throw new Exception\PackageManagerCacheUnavailableException('The package state cache could not be loaded.', 1393883342);
194 }
195 $this->packageStatesConfiguration = $packageCache['packageStatesConfiguration'];
196 if ($this->packageStatesConfiguration['version'] < 5) {
197 throw new Exception\PackageManagerCacheUnavailableException('The package state cache could not be loaded.', 1393883341);
198 }
199 $this->packageAliasMap = $packageCache['packageAliasMap'];
200 $this->composerNameToPackageKeyMap = $packageCache['composerNameToPackageKeyMap'];
201 $this->packages = unserialize($packageCache['packageObjects'], [
202 'allowed_classes' => [
203 Package::class,
204 MetaData::class,
205 MetaData\PackageConstraint::class,
206 \stdClass::class,
207 ]
208 ]);
209 }
210
211 /**
212 * Loads the states of available packages from the PackageStates.php file.
213 * The result is stored in $this->packageStatesConfiguration.
214 *
215 * @throws Exception\PackageStatesUnavailableException
216 */
217 protected function loadPackageStates()
218 {
219 $forcePackageStatesRewrite = false;
220 $this->packageStatesConfiguration = @include $this->packageStatesPathAndFilename ?: [];
221 if (!isset($this->packageStatesConfiguration['version']) || $this->packageStatesConfiguration['version'] < 4) {
222 $this->packageStatesConfiguration = [];
223 } elseif ($this->packageStatesConfiguration['version'] === 4) {
224 // Convert to v5 format which only includes a list of active packages.
225 // Deprecated since version 8, will be removed in version 10.
226 $activePackages = [];
227 foreach ($this->packageStatesConfiguration['packages'] as $packageKey => $packageConfiguration) {
228 if ($packageConfiguration['state'] !== 'active') {
229 continue;
230 }
231 $activePackages[$packageKey] = ['packagePath' => $packageConfiguration['packagePath']];
232 }
233 $this->packageStatesConfiguration['packages'] = $activePackages;
234 $this->packageStatesConfiguration['version'] = 5;
235 $forcePackageStatesRewrite = true;
236 }
237 if ($this->packageStatesConfiguration !== []) {
238 $this->registerPackagesFromConfiguration($this->packageStatesConfiguration['packages'], false, $forcePackageStatesRewrite);
239 } else {
240 throw new Exception\PackageStatesUnavailableException('The PackageStates.php file is either corrupt or unavailable.', 1381507733);
241 }
242 }
243
244 /**
245 * Initializes activePackages property
246 *
247 * Saves PackageStates.php if list of required extensions has changed.
248 */
249 protected function initializePackageObjects()
250 {
251 $requiredPackages = [];
252 $activePackages = [];
253 foreach ($this->packages as $packageKey => $package) {
254 if ($package->isProtected()) {
255 $requiredPackages[$packageKey] = $package;
256 }
257 if (isset($this->packageStatesConfiguration['packages'][$packageKey])) {
258 $activePackages[$packageKey] = $package;
259 }
260 }
261 $previousActivePackages = $activePackages;
262 $activePackages = array_merge($requiredPackages, $activePackages);
263
264 if ($activePackages != $previousActivePackages) {
265 foreach ($requiredPackages as $requiredPackageKey => $package) {
266 $this->registerActivePackage($package);
267 }
268 $this->sortAndSavePackageStates();
269 }
270 }
271
272 /**
273 * @param PackageInterface $package
274 */
275 protected function registerActivePackage(PackageInterface $package)
276 {
277 // reset the active packages so they are rebuilt.
278 $this->activePackages = [];
279 $this->packageStatesConfiguration['packages'][$package->getPackageKey()] = ['packagePath' => str_replace($this->packagesBasePath, '', $package->getPackagePath())];
280 }
281
282 /**
283 * Scans all directories in the packages directories for available packages.
284 * For each package a Package object is created and stored in $this->packages.
285 * @internal
286 */
287 public function scanAvailablePackages()
288 {
289 $packagePaths = $this->scanPackagePathsForExtensions();
290 $packages = [];
291 foreach ($packagePaths as $packageKey => $packagePath) {
292 try {
293 $composerManifest = $this->getComposerManifest($packagePath);
294 $packageKey = $this->getPackageKeyFromManifest($composerManifest, $packagePath);
295 $this->composerNameToPackageKeyMap[strtolower($composerManifest->name)] = $packageKey;
296 $packages[$packageKey] = ['packagePath' => str_replace($this->packagesBasePath, '', $packagePath)];
297 } catch (Exception\MissingPackageManifestException $exception) {
298 if (!$this->isPackageKeyValid($packageKey)) {
299 continue;
300 }
301 } catch (Exception\InvalidPackageKeyException $exception) {
302 continue;
303 }
304 }
305
306 $this->availablePackagesScanned = true;
307 $registerOnlyNewPackages = !empty($this->packages);
308 $this->registerPackagesFromConfiguration($packages, $registerOnlyNewPackages);
309 }
310
311 /**
312 * Scans all directories for a certain package.
313 *
314 * @param string $packageKey
315 * @return PackageInterface
316 */
317 protected function registerPackageDuringRuntime($packageKey)
318 {
319 $packagePaths = $this->scanPackagePathsForExtensions();
320 $packagePath = $packagePaths[$packageKey];
321 $composerManifest = $this->getComposerManifest($packagePath);
322 $packageKey = $this->getPackageKeyFromManifest($composerManifest, $packagePath);
323 $this->composerNameToPackageKeyMap[strtolower($composerManifest->name)] = $packageKey;
324 $packagePath = PathUtility::sanitizeTrailingSeparator($packagePath);
325 $package = new Package($this, $packageKey, $packagePath);
326 $this->registerPackage($package);
327 return $package;
328 }
329
330 /**
331 * Fetches all directories from sysext/global/local locations and checks if the extension contains an ext_emconf.php
332 *
333 * @return array
334 */
335 protected function scanPackagePathsForExtensions()
336 {
337 $collectedExtensionPaths = [];
338 foreach ($this->getPackageBasePaths() as $packageBasePath) {
339 // Only add the extension if we have an EMCONF and the extension is not yet registered.
340 // This is crucial in order to allow overriding of system extension by local extensions
341 // and strongly depends on the order of paths defined in $this->packagesBasePaths.
342 $finder = new Finder();
343 $finder
344 ->name('ext_emconf.php')
345 ->followLinks()
346 ->depth(0)
347 ->ignoreUnreadableDirs()
348 ->in($packageBasePath);
349
350 /** @var SplFileInfo $fileInfo */
351 foreach ($finder as $fileInfo) {
352 $path = PathUtility::dirname($fileInfo->getPathname());
353 $extensionName = PathUtility::basename($path);
354 // Fix Windows backslashes
355 // we can't use GeneralUtility::fixWindowsFilePath as we have to keep double slashes for Unit Tests (vfs://)
356 $currentPath = str_replace('\\', '/', $path) . '/';
357 if (!isset($collectedExtensionPaths[$extensionName])) {
358 $collectedExtensionPaths[$extensionName] = $currentPath;
359 }
360 }
361 }
362 return $collectedExtensionPaths;
363 }
364
365 /**
366 * Requires and registers all packages which were defined in packageStatesConfiguration
367 *
368 * @param array $packages
369 * @param bool $registerOnlyNewPackages
370 * @param bool $packageStatesHasChanged
371 * @throws Exception\InvalidPackageStateException
372 * @throws Exception\PackageStatesFileNotWritableException
373 */
374 protected function registerPackagesFromConfiguration(array $packages, $registerOnlyNewPackages = false, $packageStatesHasChanged = false)
375 {
376 foreach ($packages as $packageKey => $stateConfiguration) {
377 if ($registerOnlyNewPackages && $this->isPackageRegistered($packageKey)) {
378 continue;
379 }
380
381 if (!isset($stateConfiguration['packagePath'])) {
382 $this->unregisterPackageByPackageKey($packageKey);
383 $packageStatesHasChanged = true;
384 continue;
385 }
386
387 try {
388 $packagePath = PathUtility::sanitizeTrailingSeparator($this->packagesBasePath . $stateConfiguration['packagePath']);
389 $package = new Package($this, $packageKey, $packagePath);
390 } catch (Exception\InvalidPackagePathException $exception) {
391 $this->unregisterPackageByPackageKey($packageKey);
392 $packageStatesHasChanged = true;
393 continue;
394 } catch (Exception\InvalidPackageKeyException $exception) {
395 $this->unregisterPackageByPackageKey($packageKey);
396 $packageStatesHasChanged = true;
397 continue;
398 } catch (Exception\InvalidPackageManifestException $exception) {
399 $this->unregisterPackageByPackageKey($packageKey);
400 $packageStatesHasChanged = true;
401 continue;
402 }
403
404 $this->registerPackage($package);
405 }
406 if ($packageStatesHasChanged) {
407 $this->sortAndSavePackageStates();
408 }
409 }
410
411 /**
412 * Register a native TYPO3 package
413 *
414 * @param PackageInterface $package The Package to be registered
415 * @return PackageInterface
416 * @throws Exception\InvalidPackageStateException
417 * @internal
418 */
419 public function registerPackage(PackageInterface $package)
420 {
421 $packageKey = $package->getPackageKey();
422 if ($this->isPackageRegistered($packageKey)) {
423 throw new Exception\InvalidPackageStateException('Package "' . $packageKey . '" is already registered.', 1338996122);
424 }
425
426 $this->packages[$packageKey] = $package;
427
428 if ($package instanceof PackageInterface) {
429 foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) {
430 $this->packageAliasMap[strtolower($packageToReplace)] = $package->getPackageKey();
431 }
432 }
433 return $package;
434 }
435
436 /**
437 * Unregisters a package from the list of available packages
438 *
439 * @param string $packageKey Package Key of the package to be unregistered
440 */
441 protected function unregisterPackageByPackageKey($packageKey)
442 {
443 try {
444 $package = $this->getPackage($packageKey);
445 if ($package instanceof PackageInterface) {
446 foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) {
447 unset($this->packageAliasMap[strtolower($packageToReplace)]);
448 }
449 }
450 } catch (Exception\UnknownPackageException $e) {
451 }
452 unset($this->packages[$packageKey]);
453 unset($this->packageStatesConfiguration['packages'][$packageKey]);
454 }
455
456 /**
457 * Resolves a TYPO3 package key from a composer package name.
458 *
459 * @param string $composerName
460 * @return string
461 * @internal
462 */
463 public function getPackageKeyFromComposerName($composerName)
464 {
465 $lowercasedComposerName = strtolower($composerName);
466 if (isset($this->packageAliasMap[$lowercasedComposerName])) {
467 return $this->packageAliasMap[$lowercasedComposerName];
468 }
469 if (isset($this->composerNameToPackageKeyMap[$lowercasedComposerName])) {
470 return $this->composerNameToPackageKeyMap[$lowercasedComposerName];
471 }
472 return $composerName;
473 }
474
475 /**
476 * Returns a PackageInterface object for the specified package.
477 * A package is available, if the package directory contains valid MetaData information.
478 *
479 * @param string $packageKey
480 * @return PackageInterface The requested package object
481 * @throws Exception\UnknownPackageException if the specified package is not known
482 */
483 public function getPackage($packageKey)
484 {
485 if (!$this->isPackageRegistered($packageKey) && !$this->isPackageAvailable($packageKey)) {
486 throw new Exception\UnknownPackageException('Package "' . $packageKey . '" is not available. Please check if the package exists and that the package key is correct (package keys are case sensitive).', 1166546734);
487 }
488 return $this->packages[$packageKey];
489 }
490
491 /**
492 * Returns TRUE if a package is available (the package's files exist in the packages directory)
493 * or FALSE if it's not. If a package is available it doesn't mean necessarily that it's active!
494 *
495 * @param string $packageKey The key of the package to check
496 * @return bool TRUE if the package is available, otherwise FALSE
497 */
498 public function isPackageAvailable($packageKey)
499 {
500 if ($this->isPackageRegistered($packageKey)) {
501 return true;
502 }
503
504 // If activePackages is empty, the PackageManager is currently initializing
505 // thus packages should not be scanned
506 if (!$this->availablePackagesScanned && !empty($this->activePackages)) {
507 $this->scanAvailablePackages();
508 }
509
510 return $this->isPackageRegistered($packageKey);
511 }
512
513 /**
514 * Returns TRUE if a package is activated or FALSE if it's not.
515 *
516 * @param string $packageKey The key of the package to check
517 * @return bool TRUE if package is active, otherwise FALSE
518 */
519 public function isPackageActive($packageKey)
520 {
521 $packageKey = $this->getPackageKeyFromComposerName($packageKey);
522
523 return isset($this->runtimeActivatedPackages[$packageKey]) || isset($this->packageStatesConfiguration['packages'][$packageKey]);
524 }
525
526 /**
527 * Deactivates a package and updates the packagestates configuration
528 *
529 * @param string $packageKey
530 * @throws Exception\PackageStatesFileNotWritableException
531 * @throws Exception\ProtectedPackageKeyException
532 * @throws Exception\UnknownPackageException
533 * @internal
534 */
535 public function deactivatePackage($packageKey)
536 {
537 $packagesWithDependencies = $this->sortActivePackagesByDependencies();
538
539 foreach ($packagesWithDependencies as $packageStateKey => $packageStateConfiguration) {
540 if ($packageKey === $packageStateKey || empty($packageStateConfiguration['dependencies'])) {
541 continue;
542 }
543 if (in_array($packageKey, $packageStateConfiguration['dependencies'], true)) {
544 $this->deactivatePackage($packageStateKey);
545 }
546 }
547
548 if (!$this->isPackageActive($packageKey)) {
549 return;
550 }
551
552 $package = $this->getPackage($packageKey);
553 if ($package->isProtected()) {
554 throw new Exception\ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be deactivated.', 1308662891);
555 }
556
557 $this->activePackages = [];
558 unset($this->packageStatesConfiguration['packages'][$packageKey]);
559 $this->sortAndSavePackageStates();
560 }
561
562 /**
563 * @param string $packageKey
564 * @internal
565 */
566 public function activatePackage($packageKey)
567 {
568 $package = $this->getPackage($packageKey);
569 $this->registerTransientClassLoadingInformationForPackage($package);
570
571 if ($this->isPackageActive($packageKey)) {
572 return;
573 }
574
575 $this->registerActivePackage($package);
576 $this->sortAndSavePackageStates();
577 }
578
579 /**
580 * Enables packages during runtime, but no class aliases will be available
581 *
582 * @param string $packageKey
583 */
584 public function activatePackageDuringRuntime($packageKey)
585 {
586 $package = $this->registerPackageDuringRuntime($packageKey);
587 $this->runtimeActivatedPackages[$package->getPackageKey()] = $package;
588 $this->registerTransientClassLoadingInformationForPackage($package);
589 }
590
591 /**
592 * @param PackageInterface $package
593 * @throws \TYPO3\CMS\Core\Exception
594 */
595 protected function registerTransientClassLoadingInformationForPackage(PackageInterface $package)
596 {
597 if (Environment::isComposerMode()) {
598 return;
599 }
600 ClassLoadingInformation::registerTransientClassLoadingInformationForPackage($package);
601 }
602
603 /**
604 * Removes a package from the file system.
605 *
606 * @param string $packageKey
607 * @throws Exception
608 * @throws Exception\ProtectedPackageKeyException
609 * @throws Exception\UnknownPackageException
610 * @internal
611 */
612 public function deletePackage($packageKey)
613 {
614 if (!$this->isPackageAvailable($packageKey)) {
615 throw new Exception\UnknownPackageException('Package "' . $packageKey . '" is not available and cannot be removed.', 1166543253);
616 }
617
618 $package = $this->getPackage($packageKey);
619 if ($package->isProtected()) {
620 throw new Exception\ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be removed.', 1220722120);
621 }
622
623 if ($this->isPackageActive($packageKey)) {
624 $this->deactivatePackage($packageKey);
625 }
626
627 $this->unregisterPackage($package);
628 $this->sortAndSavePackageStates();
629
630 $packagePath = $package->getPackagePath();
631 $deletion = GeneralUtility::rmdir($packagePath, true);
632 if ($deletion === false) {
633 throw new Exception('Please check file permissions. The directory "' . $packagePath . '" for package "' . $packageKey . '" could not be removed.', 1301491089);
634 }
635 }
636
637 /**
638 * Returns an array of \TYPO3\CMS\Core\Package objects of all active packages.
639 * A package is active, if it is available and has been activated in the package
640 * manager settings. This method returns runtime activated packages too
641 *
642 * @return PackageInterface[]
643 */
644 public function getActivePackages()
645 {
646 if (empty($this->activePackages)) {
647 if (!empty($this->packageStatesConfiguration['packages'])) {
648 foreach ($this->packageStatesConfiguration['packages'] as $packageKey => $packageConfig) {
649 $this->activePackages[$packageKey] = $this->getPackage($packageKey);
650 }
651 }
652 }
653 return array_merge($this->activePackages, $this->runtimeActivatedPackages);
654 }
655
656 /**
657 * Returns TRUE if a package was already registered or FALSE if it's not.
658 *
659 * @param string $packageKey
660 * @return bool
661 */
662 protected function isPackageRegistered($packageKey)
663 {
664 $packageKey = $this->getPackageKeyFromComposerName($packageKey);
665
666 return isset($this->packages[$packageKey]);
667 }
668
669 /**
670 * Orders all active packages by comparing their dependencies. By this, the packages
671 * and package configurations arrays holds all packages in the correct
672 * initialization order.
673 *
674 * @return array
675 */
676 protected function sortActivePackagesByDependencies()
677 {
678 $packagesWithDependencies = $this->resolvePackageDependencies($this->packageStatesConfiguration['packages']);
679
680 // sort the packages by key at first, so we get a stable sorting of "equivalent" packages afterwards
681 ksort($packagesWithDependencies);
682 $sortedPackageKeys = $this->sortPackageStatesConfigurationByDependency($packagesWithDependencies);
683
684 // Reorder the packages according to the loading order
685 $this->packageStatesConfiguration['packages'] = [];
686 foreach ($sortedPackageKeys as $packageKey) {
687 $this->registerActivePackage($this->packages[$packageKey]);
688 }
689 return $packagesWithDependencies;
690 }
691
692 /**
693 * Resolves the dependent packages from the meta data of all packages recursively. The
694 * resolved direct or indirect dependencies of each package will put into the package
695 * states configuration array.
696 *
697 * @param $packageConfig
698 * @return array
699 */
700 protected function resolvePackageDependencies($packageConfig)
701 {
702 $packagesWithDependencies = [];
703 foreach ($packageConfig as $packageKey => $_) {
704 $packagesWithDependencies[$packageKey]['dependencies'] = $this->getDependencyArrayForPackage($packageKey);
705 $packagesWithDependencies[$packageKey]['suggestions'] = $this->getSuggestionArrayForPackage($packageKey);
706 }
707 return $packagesWithDependencies;
708 }
709
710 /**
711 * Returns an array of suggested package keys for the given package.
712 *
713 * @param string $packageKey The package key to fetch the suggestions for
714 * @return array|null An array of directly suggested packages
715 */
716 protected function getSuggestionArrayForPackage($packageKey)
717 {
718 if (!isset($this->packages[$packageKey])) {
719 return null;
720 }
721 $suggestedPackageKeys = [];
722 $suggestedPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_SUGGESTS);
723 foreach ($suggestedPackageConstraints as $constraint) {
724 if ($constraint instanceof MetaData\PackageConstraint) {
725 $suggestedPackageKey = $constraint->getValue();
726 if (isset($this->packages[$suggestedPackageKey])) {
727 $suggestedPackageKeys[] = $suggestedPackageKey;
728 }
729 }
730 }
731 return array_reverse($suggestedPackageKeys);
732 }
733
734 /**
735 * Saves the current content of $this->packageStatesConfiguration to the
736 * PackageStates.php file.
737 *
738 * @throws Exception\PackageStatesFileNotWritableException
739 */
740 protected function savePackageStates()
741 {
742 $this->packageStatesConfiguration['version'] = 5;
743
744 $fileDescription = "# PackageStates.php\n\n";
745 $fileDescription .= "# This file is maintained by TYPO3's package management. Although you can edit it\n";
746 $fileDescription .= "# manually, you should rather use the extension manager for maintaining packages.\n";
747 $fileDescription .= "# This file will be regenerated automatically if it doesn't exist. Deleting this file\n";
748 $fileDescription .= "# should, however, never become necessary if you use the package commands.\n";
749
750 if (!@is_writable($this->packageStatesPathAndFilename)) {
751 // If file does not exists try to create it
752 $fileHandle = @fopen($this->packageStatesPathAndFilename, 'x');
753 if (!$fileHandle) {
754 throw new Exception\PackageStatesFileNotWritableException(
755 sprintf('We could not update the list of installed packages because the file %s is not writable. Please, check the file system permissions for this file and make sure that the web server can update it.', $this->packageStatesPathAndFilename),
756 1382449759
757 );
758 }
759 fclose($fileHandle);
760 }
761 $packageStatesCode = "<?php\n$fileDescription\nreturn " . ArrayUtility::arrayExport($this->packageStatesConfiguration) . ";\n";
762 GeneralUtility::writeFile($this->packageStatesPathAndFilename, $packageStatesCode, true);
763
764 GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($this->packageStatesPathAndFilename);
765 }
766
767 /**
768 * Saves the current content of $this->packageStatesConfiguration to the
769 * PackageStates.php file.
770 *
771 * @throws Exception\PackageStatesFileNotWritableException
772 */
773 protected function sortAndSavePackageStates()
774 {
775 $this->sortActivePackagesByDependencies();
776 $this->savePackageStates();
777 }
778
779 /**
780 * Check the conformance of the given package key
781 *
782 * @param string $packageKey The package key to validate
783 * @return bool If the package key is valid, returns TRUE otherwise FALSE
784 */
785 public function isPackageKeyValid($packageKey)
786 {
787 return preg_match(PackageInterface::PATTERN_MATCH_PACKAGEKEY, $packageKey) === 1 || preg_match(PackageInterface::PATTERN_MATCH_EXTENSIONKEY, $packageKey) === 1;
788 }
789
790 /**
791 * Returns an array of \TYPO3\CMS\Core\Package objects of all available packages.
792 * A package is available, if the package directory contains valid meta information.
793 *
794 * @return PackageInterface[] Array of PackageInterface
795 */
796 public function getAvailablePackages()
797 {
798 if ($this->availablePackagesScanned === false) {
799 $this->scanAvailablePackages();
800 }
801
802 return $this->packages;
803 }
804
805 /**
806 * Unregisters a package from the list of available packages
807 *
808 * @param PackageInterface $package The package to be unregistered
809 * @throws Exception\InvalidPackageStateException
810 * @internal
811 */
812 public function unregisterPackage(PackageInterface $package)
813 {
814 $packageKey = $package->getPackageKey();
815 if (!$this->isPackageRegistered($packageKey)) {
816 throw new Exception\InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1338996142);
817 }
818 $this->unregisterPackageByPackageKey($packageKey);
819 }
820
821 /**
822 * Reloads a package and its information
823 *
824 * @param string $packageKey
825 * @throws Exception\InvalidPackageStateException if the package isn't available
826 * @internal
827 */
828 public function reloadPackageInformation($packageKey)
829 {
830 if (!$this->isPackageRegistered($packageKey)) {
831 throw new Exception\InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1436201329);
832 }
833
834 /** @var PackageInterface $package */
835 $package = $this->packages[$packageKey];
836 $packagePath = $package->getPackagePath();
837 $newPackage = new Package($this, $packageKey, $packagePath);
838 $this->packages[$packageKey] = $newPackage;
839 unset($package);
840 }
841
842 /**
843 * Returns contents of Composer manifest as a stdObject
844 *
845 * @param string $manifestPath
846 * @return \stdClass
847 * @throws Exception\InvalidPackageManifestException
848 * @internal
849 */
850 public function getComposerManifest($manifestPath)
851 {
852 $composerManifest = null;
853 if (file_exists($manifestPath . 'composer.json')) {
854 $json = file_get_contents($manifestPath . 'composer.json');
855 $composerManifest = json_decode($json);
856 if (!$composerManifest instanceof \stdClass) {
857 throw new Exception\InvalidPackageManifestException('The composer.json found for extension "' . PathUtility::basename($manifestPath) . '" is invalid!', 1439555561);
858 }
859 }
860
861 $extensionManagerConfiguration = $this->getExtensionEmConf($manifestPath);
862 $composerManifest = $this->mapExtensionManagerConfigurationToComposerManifest(
863 PathUtility::basename($manifestPath),
864 $extensionManagerConfiguration,
865 $composerManifest ?: new \stdClass()
866 );
867
868 return $composerManifest;
869 }
870
871 /**
872 * Fetches MetaData information from ext_emconf.php, used for
873 * resolving dependencies as well.
874 *
875 * @param string $packagePath
876 * @return array
877 * @throws Exception\InvalidPackageManifestException
878 */
879 protected function getExtensionEmConf($packagePath)
880 {
881 $packageKey = PathUtility::basename($packagePath);
882 $_EXTKEY = $packageKey;
883 $path = $packagePath . 'ext_emconf.php';
884 $EM_CONF = null;
885 if (@file_exists($path)) {
886 include $path;
887 if (is_array($EM_CONF[$_EXTKEY])) {
888 return $EM_CONF[$_EXTKEY];
889 }
890 }
891 throw new Exception\InvalidPackageManifestException('No valid ext_emconf.php file found for package "' . $packageKey . '".', 1360403545);
892 }
893
894 /**
895 * Fetches information from ext_emconf.php and maps it so it is treated as it would come from composer.json
896 *
897 * @param string $packageKey
898 * @param array $extensionManagerConfiguration
899 * @param \stdClass $composerManifest
900 * @return \stdClass
901 * @throws Exception\InvalidPackageManifestException
902 */
903 protected function mapExtensionManagerConfigurationToComposerManifest($packageKey, array $extensionManagerConfiguration, \stdClass $composerManifest)
904 {
905 $this->setComposerManifestValueIfEmpty($composerManifest, 'name', $packageKey);
906 $this->setComposerManifestValueIfEmpty($composerManifest, 'type', 'typo3-cms-extension');
907 $this->setComposerManifestValueIfEmpty($composerManifest, 'description', $extensionManagerConfiguration['title'] ?? '');
908 $this->setComposerManifestValueIfEmpty($composerManifest, 'authors', [['name' => $extensionManagerConfiguration['author'] ?? '', 'email' => $extensionManagerConfiguration['author_email'] ?? '']]);
909 $composerManifest->version = $extensionManagerConfiguration['version'] ?? '';
910 if (isset($extensionManagerConfiguration['constraints']['depends']) && is_array($extensionManagerConfiguration['constraints']['depends'])) {
911 $composerManifest->require = new \stdClass();
912 foreach ($extensionManagerConfiguration['constraints']['depends'] as $requiredPackageKey => $requiredPackageVersion) {
913 if (!empty($requiredPackageKey)) {
914 if ($requiredPackageKey === 'typo3') {
915 // Add implicit dependency to 'core'
916 $composerManifest->require->core = $requiredPackageVersion;
917 } elseif ($requiredPackageKey !== 'php') {
918 // Skip php dependency
919 $composerManifest->require->{$requiredPackageKey} = $requiredPackageVersion;
920 }
921 } else {
922 throw new Exception\InvalidPackageManifestException(sprintf('The extension "%s" has invalid version constraints in depends section. Extension key is missing!', $packageKey), 1439552058);
923 }
924 }
925 }
926 if (isset($extensionManagerConfiguration['constraints']['conflicts']) && is_array($extensionManagerConfiguration['constraints']['conflicts'])) {
927 $composerManifest->conflict = new \stdClass();
928 foreach ($extensionManagerConfiguration['constraints']['conflicts'] as $conflictingPackageKey => $conflictingPackageVersion) {
929 if (!empty($conflictingPackageKey)) {
930 $composerManifest->conflict->$conflictingPackageKey = $conflictingPackageVersion;
931 } else {
932 throw new Exception\InvalidPackageManifestException(sprintf('The extension "%s" has invalid version constraints in conflicts section. Extension key is missing!', $packageKey), 1439552059);
933 }
934 }
935 }
936 if (isset($extensionManagerConfiguration['constraints']['suggests']) && is_array($extensionManagerConfiguration['constraints']['suggests'])) {
937 $composerManifest->suggest = new \stdClass();
938 foreach ($extensionManagerConfiguration['constraints']['suggests'] as $suggestedPackageKey => $suggestedPackageVersion) {
939 if (!empty($suggestedPackageKey)) {
940 $composerManifest->suggest->$suggestedPackageKey = $suggestedPackageVersion;
941 } else {
942 throw new Exception\InvalidPackageManifestException(sprintf('The extension "%s" has invalid version constraints in suggests section. Extension key is missing!', $packageKey), 1439552060);
943 }
944 }
945 }
946 if (isset($extensionManagerConfiguration['autoload'])) {
947 $composerManifest->autoload = json_decode(json_encode($extensionManagerConfiguration['autoload']));
948 }
949 // composer.json autoload-dev information must be discarded, as it may contain information only available after a composer install
950 unset($composerManifest->{'autoload-dev'});
951 if (isset($extensionManagerConfiguration['autoload-dev'])) {
952 $composerManifest->{'autoload-dev'} = json_decode(json_encode($extensionManagerConfiguration['autoload-dev']));
953 }
954
955 return $composerManifest;
956 }
957
958 /**
959 * @param \stdClass $manifest
960 * @param string $property
961 * @param mixed $value
962 * @return \stdClass
963 */
964 protected function setComposerManifestValueIfEmpty(\stdClass $manifest, $property, $value)
965 {
966 if (empty($manifest->{$property})) {
967 $manifest->{$property} = $value;
968 }
969
970 return $manifest;
971 }
972
973 /**
974 * Returns an array of dependent package keys for the given package. It will
975 * do this recursively, so dependencies of dependent packages will also be
976 * in the result.
977 *
978 * @param string $packageKey The package key to fetch the dependencies for
979 * @param array $dependentPackageKeys
980 * @param array $trace An array of already visited package keys, to detect circular dependencies
981 * @return array|null An array of direct or indirect dependent packages
982 * @throws Exception\InvalidPackageKeyException
983 */
984 protected function getDependencyArrayForPackage($packageKey, array &$dependentPackageKeys = [], array $trace = [])
985 {
986 if (!isset($this->packages[$packageKey])) {
987 return null;
988 }
989 if (in_array($packageKey, $trace, true) !== false) {
990 return $dependentPackageKeys;
991 }
992 $trace[] = $packageKey;
993 $dependentPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_DEPENDS);
994 foreach ($dependentPackageConstraints as $constraint) {
995 if ($constraint instanceof MetaData\PackageConstraint) {
996 $dependentPackageKey = $constraint->getValue();
997 if (in_array($dependentPackageKey, $dependentPackageKeys, true) === false && in_array($dependentPackageKey, $trace, true) === false) {
998 $dependentPackageKeys[] = $dependentPackageKey;
999 }
1000 $this->getDependencyArrayForPackage($dependentPackageKey, $dependentPackageKeys, $trace);
1001 }
1002 }
1003 return array_reverse($dependentPackageKeys);
1004 }
1005
1006 /**
1007 * Resolves package key from Composer manifest
1008 *
1009 * If it is a TYPO3 package the name of the containing directory will be used.
1010 *
1011 * Else if the composer name of the package matches the first part of the lowercased namespace of the package, the mixed
1012 * case version of the composer name / namespace will be used, with backslashes replaced by dots.
1013 *
1014 * Else the composer name will be used with the slash replaced by a dot
1015 *
1016 * @param object $manifest
1017 * @param string $packagePath
1018 * @throws Exception\InvalidPackageManifestException
1019 * @return string
1020 */
1021 protected function getPackageKeyFromManifest($manifest, $packagePath)
1022 {
1023 if (!is_object($manifest)) {
1024 throw new Exception\InvalidPackageManifestException('Invalid composer manifest in package path: ' . $packagePath, 1348146451);
1025 }
1026 if (isset($manifest->type) && strpos($manifest->type, 'typo3-cms-') === 0) {
1027 $packageKey = PathUtility::basename($packagePath);
1028 return preg_replace('/[^A-Za-z0-9._-]/', '', $packageKey);
1029 }
1030 $packageKey = str_replace('/', '.', $manifest->name);
1031 return preg_replace('/[^A-Za-z0-9.]/', '', $packageKey);
1032 }
1033
1034 /**
1035 * The order of paths is crucial for allowing overriding of system extension by local extensions.
1036 * Pay attention if you change order of the paths here.
1037 *
1038 * @return array
1039 */
1040 protected function getPackageBasePaths()
1041 {
1042 if (count($this->packagesBasePaths) < 3) {
1043 // Check if the directory even exists and if it is not empty
1044 if (is_dir(Environment::getExtensionsPath()) && $this->hasSubDirectories(Environment::getExtensionsPath())) {
1045 $this->packagesBasePaths['local'] = Environment::getExtensionsPath() . '/*/';
1046 }
1047 if (is_dir(Environment::getBackendPath() . '/ext') && $this->hasSubDirectories(Environment::getBackendPath() . '/ext')) {
1048 $this->packagesBasePaths['global'] = Environment::getBackendPath() . '/ext/*/';
1049 }
1050 $this->packagesBasePaths['system'] = Environment::getFrameworkBasePath() . '/*/';
1051 }
1052 return $this->packagesBasePaths;
1053 }
1054
1055 /**
1056 * Returns true if the given path has valid subdirectories, false otherwise.
1057 *
1058 * @param string $path
1059 * @return bool
1060 */
1061 protected function hasSubDirectories(string $path): bool
1062 {
1063 return !empty(glob(rtrim($path, '/\\') . '/*', GLOB_ONLYDIR));
1064 }
1065
1066 /**
1067 * @param array $packageStatesConfiguration
1068 * @return array Returns the packageStatesConfiguration sorted by dependencies
1069 * @throws \UnexpectedValueException
1070 */
1071 protected function sortPackageStatesConfigurationByDependency(array $packageStatesConfiguration)
1072 {
1073 return $this->dependencyOrderingService->calculateOrder($this->buildDependencyGraph($packageStatesConfiguration));
1074 }
1075
1076 /**
1077 * Convert the package configuration into a dependency definition
1078 *
1079 * This converts "dependencies" and "suggestions" to "after" syntax for the usage in DependencyOrderingService
1080 *
1081 * @param array $packageStatesConfiguration
1082 * @param array $packageKeys
1083 * @return array
1084 * @throws \UnexpectedValueException
1085 */
1086 protected function convertConfigurationForGraph(array $packageStatesConfiguration, array $packageKeys)
1087 {
1088 $dependencies = [];
1089 foreach ($packageKeys as $packageKey) {
1090 if (!isset($packageStatesConfiguration[$packageKey]['dependencies']) && !isset($packageStatesConfiguration[$packageKey]['suggestions'])) {
1091 continue;
1092 }
1093 $dependencies[$packageKey] = [
1094 'after' => []
1095 ];
1096 if (isset($packageStatesConfiguration[$packageKey]['dependencies'])) {
1097 foreach ($packageStatesConfiguration[$packageKey]['dependencies'] as $dependentPackageKey) {
1098 if (!in_array($dependentPackageKey, $packageKeys, true)) {
1099 throw new \UnexpectedValueException(
1100 'The package "' . $packageKey . '" depends on "'
1101 . $dependentPackageKey . '" which is not present in the system.',
1102 1519931815
1103 );
1104 }
1105 $dependencies[$packageKey]['after'][] = $dependentPackageKey;
1106 }
1107 }
1108 if (isset($packageStatesConfiguration[$packageKey]['suggestions'])) {
1109 foreach ($packageStatesConfiguration[$packageKey]['suggestions'] as $suggestedPackageKey) {
1110 // skip suggestions on not existing packages
1111 if (in_array($suggestedPackageKey, $packageKeys, true)) {
1112 // Suggestions actually have never been meant to influence loading order.
1113 // We misuse this currently, as there is no other way to influence the loading order
1114 // for not-required packages (soft-dependency).
1115 // When considering suggestions for the loading order, we might create a cyclic dependency
1116 // if the suggested package already has a real dependency on this package, so the suggestion
1117 // has do be dropped in this case and must *not* be taken into account for loading order evaluation.
1118 $dependencies[$packageKey]['after-resilient'][] = $suggestedPackageKey;
1119 }
1120 }
1121 }
1122 }
1123 return $dependencies;
1124 }
1125
1126 /**
1127 * Adds all root packages of current dependency graph as dependency to all extensions
1128 *
1129 * This ensures that the framework extensions (aka sysext) are
1130 * always loaded first, before any other external extension.
1131 *
1132 * @param array $packageStateConfiguration
1133 * @param array $rootPackageKeys
1134 * @return array
1135 */
1136 protected function addDependencyToFrameworkToAllExtensions(array $packageStateConfiguration, array $rootPackageKeys)
1137 {
1138 $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration);
1139 $extensionPackageKeys = array_diff(array_keys($packageStateConfiguration), $frameworkPackageKeys);
1140 foreach ($extensionPackageKeys as $packageKey) {
1141 // Remove framework packages from list
1142 $packageKeysWithoutFramework = array_diff(
1143 $packageStateConfiguration[$packageKey]['dependencies'],
1144 $frameworkPackageKeys
1145 );
1146 // The order of the array_merge is crucial here,
1147 // we want the framework first
1148 $packageStateConfiguration[$packageKey]['dependencies'] = array_merge(
1149 $rootPackageKeys,
1150 $packageKeysWithoutFramework
1151 );
1152 }
1153 return $packageStateConfiguration;
1154 }
1155
1156 /**
1157 * Builds the dependency graph for all packages
1158 *
1159 * This method also introduces dependencies among the dependencies
1160 * to ensure the loading order is exactly as specified in the list.
1161 *
1162 * @param array $packageStateConfiguration
1163 * @return array
1164 */
1165 protected function buildDependencyGraph(array $packageStateConfiguration)
1166 {
1167 $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration);
1168 $frameworkPackagesDependencyGraph = $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $frameworkPackageKeys));
1169 $packageStateConfiguration = $this->addDependencyToFrameworkToAllExtensions($packageStateConfiguration, $this->dependencyOrderingService->findRootIds($frameworkPackagesDependencyGraph));
1170
1171 $packageKeys = array_keys($packageStateConfiguration);
1172 return $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $packageKeys));
1173 }
1174
1175 /**
1176 * @param array $packageStateConfiguration
1177 * @return array
1178 */
1179 protected function findFrameworkPackages(array $packageStateConfiguration)
1180 {
1181 $frameworkPackageKeys = [];
1182 foreach ($packageStateConfiguration as $packageKey => $packageConfiguration) {
1183 $package = $this->getPackage($packageKey);
1184 if ($package->getValueFromComposerManifest('type') === 'typo3-cms-framework') {
1185 $frameworkPackageKeys[] = $packageKey;
1186 }
1187 }
1188
1189 return $frameworkPackageKeys;
1190 }
1191 }