ServerRequest.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. <?php
  2. namespace React\Http\Message;
  3. use Psr\Http\Message\ServerRequestInterface;
  4. use Psr\Http\Message\StreamInterface;
  5. use Psr\Http\Message\UriInterface;
  6. use React\Http\Io\AbstractRequest;
  7. use React\Http\Io\BufferedBody;
  8. use React\Http\Io\HttpBodyStream;
  9. use React\Stream\ReadableStreamInterface;
  10. /**
  11. * Respresents an incoming server request message.
  12. *
  13. * This class implements the
  14. * [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface)
  15. * which extends the
  16. * [PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface)
  17. * which in turn extends the
  18. * [PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface).
  19. *
  20. * This is mostly used internally to represent each incoming request message.
  21. * Likewise, you can also use this class in test cases to test how your web
  22. * application reacts to certain HTTP requests.
  23. *
  24. * > Internally, this implementation builds on top of a base class which is
  25. * considered an implementation detail that may change in the future.
  26. *
  27. * @see ServerRequestInterface
  28. */
  29. final class ServerRequest extends AbstractRequest implements ServerRequestInterface
  30. {
  31. private $attributes = array();
  32. private $serverParams;
  33. private $fileParams = array();
  34. private $cookies = array();
  35. private $queryParams = array();
  36. private $parsedBody;
  37. /**
  38. * @param string $method HTTP method for the request.
  39. * @param string|UriInterface $url URL for the request.
  40. * @param array<string,string|string[]> $headers Headers for the message.
  41. * @param string|ReadableStreamInterface|StreamInterface $body Message body.
  42. * @param string $version HTTP protocol version.
  43. * @param array<string,string> $serverParams server-side parameters
  44. * @throws \InvalidArgumentException for an invalid URL or body
  45. */
  46. public function __construct(
  47. $method,
  48. $url,
  49. array $headers = array(),
  50. $body = '',
  51. $version = '1.1',
  52. $serverParams = array()
  53. ) {
  54. if (\is_string($body)) {
  55. $body = new BufferedBody($body);
  56. } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) {
  57. $temp = new self($method, '', $headers);
  58. $size = (int) $temp->getHeaderLine('Content-Length');
  59. if (\strtolower($temp->getHeaderLine('Transfer-Encoding')) === 'chunked') {
  60. $size = null;
  61. }
  62. $body = new HttpBodyStream($body, $size);
  63. } elseif (!$body instanceof StreamInterface) {
  64. throw new \InvalidArgumentException('Invalid server request body given');
  65. }
  66. parent::__construct($method, $url, $headers, $body, $version);
  67. $this->serverParams = $serverParams;
  68. $query = $this->getUri()->getQuery();
  69. if ($query !== '') {
  70. \parse_str($query, $this->queryParams);
  71. }
  72. // Multiple cookie headers are not allowed according
  73. // to https://tools.ietf.org/html/rfc6265#section-5.4
  74. $cookieHeaders = $this->getHeader("Cookie");
  75. if (count($cookieHeaders) === 1) {
  76. $this->cookies = $this->parseCookie($cookieHeaders[0]);
  77. }
  78. }
  79. public function getServerParams()
  80. {
  81. return $this->serverParams;
  82. }
  83. public function getCookieParams()
  84. {
  85. return $this->cookies;
  86. }
  87. public function withCookieParams(array $cookies)
  88. {
  89. $new = clone $this;
  90. $new->cookies = $cookies;
  91. return $new;
  92. }
  93. public function getQueryParams()
  94. {
  95. return $this->queryParams;
  96. }
  97. public function withQueryParams(array $query)
  98. {
  99. $new = clone $this;
  100. $new->queryParams = $query;
  101. return $new;
  102. }
  103. public function getUploadedFiles()
  104. {
  105. return $this->fileParams;
  106. }
  107. public function withUploadedFiles(array $uploadedFiles)
  108. {
  109. $new = clone $this;
  110. $new->fileParams = $uploadedFiles;
  111. return $new;
  112. }
  113. public function getParsedBody()
  114. {
  115. return $this->parsedBody;
  116. }
  117. public function withParsedBody($data)
  118. {
  119. $new = clone $this;
  120. $new->parsedBody = $data;
  121. return $new;
  122. }
  123. public function getAttributes()
  124. {
  125. return $this->attributes;
  126. }
  127. public function getAttribute($name, $default = null)
  128. {
  129. if (!\array_key_exists($name, $this->attributes)) {
  130. return $default;
  131. }
  132. return $this->attributes[$name];
  133. }
  134. public function withAttribute($name, $value)
  135. {
  136. $new = clone $this;
  137. $new->attributes[$name] = $value;
  138. return $new;
  139. }
  140. public function withoutAttribute($name)
  141. {
  142. $new = clone $this;
  143. unset($new->attributes[$name]);
  144. return $new;
  145. }
  146. /**
  147. * @param string $cookie
  148. * @return array
  149. */
  150. private function parseCookie($cookie)
  151. {
  152. $cookieArray = \explode(';', $cookie);
  153. $result = array();
  154. foreach ($cookieArray as $pair) {
  155. $pair = \trim($pair);
  156. $nameValuePair = \explode('=', $pair, 2);
  157. if (\count($nameValuePair) === 2) {
  158. $key = $nameValuePair[0];
  159. $value = \urldecode($nameValuePair[1]);
  160. $result[$key] = $value;
  161. }
  162. }
  163. return $result;
  164. }
  165. /**
  166. * [Internal] Parse incoming HTTP protocol message
  167. *
  168. * @internal
  169. * @param string $message
  170. * @param array<string,string|int|float> $serverParams
  171. * @return self
  172. * @throws \InvalidArgumentException if given $message is not a valid HTTP request message
  173. */
  174. public static function parseMessage($message, array $serverParams)
  175. {
  176. // parse request line like "GET /path HTTP/1.1"
  177. $start = array();
  178. if (!\preg_match('#^(?<method>[^ ]+) (?<target>[^ ]+) HTTP/(?<version>\d\.\d)#m', $message, $start)) {
  179. throw new \InvalidArgumentException('Unable to parse invalid request-line');
  180. }
  181. // only support HTTP/1.1 and HTTP/1.0 requests
  182. if ($start['version'] !== '1.1' && $start['version'] !== '1.0') {
  183. throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED);
  184. }
  185. // check number of valid header fields matches number of lines + request line
  186. $matches = array();
  187. $n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER);
  188. if (\substr_count($message, "\n") !== $n + 1) {
  189. throw new \InvalidArgumentException('Unable to parse invalid request header fields');
  190. }
  191. // format all header fields into associative array
  192. $host = null;
  193. $headers = array();
  194. foreach ($matches as $match) {
  195. $headers[$match[1]][] = $match[2];
  196. // match `Host` request header
  197. if ($host === null && \strtolower($match[1]) === 'host') {
  198. $host = $match[2];
  199. }
  200. }
  201. // scheme is `http` unless TLS is used
  202. $scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://';
  203. // default host if unset comes from local socket address or defaults to localhost
  204. $hasHost = $host !== null;
  205. if ($host === null) {
  206. $host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1';
  207. }
  208. if ($start['method'] === 'OPTIONS' && $start['target'] === '*') {
  209. // support asterisk-form for `OPTIONS *` request line only
  210. $uri = $scheme . $host;
  211. } elseif ($start['method'] === 'CONNECT') {
  212. $parts = \parse_url('tcp://' . $start['target']);
  213. // check this is a valid authority-form request-target (host:port)
  214. if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) {
  215. throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
  216. }
  217. $uri = $scheme . $start['target'];
  218. } else {
  219. // support absolute-form or origin-form for proxy requests
  220. if ($start['target'][0] === '/') {
  221. $uri = $scheme . $host . $start['target'];
  222. } else {
  223. // ensure absolute-form request-target contains a valid URI
  224. $parts = \parse_url($start['target']);
  225. // make sure value contains valid host component (IP or hostname), but no fragment
  226. if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
  227. throw new \InvalidArgumentException('Invalid absolute-form request-target');
  228. }
  229. $uri = $start['target'];
  230. }
  231. }
  232. $request = new self(
  233. $start['method'],
  234. $uri,
  235. $headers,
  236. '',
  237. $start['version'],
  238. $serverParams
  239. );
  240. // only assign request target if it is not in origin-form (happy path for most normal requests)
  241. if ($start['target'][0] !== '/') {
  242. $request = $request->withRequestTarget($start['target']);
  243. }
  244. if ($hasHost) {
  245. // Optional Host request header value MUST be valid (host and optional port)
  246. $parts = \parse_url('http://' . $request->getHeaderLine('Host'));
  247. // make sure value contains valid host component (IP or hostname)
  248. if (!$parts || !isset($parts['scheme'], $parts['host'])) {
  249. $parts = false;
  250. }
  251. // make sure value does not contain any other URI component
  252. if (\is_array($parts)) {
  253. unset($parts['scheme'], $parts['host'], $parts['port']);
  254. }
  255. if ($parts === false || $parts) {
  256. throw new \InvalidArgumentException('Invalid Host header value');
  257. }
  258. } elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') {
  259. // require Host request header for HTTP/1.1 (except for CONNECT method)
  260. throw new \InvalidArgumentException('Missing required Host request header');
  261. } elseif (!$hasHost) {
  262. // remove default Host request header for HTTP/1.0 when not explicitly given
  263. $request = $request->withoutHeader('Host');
  264. }
  265. // ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers
  266. if ($request->hasHeader('Transfer-Encoding')) {
  267. if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') {
  268. throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED);
  269. }
  270. // Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time
  271. // as per https://tools.ietf.org/html/rfc7230#section-3.3.3
  272. if ($request->hasHeader('Content-Length')) {
  273. throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST);
  274. }
  275. } elseif ($request->hasHeader('Content-Length')) {
  276. $string = $request->getHeaderLine('Content-Length');
  277. if ((string)(int)$string !== $string) {
  278. // Content-Length value is not an integer or not a single integer
  279. throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST);
  280. }
  281. }
  282. return $request;
  283. }
  284. }