Fix typo in parameter type documentation
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / util / HTTPRequest.class.php
CommitLineData
86fc0430
TD
1<?php
2namespace wcf\util;
3536d2fe
AE
3use wcf\system\exception\HTTPNotFoundException;
4use wcf\system\exception\HTTPServerErrorException;
5use wcf\system\exception\HTTPUnauthorizedException;
86fc0430
TD
6use wcf\system\exception\SystemException;
7use wcf\system\io\RemoteFile;
8use wcf\system\Regex;
9use wcf\system\WCF;
10
11/**
8fe14bd6 12 * Sends HTTP/1.1 requests.
86fc0430
TD
13 * It supports POST, SSL, Basic Auth etc.
14 *
3536d2fe 15 * @author Tim Duesterhus
2b6cb5c2 16 * @copyright 2001-2015 WoltLab GmbH
86fc0430
TD
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package com.woltlab.wcf
19 * @subpackage util
20 * @category Community Framework
21 */
a195ffa6 22final class HTTPRequest {
86fc0430
TD
23 /**
24 * given options
a17de04e 25 * @var array
86fc0430
TD
26 */
27 private $options = array();
28
29 /**
30 * given post parameters
a17de04e 31 * @var array
86fc0430
TD
32 */
33 private $postParameters = array();
34
5f70a0de
TD
35 /**
36 * given files
06355ec3 37 * @var array
5f70a0de
TD
38 */
39 private $files = array();
40
86fc0430 41 /**
60f613e2 42 * indicates if request will be made via SSL
a17de04e 43 * @var boolean
86fc0430
TD
44 */
45 private $useSSL = false;
46
5819f701
TD
47 /**
48 * indicates if the connection to the proxy target will be made via SSL
49 * @var boolean
50 */
51 private $originUseSSL = false;
52
86fc0430
TD
53 /**
54 * target host
a17de04e 55 * @var string
86fc0430
TD
56 */
57 private $host;
58
5819f701
TD
59 /**
60 * target host if a proxy is used
61 * @var string
62 */
63 private $originHost;
64
86fc0430
TD
65 /**
66 * target port
a17de04e 67 * @var integer
86fc0430
TD
68 */
69 private $port;
a17de04e 70
5819f701
TD
71 /**
72 * target port if a proxy is used
73 * @var integer
74 */
75 private $originPort;
76
86fc0430
TD
77 /**
78 * target path
a17de04e 79 * @var string
86fc0430
TD
80 */
81 private $path;
82
83 /**
84 * target query string
a17de04e 85 * @var string
86fc0430
TD
86 */
87 private $query;
88
25cdc083
AE
89 /**
90 * request URL
91 * @var string
92 */
93 private $url = '';
94
86fc0430
TD
95 /**
96 * request headers
a17de04e 97 * @var array<string>
86fc0430
TD
98 */
99 private $headers = array();
100
8fe14bd6
TD
101 /**
102 * legacy headers
103 * @var array<string>
104 */
105 private $legacyHeaders = array();
106
5f70a0de
TD
107 /**
108 * request body
109 * @var string
110 */
111 private $body = '';
112
86fc0430
TD
113 /**
114 * reply headers
a17de04e 115 * @var array<string>
86fc0430
TD
116 */
117 private $replyHeaders = array();
118
119 /**
120 * reply body
a17de04e 121 * @var string
86fc0430
TD
122 */
123 private $replyBody = '';
124
125 /**
126 * reply status code
a17de04e 127 * @var integer
86fc0430
TD
128 */
129 private $statusCode = 0;
130
131 /**
a17de04e 132 * Constructs a new instance of HTTPRequest.
86fc0430
TD
133 *
134 * @param string $url URL to connect to
135 * @param array<string> $options
1b20f990 136 * @param mixed $postParameters Parameters to send via POST
5f70a0de 137 * @param array $files Files to attach to the request
86fc0430 138 */
1b20f990 139 public function __construct($url, array $options = array(), $postParameters = array(), array $files = array()) {
86fc0430
TD
140 $this->setURL($url);
141
142 $this->postParameters = $postParameters;
5f70a0de 143 $this->files = $files;
86fc0430
TD
144
145 $this->setOptions($options);
146
a195ffa6 147 // set default headers
8fe14bd6
TD
148 $this->addHeader('user-agent', "HTTP.PHP (HTTPRequest.class.php; WoltLab Community Framework/".WCF_VERSION."; ".WCF::getLanguage()->languageCode.")");
149 $this->addHeader('accept', '*/*');
150 $this->addHeader('accept-language', WCF::getLanguage()->getFixedLanguageCode());
151
4d28d5a2 152 if (isset($this->options['maxLength'])) {
5262964b 153 $this->addHeader('Range', 'bytes=0-'.($this->options['maxLength'] - 1));
4d28d5a2
SG
154 }
155
86fc0430 156 if ($this->options['method'] !== 'GET') {
5f70a0de 157 if (empty($this->files)) {
1b20f990
AE
158 if (is_array($postParameters)) {
159 $this->body = http_build_query($this->postParameters, '', '&');
160 }
161 else if (is_string($postParameters) && !empty($postParameters)) {
162 $this->body = $postParameters;
163 }
164
8fe14bd6 165 $this->addHeader('content-type', 'application/x-www-form-urlencoded');
5f70a0de
TD
166 }
167 else {
168 $boundary = StringUtil::getRandomID();
8fe14bd6 169 $this->addHeader('content-type', 'multipart/form-data; boundary='.$boundary);
5f70a0de
TD
170
171 // source of the iterators: http://stackoverflow.com/a/7623716/782822
172 if (!empty($this->postParameters)) {
173 $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($this->postParameters), \RecursiveIteratorIterator::SELF_FIRST);
174 foreach ($iterator as $k => $v) {
175 if (!$iterator->hasChildren()) {
176 $key = '';
177 for ($i = 0, $max = $iterator->getDepth(); $i <= $max; $i++) {
178 if ($i === 0) $key .= $iterator->getSubIterator($i)->key();
179 else $key .= '['.$iterator->getSubIterator($i)->key().']';
180 }
181
182 $this->body .= "--".$boundary."\r\n";
183 $this->body .= 'Content-Disposition: form-data; name="'.$key.'"'."\r\n\r\n";
184 $this->body .= $v."\r\n";
185 }
186 }
187 }
188
189 $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($this->files), \RecursiveIteratorIterator::SELF_FIRST);
190 foreach ($iterator as $k => $v) {
191 if (!$iterator->hasChildren()) {
192 $key = '';
193 for ($i = 0, $max = $iterator->getDepth(); $i <= $max; $i++) {
194 if ($i === 0) $key .= $iterator->getSubIterator($i)->key();
195 else $key .= '['.$iterator->getSubIterator($i)->key().']';
196 }
197
198 $this->body .= "--".$boundary."\r\n";
199 $this->body .= 'Content-Disposition: form-data; name="'.$k.'"; filename="'.basename($v).'"'."\r\n";
200 $this->body .= 'Content-Type: '.(FileUtil::getMimeType($v) ?: 'application/octet-stream.')."\r\n\r\n";
201 $this->body .= file_get_contents($v)."\r\n";
202 }
203 }
204
205 $this->body .= "--".$boundary."--";
206 }
8fe14bd6 207 $this->addHeader('content-length', strlen($this->body));
86fc0430
TD
208 }
209 if (isset($this->options['auth'])) {
8fe14bd6 210 $this->addHeader('authorization', "Basic ".base64_encode($options['auth']['username'].":".$options['auth']['password']));
86fc0430 211 }
8fe14bd6 212 $this->addHeader('connection', 'Close');
86fc0430
TD
213 }
214
215 /**
216 * Parses the given URL and applies PROXY_SERVER_HTTP.
217 *
a17de04e 218 * @param string $url
86fc0430
TD
219 */
220 private function setURL($url) {
adc039a5 221 $parsedUrl = $originUrl = parse_url($url);
63f9ff4e
TD
222
223 $this->originUseSSL = $originUrl['scheme'] === 'https';
224 $this->originHost = $originUrl['host'];
225 $this->originPort = isset($originUrl['port']) ? $originUrl['port'] : ($this->originUseSSL ? 443 : 80);
226
63f9ff4e 227 if (PROXY_SERVER_HTTP && !$this->originUseSSL) {
86fc0430 228 $this->path = $url;
1d50541d 229 $this->query = '';
86fc0430
TD
230 }
231 else {
86fc0430 232 $this->path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '/';
1d50541d 233 $this->query = isset($parsedUrl['query']) ? $parsedUrl['query'] : '';
86fc0430 234 }
d5bd7602 235
d3ee6dcb
TD
236 if (PROXY_SERVER_HTTP) {
237 $parsedUrl = parse_url(PROXY_SERVER_HTTP);
238 }
239
86fc0430
TD
240 $this->useSSL = $parsedUrl['scheme'] === 'https';
241 $this->host = $parsedUrl['host'];
242 $this->port = isset($parsedUrl['port']) ? $parsedUrl['port'] : ($this->useSSL ? 443 : 80);
d5bd7602
AE
243
244 // update the 'Host:' header if URL has changed
adc039a5 245 if ($this->url != $url) {
5819f701 246 $this->addHeader('host', $this->originHost.($this->originPort != ($this->originUseSSL ? 443 : 80) ? ':'.$this->originPort : ''));
d5bd7602
AE
247 }
248
249 $this->url = $url;
86fc0430
TD
250 }
251
252 /**
253 * Executes the HTTP request.
254 */
255 public function execute() {
256 // connect
7e6297c8
TD
257 $remoteFile = new RemoteFile(($this->useSSL ? 'ssl://' : '').$this->host, $this->port, $this->options['timeout'], array(
258 'ssl' => array(
259 'peer_name' => $this->originHost
260 )
261 ));
86fc0430 262
5819f701
TD
263 if ($this->originUseSSL && PROXY_SERVER_HTTP) {
264 if ($this->useSSL) throw new SystemException("Unable to proxy HTTPS when using TLS for proxy connection");
265
266 $request = "CONNECT ".$this->originHost.":".$this->originPort." HTTP/1.0\r\n";
267 if (isset($this->headers['user-agent'])) {
268 $request .= 'user-agent: '.reset($this->headers['user-agent'])."\r\n";
269 }
270 $request .= "Host: ".$this->originHost.":".$this->originPort."\r\n";
271 $request .= "\r\n";
272 $remoteFile->puts($request);
273 $this->replyHeaders = array();
274 while (!$remoteFile->eof()) {
275 $line = $remoteFile->gets();
276 if (rtrim($line) === '') {
277 $this->parseReplyHeaders();
278
279 break;
280 }
281 $this->replyHeaders[] = $line;
282 }
283 if ($this->statusCode != 200) throw new SystemException("Expected 200 Ok as reply to my CONNECT, got '".$this->statusCode."'");
284 $remoteFile->setTLS(true);
285 }
286
8fe14bd6 287 $request = $this->options['method']." ".$this->path.($this->query ? '?'.$this->query : '')." HTTP/1.1\r\n";
86fc0430 288
a195ffa6 289 // add headers
86fc0430
TD
290 foreach ($this->headers as $name => $values) {
291 foreach ($values as $value) {
292 $request .= $name.": ".$value."\r\n";
293 }
294 }
295 $request .= "\r\n";
8fe14bd6 296
a195ffa6 297 // add post parameters
5f70a0de 298 if ($this->options['method'] !== 'GET') $request .= $this->body."\r\n\r\n";
d5bd7602 299
86fc0430
TD
300 $remoteFile->puts($request);
301
302 $inHeader = true;
303 $this->replyHeaders = array();
304 $this->replyBody = '';
8fe14bd6 305 $chunkLength = 0;
5262964b 306 $bodyLength = 0;
c7fe2510 307
8d2262a1 308 $chunkedTransferRegex = new Regex('(^|,)[ \t]*chunked[ \t]*$', Regex::CASE_INSENSITIVE);
7ced4c6a
TD
309 // read http response, until one of is true
310 // a) EOF is reached
311 // b) bodyLength is at least maxLength
312 // c) bodyLength is at least Content-Length
313 while (!(
314 $remoteFile->eof() ||
315 (isset($this->options['maxLength']) && $bodyLength >= $this->options['maxLength']) ||
316 (isset($this->replyHeaders['content-length']) && $bodyLength >= end($this->replyHeaders['content-length']))
317 )) {
318
8fe14bd6 319 if ($chunkLength) {
5262964b 320 if (isset($this->options['maxLength'])) $chunkLength = min($chunkLength, $this->options['maxLength'] - $bodyLength);
8fe14bd6
TD
321 $line = $remoteFile->read($chunkLength);
322 }
8d2262a1 323 else if (!$inHeader && (!isset($this->replyHeaders['transfer-encoding']) || !$chunkedTransferRegex->match(end($this->replyHeaders['transfer-encoding'])))) {
4b3a6b71
TD
324 $length = 1024;
325 if (isset($this->options['maxLength'])) $length = min($length, $this->options['maxLength'] - $bodyLength);
7ced4c6a
TD
326 if (isset($this->replyHeaders['content-length'])) $length = min($length, end($this->replyHeaders['content-length']) - $bodyLength);
327
328 $line = $remoteFile->read($length);
329 }
8fe14bd6
TD
330 else {
331 $line = $remoteFile->gets();
332 }
333
86fc0430
TD
334 if ($inHeader) {
335 if (rtrim($line) === '') {
336 $inHeader = false;
8fe14bd6
TD
337 $this->parseReplyHeaders();
338
86fc0430
TD
339 continue;
340 }
341 $this->replyHeaders[] = $line;
342 }
343 else {
8fe14bd6 344 if (isset($this->replyHeaders['transfer-encoding']) && $chunkedTransferRegex->match(end($this->replyHeaders['transfer-encoding']))) {
8fe14bd6
TD
345 // last chunk finished
346 if ($chunkLength === 0) {
347 // read hex data and trash chunk-extension
348 list($hex) = explode(';', $line, 2);
349 $chunkLength = hexdec($hex);
350
351 // $chunkLength === 0 -> no more data
352 if ($chunkLength === 0) {
353 // clear remaining response
7ced4c6a 354 while (!$remoteFile->gets(1024));
8fe14bd6 355
8f3e6138
TD
356 // remove chunked from transfer-encoding
357 $this->replyHeaders['transfer-encoding'] = array_filter(array_map(function ($element) use ($chunkedTransferRegex) {
358 return $chunkedTransferRegex->replace($element, '');
359 }, $this->replyHeaders['transfer-encoding']), 'trim');
360 if (empty($this->replyHeaders['transfer-encoding'])) unset($this->replyHeaders['transfer-encoding']);
361
8fe14bd6
TD
362 // break out of main reading loop
363 break;
364 }
365 }
366 else {
367 $this->replyBody .= $line;
368 $chunkLength -= strlen($line);
8f3e6138
TD
369 $bodyLength += strlen($line);
370 if ($chunkLength === 0) $remoteFile->read(2); // CRLF
8fe14bd6
TD
371 }
372 }
373 else {
374 $this->replyBody .= $line;
8f3e6138 375 $bodyLength += strlen($line);
5262964b 376 }
86fc0430
TD
377 }
378 }
379
5262964b
TD
380 if (isset($this->options['maxLength'])) $this->replyBody = substr($this->replyBody, 0, $this->options['maxLength']);
381
8fe14bd6
TD
382 $remoteFile->close();
383
86fc0430
TD
384 $this->parseReply();
385 }
386
387 /**
8fe14bd6 388 * Parses the reply headers.
86fc0430 389 */
8fe14bd6 390 private function parseReplyHeaders() {
86fc0430 391 $headers = array();
8fe14bd6 392 $lastKey = '';
86fc0430
TD
393 foreach ($this->replyHeaders as $header) {
394 if (strpos($header, ':') === false) {
8fe14bd6 395 $headers[trim($header)] = array(trim($header));
86fc0430
TD
396 continue;
397 }
8fe14bd6
TD
398
399 // 4.2 Header fields can be
400 // extended over multiple lines by preceding each extra line with at
401 // least one SP or HT.
402 if (ltrim($header, "\t ") !== $header) {
403 $headers[$lastKey][] = array_pop($headers[$lastKey]).' '.trim($header);
404 }
405 else {
406 list($key, $value) = explode(':', $header, 2);
407
408 $lastKey = $key;
409 if (!isset($headers[$key])) $headers[$key] = array();
410 $headers[$key][] = trim($value);
411 }
86fc0430 412 }
8fe14bd6
TD
413 // 4.2 Field names are case-insensitive.
414 $this->replyHeaders = array_change_key_case($headers);
415 if (isset($this->replyHeaders['transfer-encoding'])) $this->replyHeaders['transfer-encoding'] = array(implode(',', $this->replyHeaders['transfer-encoding']));
416 $this->legacyHeaders = array_map('end', $headers);
86fc0430 417
a0eb8370 418 // get status code
86fc0430 419 $statusLine = reset($this->replyHeaders);
8fe14bd6
TD
420 $regex = new Regex('^HTTP/1.\d+ +(\d{3})');
421 if (!$regex->match($statusLine[0])) throw new SystemException("Unexpected status '".$statusLine."'");
86fc0430 422 $matches = $regex->getMatches();
aeaa135c 423 $this->statusCode = $matches[1];
8fe14bd6
TD
424 }
425
426 /**
427 * Parses the reply.
428 */
429 private function parseReply() {
430 // 4.4 Messages MUST NOT include both a Content-Length header field and a
431 // non-identity transfer-coding. If the message does include a non-
432 // identity transfer-coding, the Content-Length MUST be ignored.
5262964b 433 if (isset($this->replyHeaders['content-length']) && (!isset($this->replyHeaders['transfer-encoding']) || strtolower(end($this->replyHeaders['transfer-encoding'])) !== 'identity') && !isset($this->options['maxLength'])) {
8009cc11 434 if (strlen($this->replyBody) != end($this->replyHeaders['content-length'])) {
a0eb8370
DR
435 throw new SystemException('Body length does not match length given in header');
436 }
437 }
438
439 // validate status code
aeaa135c 440 switch ($this->statusCode) {
86fc0430
TD
441 case '301':
442 case '302':
443 case '303':
444 case '307':
445 // redirect
c7fe2510 446 if ($this->options['maxDepth'] <= 0) throw new SystemException("Received status code '".$this->statusCode."' from server, but recursion level is exhausted");
86fc0430
TD
447
448 $newRequest = clone $this;
449 $newRequest->options['maxDepth']--;
c7fe2510 450
8fe14bd6 451 // 10.3.4 The response to the request can be found under a different URI and SHOULD
c7fe2510 452 // be retrieved using a GET method on that resource.
c7fe2510 453 if ($this->statusCode == '303') {
86fc0430
TD
454 $newRequest->options['method'] = 'GET';
455 $newRequest->postParameters = array();
8fe14bd6
TD
456 $newRequest->addHeader('content-length', '');
457 $newRequest->addHeader('content-type', '');
86fc0430 458 }
c7fe2510 459
86fc0430 460 try {
8fe14bd6 461 $newRequest->setURL(end($this->replyHeaders['location']));
86fc0430
TD
462 }
463 catch (SystemException $e) {
8fe14bd6 464 throw new SystemException("Received 'Location: ".end($this->replyHeaders['location'])."' from server, which is invalid.", 0, $e);
86fc0430 465 }
86fc0430 466
283df336
TD
467 try {
468 $newRequest->execute();
469
470 // update data with data from the inner request
471 $this->url = $newRequest->url;
472 $this->statusCode = $newRequest->statusCode;
473 $this->replyHeaders = $newRequest->replyHeaders;
df37e22d 474 $this->legacyHeaders = $newRequest->legacyHeaders;
283df336
TD
475 $this->replyBody = $newRequest->replyBody;
476 }
477 catch (SystemException $e) {
478 // update data with data from the inner request
479 $this->url = $newRequest->url;
480 $this->statusCode = $newRequest->statusCode;
481 $this->replyHeaders = $newRequest->replyHeaders;
df37e22d 482 $this->legacyHeaders = $newRequest->legacyHeaders;
283df336
TD
483 $this->replyBody = $newRequest->replyBody;
484
485 throw $e;
486 }
487
86fc0430
TD
488 return;
489 break;
a17de04e 490
4d28d5a2
SG
491 case '206':
492 // check, if partial content was expected
5262964b 493 if (!isset($this->headers['range'])) {
4d28d5a2
SG
494 throw new HTTPServerErrorException("Received unexpected status code '206' from server");
495 }
5262964b 496 else if (!isset($this->replyHeaders['content-range'])) {
4d28d5a2
SG
497 throw new HTTPServerErrorException("Content-Range is missing in reply header");
498 }
499 break;
500
3536d2fe 501 case '401':
5cd59413 502 case '402':
3536d2fe
AE
503 case '403':
504 throw new HTTPUnauthorizedException("Received status code '".$this->statusCode."' from server");
505 break;
506
507 case '404':
508 throw new HTTPNotFoundException("Received status code '404' from server");
509 break;
8fe14bd6 510
86fc0430 511 default:
8fe14bd6
TD
512 // 6.1.1 However, applications MUST
513 // understand the class of any status code, as indicated by the first
514 // digit, and treat any unrecognized response as being equivalent to the
515 // x00 status code of that class, with the exception that an
516 // unrecognized response MUST NOT be cached.
517 switch (substr($this->statusCode, 0, 1)) {
518 case '2': // 200 and unknown 2XX
519 case '3': // 300 and unknown 3XX
520 // we are fine
521 break;
522 case '5': // 500 and unknown 5XX
523 throw new HTTPServerErrorException("Received status code '".$this->statusCode."' from server");
524 break;
525 default:
526 throw new SystemException("Received unhandled status code '".$this->statusCode."' from server");
527 break;
528 }
86fc0430
TD
529 break;
530 }
86fc0430
TD
531 }
532
533 /**
534 * Returns an array with the replied data.
8fe14bd6 535 * Note that the 'headers' element is deprecated and may be removed in the future.
86fc0430 536 *
a17de04e 537 * @return array
86fc0430
TD
538 */
539 public function getReply() {
a195ffa6
TD
540 return array(
541 'statusCode' => $this->statusCode,
8fe14bd6
TD
542 'headers' => $this->legacyHeaders,
543 'httpHeaders' => $this->replyHeaders,
25cdc083
AE
544 'body' => $this->replyBody,
545 'url' => $this->url
a195ffa6 546 );
86fc0430
TD
547 }
548
549 /**
550 * Sets options and applies default values when an option is omitted.
551 *
a17de04e 552 * @param array $options
2b770bdd 553 * @throws SystemException
86fc0430
TD
554 */
555 private function setOptions(array $options) {
556 if (!isset($options['timeout'])) {
c7fe2510 557 $options['timeout'] = 10;
86fc0430
TD
558 }
559
560 if (!isset($options['method'])) {
5f70a0de 561 $options['method'] = (!empty($this->postParameters) || !empty($this->files) ? 'POST' : 'GET');
86fc0430
TD
562 }
563
564 if (!isset($options['maxDepth'])) {
565 $options['maxDepth'] = 2;
566 }
567
568 if (isset($options['auth'])) {
569 if (!isset($options['auth']['username'])) {
c7fe2510 570 throw new SystemException('Username is missing in authentification data.');
86fc0430
TD
571 }
572 if (!isset($options['auth']['password'])) {
c7fe2510 573 throw new SystemException('Password is missing in authentification data.');
86fc0430
TD
574 }
575 }
576
577 $this->options = $options;
578 }
579
580 /**
581 * Adds a header to this request.
c7fe2510 582 * When an empty value is given existing headers of this name will be removed. When append
86fc0430
TD
583 * is set to false existing values will be overwritten.
584 *
a17de04e
MS
585 * @param string $name
586 * @param string $value
587 * @param boolean $append
86fc0430
TD
588 */
589 public function addHeader($name, $value, $append = false) {
8fe14bd6
TD
590 // 4.2 Field names are case-insensitive.
591 $name = strtolower($name);
592
86fc0430
TD
593 if ($value === '') {
594 unset($this->headers[$name]);
595 return;
596 }
597
598 if ($append && isset($this->headers[$name])) {
599 $this->headers[$name][] = $value;
600 }
a377993e
TD
601 else {
602 $this->headers[$name] = array($value);
603 }
86fc0430
TD
604 }
605
606 /**
607 * Resets reply data when cloning.
608 */
609 private function __clone() {
610 $this->replyHeaders = array();
611 $this->replyBody = '';
612 $this->statusCode = 0;
613 }
58e1d71f 614}