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