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