handleRequest()}. * It's a "historical record" that's specific to the current call of * {@link handleRequest()}, and is only complete once the "last call" to that method is made. */ protected $allParams = array(); /** * @var array $latestParams Contains an associative array of all * arguments matched in the current call from {@link RequestHandler->handleRequest()}, * as denoted with a "$"-prefix in the $url_handlers definitions. * Contains different states throughout its lifespan, so just useful * while processed in {@link RequestHandler} and to get the last * processes arguments. */ protected $latestParams = array(); /** * @var array $routeParams Contains an associative array of all arguments * explicitly set in the route table for the current request. * Useful for passing generic arguments via custom routes. * * E.g. The "Locale" parameter would be assigned "en_NZ" below * * Director: * rules: * 'en_NZ/$URLSegment!//$Action/$ID/$OtherID': * Controller: 'ModelAsController' * Locale: 'en_NZ' */ protected $routeParams = array(); protected $unshiftedButParsedParts = 0; /** * Construct a SS_HTTPRequest from a URL relative to the site root. */ public function __construct($httpMethod, $url, $getVars = array(), $postVars = array(), $body = null) { $this->httpMethod = strtoupper($httpMethod); $this->setUrl($url); $this->getVars = (array) $getVars; $this->postVars = (array) $postVars; $this->body = $body; } /** * Allow the setting of a URL * * This is here so that RootURLController can change the URL of the request * without us loosing all the other info attached (like headers) * * @param string The new URL * * @return SS_HTTPRequest The updated request */ public function setUrl($url) { $this->url = $url; // Normalize URL if its relative (strictly speaking), or has leading slashes if(Director::is_relative_url($url) || preg_match('/^\//', $url)) { $this->url = preg_replace(array('/\/+/','/^\//', '/\/$/'),array('/','',''), $this->url); } if(preg_match('/^(.+)\.([A-Za-z][A-Za-z0-9]*)$/', $this->url, $matches)) { $this->url = $matches[1]; $this->extension = $matches[2]; } if($this->url) $this->dirParts = preg_split('|/+|', $this->url); else $this->dirParts = array(); return $this; } /** * @return bool */ public function isGET() { return $this->httpMethod == 'GET'; } /** * @return bool */ public function isPOST() { return $this->httpMethod == 'POST'; } /** * @return bool */ public function isPUT() { return $this->httpMethod == 'PUT'; } /** * @return bool */ public function isDELETE() { return $this->httpMethod == 'DELETE'; } /** * @return bool */ public function isHEAD() { return $this->httpMethod == 'HEAD'; } /** * @param string $body * @return SS_HTTPRequest $this */ public function setBody($body) { $this->body = $body; return $this; } /** * @return null|string */ public function getBody() { return $this->body; } /** * @return array */ public function getVars() { return $this->getVars; } /** * @return array */ public function postVars() { return $this->postVars; } /** * Returns all combined HTTP GET and POST parameters * passed into this request. If a parameter with the same * name exists in both arrays, the POST value is returned. * * @return array */ public function requestVars() { return ArrayLib::array_merge_recursive($this->getVars, $this->postVars); } /** * @param string $name * @return mixed */ public function getVar($name) { if(isset($this->getVars[$name])) return $this->getVars[$name]; } /** * @param string $name * @return mixed */ public function postVar($name) { if(isset($this->postVars[$name])) return $this->postVars[$name]; } /** * @param string $name * @return mixed */ public function requestVar($name) { if(isset($this->postVars[$name])) return $this->postVars[$name]; if(isset($this->getVars[$name])) return $this->getVars[$name]; } /** * Returns a possible file extension found in parsing the URL * as denoted by a "."-character near the end of the URL. * Doesn't necessarily have to belong to an existing file, * as extensions can be also used for content-type-switching. * * @return string */ public function getExtension() { return $this->extension; } /** * Checks if the {@link SS_HTTPRequest->getExtension()} on this request matches one of the more common media types * embedded into a webpage - e.g. css, png. * * This is useful for things like determining wether to display a fully rendered error page or not. Note that the * media file types is not at all comprehensive. * * @return bool */ public function isMedia() { return in_array($this->getExtension(), array('css', 'js', 'jpg', 'jpeg', 'gif', 'png', 'bmp', 'ico')); } /** * Add a HTTP header to the response, replacing any header of the same name. * * @param string $header Example: "Content-Type" * @param string $value Example: "text/xml" */ public function addHeader($header, $value) { $this->headers[$header] = $value; } /** * @return array */ public function getHeaders() { return $this->headers; } /** * Remove an existing HTTP header * * @param string $header * @return mixed */ public function getHeader($header) { return (isset($this->headers[$header])) ? $this->headers[$header] : null; } /** * Remove an existing HTTP header by its name, * e.g. "Content-Type". * * @param string $header * @return SS_HTTPRequest $this */ public function removeHeader($header) { if(isset($this->headers[$header])) unset($this->headers[$header]); return $this; } /** * Returns the URL used to generate the page * * @param bool $includeGetVars whether or not to include the get parameters\ * @return string */ public function getURL($includeGetVars = false) { $url = ($this->getExtension()) ? $this->url . '.' . $this->getExtension() : $this->url; if ($includeGetVars) { // if we don't unset $vars['url'] we end up with /my/url?url=my/url&foo=bar etc $vars = $this->getVars(); unset($vars['url']); if (count($vars)) { $url .= '?' . http_build_query($vars); } } else if(strpos($url, "?") !== false) { $url = substr($url, 0, strpos($url, "?")); } return $url; } /** * Returns true if this request an ajax request, * based on custom HTTP ajax added by common JavaScript libraries, * or based on an explicit "ajax" request parameter. * * @return boolean */ public function isAjax() { return ( $this->requestVar('ajax') || $this->getHeader('X-Requested-With') && $this->getHeader('X-Requested-With') == "XMLHttpRequest" ); } /** * Enables the existence of a key-value pair in the request to be checked using * array syntax, so isset($request['title']) will check for $_POST['title'] and $_GET['title'] * * @param unknown_type $offset * @return boolean */ public function offsetExists($offset) { if(isset($this->postVars[$offset])) return true; if(isset($this->getVars[$offset])) return true; return false; } /** * Access a request variable using array syntax. eg: $request['title'] instead of $request->postVar('title') * * @param unknown_type $offset * @return unknown */ public function offsetGet($offset) { return $this->requestVar($offset); } /** * @ignore */ public function offsetSet($offset, $value) {} /** * @ignore */ public function offsetUnset($offset) {} /** * Construct an SS_HTTPResponse that will deliver a file to the client. * Caution: Since it requires $fileData to be passed as binary data (no stream support), * it's only advisable to send small files through this method. * * @static * @param $fileData * @param $fileName * @param null $mimeType * @return SS_HTTPResponse */ public static function send_file($fileData, $fileName, $mimeType = null) { if(!$mimeType) { $mimeType = HTTP::get_mime_type($fileName); } $response = new SS_HTTPResponse($fileData); $response->addHeader("Content-Type", "$mimeType; name=\"" . addslashes($fileName) . "\""); // Note a IE-only fix that inspects this header in HTTP::add_cache_headers(). $response->addHeader("Content-Disposition", "attachment; filename=\"" . addslashes($fileName) . "\""); $response->addHeader("Content-Length", strlen($fileData)); return $response; } /** * Matches a URL pattern * The pattern can contain a number of segments, separated by / (and an extension indicated by a .) * * The parts can be either literals, or, if they start with a $ they are interpreted as variables. * - Literals must be provided in order to match * - $Variables are optional * - However, if you put ! at the end of a variable, then it becomes mandatory. * * For example: * - admin/crm/list will match admin/crm/$Action/$ID/$OtherID, but it won't match admin/crm/$Action!/$ClassName! * * The pattern can optionally start with an HTTP method and a space. For example, "POST $Controller/$Action". * This is used to define a rule that only matches on a specific HTTP method. * * @param $pattern * @param bool $shiftOnSuccess * @return array|bool */ public function match($pattern, $shiftOnSuccess = false) { // Check if a specific method is required if(preg_match('/^([A-Za-z]+) +(.*)$/', $pattern, $matches)) { $requiredMethod = $matches[1]; if($requiredMethod != $this->httpMethod) return false; // If we get this far, we can match the URL pattern as usual. $pattern = $matches[2]; } // Special case for the root URL controller if(!$pattern) { return ($this->dirParts == array()) ? array('Matched' => true) : false; } // Check for the '//' marker that represents the "shifting point" $doubleSlashPoint = strpos($pattern, '//'); if($doubleSlashPoint !== false) { $shiftCount = substr_count(substr($pattern,0,$doubleSlashPoint), '/') + 1; $pattern = str_replace('//', '/', $pattern); $patternParts = explode('/', $pattern); } else { $patternParts = explode('/', $pattern); $shiftCount = sizeof($patternParts); } // Filter out any "empty" matching parts - either from an initial / or a trailing / $patternParts = array_values(array_filter($patternParts)); $arguments = array(); foreach($patternParts as $i => $part) { $part = trim($part); // Match a variable if(isset($part[0]) && $part[0] == '$') { // A variable ending in ! is required if(substr($part,-1) == '!') { $varRequired = true; $varName = substr($part,1,-1); } else { $varRequired = false; $varName = substr($part,1); } // Fail if a required variable isn't populated if($varRequired && !isset($this->dirParts[$i])) return false; $arguments[$varName] = isset($this->dirParts[$i]) ? $this->dirParts[$i] : null; if($part == '$Controller' && (!ClassInfo::exists($arguments['Controller']) || !is_subclass_of($arguments['Controller'], 'Controller'))) { return false; } // Literal parts with extension } else if(isset($this->dirParts[$i]) && $this->dirParts[$i] . '.' . $this->extension == $part) { continue; // Literal parts must always be there } else if(!isset($this->dirParts[$i]) || $this->dirParts[$i] != $part) { return false; } } if($shiftOnSuccess) { $this->shift($shiftCount); // We keep track of pattern parts that we looked at but didn't shift off. // This lets us say that we have *parsed* the whole URL even when we haven't *shifted* it all $this->unshiftedButParsedParts = sizeof($patternParts) - $shiftCount; } $this->latestParams = $arguments; // Load the arguments that actually have a value into $this->allParams // This ensures that previous values aren't overridden with blanks foreach($arguments as $k => $v) { if($v || !isset($this->allParams[$k])) $this->allParams[$k] = $v; } if($arguments === array()) $arguments['_matched'] = true; return $arguments; } /** * @return array */ public function allParams() { return $this->allParams; } /** * Shift all the parameter values down a key space, and return the shifted value. * * @return string */ public function shiftAllParams() { $keys = array_keys($this->allParams); $values = array_values($this->allParams); $value = array_shift($values); // push additional unparsed URL parts onto the parameter stack if(array_key_exists($this->unshiftedButParsedParts, $this->dirParts)) { $values[] = $this->dirParts[$this->unshiftedButParsedParts]; } foreach($keys as $position => $key) { $this->allParams[$key] = isset($values[$position]) ? $values[$position] : null; } return $value; } /** * @return array */ public function latestParams() { return $this->latestParams; } /** * @param string $name * @return string|null */ public function latestParam($name) { if(isset($this->latestParams[$name])) return $this->latestParams[$name]; else return null; } /** * @return array */ public function routeParams() { return $this->routeParams; } /** * @param $params * @return SS_HTTPRequest $this */ public function setRouteParams($params) { $this->routeParams = $params; return $this; } /** * @return array */ public function params() { return array_merge($this->allParams, $this->routeParams); } /** * Finds a named URL parameter (denoted by "$"-prefix in $url_handlers) * from the full URL, or a parameter specified in the route table * * @param string $name * @return string Value of the URL parameter (if found) */ public function param($name) { $params = $this->params(); if(isset($params[$name])) return $params[$name]; else return null; } /** * Returns the unparsed part of the original URL * separated by commas. This is used by {@link RequestHandler->handleRequest()} * to determine if further URL processing is necessary. * * @return string Partial URL */ public function remaining() { return implode("/", $this->dirParts); } /** * Returns true if this is a URL that will match without shifting off any of the URL. * This is used by the request handler to prevent infinite parsing loops. * * @param $pattern * @return bool */ public function isEmptyPattern($pattern) { if(preg_match('/^([A-Za-z]+) +(.*)$/', $pattern, $matches)) { $pattern = $matches[2]; } if(trim($pattern) == "") return true; } /** * Shift one or more parts off the beginning of the URL. * If you specify shifting more than 1 item off, then the items will be returned as an array * * @param int $count Shift Count * @return String|Array */ public function shift($count = 1) { $return = array(); if($count == 1) return array_shift($this->dirParts); for($i=0;$i<$count;$i++) { $value = array_shift($this->dirParts); if($value === null) break; $return[] = $value; } return $return; } /** * Returns true if the URL has been completely parsed. * This will respect parsed but unshifted directory parts. * * @return bool */ public function allParsed() { return sizeof($this->dirParts) <= $this->unshiftedButParsedParts; } /** * Returns the client IP address which * originated this request. * * @return string */ public function getIP() { $headerOverrideIP = null; if(TRUSTED_PROXY) { $headers = (defined('SS_TRUSTED_PROXY_IP_HEADER')) ? array(SS_TRUSTED_PROXY_IP_HEADER) : null; if(!$headers) { // Backwards compatible defaults $headers = array('HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR'); } foreach($headers as $header) { if(!empty($_SERVER[$header])) { $headerOverrideIP = $_SERVER[$header]; break; } } } if ($headerOverrideIP) { return $this->getIPFromHeaderValue($headerOverrideIP); } elseif(isset($_SERVER['REMOTE_ADDR'])) { return $_SERVER['REMOTE_ADDR']; } else { return null; } } /** * Extract an IP address from a header value that has been obtained. Accepts single IP or comma separated string of * IPs * * @param string $headerValue The value from a trusted header * @return string The IP address */ protected function getIPFromHeaderValue($headerValue) { if (strpos($headerValue, ',') !== false) { //sometimes the IP from a load balancer could be "x.x.x.x, y.y.y.y, z.z.z.z" so we need to find the most // likely candidate $ips = explode(',', $headerValue); foreach ($ips as $ip) { $ip = trim($ip); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE)) { return $ip; } } } return $headerValue; } /** * Returns all mimetypes from the HTTP "Accept" header * as an array. * * @param boolean $includeQuality Don't strip away optional "quality indicators", e.g. "application/xml;q=0.9" * (Default: false) * @return array */ public function getAcceptMimetypes($includeQuality = false) { $mimetypes = array(); $mimetypesWithQuality = explode(',',$this->getHeader('Accept')); foreach($mimetypesWithQuality as $mimetypeWithQuality) { $mimetypes[] = ($includeQuality) ? $mimetypeWithQuality : preg_replace('/;.*/', '', $mimetypeWithQuality); } return $mimetypes; } /** * @return string HTTP method (all uppercase) */ public function httpMethod() { return $this->httpMethod; } /** * Explicitly set the HTTP method for this request. * @param string $method * @return $this */ public function setHttpMethod($method) { if(!self::isValidHttpMethod($method)) { user_error('SS_HTTPRequest::setHttpMethod: Invalid HTTP method', E_USER_ERROR); } $this->httpMethod = strtoupper($method); return $this; } /** * @param string $method * @return bool */ private static function isValidHttpMethod($method) { return in_array(strtoupper($method), array('GET','POST','PUT','DELETE','HEAD')); } /** * Gets the "real" HTTP method for a request. This method is no longer used to mitigate the risk of web cache * poisoning. * * @see https://www.silverstripe.org/download/security-releases/CVE-2019-19326 * @param string $origMethod Original HTTP method from the browser request * @param array $postVars * @return string HTTP method (all uppercase) * @deprecated 3.7.5 */ public static function detect_method($origMethod, $postVars) { if(isset($postVars['_method'])) { if (!self::isValidHttpMethod($postVars['_method'])) { user_error('Director::direct(): Invalid "_method" parameter', E_USER_ERROR); } return strtoupper($postVars['_method']); } else { return $origMethod; } } }