Merge branch 'MDL-87922-main' of https://github.com/meirzamoodle/moodle
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace core\output\requirements;
|
||||
|
||||
/**
|
||||
* The import map requirement class, which defines the import map for ES module loading.
|
||||
*
|
||||
* This class is responsible for defining the import map that will be used by the ES module loader to
|
||||
* resolve module specifiers to URLs.
|
||||
*
|
||||
* A default loader URL should be set for the import map, which will be used for any specifiers
|
||||
* that do not have a specific loader defined.
|
||||
*
|
||||
* The import map can be extended by adding additional imports with specific loaders, or overriding
|
||||
* the standard loaders, during a pre_render hook.
|
||||
*
|
||||
* The import map will be serialized to JSON and included in the page output as a script tag with type "importmap".
|
||||
*
|
||||
* The class should be fetched using the dependency injection container, and the default loader URL
|
||||
* should be set before the page is rendered.
|
||||
*
|
||||
* @package core
|
||||
* @copyright Andrew Lyons <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class import_map implements \JsonSerializable {
|
||||
/** @var array The list of imports */
|
||||
protected array $imports = [];
|
||||
|
||||
/**
|
||||
* @var bool Whether $imports has been sorted longest-key-first for prefix matching.
|
||||
*
|
||||
* The flag is reset to false whenever add_import() is called so the sort is re-applied
|
||||
* if new entries are registered after the first resolution.
|
||||
*/
|
||||
private bool $importssorted = false;
|
||||
|
||||
/** @var \core\url The default loader URL to use */
|
||||
protected \core\url $loader;
|
||||
|
||||
/**
|
||||
* Initialise the import_map requirement by setting the standard import list.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->add_standard_imports();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the content for json encoding.
|
||||
*
|
||||
* @return array[]|array{imports: array}
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
$importmap = [
|
||||
'imports' => [],
|
||||
];
|
||||
|
||||
if (!isset($this->loader)) {
|
||||
throw new \core\exception\coding_exception('Default loader URL must be set before serializing the import map.');
|
||||
}
|
||||
|
||||
foreach ($this->imports as $specifier => $importdata) {
|
||||
$loader = $importdata->loader instanceof \core\url
|
||||
? $importdata->loader
|
||||
: new \core\url($this->loader->out(false) . $specifier);
|
||||
$importmap['imports'][$specifier] = $loader->out(false);
|
||||
}
|
||||
|
||||
return $importmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default loader URL.
|
||||
*
|
||||
* @param \core\url $loader The default loader URL.
|
||||
*/
|
||||
public function set_default_loader(\core\url $loader): void {
|
||||
$this->loader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the standard entries to the importmap.
|
||||
* @return void
|
||||
*/
|
||||
protected function add_standard_imports(): void {
|
||||
$this->add_import('@moodle/lms/', path: 'js/esm/build', loadfromcomponent: true);
|
||||
$this->add_import('@moodlehq/design-system', path: 'lib/js/bundles/design-system');
|
||||
$this->add_import('react', path: 'lib/js/bundles/react/react');
|
||||
$this->add_import('react/', path: 'lib/js/bundles/react/');
|
||||
$this->add_import('react-dom', path: 'lib/js/bundles/react-dom/react-dom');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a specifier in the import map.
|
||||
*
|
||||
* @param string $specifier The bare specifier used in import statements (e.g. `react`, `@moodle/lms/`).
|
||||
* @param \core\url|null $loader Absolute URL written verbatim into the import map.
|
||||
* When provided, $path is ignored for URL generation.
|
||||
* @param string|null $path Filesystem path relative to $CFG->root, used by the ESM controller
|
||||
* to locate the file on disk. Has no effect on the URL in the import map.
|
||||
* @param bool $loadfromcomponent When true, the specifier is treated as a `<component>/<module>`
|
||||
* prefix and resolved to the component's `js/esm/build/` directory. Used internally for `@moodle/lms/`.
|
||||
*/
|
||||
public function add_import(
|
||||
string $specifier,
|
||||
?\core\url $loader = null,
|
||||
?string $path = null,
|
||||
bool $loadfromcomponent = false,
|
||||
): void {
|
||||
$this->imports[$specifier] = (object) [
|
||||
'loader' => $loader,
|
||||
'path' => $path,
|
||||
'loadfromcomponent' => $loadfromcomponent,
|
||||
];
|
||||
$this->importssorted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare specifier path to an absolute filesystem path.
|
||||
*
|
||||
* Entries are matched longest-key-first so a more-specific prefix always wins
|
||||
* (e.g. `react/` is matched before `react`). Returns null if no entry matches.
|
||||
*
|
||||
* @param string $requestedpath The bare specifier path (e.g. `react`, `@moodle/lms/mod_book/viewer`).
|
||||
* @return string|null Absolute filesystem path to the JS file, or null if unresolved.
|
||||
*/
|
||||
public function get_path_for_script(string $requestedpath): ?string {
|
||||
global $CFG;
|
||||
|
||||
// Sort longest-key-first once so a more-specific prefix always wins over a shorter one.
|
||||
if (!$this->importssorted) {
|
||||
uksort($this->imports, fn ($a, $b) => strlen($b) <=> strlen($a));
|
||||
$this->importssorted = true;
|
||||
}
|
||||
|
||||
foreach ($this->imports as $specifier => $importdata) {
|
||||
if (!str_starts_with($requestedpath, $specifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($importdata->loader !== null) {
|
||||
throw new \core\exception\coding_exception(
|
||||
'Import map entries with explicit loaders cannot be resolved to filesystem paths.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($importdata->loadfromcomponent) {
|
||||
$subpath = substr($requestedpath, strlen($specifier));
|
||||
return $this->resolve_module_identifier($importdata, $subpath);
|
||||
}
|
||||
|
||||
$pathremainder = substr($requestedpath, strlen($specifier));
|
||||
// Reject '..' as a path segment to prevent directory traversal. A single dot in a
|
||||
// filename (e.g. 'button.small') is allowed because it is not a segment on its own.
|
||||
if (in_array('..', explode('/', $pathremainder), true)) {
|
||||
return null;
|
||||
}
|
||||
return implode(DIRECTORY_SEPARATOR, array_filter([$CFG->root, $importdata->path, $pathremainder])) . '.js';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `<component>/<module>` subpath to an absolute filesystem path.
|
||||
*
|
||||
* For example, `mod_book/viewer` resolves to
|
||||
* `<dirroot>/mod/book/js/esm/build/viewer.js`.
|
||||
*
|
||||
* @param object $importdata The import entry containing the path and loadfromcomponent flag.
|
||||
* @param string $subpath The subpath after the specifier prefix (e.g. `mod_book/viewer`).
|
||||
* @return string Absolute path to the JS file.
|
||||
* @throws \core\exception\not_found_exception If the subpath is missing a slash, contains `..`,
|
||||
* the component is unknown, or the resolved file does not exist.
|
||||
*/
|
||||
protected function resolve_module_identifier(object $importdata, string $subpath): string {
|
||||
if (!str_contains($subpath, '/')) {
|
||||
throw new \core\exception\not_found_exception('component', $subpath);
|
||||
}
|
||||
|
||||
[$component, $modulerest] = explode('/', $subpath, 2);
|
||||
|
||||
// Reject '..' as a path segment to prevent directory traversal. A single dot in a
|
||||
// filename (e.g. 'button.small') is allowed because it is not a segment on its own.
|
||||
if (in_array('..', explode('/', $modulerest), true)) {
|
||||
throw new \core\exception\not_found_exception('script', $subpath);
|
||||
}
|
||||
|
||||
// Resolve the component directory; an unknown component name returns null.
|
||||
$dir = \core\component::get_component_directory($component);
|
||||
$file = "{$dir}/{$importdata->path}/{$modulerest}.js";
|
||||
if (!file_exists($file)) {
|
||||
throw new \core\exception\not_found_exception('script', $subpath);
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
@@ -1040,6 +1040,33 @@ class page_requirements_manager {
|
||||
$this->skiplinks[$target] = $linktext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the import map script tag for React platform files.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_import_map(): string {
|
||||
$importmap = \core\di::get(import_map::class);
|
||||
$importmap->set_default_loader(
|
||||
\core\router\util::get_path_for_callable(
|
||||
[\core\route\controller\esm_controller::class, 'serve'],
|
||||
[
|
||||
'revision' => $this->get_jsrev(),
|
||||
'scriptpath' => '',
|
||||
]
|
||||
),
|
||||
);
|
||||
|
||||
return html_writer::tag(
|
||||
'script',
|
||||
json_encode(
|
||||
$importmap,
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
['type' => 'importmap'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* !!!DEPRECATED!!! please use js_init_call() if possible
|
||||
* Ensure that the specified JavaScript function is called from an inline script
|
||||
@@ -1703,6 +1730,9 @@ EOF;
|
||||
$output .= html_writer::script($js);
|
||||
}
|
||||
|
||||
// Inject the ES module import map for bare specifier resolution.
|
||||
$output .= $this->get_import_map();
|
||||
|
||||
// Mark head sending done, it is not possible to anything there.
|
||||
$this->headdone = true;
|
||||
|
||||
|
||||
@@ -343,6 +343,16 @@ enum param: string {
|
||||
#[param_clientside_regex('^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]+$')]
|
||||
case PLUGIN = 'plugin';
|
||||
|
||||
|
||||
/**
|
||||
* PARAM_ESM_PATH is used for ES module script paths as they arrive at the ESM controller,
|
||||
* such as 'react', '@moodle/lms/mod_forum/index', 'react/jsx-runtime', or '@moodlehq/design-system/button.small'.
|
||||
* Accepts lowercase letters, numbers, hyphens, underscores, dots, forward slashes, and a leading @.
|
||||
* The path must start with a letter or @. Directory traversal via '..' is rejected by the ESM controller.
|
||||
*/
|
||||
#[param_clientside_regex('^[@a-z][a-z0-9_.-]*(/[a-z0-9_./-]+)?$')]
|
||||
case ESM_PATH = 'esm_path';
|
||||
|
||||
/**
|
||||
* Get the canonical enumerated parameter from the parameter type name.
|
||||
*
|
||||
@@ -1336,6 +1346,30 @@ enum param: string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean an ESM script path value.
|
||||
*
|
||||
* Accepts only paths that match the ESM_PATH regex (lowercase letters, digits,
|
||||
* hyphens, underscores, dots, and forward slashes, starting with a letter or @).
|
||||
* Returns an empty string if the value does not match.
|
||||
*
|
||||
* Note: directory traversal via '..' segments is enforced by the ESM controller,
|
||||
* not by this method.
|
||||
*
|
||||
* @param mixed $param The raw parameter value to clean.
|
||||
* @return string The cleaned path, or an empty string if invalid.
|
||||
*/
|
||||
protected function clean_param_value_esm_path(mixed $param): string {
|
||||
// ESM paths must be relative and contain only safe characters.
|
||||
$param = (string)fix_utf8($param);
|
||||
$regex = $this->get_clientside_expression();
|
||||
if (preg_match("~{$regex}~", $param)) {
|
||||
return $param;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parameter is deprecated.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace core\route\controller;
|
||||
|
||||
use core\router\schema\parameters\path_parameter;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Controller for serving ES module files.
|
||||
*
|
||||
* Resolves all ESM requests under /esm/{revision}/{scriptpath} by delegating to the
|
||||
* import_map registry, which is the single source of truth for specifier → file mappings.
|
||||
*
|
||||
* @package core
|
||||
* @copyright 2026 Andrew Lyons <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class esm_controller {
|
||||
use \core\router\route_controller;
|
||||
|
||||
#[\core\router\route(
|
||||
title: 'Serve ESM Content',
|
||||
path: '/esm/{revision:[0-9-]+}/{scriptpath:.*}',
|
||||
pathtypes: [
|
||||
new path_parameter(
|
||||
name: 'revision',
|
||||
description: 'The revision number of the script to serve.',
|
||||
type: \core\param::INT,
|
||||
),
|
||||
new path_parameter(
|
||||
name: 'scriptpath',
|
||||
description: 'The path to the script to serve.',
|
||||
type: \core\param::ESM_PATH,
|
||||
),
|
||||
],
|
||||
method: ['GET'],
|
||||
abortafterconfig: true,
|
||||
)]
|
||||
/**
|
||||
* Serve an ES module file by resolving the specifier via the import map.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param int $revision
|
||||
* @param string $scriptpath
|
||||
*/
|
||||
public function serve(
|
||||
ServerRequestInterface $request,
|
||||
ResponseInterface $response,
|
||||
int $revision,
|
||||
string $scriptpath,
|
||||
): ResponseInterface {
|
||||
// Normalise the revision: an outdated or invalid value disables long-term caching
|
||||
// so browsers always re-fetch rather than serving a stale file.
|
||||
if (!min_is_revision_valid_and_current($revision)) {
|
||||
$revision = -1;
|
||||
}
|
||||
|
||||
$importmap = \core\di::get(\core\output\requirements\import_map::class);
|
||||
$fullpath = $importmap->get_path_for_script($scriptpath);
|
||||
if ($fullpath !== null && file_exists($fullpath)) {
|
||||
return $this->serve_script($request, $response, $revision, $fullpath, basename($fullpath));
|
||||
}
|
||||
throw new \core\exception\not_found_exception('script', $scriptpath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the file content into the response with appropriate cache headers.
|
||||
*
|
||||
* When $revision is -1 (invalid/development), short-lived cache headers are used.
|
||||
* Otherwise, immutable long-lived cache headers are set with an ETag, and a
|
||||
* 304 Not Modified response is returned if the client already has the file cached.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param int $revision The JS revision number; -1 disables long-term caching.
|
||||
* @param string $file Absolute filesystem path to the JS file.
|
||||
* @param string $presentedfilename Filename to use in Content-Disposition.
|
||||
*/
|
||||
protected function serve_script(
|
||||
ServerRequestInterface $request,
|
||||
ResponseInterface $response,
|
||||
int $revision,
|
||||
string $file,
|
||||
string $presentedfilename,
|
||||
): ResponseInterface {
|
||||
$now = \core\di::get(\core\clock::class)->time();
|
||||
|
||||
if ($revision === -1) {
|
||||
$response = $response
|
||||
->withHeader('Content-Type', 'application/javascript; charset=utf-8')
|
||||
->withHeader('Content-Disposition', "inline; filename=\"{$presentedfilename}\"")
|
||||
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s', $now) . ' GMT')
|
||||
->withHeader('Expires', gmdate('D, d M Y H:i:s', $now + 2) . ' GMT')
|
||||
->withHeader('Pragma', '')
|
||||
->withHeader('Accept-Ranges', 'none');
|
||||
} else {
|
||||
$etag = sha1($revision . ':' . $file);
|
||||
|
||||
if ($request->hasHeader('If-None-Match') && in_array($etag, $request->getHeader('If-None-Match'))) {
|
||||
return $response->withStatus(304);
|
||||
}
|
||||
|
||||
$response = $response
|
||||
->withHeader('Content-Type', 'application/javascript; charset=utf-8')
|
||||
->withHeader('ETag', $etag)
|
||||
->withHeader('Content-Disposition', 'inline; filename="' . basename($file) . '"')
|
||||
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s', filemtime($file)) . ' GMT')
|
||||
->withHeader('Expires', gmdate('D, d M Y H:i:s', $now + 31536000) . ' GMT')
|
||||
->withHeader('Pragma', '')
|
||||
->withHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
->withHeader('Accept-Ranges', 'none');
|
||||
}
|
||||
|
||||
$response->getBody()->write(file_get_contents($file));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -58,53 +58,55 @@ class moodle_bootstrap_middleware implements MiddlewareInterface {
|
||||
$this->load_full_moodle();
|
||||
}
|
||||
|
||||
// Set the URL for the page.
|
||||
// Normally in Moodle this is a largely hard-coded value with only the query string changing dynamically in the page.
|
||||
// However, in this instance, we are generating the URL dynamically because we are the request terminator
|
||||
// for a large number of requests at different endpoints.
|
||||
if ($PAGE) {
|
||||
// Set the URL for the page.
|
||||
// Normally in Moodle this is a largely hard-coded value with only the query string changing dynamically in the page.
|
||||
// However, in this instance, we are generating the URL dynamically because we are the request terminator
|
||||
// for a large number of requests at different endpoints.
|
||||
|
||||
// In basic cases this will just work, but there are some edge cases to consider - specficially were the site
|
||||
// is behind a reverse proxy and/or an SSL terminator.
|
||||
// In these cases the URL we generate from the ServerRequestInterface may be the _terminated_ URL,
|
||||
// and not the URL that was requested by the client.
|
||||
// In basic cases this will just work, but there are some edge cases to consider - specficially were the site
|
||||
// is behind a reverse proxy and/or an SSL terminator.
|
||||
// In these cases the URL we generate from the ServerRequestInterface may be the _terminated_ URL,
|
||||
// and not the URL that was requested by the client.
|
||||
|
||||
// We need to generate the URL that the client requested, not the URL that the server received.
|
||||
$url = $request->getUri();
|
||||
// We need to generate the URL that the client requested, not the URL that the server received.
|
||||
$url = $request->getUri();
|
||||
|
||||
if (!empty($CFG->reverseproxy)) {
|
||||
// This site is behind a reverse proxy. The requested URI may have a different:
|
||||
// - scheme
|
||||
// - host
|
||||
// - port
|
||||
// to the URL that the client requested.
|
||||
if (!empty($CFG->reverseproxy)) {
|
||||
// This site is behind a reverse proxy. The requested URI may have a different:
|
||||
// - scheme
|
||||
// - host
|
||||
// - port
|
||||
// to the URL that the client requested.
|
||||
|
||||
$url = $url
|
||||
// Start by setting the scheme and host to the wwwroot.
|
||||
->withScheme(parse_url($CFG->wwwroot, PHP_URL_SCHEME))
|
||||
->withHost(parse_url($CFG->wwwroot, PHP_URL_HOST))
|
||||
$url = $url
|
||||
// Start by setting the scheme and host to the wwwroot.
|
||||
->withScheme(parse_url($CFG->wwwroot, PHP_URL_SCHEME))
|
||||
->withHost(parse_url($CFG->wwwroot, PHP_URL_HOST))
|
||||
|
||||
// Update the URL to match the port of the wwwroot.
|
||||
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
|
||||
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
|
||||
// Update the URL to match the port of the wwwroot.
|
||||
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
|
||||
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
|
||||
|
||||
}
|
||||
|
||||
if (!empty($CFG->sslproxy)) {
|
||||
// This site is behind an ssl terminating proxy. The requested URI may have a different:
|
||||
// - scheme
|
||||
// - port
|
||||
// to the URL that the client requested.
|
||||
$url = $url
|
||||
// The wwwroot must use the https scheme, but the terminating request may have been received using http.
|
||||
->withScheme('https')
|
||||
|
||||
// Update the URL to match the port of the wwwroot.
|
||||
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
|
||||
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
|
||||
}
|
||||
|
||||
$PAGE->set_url((string) $url);
|
||||
}
|
||||
|
||||
if (!empty($CFG->sslproxy)) {
|
||||
// This site is behind an ssl terminating proxy. The requested URI may have a different:
|
||||
// - scheme
|
||||
// - port
|
||||
// to the URL that the client requested.
|
||||
$url = $url
|
||||
// The wwwroot must use the https scheme, but the terminating request may have been received using http.
|
||||
->withScheme('https')
|
||||
|
||||
// Update the URL to match the port of the wwwroot.
|
||||
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
|
||||
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
|
||||
}
|
||||
|
||||
$PAGE->set_url((string) $url);
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace core\output\requirements;
|
||||
|
||||
/**
|
||||
* Tests for the ESM import map class.
|
||||
*
|
||||
* @package core
|
||||
* @category test
|
||||
* @copyright 2026 Meirza <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\CoversClass(import_map::class)]
|
||||
final class import_map_test extends \advanced_testcase {
|
||||
/**
|
||||
* The constructor pre-populates the standard ESM specifiers.
|
||||
*/
|
||||
public function test_constructor_adds_standard_imports(): void {
|
||||
$map = new import_map();
|
||||
$map->set_default_loader(new \core\url('https://example.com/'));
|
||||
|
||||
$data = $map->jsonSerialize();
|
||||
|
||||
$this->assertArrayHasKey('@moodle/lms/', $data['imports']);
|
||||
$this->assertArrayHasKey('react', $data['imports']);
|
||||
$this->assertArrayHasKey('react/', $data['imports']);
|
||||
}
|
||||
|
||||
/**
|
||||
* jsonSerialize() throws a coding_exception when no default loader has been set.
|
||||
*/
|
||||
public function test_jsonserialize_throws_without_loader(): void {
|
||||
$this->expectException(\core\exception\coding_exception::class);
|
||||
(new import_map())->jsonSerialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* jsonSerialize() returns an array with an 'imports' key.
|
||||
*/
|
||||
public function test_jsonserialize_returns_imports_structure(): void {
|
||||
$map = new import_map();
|
||||
$map->set_default_loader(new \core\url('https://example.com/'));
|
||||
|
||||
$data = $map->jsonSerialize();
|
||||
|
||||
$this->assertIsArray($data);
|
||||
$this->assertArrayHasKey('imports', $data);
|
||||
$this->assertIsArray($data['imports']);
|
||||
}
|
||||
|
||||
/**
|
||||
* set_default_loader() is used as the base URL when no explicit loader or path is given.
|
||||
*/
|
||||
public function test_set_default_loader_is_used_as_base_url(): void {
|
||||
$map = new import_map();
|
||||
$map->set_default_loader(new \core\url('https://example.com/esm/12345/'));
|
||||
$map->add_import('my-module');
|
||||
|
||||
$data = $map->jsonSerialize();
|
||||
|
||||
$this->assertEquals('https://example.com/esm/12345/my-module', $data['imports']['my-module']);
|
||||
}
|
||||
|
||||
/**
|
||||
* add_import() with an explicit \core\url uses that URL verbatim, ignoring the default loader.
|
||||
*/
|
||||
public function test_add_import_with_explicit_url(): void {
|
||||
$map = new import_map();
|
||||
$map->set_default_loader(new \core\url('https://example.com/'));
|
||||
$map->add_import('my-module', loader: new \core\url('https://cdn.example.com/my-module.js'));
|
||||
|
||||
$data = $map->jsonSerialize();
|
||||
|
||||
$this->assertEquals('https://cdn.example.com/my-module.js', $data['imports']['my-module']);
|
||||
}
|
||||
|
||||
/**
|
||||
* add_import() with no $path and no explicit $loader appends the specifier to the loader URL.
|
||||
*/
|
||||
public function test_add_import_without_path_uses_specifier(): void {
|
||||
$map = new import_map();
|
||||
$map->set_default_loader(new \core\url('https://example.com/esm/12345/'));
|
||||
$map->add_import('some/specifier');
|
||||
|
||||
$data = $map->jsonSerialize();
|
||||
|
||||
$this->assertEquals('https://example.com/esm/12345/some/specifier', $data['imports']['some/specifier']);
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,16 @@ namespace core;
|
||||
* @package core
|
||||
* @copyright Andrew Lyons <[email protected]>
|
||||
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \core\param
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\CoversClass(param::class)]
|
||||
final class param_test extends \advanced_testcase {
|
||||
/**
|
||||
* Test that the Moodle `from_type` method provides canonicalised parameter values.
|
||||
*
|
||||
* @dataProvider valid_param_provider
|
||||
* @param string $type
|
||||
* @param param $expected
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('valid_param_provider')]
|
||||
public function test_from_type(string $type, param $expected): void {
|
||||
$this->assertEquals($expected, param::from_type($type));
|
||||
}
|
||||
@@ -71,10 +71,10 @@ final class param_test extends \advanced_testcase {
|
||||
/**
|
||||
* Test that deprecated parameters are marked as such.
|
||||
*
|
||||
* @dataProvider is_deprecated_provider
|
||||
* @param param $param
|
||||
* @param bool $expected
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('is_deprecated_provider')]
|
||||
public function test_is_deprecated(param $param, bool $expected): void {
|
||||
$this->assertEquals(
|
||||
$expected,
|
||||
@@ -105,9 +105,9 @@ final class param_test extends \advanced_testcase {
|
||||
/**
|
||||
* Test that finally deprecated params throw an exception when cleaning.
|
||||
*
|
||||
* @dataProvider deprecated_param_provider
|
||||
* @param param $params
|
||||
* @param param $param
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('deprecated_param_provider')]
|
||||
public function test_deprecated_params_except(param $param): void {
|
||||
$this->expectException(\coding_exception::class);
|
||||
$param->clean('foo');
|
||||
@@ -132,4 +132,65 @@ final class param_test extends \advanced_testcase {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that valid parameters clean correctly (i.e. return the input value).
|
||||
*
|
||||
* @param string $input
|
||||
* @param \core\param $param
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('valid_param_clean_provider')]
|
||||
public function test_param_clean(
|
||||
string $input,
|
||||
\core\param $param,
|
||||
): void {
|
||||
$cleaned = $param->clean($input);
|
||||
$this->assertEquals($input, $cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for the valid provider for param::clean.
|
||||
*
|
||||
* @return array<param|string>[]
|
||||
*/
|
||||
public static function valid_param_clean_provider(): array {
|
||||
return [
|
||||
['1', param::INT],
|
||||
['1.5', param::FLOAT],
|
||||
['foo', param::ESM_PATH],
|
||||
['foo/bar', param::ESM_PATH],
|
||||
['foo/bar/baz', param::ESM_PATH],
|
||||
['@moodle/lms/example', param::ESM_PATH],
|
||||
['react/jsx-runtime', param::ESM_PATH],
|
||||
['button.small', param::ESM_PATH],
|
||||
['@moodlehq/design-system/button.small', param::ESM_PATH],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that valid parameters clean correctly (i.e. return the input value).
|
||||
*
|
||||
* @param string $input
|
||||
* @param \core\param $param
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('valid_param_clean_invalid_provider')]
|
||||
public function test_param_clean_invalid(
|
||||
string $input,
|
||||
\core\param $param,
|
||||
): void {
|
||||
$cleaned = $param->clean($input);
|
||||
$this->assertEquals('', $cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for the invalid provider for param::clean.
|
||||
*
|
||||
* @return array<param|string>[]
|
||||
*/
|
||||
public static function valid_param_clean_invalid_provider(): array {
|
||||
return [
|
||||
['foo@', param::ESM_PATH],
|
||||
['@moodle/lms/@example', param::ESM_PATH],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace core\route\controller;
|
||||
|
||||
use core\tests\router\route_testcase;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
|
||||
/**
|
||||
* Tests for the ESM controller shim route.
|
||||
*
|
||||
* @package core
|
||||
* @category test
|
||||
* @copyright 2026 Meirza <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\CoversClass(esm_controller::class)]
|
||||
final class esm_controller_test extends route_testcase {
|
||||
#[\PHPUnit\Framework\Attributes\After]
|
||||
public function reset(): void {
|
||||
\core\di::reset_container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a stub import_map into the DI container that always returns a given fixture file,
|
||||
* then return a plain esm_controller that will use it.
|
||||
*
|
||||
* @return esm_controller
|
||||
*/
|
||||
private function make_test_controller(?string $filename = null): esm_controller {
|
||||
if ($filename === null) {
|
||||
$filename = make_request_directory() . '/test.js';
|
||||
}
|
||||
file_put_contents($filename, 'export default {};');
|
||||
|
||||
\core\di::set(
|
||||
\core\output\requirements\import_map::class,
|
||||
new class ($filename) extends \core\output\requirements\import_map {
|
||||
// phpcs:ignore
|
||||
public function __construct(private readonly string $fixture) {}
|
||||
// phpcs:ignore
|
||||
public function get_path_for_script(string $requestedpath): ?string {
|
||||
return $this->fixture;
|
||||
}
|
||||
},
|
||||
);
|
||||
return new esm_controller();
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for serve() not-found cases.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function serve_not_found_provider(): array {
|
||||
return [
|
||||
'unmatched specifier' => ['some-unknown-lib'],
|
||||
'matched prefix, missing module file' => ['@moodle/lms/core/nonexistentmodule'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* serve() throws not_found_exception for paths that cannot be resolved to a file.
|
||||
*
|
||||
* @param string $scriptpath
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('serve_not_found_provider')]
|
||||
public function test_serve_not_found(string $scriptpath): void {
|
||||
$this->expectException(\core\exception\not_found_exception::class);
|
||||
(new esm_controller())->serve(
|
||||
new ServerRequest('GET', "/12345/{$scriptpath}"),
|
||||
new Response(),
|
||||
12345,
|
||||
$scriptpath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid component module returns 200 with an application/javascript Content-Type.
|
||||
*/
|
||||
public function test_serve_component_module_returns_javascript(): void {
|
||||
$response = $this->make_test_controller()->serve(
|
||||
new ServerRequest('GET', '/-1/mod_test/index'),
|
||||
new Response(),
|
||||
-1,
|
||||
'mod_test/index',
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertStringContainsString('application/javascript', $response->getHeaderLine('Content-Type'));
|
||||
}
|
||||
|
||||
/**
|
||||
* An invalid revision (−1) results in a short-lived cache response with no ETag.
|
||||
*/
|
||||
public function test_serve_invalid_revision_uses_short_cache(): void {
|
||||
$response = $this->make_test_controller()->serve(
|
||||
new ServerRequest('GET', '/-1/mod_test/index'),
|
||||
new Response(),
|
||||
-1,
|
||||
'mod_test/index',
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertFalse($response->hasHeader('ETag'), 'Short-cache responses must not include an ETag.');
|
||||
$this->assertFalse($response->hasHeader('Cache-Control'), 'Short-cache responses must not include Cache-Control.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid revision results in a long-lived immutable cache response with an ETag.
|
||||
*/
|
||||
public function test_serve_valid_revision_sets_long_cache(): void {
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
$revision = $clock->time();
|
||||
|
||||
$response = $this->make_test_controller()->serve(
|
||||
new ServerRequest('GET', "/{$revision}/mod_test/index"),
|
||||
new Response(),
|
||||
$revision,
|
||||
'mod_test/index',
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertTrue($response->hasHeader('ETag'), 'Long-cache responses must include an ETag.');
|
||||
$this->assertStringContainsString('immutable', $response->getHeaderLine('Cache-Control'));
|
||||
}
|
||||
|
||||
/**
|
||||
* When the request carries an If-None-Match header that matches the ETag, serve() returns 304.
|
||||
*/
|
||||
public function test_serve_matching_etag_returns_304(): void {
|
||||
$filename = make_request_directory() . '/test.js';
|
||||
$controller = $this->make_test_controller($filename);
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
$revision = $clock->time();
|
||||
|
||||
// Create the file content.
|
||||
$etag = sha1("{$revision}:{$filename}");
|
||||
|
||||
$request = (new ServerRequest('GET', "/{$revision}/mod_test/index"))
|
||||
->withHeader('If-None-Match', $etag);
|
||||
|
||||
$response = $controller->serve($request, new Response(), $revision, 'mod_test/index');
|
||||
|
||||
$this->assertEquals(304, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user