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', + ]; + } }