Fixed time zone calculation issue
[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 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 * request body
85 * @var string
86 */
87 private $body = '';
88
89 /**
90 * reply headers
91 * @var array<string>
92 */
93 private $replyHeaders = array();
94
95 /**
96 * reply body
97 * @var string
98 */
99 private $replyBody = '';
100
101 /**
102 * reply status code
103 * @var integer
104 */
105 private $statusCode = 0;
106
107 /**
108 * Constructs a new instance of HTTPRequest.
109 *
110 * @param string $url URL to connect to
111 * @param array<string> $options
112 * @param mixed $postParameters Parameters to send via POST
113 * @param array $files Files to attach to the request
114 */
115 public function __construct($url, array $options = array(), $postParameters = array(), array $files = array()) {
116 $this->setURL($url);
117
118 $this->postParameters = $postParameters;
119 $this->files = $files;
120
121 $this->setOptions($options);
122
123 // set default headers
124 $this->addHeader('User-Agent', "HTTP.PHP (HTTPRequest.class.php; WoltLab Community Framework/".WCF_VERSION."; ".WCF::getLanguage()->languageCode.")");
125 $this->addHeader('Accept', '*/*');
126 $this->addHeader('Accept-Language', WCF::getLanguage()->getFixedLanguageCode());
127 if ($this->options['method'] !== 'GET') {
128 if (empty($this->files)) {
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
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));
179 }
180 if (isset($this->options['auth'])) {
181 $this->addHeader('Authorization', "Basic ".base64_encode($options['auth']['username'].":".$options['auth']['password']));
182 }
183 $this->addHeader('Host', $this->host.($this->port != ($this->useSSL ? 443 : 80) ? ':'.$this->port : ''));
184 $this->addHeader('Connection', 'Close');
185 }
186
187 /**
188 * Parses the given URL and applies PROXY_SERVER_HTTP.
189 *
190 * @param string $url
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 }
201
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->query = isset($parsedUrl['query']) ? $parsedUrl['query'] : '';
206
207 // update the 'Host:' header if URL has changed
208 if (!empty($this->url) && $this->url != $url) {
209 $this->addHeader('Host', $this->host.($this->port != ($this->useSSL ? 443 : 80) ? ':'.$this->port : ''));
210 }
211
212 $this->url = $url;
213 }
214
215 /**
216 * Executes the HTTP request.
217 */
218 public function execute() {
219 // connect
220 $remoteFile = new RemoteFile(($this->useSSL ? 'ssl://' : '').$this->host, $this->port, $this->options['timeout']);
221
222 $request = $this->options['method']." ".$this->path.($this->query ? '?'.$this->query : '')." HTTP/1.0\r\n";
223
224 // add headers
225 foreach ($this->headers as $name => $values) {
226 foreach ($values as $value) {
227 $request .= $name.": ".$value."\r\n";
228 }
229 }
230 $request .= "\r\n";
231 // add post parameters
232 if ($this->options['method'] !== 'GET') $request .= $this->body."\r\n\r\n";
233
234 $remoteFile->puts($request);
235
236 $inHeader = true;
237 $this->replyHeaders = array();
238 $this->replyBody = '';
239
240 // read http response.
241 while (!$remoteFile->eof()) {
242 $line = $remoteFile->gets();
243 if ($inHeader) {
244 if (rtrim($line) === '') {
245 $inHeader = false;
246 continue;
247 }
248 $this->replyHeaders[] = $line;
249 }
250 else {
251 $this->replyBody .= $line;
252 }
253 }
254
255 $this->parseReply();
256 }
257
258 /**
259 * Parses the reply.
260 */
261 private function parseReply() {
262 $headers = array();
263
264 foreach ($this->replyHeaders as $header) {
265 if (strpos($header, ':') === false) {
266 $headers[trim($header)] = trim($header);
267 continue;
268 }
269 list($key, $value) = explode(':', $header, 2);
270 $headers[$key] = trim($value);
271 }
272 $this->replyHeaders = $headers;
273
274 // get status code
275 $statusLine = reset($this->replyHeaders);
276 $regex = new Regex('^HTTP/1.[01] (\d{3})');
277 if (!$regex->match($statusLine)) throw new SystemException("Unexpected status '".$statusLine."'");
278 $matches = $regex->getMatches();
279 $this->statusCode = $matches[1];
280
281 // validate length
282 if (isset($this->replyHeaders['Content-Length'])) {
283 if (strlen($this->replyBody) != $this->replyHeaders['Content-Length']) {
284 throw new SystemException('Body length does not match length given in header');
285 }
286 }
287
288 // validate status code
289 switch ($this->statusCode) {
290 case '301':
291 case '302':
292 case '303':
293 case '307':
294 // redirect
295 if ($this->options['maxDepth'] <= 0) throw new SystemException("Received status code '".$this->statusCode."' from server, but recursion level is exhausted");
296
297 $newRequest = clone $this;
298 $newRequest->options['maxDepth']--;
299
300 // The response to the request can be found under a different URI and SHOULD
301 // be retrieved using a GET method on that resource.
302 // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.4
303 if ($this->statusCode == '303') {
304 $newRequest->options['method'] = 'GET';
305 $newRequest->postParameters = array();
306 $newRequest->addHeader('Content-length', '');
307 $newRequest->addHeader('Content-Type', '');
308 }
309
310 try {
311 $newRequest->setURL($this->replyHeaders['Location']);
312 }
313 catch (SystemException $e) {
314 throw new SystemException("Received 'Location: ".$this->replyHeaders['Location']."' from server, which is invalid.", 0, $e);
315 }
316
317 try {
318 $newRequest->execute();
319
320 // update data with data from the inner request
321 $this->url = $newRequest->url;
322 $this->statusCode = $newRequest->statusCode;
323 $this->replyHeaders = $newRequest->replyHeaders;
324 $this->replyBody = $newRequest->replyBody;
325 }
326 catch (SystemException $e) {
327 // update data with data from the inner request
328 $this->url = $newRequest->url;
329 $this->statusCode = $newRequest->statusCode;
330 $this->replyHeaders = $newRequest->replyHeaders;
331 $this->replyBody = $newRequest->replyBody;
332
333 throw $e;
334 }
335
336 return;
337 break;
338
339 case '200':
340 case '204':
341 // we are fine
342 break;
343
344 case '401':
345 case '403':
346 throw new HTTPUnauthorizedException("Received status code '".$this->statusCode."' from server");
347 break;
348
349 case '404':
350 throw new HTTPNotFoundException("Received status code '404' from server");
351 break;
352
353 case '500':
354 throw new HTTPServerErrorException("Received status code '500' from server");
355 break;
356
357 default:
358 throw new SystemException("Received unhandled status code '".$this->statusCode."' from server");
359 break;
360 }
361 }
362
363 /**
364 * Returns an array with the replied data.
365 *
366 * @return array
367 */
368 public function getReply() {
369 return array(
370 'statusCode' => $this->statusCode,
371 'headers' => $this->replyHeaders,
372 'body' => $this->replyBody,
373 'url' => $this->url
374 );
375 }
376
377 /**
378 * Sets options and applies default values when an option is omitted.
379 *
380 * @param array $options
381 */
382 private function setOptions(array $options) {
383 if (!isset($options['timeout'])) {
384 $options['timeout'] = 10;
385 }
386
387 if (!isset($options['method'])) {
388 $options['method'] = (!empty($this->postParameters) || !empty($this->files) ? 'POST' : 'GET');
389 }
390
391 if (!isset($options['maxDepth'])) {
392 $options['maxDepth'] = 2;
393 }
394
395 if (isset($options['auth'])) {
396 if (!isset($options['auth']['username'])) {
397 throw new SystemException('Username is missing in authentification data.');
398 }
399 if (!isset($options['auth']['password'])) {
400 throw new SystemException('Password is missing in authentification data.');
401 }
402 }
403
404 $this->options = $options;
405 }
406
407 /**
408 * Adds a header to this request.
409 * When an empty value is given existing headers of this name will be removed. When append
410 * is set to false existing values will be overwritten.
411 *
412 * @param string $name
413 * @param string $value
414 * @param boolean $append
415 */
416 public function addHeader($name, $value, $append = false) {
417 if ($value === '') {
418 unset($this->headers[$name]);
419 return;
420 }
421
422 if ($append && isset($this->headers[$name])) {
423 $this->headers[$name][] = $value;
424 }
425 else {
426 $this->headers[$name] = array($value);
427 }
428 }
429
430 /**
431 * Resets reply data when cloning.
432 */
433 private function __clone() {
434 $this->replyHeaders = array();
435 $this->replyBody = '';
436 $this->statusCode = 0;
437 }
438 }