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/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/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/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
new file mode 100644
index 00000000000..6f5194ff339
--- /dev/null
+++ b/course/classes/route/shim/course_routes.php
@@ -0,0 +1,105 @@
+.
+
+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'],
+ );
+ }
+
+ /**
+ * 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/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/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/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/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/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 f74ffa86834..3a5e70bea0a 100644
--- a/lib/classes/router.php
+++ b/lib/classes/router.php
@@ -16,9 +16,10 @@
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;
@@ -31,7 +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\Middleware\ErrorMiddleware;
/**
* Moodle Router.
@@ -99,19 +103,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;
}
@@ -186,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);
}
/**
@@ -200,6 +230,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 +247,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/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);
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/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/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..69684414ddb 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,11 +39,11 @@ 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 {
- throw new \Slim\Exception\HttpNotFoundException($request);
+ return util::throw_page_not_found($request, $response);
}
/**
@@ -65,13 +53,11 @@ trait route_controller {
* @param string|moodle_url $url
* @return ResponseInterface
*/
- protected function redirect(
+ public static function redirect(
ResponseInterface $response,
string|moodle_url $url,
): ResponseInterface {
- return $response
- ->withStatus(302)
- ->withHeader('Location', (string) $url);
+ return util::redirect($response, $url);
}
/**
@@ -85,7 +71,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,
@@ -93,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 $this->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..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.
@@ -227,4 +279,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/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')),
+ );
+ }
+}
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',
+ ];
+ }
}