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