1<?php
2
3namespace Kanboard\Core\Http;
4
5use Kanboard\Core\Base;
6use Kanboard\Job\HttpAsyncJob;
7
8/**
9 * HTTP client
10 *
11 * @package  Kanboard\Core\Http
12 * @author   Frederic Guillot
13 */
14class Client extends Base
15{
16    /**
17     * HTTP client user agent
18     *
19     * @var string
20     */
21    const HTTP_USER_AGENT = 'Kanboard';
22
23    /**
24     * Send a GET HTTP request
25     *
26     * @access public
27     * @param  string     $url
28     * @param  string[]   $headers
29     * @param  bool       $raiseForErrors
30     * @return string
31     */
32    public function get($url, array $headers = [], $raiseForErrors = false)
33    {
34        return $this->doRequest('GET', $url, '', $headers, $raiseForErrors);
35    }
36
37    /**
38     * Send a GET HTTP request and parse JSON response
39     *
40     * @access public
41     * @param  string     $url
42     * @param  string[]   $headers
43     * @param  bool       $raiseForErrors
44     * @return array
45     */
46    public function getJson($url, array $headers = [], $raiseForErrors = false)
47    {
48        $response = $this->doRequest('GET', $url, '', array_merge(['Accept: application/json'], $headers), $raiseForErrors);
49        return json_decode($response, true) ?: [];
50    }
51
52    /**
53     * Send a POST HTTP request encoded in JSON
54     *
55     * @access public
56     * @param  string     $url
57     * @param  array      $data
58     * @param  string[]   $headers
59     * @param  bool       $raiseForErrors
60     * @return string
61     */
62    public function postJson($url, array $data, array $headers = [], $raiseForErrors = false)
63    {
64        return $this->doRequest(
65            'POST',
66            $url,
67            json_encode($data),
68            array_merge(['Content-type: application/json'], $headers),
69            $raiseForErrors
70        );
71    }
72
73    /**
74     * Send a POST HTTP request encoded in JSON (Fire and forget)
75     *
76     * @access public
77     * @param  string     $url
78     * @param  array      $data
79     * @param  string[]   $headers
80     * @param  bool       $raiseForErrors
81     */
82    public function postJsonAsync($url, array $data, array $headers = [], $raiseForErrors = false)
83    {
84        $this->queueManager->push(HttpAsyncJob::getInstance($this->container)->withParams(
85            'POST',
86            $url,
87            json_encode($data),
88            array_merge(['Content-type: application/json'], $headers),
89            $raiseForErrors
90        ));
91    }
92
93    /**
94     * Send a POST HTTP request encoded in www-form-urlencoded
95     *
96     * @access public
97     * @param  string     $url
98     * @param  array      $data
99     * @param  string[]   $headers
100     * @param  bool       $raiseForErrors
101     * @return string
102     */
103    public function postForm($url, array $data, array $headers = [], $raiseForErrors = false)
104    {
105        return $this->doRequest(
106            'POST',
107            $url,
108            http_build_query($data),
109            array_merge(['Content-type: application/x-www-form-urlencoded'], $headers),
110            $raiseForErrors
111        );
112    }
113
114    /**
115     * Send a POST HTTP request encoded in www-form-urlencoded (fire and forget)
116     *
117     * @access public
118     * @param  string     $url
119     * @param  array      $data
120     * @param  string[]   $headers
121     * @param  bool       $raiseForErrors
122     */
123    public function postFormAsync($url, array $data, array $headers = [], $raiseForErrors = false)
124    {
125        $this->queueManager->push(HttpAsyncJob::getInstance($this->container)->withParams(
126            'POST',
127            $url,
128            http_build_query($data),
129            array_merge(['Content-type: application/x-www-form-urlencoded'], $headers),
130            $raiseForErrors
131        ));
132    }
133
134    /**
135     * Make the HTTP request with cURL if detected, socket otherwise
136     *
137     * @access public
138     * @param  string     $method
139     * @param  string     $url
140     * @param  string     $content
141     * @param  string[]   $headers
142     * @param  bool       $raiseForErrors
143     * @return string
144     */
145    public function doRequest($method, $url, $content, array $headers, $raiseForErrors = false)
146    {
147        $requestBody = '';
148
149        if (! empty($url)) {
150            if (function_exists('curl_version')) {
151                if (DEBUG) {
152                    $this->logger->debug('HttpClient::doRequest: cURL detected');
153                }
154                $requestBody = $this->doRequestWithCurl($method, $url, $content, $headers, $raiseForErrors);
155            } else {
156                if (DEBUG) {
157                    $this->logger->debug('HttpClient::doRequest: using socket');
158                }
159                $requestBody = $this->doRequestWithSocket($method, $url, $content, $headers, $raiseForErrors);
160            }
161        }
162
163        return $requestBody;
164    }
165
166    /**
167     * Make the HTTP request with socket
168     *
169     * @access private
170     * @param  string     $method
171     * @param  string     $url
172     * @param  string     $content
173     * @param  string[]   $headers
174     * @param  bool       $raiseForErrors
175     * @return string
176     */
177    private function doRequestWithSocket($method, $url, $content, array $headers, $raiseForErrors = false)
178    {
179        $startTime = microtime(true);
180        $stream = @fopen(trim($url), 'r', false, stream_context_create($this->getContext($method, $content, $headers, $raiseForErrors)));
181
182        if (! is_resource($stream)) {
183            $this->logger->error('HttpClient: request failed ('.$url.')');
184
185            if ($raiseForErrors) {
186                throw new ClientException('Unreachable URL: '.$url);
187            }
188
189            return '';
190        }
191
192        $body = stream_get_contents($stream);
193        $metadata = stream_get_meta_data($stream);
194
195        if ($raiseForErrors && array_key_exists('wrapper_data', $metadata)) {
196            $statusCode = $this->getStatusCode($metadata['wrapper_data']);
197
198            if ($statusCode >= 400) {
199                throw new InvalidStatusException('Request failed with status code '.$statusCode, $statusCode, $body);
200            }
201        }
202
203        if (DEBUG) {
204            $this->logger->debug('HttpClient: url='.$url);
205            $this->logger->debug('HttpClient: headers='.var_export($headers, true));
206            $this->logger->debug('HttpClient: payload='.$content);
207            $this->logger->debug('HttpClient: metadata='.var_export($metadata, true));
208            $this->logger->debug('HttpClient: body='.$body);
209            $this->logger->debug('HttpClient: executionTime='.(microtime(true) - $startTime));
210        }
211
212        return $body;
213    }
214
215
216    /**
217     * Make the HTTP request with cURL
218     *
219     * @access private
220     * @param  string     $method
221     * @param  string     $url
222     * @param  string     $content
223     * @param  string[]   $headers
224     * @param  bool       $raiseForErrors
225     * @return string
226     */
227    private function doRequestWithCurl($method, $url, $content, array $headers, $raiseForErrors = false)
228    {
229        $startTime = microtime(true);
230        $curlSession = @curl_init();
231
232        curl_setopt($curlSession, CURLOPT_URL, trim($url));
233        curl_setopt($curlSession, CURLOPT_USERAGENT, self::HTTP_USER_AGENT);
234        curl_setopt($curlSession, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
235        curl_setopt($curlSession, CURLOPT_TIMEOUT, HTTP_TIMEOUT);
236        curl_setopt($curlSession, CURLOPT_FORBID_REUSE, true);
237        curl_setopt($curlSession, CURLOPT_MAXREDIRS, HTTP_MAX_REDIRECTS);
238        curl_setopt($curlSession, CURLOPT_RETURNTRANSFER, true);
239        curl_setopt($curlSession, CURLOPT_FOLLOWLOCATION, true);
240
241        if ('POST' === $method) {
242            curl_setopt($curlSession, CURLOPT_POST, true);
243            curl_setopt($curlSession, CURLOPT_POSTFIELDS, $content);
244        } elseif ('PUT' === $method) {
245            curl_setopt($curlSession, CURLOPT_CUSTOMREQUEST, 'PUT');
246            curl_setopt($curlSession, CURLOPT_POST, true);
247            curl_setopt($curlSession, CURLOPT_POSTFIELDS, $content);
248        }
249
250        if (! empty($headers)) {
251            curl_setopt($curlSession, CURLOPT_HTTPHEADER, $headers);
252        }
253
254        if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
255            curl_setopt($curlSession, CURLOPT_SSL_VERIFYHOST, 0);
256            curl_setopt($curlSession, CURLOPT_SSL_VERIFYPEER, false);
257        }
258
259        if (HTTP_PROXY_HOSTNAME) {
260            curl_setopt($curlSession, CURLOPT_PROXY, HTTP_PROXY_HOSTNAME);
261            curl_setopt($curlSession, CURLOPT_PROXYPORT, HTTP_PROXY_PORT);
262            curl_setopt($curlSession, CURLOPT_NOPROXY, HTTP_PROXY_EXCLUDE);
263        }
264
265        if (HTTP_PROXY_USERNAME) {
266            curl_setopt($curlSession, CURLOPT_PROXYAUTH, CURLAUTH_BASIC);
267            curl_setopt($curlSession, CURLOPT_PROXYUSERPWD, HTTP_PROXY_USERNAME.':'.HTTP_PROXY_PASSWORD);
268        }
269
270        $body = curl_exec($curlSession);
271
272        if ($body === false) {
273            $errorMsg = curl_error($curlSession);
274            curl_close($curlSession);
275
276            $this->logger->error('HttpClient: request failed ('.$url.' - '.$errorMsg.')');
277
278            if ($raiseForErrors) {
279                throw new ClientException('Unreachable URL: '.$url.' ('.$errorMsg.')');
280            }
281
282            return '';
283        }
284
285        if ($raiseForErrors) {
286            $statusCode = curl_getinfo($curlSession, CURLINFO_RESPONSE_CODE);
287
288            if ($statusCode >= 400) {
289                curl_close($curlSession);
290                throw new InvalidStatusException('Request failed with status code '.$statusCode, $statusCode, $body);
291            }
292        }
293
294        if (DEBUG) {
295            $this->logger->debug('HttpClient: url='.$url);
296            $this->logger->debug('HttpClient: headers='.var_export($headers, true));
297            $this->logger->debug('HttpClient: payload='.$content);
298            $this->logger->debug('HttpClient: metadata='.var_export(curl_getinfo($curlSession), true));
299            $this->logger->debug('HttpClient: body='.$body);
300            $this->logger->debug('HttpClient: executionTime='.(microtime(true) - $startTime));
301        }
302
303        curl_close($curlSession);
304        return $body;
305    }
306
307    /**
308     * Get stream context
309     *
310     * @access private
311     * @param  string     $method
312     * @param  string     $content
313     * @param  string[]   $headers
314     * @param  bool       $raiseForErrors
315     * @return array
316     */
317    private function getContext($method, $content, array $headers, $raiseForErrors = false)
318    {
319        $default_headers = [
320            'User-Agent: '.self::HTTP_USER_AGENT,
321            'Connection: close',
322        ];
323
324        if (HTTP_PROXY_USERNAME) {
325            $default_headers[] = 'Proxy-Authorization: Basic '.base64_encode(HTTP_PROXY_USERNAME.':'.HTTP_PROXY_PASSWORD);
326        }
327
328        $headers = array_merge($default_headers, $headers);
329
330        $context = [
331            'http' => [
332                'method' => $method,
333                'protocol_version' => 1.1,
334                'timeout' => HTTP_TIMEOUT,
335                'max_redirects' => HTTP_MAX_REDIRECTS,
336                'header' => implode("\r\n", $headers),
337                'content' => $content,
338                'ignore_errors' => $raiseForErrors,
339            ]
340        ];
341
342        if (HTTP_PROXY_HOSTNAME) {
343            $context['http']['proxy'] = 'tcp://'.HTTP_PROXY_HOSTNAME.':'.HTTP_PROXY_PORT;
344            $context['http']['request_fulluri'] = true;
345        }
346
347        if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
348            $context['ssl'] = [
349                'verify_peer' => false,
350                'verify_peer_name' => false,
351                'allow_self_signed' => true,
352            ];
353        }
354
355        return $context;
356    }
357
358    private function getStatusCode(array $lines)
359    {
360        $status = 200;
361
362        foreach ($lines as $line) {
363            if (strpos($line, 'HTTP/1') === 0) {
364                $status = (int) substr($line, 9, 3);
365            }
366        }
367
368        return $status;
369    }
370
371    /**
372     * Get backend used for making HTTP connections
373     *
374     * @access public
375     * @return string
376     */
377    public static function backend()
378    {
379        return function_exists('curl_version') ? 'cURL' : 'socket';
380    }
381}
382