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
;
12 * Sends HTTP/1.1 requests.
13 * It supports POST, SSL, Basic Auth etc.
15 * @author Tim Duesterhus
16 * @copyright 2001-2015 WoltLab GmbH
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package com.woltlab.wcf
20 * @category Community Framework
22 final class HTTPRequest
{
27 private $options = array();
30 * given post parameters
33 private $postParameters = array();
39 private $files = array();
42 * indicates if request will be made via SSL
45 private $useSSL = false;
48 * indicates if the connection to the proxy target will be made via SSL
51 private $originUseSSL = false;
60 * target host if a proxy is used
72 * target port if a proxy is used
99 private $headers = array();
105 private $legacyHeaders = array();
117 private $replyHeaders = array();
123 private $replyBody = '';
129 private $statusCode = 0;
132 * Constructs a new instance of HTTPRequest.
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
139 public function __construct($url, array $options = array(), $postParameters = array(), array $files = array()) {
142 $this->postParameters
= $postParameters;
143 $this->files
= $files;
145 $this->setOptions($options);
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());
152 if (isset($this->options
['maxLength'])) {
153 $this->addHeader('Range', 'bytes=0-'.($this->options
['maxLength'] - 1));
156 if ($this->options
['method'] !== 'GET') {
157 if (empty($this->files
)) {
158 if (is_array($postParameters)) {
159 $this->body
= http_build_query($this->postParameters
, '', '&');
161 else if (is_string($postParameters) && !empty($postParameters)) {
162 $this->body
= $postParameters;
165 $this->addHeader('content-type', 'application/x-www-form-urlencoded');
168 $boundary = StringUtil
::getRandomID();
169 $this->addHeader('content-type', 'multipart/form-data; boundary='.$boundary);
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()) {
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().']';
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";
189 $iterator = new \
RecursiveIteratorIterator(new \
RecursiveArrayIterator($this->files
), \RecursiveIteratorIterator
::SELF_FIRST
);
190 foreach ($iterator as $k => $v) {
191 if (!$iterator->hasChildren()) {
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().']';
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";
205 $this->body
.= "--".$boundary."--";
207 $this->addHeader('content-length', strlen($this->body
));
209 if (isset($this->options
['auth'])) {
210 $this->addHeader('authorization', "Basic ".base64_encode($options['auth']['username'].":".$options['auth']['password']));
212 $this->addHeader('connection', 'Close');
216 * Parses the given URL and applies PROXY_SERVER_HTTP.
220 private function setURL($url) {
221 $parsedUrl = $originUrl = parse_url($url);
222 if (PROXY_SERVER_HTTP
) {
223 $parsedUrl = parse_url(PROXY_SERVER_HTTP
);
227 $this->path
= isset($parsedUrl['path']) ?
$parsedUrl['path'] : '/';
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'] : '';
235 $this->originUseSSL
= $originUrl['scheme'] === 'https';
236 $this->originHost
= $originUrl['host'];
237 $this->originPort
= isset($originUrl['port']) ?
$originUrl['port'] : ($this->originUseSSL ?
443 : 80);
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
: ''));
248 * Executes the HTTP request.
250 public function execute() {
252 $remoteFile = new RemoteFile(($this->useSSL ?
'ssl://' : '').$this->host
, $this->port
, $this->options
['timeout'], array(
254 'peer_name' => $this->originHost
258 if ($this->originUseSSL
&& PROXY_SERVER_HTTP
) {
259 if ($this->useSSL
) throw new SystemException("Unable to proxy HTTPS when using TLS for proxy connection");
261 $request = "CONNECT ".$this->originHost
.":".$this->originPort
." HTTP/1.0\r\n";
262 if (isset($this->headers
['user-agent'])) {
263 $request .= 'user-agent: '.reset($this->headers
['user-agent'])."\r\n";
265 $request .= "Host: ".$this->originHost
.":".$this->originPort
."\r\n";
267 $remoteFile->puts($request);
268 $this->replyHeaders
= array();
269 while (!$remoteFile->eof()) {
270 $line = $remoteFile->gets();
271 if (rtrim($line) === '') {
272 $this->parseReplyHeaders();
276 $this->replyHeaders
[] = $line;
278 if ($this->statusCode
!= 200) throw new SystemException("Expected 200 Ok as reply to my CONNECT, got '".$this->statusCode
."'");
279 $remoteFile->setTLS(true);
282 $request = $this->options
['method']." ".$this->path
.($this->query ?
'?'.$this->query
: '')." HTTP/1.1\r\n";
285 foreach ($this->headers
as $name => $values) {
286 foreach ($values as $value) {
287 $request .= $name.": ".$value."\r\n";
292 // add post parameters
293 if ($this->options
['method'] !== 'GET') $request .= $this->body
."\r\n\r\n";
295 $remoteFile->puts($request);
298 $this->replyHeaders
= array();
299 $this->replyBody
= '';
303 $chunkedTransferRegex = new Regex('(^|,)[ \t]*chunked[ \t]*$', Regex
::CASE_INSENSITIVE
);
304 // read http response, until one of is true
306 // b) bodyLength is at least maxLength
307 // c) bodyLength is at least Content-Length
309 $remoteFile->eof() ||
310 (isset($this->options
['maxLength']) && $bodyLength >= $this->options
['maxLength']) ||
311 (isset($this->replyHeaders
['content-length']) && $bodyLength >= end($this->replyHeaders
['content-length']))
315 if (isset($this->options
['maxLength'])) $chunkLength = min($chunkLength, $this->options
['maxLength'] - $bodyLength);
316 $line = $remoteFile->read($chunkLength);
318 else if (!$inHeader && (!isset($this->replyHeaders
['transfer-encoding']) ||
!$chunkedTransferRegex->match(end($this->replyHeaders
['transfer-encoding'])))) {
320 if (isset($this->options
['maxLength'])) $length = min($length, $this->options
['maxLength'] - $bodyLength);
321 if (isset($this->replyHeaders
['content-length'])) $length = min($length, end($this->replyHeaders
['content-length']) - $bodyLength);
323 $line = $remoteFile->read($length);
326 $line = $remoteFile->gets();
330 if (rtrim($line) === '') {
332 $this->parseReplyHeaders();
336 $this->replyHeaders
[] = $line;
339 if (isset($this->replyHeaders
['transfer-encoding']) && $chunkedTransferRegex->match(end($this->replyHeaders
['transfer-encoding']))) {
340 // last chunk finished
341 if ($chunkLength === 0) {
342 // read hex data and trash chunk-extension
343 list($hex) = explode(';', $line, 2);
344 $chunkLength = hexdec($hex);
346 // $chunkLength === 0 -> no more data
347 if ($chunkLength === 0) {
348 // clear remaining response
349 while (!$remoteFile->gets(1024));
351 // remove chunked from transfer-encoding
352 $this->replyHeaders
['transfer-encoding'] = array_filter(array_map(function ($element) use ($chunkedTransferRegex) {
353 return $chunkedTransferRegex->replace($element, '');
354 }, $this->replyHeaders
['transfer-encoding']), 'trim');
355 if (empty($this->replyHeaders
['transfer-encoding'])) unset($this->replyHeaders
['transfer-encoding']);
357 // break out of main reading loop
362 $this->replyBody
.= $line;
363 $chunkLength -= strlen($line);
364 $bodyLength +
= strlen($line);
365 if ($chunkLength === 0) $remoteFile->read(2); // CRLF
369 $this->replyBody
.= $line;
370 $bodyLength +
= strlen($line);
375 if (isset($this->options
['maxLength'])) $this->replyBody
= substr($this->replyBody
, 0, $this->options
['maxLength']);
377 $remoteFile->close();
383 * Parses the reply headers.
385 private function parseReplyHeaders() {
388 foreach ($this->replyHeaders
as $header) {
389 if (strpos($header, ':') === false) {
390 $headers[trim($header)] = array(trim($header));
394 // 4.2 Header fields can be
395 // extended over multiple lines by preceding each extra line with at
396 // least one SP or HT.
397 if (ltrim($header, "\t ") !== $header) {
398 $headers[$lastKey][] = array_pop($headers[$lastKey]).' '.trim($header);
401 list($key, $value) = explode(':', $header, 2);
404 if (!isset($headers[$key])) $headers[$key] = array();
405 $headers[$key][] = trim($value);
408 // 4.2 Field names are case-insensitive.
409 $this->replyHeaders
= array_change_key_case($headers);
410 if (isset($this->replyHeaders
['transfer-encoding'])) $this->replyHeaders
['transfer-encoding'] = array(implode(',', $this->replyHeaders
['transfer-encoding']));
411 $this->legacyHeaders
= array_map('end', $headers);
414 $statusLine = reset($this->replyHeaders
);
415 $regex = new Regex('^HTTP/1.\d+ +(\d{3})');
416 if (!$regex->match($statusLine[0])) throw new SystemException("Unexpected status '".$statusLine."'");
417 $matches = $regex->getMatches();
418 $this->statusCode
= $matches[1];
424 private function parseReply() {
425 // 4.4 Messages MUST NOT include both a Content-Length header field and a
426 // non-identity transfer-coding. If the message does include a non-
427 // identity transfer-coding, the Content-Length MUST be ignored.
428 if (isset($this->replyHeaders
['content-length']) && (!isset($this->replyHeaders
['transfer-encoding']) ||
strtolower(end($this->replyHeaders
['transfer-encoding'])) !== 'identity') && !isset($this->options
['maxLength'])) {
429 if (strlen($this->replyBody
) != end($this->replyHeaders
['content-length'])) {
430 throw new SystemException('Body length does not match length given in header');
434 // validate status code
435 switch ($this->statusCode
) {
441 if ($this->options
['maxDepth'] <= 0) throw new SystemException("Received status code '".$this->statusCode
."' from server, but recursion level is exhausted");
443 $newRequest = clone $this;
444 $newRequest->options
['maxDepth']--;
446 // 10.3.4 The response to the request can be found under a different URI and SHOULD
447 // be retrieved using a GET method on that resource.
448 if ($this->statusCode
== '303') {
449 $newRequest->options
['method'] = 'GET';
450 $newRequest->postParameters
= array();
451 $newRequest->addHeader('content-length', '');
452 $newRequest->addHeader('content-type', '');
456 $newRequest->setURL(end($this->replyHeaders
['location']));
458 catch (SystemException
$e) {
459 throw new SystemException("Received 'Location: ".end($this->replyHeaders
['location'])."' from server, which is invalid.", 0, $e);
463 $newRequest->execute();
465 // update data with data from the inner request
466 $this->url
= $newRequest->url
;
467 $this->statusCode
= $newRequest->statusCode
;
468 $this->replyHeaders
= $newRequest->replyHeaders
;
469 $this->legacyHeaders
= $newRequest->legacyHeaders
;
470 $this->replyBody
= $newRequest->replyBody
;
472 catch (SystemException
$e) {
473 // update data with data from the inner request
474 $this->url
= $newRequest->url
;
475 $this->statusCode
= $newRequest->statusCode
;
476 $this->replyHeaders
= $newRequest->replyHeaders
;
477 $this->legacyHeaders
= $newRequest->legacyHeaders
;
478 $this->replyBody
= $newRequest->replyBody
;
487 // check, if partial content was expected
488 if (!isset($this->headers
['range'])) {
489 throw new HTTPServerErrorException("Received unexpected status code '206' from server");
491 else if (!isset($this->replyHeaders
['content-range'])) {
492 throw new HTTPServerErrorException("Content-Range is missing in reply header");
499 throw new HTTPUnauthorizedException("Received status code '".$this->statusCode
."' from server");
503 throw new HTTPNotFoundException("Received status code '404' from server");
507 // 6.1.1 However, applications MUST
508 // understand the class of any status code, as indicated by the first
509 // digit, and treat any unrecognized response as being equivalent to the
510 // x00 status code of that class, with the exception that an
511 // unrecognized response MUST NOT be cached.
512 switch (substr($this->statusCode
, 0, 1)) {
513 case '2': // 200 and unknown 2XX
514 case '3': // 300 and unknown 3XX
517 case '5': // 500 and unknown 5XX
518 throw new HTTPServerErrorException("Received status code '".$this->statusCode
."' from server");
521 throw new SystemException("Received unhandled status code '".$this->statusCode
."' from server");
529 * Returns an array with the replied data.
530 * Note that the 'headers' element is deprecated and may be removed in the future.
534 public function getReply() {
536 'statusCode' => $this->statusCode
,
537 'headers' => $this->legacyHeaders
,
538 'httpHeaders' => $this->replyHeaders
,
539 'body' => $this->replyBody
,
545 * Sets options and applies default values when an option is omitted.
547 * @param array $options
549 private function setOptions(array $options) {
550 if (!isset($options['timeout'])) {
551 $options['timeout'] = 10;
554 if (!isset($options['method'])) {
555 $options['method'] = (!empty($this->postParameters
) ||
!empty($this->files
) ?
'POST' : 'GET');
558 if (!isset($options['maxDepth'])) {
559 $options['maxDepth'] = 2;
562 if (isset($options['auth'])) {
563 if (!isset($options['auth']['username'])) {
564 throw new SystemException('Username is missing in authentification data.');
566 if (!isset($options['auth']['password'])) {
567 throw new SystemException('Password is missing in authentification data.');
571 $this->options
= $options;
575 * Adds a header to this request.
576 * When an empty value is given existing headers of this name will be removed. When append
577 * is set to false existing values will be overwritten.
579 * @param string $name
580 * @param string $value
581 * @param boolean $append
583 public function addHeader($name, $value, $append = false) {
584 // 4.2 Field names are case-insensitive.
585 $name = strtolower($name);
588 unset($this->headers
[$name]);
592 if ($append && isset($this->headers
[$name])) {
593 $this->headers
[$name][] = $value;
596 $this->headers
[$name] = array($value);
601 * Resets reply data when cloning.
603 private function __clone() {
604 $this->replyHeaders
= array();
605 $this->replyBody
= '';
606 $this->statusCode
= 0;