. namespace core; use core\context\user as context_user; use core\exception\coding_exception; use core\exception\moodle_exception; use Psr\Http\Message\UriInterface; /** * Class for creating and manipulating urls. * * It can be used in moodle pages where config.php has been included without any further includes. * * It is useful for manipulating urls with long lists of params. * One situation where it will be useful is a page which links to itself to perform various actions * and / or to process form data. A url object: * can be created for a page to refer to itself with all the proper get params being passed from page call to * page call and methods can be used to output a url including all the params, optionally adding and overriding * params and can also be used to * - output the url without any get params * - and output the params as hidden fields to be output within a form * * @copyright 2007 jamiesensei * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @package core */ class url { /** * Scheme, ex.: http, https * @var string */ protected $scheme = ''; /** * Hostname. * @var string */ protected $host = ''; /** * Port number, empty means default 80 or 443 in case of http. * @var int */ protected $port = ''; /** * Username for http auth. * @var string */ protected $user = ''; /** * Password for http auth. * @var string */ protected $pass = ''; /** * Script path. * @var string */ protected $path = ''; /** * Optional slash argument value. * @var string */ protected $slashargument = ''; /** * Anchor, may be also empty, null means none. * @var string */ protected $anchor = null; /** * Url parameters as associative array. * @var array */ protected $params = []; /** * Create new instance of url. * * @param self|string $url - moodle_url means make a copy of another * moodle_url and change parameters, string means full url or shortened * form (ex.: '/course/view.php'). It is strongly encouraged to not include * query string because it may result in double encoded values. Use the * $params instead. For admin URLs, just use /admin/script.php, this * class takes care of the $CFG->admin issue. * @param null|array $params these params override current params or add new * @param string $anchor The anchor to use as part of the URL if there is one. * @throws moodle_exception */ public function __construct( $url, ?array $params = null, $anchor = null, ) { global $CFG; if ($url instanceof self) { $this->scheme = $url->scheme; $this->host = $url->host; $this->port = $url->port; $this->user = $url->user; $this->pass = $url->pass; $this->path = $url->path; $this->slashargument = $url->slashargument; $this->params = $url->params; $this->anchor = $url->anchor; } else { $url = $url ?? ''; // Detect if anchor used. $apos = strpos($url, '#'); if ($apos !== false) { $anchor = substr($url, $apos); $anchor = ltrim($anchor, '#'); $this->set_anchor($anchor); $url = substr($url, 0, $apos); } // Normalise shortened form of our url ex.: '/course/view.php'. if (strpos($url, '/') === 0) { $url = $CFG->wwwroot . $url; } if ($CFG->admin !== 'admin') { if (strpos($url, "$CFG->wwwroot/admin/") === 0) { $url = str_replace("$CFG->wwwroot/admin/", "$CFG->wwwroot/$CFG->admin/", $url); } } // Parse the $url. $parts = parse_url($url); if ($parts === false) { throw new moodle_exception('invalidurl'); } if (isset($parts['query'])) { // Note: the values may not be correctly decoded, url parameters should be always passed as array. parse_str(str_replace('&', '&', $parts['query']), $this->params); } unset($parts['query']); foreach ($parts as $key => $value) { $this->$key = $value; } // Detect slashargument value from path - we do not support directory names ending with .php. $pos = strpos($this->path, '.php/'); if ($pos !== false) { $this->slashargument = substr($this->path, $pos + 4); $this->path = substr($this->path, 0, $pos + 4); } } $this->params($params); if ($anchor !== null) { $this->anchor = (string)$anchor; } } /** * Add an array of params to the params for this url. * * The added params override existing ones if they have the same name. * * @param null|array $params Defaults to null. If null then returns all params. * @return array Array of Params for url. * @throws coding_exception */ public function params(?array $params = null) { $params = (array)$params; foreach ($params as $key => $value) { if (is_int($key)) { throw new coding_exception('Url parameters can not have numeric keys!'); } if (!is_string($value)) { if (is_array($value)) { throw new coding_exception('Url parameters values can not be arrays!'); } if (is_object($value) && !method_exists($value, '__toString')) { throw new coding_exception('Url parameters values can not be objects, unless __toString() is defined!'); } } $this->params[$key] = (string)$value; } return $this->params; } /** * Remove all params if no arguments passed. * Remove selected params if arguments are passed. * * Can be called as either remove_params('param1', 'param2') * or remove_params(array('param1', 'param2')). * * @param string[]|string ...$params either an array of param names, or 1..n string params to remove as args. * @return array url parameters */ public function remove_params(...$params) { if (empty($params)) { return $this->params; } $firstparam = reset($params); if (is_array($firstparam)) { $params = $firstparam; } foreach ($params as $param) { unset($this->params[$param]); } return $this->params; } /** * Remove all url parameters. * * @param array $unused Unused param */ public function remove_all_params($unused = null) { $this->params = []; $this->slashargument = ''; } /** * Add a param to the params for this url. * * The added param overrides existing one if they have the same name. * * @param string $paramname name * @param string $newvalue Param value. If new value specified current value is overriden or parameter is added * @return mixed string parameter value, null if parameter does not exist */ public function param($paramname, $newvalue = '') { if (func_num_args() > 1) { // Set new value. $this->params([$paramname => $newvalue]); } if (isset($this->params[$paramname])) { return $this->params[$paramname]; } else { return null; } } /** * Merges parameters and validates them * * @param null|array $overrideparams * @return array merged parameters * @throws coding_exception */ protected function merge_overrideparams(?array $overrideparams = null) { $overrideparams = (array)$overrideparams; $params = $this->params; foreach ($overrideparams as $key => $value) { if (is_int($key)) { throw new coding_exception('Overridden parameters can not have numeric keys!'); } if (is_array($value)) { throw new coding_exception('Overridden parameters values can not be arrays!'); } if (is_object($value) && !method_exists($value, '__toString')) { throw new coding_exception('Overridden parameters values can not be objects, unless __toString() is defined!'); } $params[$key] = (string)$value; } return $params; } /** * Get the params as as a query string. * * This method should not be used outside of this method. * * @param bool $escaped Use & as params separator instead of plain & * @param null|array $overrideparams params to add to the output params, these * override existing ones with the same name. * @return string query string that can be added to a url. */ public function get_query_string($escaped = true, ?array $overrideparams = null) { $arr = []; if ($overrideparams !== null) { $params = $this->merge_overrideparams($overrideparams); } else { $params = $this->params; } foreach ($params as $key => $val) { if (is_array($val)) { foreach ($val as $index => $value) { $arr[] = rawurlencode($key . '[' . $index . ']') . "=" . rawurlencode($value); } } else { if (isset($val) && $val !== '') { $arr[] = rawurlencode($key) . "=" . rawurlencode($val); } else { $arr[] = rawurlencode($key); } } } if ($escaped) { return implode('&', $arr); } else { return implode('&', $arr); } } /** * Get the url params as an array of key => value pairs. * * This helps in handling cases where url params contain arrays. * * @return array params array for templates. */ public function export_params_for_template(): array { $data = []; foreach ($this->params as $key => $val) { if (is_array($val)) { foreach ($val as $index => $value) { $data[] = ['name' => $key . '[' . $index . ']', 'value' => $value]; } } else { $data[] = ['name' => $key, 'value' => $val]; } } return $data; } /** * Shortcut for printing of encoded URL. * * @return string */ public function __toString() { return $this->out(true); } /** * Output url. * * If you use the returned URL in HTML code, you want the escaped ampersands. If you use * the returned URL in HTTP headers, you want $escaped=false. * * @param bool $escaped Use & as params separator instead of plain & * @param null|array $overrideparams params to add to the output url, these override existing ones with the same name. * @return string Resulting URL */ public function out($escaped = true, ?array $overrideparams = null) { global $CFG; if (!is_bool($escaped)) { debugging('Escape parameter must be of type boolean, ' . gettype($escaped) . ' given instead.'); } $url = $this; // Allow url's to be rewritten by a plugin. if (isset($CFG->urlrewriteclass) && !isset($CFG->upgraderunning)) { $class = $CFG->urlrewriteclass; $pluginurl = $class::url_rewrite($url); if ($pluginurl instanceof url) { $url = $pluginurl; } } return $url->raw_out($escaped, $overrideparams); } /** * Output url without any rewrites * * This is identical in signature and use to out() but doesn't call the rewrite handler. * * @param bool $escaped Use & as params separator instead of plain & * @param null|array $overrideparams params to add to the output url, these override existing ones with the same name. * @return string Resulting URL */ public function raw_out($escaped = true, ?array $overrideparams = null) { if (!is_bool($escaped)) { debugging('Escape parameter must be of type boolean, ' . gettype($escaped) . ' given instead.'); } $uri = $this->out_omit_querystring() . $this->slashargument; $querystring = $this->get_query_string($escaped, $overrideparams); if ($querystring !== '') { $uri .= '?' . $querystring; } $uri .= $this->get_encoded_anchor(); return $uri; } /** * Encode the anchor according to RFC 3986. * * @return string The encoded anchor */ public function get_encoded_anchor(): string { if (is_null($this->anchor)) { return ''; } // RFC 3986 allows the following characters in a fragment without them being encoded: // pct-encoded: "%" HEXDIG HEXDIG // unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~" / // sub-delims: "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" / ":" / "@" // fragment: "/" / "?" // // All other characters should be encoded. // These should not be encoded in the fragment unless they were already encoded. // The following characters are allowed in the fragment without encoding. // In addition to this list is pct-encoded, but we can't easily handle this with a regular expression. $allowed = 'a-zA-Z0-9\\-._~!$&\'()*+,;=:@\/?'; $anchor = '#'; $remainder = $this->anchor; do { // Split the string on any %. $parts = explode('%', $remainder, 2); $anchorparts = array_shift($parts); // The first part can go through our preg_replace_callback to quote any relevant characters. $anchor .= preg_replace_callback( '/[^' . $allowed . ']/', fn ($matches) => rawurlencode($matches[0]), $anchorparts, ); // The second part _might_ be a valid pct-encoded character. if (count($parts) === 0) { break; } // If the second part is a valid pct-encoded character, append it to the anchor. $remainder = array_shift($parts); if (preg_match('/^[a-fA-F0-9]{2}/', $remainder, $matches)) { $anchor .= "%{$matches[0]}"; $remainder = substr($remainder, 2); } else { // This was not a valid pct-encoded character. Encode the % and continue with the next part. $anchor .= rawurlencode('%'); } } while (strlen($remainder) > 0); return $anchor; } /** * Returns url without parameters, everything before '?'. * * @param bool $includeanchor if {@see self::anchor} is defined, should it be returned? * @return string */ public function out_omit_querystring($includeanchor = false) { $uri = $this->scheme ? $this->scheme . ':' . ((strtolower($this->scheme) == 'mailto') ? '' : '//') : ''; $uri .= $this->user ? $this->user . ($this->pass ? ':' . $this->pass : '') . '@' : ''; $uri .= $this->host ? $this->host : ''; $uri .= $this->port ? ':' . $this->port : ''; $uri .= $this->path ? $this->path : ''; if ($includeanchor) { $uri .= $this->get_encoded_anchor(); } return $uri; } /** * Compares this url with another. * * See documentation of constants for an explanation of the comparison flags. * * @param self $url The moodle_url object to compare * @param int $matchtype The type of comparison (URL_MATCH_BASE, URL_MATCH_PARAMS, URL_MATCH_EXACT) * @return bool */ public function compare(self $url, $matchtype = URL_MATCH_EXACT) { $baseself = $this->out_omit_querystring(); $baseother = $url->out_omit_querystring(); // Append index.php if there is no specific file. if (substr($baseself, -1) == '/') { $baseself .= 'index.php'; } if (substr($baseother, -1) == '/') { $baseother .= 'index.php'; } // Compare the two base URLs. if ($baseself != $baseother) { return false; } if ($matchtype == URL_MATCH_BASE) { return true; } $urlparams = $url->params(); foreach ($this->params() as $param => $value) { if ($param == 'sesskey') { continue; } if (!array_key_exists($param, $urlparams) || $urlparams[$param] != $value) { return false; } } if ($matchtype == URL_MATCH_PARAMS) { return true; } foreach ($urlparams as $param => $value) { if ($param == 'sesskey') { continue; } if (!array_key_exists($param, $this->params()) || $this->param($param) != $value) { return false; } } if ($url->anchor !== $this->anchor) { return false; } return true; } /** * Sets the anchor for the URI (the bit after the hash) * * @param string $anchor null means remove previous */ public function set_anchor($anchor) { if (is_null($anchor)) { // Remove. $this->anchor = null; } else { $this->anchor = $anchor; } } /** * Sets the scheme for the URI (the bit before ://) * * @param string $scheme */ public function set_scheme($scheme) { // See http://www.ietf.org/rfc/rfc3986.txt part 3.1. if (preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*$/', $scheme)) { $this->scheme = $scheme; } else { throw new coding_exception('Bad URL scheme.'); } } /** * Sets the url slashargument value. * * @param string $path usually file path * @param string $parameter name of page parameter if slasharguments not supported * @param bool $supported usually null, then it depends on $CFG->slasharguments, use true or false for other servers */ public function set_slashargument($path, $parameter = 'file', $supported = null) { global $CFG; if (is_null($supported)) { $supported = !empty($CFG->slasharguments); } if ($supported) { $parts = explode('/', $path); $parts = array_map('rawurlencode', $parts); $path = implode('/', $parts); $this->slashargument = $path; unset($this->params[$parameter]); } else { $this->slashargument = ''; $this->params[$parameter] = $path; } } // Static factory methods. /** * Create a new url instance from a UriInterface. * * @param UriInterface $uri * @return self */ public static function from_uri(UriInterface $uri): self { $url = new self( url: $uri->getScheme() . '://' . $uri->getAuthority() . $uri->getPath(), anchor: $uri->getFragment() ?: null, ); $params = $uri->getQuery(); foreach (explode('&', $params) as $param) { $url->param(...explode('=', $param, 2)); } return $url; } /** * Create a new moodle_url instance from routed path. * * @param string $path The routed path * @param null|array $params The path parameters * @param null|string $anchor The anchor * @return self */ public static function routed_path( string $path, ?array $params = null, ?string $anchor = null, ): self { global $CFG; if (!$CFG->routerconfigured) { $path = '/r.php/' . ltrim($path, '/'); } $url = new self($path, $params, $anchor); return $url; } /** * General moodle file url. * * @param string $urlbase the script serving the file * @param string $path * @param bool $forcedownload * @return self */ public static function make_file_url($urlbase, $path, $forcedownload = false) { $params = []; if ($forcedownload) { $params['forcedownload'] = 1; } $url = new self($urlbase, $params); $url->set_slashargument($path); return $url; } /** * Factory method for creation of url pointing to plugin file. * * Please note this method can be used only from the plugins to * create urls of own files, it must not be used outside of plugins! * * @param int $contextid * @param string $component * @param string $area * @param ?int $itemid * @param string $pathname * @param string $filename * @param bool $forcedownload * @param mixed $includetoken Whether to use a user token when displaying this group image. * True indicates to generate a token for current user, and integer value indicates to generate a token for the * user whose id is the value indicated. * If the group picture is included in an e-mail or some other location where the audience is a specific * user who will not be logged in when viewing, then we use a token to authenticate the user. * @return url */ public static function make_pluginfile_url( $contextid, $component, $area, $itemid, $pathname, $filename, $forcedownload = false, $includetoken = false ) { global $CFG, $USER; $path = []; if ($includetoken) { $urlbase = "$CFG->wwwroot/tokenpluginfile.php"; $userid = $includetoken === true ? $USER->id : $includetoken; $token = get_user_key('core_files', $userid); if ($CFG->slasharguments) { $path[] = $token; } } else { $urlbase = "$CFG->wwwroot/pluginfile.php"; } $path[] = $contextid; $path[] = $component; $path[] = $area; if ($itemid !== null) { $path[] = $itemid; } $path = "/" . implode('/', $path) . "{$pathname}{$filename}"; $url = self::make_file_url($urlbase, $path, $forcedownload, $includetoken); if ($includetoken && empty($CFG->slasharguments)) { $url->param('token', $token); } return $url; } /** * Factory method for creation of url pointing to plugin file. * This method is the same that make_pluginfile_url but pointing to the webservice pluginfile.php script. * It should be used only in external functions. * * @since 2.8 * @param int $contextid * @param string $component * @param string $area * @param int $itemid * @param string $pathname * @param string $filename * @param bool $forcedownload * @return url */ public static function make_webservice_pluginfile_url( $contextid, $component, $area, $itemid, $pathname, $filename, $forcedownload = false ) { global $CFG; $urlbase = "$CFG->wwwroot/webservice/pluginfile.php"; if ($itemid === null) { return self::make_file_url($urlbase, "/$contextid/$component/$area" . $pathname . $filename, $forcedownload); } else { return self::make_file_url($urlbase, "/$contextid/$component/$area/$itemid" . $pathname . $filename, $forcedownload); } } /** * Factory method for creation of url pointing to draft file of current user. * * @param int $draftid draft item id * @param string $pathname * @param string $filename * @param bool $forcedownload * @return url */ public static function make_draftfile_url($draftid, $pathname, $filename, $forcedownload = false) { global $CFG, $USER; $urlbase = "$CFG->wwwroot/draftfile.php"; $context = context_user::instance($USER->id); return self::make_file_url($urlbase, "/$context->id/user/draft/$draftid" . $pathname . $filename, $forcedownload); } /** * Factory method for creating of links to legacy course files. * * @param int $courseid * @param string $filepath * @param bool $forcedownload * @return url */ public static function make_legacyfile_url($courseid, $filepath, $forcedownload = false) { global $CFG; $urlbase = "$CFG->wwwroot/file.php"; return self::make_file_url($urlbase, '/' . $courseid . '/' . $filepath, $forcedownload); } /** * Checks if URL is relative to $CFG->wwwroot. * * @return bool True if URL is relative to $CFG->wwwroot; otherwise, false. */ public function is_local_url(): bool { global $CFG; $url = $this->out(); // Does URL start with wwwroot? Otherwise, URL isn't relative to wwwroot. return ( ($url === $CFG->wwwroot) || (strpos($url, $CFG->wwwroot . '/') === 0) ); } /** * Returns URL as relative path from $CFG->wwwroot * * Can be used for passing around urls with the wwwroot stripped * * @param boolean $escaped Use & as params separator instead of plain & * @param ?array $overrideparams params to add to the output url, these override existing ones with the same name. * @return string Resulting URL * @throws coding_exception if called on a non-local url */ public function out_as_local_url($escaped = true, ?array $overrideparams = null) { global $CFG; // URL should be relative to wwwroot. If not then throw exception. if ($this->is_local_url()) { $url = $this->out($escaped, $overrideparams); $localurl = substr($url, strlen($CFG->wwwroot)); return !empty($localurl) ? $localurl : ''; } else { throw new coding_exception('out_as_local_url called on a non-local URL'); } } /** * Returns the 'path' portion of a URL. For example, if the URL is * http://www.example.org:447/my/file/is/here.txt?really=1 then this will * return '/my/file/is/here.txt'. * * By default the path includes slash-arguments (for example, * '/myfile.php/extra/arguments') so it is what you would expect from a * URL path. If you don't want this behaviour, you can opt to exclude the * slash arguments. (Be careful: if the $CFG variable slasharguments is * disabled, these URLs will have a different format and you may need to * look at the 'file' parameter too.) * * @param bool $includeslashargument If true, includes slash arguments * @return string Path of URL */ public function get_path($includeslashargument = true) { return $this->path . ($includeslashargument ? $this->slashargument : ''); } /** * Returns a given parameter value from the URL. * * @param string $name Name of parameter * @return string Value of parameter or null if not set */ public function get_param($name) { if (array_key_exists($name, $this->params)) { return $this->params[$name]; } else { return null; } } /** * Returns the 'scheme' portion of a URL. For example, if the URL is * http://www.example.org:447/my/file/is/here.txt?really=1 then this will * return 'http' (without the colon). * * @return string Scheme of the URL. */ public function get_scheme() { return $this->scheme; } /** * Returns the 'host' portion of a URL. For example, if the URL is * http://www.example.org:447/my/file/is/here.txt?really=1 then this will * return 'www.example.org'. * * @return string Host of the URL. */ public function get_host() { return $this->host; } /** * Returns the 'port' portion of a URL. For example, if the URL is * http://www.example.org:447/my/file/is/here.txt?really=1 then this will * return '447'. * * @return string Port of the URL. */ public function get_port() { return $this->port; } } // Alias this class to the old name. // This file will be autoloaded by the legacyclasses autoload system. // In future all uses of this class will be corrected and the legacy references will be removed. class_alias(url::class, \moodle_url::class);