site-accueil-insa/matomo/core/Http.php

1144 lines
44 KiB
PHP
Raw Normal View History

2022-10-26 08:59:36 +02:00
<?php
/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/
namespace Piwik;
use Composer\CaBundle\CaBundle;
use Exception;
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;
}
protected static function isSocketEnabled()
{
return function_exists('fsockopen');
}
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 $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.
*
* @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.
* @return bool|string 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.
* @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, $acceptLanguage, $acceptInvalidSslCertificate = false, $byteRange, $getExtendedInfo, $httpMethod,
$httpUsername, $httpPassword, null, [], null, $checkHostIsAllowed);
}
public static function ensureDestinationDirectoryExists($destinationPath)
{
if ($destinationPath) {
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($wildcardHost)
{
$flexibleStart = $flexibleEnd = false;
if (strpos($wildcardHost, '*.') === 0) {
$flexibleStart = true;
$wildcardHost = substr($wildcardHost, 2);
}
if (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 $method
* @param string $aUrl
* @param int $timeout in seconds
* @param string $userAgent
* @param string $destinationPath
* @param resource $file
* @param int $followDepth
* @param bool|string $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|bool $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 string|array true (or string/array) on success; false on HTTP response error code (1xx or 4xx)
*@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 = Config::getInstance()->General['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']
));
}
}
$contentLength = 0;
$fileLength = 0;
if ( !empty($requestBody ) && is_array($requestBody )) {
$requestBodyQuery = self::buildQuery($requestBody );
} else {
$requestBodyQuery = $requestBody;
}
// Piwik services behave like a proxy, so we should act like one.
$xff = 'X-Forwarded-For: '
. (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && !empty($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] . ',' : '')
. IP::getIpFromHeader();
if (empty($userAgent)) {
$userAgent = self::getUserAgent();
}
$via = 'Via: '
. (isset($_SERVER['HTTP_VIA']) && !empty($_SERVER['HTTP_VIA']) ? $_SERVER['HTTP_VIA'] . ', ' : '')
. 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);
// other result data
$status = null;
$headers = array();
$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(array(
$rangeHeader, $via, $xff, $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 string &$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::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::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 = 'ssl://' . $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" : '')
. $xff . "\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 = (integer)$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 = (integer)$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', $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" : '')
. $xff . "\r\n"
. $via . "\r\n"
. (!empty($additionalHeaders) ? implode("\r\n", $additionalHeaders) . "\r\n" : '')
. $rangeHeader,
'max_redirects' => 5, // PHP 5.1.0
'timeout' => $timeout, // PHP 5.2.1
)
);
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);
} else {
$response = @file_get_contents($aUrl, 0, $ctx);
// try to get http status code from response headers
if (isset($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 = 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(
// internal to ext/curl
CURLOPT_BINARYTRANSFER => is_resource($file),
// curl options (sorted oldest to newest)
CURLOPT_URL => $aUrl,
CURLOPT_USERAGENT => $userAgent,
CURLOPT_HTTPHEADER => array_merge(array(
$xff,
$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 (strtolower($httpMethod) === 'post' && !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: ' . 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 = $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 string &$status The returned HTTP status code, for example "200"
* @param array &$headers The returned headers, eg array('Content-Length' => '5')
*/
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($requestBody)
{
$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("http://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::translate('General_DownloadFail_FileExists', "'" . $outputPath . "'")
. ' ' . 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 = 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) {
Log::info("HEAD request for '%s' failed, got following: %s", $url, print_r($expectedFileSizeResult, true));
throw new Exception(Piwik::translate('General_DownloadFail_HttpRequestFail'));
}
Option::set($downloadOption, $expectedFileSize);
} else {
$expectedFileSize = (int)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...");
}
}
// 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::translate('General_DownloadFail_FileExistsContinue', "'" . $outputPath . "'")
. ' ' . Piwik::translate('General_DownloadPleaseRemoveExisting'));
}
// download a chunk of the file
$result = 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 === false
|| $result['status'] < 200
|| $result['status'] > 299
) {
$result['data'] = self::truncateStr($result['data'], 1024);
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::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)
{
$general = Config::getInstance()->General;
if (!empty($general['custom_cacert_pem'])) {
$cacertPath = $general['custom_cacert_pem'];
} else {
$cacertPath = CaBundle::getBundledCaBundlePath();
}
@curl_setopt($ch, CURLOPT_CAINFO, $cacertPath);
}
public static function getUserAgent()
{
return !empty($_SERVER['HTTP_USER_AGENT'])
? $_SERVER['HTTP_USER_AGENT']
: 'Matomo/' . 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.
* @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.
* @return bool `true` on success, throws Exception on failure
* @api
*/
public static function fetchRemoteFile($url, $destinationPath = null, $tries = 0, $timeout = 10)
{
@ignore_user_abort(true);
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)
{
$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.
*/
if (version_compare(PHP_VERSION, '5.5.16', '>=')) {
// Passing a second arg to ucwords is not supported by older versions of PHP
$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
*/
private static function getProxyConfiguration($url)
{
$hostname = UrlHelper::getHostFromUrl($url);
if (Url::isLocalHost($hostname)) {
return array(null, null, null, null);
}
// proxy configuration
$proxyHost = Config::getInstance()->proxy['host'];
$proxyPort = Config::getInstance()->proxy['port'];
$proxyUser = Config::getInstance()->proxy['username'];
$proxyPassword = Config::getInstance()->proxy['password'];
$proxyExclude = 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 array(null, null, null, null);
}
}
return array($proxyHost, $proxyPort, $proxyUser, $proxyPassword);
}
/**
* Checks the request is over SSL
* @return bool
*/
public static function isUpdatingOverHttps()
{
$openSslEnabled = extension_loaded('openssl');
$usingMethodSupportingHttps = (Http::getTransportMethod() !== 'socket');
return $openSslEnabled && $usingMethodSupportingHttps;
}
}