OpenXE/classes/Components/Http/Response.php
2021-05-21 08:49:41 +02:00

558 lines
17 KiB
PHP

<?php
namespace Xentral\Components\Http;
use DateTimeInterface;
use Xentral\Components\Http\Cookie\Cookie;
use Xentral\Components\Http\Cookie\CookieCollection;
use Xentral\Components\Http\Exception\HttpHeaderValueException;
use Xentral\Components\Http\Exception\InvalidArgumentException;
use Xentral\Components\Util\StringUtil;
class Response
{
const HTTP_CONTINUE = 100;
const HTTP_SWITCHING_PROTOCOLS = 101;
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_ACCEPT = 202;
const HTTP_NON_AUTHORITATIVE_INFORMATION = 203;
const HTTP_NO_CONTENT = 204;
const HTTP_RESET_CONTENT = 205;
const HTTP_PARTIAL_CONTENT = 206;
const HTTP_MULTIPLE_CHOICES = 300;
const HTTP_MOVED_PERMANENTLY = 301;
const HTTP_MOVED_TEMPORARILY = 302;
const HTTP_SEE_OTHER = 303;
const HTTP_NOT_MODIFIED = 304;
const HTTP_USE_PROXY = 305;
const HTTP_TEMPORARY_REDIRECT = 307;
const HTTP_BAD_REQUEST = 400;
const HTTP_UNAUTHORIZED = 401;
const HTTP_PAYMENT_REQUIRED = 402;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_METHOD_NOT_ALLOWED = 405;
const HTTP_NOT_ACCEPTABLE = 406;
const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;
const HTTP_REQUEST_TIMEOUT = 408;
const HTTP_CONFILICT = 409;
const HTTP_GONE = 410;
const HTTP_LENGTH_REQUIRED = 411;
const HTTP_PRECONDITION_FAILED = 412;
const HTTP_PAYLOAD_TOO_LARGE = 413;
const HTTP_URI_TOO_LONG = 414;
const HTTP_UNSUPPORTET_MEDIA_TYPE = 415;
const HTTP_RANGE_NOT_SATISFIABLE = 416;
const HTTP_EXPECTATION_FAILED = 417;
const HTTP_UNPROCESSABLE_ENTITY = 422;
const HTTP_UPGRADE_REQUIRED = 426;
const HTTP_INTERNAL_SERVER_ERROR = 500;
const HTTP_NOT_IMPLEMENTED = 501;
const HTTP_BAD_GATEWAY = 502;
const HTTP_SERVICE_UNAVAILABLE = 503;
const HTTP_GATEWAY_TIMEOUT = 504;
const HTTP_VERSION_NOT_SUPPORTED = 505;
const DISPOSITION_INLINE = 'inline';
const DISPOSITION_ATTACHMENT = 'attachment';
/** @var array $statusMessages */
protected static $statusMessages = [
self::HTTP_CONTINUE => 'Continue',
self::HTTP_SWITCHING_PROTOCOLS => 'Switching Protocols',
self::HTTP_NON_AUTHORITATIVE_INFORMATION => 'Non-Authoritative Information',
self::HTTP_OK => 'OK',
self::HTTP_CREATED => 'Created',
self::HTTP_ACCEPT => 'Accepted',
self::HTTP_NO_CONTENT => 'No Content',
self::HTTP_RESET_CONTENT => 'Reset Content',
self::HTTP_PARTIAL_CONTENT => 'Partial Content',
self::HTTP_MULTIPLE_CHOICES => 'Multiple Choices',
self::HTTP_MOVED_PERMANENTLY => 'Moved Permanently',
self::HTTP_MOVED_TEMPORARILY => 'Found',
self::HTTP_SEE_OTHER => 'See Other',
self::HTTP_NOT_MODIFIED => 'Not Modified',
self::HTTP_USE_PROXY => 'Use Proxy',
self::HTTP_TEMPORARY_REDIRECT => 'Temporary Redirect',
self::HTTP_BAD_REQUEST => 'Bad Request',
self::HTTP_UNAUTHORIZED => 'Unauthorized',
self::HTTP_PAYMENT_REQUIRED => 'Payment Required',
self::HTTP_FORBIDDEN => 'Forbidden',
self::HTTP_NOT_FOUND => 'Not Found',
self::HTTP_METHOD_NOT_ALLOWED => 'Method Not Allowed',
self::HTTP_NOT_ACCEPTABLE => 'Not Acceptable',
self::HTTP_PROXY_AUTHENTICATION_REQUIRED => 'Proxy Authentication Required',
self::HTTP_REQUEST_TIMEOUT => 'Request Timeout',
self::HTTP_CONFILICT => 'Conflict',
self::HTTP_GONE => 'Gone',
self::HTTP_LENGTH_REQUIRED => 'Length Required',
self::HTTP_PRECONDITION_FAILED => 'Precondition Failed',
self::HTTP_PAYLOAD_TOO_LARGE => 'Payload Too Large',
self::HTTP_URI_TOO_LONG => 'URI Too Long',
self::HTTP_UNSUPPORTET_MEDIA_TYPE => 'Unsupported Media Type',
self::HTTP_RANGE_NOT_SATISFIABLE => 'Range Not Satisfiable',
self::HTTP_EXPECTATION_FAILED => 'Expectation Failed',
self::HTTP_UPGRADE_REQUIRED => 'Upgrade Required',
self::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error',
self::HTTP_NOT_IMPLEMENTED => 'Not Implemented',
self::HTTP_BAD_GATEWAY => 'Bad Gateway',
self::HTTP_SERVICE_UNAVAILABLE => 'Service Unavailable',
self::HTTP_GATEWAY_TIMEOUT => 'Gateway Timeout',
self::HTTP_VERSION_NOT_SUPPORTED => 'HTTP Version Not Supported',
];
/** @var array $oneValueHeaders headers which can hold ONE value ONLY */
protected static $oneValueHeaders = ['content-type', 'content-length', 'content-disposition', 'date'];
/** @var array $headers */
protected $headers = [];
/** @var array $headerNames */
protected $headerNames = [];
/** @var string $content Response content */
protected $content;
/** @var int $statusCode HTTP status code */
protected $statusCode;
/** @var string $statusText HTTP status text */
protected $statusText;
/** @var string $protocolVersion HTTP protocol version */
protected $protocolVersion;
/** @var CookieCollection $cookies */
protected $cookies;
/**
* @param string $content
* @param int $statusCode
* @param array $headers
* @param string $protocolVersion
* @param null $statusText
* @param CookieCollection|array $cookies
*/
public function __construct(
$content = null,
$statusCode = self::HTTP_OK,
array $headers = [],
$protocolVersion = '1.1',
$statusText = null,
$cookies = []
) {
$this->statusCode = (int)$statusCode;
$this->headers = [];
foreach ($headers as $name => $value) {
$this->setHeader($name, $value);
}
if (!$this->hasHeader('content-type')) {
$this->setHeader('Content-Type', 'text/html; charset=utf-8');
}
$this->setContent($content);
$this->protocolVersion = $protocolVersion;
$this->statusText = $statusText;
$this->setCookies($cookies);
}
/**
* Sends the response headers and content to the client.
*
* @param DateTimeInterface|null $sendTime
*
* @return void
*/
public function send(DateTimeInterface $sendTime = null)
{
header(sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->getStatusCode(), $this->getStatusText()));
if (!$this->hasHeader('date')) {
if ($sendTime === null) {
$time = time();
} else {
$time = $sendTime->getTimestamp();
}
$this->setHeader('Date', gmdate('D, d M Y H:i:s \G\M\T', $time));
}
foreach ($this->headers as $name => $value) {
$replace = strtolower($name) === 'content-type';
$value = implode(', ', $value);
header(sprintf('%s: %s', $this->headerNames[$name], $value), $replace, $this->getStatusCode());
}
foreach ($this->cookies as $key => $cookie) {
header($cookie->toHttpHeader(), false);
}
if ($this->content !== null) {
echo $this->content;
}
}
/**
* Returns the response body.
*
* @return string|null
*/
public function getContent()
{
return $this->content;
}
/**
* Sets the response body.
*
* @param string|null $content
*/
public function setContent($content)
{
if ($content !== null) {
$this->content = (string)$content;
$this->setHeader('Content-Length', (string)strlen($this->content));
} else {
$this->content = null;
unset($this->headers['content-type'], $this->headers['content-length']);
}
}
/**
* Returns the HTTP status code.
*
* @return int HTTP status code
*/
public function getStatusCode()
{
return $this->statusCode;
}
/**
* Sets the HTTP status code.
*
* @param int $statusCode HTTP status code
*/
public function setStatusCode($statusCode)
{
if (!array_key_exists($statusCode, self::$statusMessages)) {
throw new InvalidArgumentException(sprintf('Status Code %s is not supported.', $statusCode));
}
$this->statusCode = (int)$statusCode;
}
/**
* Gets the HTTP status text (=reason).
*
* @return string HTTP status text
*/
public function getStatusText()
{
if ($this->statusText === null) {
$this->statusText = self::$statusMessages[$this->statusCode];
}
return $this->statusText;
}
/**
* Sets the HTTP status text (=reason).
*
* @param string $message
*/
public function setStatusText($message)
{
$this->statusText = $message;
}
/**
* Returns the HTTP version
*
* @return string
*/
public function getProtocolVersion()
{
return $this->protocolVersion;
}
/**
* Sets header (overwrites existing header).
*
* @param string $name
* @param string|string[] $values
*/
public function setHeader($name, $values)
{
if (!is_string($values) && !is_array($values)) {
throw new HttpHeaderValueException(
'Invalid header, only string|string[] allowed',
0,
null,
$values
);
}
if ($values === '' || $values === []) {
throw new HttpHeaderValueException('Empty header not allowed', 0, null, $values);
}
if (!is_array($values)) {
$values = [$values];
}
foreach ($values as $value) {
$value = $this->sanitizeHeaderValue($value);
if (!is_string($value) || $value === '') {
throw new HttpHeaderValueException('Invalid header', 0, null, $value);
}
}
$normalized = $this->normalizeHeaderName($name);
$this->headers[$normalized] = $values;
$this->headerNames[$normalized] = $name;
}
/**
* Sets header (appends existing header).
*
* @param string $name
* @param string $value
*/
public function addHeader($name, $value)
{
if (in_array($this->normalizeHeaderName($name), self::$oneValueHeaders, true)) {
throw new InvalidArgumentException(sprintf('Cannot append header "%s".', $name));
}
if (!is_string($value)) {
throw new HttpHeaderValueException('Invalid header', 0, null, $value);
}
$value = $this->sanitizeHeaderValue($value);
if ($value === '') {
throw new HttpHeaderValueException('Empty header not allowed', 0, null, $value);
}
$normalized = $this->normalizeHeaderName($name);
$header = $this->getHeader($normalized);
if (count($header) === 0) {
$this->headerNames[$normalized] = $name;
}
if (count(array_intersect($header, [$value])) === 0) {
$header[] = $value;
$this->headers[$normalized] = $header;
}
}
/**
* Gets header as array.
*
* @param string $name
*
* @return string[]|array empty if not set
*/
public function getHeader($name)
{
$normalized = $this->normalizeHeaderName($name);
if ($this->hasHeader($normalized)) {
return $this->headers[$normalized];
}
return [];
}
/**
* Gets header as comma-seperated string.
*
* @example getHeaderLine(Headername) -> 'value1, value2'
*
* @param string $name
*
* @return string|null null if header not set
*/
public function getHeaderLine($name)
{
$header = $this->getHeader($name);
if (count($header) === 0) {
return null;
}
return implode(', ', $header);
}
/**
* Returns true if the header exists.
*
* @param string $name
*
* @return bool
*/
public function hasHeader($name)
{
return array_key_exists($this->normalizeHeaderName($name), $this->headers);
}
/**
* Returns all headers
*
* @return array all headers
*/
public function getHeaders()
{
$headers = [];
foreach ($this->headers as $name => $value) {
$headers[$this->headerNames[$name]] = $value;
}
return $headers;
}
/**
* Returns all headers with normalized names
*
* @return array all headers
*/
public function getHeadersNormalized()
{
return $this->headers;
}
/**
* Returns the value of the Content-Type header.
*
* @example getContentType() -> 'text/html; charset=utf-8'
*
* @return string
*/
public function getContentType()
{
return $this->getHeaderLine('content-type');
}
/**
* Overwrites the Content-Type header.
*
* @param string $contentType
* @param string $charset
*/
public function setContentType($contentType, $charset = 'utf-8')
{
$this->setHeader('Content-Type', sprintf('%s; charset=%s', $contentType, $charset));
}
/**
* Returns value of the Content-Disposition header.
*
* @example getContentDisposition() -> 'attachment; filename*="file.txt"; filename="file.txt"'
*
* @return string Content-Disposition header
*/
public function getContentDisposition()
{
return $this->getHeaderLine('content-disposition');
}
/**
* Sets the Content-Disposition HTTP header.
*
* @param string $disposition values: 'inline'|'attachment'
* @param string $clientFileName file name for download on client
*/
public function setContentDisposition($disposition = self::DISPOSITION_ATTACHMENT, $clientFileName = '')
{
$disposition = strtolower($disposition);
if (!in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE], true)) {
throw new InvalidArgumentException(sprintf('Invalid Content-Disposition "%s".', $disposition));
}
if ($clientFileName === '') {
throw new InvalidArgumentException('Filename required.');
}
$encodedName = urlencode(StringUtil::toFilename($clientFileName));
$fallbackName = StringUtil::toAscii($clientFileName);
$header = sprintf('%s; filename*="%s"', $disposition, $encodedName);
if ($fallbackName !== '') {
$header .= sprintf('; filename="%s"', $fallbackName);
}
$this->setHeader('Content-Disposition', $header);
}
/**
* Removes all non-ASCII characters from a header value
*
* @param string $value
*
* @return string
*/
protected function sanitizeHeaderValue($value)
{
if (!is_string($value)) {
throw new HttpHeaderValueException('Invalid header', 0, null, $value);
}
return StringUtil::toAscii($value);
}
/**
* @param string $name
* @param string $value
* @param int $timeToLive 0 = for ever
*/
public function addSimpleCookie($name, $value, $timeToLive = 0)
{
$this->addCookie(new Cookie($name, $value, $timeToLive));
}
/**
* @param Cookie $cookie
*/
public function addCookie(Cookie $cookie)
{
$this->cookies[] = $cookie;
}
/**
* @param string $cookieName
*/
public function removeCookie($cookieName)
{
foreach ($this->cookies as $key => $cookie) {
if ($cookieName === $cookie->getName()) {
unset($this->cookies[$key]);
}
}
}
/**
* @return CookieCollection
*/
public function getCookies()
{
return $this->cookies;
}
/**
* @param CookieCollection|array $cookies
*/
protected function setCookies($cookies)
{
if (is_object($cookies) && get_class($cookies) === CookieCollection::class) {
$this->cookies = $cookies;
} else {
$this->cookies = new CookieCollection($cookies);
}
}
/**
* Transforms header name to normalized form.
*
* @example 'HEADER-NAME' -> 'header-name'
*
* @param string $name
*
* @return string
*/
protected function normalizeHeaderName($name)
{
if (!preg_match('/^[a-zA-Z0-9\-]+$/', $name)) {
throw new InvalidArgumentException(sprintf('Invalid character in header name "%s".', $name));
}
return str_replace('_', '-', strtolower($name));
}
}