291 lines
9.7 KiB
PHP
291 lines
9.7 KiB
PHP
<?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 TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
|
|
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
|
|
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;
|
|
|
|
class JavaScriptRenderer
|
|
{
|
|
protected string $handlerResource;
|
|
protected JavaScriptItems $items;
|
|
protected ImportMap $importMap;
|
|
protected int $javaScriptModuleInstructionFlags = 0;
|
|
protected int $instructionsWithItems = 0;
|
|
|
|
/**
|
|
* @internal Only to be used by PageRenderer
|
|
*/
|
|
public static function create(?string $resourceIdentifier = null): self
|
|
{
|
|
$resourceIdentifier ??= 'EXT:core/Resources/Public/JavaScript/java-script-item-handler.js';
|
|
return GeneralUtility::makeInstance(static::class, $resourceIdentifier);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
public function __construct(string $handlerResource)
|
|
{
|
|
$this->handlerResource = $handlerResource;
|
|
$this->items = new JavaScriptItems();
|
|
$this->importMap = GeneralUtility::makeInstance(ImportMapFactory::class)->create();
|
|
}
|
|
|
|
public function addGlobalAssignment(array $payload): void
|
|
{
|
|
$this->items->addGlobalAssignment($payload);
|
|
}
|
|
|
|
public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void
|
|
{
|
|
if ($instruction->shallLoadImportMap()) {
|
|
$this->importMap->includeImportsFor($instruction->getName());
|
|
}
|
|
$this->javaScriptModuleInstructionFlags |= $instruction->getFlags();
|
|
if ($instruction->getItems() !== []) {
|
|
$this->instructionsWithItems++;
|
|
}
|
|
$this->items->addJavaScriptModuleInstruction($instruction);
|
|
}
|
|
|
|
public function hasImportMap(): bool
|
|
{
|
|
return ($this->javaScriptModuleInstructionFlags & JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP) === JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP;
|
|
}
|
|
|
|
/**
|
|
* HEADS UP: Do only use in authenticated mode as this discloses as installed extensions
|
|
*/
|
|
public function includeAllImports(): void
|
|
{
|
|
$this->importMap->includeAllImports();
|
|
}
|
|
|
|
public function includeTaggedImports(string $tag): void
|
|
{
|
|
$this->importMap->includeTaggedImports($tag);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{type: string, payload: mixed}>
|
|
* @internal
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
if ($this->isEmpty()) {
|
|
return [];
|
|
}
|
|
return $this->items->toArray();
|
|
}
|
|
|
|
/**
|
|
* @throws \InvalidArgumentException when a JavaScript module could not be resolved (no src URL in import map)
|
|
*/
|
|
public function render(string|ConsumableNonce|null $nonce, string $uriPrefix): string
|
|
{
|
|
if ($this->isEmpty()) {
|
|
return '';
|
|
}
|
|
|
|
$scriptTags = [];
|
|
|
|
$modules = [];
|
|
$dynamicInstructions = [];
|
|
foreach ($this->items->getJavascriptModuleInstructions() as $instruction) {
|
|
$moduleName = $instruction->getName();
|
|
$url = $this->importMap->resolveImport($moduleName, true, $uriPrefix);
|
|
if ($url === null) {
|
|
throw new \InvalidArgumentException(
|
|
sprintf(
|
|
'JavaScript module "%s" could not be resolved. (Missing entry in Configuration/JavaScriptModules.php?).',
|
|
$moduleName
|
|
),
|
|
1728220800
|
|
);
|
|
}
|
|
if (
|
|
$instruction->getItems() !== []
|
|
|| ($instruction->getFlags() & JavaScriptModuleInstruction::FLAG_USE_TOP_WINDOW) !== 0
|
|
) {
|
|
$dynamicInstructions[] = [
|
|
'type' => 'javaScriptModuleInstruction',
|
|
'payload' => $instruction,
|
|
];
|
|
} else {
|
|
$modules[$moduleName] = $url;
|
|
}
|
|
}
|
|
|
|
$globalAssignments = $this->mergeGlobalAssignments($this->items->getGlobalAssignments());
|
|
if ($globalAssignments !== []) {
|
|
$scriptTags[] = $this->createScriptElement(
|
|
['nonce' => $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : (string)$nonce],
|
|
sprintf('Object.assign(globalThis, %s)', $this->jsonEncode($globalAssignments))
|
|
);
|
|
}
|
|
$scriptTags = [
|
|
...$scriptTags,
|
|
...array_map(
|
|
fn(string $url): string => $this->createScriptElement([
|
|
'type' => 'module',
|
|
'async' => 'async',
|
|
'src' => $url,
|
|
]),
|
|
$modules
|
|
),
|
|
];
|
|
|
|
if ($dynamicInstructions !== []) {
|
|
$scriptTags[] = $this->createItemHandlerElement($dynamicInstructions, true, $nonce, $uriPrefix);
|
|
}
|
|
|
|
return implode(PHP_EOL, $scriptTags);
|
|
}
|
|
|
|
public function renderImportMap(string $uriPrefix, string|ConsumableNonce|null $nonce = null): string
|
|
{
|
|
if (!$this->isEmpty() && ($this->instructionsWithItems > 0 || $this->items->getGlobalAssignments() !== [])) {
|
|
$this->importMap->includeImportsFor('@typo3/core/java-script-item-handler.js');
|
|
}
|
|
return $this->importMap->render($uriPrefix, $nonce);
|
|
}
|
|
|
|
protected function isEmpty(): bool
|
|
{
|
|
return $this->items->isEmpty();
|
|
}
|
|
|
|
protected function createItemHandlerElement(array $payload, bool $async, string|ConsumableNonce|null $nonce, string $uriPrefix): string
|
|
{
|
|
// actual JSON payload is stored as comment in `script.textContent`
|
|
// and consumed by java-script-item-handler.js
|
|
return $this->createScriptElement(
|
|
[
|
|
'src' => PathUtility::getSystemResourceUri(
|
|
$this->handlerResource,
|
|
null,
|
|
new UriGenerationOptions(
|
|
uriPrefix: $uriPrefix,
|
|
cacheBusting: false,
|
|
)
|
|
),
|
|
'nonce' => (string)$nonce,
|
|
'async' => $async ? 'async' : '',
|
|
],
|
|
'/* ' . $this->jsonEncode($payload) . ' */'
|
|
);
|
|
}
|
|
|
|
protected function createScriptElement(array $attributes, string $textContent = ''): string
|
|
{
|
|
if (empty($attributes)) {
|
|
return '';
|
|
}
|
|
$attributesPart = GeneralUtility::implodeAttributes($attributes, true);
|
|
return sprintf('<script%s%s>%s</script>', $attributesPart ? ' ' : '', $attributesPart, $textContent);
|
|
}
|
|
|
|
protected function jsonEncode($value): string
|
|
{
|
|
return (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG);
|
|
}
|
|
|
|
protected function mergeGlobalAssignments(array $assignments): array
|
|
{
|
|
$globalAssignments = [];
|
|
foreach ($assignments as $assignment) {
|
|
// Merge `window` one level up, as we target `globalThis` which is window.
|
|
// Needed because we assign to globalThis and must not overwrite window entirely,
|
|
// but only merge to it and because we want to forbid nested assignments like
|
|
// `window.parent.foo` below.
|
|
if (isset($assignment['window'])) {
|
|
$assignment = [
|
|
...$assignment,
|
|
...$assignment['window'],
|
|
];
|
|
unset($assignment['window']);
|
|
}
|
|
$globalAssignments = array_merge_recursive($globalAssignments, $assignment);
|
|
}
|
|
|
|
// deny indirect global assignments (not for security reasons, but for reducing
|
|
// the chance of hard-to-debug side-effects)
|
|
unset($globalAssignments['window']);
|
|
unset($globalAssignments['parent']);
|
|
unset($globalAssignments['globalThis']);
|
|
unset($globalAssignments['document']);
|
|
|
|
// filter potential prototype pollution side-effects
|
|
return ArrayUtility::filterRecursive(
|
|
$globalAssignments,
|
|
static fn(string $key): bool => match ($key) {
|
|
'__proto__', 'prototype', 'constructor' => false,
|
|
default => true,
|
|
},
|
|
ARRAY_FILTER_USE_KEY
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
public function updateState(array $state): void
|
|
{
|
|
foreach ($state as $var => $value) {
|
|
switch ($var) {
|
|
case 'items':
|
|
$this->items->updateState($value);
|
|
break;
|
|
case 'importMap':
|
|
$this->importMap->updateState($value);
|
|
break;
|
|
default:
|
|
$this->{$var} = $value;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
public function getState(): array
|
|
{
|
|
$state = [];
|
|
foreach (get_object_vars($this) as $var => $value) {
|
|
switch ($var) {
|
|
case 'items':
|
|
$state[$var] = $this->items->getState();
|
|
break;
|
|
case 'importMap':
|
|
$state[$var] = $this->importMap->getState();
|
|
break;
|
|
default:
|
|
$state[$var] = $value;
|
|
break;
|
|
}
|
|
}
|
|
return $state;
|
|
}
|
|
}
|