<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */
namespace Piwik;

use Composer\CaBundle\CaBundle;
use Exception;
use Piwik\Config\GeneralConfig;
use Piwik\Container\StaticContainer;
/**
 * Contains HTTP client related helper methods that can retrieve content from remote servers
 * and optionally save to a local file.
 *
 * Used to check for the latest Piwik version and download updates.
 *
 */
class Http
{
    /**
     * Returns the "best" available transport method for {@link sendHttpRequest()} calls.
     *
     * @return string|null Either curl, fopen, socket or null if no method is supported.
     * @api
     */
    public static function getTransportMethod()
    {
        $method = 'curl';
        if (!self::isCurlEnabled()) {
            $method = 'fopen';
            if (@ini_get('allow_url_fopen') != '1') {
                $method = 'socket';
                if (!self::isSocketEnabled()) {
                    return null;
                }
            }
        }
        return $method;
    }
    /**
     * @return bool
     */
    protected static function isSocketEnabled()
    {
        return function_exists('fsockopen');
    }
    /**
     * @return bool
     */
    protected static function isCurlEnabled()
    {
        return function_exists('curl_init') && function_exists('curl_exec');
    }
    /**
     * Sends an HTTP request using best available transport method.
     *
     * @param string $aUrl The target URL.
     * @param int $timeout The number of seconds to wait before aborting the HTTP request.
     * @param string|null $userAgent The user agent to use.
     * @param string|null $destinationPath If supplied, the HTTP response will be saved to the file specified by
     *                                     this path.
     * @param int|null $followDepth Internal redirect count. Should always pass `null` for this parameter.
     * @param bool|string $acceptLanguage The value to use for the `'Accept-Language'` HTTP request header.
     * @param array|bool $byteRange For `Range:` header. Should be two element array of bytes, eg, `array(0, 1024)`
     *                              Doesn't work w/ `fopen` transport method.
     * @param bool $getExtendedInfo If true returns the status code, headers & response, if false just the response.
     * @param string $httpMethod The HTTP method to use. Defaults to `'GET'`.
     * @param string $httpUsername HTTP Auth username
     * @param string $httpPassword HTTP Auth password
     * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only
     *                                 be set to false when using a hardcoded URL.
     *
     * @return string|array|bool  If `$destinationPath` is not specified the HTTP response is returned on success. `false`
     *                            is returned on failure.
     *                            If `$getExtendedInfo` is `true` and `$destinationPath` is not specified an array with
     *                            the following information is returned on success:
     *
     *                            - **status**: the HTTP status code
     *                            - **headers**: the HTTP headers
     *                            - **data**: the HTTP response data
     *
     *                            `false` is still returned on failure.
     * @throws Exception if the response cannot be saved to `$destinationPath`, if the HTTP response cannot be sent,
     *                   if there are more than 5 redirects or if the request times out.
     * @phpstan-return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool)
     * @api
     */
    public static function sendHttpRequest($aUrl, $timeout, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $checkHostIsAllowed = \true)
    {
        // create output file
        $file = self::ensureDestinationDirectoryExists($destinationPath);
        $acceptLanguage = $acceptLanguage ? 'Accept-Language: ' . $acceptLanguage : '';
        return self::sendHttpRequestBy(self::getTransportMethod(), $aUrl, $timeout, $userAgent, $destinationPath, $file, $followDepth ?? 0, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, null, [], null, $checkHostIsAllowed);
    }
    /**
     * @param string|null $destinationPath
     * @return resource|null
     * @throws Exception
     */
    public static function ensureDestinationDirectoryExists($destinationPath)
    {
        if ($destinationPath) {
            \Piwik\Filesystem::mkdir(dirname($destinationPath));
            if (($file = @fopen($destinationPath, 'wb')) === \false || !is_resource($file)) {
                throw new Exception('Error while creating the file: ' . $destinationPath);
            }
            return $file;
        }
        return null;
    }
    private static function convertWildcardToPattern(string $wildcardHost) : string
    {
        $flexibleStart = $flexibleEnd = \false;
        if (strpos($wildcardHost, '*.') === 0) {
            $flexibleStart = \true;
            $wildcardHost = substr($wildcardHost, 2);
        }
        if (\Piwik\Common::stringEndsWith($wildcardHost, '.*')) {
            $flexibleEnd = \true;
            $wildcardHost = substr($wildcardHost, 0, -2);
        }
        $pattern = preg_quote($wildcardHost);
        if ($flexibleStart) {
            $pattern = '.*\\.' . $pattern;
        }
        if ($flexibleEnd) {
            $pattern .= '\\..*';
        }
        return '/^' . $pattern . '$/i';
    }
    /**
     * Sends an HTTP request using the specified transport method.
     *
     * @param string|null $method
     * @param string $aUrl
     * @param int $timeout in seconds
     * @param string $userAgent
     * @param string $destinationPath
     * @param resource $file
     * @param int $followDepth
     * @param string|false $acceptLanguage Accept-language header
     * @param bool $acceptInvalidSslCertificate Only used with $method == 'curl'. If set to true (NOT recommended!) the SSL certificate will not be checked
     * @param array|false $byteRange For Range: header. Should be two element array of bytes, eg, array(0, 1024)
     *                                                  Doesn't work w/ fopen method.
     * @param bool $getExtendedInfo True to return status code, headers & response, false if just response.
     * @param string $httpMethod The HTTP method to use. Defaults to `'GET'`.
     * @param string $httpUsername HTTP Auth username
     * @param string $httpPassword HTTP Auth password
     * @param array|string $requestBody If $httpMethod is 'POST' this may accept an array of variables or a string that needs to be posted
     * @param array $additionalHeaders List of additional headers to set for the request
     * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only
     *                                 be set to false when using a hardcoded URL.
     *
     * @return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool)
     * @throws Exception
     */
    public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = null, $destinationPath = null, $file = null, $followDepth = 0, $acceptLanguage = \false, $acceptInvalidSslCertificate = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $requestBody = null, $additionalHeaders = array(), $forcePost = null, $checkHostIsAllowed = \true)
    {
        if ($followDepth > 5) {
            throw new Exception('Too many redirects (' . $followDepth . ')');
        }
        $aUrl = preg_replace('/[\\x00-\\x1F\\x7F]/', '', trim($aUrl));
        $parsedUrl = @parse_url($aUrl);
        if (empty($parsedUrl['scheme'])) {
            throw new Exception('Missing scheme in given url');
        }
        $allowedProtocols = GeneralConfig::getConfigValue('allowed_outgoing_protocols');
        $isAllowed = \false;
        foreach (explode(',', $allowedProtocols) as $protocol) {
            if (strtolower($parsedUrl['scheme']) === strtolower(trim($protocol))) {
                $isAllowed = \true;
                break;
            }
        }
        if (!$isAllowed) {
            throw new Exception(sprintf('Protocol %s not in list of allowed protocols: %s', $parsedUrl['scheme'], $allowedProtocols));
        }
        if ($checkHostIsAllowed) {
            $disallowedHosts = StaticContainer::get('http.blocklist.hosts');
            $isBlocked = \false;
            foreach ($disallowedHosts as $host) {
                if (!empty($parsedUrl['host']) && preg_match(self::convertWildcardToPattern($host), $parsedUrl['host']) === 1) {
                    $isBlocked = \true;
                    break;
                }
            }
            if ($isBlocked) {
                throw new Exception(sprintf('Hostname %s is in list of disallowed hosts', $parsedUrl['host']));
            }
        }
        // When sending an insecure request, but https is forced, and we would care about valid certificates, log a warning
        // Note: accepting invalid ssl certificates should only be used when requesting data from a configured website
        if ($parsedUrl['scheme'] === 'http' && \Piwik\SettingsPiwik::isHttpsForced() && $acceptInvalidSslCertificate === \false) {
            \Piwik\Log::warning('Matomo is configured to force HTTPS, but is sending an insecure request to ' . $aUrl);
        }
        $contentLength = 0;
        $fileLength = 0;
        if (!empty($requestBody) && is_array($requestBody)) {
            $requestBodyQuery = self::buildQuery($requestBody);
        } else {
            $requestBodyQuery = $requestBody;
        }
        if (empty($userAgent)) {
            $userAgent = self::getUserAgent();
        }
        $via = 'Via: ' . (isset($_SERVER['HTTP_VIA']) && !empty($_SERVER['HTTP_VIA']) ? $_SERVER['HTTP_VIA'] . ', ' : '') . \Piwik\Version::VERSION . ' ' . ($userAgent ? " ({$userAgent})" : '');
        // range header
        $rangeBytes = '';
        $rangeHeader = '';
        if (!empty($byteRange)) {
            $rangeBytes = $byteRange[0] . '-' . $byteRange[1];
            $rangeHeader = 'Range: bytes=' . $rangeBytes . "\r\n";
        }
        [$proxyHost, $proxyPort, $proxyUser, $proxyPassword] = self::getProxyConfiguration($aUrl);
        /** @var int|null $status */
        $status = null;
        /** @var array<string, string> $headers */
        $headers = array();
        /** @var string|null $response */
        $response = null;
        $httpAuthIsUsed = !empty($httpUsername) || !empty($httpPassword);
        $httpAuth = '';
        if ($httpAuthIsUsed) {
            $httpAuth = 'Authorization: Basic ' . base64_encode($httpUsername . ':' . $httpPassword) . "\r\n";
        }
        $httpEventParams = array('httpMethod' => $httpMethod, 'body' => $requestBody, 'userAgent' => $userAgent, 'timeout' => $timeout, 'headers' => array_map('trim', array_filter(array_merge([$rangeHeader, $via, $httpAuth, $acceptLanguage], $additionalHeaders))), 'verifySsl' => !$acceptInvalidSslCertificate, 'destinationPath' => $destinationPath);
        /**
         * Triggered to send an HTTP request. Allows plugins to resolve the HTTP request themselves or to find out
         * when an HTTP request is triggered to log this information for example to a monitoring tool.
         *
         * @param string $url The URL that needs to be requested
         * @param array $params HTTP params like
         *                      - 'httpMethod' (eg GET, POST, ...),
         *                      - 'body' the request body if the HTTP method needs to be posted
         *                      - 'userAgent'
         *                      - 'timeout' After how many seconds a request should time out
         *                      - 'headers' An array of header strings like array('Accept-Language: en', '...')
         *                      - 'verifySsl' A boolean whether SSL certificate should be verified
         *                      - 'destinationPath' If set, the response of the HTTP request should be saved to this file
         * @param string &$response A plugin listening to this event should assign the HTTP response it received to this variable, for example "{value: true}"
         * @param int &$status A plugin listening to this event should assign the HTTP status code it received to this variable, for example "200"
         * @param array &$headers A plugin listening to this event should assign the HTTP headers it received to this variable, eg array('Content-Length' => '5')
         */
        \Piwik\Piwik::postEvent('Http.sendHttpRequest', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
        if ($response !== null || $status !== null || !empty($headers)) {
            // was handled by event above...
            /**
             * described below
             * @ignore
             */
            \Piwik\Piwik::postEvent('Http.sendHttpRequest.end', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
            if ($destinationPath && file_exists($destinationPath)) {
                return \true;
            }
            if ($getExtendedInfo) {
                return array('status' => $status, 'headers' => $headers, 'data' => $response);
            } else {
                return trim($response);
            }
        }
        if ($method == 'socket') {
            if (!self::isSocketEnabled()) {
                // can be triggered in tests
                throw new Exception("HTTP socket support is not enabled (php function fsockopen is not available) ");
            }
            // initialization
            $url = @parse_url($aUrl);
            if ($url === \false || !isset($url['scheme'])) {
                throw new Exception('Malformed URL: ' . $aUrl);
            }
            if ($url['scheme'] != 'http' && $url['scheme'] != 'https') {
                throw new Exception('Invalid protocol/scheme: ' . $url['scheme']);
            }
            $host = $url['host'];
            $port = isset($url['port']) ? $url['port'] : ('https' == $url['scheme'] ? 443 : 80);
            $path = isset($url['path']) ? $url['path'] : '/';
            if (isset($url['query'])) {
                $path .= '?' . $url['query'];
            }
            $errno = null;
            $errstr = null;
            if (!empty($proxyHost) && !empty($proxyPort) || !empty($byteRange)) {
                $httpVer = '1.1';
            } else {
                $httpVer = '1.0';
            }
            $proxyAuth = null;
            if (!empty($proxyHost) && !empty($proxyPort)) {
                $connectHost = $proxyHost;
                $connectPort = $proxyPort;
                if (!empty($proxyUser) && !empty($proxyPassword)) {
                    $proxyAuth = 'Proxy-Authorization: Basic ' . base64_encode("{$proxyUser}:{$proxyPassword}") . "\r\n";
                }
                $requestHeader = "{$httpMethod} {$aUrl} HTTP/{$httpVer}\r\n";
            } else {
                $connectHost = $host;
                $connectPort = $port;
                $requestHeader = "{$httpMethod} {$path} HTTP/{$httpVer}\r\n";
                if ('https' == $url['scheme']) {
                    $connectHost = 'tls://' . $connectHost;
                }
            }
            // connection attempt
            if (($fsock = @fsockopen($connectHost, $connectPort, $errno, $errstr, $timeout)) === \false || !is_resource($fsock)) {
                if (is_resource($file)) {
                    @fclose($file);
                }
                throw new Exception("Error while connecting to: {$host}. Please try again later. {$errstr}");
            }
            // send HTTP request header
            $requestHeader .= "Host: {$host}" . ($port != 80 && ('https' == $url['scheme'] && $port != 443) ? ':' . $port : '') . "\r\n" . ($httpAuth ? $httpAuth : '') . ($proxyAuth ? $proxyAuth : '') . 'User-Agent: ' . $userAgent . "\r\n" . ($acceptLanguage ? $acceptLanguage . "\r\n" : '') . $via . "\r\n" . $rangeHeader . (!empty($additionalHeaders) ? implode("\r\n", $additionalHeaders) . "\r\n" : '') . "Connection: close\r\n";
            fwrite($fsock, $requestHeader);
            if (strtolower($httpMethod) === 'post' && !empty($requestBodyQuery)) {
                fwrite($fsock, self::buildHeadersForPost($requestBodyQuery));
                fwrite($fsock, "\r\n");
                fwrite($fsock, $requestBodyQuery);
            } else {
                fwrite($fsock, "\r\n");
            }
            $streamMetaData = array('timed_out' => \false);
            @stream_set_blocking($fsock, \true);
            if (function_exists('stream_set_timeout')) {
                @stream_set_timeout($fsock, $timeout);
            } elseif (function_exists('socket_set_timeout')) {
                @socket_set_timeout($fsock, $timeout);
            }
            // process header
            $status = null;
            while (!feof($fsock)) {
                $line = fgets($fsock, 4096);
                $streamMetaData = @stream_get_meta_data($fsock);
                if ($streamMetaData['timed_out']) {
                    if (is_resource($file)) {
                        @fclose($file);
                    }
                    @fclose($fsock);
                    throw new Exception('Timed out waiting for server response');
                }
                // a blank line marks the end of the server response header
                if (rtrim($line, "\r\n") == '') {
                    break;
                }
                // parse first line of server response header
                if (!$status) {
                    // expect first line to be HTTP response status line, e.g., HTTP/1.1 200 OK
                    if (!preg_match('~^HTTP/(\\d\\.\\d)\\s+(\\d+)(\\s*.*)?~', $line, $m)) {
                        if (is_resource($file)) {
                            @fclose($file);
                        }
                        @fclose($fsock);
                        throw new Exception('Expected server response code.  Got ' . rtrim($line, "\r\n"));
                    }
                    $status = (int) $m[2];
                    // Informational 1xx or Client Error 4xx
                    if ($status < 200 || $status >= 400) {
                        if (is_resource($file)) {
                            @fclose($file);
                        }
                        @fclose($fsock);
                        if (!$getExtendedInfo) {
                            return \false;
                        } else {
                            return array('status' => $status);
                        }
                    }
                    continue;
                }
                // handle redirect
                if (preg_match('/^Location:\\s*(.+)/', rtrim($line, "\r\n"), $m)) {
                    if (is_resource($file)) {
                        @fclose($file);
                    }
                    @fclose($fsock);
                    // Successful 2xx vs Redirect 3xx
                    if ($status < 300) {
                        throw new Exception('Unexpected redirect to Location: ' . rtrim($line) . ' for status code ' . $status);
                    }
                    return self::sendHttpRequestBy($method, trim($m[1]), $timeout, $userAgent, $destinationPath, $file, $followDepth + 1, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, $requestBodyQuery, $additionalHeaders);
                }
                // save expected content length for later verification
                if (preg_match('/^Content-Length:\\s*(\\d+)/', $line, $m)) {
                    $contentLength = (int) $m[1];
                }
                self::parseHeaderLine($headers, $line);
            }
            if (feof($fsock) && $httpMethod != 'HEAD') {
                throw new Exception('Unexpected end of transmission');
            }
            // process content/body
            $response = '';
            while (!feof($fsock)) {
                $line = fread($fsock, 8192);
                $streamMetaData = @stream_get_meta_data($fsock);
                if ($streamMetaData['timed_out']) {
                    if (is_resource($file)) {
                        @fclose($file);
                    }
                    @fclose($fsock);
                    throw new Exception('Timed out waiting for server response');
                }
                $fileLength += strlen($line);
                if (is_resource($file)) {
                    // save to file
                    fwrite($file, $line);
                } else {
                    // concatenate to response string
                    $response .= $line;
                }
            }
            // determine success or failure
            @fclose(@$fsock);
        } elseif ($method == 'fopen') {
            $response = \false;
            // we make sure the request takes less than a few seconds to fail
            // we create a stream_context (works in php >= 5.2.1)
            // we also set the socket_timeout (for php < 5.2.1)
            $default_socket_timeout = @ini_get('default_socket_timeout');
            @ini_set('default_socket_timeout', (string) $timeout);
            $ctx = null;
            if (function_exists('stream_context_create')) {
                $stream_options = array('http' => array(
                    'header' => 'User-Agent: ' . $userAgent . "\r\n" . ($httpAuth ? $httpAuth : '') . ($acceptLanguage ? $acceptLanguage . "\r\n" : '') . $via . "\r\n" . (!empty($additionalHeaders) ? implode("\r\n", $additionalHeaders) . "\r\n" : '') . $rangeHeader,
                    'max_redirects' => 5,
                    // PHP 5.1.0
                    'timeout' => $timeout,
                ));
                if (!empty($proxyHost) && !empty($proxyPort)) {
                    $stream_options['http']['proxy'] = 'tcp://' . $proxyHost . ':' . $proxyPort;
                    $stream_options['http']['request_fulluri'] = \true;
                    // required by squid proxy
                    if (!empty($proxyUser) && !empty($proxyPassword)) {
                        $stream_options['http']['header'] .= 'Proxy-Authorization: Basic ' . base64_encode("{$proxyUser}:{$proxyPassword}") . "\r\n";
                    }
                }
                if (strtolower($httpMethod) === 'post' && !empty($requestBodyQuery)) {
                    $postHeader = self::buildHeadersForPost($requestBodyQuery);
                    $postHeader .= "\r\n";
                    $stream_options['http']['method'] = 'POST';
                    $stream_options['http']['header'] .= $postHeader;
                    $stream_options['http']['content'] = $requestBodyQuery;
                }
                $ctx = stream_context_create($stream_options);
            }
            // save to file
            if (is_resource($file)) {
                if (!($handle = fopen($aUrl, 'rb', \false, $ctx))) {
                    throw new Exception("Unable to open {$aUrl}");
                }
                while (!feof($handle)) {
                    $response = fread($handle, 8192);
                    $fileLength += strlen($response);
                    fwrite($file, $response);
                }
                fclose($handle);
                if (function_exists('http_get_last_response_headers')) {
                    $http_response_header = http_get_last_response_headers();
                }
            } else {
                $response = @file_get_contents($aUrl, \false, $ctx);
                if (function_exists('http_get_last_response_headers')) {
                    $http_response_header = http_get_last_response_headers();
                }
                // try to get http status code from response headers
                if (!empty($http_response_header) && preg_match('~^HTTP/(\\d\\.\\d)\\s+(\\d+)(\\s*.*)?~', implode("\n", $http_response_header), $m)) {
                    $status = (int) $m[2];
                }
                if (!$status && $response === \false) {
                    $error = \Piwik\ErrorHandler::getLastError();
                    throw new \Exception($error);
                }
                $fileLength = strlen($response);
            }
            foreach ($http_response_header as $line) {
                self::parseHeaderLine($headers, $line);
            }
            // restore the socket_timeout value
            if (!empty($default_socket_timeout)) {
                @ini_set('default_socket_timeout', $default_socket_timeout);
            }
        } elseif ($method == 'curl') {
            if (!self::isCurlEnabled()) {
                // can be triggered in tests
                throw new Exception("CURL is not enabled in php.ini, but is being used.");
            }
            $ch = @curl_init();
            if (!empty($proxyHost) && !empty($proxyPort)) {
                @curl_setopt($ch, \CURLOPT_PROXY, $proxyHost . ':' . $proxyPort);
                if (!empty($proxyUser) && !empty($proxyPassword)) {
                    // PROXYAUTH defaults to BASIC
                    @curl_setopt($ch, \CURLOPT_PROXYUSERPWD, $proxyUser . ':' . $proxyPassword);
                }
            }
            $curl_options = array(
                // curl options (sorted oldest to newest)
                \CURLOPT_URL => $aUrl,
                \CURLOPT_USERAGENT => $userAgent,
                \CURLOPT_HTTPHEADER => array_merge(array($via, $acceptLanguage), $additionalHeaders),
                // only get header info if not saving directly to file
                \CURLOPT_HEADER => is_resource($file) ? \false : \true,
                \CURLOPT_CONNECTTIMEOUT => $timeout,
                \CURLOPT_TIMEOUT => $timeout,
            );
            if ($rangeBytes) {
                curl_setopt($ch, \CURLOPT_RANGE, $rangeBytes);
            } else {
                // see https://github.com/matomo-org/matomo/pull/17009 for more info
                // NOTE: we only do this when CURLOPT_RANGE is not being used, because when using both the
                // response is empty.
                $curl_options[\CURLOPT_ENCODING] = "";
            }
            // Case core:archive command is triggering archiving on https:// and the certificate is not valid
            if ($acceptInvalidSslCertificate) {
                $curl_options += array(\CURLOPT_SSL_VERIFYHOST => \false, \CURLOPT_SSL_VERIFYPEER => \false);
            }
            @curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $httpMethod);
            if ($httpMethod == 'HEAD') {
                @curl_setopt($ch, \CURLOPT_NOBODY, \true);
            }
            if (in_array(strtolower($httpMethod), ['post', 'put']) && !empty($requestBodyQuery)) {
                curl_setopt($ch, \CURLOPT_POST, 1);
                curl_setopt($ch, \CURLOPT_POSTFIELDS, $requestBodyQuery);
            }
            if (!empty($httpUsername) && !empty($httpPassword)) {
                $curl_options += array(\CURLOPT_USERPWD => $httpUsername . ':' . $httpPassword);
            }
            @curl_setopt_array($ch, $curl_options);
            self::configCurlCertificate($ch);
            /*
             * as of php 5.2.0, CURLOPT_FOLLOWLOCATION can't be set if
             * in safe_mode or open_basedir is set
             */
            if ((string) ini_get('safe_mode') == '' && ini_get('open_basedir') == '') {
                $protocols = 0;
                foreach (explode(',', $allowedProtocols) as $protocol) {
                    if (defined('CURLPROTO_' . strtoupper(trim($protocol)))) {
                        $protocols |= constant('CURLPROTO_' . strtoupper(trim($protocol)));
                    }
                }
                $curl_options = array(
                    // curl options (sorted oldest to newest)
                    \CURLOPT_FOLLOWLOCATION => \true,
                    \CURLOPT_REDIR_PROTOCOLS => $protocols,
                    \CURLOPT_MAXREDIRS => 5,
                );
                if ($forcePost) {
                    $curl_options[\CURLOPT_POSTREDIR] = \CURL_REDIR_POST_ALL;
                }
                @curl_setopt_array($ch, $curl_options);
            }
            if (is_resource($file)) {
                // write output directly to file
                @curl_setopt($ch, \CURLOPT_FILE, $file);
            } else {
                // internal to ext/curl
                @curl_setopt($ch, \CURLOPT_RETURNTRANSFER, \true);
            }
            ob_start();
            $response = @curl_exec($ch);
            ob_end_clean();
            if ($response === \true) {
                $response = '';
            } elseif ($response === \false) {
                $errstr = curl_error($ch);
                if ($errstr != '') {
                    throw new Exception('curl_exec: ' . $errstr . '. Hostname requested was: ' . \Piwik\UrlHelper::getHostFromUrl($aUrl));
                }
                $response = '';
            } else {
                $header = '';
                // redirects are included in the output html, so we look for the last line that starts w/ HTTP/...
                // to split the response
                while (substr($response, 0, 5) == "HTTP/") {
                    $split = explode("\r\n\r\n", $response, 2);
                    if (count($split) == 2) {
                        [$header, $response] = $split;
                    } else {
                        $response = '';
                        $header = reset($split);
                    }
                }
                foreach (explode("\r\n", $header) as $line) {
                    self::parseHeaderLine($headers, $line);
                }
            }
            $contentLength = @curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD);
            $fileLength = is_resource($file) ? @curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) : strlen($response);
            $status = @curl_getinfo($ch, \CURLINFO_HTTP_CODE);
            @curl_close($ch);
            unset($ch);
        } else {
            throw new Exception('Invalid request method: ' . $method);
        }
        if (is_resource($file)) {
            fflush($file);
            @fclose($file);
            $fileSize = filesize($destinationPath);
            if ($contentLength > 0 && $fileSize != $contentLength) {
                throw new Exception('File size error: ' . $destinationPath . '; expected ' . $contentLength . ' bytes; received ' . $fileLength . ' bytes; saved ' . $fileSize . ' bytes to file');
            }
            return \true;
        }
        /**
         * Triggered when an HTTP request finished. A plugin can for example listen to this and alter the response,
         * status code, or finish a timer in case the plugin is measuring how long it took to execute the request
         *
         * @param string $url The URL that needs to be requested
         * @param array $params HTTP params like
         *                      - 'httpMethod' (eg GET, POST, ...),
         *                      - 'body' the request body if the HTTP method needs to be posted
         *                      - 'userAgent'
         *                      - 'timeout' After how many seconds a request should time out
         *                      - 'headers' An array of header strings like array('Accept-Language: en', '...')
         *                      - 'verifySsl' A boolean whether SSL certificate should be verified
         *                      - 'destinationPath' If set, the response of the HTTP request should be saved to this file
         * @param string &$response The response of the HTTP request, for example "{value: true}"
         * @param int &$status The returned HTTP status code, for example "200"
         * @param array &$headers The returned headers, eg array('Content-Length' => '5')
         */
        \Piwik\Piwik::postEvent('Http.sendHttpRequest.end', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
        if (!$getExtendedInfo) {
            return trim($response);
        } else {
            return array('status' => $status, 'headers' => $headers, 'data' => $response);
        }
    }
    public static function buildQuery($params)
    {
        return http_build_query($params, '', '&');
    }
    private static function buildHeadersForPost(string $requestBody) : string
    {
        $postHeader = "Content-Type: application/x-www-form-urlencoded\r\n";
        $postHeader .= "Content-Length: " . strlen($requestBody) . "\r\n";
        return $postHeader;
    }
    /**
     * Downloads the next chunk of a specific file. The next chunk's byte range
     * is determined by the existing file's size and the expected file size, which
     * is stored in the option table before starting a download. The expected
     * file size is obtained through a `HEAD` HTTP request.
     *
     * _Note: this function uses the **Range** HTTP header to accomplish downloading in
     * parts. Not every server supports this header._
     *
     * The proper use of this function is to call it once per request. The browser
     * should continue to send requests to Piwik which will in turn call this method
     * until the file has completely downloaded. In this way, the user can be informed
     * of a download's progress.
     *
     * **Example Usage**
     *
     * ```
     * // browser JavaScript
     * var downloadFile = function (isStart) {
     *     var ajax = new ajaxHelper();
     *     ajax.addParams({
     *         module: 'MyPlugin',
     *         action: 'myAction',
     *         isStart: isStart ? 1 : 0
     *     }, 'post');
     *     ajax.setCallback(function (response) {
     *         var progress = response.progress
     *         // ...update progress...
     *
     *         downloadFile(false);
     *     });
     *     ajax.send();
     * }
     *
     * downloadFile(true);
     * ```
     *
     * ```
     * // PHP controller action
     * public function myAction()
     * {
     *     $outputPath = PIWIK_INCLUDE_PATH . '/tmp/averybigfile.zip';
     *     $isStart = Common::getRequestVar('isStart', 1, 'int');
     *     Http::downloadChunk("https://bigfiles.com/averybigfile.zip", $outputPath, $isStart == 1);
     * }
     * ```
     *
     * @param string $url The url to download from.
     * @param string $outputPath The path to the file to save/append to.
     * @param bool $isContinuation `true` if this is the continuation of a download,
     *                             or if we're starting a fresh one.
     * @throws Exception if the file already exists and we're starting a new download,
     *                   if we're trying to continue a download that never started
     * @return array
     * @api
     */
    public static function downloadChunk($url, $outputPath, $isContinuation)
    {
        // make sure file doesn't already exist if we're starting a new download
        if (!$isContinuation && file_exists($outputPath)) {
            throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_FileExists', "'" . $outputPath . "'") . ' ' . \Piwik\Piwik::translate('General_DownloadPleaseRemoveExisting'));
        }
        // if we're starting a download, get the expected file size & save as an option
        $downloadOption = $outputPath . '_expectedDownloadSize';
        if (!$isContinuation) {
            $expectedFileSizeResult = \Piwik\Http::sendHttpRequest($url, $timeout = 300, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \true, $httpMethod = 'HEAD');
            $expectedFileSize = 0;
            if (isset($expectedFileSizeResult['headers']['Content-Length'])) {
                $expectedFileSize = (int) $expectedFileSizeResult['headers']['Content-Length'];
            }
            if ($expectedFileSize == 0) {
                \Piwik\Log::info("HEAD request for '%s' failed, got following: %s", $url, print_r($expectedFileSizeResult, \true));
                throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_HttpRequestFail'));
            }
            \Piwik\Option::set($downloadOption, (string) $expectedFileSize);
        } else {
            $expectedFileSize = \Piwik\Option::get($downloadOption);
            if ($expectedFileSize === \false) {
                // sanity check
                throw new Exception("Trying to continue a download that never started?! That's not supposed to happen...");
            }
            $expectedFileSize = (int) $expectedFileSize;
        }
        // if existing file is already big enough, then fail so we don't accidentally overwrite
        // existing DB
        $existingSize = file_exists($outputPath) ? filesize($outputPath) : 0;
        if ($existingSize >= $expectedFileSize) {
            throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_FileExistsContinue', "'" . $outputPath . "'") . ' ' . \Piwik\Piwik::translate('General_DownloadPleaseRemoveExisting'));
        }
        // download a chunk of the file
        $result = \Piwik\Http::sendHttpRequest($url, $timeout = 300, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = array($existingSize, min($existingSize + 1024 * 1024 - 1, $expectedFileSize)), $getExtendedInfo = \true);
        if ($result['status'] < 200 || $result['status'] > 299) {
            $result['data'] = self::truncateStr($result['data'], 1024);
            \Piwik\Log::info("Failed to download range '%s-%s' of file from url '%s'. Got result: %s", $byteRange[0], $byteRange[1], $url, print_r($result, \true));
            throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_HttpRequestFail'));
        }
        // write chunk to file
        $f = fopen($outputPath, 'ab');
        fwrite($f, $result['data']);
        fclose($f);
        clearstatcache($clear_realpath_cache = \true, $outputPath);
        return array('current_size' => filesize($outputPath), 'expected_file_size' => $expectedFileSize);
    }
    /**
     * Will configure CURL handle $ch
     * to use local list of Certificate Authorities,
     */
    public static function configCurlCertificate(&$ch)
    {
        $cacertPath = GeneralConfig::getConfigValue('custom_cacert_pem');
        if (empty($cacertPath)) {
            $cacertPath = CaBundle::getBundledCaBundlePath();
        }
        @curl_setopt($ch, \CURLOPT_CAINFO, $cacertPath);
    }
    public static function getUserAgent()
    {
        return !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Matomo/' . \Piwik\Version::VERSION;
    }
    public static function getClientHintsFromServerVariables() : array
    {
        $clientHints = [];
        foreach ($_SERVER as $key => $value) {
            if (0 === strpos(strtolower($key), strtolower('HTTP_SEC_CH_UA')) || 'X_HTTP_REQUESTED_WITH' === strtoupper($key)) {
                $clientHints[$key] = $value;
            }
        }
        ksort($clientHints);
        return $clientHints;
    }
    /**
     * Fetches a file located at `$url` and saves it to `$destinationPath`.
     *
     * @param string $url The URL of the file to download.
     * @param string $destinationPath The path to download the file to.
     * @param int $tries (deprecated)
     * @param int $timeout The amount of seconds to wait before aborting the HTTP request.
     * @return string|bool
     * @throws Exception if the response cannot be saved to `$destinationPath`, if the HTTP response cannot be sent,
     *                   if there are more than 5 redirects or if the request times out.
     * @phpstan-return ($destinationPath is null ? false|string : bool)
     * @api
     */
    public static function fetchRemoteFile($url, $destinationPath = null, $tries = 0, $timeout = 10)
    {
        @ignore_user_abort(\true);
        \Piwik\SettingsServer::setMaxExecutionTime(0);
        return self::sendHttpRequest($url, $timeout, 'Update', $destinationPath);
    }
    /**
     * Utility function, parses an HTTP header line into key/value & sets header
     * array with them.
     *
     * @param array $headers
     * @param string $line
     */
    private static function parseHeaderLine(&$headers, $line) : void
    {
        $parts = explode(':', $line, 2);
        if (count($parts) == 1) {
            return;
        }
        [$name, $value] = $parts;
        $name = trim($name);
        $headers[$name] = trim($value);
        /**
         * With HTTP/2 Cloudflare is passing headers in lowercase (e.g. 'content-type' instead of 'Content-Type')
         * which breaks any code which uses the header data.
         */
        $camelName = ucwords($name, '-');
        if ($camelName !== $name) {
            $headers[$camelName] = trim($value);
        }
    }
    /**
     * Utility function that truncates a string to an arbitrary limit.
     *
     * @param string $str The string to truncate.
     * @param int $limit The maximum length of the truncated string.
     * @return string
     */
    private static function truncateStr($str, $limit)
    {
        if (strlen($str) > $limit) {
            return substr($str, 0, $limit) . '...';
        }
        return $str;
    }
    /**
     * Returns the If-Modified-Since HTTP header if it can be found. If it cannot be
     * found, an empty string is returned.
     *
     * @return string
     */
    public static function getModifiedSinceHeader()
    {
        $modifiedSince = '';
        if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
            $modifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
            // strip any trailing data appended to header
            if (\false !== ($semicolonPos = strpos($modifiedSince, ';'))) {
                $modifiedSince = substr($modifiedSince, 0, $semicolonPos);
            }
        }
        return $modifiedSince;
    }
    /**
     * Returns Proxy to use for connecting via HTTP to given URL
     *
     * @param string $url
     * @return array{0: string|null, 1: string|null, 2: string|null, 3: string|null}
     */
    private static function getProxyConfiguration($url) : array
    {
        $hostname = \Piwik\UrlHelper::getHostFromUrl($url);
        if (\Piwik\Url::isLocalHost($hostname)) {
            return [null, null, null, null];
        }
        // proxy configuration
        $proxyHost = \Piwik\Config::getInstance()->proxy['host'];
        $proxyPort = \Piwik\Config::getInstance()->proxy['port'];
        $proxyUser = \Piwik\Config::getInstance()->proxy['username'];
        $proxyPassword = \Piwik\Config::getInstance()->proxy['password'];
        $proxyExclude = \Piwik\Config::getInstance()->proxy['exclude'];
        if (!empty($proxyExclude)) {
            $excludes = explode(',', $proxyExclude);
            $excludes = array_map('trim', $excludes);
            $excludes = array_filter($excludes);
            if (in_array($hostname, $excludes)) {
                return [null, null, null, null];
            }
        }
        return array($proxyHost, $proxyPort, $proxyUser, $proxyPassword);
    }
    /**
     * Checks if HTTPS is available
     *
     * @return bool
     */
    public static function isUpdatingOverHttps()
    {
        $openSslEnabled = extension_loaded('openssl');
        $usingMethodSupportingHttps = \Piwik\Http::getTransportMethod() !== 'socket';
        return $openSslEnabled && $usingMethodSupportingHttps;
    }
}
