TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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\Page;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Crypto\HashService;
|
||||
use TYPO3\CMS\Core\Package\PackageInterface;
|
||||
use TYPO3\CMS\Core\Page\Event\ResolveJavaScriptImportEvent;
|
||||
use TYPO3\CMS\Core\Page\Event\ResolveVirtualJavaScriptImportEvent;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashValue;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode;
|
||||
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyRegistry;
|
||||
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolvePublicResourceException;
|
||||
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceException;
|
||||
use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions;
|
||||
use TYPO3\CMS\Core\Utility\ArrayUtility;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\PathUtility;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ImportMap
|
||||
{
|
||||
protected array $extensionsToLoad = [];
|
||||
|
||||
private ?array $importMaps = null;
|
||||
|
||||
/**
|
||||
* @param list<PackageInterface> $packages
|
||||
*/
|
||||
public function __construct(
|
||||
protected readonly HashService $hashService,
|
||||
protected readonly array $packages,
|
||||
protected readonly ?PolicyRegistry $policyRegistry = null,
|
||||
protected readonly ?FrontendInterface $cache = null,
|
||||
protected readonly string $cacheIdentifier = '',
|
||||
protected readonly ?EventDispatcherInterface $eventDispatcher = null,
|
||||
protected readonly bool $bustSuffix = true
|
||||
) {}
|
||||
|
||||
/**
|
||||
* HEADS UP: Do only use in authenticated mode as this discloses as installed extensions
|
||||
*/
|
||||
public function includeAllImports(): void
|
||||
{
|
||||
$this->extensionsToLoad['*'] = true;
|
||||
}
|
||||
|
||||
public function includeTaggedImports(string $tag): void
|
||||
{
|
||||
if (isset($this->extensionsToLoad['*'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->getImportMaps() as $package => $config) {
|
||||
$tags = $config['tags'] ?? [];
|
||||
if (in_array($tag, $tags, true)) {
|
||||
$this->loadDependency($package);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function includeImportsFor(string $specifier): void
|
||||
{
|
||||
if (!isset($this->extensionsToLoad['*'])) {
|
||||
$this->resolveImport($specifier, true);
|
||||
} else {
|
||||
$this->dispatchResolveJavaScriptImportEvent($specifier, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?non-empty-string
|
||||
*/
|
||||
public function resolveImport(
|
||||
string $specifier,
|
||||
bool $loadImportConfiguration = true,
|
||||
string $uriPrefix = '/'
|
||||
): ?string {
|
||||
$resolution = $this->dispatchResolveJavaScriptImportEvent($specifier, $loadImportConfiguration);
|
||||
if ($resolution !== null) {
|
||||
return $resolution;
|
||||
}
|
||||
|
||||
foreach (array_reverse($this->getImportMaps()) as $package => $config) {
|
||||
$imports = $config['imports'] ?? [];
|
||||
if (isset($imports[$specifier])) {
|
||||
if ($loadImportConfiguration) {
|
||||
$this->loadDependency($package);
|
||||
}
|
||||
return $this->getResourceUri($imports[$specifier], $uriPrefix);
|
||||
}
|
||||
|
||||
$specifierParts = explode('/', $specifier);
|
||||
$specifierPartCount = count($specifierParts);
|
||||
for ($i = 1; $i < $specifierPartCount; ++$i) {
|
||||
$prefix = implode('/', array_slice($specifierParts, 0, $i)) . '/';
|
||||
if (isset($imports[$prefix])) {
|
||||
if ($loadImportConfiguration) {
|
||||
$this->loadDependency($package);
|
||||
}
|
||||
return $this->getResourceUri($imports[$prefix] . implode('/', array_slice($specifierParts, $i)), $uriPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function render(
|
||||
string $uriPrefix,
|
||||
string|ConsumableNonce|null $nonce
|
||||
): string {
|
||||
if (count($this->extensionsToLoad) === 0 || count($this->getImportMaps()) === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = [];
|
||||
|
||||
$importMap = $this->composeImportMap($uriPrefix);
|
||||
$json = json_encode(
|
||||
$importMap,
|
||||
JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR
|
||||
);
|
||||
$attributes = [
|
||||
'type' => 'importmap',
|
||||
];
|
||||
if ($nonce !== null) {
|
||||
$attributes['nonce'] = $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : $nonce;
|
||||
} else {
|
||||
$this->policyRegistry?->appendMutationCollection(
|
||||
new MutationCollection(
|
||||
new Mutation(MutationMode::Extend, Directive::ScriptSrcElem, HashValue::hash($json))
|
||||
)
|
||||
);
|
||||
}
|
||||
$html[] = sprintf(
|
||||
'<script %s>%s</script>',
|
||||
GeneralUtility::implodeAttributes($attributes, true),
|
||||
$json
|
||||
);
|
||||
|
||||
return implode(PHP_EOL, $html) . PHP_EOL;
|
||||
}
|
||||
|
||||
public function warmupCaches(): void
|
||||
{
|
||||
$this->computeImportMaps();
|
||||
}
|
||||
|
||||
protected function getImportMaps(): array
|
||||
{
|
||||
return $this->importMaps ?? $this->getFromCache() ?? $this->computeImportMaps();
|
||||
}
|
||||
|
||||
protected function getFromCache(): ?array
|
||||
{
|
||||
if ($this->cache === null) {
|
||||
return null;
|
||||
}
|
||||
if (!$this->cache->has($this->cacheIdentifier)) {
|
||||
return null;
|
||||
}
|
||||
$importMaps = $this->cache->get($this->cacheIdentifier);
|
||||
if ($importMaps === false) {
|
||||
// Cache entry has been removed in the meantime
|
||||
return null;
|
||||
}
|
||||
if (!is_array($importMaps)) {
|
||||
// An invalid result is to be ignored (cache will be recreated)
|
||||
return null;
|
||||
}
|
||||
$this->importMaps = $importMaps;
|
||||
return $importMaps;
|
||||
}
|
||||
|
||||
protected function computeImportMaps(): array
|
||||
{
|
||||
$extensionVersions = [];
|
||||
$importMaps = [];
|
||||
foreach ($this->packages as $package) {
|
||||
$configurationFile = $package->getPackagePath() . 'Configuration/JavaScriptModules.php';
|
||||
if (!is_readable($configurationFile)) {
|
||||
continue;
|
||||
}
|
||||
$extensionVersions[$package->getPackageKey()] = implode(':', [
|
||||
$package->getPackageKey(),
|
||||
$package->getPackageMetadata()->getVersion(),
|
||||
]);
|
||||
$packageConfiguration = require($configurationFile);
|
||||
$importMaps[$package->getPackageKey()] = $packageConfiguration ?? [];
|
||||
}
|
||||
|
||||
$isDevelopment = Environment::getContext()->isDevelopment();
|
||||
if ($isDevelopment) {
|
||||
$bust = (string)$GLOBALS['EXEC_TIME'];
|
||||
} else {
|
||||
$bust = $this->hashService->hmac(
|
||||
Environment::getProjectPath() . implode('|', $extensionVersions),
|
||||
self::class
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($importMaps as $packageName => $config) {
|
||||
$importMaps[$packageName]['imports'] = $this->resolvePaths(
|
||||
$config['imports'] ?? [],
|
||||
$this->bustSuffix ? $bust : null
|
||||
);
|
||||
}
|
||||
|
||||
$this->importMaps = $importMaps;
|
||||
if ($this->cache !== null) {
|
||||
$this->cache->set($this->cacheIdentifier, $importMaps);
|
||||
}
|
||||
return $importMaps;
|
||||
}
|
||||
|
||||
protected function resolveRecursiveImportMap(
|
||||
string $prefix,
|
||||
string $pathResourceIdentifier,
|
||||
array $exclude,
|
||||
string $bust
|
||||
): array {
|
||||
// @todo resolve with resource directory object once available
|
||||
$absolutePath = GeneralUtility::getFileAbsFileName($pathResourceIdentifier);
|
||||
if (!$absolutePath || @!is_dir($absolutePath)) {
|
||||
return [];
|
||||
}
|
||||
$exclude = array_map(
|
||||
static fn(string $excludePath): string => GeneralUtility::getFileAbsFileName($excludePath),
|
||||
$exclude
|
||||
);
|
||||
|
||||
$fileIterator = new \RegexIterator(
|
||||
new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($absolutePath)
|
||||
),
|
||||
'#^' . preg_quote($absolutePath, '#') . '(.+\.js)$#',
|
||||
\RegexIterator::GET_MATCH
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($fileIterator as $match) {
|
||||
$fileName = $match[0];
|
||||
$specifier = $prefix . ($match[1] ?? '');
|
||||
$resourceIdentifier = $pathResourceIdentifier . ($match[1] ?? '');
|
||||
|
||||
// @todo: Abstract into an iterator?
|
||||
foreach ($exclude as $excludedPath) {
|
||||
if (str_starts_with($fileName, $excludedPath)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$map[$specifier] = $resourceIdentifier . '?bust=' . $bust;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
protected function resolvePaths(
|
||||
array $imports,
|
||||
?string $bust = null
|
||||
): array {
|
||||
$cacheBustingSpecifiers = [];
|
||||
foreach ($imports as $specifier => $address) {
|
||||
if (is_string($address) && str_starts_with($address, 'VIRTUAL:')) {
|
||||
$imports[$specifier] = $address;
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($specifier, '/')) {
|
||||
$resourceIdentifier = is_array($address) ? ($address['path'] ?? '') : $address;
|
||||
$exclude = is_array($address) ? ($address['exclude'] ?? []) : [];
|
||||
if ($bust !== null) {
|
||||
// Resolve recursive importmap in order to add a bust suffix
|
||||
// to each file.
|
||||
$cacheBustingSpecifiers[] = $this->resolveRecursiveImportMap($specifier, $resourceIdentifier, $exclude, $bust);
|
||||
}
|
||||
} else {
|
||||
$resourceIdentifier = $address;
|
||||
if ($bust !== null) {
|
||||
$resourceIdentifier .= '?bust=' . $bust;
|
||||
}
|
||||
}
|
||||
$imports[$specifier] = $resourceIdentifier;
|
||||
}
|
||||
|
||||
return $imports + array_merge(...$cacheBustingSpecifiers);
|
||||
}
|
||||
|
||||
protected function loadDependency(string $packageName): void
|
||||
{
|
||||
if (isset($this->extensionsToLoad[$packageName])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->extensionsToLoad[$packageName] = true;
|
||||
$dependencies = $this->getImportMaps()[$packageName]['dependencies'] ?? [];
|
||||
foreach ($dependencies as $dependency) {
|
||||
$this->loadDependency($dependency);
|
||||
}
|
||||
}
|
||||
|
||||
protected function composeImportMap(string $uriPrefix): array
|
||||
{
|
||||
$importMaps = $this->getImportMaps();
|
||||
|
||||
if (!isset($this->extensionsToLoad['*'])) {
|
||||
$importMaps = array_intersect_key($importMaps, $this->extensionsToLoad);
|
||||
}
|
||||
|
||||
$importMap = [];
|
||||
foreach ($importMaps as $singleImportMap) {
|
||||
ArrayUtility::mergeRecursiveWithOverrule($importMap, $singleImportMap);
|
||||
}
|
||||
unset($importMap['dependencies']);
|
||||
unset($importMap['tags']);
|
||||
|
||||
foreach ($importMap['imports'] ?? [] as $specifier => $resourceIdentifier) {
|
||||
if (str_starts_with($resourceIdentifier, 'VIRTUAL:')) {
|
||||
$virtualName = substr($resourceIdentifier, 8);
|
||||
$resolved = $this->dispatchResolveVirtualJavaScriptImportEvent($virtualName);
|
||||
if ($resolved === null) {
|
||||
unset($importMap['imports'][$specifier]);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$resolved = $this->getResourceUri($resourceIdentifier, $uriPrefix);
|
||||
}
|
||||
$importMap['imports'][$specifier] = $resolved;
|
||||
}
|
||||
|
||||
return $importMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CanNotResolvePublicResourceException
|
||||
* @throws CanNotResolveSystemResourceException
|
||||
*/
|
||||
protected function getResourceUri(string $resourceIdentifier, $uriPrefix): string
|
||||
{
|
||||
return (string)PathUtility::getSystemResourceUri(
|
||||
$resourceIdentifier,
|
||||
null,
|
||||
new UriGenerationOptions(
|
||||
uriPrefix: $uriPrefix,
|
||||
cacheBusting: false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?non-empty-string
|
||||
*/
|
||||
protected function dispatchResolveJavaScriptImportEvent(
|
||||
string $specifier,
|
||||
bool $loadImportConfiguration = true
|
||||
): ?string {
|
||||
if ($this->eventDispatcher === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->eventDispatcher->dispatch(
|
||||
new ResolveJavaScriptImportEvent($specifier, $loadImportConfiguration, $this)
|
||||
)->resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?non-empty-string
|
||||
*/
|
||||
protected function dispatchResolveVirtualJavaScriptImportEvent(
|
||||
string $specifier,
|
||||
): ?string {
|
||||
if ($this->eventDispatcher === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->eventDispatcher->dispatch(
|
||||
new ResolveVirtualJavaScriptImportEvent($specifier, $this)
|
||||
)->resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function updateState(array $state): void
|
||||
{
|
||||
$this->extensionsToLoad = $state['extensionsToLoad'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function getState(): array
|
||||
{
|
||||
return [
|
||||
'extensionsToLoad' => $this->extensionsToLoad,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user