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