response = $response ?? new Response('', 0); } public static function fromResponse(Response $response, string $url): HttpException { $message = sprintf( '%s resulted in %s %s %s', $url, $response->getCode(), $response->getStatusLine(), // If debug, include a part of the response body in the exception message Debug::isEnabled() ? mb_substr($response->getBody(), 0, 500) : '', ); if (CloudFlareException::isCloudFlareResponse($response)) { return new CloudFlareException($message, $response->getCode(), $response); } return new HttpException(trim($message), $response->getCode(), $response); } } final class CloudFlareException extends HttpException { public static function isCloudFlareResponse(Response $response): bool { $cloudflareTitles = [ 'Just a moment...', '<title>Please Wait...', '<title>Attention Required!', '<title>Security | Glassdoor', '<title>Access denied', // cf as seen on patreon.com ]; foreach ($cloudflareTitles as $cloudflareTitle) { if (str_contains($response->getBody(), $cloudflareTitle)) { return true; } } return false; } } interface HttpClient { public function request(string $url, array $config = []): Response; } final class CurlHttpClient implements HttpClient { public function request(string $url, array $config = []): Response { $defaults = [ 'useragent' => null, 'timeout' => 5, 'headers' => [], 'proxy' => null, 'curl_options' => [], 'if_not_modified_since' => null, 'retries' => 2, 'max_filesize' => null, 'max_redirections' => 5, ]; $config = array_merge($defaults, $config); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, $config['max_redirections']); curl_setopt($ch, CURLOPT_HEADER, false); $httpHeaders = []; foreach ($config['headers'] as $name => $value) { $httpHeaders[] = sprintf('%s: %s', $name, $value); } curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders); if ($config['useragent']) { curl_setopt($ch, CURLOPT_USERAGENT, $config['useragent']); } curl_setopt($ch, CURLOPT_TIMEOUT, $config['timeout']); curl_setopt($ch, CURLOPT_ENCODING, ''); curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); if ($config['max_filesize']) { // This option inspects the Content-Length header curl_setopt($ch, CURLOPT_MAXFILESIZE, $config['max_filesize']); curl_setopt($ch, CURLOPT_NOPROGRESS, false); // This progress function will monitor responses who omit the Content-Length header curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($ch, $downloadSize, $downloaded, $uploadSize, $uploaded) use ($config) { if ($downloaded > $config['max_filesize']) { // Return a non-zero value to abort the transfer return -1; } return 0; }); } if ($config['proxy']) { curl_setopt($ch, CURLOPT_PROXY, $config['proxy']); } if (curl_setopt_array($ch, $config['curl_options']) === false) { throw new \Exception('Tried to set an illegal curl option'); } if ($config['if_not_modified_since']) { curl_setopt($ch, CURLOPT_TIMEVALUE, $config['if_not_modified_since']); curl_setopt($ch, CURLOPT_TIMECONDITION, CURL_TIMECOND_IFMODSINCE); } $responseStatusLines = []; $responseHeaders = []; curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $rawHeader) use (&$responseHeaders, &$responseStatusLines) { $len = strlen($rawHeader); if ($rawHeader === "\r\n") { return $len; } if (preg_match('#^HTTP/(2|1.1|1.0)#', $rawHeader)) { $responseStatusLines[] = trim($rawHeader); return $len; } $header = explode(':', $rawHeader); if (count($header) === 1) { return $len; } $name = mb_strtolower(trim($header[0])); $value = trim(implode(':', array_slice($header, 1))); if (!isset($responseHeaders[$name])) { $responseHeaders[$name] = []; } $responseHeaders[$name][] = $value; return $len; }); // This retry logic is a bit hard to understand, but it works $tries = 0; while (true) { $tries++; $body = curl_exec($ch); if ($body !== false) { // The network call was successful, so break out of the loop break; } if ($tries <= $config['retries']) { continue; } // Max retries reached, give up $curl_error = curl_error($ch); $curl_errno = curl_errno($ch); throw new HttpException(sprintf( 'cURL error %s: %s (%s) for %s', $curl_error, $curl_errno, 'https://curl.haxx.se/libcurl/c/libcurl-errors.html', $url )); } $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return new Response($body, $statusCode, $responseHeaders); } } final class Request { private array $get; private array $server; private array $attributes; private function __construct() { } public static function fromGlobals(): self { $self = new self(); $self->get = $_GET; $self->server = $_SERVER; $self->attributes = []; return $self; } public static function fromCli(array $cliArgs): self { $self = new self(); $self->get = $cliArgs; return $self; } public function get(string $key, $default = null): ?string { return $this->get[$key] ?? $default; } public function server(string $key, string $default = null): ?string { return $this->server[$key] ?? $default; } public function withAttribute(string $name, $value = true): self { $clone = clone $this; $clone->attributes[$name] = $value; return $clone; } public function attribute(string $key, $default = null) { return $this->attributes[$key] ?? $default; } public function toArray(): array { return $this->get; } } final class Response { public const STATUS_CODES = [ '100' => 'Continue', '101' => 'Switching Protocols', '200' => 'OK', '201' => 'Created', '202' => 'Accepted', '203' => 'Non-Authoritative Information', '204' => 'No Content', '205' => 'Reset Content', '206' => 'Partial Content', '300' => 'Multiple Choices', '301' => 'Moved Permanently', '302' => 'Found', '303' => 'See Other', '304' => 'Not Modified', '305' => 'Use Proxy', '400' => 'Bad Request', '401' => 'Unauthorized', '402' => 'Payment Required', '403' => 'Forbidden', '404' => 'Not Found', '405' => 'Method Not Allowed', '406' => 'Not Acceptable', '407' => 'Proxy Authentication Required', '408' => 'Request Timeout', '409' => 'Conflict', '410' => 'Gone', '411' => 'Length Required', '412' => 'Precondition Failed', '413' => 'Request Entity Too Large', '414' => 'Request-URI Too Long', '415' => 'Unsupported Media Type', '416' => 'Requested Range Not Satisfiable', '417' => 'Expectation Failed', '429' => 'Too Many Requests', '500' => 'Internal Server Error', '501' => 'Not Implemented', '502' => 'Bad Gateway', '503' => 'Service Unavailable', '504' => 'Gateway Timeout', '505' => 'HTTP Version Not Supported' ]; private string $body; private int $code; private array $headers; public function __construct( string $body = '', int $code = 200, array $headers = [] ) { $this->body = $body; $this->code = $code; $this->headers = []; foreach ($headers as $name => $value) { $name = mb_strtolower($name); if (!isset($this->headers[$name])) { $this->headers[$name] = []; } if (is_string($value)) { $this->headers[$name][] = $value; } if (is_array($value)) { $this->headers[$name] = $value; } } } public function getBody(): string { return $this->body; } public function getCode(): int { return $this->code; } public function getStatusLine(): string { return self::STATUS_CODES[$this->code] ?? ''; } public function getHeaders(): array { return $this->headers; } /** * HTTP response may have multiple headers with the same name. * * This method by default, returns only the last header. * * @return string[]|string|null */ public function getHeader(string $name, bool $all = false) { $name = mb_strtolower($name); $header = $this->headers[$name] ?? null; if (!$header) { return null; } if ($all) { return $header; } return array_pop($header); } public function withHeader(string $name, string $value): self { $clone = clone $this; $clone->headers[$name] = [$value]; return $clone; } public function withBody(string $body): self { $clone = clone $this; $clone->body = $body; return $clone; } public function send(): void { http_response_code($this->code); foreach ($this->headers as $name => $values) { foreach ($values as $value) { header(sprintf('%s: %s', $name, $value)); } } print $this->body; } }