CodeIgniter4/system/HTTP/Response.php
John Paul E. Balandan, CPA 32978d2598
Fix whitespaces for system/
2020-11-19 01:25:50 +08:00

1098 lines
26 KiB
PHP

<?php
/**
* This file is part of the CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace CodeIgniter\HTTP;
use CodeIgniter\HTTP\Exceptions\HTTPException;
use CodeIgniter\Pager\PagerInterface;
use Config\App;
use Config\Services;
use DateTime;
use DateTimeZone;
use InvalidArgumentException;
/**
* Representation of an outgoing, getServer-side response.
*
* Per the HTTP specification, this interface includes properties for
* each of the following:
*
* - Protocol version
* - Status code and reason phrase
* - Headers
* - Message body
*/
class Response extends Message implements ResponseInterface
{
/**
* HTTP status codes
*
* @var array
*/
protected static $statusCodes = [
// 1xx: Informational
100 => 'Continue',
101 => 'Switching Protocols',
102 => 'Processing', // http://www.iana.org/go/rfc2518
103 => 'Early Hints', // http://www.ietf.org/rfc/rfc8297.txt
// 2xx: Success
200 => 'OK',
201 => 'Created',
202 => 'Accepted',
203 => 'Non-Authoritative Information', // 1.1
204 => 'No Content',
205 => 'Reset Content',
206 => 'Partial Content',
207 => 'Multi-Status', // http://www.iana.org/go/rfc4918
208 => 'Already Reported', // http://www.iana.org/go/rfc5842
226 => 'IM Used', // 1.1; http://www.ietf.org/rfc/rfc3229.txt
// 3xx: Redirection
300 => 'Multiple Choices',
301 => 'Moved Permanently',
302 => 'Found', // Formerly 'Moved Temporarily'
303 => 'See Other', // 1.1
304 => 'Not Modified',
305 => 'Use Proxy', // 1.1
306 => 'Switch Proxy', // No longer used
307 => 'Temporary Redirect', // 1.1
308 => 'Permanent Redirect', // 1.1; Experimental; http://www.ietf.org/rfc/rfc7238.txt
// 4xx: Client error
400 => 'Bad Request',
401 => 'Unauthorized',
402 => 'Payment Required',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
406 => 'Not Acceptable',
407 => 'Proxy Authentication Required',
408 => 'Request Timeout',
409 => 'Conflict',
410 => 'Gone',
411 => 'Length Required',
412 => 'Precondition Failed',
413 => 'Request Entity Too Large',
414 => 'Request-URI Too Long',
415 => 'Unsupported Media Type',
416 => 'Requested Range Not Satisfiable',
417 => 'Expectation Failed',
418 => "I'm a teapot", // April's Fools joke; http://www.ietf.org/rfc/rfc2324.txt
// 419 (Authentication Timeout) is a non-standard status code with unknown origin
421 => 'Misdirected Request', // http://www.iana.org/go/rfc7540 Section 9.1.2
422 => 'Unprocessable Entity', // http://www.iana.org/go/rfc4918
423 => 'Locked', // http://www.iana.org/go/rfc4918
424 => 'Failed Dependency', // http://www.iana.org/go/rfc4918
425 => 'Too Early', // https://datatracker.ietf.org/doc/draft-ietf-httpbis-replay/
426 => 'Upgrade Required',
428 => 'Precondition Required', // 1.1; http://www.ietf.org/rfc/rfc6585.txt
429 => 'Too Many Requests', // 1.1; http://www.ietf.org/rfc/rfc6585.txt
431 => 'Request Header Fields Too Large', // 1.1; http://www.ietf.org/rfc/rfc6585.txt
451 => 'Unavailable For Legal Reasons', // http://tools.ietf.org/html/rfc7725
499 => 'Client Closed Request', // http://lxr.nginx.org/source/src/http/ngx_http_request.h#0133
// 5xx: Server error
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
505 => 'HTTP Version Not Supported',
506 => 'Variant Also Negotiates', // 1.1; http://www.ietf.org/rfc/rfc2295.txt
507 => 'Insufficient Storage', // http://www.iana.org/go/rfc4918
508 => 'Loop Detected', // http://www.iana.org/go/rfc5842
510 => 'Not Extended', // http://www.ietf.org/rfc/rfc2774.txt
511 => 'Network Authentication Required', // http://www.ietf.org/rfc/rfc6585.txt
599 => 'Network Connect Timeout Error', // https://httpstatuses.com/599
];
/**
* The current reason phrase for this response.
* If null, will use the default provided for the status code.
*
* @var string
*/
protected $reason;
/**
* The current status code for this response.
*
* @var integer
*/
protected $statusCode = 200;
/**
* Whether Content Security Policy is being enforced.
*
* @var boolean
*/
protected $CSPEnabled = false;
/**
* Content security policy handler
*
* @var ContentSecurityPolicy
*/
public $CSP;
/**
* Set a cookie name prefix if you need to avoid collisions
*
* @var string
*/
protected $cookiePrefix = '';
/**
* Set to .your-domain.com for site-wide cookies
*
* @var string
*/
protected $cookieDomain = '';
/**
* Typically will be a forward slash
*
* @var string
*/
protected $cookiePath = '/';
/**
* Cookie will only be set if a secure HTTPS connection exists.
*
* @var boolean
*/
protected $cookieSecure = false;
/**
* Cookie will only be accessible via HTTP(S) (no javascript)
*
* @var boolean
*/
protected $cookieHTTPOnly = false;
/**
* Cookie SameSite setting
*
* @var string
*/
protected $cookieSameSite = 'Lax';
/**
* Stores all cookies that were set in the response.
*
* @var array
*/
protected $cookies = [];
/**
* If true, will not write output. Useful during testing.
*
* @var boolean
*/
protected $pretend = false;
/**
* Type of format the body is in.
* Valid: html, json, xml
*
* @var string
*/
protected $bodyFormat = 'html';
//--------------------------------------------------------------------
/**
* Constructor
*
* @param App $config
*/
public function __construct($config)
{
// Default to a non-caching page.
// Also ensures that a Cache-control header exists.
$this->noCache();
// We need CSP object even if not enabled to avoid calls to non existing methods
$this->CSP = new ContentSecurityPolicy(new \Config\ContentSecurityPolicy());
$this->CSPEnabled = $config->CSPEnabled;
$this->cookiePrefix = $config->cookiePrefix;
$this->cookieDomain = $config->cookieDomain;
$this->cookiePath = $config->cookiePath;
$this->cookieSecure = $config->cookieSecure;
$this->cookieHTTPOnly = $config->cookieHTTPOnly;
$this->cookieSameSite = $config->cookieSameSite ?? $this->cookieSameSite;
if (! in_array(strtolower($this->cookieSameSite), ['', 'none', 'lax', 'strict'], true))
{
throw HTTPException::forInvalidSameSiteSetting($this->cookieSameSite);
}
// Default to an HTML Content-Type. Devs can override if needed.
$this->setContentType('text/html');
}
//--------------------------------------------------------------------
/**
* Turns "pretend" mode on or off to aid in testing.
*
* @param boolean $pretend
*
* @return $this
*/
public function pretend(bool $pretend = true)
{
$this->pretend = $pretend;
return $this;
}
/**
* Gets the response status code.
*
* The status code is a 3-digit integer result code of the getServer's attempt
* to understand and satisfy the request.
*
* @return integer Status code.
*/
public function getStatusCode(): int
{
if (empty($this->statusCode))
{
throw HTTPException::forMissingResponseStatus();
}
return $this->statusCode;
}
//--------------------------------------------------------------------
/**
* Return an instance with the specified status code and, optionally, reason phrase.
*
* If no reason phrase is specified, will default recommended reason phrase for
* the response's status code.
*
* @see http://tools.ietf.org/html/rfc7231#section-6
* @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
*
* @param integer $code The 3-digit integer result code to set.
* @param string $reason The reason phrase to use with the
* provided status code; if none is provided, will
* default to the IANA name.
*
* @return $this
* @throws HTTPException For invalid status code arguments.
*/
public function setStatusCode(int $code, string $reason = '')
{
// Valid range?
if ($code < 100 || $code > 599)
{
throw HTTPException::forInvalidStatusCode($code);
}
// Unknown and no message?
if (! array_key_exists($code, static::$statusCodes) && empty($reason))
{
throw HTTPException::forUnkownStatusCode($code);
}
$this->statusCode = $code;
if (! empty($reason))
{
$this->reason = $reason;
}
else
{
$this->reason = static::$statusCodes[$code];
}
return $this;
}
//--------------------------------------------------------------------
/**
* Gets the response response phrase associated with the status code.
*
* @see http://tools.ietf.org/html/rfc7231#section-6
* @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
*
* @return string
*/
public function getReason(): string
{
if (empty($this->reason))
{
return ! empty($this->statusCode) ? static::$statusCodes[$this->statusCode] : '';
}
return $this->reason;
}
//--------------------------------------------------------------------
// Convenience Methods
//--------------------------------------------------------------------
/**
* Sets the date header
*
* @param DateTime $date
*
* @return Response
*/
public function setDate(DateTime $date)
{
$date->setTimezone(new DateTimeZone('UTC'));
$this->setHeader('Date', $date->format('D, d M Y H:i:s') . ' GMT');
return $this;
}
//--------------------------------------------------------------------
/**
* Set the Link Header
*
* @param PagerInterface $pager
*
* @see http://tools.ietf.org/html/rfc5988
*
* @return Response
*/
public function setLink(PagerInterface $pager)
{
$links = '';
if ($previous = $pager->getPreviousPageURI())
{
$links .= '<' . $pager->getPageURI($pager->getFirstPage()) . '>; rel="first",';
$links .= '<' . $previous . '>; rel="prev"';
}
if (($next = $pager->getNextPageURI()) && $previous)
{
$links .= ',';
}
if ($next)
{
$links .= '<' . $next . '>; rel="next",';
$links .= '<' . $pager->getPageURI($pager->getLastPage()) . '>; rel="last"';
}
$this->setHeader('Link', $links);
return $this;
}
//--------------------------------------------------------------------
/**
* Sets the Content Type header for this response with the mime type
* and, optionally, the charset.
*
* @param string $mime
* @param string $charset
*
* @return Response
*/
public function setContentType(string $mime, string $charset = 'UTF-8')
{
// add charset attribute if not already there and provided as parm
if ((strpos($mime, 'charset=') < 1) && ! empty($charset))
{
$mime .= '; charset=' . $charset;
}
$this->removeHeader('Content-Type'); // replace existing content type
$this->setHeader('Content-Type', $mime);
return $this;
}
//--------------------------------------------------------------------
/**
* Converts the $body into JSON and sets the Content Type header.
*
* @param array|string $body
* @param boolean $unencoded
*
* @return $this
*/
public function setJSON($body, bool $unencoded = false)
{
$this->body = $this->formatBody($body, 'json' . ($unencoded ? '-unencoded' : ''));
return $this;
}
//--------------------------------------------------------------------
/**
* Returns the current body, converted to JSON is it isn't already.
*
* @return mixed|string
*
* @throws InvalidArgumentException If the body property is not array.
*/
public function getJSON()
{
$body = $this->body;
if ($this->bodyFormat !== 'json')
{
$body = Services::format()->getFormatter('application/json')->format($body);
}
return $body ?: null;
}
//--------------------------------------------------------------------
/**
* Converts $body into XML, and sets the correct Content-Type.
*
* @param array|string $body
*
* @return $this
*/
public function setXML($body)
{
$this->body = $this->formatBody($body, 'xml');
return $this;
}
//--------------------------------------------------------------------
/**
* Retrieves the current body into XML and returns it.
*
* @return mixed|string
* @throws InvalidArgumentException If the body property is not array.
*/
public function getXML()
{
$body = $this->body;
if ($this->bodyFormat !== 'xml')
{
$body = Services::format()->getFormatter('application/xml')->format($body);
}
return $body;
}
//--------------------------------------------------------------------
/**
* Handles conversion of the of the data into the appropriate format,
* and sets the correct Content-Type header for our response.
*
* @param string|array $body
* @param string $format Valid: json, xml
*
* @return mixed
* @throws InvalidArgumentException If the body property is not string or array.
*/
protected function formatBody($body, string $format)
{
$this->bodyFormat = ($format === 'json-unencoded' ? 'json' : $format);
$mime = "application/{$this->bodyFormat}";
$this->setContentType($mime);
// Nothing much to do for a string...
if (! is_string($body) || $format === 'json-unencoded')
{
$body = Services::format()->getFormatter($mime)->format($body);
}
return $body;
}
//--------------------------------------------------------------------
//--------------------------------------------------------------------
// Cache Control Methods
//
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9
//--------------------------------------------------------------------
/**
* Sets the appropriate headers to ensure this response
* is not cached by the browsers.
*
* @return Response
*/
public function noCache(): self
{
$this->removeHeader('Cache-control');
$this->setHeader('Cache-control', ['no-store', 'max-age=0', 'no-cache']);
return $this;
}
//--------------------------------------------------------------------
/**
* A shortcut method that allows the developer to set all of the
* cache-control headers in one method call.
*
* The options array is used to provide the cache-control directives
* for the header. It might look something like:
*
* $options = [
* 'max-age' => 300,
* 's-maxage' => 900
* 'etag' => 'abcde',
* ];
*
* Typical options are:
* - etag
* - last-modified
* - max-age
* - s-maxage
* - private
* - public
* - must-revalidate
* - proxy-revalidate
* - no-transform
*
* @param array $options
*
* @return Response
*/
public function setCache(array $options = [])
{
if (empty($options))
{
return $this;
}
$this->removeHeader('Cache-Control');
$this->removeHeader('ETag');
// ETag
if (isset($options['etag']))
{
$this->setHeader('ETag', $options['etag']);
unset($options['etag']);
}
// Last Modified
if (isset($options['last-modified']))
{
$this->setLastModified($options['last-modified']);
unset($options['last-modified']);
}
$this->setHeader('Cache-control', $options);
return $this;
}
//--------------------------------------------------------------------
/**
* Sets the Last-Modified date header.
*
* $date can be either a string representation of the date or,
* preferably, an instance of DateTime.
*
* @param DateTime|string $date
*
* @return Response
*/
public function setLastModified($date)
{
if ($date instanceof DateTime)
{
$date->setTimezone(new DateTimeZone('UTC'));
$this->setHeader('Last-Modified', $date->format('D, d M Y H:i:s') . ' GMT');
}
elseif (is_string($date))
{
$this->setHeader('Last-Modified', $date);
}
return $this;
}
//--------------------------------------------------------------------
//--------------------------------------------------------------------
// Output Methods
//--------------------------------------------------------------------
/**
* Sends the output to the browser.
*
* @return Response
*/
public function send()
{
// If we're enforcing a Content Security Policy,
// we need to give it a chance to build out it's headers.
if ($this->CSPEnabled === true)
{
$this->CSP->finalize($this);
}
else
{
$this->body = str_replace(['{csp-style-nonce}', '{csp-script-nonce}'], '', $this->body);
}
$this->sendHeaders();
$this->sendCookies();
$this->sendBody();
return $this;
}
//--------------------------------------------------------------------
/**
* Sends the headers of this HTTP request to the browser.
*
* @return Response
*/
public function sendHeaders()
{
// Have the headers already been sent?
if ($this->pretend || headers_sent())
{
return $this;
}
// Per spec, MUST be sent with each request, if possible.
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html
if (! isset($this->headers['Date']) && php_sapi_name() !== 'cli-server')
{
$this->setDate(DateTime::createFromFormat('U', (string) time()));
}
// HTTP Status
header(sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->statusCode, $this->reason), true, $this->statusCode);
// Send all of our headers
foreach ($this->getHeaders() as $name => $values)
{
header($name . ': ' . $this->getHeaderLine($name), true, $this->statusCode);
}
return $this;
}
//--------------------------------------------------------------------
/**
* Sends the Body of the message to the browser.
*
* @return Response
*/
public function sendBody()
{
echo $this->body;
return $this;
}
//--------------------------------------------------------------------
/**
* Grabs the current body.
*
* @return mixed|string
*/
public function getBody()
{
return $this->body;
}
/**
* Perform a redirect to a new URL, in two flavors: header or location.
*
* @param string $uri The URI to redirect to
* @param string $method
* @param integer $code The type of redirection, defaults to 302
*
* @return $this
* @throws HTTPException For invalid status code.
*/
public function redirect(string $uri, string $method = 'auto', int $code = null)
{
// Assume 302 status code response; override if needed
if (empty($code))
{
$code = 302;
}
// IIS environment likely? Use 'refresh' for better compatibility
if ($method === 'auto' && isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS') !== false)
{
$method = 'refresh';
}
// override status code for HTTP/1.1 & higher
// reference: http://en.wikipedia.org/wiki/Post/Redirect/Get
if (isset($_SERVER['SERVER_PROTOCOL'], $_SERVER['REQUEST_METHOD']) && $this->getProtocolVersion() >= 1.1)
{
if ($method !== 'refresh')
{
$code = ($_SERVER['REQUEST_METHOD'] !== 'GET') ? 303 : ($code === 302 ? 307 : $code);
}
}
switch ($method)
{
case 'refresh':
$this->setHeader('Refresh', '0;url=' . $uri);
break;
default:
$this->setHeader('Location', $uri);
break;
}
$this->setStatusCode($code);
return $this;
}
//--------------------------------------------------------------------
/**
* Set a cookie
*
* Accepts an arbitrary number of binds (up to 7) or an associative
* array in the first parameter containing all the values.
*
* @param string|array $name Cookie name or array containing binds
* @param string $value Cookie value
* @param string $expire Cookie expiration time in seconds
* @param string $domain Cookie domain (e.g.: '.yourdomain.com')
* @param string $path Cookie path (default: '/')
* @param string $prefix Cookie name prefix
* @param boolean $secure Whether to only transfer cookies via SSL
* @param boolean $httponly Whether only make the cookie accessible via HTTP (no javascript)
* @param string|null $samesite
*
* @return $this
*/
public function setCookie(
$name,
$value = '',
$expire = '',
$domain = '',
$path = '/',
$prefix = '',
$secure = false,
$httponly = false,
$samesite = null
)
{
if (is_array($name))
{
// always leave 'name' in last place, as the loop will break otherwise, due to $$item
foreach (['samesite', 'value', 'expire', 'domain', 'path', 'prefix', 'secure', 'httponly', 'name'] as $item)
{
if (isset($name[$item]))
{
$$item = $name[$item];
}
}
}
if ($prefix === '' && $this->cookiePrefix !== '')
{
$prefix = $this->cookiePrefix;
}
if ($domain === '' && $this->cookieDomain !== '')
{
$domain = $this->cookieDomain;
}
if ($path === '/' && $this->cookiePath !== '/')
{
$path = $this->cookiePath;
}
if ($secure === false && $this->cookieSecure === true)
{
$secure = $this->cookieSecure;
}
if ($httponly === false && $this->cookieHTTPOnly !== false)
{
$httponly = $this->cookieHTTPOnly;
}
if (is_null($samesite))
{
$samesite = $this->cookieSameSite ?? '';
}
if (! in_array(strtolower($samesite), ['', 'none', 'lax', 'strict'], true))
{
throw HTTPException::forInvalidSameSiteSetting($samesite);
}
if (! is_numeric($expire))
{
$expire = time() - 86500;
}
else
{
$expire = ($expire > 0) ? time() + $expire : 0;
}
$cookie = [
'name' => $prefix . $name,
'value' => $value,
'expires' => $expire,
'path' => $path,
'domain' => $domain,
'secure' => $secure,
'httponly' => $httponly,
];
if ($samesite !== '')
{
$cookie['samesite'] = $samesite;
}
$this->cookies[] = $cookie;
return $this;
}
//--------------------------------------------------------------------
/**
* Checks to see if the Response has a specified cookie or not.
*
* @param string $name
* @param string|null $value
* @param string $prefix
*
* @return boolean
*/
public function hasCookie(string $name, string $value = null, string $prefix = ''): bool
{
if ($prefix === '' && $this->cookiePrefix !== '')
{
$prefix = $this->cookiePrefix;
}
$name = $prefix . $name;
foreach ($this->cookies as $cookie)
{
if ($cookie['name'] !== $name)
{
continue;
}
if ($value === null)
{
return true;
}
return $cookie['value'] === $value;
}
return false;
}
/**
* Returns the cookie
*
* @param string|null $name
* @param string $prefix
*
* @return mixed
*/
public function getCookie(string $name = null, string $prefix = '')
{
// if no name given, return them all
if (empty($name))
{
return $this->cookies;
}
if ($prefix === '' && $this->cookiePrefix !== '')
{
$prefix = $this->cookiePrefix;
}
$name = $prefix . $name;
foreach ($this->cookies as $cookie)
{
if ($cookie['name'] === $name)
{
return $cookie;
}
}
return null;
}
/**
* Sets a cookie to be deleted when the response is sent.
*
* @param string $name
* @param string $domain
* @param string $path
* @param string $prefix
*
* @return $this
*/
public function deleteCookie(string $name = '', string $domain = '', string $path = '/', string $prefix = '')
{
if (empty($name))
{
return $this;
}
if ($prefix === '' && $this->cookiePrefix !== '')
{
$prefix = $this->cookiePrefix;
}
$prefixedName = $prefix . $name;
$cookieHasFlag = false;
foreach ($this->cookies as &$cookie)
{
if ($cookie['name'] === $prefixedName)
{
if (! empty($domain) && $cookie['domain'] !== $domain)
{
continue;
}
if (! empty($path) && $cookie['path'] !== $path)
{
continue;
}
$cookie['value'] = '';
$cookie['expires'] = '';
$cookieHasFlag = true;
break;
}
}
if (! $cookieHasFlag)
{
$this->setCookie($name, '', '', $domain, $path, $prefix);
}
return $this;
}
/**
* Returns all cookies currently set.
*
* @return array
*/
public function getCookies()
{
return $this->cookies;
}
/**
* Actually sets the cookies.
*/
protected function sendCookies()
{
if ($this->pretend)
{
return;
}
foreach ($this->cookies as $params)
{
if (PHP_VERSION_ID < 70300)
{
// For PHP 7.2 we need to use the hacky method of setting SameSite in the path
if (isset($params['samesite']) && in_array(strtolower($params['samesite']), ['none', 'lax', 'strict'], true))
{
$params['path'] .= '; samesite=' . $params['samesite'];
unset($params['samesite']);
}
// PHP cannot unpack array with string keys
$params = array_values($params);
setcookie(...$params);
}
else
{
// PHP 7.3 and later have a signature for setcookie() with options array as third argument
// and SameSite is possible to set there
$name = $params['name'];
$value = $params['value'];
unset($params['name'], $params['value']);
// If samesite is blank string, skip setting the attribute on the cookie
if (isset($params['samesite']) && $params['samesite'] === '')
{
unset($params['samesite']);
}
setcookie($name, $value, $params);
}
}
}
/**
* Force a download.
*
* Generates the headers that force a download to happen. And
* sends the file to the browser.
*
* @param string $filename The path to the file to send
* @param string|null $data The data to be downloaded
* @param boolean $setMime Whether to try and send the actual MIME type
*
* @return DownloadResponse|null
*/
public function download(string $filename = '', $data = '', bool $setMime = false)
{
if ($filename === '' || $data === '')
{
return null;
}
$filepath = '';
if ($data === null)
{
$filepath = $filename;
$filename = explode('/', str_replace(DIRECTORY_SEPARATOR, '/', $filename));
$filename = end($filename);
}
$response = new DownloadResponse($filename, $setMime);
if ($filepath !== '')
{
$response->setFilePath($filepath);
}
elseif ($data !== null)
{
$response->setBinary($data);
}
return $response;
}
}