Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions lib/private/Template/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,22 @@ function emit_css_loading_tags($obj): void {
* @param string $src the source URL, ignored when empty
* @param string $script_content the inline script content, ignored when empty
* @param string $content_type the type of the source (e.g. 'module')
*
* @since 27.0.0 added the $content_type parameter
*/
function emit_script_tag(string $src, string $script_content = '', string $content_type = ''): void {
$nonceManager = Server::get(ContentSecurityPolicyNonceManager::class);

$defer_str = ' defer';
$defer_str = $content_type === '' ? ' defer' : ''; // "defer" only works with classic scripts
$type = $content_type !== '' ? ' type="' . $content_type . '"' : '';

$s = '<script nonce="' . $nonceManager->getNonce() . '"';
$s = '<script nonce="' . $nonceManager->getNonce() . '"' . $type;
if (!empty($src)) {
// emit script tag for deferred loading from $src
$s .= $defer_str . ' src="' . $src . '"' . $type . '>';
} elseif ($script_content !== '') {
$s .= $defer_str . ' src="' . $src . '">';
} else {
// emit script tag for inline script from $script_content without defer (see MDN)
$s .= ">\n" . $script_content . "\n";
} else {
// no $src nor $src_content, really useless empty tag
$s .= '>';
}
$s .= '</script>';
print_unescaped($s . "\n");
Expand All @@ -81,6 +80,8 @@ function emit_script_tag(string $src, string $script_content = '', string $conte
* @param array $obj all the script information from template
*/
function emit_script_loading_tags($obj): void {
emit_import_map($obj);

foreach ($obj['jsfiles'] as $jsfile) {
$fileName = explode('?', $jsfile, 2)[0];
$type = str_ends_with($fileName, '.mjs') ? 'module' : '';
Expand All @@ -91,6 +92,29 @@ function emit_script_loading_tags($obj): void {
}
}

/**
* Print the import map for the current JS modules.
* The import map is needed to ensure that an import of an entry point does not duplicate the state,
* but reuses the already loaded module. This is needed because Nextcloud will append a cache buster
* to the entry point URLs but the scripts does not know about that (both must match).
*
* @param $obj all the script information from template
*/
function emit_import_map(array $obj): void {
$modules = [];
foreach ($obj['jsfiles'] as $jsfile) {
$fileName = explode('?', $jsfile, 2)[0];
if (str_ends_with($fileName, '.mjs') && $jsfile !== $fileName) {
// its a module and we have a cache buster available
$modules[$fileName] = $jsfile;
}
}
if (!empty($modules)) {
$json = json_encode(['imports' => $modules], JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT);
emit_script_tag('', $json, 'importmap');
}
}

/**
* Prints an unsanitized string - usage of this function may result into XSS.
* Consider using p() instead.
Expand Down
27 changes: 24 additions & 3 deletions tests/lib/TemplateFunctionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,34 @@ public function testEmitScriptTagWithSource(): void {
}

public function testEmitScriptTagWithModuleSource(): void {
$this->expectOutputRegex('/<script nonce=".*" defer src="some.mjs" type="module"><\/script>/');
$this->expectOutputRegex('/<script nonce=".*" type="module" src="some.mjs"><\/script>/');
emit_script_tag('some.mjs', '', 'module');
}

public function testEmitImportMap(): void {
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/file\.mjs":"\/some\/path\/file\.mjs\?v=123"}}\n<\/script>$/m');
emit_import_map(['jsfiles' => ['/some/path/file.mjs?v=123']]);
}

// only create import map for modules with versioning
public function testEmitImportMapMixedScripts(): void {
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/module\.mjs":"\/some\/path\/module\.mjs\?v=123"}}\n<\/script>$/m');
emit_import_map(['jsfiles' => ['/some/path/module.mjs?v=123', '/some/path/classic.js?v=123']]);
}

public function testEmitImportMapNoOutputWithoutVersion(): void {
$this->expectOutputString('');
emit_import_map(['jsfiles' => ['some.mjs']]);
}

public function testEmitImportMapNoOutputWithClassicScript(): void {
$this->expectOutputString('');
emit_import_map(['jsfiles' => ['some.js?v=123']]);
}

public function testEmitScriptLoadingTags(): void {
// Test mjs js and inline content
$pattern = '/src="some\.mjs"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
$pattern = '/type="module"[^>]+src="some\.mjs"[^>]*>.+\n'; // some.mjs with type = module
$pattern .= '<script[^>]+src="other\.js"[^>]*>.+\n'; // other.js as plain javascript
$pattern .= '<script[^>]*>\n?.*inline.*\n?<\/script>'; // inline content
$pattern .= '/'; // no flags
Expand All @@ -74,7 +95,7 @@ public function testEmitScriptLoadingTags(): void {

public function testEmitScriptLoadingTagsWithVersion(): void {
// Test mjs js and inline content
$pattern = '/src="some\.mjs\?v=ab123cd"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
$pattern = '/type="module"[^>]+src="some\.mjs\?v=ab123cd"[^>]*>.+\n'; // some.mjs with type = module
$pattern .= '<script[^>]+src="other\.js\?v=12abc34"[^>]*>.+\n'; // other.js as plain javascript
$pattern .= '/'; // no flags

Expand Down
Loading