475 lines
16 KiB
PHP
475 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Http;
|
|
|
|
use Psr\Http\Message\MessageInterface;
|
|
use Psr\Http\Message\StreamInterface;
|
|
|
|
/**
|
|
* Default implementation for the MessageInterface of the PSR-7 standard
|
|
* It is the base for any request or response for PSR-7.
|
|
*
|
|
* Highly inspired by https://github.com/phly/http/
|
|
*
|
|
* @internal Note that this is not public API yet.
|
|
*/
|
|
class Message implements MessageInterface
|
|
{
|
|
/**
|
|
* The HTTP Protocol version, defaults to 1.1
|
|
*/
|
|
protected string $protocolVersion = '1.1';
|
|
|
|
/**
|
|
* Associative array containing all headers of this Message
|
|
* This is a mixed-case list of the headers (as due to the specification)
|
|
*/
|
|
protected array $headers = [];
|
|
|
|
/**
|
|
* Lowercased version of all headers, in order to check if a header is set or not
|
|
* this way a lot of checks are easier to be set
|
|
*/
|
|
protected array $lowercasedHeaderNames = [];
|
|
|
|
/**
|
|
* The body as a Stream object
|
|
*/
|
|
protected ?StreamInterface $body = null;
|
|
|
|
/**
|
|
* Retrieves the HTTP protocol version as a string.
|
|
*
|
|
* The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
|
|
*
|
|
* @return string HTTP protocol version.
|
|
*/
|
|
public function getProtocolVersion(): string
|
|
{
|
|
return $this->protocolVersion;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified HTTP protocol version.
|
|
*
|
|
* The version string MUST contain only the HTTP version number (e.g.,
|
|
* "1.1", "1.0").
|
|
*
|
|
* This method MUST be implemented in such a way as to retain the
|
|
* immutability of the message, and MUST return an instance that has the
|
|
* new protocol version.
|
|
*
|
|
* @param string $version HTTP protocol version
|
|
* @return static
|
|
*/
|
|
public function withProtocolVersion(string $version): MessageInterface
|
|
{
|
|
$clonedObject = clone $this;
|
|
$clonedObject->protocolVersion = $version;
|
|
return $clonedObject;
|
|
}
|
|
|
|
/**
|
|
* Retrieves all message header values.
|
|
*
|
|
* The keys represent the header name as it will be sent over the wire, and
|
|
* each value is an array of strings associated with the header.
|
|
*
|
|
* ```
|
|
* // Represent the headers as a string
|
|
* foreach ($message->getHeaders() as $name => $values) {
|
|
* echo $name . ": " . implode(", ", $values);
|
|
* }
|
|
*
|
|
* // Emit headers iteratively:
|
|
* foreach ($message->getHeaders() as $name => $values) {
|
|
* foreach ($values as $value) {
|
|
* header(sprintf('%s: %s', $name, $value), false);
|
|
* }
|
|
* }
|
|
* ```
|
|
*
|
|
* While header names are not case-sensitive, getHeaders() will preserve the
|
|
* exact case in which headers were originally specified.
|
|
*
|
|
* @return array Returns an associative array of the message's headers. Each
|
|
* key MUST be a header name, and each value MUST be an array of strings
|
|
* for that header.
|
|
*/
|
|
public function getHeaders(): array
|
|
{
|
|
return $this->headers;
|
|
}
|
|
|
|
/**
|
|
* Checks if a header exists by the given case-insensitive name.
|
|
*
|
|
* @param string $name Case-insensitive header field name.
|
|
* @return bool Returns true if any header names match the given header
|
|
* name using a case-insensitive string comparison. Returns false if
|
|
* no matching header name is found in the message.
|
|
*/
|
|
public function hasHeader(string $name): bool
|
|
{
|
|
return isset($this->lowercasedHeaderNames[strtolower($name)]);
|
|
}
|
|
|
|
/**
|
|
* Retrieves a message header value by the given case-insensitive name.
|
|
*
|
|
* This method returns an array of all the header values of the given
|
|
* case-insensitive header name.
|
|
*
|
|
* If the header does not appear in the message, this method MUST return an
|
|
* empty array.
|
|
*
|
|
* @param string $name Case-insensitive header field name.
|
|
* @return string[] An array of string values as provided for the given
|
|
* header. If the header does not appear in the message, this method MUST
|
|
* return an empty array.
|
|
*/
|
|
public function getHeader(string $name): array
|
|
{
|
|
if (!$this->hasHeader($name)) {
|
|
return [];
|
|
}
|
|
$header = $this->lowercasedHeaderNames[strtolower($name)];
|
|
$headerValue = $this->headers[$header];
|
|
if (is_array($headerValue)) {
|
|
return $headerValue;
|
|
}
|
|
return [$headerValue];
|
|
}
|
|
|
|
/**
|
|
* Retrieves a comma-separated string of the values for a single header.
|
|
*
|
|
* This method returns all of the header values of the given
|
|
* case-insensitive header name as a string concatenated together using
|
|
* a comma.
|
|
*
|
|
* NOTE: Not all header values may be appropriately represented using
|
|
* comma concatenation. For such headers, use getHeader() instead
|
|
* and supply your own delimiter when concatenating.
|
|
*
|
|
* If the header does not appear in the message, this method MUST return
|
|
* an empty string.
|
|
*
|
|
* @param string $name Case-insensitive header field name.
|
|
* @return string A string of values as provided for the given header
|
|
* concatenated together using a comma. If the header does not appear in
|
|
* the message, this method MUST return an empty string.
|
|
*/
|
|
public function getHeaderLine(string $name): string
|
|
{
|
|
$headerValue = $this->getHeader($name);
|
|
if (empty($headerValue)) {
|
|
return '';
|
|
}
|
|
return implode(',', $headerValue);
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the provided value replacing the specified header.
|
|
*
|
|
* While header names are case-insensitive, the casing of the header will
|
|
* be preserved by this function, and returned from getHeaders().
|
|
*
|
|
* This method MUST be implemented in such a way as to retain the
|
|
* immutability of the message, and MUST return an instance that has the
|
|
* new and/or updated header and value.
|
|
*
|
|
* @param string $name Case-insensitive header field name.
|
|
* @param string|string[] $value Header value(s).
|
|
* @return static
|
|
* @throws \InvalidArgumentException for invalid header names or values.
|
|
*/
|
|
public function withHeader(string $name, $value): MessageInterface
|
|
{
|
|
if (is_string($value)) {
|
|
$value = [$value];
|
|
}
|
|
|
|
if (!is_array($value) || !$this->arrayContainsOnlyStrings($value)) {
|
|
throw new \InvalidArgumentException('Invalid header value for header "' . $name . '". The value must be a string or an array of strings.', 1436717266);
|
|
}
|
|
|
|
$this->validateHeaderName($name);
|
|
$this->validateHeaderValues($value);
|
|
$lowercasedHeaderName = strtolower($name);
|
|
|
|
$clonedObject = clone $this;
|
|
$clonedObject->headers[$name] = $value;
|
|
$clonedObject->lowercasedHeaderNames[$lowercasedHeaderName] = $name;
|
|
return $clonedObject;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified header appended with the given value.
|
|
*
|
|
* Existing values for the specified header will be maintained. The new
|
|
* value(s) will be appended to the existing list. If the header did not
|
|
* exist previously, it will be added.
|
|
*
|
|
* This method MUST be implemented in such a way as to retain the
|
|
* immutability of the message, and MUST return an instance that has the
|
|
* new header and/or value.
|
|
*
|
|
* @param string $name Case-insensitive header field name to add.
|
|
* @param string|string[] $value Header value(s).
|
|
* @return static
|
|
* @throws \InvalidArgumentException for invalid header names or values.
|
|
*/
|
|
public function withAddedHeader(string $name, $value): MessageInterface
|
|
{
|
|
if (is_string($value)) {
|
|
$value = [$value];
|
|
}
|
|
if (!is_array($value) || !$this->arrayContainsOnlyStrings($value)) {
|
|
throw new \InvalidArgumentException('Invalid header value for header "' . $name . '". The header value must be a string or array of strings', 1436717267);
|
|
}
|
|
$this->validateHeaderName($name);
|
|
$this->validateHeaderValues($value);
|
|
if (!$this->hasHeader($name)) {
|
|
return $this->withHeader($name, $value);
|
|
}
|
|
$name = $this->lowercasedHeaderNames[strtolower($name)];
|
|
$clonedObject = clone $this;
|
|
$clonedObject->headers[$name] = array_merge($this->headers[$name], $value);
|
|
return $clonedObject;
|
|
}
|
|
|
|
/**
|
|
* Return an instance without the specified header.
|
|
*
|
|
* Header resolution MUST be done without case-sensitivity.
|
|
*
|
|
* This method MUST be implemented in such a way as to retain the
|
|
* immutability of the message, and MUST return an instance that removes
|
|
* the named header.
|
|
*
|
|
* @param string $name Case-insensitive header field name to remove.
|
|
* @return static
|
|
*/
|
|
public function withoutHeader(string $name): MessageInterface
|
|
{
|
|
if (!$this->hasHeader($name)) {
|
|
return clone $this;
|
|
}
|
|
// fetch the original header from the lowercased version
|
|
$lowercasedHeader = strtolower($name);
|
|
$name = $this->lowercasedHeaderNames[$lowercasedHeader];
|
|
$clonedObject = clone $this;
|
|
unset($clonedObject->headers[$name], $clonedObject->lowercasedHeaderNames[$lowercasedHeader]);
|
|
return $clonedObject;
|
|
}
|
|
|
|
/**
|
|
* Gets the body of the message.
|
|
*
|
|
* @return StreamInterface Returns the body as a stream.
|
|
*/
|
|
public function getBody(): StreamInterface
|
|
{
|
|
if ($this->body === null) {
|
|
$this->body = new Stream('php://temp', 'r+');
|
|
}
|
|
return $this->body;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified message body.
|
|
*
|
|
* The body MUST be a StreamInterface object.
|
|
*
|
|
* This method MUST be implemented in such a way as to retain the
|
|
* immutability of the message, and MUST return a new instance that has the
|
|
* new body stream.
|
|
*
|
|
* @return static
|
|
* @throws \InvalidArgumentException When the body is not valid.
|
|
*/
|
|
public function withBody(StreamInterface $body): MessageInterface
|
|
{
|
|
$clonedObject = clone $this;
|
|
$clonedObject->body = $body;
|
|
return $clonedObject;
|
|
}
|
|
|
|
/**
|
|
* Ensure header names and values are valid.
|
|
*
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
protected function assertHeaders(array $headers): void
|
|
{
|
|
foreach ($headers as $name => $headerValues) {
|
|
$this->validateHeaderName($name);
|
|
// check if all values are correct
|
|
array_walk($headerValues, static function (string $value, string $key, Message $messageObject): void {
|
|
if (!$messageObject->isValidHeaderValue($value)) {
|
|
throw new \InvalidArgumentException('Invalid header value for header "' . $key . '"', 1436717268);
|
|
}
|
|
}, $this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter a set of headers to ensure they are in the correct internal format.
|
|
*
|
|
* Used by message constructors to allow setting all initial headers at once.
|
|
*
|
|
* @param array $originalHeaders Headers to filter.
|
|
* @return array Filtered headers and names.
|
|
*/
|
|
protected function filterHeaders(array $originalHeaders): array
|
|
{
|
|
$headerNames = $headers = [];
|
|
foreach ($originalHeaders as $header => $value) {
|
|
if (!is_string($header) || (!is_array($value) && !is_scalar($value))) {
|
|
continue;
|
|
}
|
|
if (!is_array($value)) {
|
|
$value = [(string)$value];
|
|
}
|
|
$headerNames[strtolower($header)] = $header;
|
|
$headers[$header] = $value;
|
|
}
|
|
return [$headerNames, $headers];
|
|
}
|
|
|
|
/**
|
|
* Helper function to test if an array contains only strings
|
|
*/
|
|
protected function arrayContainsOnlyStrings(array $data): bool
|
|
{
|
|
return array_reduce($data, static function ($original, $item) {
|
|
return is_string($item) ? $original : false;
|
|
}, true);
|
|
}
|
|
|
|
/**
|
|
* Assert that the provided header values are valid.
|
|
*
|
|
* @see https://tools.ietf.org/html/rfc7230#section-3.2
|
|
* @param string[] $values
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
protected function validateHeaderValues(array $values): void
|
|
{
|
|
array_walk($values, static function (string $value, string $key, Message $messageObject): void {
|
|
if (!$messageObject->isValidHeaderValue($value)) {
|
|
throw new \InvalidArgumentException('Invalid header value for header "' . $key . '"', 1436717269);
|
|
}
|
|
}, $this);
|
|
}
|
|
|
|
/**
|
|
* Filter a header value
|
|
*
|
|
* Ensures CRLF header injection vectors are filtered.
|
|
*
|
|
* Per RFC 7230, only VISIBLE ASCII characters, spaces, and horizontal
|
|
* tabs are allowed in values; header continuations MUST consist of
|
|
* a single CRLF sequence followed by a space or horizontal tab.
|
|
*
|
|
* This method filters any values not allowed from the string, and is
|
|
* lossy.
|
|
*
|
|
* @see http://en.wikipedia.org/wiki/HTTP_response_splitting
|
|
* @todo: Unused? And why is this public? Maybe align with zend-diactoros again
|
|
*/
|
|
public function filter(string $value): string
|
|
{
|
|
$length = strlen($value);
|
|
$string = '';
|
|
for ($i = 0; $i < $length; $i += 1) {
|
|
$ascii = ord($value[$i]);
|
|
|
|
// Detect continuation sequences
|
|
if ($ascii === 13) {
|
|
$lf = ord($value[$i + 1]);
|
|
$ws = ord($value[$i + 2]);
|
|
if ($lf === 10 && in_array($ws, [9, 32], true)) {
|
|
$string .= $value[$i] . $value[$i + 1];
|
|
$i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Non-visible, non-whitespace characters
|
|
// 9 === horizontal tab
|
|
// 32-126, 128-254 === visible
|
|
// 127 === DEL
|
|
// 255 === null byte
|
|
if (($ascii < 32 && $ascii !== 9) || $ascii === 127 || $ascii > 254) {
|
|
continue;
|
|
}
|
|
|
|
$string .= $value[$i];
|
|
}
|
|
|
|
return $string;
|
|
}
|
|
|
|
/**
|
|
* Check whether a header name is valid and throw an exception.
|
|
*
|
|
* @see https://tools.ietf.org/html/rfc7230#section-3.2
|
|
* @throws \InvalidArgumentException
|
|
* @todo: Review. Should be protected / private, maybe align with zend-diactoros again
|
|
*/
|
|
public function validateHeaderName(string $name): void
|
|
{
|
|
if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/', $name)) {
|
|
throw new \InvalidArgumentException('Invalid header name, given "' . $name . '"', 1436717270);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if an HTTP header value is valid.
|
|
*
|
|
* Per RFC 7230, only VISIBLE ASCII characters, spaces, and horizontal
|
|
* tabs are allowed in values; header continuations MUST consist of
|
|
* a single CRLF sequence followed by a space or horizontal tab.
|
|
*
|
|
* @see http://en.wikipedia.org/wiki/HTTP_response_splitting
|
|
* @todo: Review. Should be protected / private, maybe align with zend-diactoros again
|
|
*/
|
|
public function isValidHeaderValue(string $value): bool
|
|
{
|
|
// Any occurrence of \r or \n is invalid
|
|
if (strpbrk($value, "\r\n") !== false) {
|
|
return false;
|
|
}
|
|
|
|
foreach (unpack('C*', $value) as $ascii) {
|
|
// Non-visible, non-whitespace characters
|
|
// 9 === horizontal tab
|
|
// 32-126, 128-254 === visible
|
|
// 127 === DEL
|
|
// 255 === null byte
|
|
if (($ascii < 32 && $ascii !== 9) || $ascii === 127 || $ascii > 254) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|