Merge pull request #1530 from SoftCreatR/AvatarViewPerms
[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/**
a17de04e 12 * Sends HTTP requests.
86fc0430
TD
13 * It supports POST, SSL, Basic Auth etc.
14 *
3536d2fe
AE
15 * @author Tim Duesterhus
16 * @copyright 2001-2013 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
37 * @var array
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
47 /**
48 * target host
a17de04e 49 * @var string
86fc0430
TD
50 */
51 private $host;
52
53 /**
54 * target port
a17de04e 55 * @var integer
86fc0430
TD
56 */
57 private $port;
a17de04e 58
86fc0430
TD
59 /**
60 * target path
a17de04e 61 * @var string
86fc0430
TD
62 */
63 private $path;
64
65 /**
66 * target query string
a17de04e 67 * @var string
86fc0430
TD
68 */
69 private $query;
70
25cdc083
AE
71 /**
72 * request URL
73 * @var string
74 */
75 private $url = '';
76
86fc0430
TD
77 /**
78 * request headers
a17de04e 79 * @var array<string>
86fc0430
TD
80 */
81 private $headers = array();
82
5f70a0de
TD
83 /**
84 * request body
85 * @var string
86 */
87 private $body = '';
88
86fc0430
TD
89 /**
90 * reply headers
a17de04e 91 * @var array<string>
86fc0430
TD
92 */
93 private $replyHeaders = array();
94
95 /**
96 * reply body
a17de04e 97 * @var string
86fc0430
TD
98 */
99 private $replyBody = '';
100
101 /**
102 * reply status code
a17de04e 103 * @var integer
86fc0430
TD
104 */
105 private $statusCode = 0;
106
107 /**
a17de04e 108 * Constructs a new instance of HTTPRequest.
86fc0430
TD
109 *
110 * @param string $url URL to connect to
111 * @param array<string> $options
1b20f990 112 * @param mixed $postParameters Parameters to send via POST
5f70a0de 113 * @param array $files Files to attach to the request
86fc0430 114 */
1b20f990 115 public function __construct($url, array $options = array(), $postParameters = array(), array $files = array()) {
86fc0430
TD
116 $this->setURL($url);
117
118 $this->postParameters = $postParameters;
5f70a0de 119 $this->files = $files;
86fc0430
TD
120
121 $this->setOptions($options);
122
a195ffa6 123 // set default headers
3b00890e 124 $this->addHeader('User-Agent', "HTTP.PHP (HTTPRequest.class.php; WoltLab Community Framework/".WCF_VERSION."; ".WCF::getLanguage()->languageCode.")");
86fc0430 125 $this->addHeader('Accept', '*/*');
25748531 126 $this->addHeader('Accept-Language', WCF::getLanguage()->getFixedLanguageCode());
86fc0430 127 if ($this->options['method'] !== 'GET') {
5f70a0de 128 if (empty($this->files)) {
1b20f990
AE
129 if (is_array($postParameters)) {
130 $this->body = http_build_query($this->postParameters, '', '&');
131 }
132 else if (is_string($postParameters) && !empty($postParameters)) {
133 $this->body = $postParameters;
134 }
135
5f70a0de
TD
136 $this->addHeader('Content-Type', 'application/x-www-form-urlencoded');
137 }
138 else {
139 $boundary = StringUtil::getRandomID();
140 $this->addHeader('Content-Type', 'multipart/form-data; boundary='.$boundary);
141
142 // source of the iterators: http://stackoverflow.com/a/7623716/782822
143 if (!empty($this->postParameters)) {
144 $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($this->postParameters), \RecursiveIteratorIterator::SELF_FIRST);
145 foreach ($iterator as $k => $v) {
146 if (!$iterator->hasChildren()) {
147 $key = '';
148 for ($i = 0, $max = $iterator->getDepth(); $i <= $max; $i++) {
149 if ($i === 0) $key .= $iterator->getSubIterator($i)->key();
150 else $key .= '['.$iterator->getSubIterator($i)->key().']';
151 }
152
153 $this->body .= "--".$boundary."\r\n";
154 $this->body .= 'Content-Disposition: form-data; name="'.$key.'"'."\r\n\r\n";
155 $this->body .= $v."\r\n";
156 }
157 }
158 }
159
160 $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($this->files), \RecursiveIteratorIterator::SELF_FIRST);
161 foreach ($iterator as $k => $v) {
162 if (!$iterator->hasChildren()) {
163 $key = '';
164 for ($i = 0, $max = $iterator->getDepth(); $i <= $max; $i++) {
165 if ($i === 0) $key .= $iterator->getSubIterator($i)->key();
166 else $key .= '['.$iterator->getSubIterator($i)->key().']';
167 }
168
169 $this->body .= "--".$boundary."\r\n";
170 $this->body .= 'Content-Disposition: form-data; name="'.$k.'"; filename="'.basename($v).'"'."\r\n";
171 $this->body .= 'Content-Type: '.(FileUtil::getMimeType($v) ?: 'application/octet-stream.')."\r\n\r\n";
172 $this->body .= file_get_contents($v)."\r\n";
173 }
174 }
175
176 $this->body .= "--".$boundary."--";
177 }
178 $this->addHeader('Content-length', strlen($this->body));
86fc0430
TD
179 }
180 if (isset($this->options['auth'])) {
181 $this->addHeader('Authorization', "Basic ".base64_encode($options['auth']['username'].":".$options['auth']['password']));
182 }
aaa3ef88 183 $this->addHeader('Host', $this->host.($this->port != ($this->useSSL ? 443 : 80) ? ':'.$this->port : ''));
86fc0430
TD
184 $this->addHeader('Connection', 'Close');
185 }
186
187 /**
188 * Parses the given URL and applies PROXY_SERVER_HTTP.
189 *
a17de04e 190 * @param string $url
86fc0430
TD
191 */
192 private function setURL($url) {
193 if (PROXY_SERVER_HTTP) {
194 $parsedUrl = parse_url(PROXY_SERVER_HTTP);
195 $this->path = $url;
196 }
197 else {
198 $parsedUrl = parse_url($url);
199 $this->path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '/';
200 }
d5bd7602 201
86fc0430
TD
202 $this->useSSL = $parsedUrl['scheme'] === 'https';
203 $this->host = $parsedUrl['host'];
204 $this->port = isset($parsedUrl['port']) ? $parsedUrl['port'] : ($this->useSSL ? 443 : 80);
205 $this->path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '/';
206 $this->query = isset($parsedUrl['query']) ? $parsedUrl['query'] : '';
d5bd7602
AE
207
208 // update the 'Host:' header if URL has changed
209 if (!empty($this->url) && $this->url != $url) {
210 $this->addHeader('Host', $this->host.($this->port != ($this->useSSL ? 443 : 80) ? ':'.$this->port : ''));
211 }
212
213 $this->url = $url;
86fc0430
TD
214 }
215
216 /**
217 * Executes the HTTP request.
218 */
219 public function execute() {
220 // connect
221 $remoteFile = new RemoteFile(($this->useSSL ? 'ssl://' : '').$this->host, $this->port, $this->options['timeout']);
222
223 $request = $this->options['method']." ".$this->path.($this->query ? '?'.$this->query : '')." HTTP/1.0\r\n";
224
a195ffa6 225 // add headers
86fc0430
TD
226 foreach ($this->headers as $name => $values) {
227 foreach ($values as $value) {
228 $request .= $name.": ".$value."\r\n";
229 }
230 }
231 $request .= "\r\n";
a195ffa6 232 // add post parameters
5f70a0de 233 if ($this->options['method'] !== 'GET') $request .= $this->body."\r\n\r\n";
d5bd7602 234
86fc0430
TD
235 $remoteFile->puts($request);
236
237 $inHeader = true;
238 $this->replyHeaders = array();
239 $this->replyBody = '';
c7fe2510 240
86fc0430
TD
241 // read http response.
242 while (!$remoteFile->eof()) {
243 $line = $remoteFile->gets();
244 if ($inHeader) {
245 if (rtrim($line) === '') {
246 $inHeader = false;
247 continue;
248 }
249 $this->replyHeaders[] = $line;
250 }
251 else {
252 $this->replyBody .= $line;
253 }
254 }
255
256 $this->parseReply();
257 }
258
259 /**
260 * Parses the reply.
261 */
262 private function parseReply() {
263 $headers = array();
264
265 foreach ($this->replyHeaders as $header) {
266 if (strpos($header, ':') === false) {
267 $headers[trim($header)] = trim($header);
268 continue;
269 }
270 list($key, $value) = explode(':', $header, 2);
271 $headers[$key] = trim($value);
272 }
273 $this->replyHeaders = $headers;
274
a0eb8370 275 // get status code
86fc0430 276 $statusLine = reset($this->replyHeaders);
c7fe2510 277 $regex = new Regex('^HTTP/1.[01] (\d{3})');
86fc0430
TD
278 if (!$regex->match($statusLine)) throw new SystemException("Unexpected status '".$statusLine."'");
279 $matches = $regex->getMatches();
aeaa135c 280 $this->statusCode = $matches[1];
86fc0430 281
a0eb8370
DR
282 // validate length
283 if (isset($this->replyHeaders['Content-Length'])) {
284 if (strlen($this->replyBody) != $this->replyHeaders['Content-Length']) {
285 throw new SystemException('Body length does not match length given in header');
286 }
287 }
288
289 // validate status code
aeaa135c 290 switch ($this->statusCode) {
86fc0430
TD
291 case '301':
292 case '302':
293 case '303':
294 case '307':
295 // redirect
c7fe2510 296 if ($this->options['maxDepth'] <= 0) throw new SystemException("Received status code '".$this->statusCode."' from server, but recursion level is exhausted");
86fc0430
TD
297
298 $newRequest = clone $this;
299 $newRequest->options['maxDepth']--;
c7fe2510
TD
300
301 // The response to the request can be found under a different URI and SHOULD
302 // be retrieved using a GET method on that resource.
303 // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.4
304 if ($this->statusCode == '303') {
86fc0430
TD
305 $newRequest->options['method'] = 'GET';
306 $newRequest->postParameters = array();
307 $newRequest->addHeader('Content-length', '');
308 $newRequest->addHeader('Content-Type', '');
309 }
c7fe2510 310
86fc0430
TD
311 try {
312 $newRequest->setURL($this->replyHeaders['Location']);
313 }
314 catch (SystemException $e) {
c7fe2510 315 throw new SystemException("Received 'Location: ".$this->replyHeaders['Location']."' from server, which is invalid.", 0, $e);
86fc0430
TD
316 }
317 $newRequest->execute();
318
c7fe2510 319 // update data with data from the inner request
83c3975e 320 $this->url = $newRequest->url;
86fc0430
TD
321 $this->statusCode = $newRequest->statusCode;
322 $this->replyHeaders = $newRequest->replyHeaders;
323 $this->replyBody = $newRequest->replyBody;
324 return;
325 break;
a17de04e 326
86fc0430
TD
327 case '200':
328 case '204':
329 // we are fine
330 break;
a17de04e 331
3536d2fe
AE
332 case '401':
333 case '403':
334 throw new HTTPUnauthorizedException("Received status code '".$this->statusCode."' from server");
335 break;
336
337 case '404':
338 throw new HTTPNotFoundException("Received status code '404' from server");
339 break;
340
341 case '500':
342 throw new HTTPServerErrorException("Received status code '500' from server");
343 break;
344
86fc0430 345 default:
3536d2fe 346 throw new SystemException("Received unhandled status code '".$this->statusCode."' from server");
86fc0430
TD
347 break;
348 }
86fc0430
TD
349 }
350
351 /**
352 * Returns an array with the replied data.
353 *
a17de04e 354 * @return array
86fc0430
TD
355 */
356 public function getReply() {
a195ffa6
TD
357 return array(
358 'statusCode' => $this->statusCode,
359 'headers' => $this->replyHeaders,
25cdc083
AE
360 'body' => $this->replyBody,
361 'url' => $this->url
a195ffa6 362 );
86fc0430
TD
363 }
364
365 /**
366 * Sets options and applies default values when an option is omitted.
367 *
a17de04e 368 * @param array $options
86fc0430
TD
369 */
370 private function setOptions(array $options) {
371 if (!isset($options['timeout'])) {
c7fe2510 372 $options['timeout'] = 10;
86fc0430
TD
373 }
374
375 if (!isset($options['method'])) {
5f70a0de 376 $options['method'] = (!empty($this->postParameters) || !empty($this->files) ? 'POST' : 'GET');
86fc0430
TD
377 }
378
379 if (!isset($options['maxDepth'])) {
380 $options['maxDepth'] = 2;
381 }
382
383 if (isset($options['auth'])) {
384 if (!isset($options['auth']['username'])) {
c7fe2510 385 throw new SystemException('Username is missing in authentification data.');
86fc0430
TD
386 }
387 if (!isset($options['auth']['password'])) {
c7fe2510 388 throw new SystemException('Password is missing in authentification data.');
86fc0430
TD
389 }
390 }
391
392 $this->options = $options;
393 }
394
395 /**
396 * Adds a header to this request.
c7fe2510 397 * When an empty value is given existing headers of this name will be removed. When append
86fc0430
TD
398 * is set to false existing values will be overwritten.
399 *
a17de04e
MS
400 * @param string $name
401 * @param string $value
402 * @param boolean $append
86fc0430
TD
403 */
404 public function addHeader($name, $value, $append = false) {
405 if ($value === '') {
406 unset($this->headers[$name]);
407 return;
408 }
409
410 if ($append && isset($this->headers[$name])) {
411 $this->headers[$name][] = $value;
412 }
413
414 $this->headers[$name] = (array) $value;
415 }
416
417 /**
418 * Resets reply data when cloning.
419 */
420 private function __clone() {
421 $this->replyHeaders = array();
422 $this->replyBody = '';
423 $this->statusCode = 0;
424 }
58e1d71f 425}