diff --git a/typo3/sysext/core/Classes/Composer/PackageArtifactBuilder.php b/typo3/sysext/core/Classes/Composer/PackageArtifactBuilder.php index eab3b940432dc35d914a9f95b2eb2c5730948b26..45f36605c634c03e41aa08da84931714810bce68 100644 --- a/typo3/sysext/core/Classes/Composer/PackageArtifactBuilder.php +++ b/typo3/sysext/core/Classes/Composer/PackageArtifactBuilder.php @@ -17,11 +17,13 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Composer; +use Composer\IO\IOInterface; use Composer\Package\PackageInterface; use Composer\Repository\PlatformRepository; use Composer\Script\Event; use Composer\Util\Filesystem; use Composer\Util\Platform; +use Symfony\Component\Filesystem\Exception\IOException; use TYPO3\CMS\Composer\Plugin\Config; use TYPO3\CMS\Composer\Plugin\Core\InstallerScript; use TYPO3\CMS\Composer\Plugin\Util\ExtensionKeyResolver; @@ -30,6 +32,7 @@ use TYPO3\CMS\Core\Package\Exception\InvalidPackageKeyException; use TYPO3\CMS\Core\Package\Exception\InvalidPackageManifestException; use TYPO3\CMS\Core\Package\Exception\InvalidPackagePathException; use TYPO3\CMS\Core\Package\Exception\InvalidPackageStateException; +use TYPO3\CMS\Core\Package\Exception\PackageAssetsPublishingFailedException; use TYPO3\CMS\Core\Package\Package; use TYPO3\CMS\Core\Package\PackageManager; use TYPO3\CMS\Core\Service\DependencyOrderingService; @@ -43,12 +46,13 @@ use TYPO3\CMS\Core\Utility\PathUtility; * All ext_emconf.php files will be completely ignored in this context, which means all extensions * are required to have a composer.json file, which works out naturally with a Composer setup. * + * @template packageMap of array<int, array{PackageInterface, string, non-empty-string}> + * @template IOMessage of array{severity: 'title'|'info'|'warning', verbosity: int, message: string} + * * @internal This class is an implementation detail and does not represent public API */ class PackageArtifactBuilder extends PackageManager implements InstallerScript { - private const LEGACY_EXTENSION_INSTALL_PATH = '/typo3conf/ext'; - /** * @var Event $event */ @@ -91,13 +95,23 @@ class PackageArtifactBuilder extends PackageManager implements InstallerScript */ public function run(Event $event): bool { + $io = $event->getIO(); $this->event = $event; - $this->config = Config::load($this->event->getComposer(), $this->event->getIO()); + $this->config = Config::load($this->event->getComposer(), $io); $this->fileSystem = new Filesystem(); $composer = $this->event->getComposer(); $basePath = $this->config->get('base-dir'); $this->packagesBasePath = $basePath . '/'; - foreach ($this->extractPackageMapFromComposer() as [$composerPackage, $path, $extensionKey]) { + $installedTypo3Packages = $this->extractPackageMapFromComposer(); + $messages = $this->publishResources($installedTypo3Packages); + foreach ($messages as $message) { + $io->writeError( + $this->formatMessage($message), + true, + $message['verbosity'], + ); + } + foreach ($installedTypo3Packages as [$composerPackage, $path, $extensionKey]) { $packagePath = PathUtility::sanitizeTrailingSeparator($path); $package = new Package($this, $extensionKey, $packagePath, true); $this->setTitleFromExtEmConf($package); @@ -155,6 +169,8 @@ class PackageArtifactBuilder extends PackageManager implements InstallerScript /** * Fetch a map of all installed packages and filter them, when they apply * for TYPO3. + * + * @return packageMap */ private function extractPackageMapFromComposer(): array { @@ -164,8 +180,8 @@ class PackageArtifactBuilder extends PackageManager implements InstallerScript $localRepo = $composer->getRepositoryManager()->getLocalRepository(); $usedExtensionKeys = []; - $installedTypo3Packages = array_map( - function (array $packageAndPath) use ($rootPackage, &$usedExtensionKeys): array { + return array_map( + function (array $packageAndPath) use (&$usedExtensionKeys): array { [$composerPackage, $packagePath] = $packageAndPath; $packageName = $composerPackage->getName(); $packagePath = GeneralUtility::fixWindowsFilePath($packagePath); @@ -194,9 +210,6 @@ class PackageArtifactBuilder extends PackageManager implements InstallerScript $usedExtensionKeys[$extensionKey] = $packageName; unset($this->availableComposerPackageKeys[$packageName]); $this->composerNameToPackageKeyMap[$packageName] = $extensionKey; - if ($composerPackage === $rootPackage) { - return $this->handleRootPackage($rootPackage, $extensionKey); - } // Add extension key to the package map for later reference return [$composerPackage, $packagePath, $extensionKey]; }, @@ -215,92 +228,127 @@ class PackageArtifactBuilder extends PackageManager implements InstallerScript } ) ); - - $this->publishResources($installedTypo3Packages); - - return $installedTypo3Packages; } /** - * TYPO3 can not handle public resources of extensions, that do not reside in typo3conf/ext - * Therefore, if the root package is of type typo3-cms-extension and has the folder Resources/Public, - * we fake the path of this extension to be in typo3conf/ext - * - * For root packages of other types or extensions without public resources, no symlink is created - * and the package path stays to be the composer root path. - * - * If extensions are installed into vendor folder, linking is skipped, because public resources - * are published anyway. - * Linking could be skipped altogether, but is kept to stay consistent: - * extensions in typo3conf/ext: root package is linked - * extensions in vendor: public resources of all packages are published - * @todo: remove this method in TYPO3 v12 - * - * @param PackageInterface $rootPackage - * @param string $extensionKey + * @param IOMessage $message + * @return string */ - private function handleRootPackage(PackageInterface $rootPackage, string $extensionKey): array + private function formatMessage(array $message): string { - $baseDir = $this->config->get('base-dir'); - $composer = $this->event->getComposer(); - if ($rootPackage->getType() !== 'typo3-cms-extension' - || !file_exists($baseDir . '/Resources/Public/') - ) { - return [$rootPackage, $baseDir, $extensionKey]; - } - $typo3ExtensionInstallPath = $composer->getInstallationManager()->getInstaller('typo3-cms-extension')->getInstallPath($rootPackage); - if (!str_contains($typo3ExtensionInstallPath, self::LEGACY_EXTENSION_INSTALL_PATH)) { - return [$rootPackage, $baseDir, $extensionKey]; - } - if (!file_exists($typo3ExtensionInstallPath) && !$this->fileSystem->isSymlinkedDirectory($typo3ExtensionInstallPath)) { - $this->fileSystem->ensureDirectoryExists(dirname($typo3ExtensionInstallPath)); - $this->fileSystem->relativeSymlink($baseDir, $typo3ExtensionInstallPath); - } - if (realpath($baseDir) !== realpath($typo3ExtensionInstallPath)) { - $this->event->getIO()->warning('The root package is of type "typo3-cms-extension" and has public resources, but could not be linked to "' . self::LEGACY_EXTENSION_INSTALL_PATH . '" directory, because target directory already exits.'); + if ($message['severity'] === 'title') { + return sprintf('<info>%s</info>', $message['message']); } - return [$rootPackage, $typo3ExtensionInstallPath, $extensionKey]; + return sprintf( + ' * <%2$s>%s</%2$s>', + sprintf(str_replace(chr(10), '</%1$s>' . chr(10) . ' <%1$s>', $message['message']), $message['severity']), + $message['severity'], + ); } - private function publishResources(array $installedTypo3Packages): void + /** + * @param packageMap $installedTypo3Packages + * @return array<int, IOMessage> + */ + private function publishResources(array $installedTypo3Packages): array { + $publishingMessages = [ + [ + 'severity' => 'title', + 'verbosity' => IOInterface::NORMAL, + 'message' => 'Publishing public assets of TYPO3 extensions', + ], + ]; $baseDir = $this->config->get('base-dir'); foreach ($installedTypo3Packages as [$composerPackage, $path, $extensionKey]) { - $fileSystemResourcesPath = $path . '/Resources/Public'; - // skip non-composer installation extension paths, or if resource paths does not exist. - if (str_ends_with($path, self::LEGACY_EXTENSION_INSTALL_PATH . '/' . $extensionKey) || !file_exists($fileSystemResourcesPath)) { + $fileSystemResourcesPath = ($path === '' ? $baseDir : $path) . '/Resources/Public'; + $relativePath = substr($fileSystemResourcesPath, strlen($baseDir)); + if (!file_exists($fileSystemResourcesPath)) { + $publishingMessages[] = [ + 'severity' => 'info', + 'verbosity' => IOInterface::VERBOSE, + 'message' => sprintf( + 'Skipping assets publishing for extension "%s",' + . chr(10) . 'because its public resources directory "%s" does not exist.', + $composerPackage->getName(), + '.' . $relativePath, + ), + ]; continue; } - $relativePath = substr($fileSystemResourcesPath, strlen($baseDir)); [$relativePrefix] = explode('Resources/Public', $relativePath); $publicResourcesPath = $this->fileSystem->normalizePath($this->config->get('web-dir') . '/_assets/' . md5($relativePrefix)); $this->fileSystem->ensureDirectoryExists(dirname($publicResourcesPath)); - if (Platform::isWindows()) { - $this->ensureJunctionExists($fileSystemResourcesPath, $publicResourcesPath); - } else { - $this->ensureSymlinkExists($fileSystemResourcesPath, $publicResourcesPath); + try { + if (Platform::isWindows()) { + $this->ensureJunctionExists($fileSystemResourcesPath, $publicResourcesPath, $composerPackage); + } else { + $this->ensureSymlinkExists($fileSystemResourcesPath, $publicResourcesPath, $composerPackage); + } + } catch (PackageAssetsPublishingFailedException $e) { + $publishingMessages[] = [ + 'severity' => 'warning', + 'verbosity' => IOInterface::NORMAL, + 'message' => sprintf( + 'Could not publish public resources for extension "%s" by using the "%s" strategy.' + . chr(10) . 'Check whether the target directory "%s" already exists' + . chr(10) . 'and Composer has permissions to write inside the "_assets" directory.', + $e->packageName, + $e->publishingStrategy, + '.' . substr($publicResourcesPath, strlen($baseDir)), + ), + ]; } } + $publishingMessages[] = [ + 'severity' => 'title', + 'verbosity' => IOInterface::NORMAL, + 'message' => 'Published public assets', + ]; + + return $publishingMessages; } - private function ensureJunctionExists(string $target, string $junction): void + /** + * @throws PackageAssetsPublishingFailedException + */ + private function ensureJunctionExists(string $target, string $junction, PackageInterface $package): void { + $e = null; if (!$this->fileSystem->isJunction($junction)) { - // Cleanup a possible symlink that might have been installed by ourselves prior to #98434 - // Note: Unprivileged deletion of symlinks is allowed, even if they were created by a - // privileged user - if (is_link($junction)) { - $this->fileSystem->unlink($junction); + try { + $this->fileSystem->junction($target, $junction); + } catch (IOException $e) { } - $this->fileSystem->junction($target, $junction); + } + + if ($e !== null || realpath($target) !== realpath($junction)) { + throw new PackageAssetsPublishingFailedException( + 'junction', + $package->getName(), + 1717488535, + $e, + ); } } - private function ensureSymlinkExists(string $target, string $link): void + /** + * @throws PackageAssetsPublishingFailedException + */ + private function ensureSymlinkExists(string $target, string $link, PackageInterface $package): void { + $success = true; if (!$this->fileSystem->isSymlinkedDirectory($link)) { - $this->fileSystem->relativeSymlink($target, $link); + $success = $this->fileSystem->relativeSymlink($target, $link); + } + + if (!$success || realpath($target) !== realpath($link)) { + throw new PackageAssetsPublishingFailedException( + 'symlink', + $package->getName(), + 1717488536, + ); } } } diff --git a/typo3/sysext/core/Classes/Package/Exception/PackageAssetsPublishingFailedException.php b/typo3/sysext/core/Classes/Package/Exception/PackageAssetsPublishingFailedException.php new file mode 100644 index 0000000000000000000000000000000000000000..7ed7f4e45d1bcc6a7462b4de7f070a159a994461 --- /dev/null +++ b/typo3/sysext/core/Classes/Package/Exception/PackageAssetsPublishingFailedException.php @@ -0,0 +1,30 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Package\Exception; + +use TYPO3\CMS\Core\Package\Exception; + +class PackageAssetsPublishingFailedException extends Exception +{ + public function __construct( + public readonly string $publishingStrategy, + public readonly ?string $packageName = null, + int $code = 0, + ?\Throwable $previous = null, + ) { + parent::__construct(sprintf('Asset publishing by "%s" failed', $publishingStrategy), $code, $previous); + } +}