Commit | Line | Data |
---|---|---|
86fc0430 TD |
1 | <?php |
2 | namespace wcf\util; | |
3536d2fe AE |
3 | use wcf\system\exception\HTTPNotFoundException; |
4 | use wcf\system\exception\HTTPServerErrorException; | |
5 | use wcf\system\exception\HTTPUnauthorizedException; | |
86fc0430 TD |
6 | use wcf\system\exception\SystemException; |
7 | use wcf\system\io\RemoteFile; | |
8 | use wcf\system\Regex; | |
9 | use 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 | 22 | final 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 | } |