From 9137558cd5723d4c9a4ff84761477ffd3f6c9bdb Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 27 Oct 2023 12:02:22 +0800 Subject: [PATCH 1/4] MDL-82565 core: Add support for routing of front-end pages This is the beginning of the end for non-routed pages in Moodle and the start of SEO-friendly page URLs. A 'shim' is provided for backwards compatibility. --- course/admin.php | 35 +----- .../route/controller/course_management.php | 81 +++++++++++++ course/classes/route/shim/course_routes.php | 67 ++++++++++ lib/amd/build/fetch.min.js | 2 +- lib/amd/build/fetch.min.js.map | 2 +- lib/amd/src/fetch.js | 2 +- lib/behat/lib.php | 3 +- lib/classes/output/core_renderer.php | 10 +- lib/classes/router.php | 42 +++++-- lib/classes/router/abstract_route_loader.php | 21 ++-- .../moodle_api_authentication_middleware.php | 59 +++++++++ .../moodle_authentication_middleware.php | 59 +++++++++ lib/classes/router/require_login.php | 86 +++++++++++++ lib/classes/router/route.php | 6 + lib/classes/router/route_controller.php | 20 +-- lib/classes/router/route_loader.php | 94 +++++++++++++++ lib/classes/router/route_loader_interface.php | 6 + lib/classes/router/schema/specification.php | 14 +-- lib/classes/router/util.php | 24 ++++ lib/classes/url.php | 2 +- lib/tests/router/util_test.php | 5 + lib/tests/router_test.php | 114 +++++++++++++++++- 22 files changed, 664 insertions(+), 90 deletions(-) create mode 100644 course/classes/route/controller/course_management.php create mode 100644 course/classes/route/shim/course_routes.php create mode 100644 lib/classes/router/middleware/moodle_api_authentication_middleware.php create mode 100644 lib/classes/router/middleware/moodle_authentication_middleware.php create mode 100644 lib/classes/router/require_login.php diff --git a/course/admin.php b/course/admin.php index ca11ff2c265..6a8faa4a2f0 100644 --- a/course/admin.php +++ b/course/admin.php @@ -20,37 +20,6 @@ * * @copyright 2016 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since 5.0 */ - -require_once("../config.php"); - -$courseid = required_param('courseid', PARAM_INT); - -$PAGE->set_url('/course/admin.php', array('courseid'=>$courseid)); - -$course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST); - -require_login($course); -$context = context_course::instance($course->id); - -$PAGE->set_pagelayout('incourse'); - -if ($courseid == $SITE->id) { - $title = get_string('frontpagesettings'); - $node = $PAGE->settingsnav->find('frontpage', navigation_node::TYPE_SETTING); - $PAGE->set_primary_active_tab('home'); -} else { - $title = get_string('courseadministration'); - $node = $PAGE->settingsnav->find('courseadmin', navigation_node::TYPE_COURSE); -} -$PAGE->set_title($title); -$PAGE->set_heading($course->fullname); -$PAGE->navbar->add($title); -echo $OUTPUT->header(); -echo $OUTPUT->heading($title); - -if ($node) { - echo $OUTPUT->render_from_template('core/settings_link_page', ['node' => $node]); -} - -echo $OUTPUT->footer(); +require_once("../r.php"); diff --git a/course/classes/route/controller/course_management.php b/course/classes/route/controller/course_management.php new file mode 100644 index 00000000000..8353d4ceebf --- /dev/null +++ b/course/classes/route/controller/course_management.php @@ -0,0 +1,81 @@ +. + +namespace core_course\route\controller; + +use core\router\route; +use core\router\require_login; +use navigation_node; +use Psr\Http\Message\ResponseInterface; + +/** + * Course Management. + * + * @package core_course + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_management { + use \core\router\route_controller; + + /** + * Administer a course. + * + * @param ResponseInterface $response + * @param \stdClass $course + * @return ResponseInterface + */ + #[route( + path: '/{course}/manage', + pathtypes: [ + new \core\router\parameters\path_course(), + ], + requirelogin: new require_login( + requirelogin: true, + courseattributename: 'course', + ), + )] + public function administer_course( + ResponseInterface $response, + \stdClass $course, + ): ResponseInterface { + global $PAGE, $SITE, $OUTPUT; + + $PAGE->set_pagelayout('incourse'); + + if ($course->id == $SITE->id) { + $title = get_string('frontpagesettings'); + $node = $PAGE->settingsnav->find('frontpage', navigation_node::TYPE_SETTING); + $PAGE->set_primary_active_tab('home'); + } else { + $title = get_string('courseadministration'); + $node = $PAGE->settingsnav->find('courseadmin', navigation_node::TYPE_COURSE); + } + $PAGE->set_title($title); + $PAGE->set_heading($course->fullname); + $PAGE->navbar->add($title); + $response->getBody()->write($OUTPUT->header()); + $response->getBody()->write($OUTPUT->heading($title)); + + if ($node) { + $response->getBody()->write($OUTPUT->render_from_template('core/settings_link_page', ['node' => $node])); + } + + $response->getBody()->write($OUTPUT->footer()); + + return $response; + } +} diff --git a/course/classes/route/shim/course_routes.php b/course/classes/route/shim/course_routes.php new file mode 100644 index 00000000000..2eb2eb6b10c --- /dev/null +++ b/course/classes/route/shim/course_routes.php @@ -0,0 +1,67 @@ +. + +namespace core_course\route\shim; + +use core\param; +use core\router\route; +use core\router\route_controller; +use core\router\schema\parameters\query_parameter; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * A shim for the course routes. + * + * @package core_course + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class course_routes { + use route_controller; + + /** + * Shim /course/admin.php to the course management controller. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + #[route( + path: '/admin.php', + queryparams: [ + new query_parameter( + name: 'courseid', + type: param::INT, + description: 'The course ID', + required: true, + ), + ], + )] + public function administer_course( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + $params = $request->getQueryParams(); + return self::redirect_to_callable( + $request, + $response, + [\core_course\route\controller\course_management::class, 'administer_course'], + pathparams: $params + ['course' => $params['courseid']], + excludeparams: ['courseid'], + ); + } +} diff --git a/lib/amd/build/fetch.min.js b/lib/amd/build/fetch.min.js index 70a3e715d3b..d512928121a 100644 --- a/lib/amd/build/fetch.min.js +++ b/lib/amd/build/fetch.min.js @@ -1,3 +1,3 @@ -define("core/fetch",["exports","core/config","./pending"],(function(_exports,_config,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _classStaticPrivateMethodGet(receiver,classConstructor,method){return function(receiver,classConstructor){if(receiver!==classConstructor)throw new TypeError("Private static access of wrong provenance")}(receiver,classConstructor),method}function _classPrivateFieldInitSpec(obj,privateMap,value){!function(obj,privateCollection){if(privateCollection.has(obj))throw new TypeError("Cannot initialize the same private elements twice on an object")}(obj,privateMap),privateMap.set(obj,value)}function _classPrivateFieldGet(receiver,privateMap){return function(receiver,descriptor){if(descriptor.get)return descriptor.get.call(receiver);return descriptor.value}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"get"))}function _classPrivateFieldSet(receiver,privateMap,value){return function(receiver,descriptor,value){if(descriptor.set)descriptor.set.call(receiver,value);else{if(!descriptor.writable)throw new TypeError("attempted to set read only private field");descriptor.value=value}}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"set"),value),value}function _classExtractFieldDescriptor(receiver,privateMap,action){if(!privateMap.has(receiver))throw new TypeError("attempted to "+action+" private field on non-instance");return privateMap.get(receiver)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_config=_interopRequireDefault(_config),_pending=_interopRequireDefault(_pending);var _request=new WeakMap,_promise=new WeakMap,_resolve=new WeakMap,_reject=new WeakMap;class RequestWrapper{constructor(request){_classPrivateFieldInitSpec(this,_request,{writable:!0,value:null}),_classPrivateFieldInitSpec(this,_promise,{writable:!0,value:null}),_classPrivateFieldInitSpec(this,_resolve,{writable:!0,value:null}),_classPrivateFieldInitSpec(this,_reject,{writable:!0,value:null}),_classPrivateFieldSet(this,_request,request),_classPrivateFieldSet(this,_promise,new Promise(((resolve,reject)=>{_classPrivateFieldSet(this,_resolve,resolve),_classPrivateFieldSet(this,_reject,reject)})))}get request(){return _classPrivateFieldGet(this,_request)}get promise(){return _classPrivateFieldGet(this,_promise)}handleResponse(response){response.ok?_classPrivateFieldGet(this,_resolve).call(this,response):_classPrivateFieldGet(this,_reject).call(this,response.statusText)}}class Fetch{static async request(component,action){let{params:params={},body:body=null,method:method="GET"}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const pending=new _pending.default("Requesting ".concat(component,"/").concat(action," with ").concat(method)),requestWrapper=_classStaticPrivateMethodGet(Fetch,Fetch,_getRequest).call(Fetch,_classStaticPrivateMethodGet(Fetch,Fetch,_normaliseComponent).call(Fetch,component),action,{params:params,method:method,body:body}),result=await fetch(requestWrapper.request);return pending.resolve(),requestWrapper.handleResponse(result),requestWrapper.promise}static performGet(component,action){let{params:params={}}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{params:params,method:"GET"})}static performHead(component,action){let{params:params={}}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{params:params,method:"HEAD"})}static performPost(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,method:"POST"})}static performPut(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,method:"PUT"})}static performPatch(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,method:"PATCH"})}static performDelete(component,action){let{params:params={},body:body=null}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,params:params,method:"DELETE"})}}function _normaliseComponent(component){return component.replace(/^core_/,"")}function _getRequest(component,endpoint,_ref){let{params:params={},body:body=null,method:method="GET"}=_ref;const url=new URL("".concat(_config.default.apibase,"/rest/v2/").concat(component,"/").concat(endpoint)),options={method:method,headers:{Accept:"application/json","Content-Type":"application/json"}};return Object.entries(params).forEach((_ref2=>{let[key,value]=_ref2;url.searchParams.append(key,value)})),body&&(body instanceof FormData?options.body=body:options.body=body instanceof Object?JSON.stringify(body):body),new RequestWrapper(new Request(url,options))}return _exports.default=Fetch,_exports.default})); +define("core/fetch",["exports","core/config","./pending"],(function(_exports,Cfg,_pending){var obj;function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _classStaticPrivateMethodGet(receiver,classConstructor,method){return function(receiver,classConstructor){if(receiver!==classConstructor)throw new TypeError("Private static access of wrong provenance")}(receiver,classConstructor),method}function _classPrivateFieldInitSpec(obj,privateMap,value){!function(obj,privateCollection){if(privateCollection.has(obj))throw new TypeError("Cannot initialize the same private elements twice on an object")}(obj,privateMap),privateMap.set(obj,value)}function _classPrivateFieldGet(receiver,privateMap){return function(receiver,descriptor){if(descriptor.get)return descriptor.get.call(receiver);return descriptor.value}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"get"))}function _classPrivateFieldSet(receiver,privateMap,value){return function(receiver,descriptor,value){if(descriptor.set)descriptor.set.call(receiver,value);else{if(!descriptor.writable)throw new TypeError("attempted to set read only private field");descriptor.value=value}}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"set"),value),value}function _classExtractFieldDescriptor(receiver,privateMap,action){if(!privateMap.has(receiver))throw new TypeError("attempted to "+action+" private field on non-instance");return privateMap.get(receiver)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,Cfg=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Cfg),_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _request=new WeakMap,_promise=new WeakMap,_resolve=new WeakMap,_reject=new WeakMap;class RequestWrapper{constructor(request){_classPrivateFieldInitSpec(this,_request,{writable:!0,value:null}),_classPrivateFieldInitSpec(this,_promise,{writable:!0,value:null}),_classPrivateFieldInitSpec(this,_resolve,{writable:!0,value:null}),_classPrivateFieldInitSpec(this,_reject,{writable:!0,value:null}),_classPrivateFieldSet(this,_request,request),_classPrivateFieldSet(this,_promise,new Promise(((resolve,reject)=>{_classPrivateFieldSet(this,_resolve,resolve),_classPrivateFieldSet(this,_reject,reject)})))}get request(){return _classPrivateFieldGet(this,_request)}get promise(){return _classPrivateFieldGet(this,_promise)}handleResponse(response){response.ok?_classPrivateFieldGet(this,_resolve).call(this,response):_classPrivateFieldGet(this,_reject).call(this,response.statusText)}}class Fetch{static async request(component,action){let{params:params={},body:body=null,method:method="GET"}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const pending=new _pending.default("Requesting ".concat(component,"/").concat(action," with ").concat(method)),requestWrapper=_classStaticPrivateMethodGet(Fetch,Fetch,_getRequest).call(Fetch,_classStaticPrivateMethodGet(Fetch,Fetch,_normaliseComponent).call(Fetch,component),action,{params:params,method:method,body:body}),result=await fetch(requestWrapper.request);return pending.resolve(),requestWrapper.handleResponse(result),requestWrapper.promise}static performGet(component,action){let{params:params={}}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{params:params,method:"GET"})}static performHead(component,action){let{params:params={}}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{params:params,method:"HEAD"})}static performPost(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,method:"POST"})}static performPut(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,method:"PUT"})}static performPatch(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,method:"PATCH"})}static performDelete(component,action){let{params:params={},body:body=null}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.request(component,action,{body:body,params:params,method:"DELETE"})}}function _normaliseComponent(component){return component.replace(/^core_/,"")}function _getRequest(component,endpoint,_ref){let{params:params={},body:body=null,method:method="GET"}=_ref;const url=new URL("".concat(Cfg.apibase,"/rest/v2/").concat(component,"/").concat(endpoint)),options={method:method,headers:{Accept:"application/json","Content-Type":"application/json"}};return Object.entries(params).forEach((_ref2=>{let[key,value]=_ref2;url.searchParams.append(key,value)})),body&&(body instanceof FormData?options.body=body:options.body=body instanceof Object?JSON.stringify(body):body),new RequestWrapper(new Request(url,options))}return _exports.default=Fetch,_exports.default})); //# sourceMappingURL=fetch.min.js.map \ No newline at end of file diff --git a/lib/amd/build/fetch.min.js.map b/lib/amd/build/fetch.min.js.map index 5b3c5b1c36b..3814206a2fa 100644 --- a/lib/amd/build/fetch.min.js.map +++ b/lib/amd/build/fetch.min.js.map @@ -1 +1 @@ -{"version":3,"file":"fetch.min.js","sources":["../src/fetch.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The core/fetch module allows you to make web service requests to the Moodle API.\n *\n * @module core/fetch\n * @copyright Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @example Perform a single GET request\n * import Fetch from 'core/fetch';\n *\n * const result = Fetch.performGet('mod_example', 'animals', { params: { type: 'mammal' } });\n *\n * result.then((response) => {\n * // Do something with the Response object.\n * })\n * .catch((error) => {\n * // Handle the error\n * });\n */\n\nimport Cfg from 'core/config';\nimport PendingPromise from './pending';\n\n/**\n * A wrapper around the Request, including a Promise that is resolved when the request is complete.\n *\n * @class RequestWrapper\n * @private\n */\nclass RequestWrapper {\n /** @var {Request} */\n #request = null;\n\n /** @var {Promise} */\n #promise = null;\n\n /** @var {Function} */\n #resolve = null;\n\n /** @var {Function} */\n #reject = null;\n\n /**\n * Create a new RequestWrapper.\n *\n * @param {Request} request The request object that is wrapped\n */\n constructor(request) {\n this.#request = request;\n this.#promise = new Promise((resolve, reject) => {\n this.#resolve = resolve;\n this.#reject = reject;\n });\n }\n\n /**\n * Get the wrapped Request.\n *\n * @returns {Request}\n * @private\n */\n get request() {\n return this.#request;\n }\n\n /**\n * Get the Promise link to this request.\n *\n * @return {Promise}\n * @private\n */\n get promise() {\n return this.#promise;\n }\n\n /**\n * Handle the response from the request.\n *\n * @param {Response} response\n * @private\n */\n handleResponse(response) {\n if (response.ok) {\n this.#resolve(response);\n } else {\n this.#reject(response.statusText);\n }\n }\n}\n\n/**\n * A class to handle requests to the Moodle REST API.\n *\n * @class Fetch\n */\nexport default class Fetch {\n /**\n * Make a single request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @param {string} [params.method = \"GET\"] The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static async request(\n component,\n action,\n {\n params = {},\n body = null,\n method = 'GET',\n } = {},\n ) {\n const pending = new PendingPromise(`Requesting ${component}/${action} with ${method}`);\n const requestWrapper = Fetch.#getRequest(\n Fetch.#normaliseComponent(component),\n action,\n { params, method, body },\n );\n const result = await fetch(requestWrapper.request);\n\n pending.resolve();\n\n requestWrapper.handleResponse(result);\n\n return requestWrapper.promise;\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performGet(\n component,\n action,\n {\n params = {},\n } = {},\n ) {\n return this.request(\n component,\n action,\n { params, method: 'GET' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performHead(\n component,\n action,\n {\n params = {},\n } = {},\n ) {\n return this.request(\n component,\n action,\n { params, method: 'HEAD' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performPost(\n component,\n action,\n {\n body,\n } = {},\n ) {\n return this.request(\n component,\n action,\n { body, method: 'POST' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performPut(\n component,\n action,\n {\n body,\n } = {},\n ) {\n return this.request(\n component,\n action,\n { body, method: 'PUT' },\n );\n }\n\n /**\n * Make a PATCH request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performPatch(\n component,\n action,\n {\n body,\n } = {},\n ) {\n return this.request(\n component,\n action,\n { body, method: 'PATCH' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performDelete(\n component,\n action,\n {\n params = {},\n body = null,\n } = {},\n ) {\n return this.request(\n component,\n action,\n {\n body,\n params,\n method: 'DELETE',\n },\n );\n }\n\n /**\n * Normalise the component name to remove the core_ prefix.\n *\n * @param {string} component\n * @returns {string}\n */\n static #normaliseComponent(component) {\n return component.replace(/^core_/, '');\n }\n\n /**\n * Get the Request for a given API request.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} endpoint The endpoint within the componet to call\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @param {string} [params.method = \"GET\"] The HTTP method to use\n * @returns {RequestWrapper}\n */\n static #getRequest(\n component,\n endpoint,\n {\n params = {},\n body = null,\n method = 'GET',\n }\n ) {\n const url = new URL(`${Cfg.apibase}/rest/v2/${component}/${endpoint}`);\n const options = {\n method,\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n };\n\n Object.entries(params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n\n if (body) {\n if (body instanceof FormData) {\n options.body = body;\n } else if (body instanceof Object) {\n options.body = JSON.stringify(body);\n } else {\n options.body = body;\n }\n }\n\n return new RequestWrapper(new Request(url, options));\n }\n}\n"],"names":["RequestWrapper","constructor","request","Promise","resolve","reject","this","promise","handleResponse","response","ok","statusText","Fetch","component","action","params","body","method","pending","PendingPromise","requestWrapper","result","fetch","replace","endpoint","url","URL","Cfg","apibase","options","headers","Object","entries","forEach","_ref2","key","value","searchParams","append","FormData","JSON","stringify","Request"],"mappings":"srDA2CMA,eAkBFC,YAAYC,qEAhBD,mEAGA,mEAGA,kEAGD,2CAQUA,6CACA,IAAIC,SAAQ,CAACC,QAASC,8CAClBD,4CACDC,YAUnBH,2CACOI,eASPC,2CACOD,eASXE,eAAeC,UACPA,SAASC,kDACKD,wDAEDA,SAASE,mBAUbC,2BAabC,UACAC,YACAC,OACIA,OAAS,GADbC,KAEIA,KAAO,KAFXC,OAGIA,OAAS,8DACT,SAEEC,QAAU,IAAIC,sCAA6BN,sBAAaC,wBAAeG,SACvEG,4CAAiBR,MAtBVA,wBAsBUA,mCACnBA,MAvBSA,gCAuBTA,MAA0BC,WAC1BC,OACA,CAAEC,OAAAA,OAAQE,OAAAA,OAAQD,KAAAA,OAEhBK,aAAeC,MAAMF,eAAelB,gBAE1CgB,QAAQd,UAERgB,eAAeZ,eAAea,QAEvBD,eAAeb,0BAatBM,UACAC,YACAC,OACIA,OAAS,2DACT,UAEGT,KAAKJ,QACRW,UACAC,OACA,CAAEC,OAAAA,OAAQE,OAAQ,2BActBJ,UACAC,YACAC,OACIA,OAAS,2DACT,UAEGT,KAAKJ,QACRW,UACAC,OACA,CAAEC,OAAAA,OAAQE,OAAQ,4BActBJ,UACAC,YACAE,KACIA,6DACA,UAEGV,KAAKJ,QACRW,UACAC,OACA,CAAEE,KAAAA,KAAMC,OAAQ,2BAcpBJ,UACAC,YACAE,KACIA,6DACA,UAEGV,KAAKJ,QACRW,UACAC,OACA,CAAEE,KAAAA,KAAMC,OAAQ,4BAcpBJ,UACAC,YACAE,KACIA,6DACA,UAEGV,KAAKJ,QACRW,UACAC,OACA,CAAEE,KAAAA,KAAMC,OAAQ,+BAepBJ,UACAC,YACAC,OACIA,OAAS,GADbC,KAEIA,KAAO,6DACP,UAEGV,KAAKJ,QACRW,UACAC,OACA,CACIE,KAAAA,KACAD,OAAAA,OACAE,OAAQ,yCAWOJ,kBAChBA,UAAUU,QAAQ,SAAU,yBAenCV,UACAW,mBACAT,OACIA,OAAS,GADbC,KAEIA,KAAO,KAFXC,OAGIA,OAAS,kBAGPQ,IAAM,IAAIC,cAAOC,gBAAIC,4BAAmBf,sBAAaW,WACrDK,QAAU,CACZZ,OAAAA,OACAa,QAAS,QACK,kCACM,4BAIxBC,OAAOC,QAAQjB,QAAQkB,SAAQC,YAAEC,IAAKC,aAClCX,IAAIY,aAAaC,OAAOH,IAAKC,UAG7BpB,OACIA,gBAAgBuB,SAChBV,QAAQb,KAAOA,KAEfa,QAAQb,KADDA,gBAAgBe,OACRS,KAAKC,UAAUzB,MAEfA,MAIhB,IAAIhB,eAAe,IAAI0C,QAAQjB,IAAKI"} \ No newline at end of file +{"version":3,"file":"fetch.min.js","sources":["../src/fetch.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The core/fetch module allows you to make web service requests to the Moodle API.\n *\n * @module core/fetch\n * @copyright Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @example Perform a single GET request\n * import Fetch from 'core/fetch';\n *\n * const result = Fetch.performGet('mod_example', 'animals', { params: { type: 'mammal' } });\n *\n * result.then((response) => {\n * // Do something with the Response object.\n * })\n * .catch((error) => {\n * // Handle the error\n * });\n */\n\nimport * as Cfg from 'core/config';\nimport PendingPromise from './pending';\n\n/**\n * A wrapper around the Request, including a Promise that is resolved when the request is complete.\n *\n * @class RequestWrapper\n * @private\n */\nclass RequestWrapper {\n /** @var {Request} */\n #request = null;\n\n /** @var {Promise} */\n #promise = null;\n\n /** @var {Function} */\n #resolve = null;\n\n /** @var {Function} */\n #reject = null;\n\n /**\n * Create a new RequestWrapper.\n *\n * @param {Request} request The request object that is wrapped\n */\n constructor(request) {\n this.#request = request;\n this.#promise = new Promise((resolve, reject) => {\n this.#resolve = resolve;\n this.#reject = reject;\n });\n }\n\n /**\n * Get the wrapped Request.\n *\n * @returns {Request}\n * @private\n */\n get request() {\n return this.#request;\n }\n\n /**\n * Get the Promise link to this request.\n *\n * @return {Promise}\n * @private\n */\n get promise() {\n return this.#promise;\n }\n\n /**\n * Handle the response from the request.\n *\n * @param {Response} response\n * @private\n */\n handleResponse(response) {\n if (response.ok) {\n this.#resolve(response);\n } else {\n this.#reject(response.statusText);\n }\n }\n}\n\n/**\n * A class to handle requests to the Moodle REST API.\n *\n * @class Fetch\n */\nexport default class Fetch {\n /**\n * Make a single request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @param {string} [params.method = \"GET\"] The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static async request(\n component,\n action,\n {\n params = {},\n body = null,\n method = 'GET',\n } = {},\n ) {\n const pending = new PendingPromise(`Requesting ${component}/${action} with ${method}`);\n const requestWrapper = Fetch.#getRequest(\n Fetch.#normaliseComponent(component),\n action,\n { params, method, body },\n );\n const result = await fetch(requestWrapper.request);\n\n pending.resolve();\n\n requestWrapper.handleResponse(result);\n\n return requestWrapper.promise;\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performGet(\n component,\n action,\n {\n params = {},\n } = {},\n ) {\n return this.request(\n component,\n action,\n { params, method: 'GET' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performHead(\n component,\n action,\n {\n params = {},\n } = {},\n ) {\n return this.request(\n component,\n action,\n { params, method: 'HEAD' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performPost(\n component,\n action,\n {\n body,\n } = {},\n ) {\n return this.request(\n component,\n action,\n { body, method: 'POST' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performPut(\n component,\n action,\n {\n body,\n } = {},\n ) {\n return this.request(\n component,\n action,\n { body, method: 'PUT' },\n );\n }\n\n /**\n * Make a PATCH request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performPatch(\n component,\n action,\n {\n body,\n } = {},\n ) {\n return this.request(\n component,\n action,\n { body, method: 'PATCH' },\n );\n }\n\n /**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @returns {Promise} A promise that resolves to the Response object for the request\n */\n static performDelete(\n component,\n action,\n {\n params = {},\n body = null,\n } = {},\n ) {\n return this.request(\n component,\n action,\n {\n body,\n params,\n method: 'DELETE',\n },\n );\n }\n\n /**\n * Normalise the component name to remove the core_ prefix.\n *\n * @param {string} component\n * @returns {string}\n */\n static #normaliseComponent(component) {\n return component.replace(/^core_/, '');\n }\n\n /**\n * Get the Request for a given API request.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} endpoint The endpoint within the componet to call\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @param {string} [params.method = \"GET\"] The HTTP method to use\n * @returns {RequestWrapper}\n */\n static #getRequest(\n component,\n endpoint,\n {\n params = {},\n body = null,\n method = 'GET',\n }\n ) {\n const url = new URL(`${Cfg.apibase}/rest/v2/${component}/${endpoint}`);\n const options = {\n method,\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n };\n\n Object.entries(params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n\n if (body) {\n if (body instanceof FormData) {\n options.body = body;\n } else if (body instanceof Object) {\n options.body = JSON.stringify(body);\n } else {\n options.body = body;\n }\n }\n\n return new RequestWrapper(new Request(url, options));\n }\n}\n"],"names":["RequestWrapper","constructor","request","Promise","resolve","reject","this","promise","handleResponse","response","ok","statusText","Fetch","component","action","params","body","method","pending","PendingPromise","requestWrapper","result","fetch","replace","endpoint","url","URL","Cfg","apibase","options","headers","Object","entries","forEach","_ref2","key","value","searchParams","append","FormData","JSON","stringify","Request"],"mappings":"y/EA2CMA,eAkBFC,YAAYC,qEAhBD,mEAGA,mEAGA,kEAGD,2CAQUA,6CACA,IAAIC,SAAQ,CAACC,QAASC,8CAClBD,4CACDC,YAUnBH,2CACOI,eASPC,2CACOD,eASXE,eAAeC,UACPA,SAASC,kDACKD,wDAEDA,SAASE,mBAUbC,2BAabC,UACAC,YACAC,OACIA,OAAS,GADbC,KAEIA,KAAO,KAFXC,OAGIA,OAAS,8DACT,SAEEC,QAAU,IAAIC,sCAA6BN,sBAAaC,wBAAeG,SACvEG,4CAAiBR,MAtBVA,wBAsBUA,mCACnBA,MAvBSA,gCAuBTA,MAA0BC,WAC1BC,OACA,CAAEC,OAAAA,OAAQE,OAAAA,OAAQD,KAAAA,OAEhBK,aAAeC,MAAMF,eAAelB,gBAE1CgB,QAAQd,UAERgB,eAAeZ,eAAea,QAEvBD,eAAeb,0BAatBM,UACAC,YACAC,OACIA,OAAS,2DACT,UAEGT,KAAKJ,QACRW,UACAC,OACA,CAAEC,OAAAA,OAAQE,OAAQ,2BActBJ,UACAC,YACAC,OACIA,OAAS,2DACT,UAEGT,KAAKJ,QACRW,UACAC,OACA,CAAEC,OAAAA,OAAQE,OAAQ,4BActBJ,UACAC,YACAE,KACIA,6DACA,UAEGV,KAAKJ,QACRW,UACAC,OACA,CAAEE,KAAAA,KAAMC,OAAQ,2BAcpBJ,UACAC,YACAE,KACIA,6DACA,UAEGV,KAAKJ,QACRW,UACAC,OACA,CAAEE,KAAAA,KAAMC,OAAQ,4BAcpBJ,UACAC,YACAE,KACIA,6DACA,UAEGV,KAAKJ,QACRW,UACAC,OACA,CAAEE,KAAAA,KAAMC,OAAQ,+BAepBJ,UACAC,YACAC,OACIA,OAAS,GADbC,KAEIA,KAAO,6DACP,UAEGV,KAAKJ,QACRW,UACAC,OACA,CACIE,KAAAA,KACAD,OAAAA,OACAE,OAAQ,yCAWOJ,kBAChBA,UAAUU,QAAQ,SAAU,yBAenCV,UACAW,mBACAT,OACIA,OAAS,GADbC,KAEIA,KAAO,KAFXC,OAGIA,OAAS,kBAGPQ,IAAM,IAAIC,cAAOC,IAAIC,4BAAmBf,sBAAaW,WACrDK,QAAU,CACZZ,OAAAA,OACAa,QAAS,QACK,kCACM,4BAIxBC,OAAOC,QAAQjB,QAAQkB,SAAQC,YAAEC,IAAKC,aAClCX,IAAIY,aAAaC,OAAOH,IAAKC,UAG7BpB,OACIA,gBAAgBuB,SAChBV,QAAQb,KAAOA,KAEfa,QAAQb,KADDA,gBAAgBe,OACRS,KAAKC,UAAUzB,MAEfA,MAIhB,IAAIhB,eAAe,IAAI0C,QAAQjB,IAAKI"} \ No newline at end of file diff --git a/lib/amd/src/fetch.js b/lib/amd/src/fetch.js index b7c5323d6ff..8b7c3be5c52 100644 --- a/lib/amd/src/fetch.js +++ b/lib/amd/src/fetch.js @@ -32,7 +32,7 @@ * }); */ -import Cfg from 'core/config'; +import * as Cfg from 'core/config'; import PendingPromise from './pending'; /** diff --git a/lib/behat/lib.php b/lib/behat/lib.php index 03233a8566d..842c00aea97 100644 --- a/lib/behat/lib.php +++ b/lib/behat/lib.php @@ -218,7 +218,8 @@ function behat_clean_init_config() { 'umaskpermissions', 'dbtype', 'dblibrary', 'dbhost', 'dbname', 'dbuser', 'dbpass', 'prefix', 'dboptions', 'proxyhost', 'proxyport', 'proxytype', 'proxyuser', 'proxypassword', 'proxybypass', 'pathtogs', 'pathtophp', 'pathtodu', 'aspellpath', 'pathtodot', 'skiplangupgrade', - 'altcacheconfigpath', 'pathtounoconv', 'alternative_file_system_class', 'pathtopython' + 'altcacheconfigpath', 'pathtounoconv', 'alternative_file_system_class', 'pathtopython', + 'routerconfigured', )); // Add extra allowed settings. diff --git a/lib/classes/output/core_renderer.php b/lib/classes/output/core_renderer.php index abcc3783284..fb04abe9104 100644 --- a/lib/classes/output/core_renderer.php +++ b/lib/classes/output/core_renderer.php @@ -4372,7 +4372,10 @@ EOD; // We only add a list to the full settings menu if we didn't include every node in the short menu. if ($skipped) { $text = get_string('morenavigationlinks'); - $url = new moodle_url('/course/admin.php', ['courseid' => $this->page->course->id]); + $url = \core\router\util::get_path_for_callable( + [\core_course\route\controller\course_management::class, 'administer_course'], + ['course' => $this->page->course->id], + ); $link = new action_link($url, $text, null, null, new pix_icon('t/edit', $text)); $menu->add_secondary_action($link); } @@ -4386,7 +4389,10 @@ EOD; // We only add a list to the full settings menu if we didn't include every node in the short menu. if ($skipped) { $text = get_string('morenavigationlinks'); - $url = new moodle_url('/course/admin.php', ['courseid' => $this->page->course->id]); + $url = \core\router\util::get_path_for_callable( + [\core_course\route\controller\course_management::class, 'administer_course'], + ['course' => $this->page->course->id], + ); $link = new action_link($url, $text, null, null, new pix_icon('t/edit', $text)); $menu->add_secondary_action($link); } diff --git a/lib/classes/router.php b/lib/classes/router.php index f74ffa86834..8b3a66234b3 100644 --- a/lib/classes/router.php +++ b/lib/classes/router.php @@ -19,6 +19,8 @@ namespace core; use core\output\routed_error_handler; use core\router\middleware\cors_middleware; use core\router\middleware\error_handling_middleware; +use core\router\middleware\moodle_api_authentication_middleware; +use core\router\middleware\moodle_authentication_middleware; use core\router\middleware\moodle_bootstrap_middleware; use core\router\middleware\moodle_route_attribute_middleware; use core\router\middleware\uri_normalisation_middleware; @@ -32,6 +34,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Slim\App; use Slim\Interfaces\RouteGroupInterface; +use Slim\Interfaces\RouteInterface; /** * Moodle Router. @@ -99,19 +102,19 @@ class router { ); // Replace occurrences of backslashes with forward slashes, especially on Windows. $scriptfile = str_replace('\\', '/', $scriptfile); - $relativeroot = sprintf( - '%s%s', - $scriptroot, - $scriptfile, - ); // The server is not configured to rewrite unknown requests to automatically use the router. + $userphp = false; if ($_SERVER && array_key_exists('REQUEST_URI', $_SERVER)) { - if (str_starts_with($_SERVER['REQUEST_URI'], $relativeroot)) { - $scriptroot .= '/r.php'; + if (str_starts_with($_SERVER['REQUEST_URI'], "{$scriptroot}/r.php")) { + $userphp = true; } } + if ($CFG->routerconfigured !== true || $userphp) { + $scriptroot .= '/r.php'; + } + return $scriptroot; } @@ -200,6 +203,8 @@ class router { foreach ($routegroups as $name => $collection) { match ($name) { route_loader_interface::ROUTE_GROUP_API => $this->configure_api_route($collection), + route_loader_interface::ROUTE_GROUP_PAGE => $this->configure_standard_route($collection), + route_loader_interface::ROUTE_GROUP_SHIM => $this->configure_shim_route($collection), default => null, }; } @@ -215,6 +220,29 @@ class router { ->add(di::get(error_handling_middleware::class)) // Add a Middleware to set the CORS headers for all REST Responses. ->add(di::get(cors_middleware::class)) + ->add(di::get(moodle_api_authentication_middleware::class)) + ->add(di::get(validation_middleware::class)); + } + + /** + * Configure the Standard page Route Middleware. + * + * @param RouteGroupInterface $group + */ + protected function configure_standard_route(RouteGroupInterface $group): void { + $group + ->add(di::get(moodle_authentication_middleware::class)) + ->add(di::get(validation_middleware::class)); + } + + /** + * Configure the Shim Route Middleware. + * + * @param RouteGroupInterface $group + */ + protected function configure_shim_route(RouteGroupInterface $group): void { + $group + // Note: In the future we may wish to add a shim middleware to notify users of updated bookmarks. ->add(di::get(validation_middleware::class)); } diff --git a/lib/classes/router/abstract_route_loader.php b/lib/classes/router/abstract_route_loader.php index 93e0de3c12d..890b8e742d6 100644 --- a/lib/classes/router/abstract_route_loader.php +++ b/lib/classes/router/abstract_route_loader.php @@ -31,11 +31,13 @@ abstract class abstract_route_loader { * * @param string $namespace The namespace to get the routes for * @param callable $componentpathcallback A callback to get the component path for a class + * @param null|callable $filtercallback A callback to use to filter routes before they are added * @return array[] */ protected function get_all_routes_in_namespace( string $namespace, callable $componentpathcallback, + ?callable $filtercallback = null, ): array { $routes = []; @@ -43,6 +45,9 @@ abstract class abstract_route_loader { $classes = \core_component::get_component_classes_in_namespace(namespace: $namespace); foreach (array_keys($classes) as $classname) { $classinfo = new \ReflectionClass($classname); + if ($filtercallback && !$filtercallback($classname)) { + continue; + } $component = \core_component::get_component_from_classname($classname); $componentpath = $componentpathcallback($component); @@ -135,7 +140,7 @@ abstract class abstract_route_loader { ): ?route { // Fetch the route attribute from the method. // Each method can only have a single route attribute. - $routeattributes = $methodinfo->getAttributes(route::class); + $routeattributes = $methodinfo->getAttributes(route::class, \ReflectionAttribute::IS_INSTANCEOF); if (empty($routeattributes)) { return null; } @@ -166,19 +171,7 @@ abstract class abstract_route_loader { protected function normalise_component_path( string $component, ): string { - if ($component === 'core') { - return $component; - } - [$type, $subsystem] = \core_component::normalize_component($component); - if ($type === 'core') { - $component = $subsystem; - } - - if ($component === null) { - $component = ''; - } - - return $component; + return util::normalise_component_path($component); } /** diff --git a/lib/classes/router/middleware/moodle_api_authentication_middleware.php b/lib/classes/router/middleware/moodle_api_authentication_middleware.php new file mode 100644 index 00000000000..27f9841b02c --- /dev/null +++ b/lib/classes/router/middleware/moodle_api_authentication_middleware.php @@ -0,0 +1,59 @@ +. + +namespace core\router\middleware; + +use core\router\route; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Middleware to check Moodle authentication. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class moodle_api_authentication_middleware extends moodle_authentication_middleware { + #[\Override] + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + // Get the Moodle Route from the request. We need this to determine if login is required for this page. + $moodleroute = $request->getAttribute(route::class); + + // Currently only Cookie authentication is supported. + if ($moodleroute && $moodleroute->requirelogin) { + $requirements = $moodleroute->requirelogin; + if ($courseattributename = $requirements->get_course_attribute_name()) { + $courseorid = $request->getAttribute($courseattributename, null); + } + + if ($requirements->should_require_course_login()) { + require_course_login( + $courseorid, + $requirements->should_autologin_guest(), + ); + } else if ($requirements->should_require_login()) { + require_login( + $courseorid, + $requirements->should_autologin_guest(), + ); + } + } + + return $handler->handle($request); + } +} diff --git a/lib/classes/router/middleware/moodle_authentication_middleware.php b/lib/classes/router/middleware/moodle_authentication_middleware.php new file mode 100644 index 00000000000..fa9286ef857 --- /dev/null +++ b/lib/classes/router/middleware/moodle_authentication_middleware.php @@ -0,0 +1,59 @@ +. + +namespace core\router\middleware; + +use core\router\route; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Middleware to check Moodle authentication. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class moodle_authentication_middleware implements MiddlewareInterface { + #[\Override] + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + // Get the Moodle Route from the request. We need this to determine if login is required for this page. + $moodleroute = $request->getAttribute(route::class); + + if ($moodleroute && $moodleroute->requirelogin) { + $requirements = $moodleroute->requirelogin; + if ($courseattributename = $requirements->get_course_attribute_name()) { + $courseorid = $request->getAttribute($courseattributename, null); + } + + if ($requirements->should_require_course_login()) { + require_course_login( + $courseorid, + $requirements->should_autologin_guest(), + ); + } else if ($requirements->should_require_login()) { + require_login( + $courseorid, + $requirements->should_autologin_guest(), + ); + } + } + + return $handler->handle($request); + } +} diff --git a/lib/classes/router/require_login.php b/lib/classes/router/require_login.php new file mode 100644 index 00000000000..971a1ac2656 --- /dev/null +++ b/lib/classes/router/require_login.php @@ -0,0 +1,86 @@ +. + +namespace core\router; + +/** + * Login metadata requirements for routes. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class require_login { + /** + * Create a new instance of the metadata class. + * + * @param bool $requirelogin Whether login is required + * @param bool $requirecourselogin Whether a course login is required + * @param mixed $courseattributename The name of the route attribute that the course object can be found in + * @param bool $autologinguest Whether to automatically log in as guest + * @throws \InvalidArgumentException + */ + public function __construct( + /** @var bool Whether login is required */ + public bool $requirelogin = false, + /** @var bool Whether a course login is required */ + public bool $requirecourselogin = false, + /** @var bool The name of the route attribute that the course object can be found in */ + protected ?string $courseattributename = null, + /** @var bool Whether to automatically log in as guest */ + public bool $autologinguest = true, + ) { + if ($requirelogin && $requirecourselogin) { + throw new \InvalidArgumentException('Cannot require login and course login at the same time'); + } + } + + /** + * Get the course attribute name. + * + * @return string + */ + public function get_course_attribute_name(): string { + return $this->courseattributename; + } + + /** + * Whether course login is required. + * + * @return bool + */ + public function should_require_course_login(): bool { + return $this->requirecourselogin; + } + + /** + * Whether login is required. + * + * @return bool + */ + public function should_require_login(): bool { + return $this->requirelogin; + } + + /** + * Whether automatic guest login is enabled. + * + * @return bool + */ + public function should_autologin_guest(): bool { + return $this->autologinguest; + } +} diff --git a/lib/classes/router/route.php b/lib/classes/router/route.php index b2050f5717a..1f00b0de60a 100644 --- a/lib/classes/router/route.php +++ b/lib/classes/router/route.php @@ -117,6 +117,12 @@ class route { /** @var bool Whether to abort after configuration */ public readonly bool $abortafterconfig = false, + /** @var null|array Whether to require login or not */ + public readonly ?require_login $requirelogin = null, + + /** @var string[] The list of scopes required to access this page */ + public readonly ?array $scopes = null, + // Note. We do not make use of these extras. // These allow us to add additional arguments in future versions, whilst allowing plugins to use this version. ...$extra, diff --git a/lib/classes/router/route_controller.php b/lib/classes/router/route_controller.php index b0764dbb921..c7cbe5981dc 100644 --- a/lib/classes/router/route_controller.php +++ b/lib/classes/router/route_controller.php @@ -17,7 +17,6 @@ namespace core\router; use moodle_url; -use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -32,17 +31,6 @@ use Psr\Http\Message\ServerRequestInterface; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait route_controller { - /** - * Constructor for Route Controllers. - * - * @param ContainerInterface $container - */ - public function __construct( - /** @var ContainerInterface The DI Container */ - protected ContainerInterface $container, - ) { - } - /** * Generate a Page Not Found result. * @@ -51,7 +39,7 @@ trait route_controller { * @return ResponseInterface * @throws \Slim\Exception\HttpNotFoundException */ - protected function page_not_found( + public static function page_not_found( ServerRequestInterface $request, ResponseInterface $response, ): ResponseInterface { @@ -65,7 +53,7 @@ trait route_controller { * @param string|moodle_url $url * @return ResponseInterface */ - protected function redirect( + public static function redirect( ResponseInterface $response, string|moodle_url $url, ): ResponseInterface { @@ -85,7 +73,7 @@ trait route_controller { * @param null|array $excludeparams A list of any parameters to remove the URI during the redirect * @return ResponseInterface */ - protected function redirect_to_callable( + public static function redirect_to_callable( ServerRequestInterface $request, ResponseInterface $response, array|callable|string $callable, @@ -111,7 +99,7 @@ trait route_controller { // Remove any params. $url->remove_params($excludeparams); - return $this->redirect($response, $url); + return self::redirect($response, $url); } /** diff --git a/lib/classes/router/route_loader.php b/lib/classes/router/route_loader.php index 13b5494e21d..aec10df4184 100644 --- a/lib/classes/router/route_loader.php +++ b/lib/classes/router/route_loader.php @@ -18,6 +18,7 @@ namespace core\router; use Slim\App; use Slim\Interfaces\RouteGroupInterface; +use Slim\Interfaces\RouteInterface; use Slim\Routing\RouteCollectorProxy; /** @@ -32,6 +33,8 @@ class route_loader extends abstract_route_loader implements route_loader_interfa public function configure_routes(App $app): array { return [ route_loader_interface::ROUTE_GROUP_API => $this->configure_api_routes($app, route_loader_interface::ROUTE_GROUP_API), + route_loader_interface::ROUTE_GROUP_PAGE => $this->configure_standard_routes($app), + route_loader_interface::ROUTE_GROUP_SHIM => $this->configure_shim_routes($app), ]; } @@ -59,6 +62,36 @@ class route_loader extends abstract_route_loader implements route_loader_interfa }); } + /** + * Configure all standard routes. + * + * @param App $app + * @return RouteGroupInterface + */ + protected function configure_standard_routes(App $app): RouteGroupInterface { + return $app->group('', function (RouteCollectorProxy $group): void { + foreach ($this->get_all_standard_routes() as $moodleroute) { + $slimroute = $group->map(...$moodleroute); + $this->set_route_name_for_callable($slimroute, $moodleroute['callable']); + } + }); + } + + /** + * Configure all route shims. + * + * @param App $app + * @return RouteGroupInterface + */ + protected function configure_shim_routes(App $app): RouteGroupInterface { + return $app->group('', function (RouteCollectorProxy $group): void { + foreach ($this->get_all_shimmed_routes() as $moodleroute) { + $slimroute = $group->map(...$moodleroute); + $this->set_route_name_for_callable($slimroute, $moodleroute['callable']); + } + }); + } + /** * Fetch all API routes. * @@ -80,4 +113,65 @@ class route_loader extends abstract_route_loader implements route_loader_interfa return $routes; } + + /** + * Fetch all shimmed routes. + * + * Shimmed routes are routes that are not part of the standard route namespace but allow backwards compatibility with + * pages which have been moved to the new routing system. + * + * Note: This method caches results in MUC. + * + * @return array|bool|mixed + */ + protected function get_all_shimmed_routes(): array { + $cache = \cache::make('core', 'routes'); + + if (!($cachedata = $cache->get('shimmed_routes'))) { + $cachedata = $this->get_all_routes_in_namespace( + namespace: 'route\shim', + componentpathcallback: function (string $component): string { + global $CFG; + if ($component === 'core') { + // The core component is a special case. + // It can place routes _anywhere_ in the codebase. + return ''; + } + + // Use the component directory path listed in \core\componnet. + return substr( + \core_component::get_component_directory($component), + strlen($CFG->dirroot) + 1, + ); + }, + ); + + $cache->set('shimmed_routes', $cachedata); + } + + return $cachedata; + } + + /** + * Fetch all standard routes. + * + * Note: This method caches results in MUC. + * + * @return array[] + */ + protected function get_all_standard_routes(): array { + $cache = \cache::make('core', 'routes'); + + if (!($cachedata = $cache->get('standard_routes'))) { + $cachedata = $this->get_all_routes_in_namespace( + namespace: 'route\controller', + componentpathcallback: $this->normalise_component_path(...), + filtercallback: fn(string $classname) => !str_contains($classname, '\\shim\\'), + ); + + $cache->set('standard_routes', $cachedata); + } + + return $cachedata; + } } diff --git a/lib/classes/router/route_loader_interface.php b/lib/classes/router/route_loader_interface.php index f469710792f..c5ab18c41da 100644 --- a/lib/classes/router/route_loader_interface.php +++ b/lib/classes/router/route_loader_interface.php @@ -31,6 +31,12 @@ interface route_loader_interface { /** @var string The route path prefix to use for API calls */ public const ROUTE_GROUP_API = '/api/rest/v2'; + /** @var string The route path prefix to use for API calls */ + public const ROUTE_GROUP_SHIM = 'shim'; + + /** @var string The route path prefix to use for API calls */ + public const ROUTE_GROUP_PAGE = '/'; + /** * Configure all routes for the Application. * diff --git a/lib/classes/router/schema/specification.php b/lib/classes/router/schema/specification.php index 6d60f47aa8d..274e6665819 100644 --- a/lib/classes/router/schema/specification.php +++ b/lib/classes/router/schema/specification.php @@ -23,6 +23,7 @@ use core\router\route; use core\router\route_loader_interface; use core\router\schema\objects\type_base; use core\router\schema\response\response; +use core\router\util; use core\url; use stdClass; @@ -263,17 +264,8 @@ class specification implements route $route, ): self { // Compile the final path, complete with component prefix. - [$type, $subsystem] = \core_component::normalize_component($component); - - if ($type === 'core') { - if ($subsystem) { - $path = "/{$subsystem}"; - } else { - $path = "/core"; - } - } else { - $path = "/{$component}"; - } + $path = "/"; + $path .= util::normalise_component_path($component); $path .= $route->get_path(); // Helper to add the path to the specification. diff --git a/lib/classes/router/util.php b/lib/classes/router/util.php index 1d9fe8bf6cc..9e67d02423c 100644 --- a/lib/classes/router/util.php +++ b/lib/classes/router/util.php @@ -227,4 +227,28 @@ class util { return $methodroute; } + + /** + * Normalise the component for use as part of the path. + * + * If the component is a subsystem, the `core_` prefix will be removed. + * If the component is 'core', it will be kept. + * All other components will use their frankenstyle name. + * + * @param string $component + * @return string + */ + public static function normalise_component_path( + string $component, + ): string { + if ($component === 'core') { + return $component; + } + [$type, $subsystem] = \core\component::normalize_component($component); + if ($type === 'core') { + return str_replace('core_', '', $subsystem); + } + + return $component ?? ''; + } } diff --git a/lib/classes/url.php b/lib/classes/url.php index d55ee3688db..5dfcc40b1aa 100644 --- a/lib/classes/url.php +++ b/lib/classes/url.php @@ -637,7 +637,7 @@ class url { ): self { global $CFG; - if (!$CFG->routerconfigured) { + if ($CFG->routerconfigured !== true) { $path = '/r.php/' . ltrim($path, '/'); } $url = new self($path, $params, $anchor); diff --git a/lib/tests/router/util_test.php b/lib/tests/router/util_test.php index 5a5877b5329..7b7040c1e7f 100644 --- a/lib/tests/router/util_test.php +++ b/lib/tests/router/util_test.php @@ -41,6 +41,11 @@ final class util_test extends route_testcase { * Test getting the path for a callable. */ public function test_get_path_for_callable(): void { + global $CFG; + + $this->resetAfterTest(); + + $CFG->routerconfigured = true; self::load_fixture('core', 'router/route_on_class.php'); $this->add_route_to_route_loader( diff --git a/lib/tests/router_test.php b/lib/tests/router_test.php index 980f0e4fc51..d7e8f089c0f 100644 --- a/lib/tests/router_test.php +++ b/lib/tests/router_test.php @@ -106,8 +106,8 @@ final class router_test extends route_testcase { } public static function basepath_provider(): \Iterator { - yield 'Domain' => ['http://example.com', '']; - yield 'Subdirectory' => ['http://example.com/moodle', '/moodle']; + yield 'Domain' => ['http://example.com', '/r.php']; + yield 'Subdirectory' => ['http://example.com/moodle', '/moodle/r.php']; } public function test_basepath_guessed_rphp(): void { @@ -119,4 +119,114 @@ final class router_test extends route_testcase { $this->assertEquals($wwwroot->get_path(), $router->basepath); } + + /** + * Test that the basepath is correctly guessed when the router is configured. + * + * @param string $wwwroot The wwwroot to use. + * @param bool|null $configured The value of $CFG->routerconfigured. + * @param string $requestedpath The path that was requested. + * @param string $expected The expected basepath. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('router_configured_basepath_provider')] + public function test_basepath_guessed_rphp_configuration_provided( + string $wwwroot, + ?bool $configured, + string $requestedpath, + string $expected, + ): void { + global $CFG; + + $this->resetAfterTest(); + $CFG->wwwroot = $wwwroot; + $CFG->routerconfigured = $configured; + $_SERVER['SCRIPT_FILENAME'] = "{$CFG->dirroot}/r.php"; + $_SERVER['REQUEST_URI'] = $requestedpath; + + $router = di::get(router::class); + + $this->assertEquals($expected, $router->basepath); + } + + /** + * Data provider for test_basepath_guessed_rphp_configuration_provided. + * + * @return \Generator, mixed, void> + */ + public static function router_configured_basepath_provider(): \Iterator { + global $CFG; + + yield 'Root domain, Not configured, accessed via r.php' => [ + 'http://example.com', + null, + '/r.php/example', + '/r.php', + ]; + yield 'Root domain, Configured true, accessed via r.php' => [ + 'http://example.com', + true, + "/r.php/example", + '/r.php', + ]; + yield 'Root domain, Configured false, accessed via r.php' => [ + 'http://example.com', + false, + '/r.php/example', + '/r.php', + ]; + yield 'Sub directory, Not configured, accessed via r.php' => [ + 'http://example.com/moodle', + null, + '/moodle/r.php/example', + '/moodle/r.php', + ]; + yield 'Sub directory, Configured true, accessed via r.php' => [ + 'http://example.com/moodle', + true, + '/moodle/r.php/example', + '/moodle/r.php', + ]; + yield 'Sub directory, Configured false, accessed via r.php' => [ + 'http://example.com/moodle', + false, + '/moodle/r.php/example', + '/moodle/r.php', + ]; + yield 'Root domain, Not configured, accessed without r.php' => [ + 'http://example.com', + null, + '/example', + '/r.php', + ]; + yield 'Root domain, Configured true, accessed without r.php' => [ + 'http://example.com', + true, + '/example', + '', + ]; + yield 'Root domain, Configured false, accessed without r.php' => [ + 'http://example.com', + false, + '/example', + '/r.php', + ]; + yield 'Sub directory, Not configured, accessed without r.php' => [ + 'http://example.com/moodle', + null, + '/example', + '/moodle/r.php', + ]; + yield 'Sub directory, Configured true, accessed without r.php' => [ + 'http://example.com/moodle', + true, + '/example', + '/moodle', + ]; + yield 'Sub directory, Configured false, accessed without r.php' => [ + 'http://example.com/moodle', + false, + '/example', + '/moodle/r.php', + ]; + } } From 0d73bbcf1e6b0c26d8e1abc3b4e3dc00fc226de7 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 4 Feb 2025 15:08:38 +0800 Subject: [PATCH 2/4] MDL-82565 core: Update router util --- lib/classes/router/route_controller.php | 29 ++----- lib/classes/router/util.php | 100 ++++++++++++++++++------ 2 files changed, 84 insertions(+), 45 deletions(-) diff --git a/lib/classes/router/route_controller.php b/lib/classes/router/route_controller.php index c7cbe5981dc..69684414ddb 100644 --- a/lib/classes/router/route_controller.php +++ b/lib/classes/router/route_controller.php @@ -43,7 +43,7 @@ trait route_controller { ServerRequestInterface $request, ResponseInterface $response, ): ResponseInterface { - throw new \Slim\Exception\HttpNotFoundException($request); + return util::throw_page_not_found($request, $response); } /** @@ -57,9 +57,7 @@ trait route_controller { ResponseInterface $response, string|moodle_url $url, ): ResponseInterface { - return $response - ->withStatus(302) - ->withHeader('Location', (string) $url); + return util::redirect($response, $url); } /** @@ -81,25 +79,14 @@ trait route_controller { ?array $queryparams = null, ?array $excludeparams = null, ): ResponseInterface { - // Provide defaults for the path and query params if not specified. - if ($pathparams === null) { - $pathparams = $request->getQueryParams(); - } - if ($queryparams === null) { - $queryparams = $request->getQueryParams(); - } - - // Generate a URI from the callable and the parameters. - $url = util::get_path_for_callable( + return util::redirect_to_callable( + $request, + $response, $callable, - $pathparams ?? [], - $queryparams ?? [], + $pathparams, + $queryparams, + $excludeparams, ); - - // Remove any params. - $url->remove_params($excludeparams); - - return self::redirect($response, $url); } /** diff --git a/lib/classes/router/util.php b/lib/classes/router/util.php index 9e67d02423c..eada7c17d8b 100644 --- a/lib/classes/router/util.php +++ b/lib/classes/router/util.php @@ -16,8 +16,9 @@ namespace core\router; -use moodle_url; +use core\url; use GuzzleHttp\Psr7\Uri; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Slim\Routing\RouteContext; @@ -31,52 +32,103 @@ use Slim\Routing\RouteContext; * - helpers to fetch the \core\router\route instance * * @package core - * @copyright 2024 Andrew Lyons + * @copyright Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class util { /** * Redirect to the specified URL, carrying all parameters across too. * - * @param string|moodle_url $path + * @param string|url $path * @param array $excludeparams Any parameters to exclude from the query params * @codeCoverageIgnore */ public static function redirect_with_params( - string|moodle_url $path, + string|url $path, array $excludeparams = [], ): never { $params = $_GET; - $url = new moodle_url( + $url = new url( $path, $params, ); - $url->remove_params($excludeparams); + $url->remove_params(...$excludeparams); redirect($url); } /** - * Redirect to the route at the callable supplied. + * Redirect to the requested callable. * - * @param callable|array|string $callable - * @param array $params Any parameters to include in the path - * @codeCoverageIgnore + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param array|callable|string $callable + * @param null|array $pathparams + * @param null|array $queryparams + * @param null|array $excludeparams A list of any parameters to remove the URI during the redirect + * @return ResponseInterface */ public static function redirect_to_callable( - callable|array|string $callable, - array $params = [], - ): never { - $params = array_merge( - $_GET, - $params, + ServerRequestInterface $request, + ResponseInterface $response, + array|callable|string $callable, + ?array $pathparams = null, + ?array $queryparams = null, + ?array $excludeparams = null, + ): ResponseInterface { + // Provide defaults for the path and query params if not specified. + if ($pathparams === null) { + $pathparams = $request->getQueryParams(); + } + if ($queryparams === null) { + $queryparams = $request->getQueryParams(); + } + + // Generate a URI from the callable and the parameters. + $url = self::get_path_for_callable( + $callable, + $pathparams ?? [], + $queryparams ?? [], ); - $url = self::get_path_for_callable($callable, $params, $params); + // Remove any params. + $url->remove_params($excludeparams); - redirect($url); + return self::redirect($response, $url); } + /** + * Generate a Page Not Found result. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + * @throws \Slim\Exception\HttpNotFoundException + */ + public static function throw_page_not_found( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + throw new \Slim\Exception\HttpNotFoundException($request); + } + + /** + * Redirect to a URL. + * + * @param ResponseInterface $response + * @param string|url $url + * @return ResponseInterface + */ + public static function redirect( + ResponseInterface $response, + string|url $url, + ): ResponseInterface { + return $response + ->withStatus(302) + ->withHeader('Location', (string) $url); + } + + /** * Get the route name for the specified callable. * @@ -103,13 +155,13 @@ class util { * @param string|array|callable $callable the Callable to get the URI for * @param array $params Any parameters to include in the path * @param array $queryparams Any parameters to include in the query string - * @return moodle_url + * @return url */ public static function get_path_for_callable( string|array|callable $callable, - array $params, - array $queryparams, - ): moodle_url { + array $params = [], + array $queryparams = [], + ): url { global $CFG; $router = \core\di::get(\core\router::class); @@ -118,7 +170,7 @@ class util { $routename = self::get_route_name_for_callable($callable); - return new moodle_url( + return new url( url: $parser->fullUrlFor( new Uri($CFG->wwwroot), $routename, @@ -149,7 +201,7 @@ class util { } /** - * Get the instance of the \route\router\route attribute for the specified callable if one is available. + * Get the instance of the \core\router\route attribute for the specified callable if one is available. * * @param callable|array|string $callable * @return null|route The route if one was found. From 968183714d15fa34c19225f198c4736cb545cc42 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 11 Feb 2025 11:45:51 +0800 Subject: [PATCH 3/4] MDL-82565 core: Improve routed Error handling In previous versions of Moodle we recommended use of the fallback resource with /error/index.php. This is incompatible with the routing system because they use the same mechanism for responding to any unknown request. To better handle this we need to move the current error handler page to a routed page, and to provide a shim for the old location. At the same time we need to improve the Slim Error handling middleware to respond with our 404 handler for all 404s, except for those on API routes where we always respond with JSON. --- error/index.php | 77 +------- lib/classes/form/error_feedback.php | 6 +- .../controller/page_not_found_controller.php | 178 ++++++++++++++++++ lib/classes/route/shim/error_controller.php | 61 ++++++ lib/classes/router.php | 37 +++- lib/classes/router/error_handler.php | 58 ++++++ .../error_renderer.php} | 18 +- 7 files changed, 347 insertions(+), 88 deletions(-) create mode 100644 lib/classes/route/controller/page_not_found_controller.php create mode 100644 lib/classes/route/shim/error_controller.php create mode 100644 lib/classes/router/error_handler.php rename lib/classes/{output/routed_error_handler.php => router/error_renderer.php} (72%) diff --git a/error/index.php b/error/index.php index b85b7b989ea..a404dd51694 100644 --- a/error/index.php +++ b/error/index.php @@ -23,80 +23,7 @@ * ErrorDocument 404 /error/index.php * * @package core - * @copyright 2020 Brendan Heywood + * @copyright Brendan Heywood * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -require('../config.php'); // phpcs:ignore - -// Until we have a more robust routing api in place this is a very simple -// and clean way to handle arbitrary urls without a php extension. -if ($ME === '/.well-known/change-password') { - redirect(new moodle_url('/login/change_password.php')); -} - -$context = context_system::instance(); -$title = get_string('pagenotexisttitle', 'error'); -$PAGE->set_url('/error/index.php'); -$PAGE->set_context($context); -$PAGE->set_title($title); -$PAGE->set_heading($title); -$PAGE->navbar->add($title); - -// This allows the webserver to dictate wether the http status should remain -// what it would have been, or force it to be a 404. Under other conditions -// it could most often be a 403, 405 or a 50x error. -$code = optional_param('code', 0, PARAM_INT); -if ($code == 404) { - header("HTTP/1.0 404 Not Found"); -} - -$canmessage = has_capability('moodle/site:senderrormessage', $context); - -$supportuser = core_user::get_support_user(); - -// We can only message support if both the user has the capability -// and the support user is a real user. -if ($canmessage) { - $canmessage = core_user::is_real_user($supportuser->id); -} - -$mform = new \core\form\error_feedback($CFG->wwwroot . '/error/index.php'); - -if ($data = $mform->get_data()) { - - if (!$canmessage) { - redirect($CFG->wwwroot); - } - - // Send the message and redirect. - $message = new \core\message\message(); - $message->courseid = SITEID; - $message->component = 'moodle'; - $message->name = 'errors'; - $message->userfrom = $USER; - $message->userto = core_user::get_support_user(); - $message->subject = 'Error: '. $data->referer .' -> '. $data->requested; - $message->fullmessage = $data->text; - $message->fullmessageformat = FORMAT_PLAIN; - $message->fullmessagehtml = ''; - $message->smallmessage = ''; - $message->contexturl = $data->requested; - message_send($message); - - redirect($CFG->wwwroot, get_string('sendmessagesent', 'error', $data->requested), 5); - exit; -} - -echo $OUTPUT->header(); -echo $OUTPUT->notification(get_string('pagenotexist', 'error', s($ME)), 'error'); -echo $OUTPUT->supportemail(['class' => 'text-center d-block mb-3 fw-bold']); - -if ($canmessage) { - echo \html_writer::tag('h4', get_string('sendmessage', 'error')); - $mform->display(); -} else { - echo $OUTPUT->continue_button($CFG->wwwroot); -} - -echo $OUTPUT->footer(); +require_once("../r.php"); diff --git a/lib/classes/form/error_feedback.php b/lib/classes/form/error_feedback.php index b4e3fd6d7e2..5d1540ef978 100644 --- a/lib/classes/form/error_feedback.php +++ b/lib/classes/form/error_feedback.php @@ -37,10 +37,7 @@ require_once($CFG->libdir.'/formslib.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class error_feedback extends moodleform { - - /** - * Error form definition - */ + #[\Override] public function definition() { global $CFG; @@ -55,4 +52,3 @@ class error_feedback extends moodleform { $mform->addElement('submit', 'submitbutton', get_string('sendmessage', 'error')); } } - diff --git a/lib/classes/route/controller/page_not_found_controller.php b/lib/classes/route/controller/page_not_found_controller.php new file mode 100644 index 00000000000..1b7ec6bdef7 --- /dev/null +++ b/lib/classes/route/controller/page_not_found_controller.php @@ -0,0 +1,178 @@ +. + +namespace core\route\controller; + +use core\form\error_feedback; +use core\router; +use core\router\route; +use core\router\schema\parameters\query_parameter; +use core\router\util; +use core\url; +use core_user; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Page Not Found Controller. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class page_not_found_controller { + use \core\router\route_controller; + + /** + * Constructor for the page not found handler. + * + * @param \core\router $router The router. + */ + public function __construct( + /** @var router The routing engine */ + private router $router, + ) { + } + + /** + * Administer a course. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + #[route( + path: '/error', + method: ['GET', 'POST'], + queryparams: [ + new query_parameter( + name: 'code', + type: \core\param::INT, + default: 404, + ), + ], + )] + public function page_not_found_handler( + ServerRequestInterface $request, + ): ResponseInterface { + global $CFG, $PAGE, $OUTPUT, $ME; + + $context = \core\context\system::instance(); + $title = get_string('pagenotexisttitle', 'error'); + $PAGE->set_url('/error'); + $PAGE->set_context($context); + $PAGE->set_title($title); + $PAGE->set_heading($title); + $PAGE->navbar->add($title); + + // This allows the webserver to dictate wether the http status should remain + // what it would have been, or force it to be a 404. Under other conditions + // it could most often be a 403, 405 or a 50x error. + $code = $request->getQueryParams()['code'] ?? 404; + $response = $this->router->get_app()->getResponseFactory()->createResponse($code); + + $mform = $this->get_message_form($response); + if ($mform) { + if ($this->process_message_form($mform)) { + // The form was submitted. Redirect to the home page. + return util::redirect( + $response, + new url('/'), + ); + } + + // Form not submitted. We need to set the referer and request path because the URI may be different on submission. + $mform->set_data([ + 'referer' => $request->getHeaderLine('Referer'), + 'requested' => $request->getUri()->getPath(), + ]); + } + + $response->getBody()->write($OUTPUT->header()); + $response->getBody()->write($OUTPUT->notification(get_string('pagenotexist', 'error', s($ME)), 'error')); + $response->getBody()->write($OUTPUT->supportemail(['class' => 'text-center d-block mb-3 fw-bold'])); + + if ($mform) { + $response->getBody()->write(\html_writer::tag('h4', get_string('sendmessage', 'error'))); + $response->getBody()->write($mform->render()); + } else { + $response->getBody()->write($OUTPUT->continue_button($CFG->wwwroot)); + } + + $response->getBody()->write($OUTPUT->footer()); + + return $response; + } + + /** + * Get the message form, or null if it should not be displayed. + * + * @param \Psr\Http\Message\ResponseInterface $response + * @return error_feedback|null + */ + protected function get_message_form( + ResponseInterface $response, + ): ?\moodleform { + $canmessage = has_capability('moodle/site:senderrormessage', \core\context\system::instance()); + $supportuser = core_user::get_support_user(); + + // We can only message support if both the user has the capability + // and the support user is a real user. + $canmessage = $canmessage && core_user::is_real_user($supportuser->id); + + if (!$canmessage) { + return null; + } + + return new error_feedback(util::get_path_for_callable([self::class, 'page_not_found_handler'], [], [])->out()); + } + + /** + * Process the message form. + * + * If the form was submitted, send the message and return a redirect response. + * + * @param error_feedback $mform + * @return bool + */ + protected function process_message_form( + error_feedback $mform, + ): bool { + global $CFG, $USER; + if ($data = $mform->get_data()) { + // Send the message and redirect. + $message = new \core\message\message(); + $message->courseid = SITEID; + $message->component = 'moodle'; + $message->name = 'errors'; + $message->userfrom = $USER; + $message->userto = core_user::get_support_user(); + $message->subject = 'Error: ' . $data->referer . ' -> ' . $data->requested; + $message->fullmessage = $data->text; + $message->fullmessageformat = FORMAT_PLAIN; + $message->fullmessagehtml = ''; + $message->smallmessage = ''; + $message->contexturl = $data->requested; + message_send($message); + + \core\notification::success(get_string('sendmessagesent', 'error', $data->requested)); + + return true; + } + + return false; + } +} diff --git a/lib/classes/route/shim/error_controller.php b/lib/classes/route/shim/error_controller.php new file mode 100644 index 00000000000..9c2611a2420 --- /dev/null +++ b/lib/classes/route/shim/error_controller.php @@ -0,0 +1,61 @@ +. + +namespace core\route\shim; + +use core\param; +use core\router\route; +use core\router\schema\parameters\query_parameter; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Redirect requests for /error/index.php and /error to the page_not_found_controller. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class error_controller { + /** + * Shim /error/index.php to /core/error + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + #[route( + path: '/error[/index.php]', + queryparams: [ + new query_parameter( + name: 'code', + type: param::INT, + description: 'The HTTP Code', + ), + ], + )] + public function administer_course( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + $params = $request->getQueryParams(); + return \core\router\util::redirect_to_callable( + $request, + $response, + [\core\route\controller\page_not_found_controller::class, 'page_not_found_handler'], + ); + } +} diff --git a/lib/classes/router.php b/lib/classes/router.php index 8b3a66234b3..3a5e70bea0a 100644 --- a/lib/classes/router.php +++ b/lib/classes/router.php @@ -16,7 +16,6 @@ namespace core; -use core\output\routed_error_handler; use core\router\middleware\cors_middleware; use core\router\middleware\error_handling_middleware; use core\router\middleware\moodle_api_authentication_middleware; @@ -33,8 +32,10 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Slim\App; +use Slim\Exception\HttpForbiddenException; +use Slim\Exception\HttpNotFoundException; use Slim\Interfaces\RouteGroupInterface; -use Slim\Interfaces\RouteInterface; +use Slim\Middleware\ErrorMiddleware; /** * Moodle Router. @@ -189,10 +190,36 @@ class router { // This must be done before the Routing Middleware to ensure that the route is matched correctly. $this->app->add(di::get(uri_normalisation_middleware::class)); + // Add the Error Handling Middleware to the App. + $this->add_error_handler_middleware(); + } + + /** + * Add the Error Handling Middleware to the RouteGroup. + */ + protected function add_error_handler_middleware(): void { // Add the Error Handling Middleware and configure it to show Moodle Errors for HTML pages. - $errormiddleware = $this->app->addErrorMiddleware(true, true, true); - $errorhandler = $errormiddleware->getDefaultErrorHandler(); - $errorhandler->registerErrorRenderer('text/html', routed_error_handler::class); + $errormiddleware = new ErrorMiddleware( + $this->app->getCallableResolver(), + $this->app->getResponseFactory(), + displayErrorDetails: true, + logErrors: true, + logErrorDetails: true, + ); + + // Set a custom error handler for the HttpNotFoundException and HttpForbiddenException. + // We route these to a custom error handler to ensure that the error is displayed with a feedback form. + $errormiddleware->setErrorHandler( + [ + HttpNotFoundException::class, + HttpForbiddenException::class, + ], + new router\error_handler($this->app), + ); + + $errormiddleware->getDefaultErrorHandler()->registerErrorRenderer('text/html', router\error_renderer::class); + + $this->app->add($errormiddleware); } /** diff --git a/lib/classes/router/error_handler.php b/lib/classes/router/error_handler.php new file mode 100644 index 00000000000..db80b3fb033 --- /dev/null +++ b/lib/classes/router/error_handler.php @@ -0,0 +1,58 @@ +. + +namespace core\router; + +use Psr\Http\Message\ServerRequestInterface; +use Slim\App; +use Slim\Handlers\ErrorHandler; + +/** + * An Eerror Handler implementation for Moodle which is aware of the REST API. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class error_handler extends ErrorHandler { + /** + * Construct a new Error Handler. + * + * @param \Slim\App $app + */ + public function __construct( + App $app, + ) { + parent::__construct( + $app->getCallableResolver(), + $app->getResponseFactory(), + ); + + $this->registerErrorRenderer('text/html', error_renderer::class); + } + + #[\Override] + protected function determineContentType(ServerRequestInterface $request): ?string { + // For anything hitting /rest/api/v2 we will default to JSON. + $restbase = (new \core\url('/rest/api/v2/'))->get_path(); + if (substr($request->getUri()->getPath(), 0, strlen($restbase)) === $restbase) { + return 'application/json'; + } + + // Fall back to the default behaviour of using the Accept header. + return parent::determineContentType($request); + } +} diff --git a/lib/classes/output/routed_error_handler.php b/lib/classes/router/error_renderer.php similarity index 72% rename from lib/classes/output/routed_error_handler.php rename to lib/classes/router/error_renderer.php index 6a80126e7e7..e26449c2d4d 100644 --- a/lib/classes/output/routed_error_handler.php +++ b/lib/classes/router/error_renderer.php @@ -14,8 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace core\output; +namespace core\router; +use Slim\Exception\HttpNotFoundException; use Slim\Interfaces\ErrorRendererInterface; use Throwable; @@ -28,7 +29,7 @@ use Throwable; * @copyright Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class routed_error_handler implements ErrorRendererInterface { +class error_renderer implements ErrorRendererInterface { #[\Override] public function __invoke(Throwable $exception, bool $displayErrorDetails): string { // @codeCoverageIgnoreStart @@ -38,8 +39,19 @@ class routed_error_handler implements ErrorRendererInterface { } // @codeCoverageIgnoreEnd + if ($exception instanceof HttpNotFoundException) { + // This is a 404 error. + $controller = \core\di::get(\core\route\controller\page_not_found_controller::class); + + return $controller->page_not_found_handler( + $exception->getRequest(), + )->getBody(); + } + if ($whoops = get_whoops()) { - $whoops->sendHttpCode($exception->getCode()); + if ($exception instanceof \Slim\Exception\HttpException) { + $whoops->sendHttpCode($exception->getCode()); + } $whoops->handleException($exception); } else { default_exception_handler($exception); From f23259c49cae4747a02c5301ab452bf45c67d726 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 26 Feb 2025 10:56:11 +0800 Subject: [PATCH 4/4] MDL-82565 core_course: Migrate tag management to route --- course/{ => classes/form}/tags_form.php | 37 +++--- .../route/controller/tags_controller.php | 105 ++++++++++++++++++ course/classes/route/shim/course_routes.php | 38 +++++++ course/tags.php | 46 +------- .../router/parameters/query_returnurl.php | 58 ++++++++++ lib/navigationlib.php | 5 +- .../parameters/query_returnurl_test.php | 47 ++++++++ 7 files changed, 271 insertions(+), 65 deletions(-) rename course/{ => classes/form}/tags_form.php (63%) create mode 100644 course/classes/route/controller/tags_controller.php create mode 100644 lib/classes/router/parameters/query_returnurl.php create mode 100644 lib/tests/router/parameters/query_returnurl_test.php diff --git a/course/tags_form.php b/course/classes/form/tags_form.php similarity index 63% rename from course/tags_form.php rename to course/classes/form/tags_form.php index fc54c03f11e..88f275d2846 100644 --- a/course/tags_form.php +++ b/course/classes/form/tags_form.php @@ -14,40 +14,37 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Edit course tags form - * - * @package core_course - * @copyright 2015 Marina Glancy - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ +namespace core_course\form; defined('MOODLE_INTERNAL') || die(); -require_once($CFG->libdir.'/formslib.php'); +require_once($CFG->libdir . '/formslib.php'); /** - * Edit course tags form + * Edit course tags form. * * @package core_course - * @copyright 2015 Marina Glancy + * @copyright Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class coursetags_form extends moodleform { +class tags_form extends \moodleform { + #[\Override] + public function definition(): void { + $mform = $this->_form; - /** - * Form definition - */ - public function definition() { - $mform = $this->_form; - - $mform->addElement('tags', 'tags', get_string('tags'), - array('itemtype' => 'course', 'component' => 'core')); + $mform->addElement( + 'tags', + 'tags', + get_string('tags'), + [ + 'itemtype' => 'course', + 'component' => 'core', + ], + ); $mform->addElement('hidden', 'id', null); $mform->setType('id', PARAM_INT); $this->add_action_buttons(); - } } diff --git a/course/classes/route/controller/tags_controller.php b/course/classes/route/controller/tags_controller.php new file mode 100644 index 00000000000..af7717412dc --- /dev/null +++ b/course/classes/route/controller/tags_controller.php @@ -0,0 +1,105 @@ +. + +namespace core_course\route\controller; + +use core\exception\moodle_exception; +use core\router\parameters\query_returnurl; +use core\router\route; +use core\router\require_login; +use core\router\util; +use core_course\form\tags_form; +use core_tag_tag; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Tag management for courses. + * + * @package core_course + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class tags_controller { + use \core\router\route_controller; + + /** + * Administer course tags. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param \stdClass $course + * @param \core\context\course $coursecontext + * @return ResponseInterface + */ + #[route( + path: '/{course}/tags', + method: ['GET', 'POST'], + pathtypes: [ + new \core\router\parameters\path_course(), + ], + queryparams: [ + new query_returnurl(), + ], + requirelogin: new require_login( + requirelogin: true, + courseattributename: 'course', + ), + )] + public function administer_tags( + ServerRequestInterface $request, + ResponseInterface $response, + \stdClass $course, + \core\context\course $coursecontext, + ): ResponseInterface { + global $CFG, $OUTPUT, $PAGE; + + if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $coursecontext)) { + throw new moodle_exception('coursehidden', '', $CFG->wwwroot .'/'); + } + require_capability('moodle/course:tag', $coursecontext); + + $PAGE->set_course($course); + $PAGE->set_pagelayout('incourse'); + $PAGE->set_url(util::get_path_for_callable([self::class, 'administer_tags'], [ + 'course' => $course->id, + ])); + $PAGE->set_title(get_string('coursetags', 'tag')); + $PAGE->set_heading($course->fullname); + + $form = new tags_form(); + $data = [ + 'id' => $course->id, + 'tags' => core_tag_tag::get_item_tags_array('core', 'course', $course->id), + ]; + $form->set_data($data); + + $redirecturl = $this->get_param($request, 'returnurl') ?? course_get_url($course); + if ($form->is_cancelled()) { + return util::redirect($response, $redirecturl); + } else if ($data = $form->get_data()) { + core_tag_tag::set_item_tags('core', 'course', $course->id, $coursecontext, $data->tags); + return util::redirect($response, $redirecturl); + } + + $response->getBody()->write($OUTPUT->header()); + $response->getBody()->write($OUTPUT->heading(get_string('coursetags', 'tag'))); + $response->getBody()->write($form->render()); + $response->getBody()->write($OUTPUT->footer()); + + return $response; + } +} diff --git a/course/classes/route/shim/course_routes.php b/course/classes/route/shim/course_routes.php index 2eb2eb6b10c..6f5194ff339 100644 --- a/course/classes/route/shim/course_routes.php +++ b/course/classes/route/shim/course_routes.php @@ -64,4 +64,42 @@ final class course_routes { excludeparams: ['courseid'], ); } + + /** + * Shim /course/tags.php to the course tag management controller. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + #[route( + path: '/tags.php', + queryparams: [ + new query_parameter( + name: 'id', + type: param::INT, + description: 'The course ID', + required: true, + ), + new query_parameter( + name: 'returnurl', + type: param::LOCALURL, + description: 'The return URL', + required: false, + ), + ], + )] + public function administer_tags( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + $params = $request->getQueryParams(); + return self::redirect_to_callable( + $request, + $response, + [\core_course\route\controller\tags_controller::class, 'administer_tags'], + pathparams: $params + ['course' => $params['id']], + excludeparams: ['id'], + ); + } } diff --git a/course/tags.php b/course/tags.php index 8c78af7daf5..159dd737ded 100644 --- a/course/tags.php +++ b/course/tags.php @@ -20,48 +20,6 @@ * @package core_course * @copyright 2015 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since 5.0 */ - -require_once("../config.php"); -require_once($CFG->dirroot . '/course/tags_form.php'); - -$id = required_param('id', PARAM_INT); // Course id. -$returnurl = optional_param('return', null, PARAM_LOCALURL); -$course = get_course($id); - -require_login(); - -// Check capabilities but do not call require_login($course) - the user does not have to be enrolled. -$context = context_course::instance($course->id); -if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) { - throw new \moodle_exception('coursehidden', '', $CFG->wwwroot .'/'); -} -require_capability('moodle/course:tag', $context); -if (!core_tag_tag::is_enabled('core', 'course')) { - throw new \moodle_exception('tagsaredisabled', 'tag'); -} - -$PAGE->set_course($course); -$PAGE->set_pagelayout('incourse'); -$PAGE->set_url('/course/tags.php', array('id' => $course->id)); -$PAGE->set_title(get_string('coursetags', 'tag')); -$PAGE->set_heading($course->fullname); - -$form = new coursetags_form(); -$data = array('id' => $course->id, 'tags' => core_tag_tag::get_item_tags_array('core', 'course', $course->id)); -$form->set_data($data); - -$redirecturl = $returnurl ? new moodle_url($returnurl) : course_get_url($course); -if ($form->is_cancelled()) { - redirect($redirecturl); -} else if ($data = $form->get_data()) { - core_tag_tag::set_item_tags('core', 'course', $course->id, context_course::instance($course->id), $data->tags); - redirect($redirecturl); -} - -echo $OUTPUT->header(); -echo $OUTPUT->heading(get_string('coursetags', 'tag')); - -$form->display(); - -echo $OUTPUT->footer(); +require_once("../r.php"); diff --git a/lib/classes/router/parameters/query_returnurl.php b/lib/classes/router/parameters/query_returnurl.php new file mode 100644 index 00000000000..f9ca5661ab9 --- /dev/null +++ b/lib/classes/router/parameters/query_returnurl.php @@ -0,0 +1,58 @@ +. + +namespace core\router\parameters; + +use core\param; +use core\router\schema\parameters\mapped_property_parameter; +use core\router\schema\referenced_object; +use Psr\Http\Message\ServerRequestInterface; + +/** + * A return URL parameter referenced in the query parameters. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class query_returnurl extends \core\router\schema\parameters\query_parameter implements + mapped_property_parameter, + referenced_object +{ + /** + * Create a new query_returnurl parameter. + * + * @param string $name The name of the parameter to use for the return URL + * @param mixed ...$extra Additional arguments + */ + public function __construct( + string $name = 'returnurl', + ...$extra, + ) { + $extra['name'] = $name; + $extra['type'] = param::LOCALURL; + + parent::__construct(...$extra); + } + + #[\Override] + public function add_attributes_for_parameter_value( + ServerRequestInterface $request, + string $value, + ): ServerRequestInterface { + return $request->withAttribute($this->name, new \core\url($value)); + } +} diff --git a/lib/navigationlib.php b/lib/navigationlib.php index 180caf2220a..9d2ddec02e9 100644 --- a/lib/navigationlib.php +++ b/lib/navigationlib.php @@ -4859,7 +4859,10 @@ class settings_navigation extends navigation_node { } if (!$adminoptions->update && $adminoptions->tags) { - $url = new moodle_url('/course/tags.php', array('id' => $course->id)); + $url = \core\router\util::get_path_for_callable([ + \core_course\route\controller\tags_controller::class, + 'administer_tags', + ], ['course' => $course->id]); $coursenode->add(get_string('coursetags', 'tag'), $url, self::TYPE_SETTING, null, 'coursetags', new pix_icon('i/settings', '')); $coursenode->get('coursetags')->set_force_into_more_menu(); } diff --git a/lib/tests/router/parameters/query_returnurl_test.php b/lib/tests/router/parameters/query_returnurl_test.php new file mode 100644 index 00000000000..eaeca3d2734 --- /dev/null +++ b/lib/tests/router/parameters/query_returnurl_test.php @@ -0,0 +1,47 @@ +. + +namespace core\router\parameters; + +use core\tests\router\route_testcase; +use core\url; +use GuzzleHttp\Psr7\ServerRequest; + +/** + * Tests for the query_returnurl parameter. + * + * @package core + * @category test + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(query_returnurl::class)] +final class query_returnurl_test extends route_testcase { + public function test_returnurl_specified(): void { + $this->resetAfterTest(); + + $param = new query_returnurl(); + $request = $param->add_attributes_for_parameter_value( + new ServerRequest('GET', '/course/edit'), + '/course/view', + ); + + $this->assertInstanceOf(url::class, $request->getAttribute('returnurl')); + $this->assertTrue( + $request->getAttribute('returnurl')->compare(new url('/course/view')), + ); + } +}