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