PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / trunk
Matomo Analytics – Powerful, Privacy-First Insights for WordPress vtrunk
5.11.1 5.11.0 5.10.2 5.10.1 trunk 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.3.2 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.10.0 4.11.0 4.12.0 4.13.0 4.13.2 4.13.3 4.13.4 4.13.5 4.14.0 4.14.1 4.14.2 4.15.0 4.15.1 4.15.2 4.15.3 4.2.0 4.3.0 4.3.1 4.4.1 4.4.2 4.5.0 4.6.0 5.0.1 5.0.2 5.0.3 5.0.4 5.0.5 5.0.6 5.0.7 5.0.8 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.10.0 5.2.0 5.2.1 5.2.2 5.3.0 5.3.1 5.3.2 5.3.3 5.6.0 5.6.1 5.7.0 5.7.1 5.8.0 5.8.1 5.8.2
matomo / app / core / Http.php
matomo / app / core Last commit date
API 1 month ago Access 3 months ago Application 1 month ago Archive 1 month ago ArchiveProcessor 1 month ago Archiver 2 years ago AssetManager 1 month ago Auth 6 months ago Category 6 months ago Changes 1 month ago CliMulti 1 year ago Columns 1 month ago Concurrency 1 month ago Config 1 month ago Container 1 month ago CronArchive 3 months ago DataAccess 1 month ago DataFiles 2 years ago DataTable 2 weeks ago Db 2 weeks ago DeviceDetector 1 year ago Email 2 years ago Exception 4 months ago Http 4 months ago Intl 3 months ago Log 2 years ago Mail 1 year ago Measurable 6 months ago Menu 1 month ago Metrics 3 months ago Notification 6 months ago Period 1 month ago Plugin 2 weeks ago Policy 1 month ago ProfessionalServices 1 year ago Report 1 year ago ReportRenderer 3 months ago Request 3 months ago Scheduler 1 month ago Segment 1 month ago Session 2 weeks ago Settings 1 month ago Tracker 2 weeks ago Translation 1 month ago Twig 1 year ago UpdateCheck 3 months ago Updater 1 month ago Updates 2 days ago Validators 1 year ago View 1 month ago ViewDataTable 2 weeks ago Visualization 1 year ago Widget 1 month ago .htaccess 2 years ago Access.php 1 month ago Archive.php 1 month ago ArchiveProcessor.php 1 month ago AssetManager.php 1 month ago Auth.php 6 months ago AuthResult.php 6 months ago BaseFactory.php 2 years ago Cache.php 2 years ago CacheId.php 4 months ago CliMulti.php 1 month ago Common.php 2 weeks ago Config.php 1 month ago Console.php 3 months ago Context.php 2 years ago Cookie.php 1 year ago CronArchive.php 1 month ago DI.php 3 months ago DataArray.php 1 month ago DataTable.php 1 month ago Date.php 1 month ago Db.php 1 month ago DbHelper.php 1 month ago Development.php 1 year ago ErrorHandler.php 6 months ago EventDispatcher.php 1 month ago ExceptionHandler.php 4 months ago FileIntegrity.php 1 month ago Filechecks.php 1 year ago Filesystem.php 1 month ago FrontController.php 4 months ago Http.php 1 month ago IP.php 1 year ago Log.php 3 months ago LogDeleter.php 1 year ago Mail.php 1 year ago Metrics.php 1 month ago NoAccessException.php 2 years ago Nonce.php 6 months ago Notification.php 1 month ago NumberFormatter.php 5 months ago Option.php 5 months ago Period.php 1 month ago Piwik.php 1 month ago Plugin.php 1 month ago Process.php 1 month ago Profiler.php 6 months ago ProxyHeaders.php 4 months ago ProxyHttp.php 5 months ago QuickForm2.php 3 months ago RankingQuery.php 1 month ago ReportRenderer.php 1 month ago Request.php 1 month ago Segment.php 1 month ago Sequence.php 6 months ago Session.php 2 weeks ago SettingsPiwik.php 1 month ago SettingsServer.php 1 year ago Singleton.php 2 years ago Site.php 1 month ago SiteContentDetector.php 1 month ago SupportedBrowser.php 2 years ago TCPDF.php 1 year ago Theme.php 1 year ago Timer.php 1 month ago Tracker.php 1 month ago Twig.php 1 month ago Unzip.php 1 year ago UpdateCheck.php 1 month ago Updater.php 1 month ago UpdaterErrorException.php 2 years ago Updates.php 3 months ago Url.php 3 months ago UrlHelper.php 1 month ago Version.php 2 days ago View.php 1 month ago bootstrap.php 1 year ago dispatch.php 2 years ago testMinimumPhpVersion.php 6 months ago
Http.php
886 lines
1 <?php
2
3 /**
4 * Matomo - free/libre analytics platform
5 *
6 * @link https://matomo.org
7 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8 */
9 namespace Piwik;
10
11 use Composer\CaBundle\CaBundle;
12 use Exception;
13 use Piwik\Config\GeneralConfig;
14 use Piwik\Container\StaticContainer;
15 /**
16 * Contains HTTP client related helper methods that can retrieve content from remote servers
17 * and optionally save to a local file.
18 *
19 * Used to check for the latest Piwik version and download updates.
20 *
21 */
22 class Http
23 {
24 /**
25 * Returns the "best" available transport method for {@link sendHttpRequest()} calls.
26 *
27 * @return string|null Either curl, fopen, socket or null if no method is supported.
28 * @api
29 */
30 public static function getTransportMethod()
31 {
32 $method = 'curl';
33 if (!self::isCurlEnabled()) {
34 $method = 'fopen';
35 if (@ini_get('allow_url_fopen') != '1') {
36 $method = 'socket';
37 if (!self::isSocketEnabled()) {
38 return null;
39 }
40 }
41 }
42 return $method;
43 }
44 /**
45 * @return bool
46 */
47 protected static function isSocketEnabled()
48 {
49 return function_exists('fsockopen');
50 }
51 /**
52 * @return bool
53 */
54 protected static function isCurlEnabled()
55 {
56 return function_exists('curl_init') && function_exists('curl_exec');
57 }
58 /**
59 * Sends an HTTP request using best available transport method.
60 *
61 * @param string $aUrl The target URL.
62 * @param int $timeout The number of seconds to wait before aborting the HTTP request.
63 * @param string|null $userAgent The user agent to use.
64 * @param string|null $destinationPath If supplied, the HTTP response will be saved to the file specified by
65 * this path.
66 * @param int|null $followDepth Internal redirect count. Should always pass `null` for this parameter.
67 * @param bool|string $acceptLanguage The value to use for the `'Accept-Language'` HTTP request header.
68 * @param array|bool $byteRange For `Range:` header. Should be two element array of bytes, eg, `array(0, 1024)`
69 * Doesn't work w/ `fopen` transport method.
70 * @param bool $getExtendedInfo If true returns the status code, headers & response, if false just the response.
71 * @param string $httpMethod The HTTP method to use. Defaults to `'GET'`.
72 * @param string $httpUsername HTTP Auth username
73 * @param string $httpPassword HTTP Auth password
74 * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only
75 * be set to false when using a hardcoded URL.
76 *
77 * @return string|array|bool If `$destinationPath` is not specified the HTTP response is returned on success. `false`
78 * is returned on failure.
79 * If `$getExtendedInfo` is `true` and `$destinationPath` is not specified an array with
80 * the following information is returned on success:
81 *
82 * - **status**: the HTTP status code
83 * - **headers**: the HTTP headers
84 * - **data**: the HTTP response data
85 *
86 * `false` is still returned on failure.
87 * @throws Exception if the response cannot be saved to `$destinationPath`, if the HTTP response cannot be sent,
88 * if there are more than 5 redirects or if the request times out.
89 * @phpstan-return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool)
90 * @api
91 */
92 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)
93 {
94 // create output file
95 $file = self::ensureDestinationDirectoryExists($destinationPath);
96 $acceptLanguage = $acceptLanguage ? 'Accept-Language: ' . $acceptLanguage : '';
97 return self::sendHttpRequestBy(self::getTransportMethod(), $aUrl, $timeout, $userAgent, $destinationPath, $file, $followDepth ?? 0, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, null, [], null, $checkHostIsAllowed);
98 }
99 /**
100 * @param string|null $destinationPath
101 * @return resource|null
102 * @throws Exception
103 */
104 public static function ensureDestinationDirectoryExists($destinationPath)
105 {
106 if ($destinationPath) {
107 \Piwik\Filesystem::mkdir(dirname($destinationPath));
108 if (($file = @fopen($destinationPath, 'wb')) === \false || !is_resource($file)) {
109 throw new Exception('Error while creating the file: ' . $destinationPath);
110 }
111 return $file;
112 }
113 return null;
114 }
115 private static function convertWildcardToPattern(string $wildcardHost) : string
116 {
117 $flexibleStart = $flexibleEnd = \false;
118 if (strpos($wildcardHost, '*.') === 0) {
119 $flexibleStart = \true;
120 $wildcardHost = substr($wildcardHost, 2);
121 }
122 if (\Piwik\Common::stringEndsWith($wildcardHost, '.*')) {
123 $flexibleEnd = \true;
124 $wildcardHost = substr($wildcardHost, 0, -2);
125 }
126 $pattern = preg_quote($wildcardHost);
127 if ($flexibleStart) {
128 $pattern = '.*\\.' . $pattern;
129 }
130 if ($flexibleEnd) {
131 $pattern .= '\\..*';
132 }
133 return '/^' . $pattern . '$/i';
134 }
135 /**
136 * Sends an HTTP request using the specified transport method.
137 *
138 * @param string|null $method
139 * @param string $aUrl
140 * @param int $timeout in seconds
141 * @param string $userAgent
142 * @param string $destinationPath
143 * @param resource $file
144 * @param int $followDepth
145 * @param string|false $acceptLanguage Accept-language header
146 * @param bool $acceptInvalidSslCertificate Only used with $method == 'curl'. If set to true (NOT recommended!) the SSL certificate will not be checked
147 * @param array|false $byteRange For Range: header. Should be two element array of bytes, eg, array(0, 1024)
148 * Doesn't work w/ fopen method.
149 * @param bool $getExtendedInfo True to return status code, headers & response, false if just response.
150 * @param string $httpMethod The HTTP method to use. Defaults to `'GET'`.
151 * @param string $httpUsername HTTP Auth username
152 * @param string $httpPassword HTTP Auth password
153 * @param array|string $requestBody If $httpMethod is 'POST' this may accept an array of variables or a string that needs to be posted
154 * @param array $additionalHeaders List of additional headers to set for the request
155 * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only
156 * be set to false when using a hardcoded URL.
157 *
158 * @return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool)
159 * @throws Exception
160 */
161 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)
162 {
163 if ($followDepth > 5) {
164 throw new Exception('Too many redirects (' . $followDepth . ')');
165 }
166 $aUrl = preg_replace('/[\\x00-\\x1F\\x7F]/', '', trim($aUrl));
167 $parsedUrl = @parse_url($aUrl);
168 if (empty($parsedUrl['scheme'])) {
169 throw new Exception('Missing scheme in given url');
170 }
171 $allowedProtocols = GeneralConfig::getConfigValue('allowed_outgoing_protocols');
172 $isAllowed = \false;
173 foreach (explode(',', $allowedProtocols) as $protocol) {
174 if (strtolower($parsedUrl['scheme']) === strtolower(trim($protocol))) {
175 $isAllowed = \true;
176 break;
177 }
178 }
179 if (!$isAllowed) {
180 throw new Exception(sprintf('Protocol %s not in list of allowed protocols: %s', $parsedUrl['scheme'], $allowedProtocols));
181 }
182 if ($checkHostIsAllowed) {
183 $disallowedHosts = StaticContainer::get('http.blocklist.hosts');
184 $isBlocked = \false;
185 foreach ($disallowedHosts as $host) {
186 if (!empty($parsedUrl['host']) && preg_match(self::convertWildcardToPattern($host), $parsedUrl['host']) === 1) {
187 $isBlocked = \true;
188 break;
189 }
190 }
191 if ($isBlocked) {
192 throw new Exception(sprintf('Hostname %s is in list of disallowed hosts', $parsedUrl['host']));
193 }
194 }
195 // When sending an insecure request, but https is forced, and we would care about valid certificates, log a warning
196 // Note: accepting invalid ssl certificates should only be used when requesting data from a configured website
197 if ($parsedUrl['scheme'] === 'http' && \Piwik\SettingsPiwik::isHttpsForced() && $acceptInvalidSslCertificate === \false) {
198 \Piwik\Log::warning('Matomo is configured to force HTTPS, but is sending an insecure request to ' . $aUrl);
199 }
200 $contentLength = 0;
201 $fileLength = 0;
202 if (!empty($requestBody) && is_array($requestBody)) {
203 $requestBodyQuery = self::buildQuery($requestBody);
204 } else {
205 $requestBodyQuery = $requestBody;
206 }
207 if (empty($userAgent)) {
208 $userAgent = self::getUserAgent();
209 }
210 $via = 'Via: ' . (isset($_SERVER['HTTP_VIA']) && !empty($_SERVER['HTTP_VIA']) ? $_SERVER['HTTP_VIA'] . ', ' : '') . \Piwik\Version::VERSION . ' ' . ($userAgent ? " ({$userAgent})" : '');
211 // range header
212 $rangeBytes = '';
213 $rangeHeader = '';
214 if (!empty($byteRange)) {
215 $rangeBytes = $byteRange[0] . '-' . $byteRange[1];
216 $rangeHeader = 'Range: bytes=' . $rangeBytes . "\r\n";
217 }
218 [$proxyHost, $proxyPort, $proxyUser, $proxyPassword] = self::getProxyConfiguration($aUrl);
219 /** @var int|null $status */
220 $status = null;
221 /** @var array<string, string> $headers */
222 $headers = array();
223 /** @var string|null $response */
224 $response = null;
225 $httpAuthIsUsed = !empty($httpUsername) || !empty($httpPassword);
226 $httpAuth = '';
227 if ($httpAuthIsUsed) {
228 $httpAuth = 'Authorization: Basic ' . base64_encode($httpUsername . ':' . $httpPassword) . "\r\n";
229 }
230 $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);
231 /**
232 * Triggered to send an HTTP request. Allows plugins to resolve the HTTP request themselves or to find out
233 * when an HTTP request is triggered to log this information for example to a monitoring tool.
234 *
235 * @param string $url The URL that needs to be requested
236 * @param array $params HTTP params like
237 * - 'httpMethod' (eg GET, POST, ...),
238 * - 'body' the request body if the HTTP method needs to be posted
239 * - 'userAgent'
240 * - 'timeout' After how many seconds a request should time out
241 * - 'headers' An array of header strings like array('Accept-Language: en', '...')
242 * - 'verifySsl' A boolean whether SSL certificate should be verified
243 * - 'destinationPath' If set, the response of the HTTP request should be saved to this file
244 * @param string &$response A plugin listening to this event should assign the HTTP response it received to this variable, for example "{value: true}"
245 * @param int &$status A plugin listening to this event should assign the HTTP status code it received to this variable, for example "200"
246 * @param array &$headers A plugin listening to this event should assign the HTTP headers it received to this variable, eg array('Content-Length' => '5')
247 */
248 \Piwik\Piwik::postEvent('Http.sendHttpRequest', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
249 if ($response !== null || $status !== null || !empty($headers)) {
250 // was handled by event above...
251 /**
252 * described below
253 * @ignore
254 */
255 \Piwik\Piwik::postEvent('Http.sendHttpRequest.end', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
256 if ($destinationPath && file_exists($destinationPath)) {
257 return \true;
258 }
259 if ($getExtendedInfo) {
260 return array('status' => $status, 'headers' => $headers, 'data' => $response);
261 } else {
262 return trim($response);
263 }
264 }
265 if ($method == 'socket') {
266 if (!self::isSocketEnabled()) {
267 // can be triggered in tests
268 throw new Exception("HTTP socket support is not enabled (php function fsockopen is not available) ");
269 }
270 // initialization
271 $url = @parse_url($aUrl);
272 if ($url === \false || !isset($url['scheme'])) {
273 throw new Exception('Malformed URL: ' . $aUrl);
274 }
275 if ($url['scheme'] != 'http' && $url['scheme'] != 'https') {
276 throw new Exception('Invalid protocol/scheme: ' . $url['scheme']);
277 }
278 $host = $url['host'];
279 $port = isset($url['port']) ? $url['port'] : ('https' == $url['scheme'] ? 443 : 80);
280 $path = isset($url['path']) ? $url['path'] : '/';
281 if (isset($url['query'])) {
282 $path .= '?' . $url['query'];
283 }
284 $errno = null;
285 $errstr = null;
286 if (!empty($proxyHost) && !empty($proxyPort) || !empty($byteRange)) {
287 $httpVer = '1.1';
288 } else {
289 $httpVer = '1.0';
290 }
291 $proxyAuth = null;
292 if (!empty($proxyHost) && !empty($proxyPort)) {
293 $connectHost = $proxyHost;
294 $connectPort = $proxyPort;
295 if (!empty($proxyUser) && !empty($proxyPassword)) {
296 $proxyAuth = 'Proxy-Authorization: Basic ' . base64_encode("{$proxyUser}:{$proxyPassword}") . "\r\n";
297 }
298 $requestHeader = "{$httpMethod} {$aUrl} HTTP/{$httpVer}\r\n";
299 } else {
300 $connectHost = $host;
301 $connectPort = $port;
302 $requestHeader = "{$httpMethod} {$path} HTTP/{$httpVer}\r\n";
303 if ('https' == $url['scheme']) {
304 $connectHost = 'tls://' . $connectHost;
305 }
306 }
307 // connection attempt
308 if (($fsock = @fsockopen($connectHost, $connectPort, $errno, $errstr, $timeout)) === \false || !is_resource($fsock)) {
309 if (is_resource($file)) {
310 @fclose($file);
311 }
312 throw new Exception("Error while connecting to: {$host}. Please try again later. {$errstr}");
313 }
314 // send HTTP request header
315 $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";
316 fwrite($fsock, $requestHeader);
317 if (strtolower($httpMethod) === 'post' && !empty($requestBodyQuery)) {
318 fwrite($fsock, self::buildHeadersForPost($requestBodyQuery));
319 fwrite($fsock, "\r\n");
320 fwrite($fsock, $requestBodyQuery);
321 } else {
322 fwrite($fsock, "\r\n");
323 }
324 $streamMetaData = array('timed_out' => \false);
325 @stream_set_blocking($fsock, \true);
326 if (function_exists('stream_set_timeout')) {
327 @stream_set_timeout($fsock, $timeout);
328 } elseif (function_exists('socket_set_timeout')) {
329 @socket_set_timeout($fsock, $timeout);
330 }
331 // process header
332 $status = null;
333 while (!feof($fsock)) {
334 $line = fgets($fsock, 4096);
335 $streamMetaData = @stream_get_meta_data($fsock);
336 if ($streamMetaData['timed_out']) {
337 if (is_resource($file)) {
338 @fclose($file);
339 }
340 @fclose($fsock);
341 throw new Exception('Timed out waiting for server response');
342 }
343 // a blank line marks the end of the server response header
344 if (rtrim($line, "\r\n") == '') {
345 break;
346 }
347 // parse first line of server response header
348 if (!$status) {
349 // expect first line to be HTTP response status line, e.g., HTTP/1.1 200 OK
350 if (!preg_match('~^HTTP/(\\d\\.\\d)\\s+(\\d+)(\\s*.*)?~', $line, $m)) {
351 if (is_resource($file)) {
352 @fclose($file);
353 }
354 @fclose($fsock);
355 throw new Exception('Expected server response code. Got ' . rtrim($line, "\r\n"));
356 }
357 $status = (int) $m[2];
358 // Informational 1xx or Client Error 4xx
359 if ($status < 200 || $status >= 400) {
360 if (is_resource($file)) {
361 @fclose($file);
362 }
363 @fclose($fsock);
364 if (!$getExtendedInfo) {
365 return \false;
366 } else {
367 return array('status' => $status);
368 }
369 }
370 continue;
371 }
372 // handle redirect
373 if (preg_match('/^Location:\\s*(.+)/', rtrim($line, "\r\n"), $m)) {
374 if (is_resource($file)) {
375 @fclose($file);
376 }
377 @fclose($fsock);
378 // Successful 2xx vs Redirect 3xx
379 if ($status < 300) {
380 throw new Exception('Unexpected redirect to Location: ' . rtrim($line) . ' for status code ' . $status);
381 }
382 return self::sendHttpRequestBy($method, trim($m[1]), $timeout, $userAgent, $destinationPath, $file, $followDepth + 1, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, $requestBodyQuery, $additionalHeaders);
383 }
384 // save expected content length for later verification
385 if (preg_match('/^Content-Length:\\s*(\\d+)/', $line, $m)) {
386 $contentLength = (int) $m[1];
387 }
388 self::parseHeaderLine($headers, $line);
389 }
390 if (feof($fsock) && $httpMethod != 'HEAD') {
391 throw new Exception('Unexpected end of transmission');
392 }
393 // process content/body
394 $response = '';
395 while (!feof($fsock)) {
396 $line = fread($fsock, 8192);
397 $streamMetaData = @stream_get_meta_data($fsock);
398 if ($streamMetaData['timed_out']) {
399 if (is_resource($file)) {
400 @fclose($file);
401 }
402 @fclose($fsock);
403 throw new Exception('Timed out waiting for server response');
404 }
405 $fileLength += strlen($line);
406 if (is_resource($file)) {
407 // save to file
408 fwrite($file, $line);
409 } else {
410 // concatenate to response string
411 $response .= $line;
412 }
413 }
414 // determine success or failure
415 @fclose(@$fsock);
416 } elseif ($method == 'fopen') {
417 $response = \false;
418 // we make sure the request takes less than a few seconds to fail
419 // we create a stream_context (works in php >= 5.2.1)
420 // we also set the socket_timeout (for php < 5.2.1)
421 $default_socket_timeout = @ini_get('default_socket_timeout');
422 @ini_set('default_socket_timeout', (string) $timeout);
423 $ctx = null;
424 if (function_exists('stream_context_create')) {
425 $stream_options = array('http' => array(
426 'header' => 'User-Agent: ' . $userAgent . "\r\n" . ($httpAuth ? $httpAuth : '') . ($acceptLanguage ? $acceptLanguage . "\r\n" : '') . $via . "\r\n" . (!empty($additionalHeaders) ? implode("\r\n", $additionalHeaders) . "\r\n" : '') . $rangeHeader,
427 'max_redirects' => 5,
428 // PHP 5.1.0
429 'timeout' => $timeout,
430 ));
431 if (!empty($proxyHost) && !empty($proxyPort)) {
432 $stream_options['http']['proxy'] = 'tcp://' . $proxyHost . ':' . $proxyPort;
433 $stream_options['http']['request_fulluri'] = \true;
434 // required by squid proxy
435 if (!empty($proxyUser) && !empty($proxyPassword)) {
436 $stream_options['http']['header'] .= 'Proxy-Authorization: Basic ' . base64_encode("{$proxyUser}:{$proxyPassword}") . "\r\n";
437 }
438 }
439 if (strtolower($httpMethod) === 'post' && !empty($requestBodyQuery)) {
440 $postHeader = self::buildHeadersForPost($requestBodyQuery);
441 $postHeader .= "\r\n";
442 $stream_options['http']['method'] = 'POST';
443 $stream_options['http']['header'] .= $postHeader;
444 $stream_options['http']['content'] = $requestBodyQuery;
445 }
446 $ctx = stream_context_create($stream_options);
447 }
448 // save to file
449 if (is_resource($file)) {
450 if (!($handle = fopen($aUrl, 'rb', \false, $ctx))) {
451 throw new Exception("Unable to open {$aUrl}");
452 }
453 while (!feof($handle)) {
454 $response = fread($handle, 8192);
455 $fileLength += strlen($response);
456 fwrite($file, $response);
457 }
458 fclose($handle);
459 if (function_exists('http_get_last_response_headers')) {
460 $http_response_header = http_get_last_response_headers();
461 }
462 } else {
463 $response = @file_get_contents($aUrl, \false, $ctx);
464 if (function_exists('http_get_last_response_headers')) {
465 $http_response_header = http_get_last_response_headers();
466 }
467 // try to get http status code from response headers
468 if (!empty($http_response_header) && preg_match('~^HTTP/(\\d\\.\\d)\\s+(\\d+)(\\s*.*)?~', implode("\n", $http_response_header), $m)) {
469 $status = (int) $m[2];
470 }
471 if (!$status && $response === \false) {
472 $error = \Piwik\ErrorHandler::getLastError();
473 throw new \Exception($error);
474 }
475 $fileLength = strlen($response);
476 }
477 foreach ($http_response_header as $line) {
478 self::parseHeaderLine($headers, $line);
479 }
480 // restore the socket_timeout value
481 if (!empty($default_socket_timeout)) {
482 @ini_set('default_socket_timeout', $default_socket_timeout);
483 }
484 } elseif ($method == 'curl') {
485 if (!self::isCurlEnabled()) {
486 // can be triggered in tests
487 throw new Exception("CURL is not enabled in php.ini, but is being used.");
488 }
489 $ch = @curl_init();
490 if (!empty($proxyHost) && !empty($proxyPort)) {
491 @curl_setopt($ch, \CURLOPT_PROXY, $proxyHost . ':' . $proxyPort);
492 if (!empty($proxyUser) && !empty($proxyPassword)) {
493 // PROXYAUTH defaults to BASIC
494 @curl_setopt($ch, \CURLOPT_PROXYUSERPWD, $proxyUser . ':' . $proxyPassword);
495 }
496 }
497 $curl_options = array(
498 // curl options (sorted oldest to newest)
499 \CURLOPT_URL => $aUrl,
500 \CURLOPT_USERAGENT => $userAgent,
501 \CURLOPT_HTTPHEADER => array_merge(array($via, $acceptLanguage), $additionalHeaders),
502 // only get header info if not saving directly to file
503 \CURLOPT_HEADER => is_resource($file) ? \false : \true,
504 \CURLOPT_CONNECTTIMEOUT => $timeout,
505 \CURLOPT_TIMEOUT => $timeout,
506 );
507 if ($rangeBytes) {
508 curl_setopt($ch, \CURLOPT_RANGE, $rangeBytes);
509 } else {
510 // see https://github.com/matomo-org/matomo/pull/17009 for more info
511 // NOTE: we only do this when CURLOPT_RANGE is not being used, because when using both the
512 // response is empty.
513 $curl_options[\CURLOPT_ENCODING] = "";
514 }
515 // Case core:archive command is triggering archiving on https:// and the certificate is not valid
516 if ($acceptInvalidSslCertificate) {
517 $curl_options += array(\CURLOPT_SSL_VERIFYHOST => \false, \CURLOPT_SSL_VERIFYPEER => \false);
518 }
519 @curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $httpMethod);
520 if ($httpMethod == 'HEAD') {
521 @curl_setopt($ch, \CURLOPT_NOBODY, \true);
522 }
523 if (in_array(strtolower($httpMethod), ['post', 'put']) && !empty($requestBodyQuery)) {
524 curl_setopt($ch, \CURLOPT_POST, 1);
525 curl_setopt($ch, \CURLOPT_POSTFIELDS, $requestBodyQuery);
526 }
527 if (!empty($httpUsername) && !empty($httpPassword)) {
528 $curl_options += array(\CURLOPT_USERPWD => $httpUsername . ':' . $httpPassword);
529 }
530 @curl_setopt_array($ch, $curl_options);
531 self::configCurlCertificate($ch);
532 /*
533 * as of php 5.2.0, CURLOPT_FOLLOWLOCATION can't be set if
534 * in safe_mode or open_basedir is set
535 */
536 if ((string) ini_get('safe_mode') == '' && ini_get('open_basedir') == '') {
537 $protocols = 0;
538 foreach (explode(',', $allowedProtocols) as $protocol) {
539 if (defined('CURLPROTO_' . strtoupper(trim($protocol)))) {
540 $protocols |= constant('CURLPROTO_' . strtoupper(trim($protocol)));
541 }
542 }
543 $curl_options = array(
544 // curl options (sorted oldest to newest)
545 \CURLOPT_FOLLOWLOCATION => \true,
546 \CURLOPT_REDIR_PROTOCOLS => $protocols,
547 \CURLOPT_MAXREDIRS => 5,
548 );
549 if ($forcePost) {
550 $curl_options[\CURLOPT_POSTREDIR] = \CURL_REDIR_POST_ALL;
551 }
552 @curl_setopt_array($ch, $curl_options);
553 }
554 if (is_resource($file)) {
555 // write output directly to file
556 @curl_setopt($ch, \CURLOPT_FILE, $file);
557 } else {
558 // internal to ext/curl
559 @curl_setopt($ch, \CURLOPT_RETURNTRANSFER, \true);
560 }
561 ob_start();
562 $response = @curl_exec($ch);
563 ob_end_clean();
564 if ($response === \true) {
565 $response = '';
566 } elseif ($response === \false) {
567 $errstr = curl_error($ch);
568 if ($errstr != '') {
569 throw new Exception('curl_exec: ' . $errstr . '. Hostname requested was: ' . \Piwik\UrlHelper::getHostFromUrl($aUrl));
570 }
571 $response = '';
572 } else {
573 $header = '';
574 // redirects are included in the output html, so we look for the last line that starts w/ HTTP/...
575 // to split the response
576 while (substr($response, 0, 5) == "HTTP/") {
577 $split = explode("\r\n\r\n", $response, 2);
578 if (count($split) == 2) {
579 [$header, $response] = $split;
580 } else {
581 $response = '';
582 $header = reset($split);
583 }
584 }
585 foreach (explode("\r\n", $header) as $line) {
586 self::parseHeaderLine($headers, $line);
587 }
588 }
589 $contentLength = @curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD);
590 $fileLength = is_resource($file) ? @curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) : strlen($response);
591 $status = @curl_getinfo($ch, \CURLINFO_HTTP_CODE);
592 @curl_close($ch);
593 unset($ch);
594 } else {
595 throw new Exception('Invalid request method: ' . $method);
596 }
597 if (is_resource($file)) {
598 fflush($file);
599 @fclose($file);
600 $fileSize = filesize($destinationPath);
601 if ($contentLength > 0 && $fileSize != $contentLength) {
602 throw new Exception('File size error: ' . $destinationPath . '; expected ' . $contentLength . ' bytes; received ' . $fileLength . ' bytes; saved ' . $fileSize . ' bytes to file');
603 }
604 return \true;
605 }
606 /**
607 * Triggered when an HTTP request finished. A plugin can for example listen to this and alter the response,
608 * status code, or finish a timer in case the plugin is measuring how long it took to execute the request
609 *
610 * @param string $url The URL that needs to be requested
611 * @param array $params HTTP params like
612 * - 'httpMethod' (eg GET, POST, ...),
613 * - 'body' the request body if the HTTP method needs to be posted
614 * - 'userAgent'
615 * - 'timeout' After how many seconds a request should time out
616 * - 'headers' An array of header strings like array('Accept-Language: en', '...')
617 * - 'verifySsl' A boolean whether SSL certificate should be verified
618 * - 'destinationPath' If set, the response of the HTTP request should be saved to this file
619 * @param string &$response The response of the HTTP request, for example "{value: true}"
620 * @param int &$status The returned HTTP status code, for example "200"
621 * @param array &$headers The returned headers, eg array('Content-Length' => '5')
622 */
623 \Piwik\Piwik::postEvent('Http.sendHttpRequest.end', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
624 if (!$getExtendedInfo) {
625 return trim($response);
626 } else {
627 return array('status' => $status, 'headers' => $headers, 'data' => $response);
628 }
629 }
630 public static function buildQuery($params)
631 {
632 return http_build_query($params, '', '&');
633 }
634 private static function buildHeadersForPost(string $requestBody) : string
635 {
636 $postHeader = "Content-Type: application/x-www-form-urlencoded\r\n";
637 $postHeader .= "Content-Length: " . strlen($requestBody) . "\r\n";
638 return $postHeader;
639 }
640 /**
641 * Downloads the next chunk of a specific file. The next chunk's byte range
642 * is determined by the existing file's size and the expected file size, which
643 * is stored in the option table before starting a download. The expected
644 * file size is obtained through a `HEAD` HTTP request.
645 *
646 * _Note: this function uses the **Range** HTTP header to accomplish downloading in
647 * parts. Not every server supports this header._
648 *
649 * The proper use of this function is to call it once per request. The browser
650 * should continue to send requests to Piwik which will in turn call this method
651 * until the file has completely downloaded. In this way, the user can be informed
652 * of a download's progress.
653 *
654 * **Example Usage**
655 *
656 * ```
657 * // browser JavaScript
658 * var downloadFile = function (isStart) {
659 * var ajax = new ajaxHelper();
660 * ajax.addParams({
661 * module: 'MyPlugin',
662 * action: 'myAction',
663 * isStart: isStart ? 1 : 0
664 * }, 'post');
665 * ajax.setCallback(function (response) {
666 * var progress = response.progress
667 * // ...update progress...
668 *
669 * downloadFile(false);
670 * });
671 * ajax.send();
672 * }
673 *
674 * downloadFile(true);
675 * ```
676 *
677 * ```
678 * // PHP controller action
679 * public function myAction()
680 * {
681 * $outputPath = PIWIK_INCLUDE_PATH . '/tmp/averybigfile.zip';
682 * $isStart = Common::getRequestVar('isStart', 1, 'int');
683 * Http::downloadChunk("https://bigfiles.com/averybigfile.zip", $outputPath, $isStart == 1);
684 * }
685 * ```
686 *
687 * @param string $url The url to download from.
688 * @param string $outputPath The path to the file to save/append to.
689 * @param bool $isContinuation `true` if this is the continuation of a download,
690 * or if we're starting a fresh one.
691 * @throws Exception if the file already exists and we're starting a new download,
692 * if we're trying to continue a download that never started
693 * @return array
694 * @api
695 */
696 public static function downloadChunk($url, $outputPath, $isContinuation)
697 {
698 // make sure file doesn't already exist if we're starting a new download
699 if (!$isContinuation && file_exists($outputPath)) {
700 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_FileExists', "'" . $outputPath . "'") . ' ' . \Piwik\Piwik::translate('General_DownloadPleaseRemoveExisting'));
701 }
702 // if we're starting a download, get the expected file size & save as an option
703 $downloadOption = $outputPath . '_expectedDownloadSize';
704 if (!$isContinuation) {
705 $expectedFileSizeResult = \Piwik\Http::sendHttpRequest($url, $timeout = 300, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \true, $httpMethod = 'HEAD');
706 $expectedFileSize = 0;
707 if (isset($expectedFileSizeResult['headers']['Content-Length'])) {
708 $expectedFileSize = (int) $expectedFileSizeResult['headers']['Content-Length'];
709 }
710 if ($expectedFileSize == 0) {
711 \Piwik\Log::info("HEAD request for '%s' failed, got following: %s", $url, print_r($expectedFileSizeResult, \true));
712 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_HttpRequestFail'));
713 }
714 \Piwik\Option::set($downloadOption, (string) $expectedFileSize);
715 } else {
716 $expectedFileSize = \Piwik\Option::get($downloadOption);
717 if ($expectedFileSize === \false) {
718 // sanity check
719 throw new Exception("Trying to continue a download that never started?! That's not supposed to happen...");
720 }
721 $expectedFileSize = (int) $expectedFileSize;
722 }
723 // if existing file is already big enough, then fail so we don't accidentally overwrite
724 // existing DB
725 $existingSize = file_exists($outputPath) ? filesize($outputPath) : 0;
726 if ($existingSize >= $expectedFileSize) {
727 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_FileExistsContinue', "'" . $outputPath . "'") . ' ' . \Piwik\Piwik::translate('General_DownloadPleaseRemoveExisting'));
728 }
729 // download a chunk of the file
730 $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);
731 if ($result['status'] < 200 || $result['status'] > 299) {
732 $result['data'] = self::truncateStr($result['data'], 1024);
733 \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));
734 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_HttpRequestFail'));
735 }
736 // write chunk to file
737 $f = fopen($outputPath, 'ab');
738 fwrite($f, $result['data']);
739 fclose($f);
740 clearstatcache($clear_realpath_cache = \true, $outputPath);
741 return array('current_size' => filesize($outputPath), 'expected_file_size' => $expectedFileSize);
742 }
743 /**
744 * Will configure CURL handle $ch
745 * to use local list of Certificate Authorities,
746 */
747 public static function configCurlCertificate(&$ch)
748 {
749 $cacertPath = GeneralConfig::getConfigValue('custom_cacert_pem');
750 if (empty($cacertPath)) {
751 $cacertPath = CaBundle::getBundledCaBundlePath();
752 }
753 @curl_setopt($ch, \CURLOPT_CAINFO, $cacertPath);
754 }
755 public static function getUserAgent()
756 {
757 return !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Matomo/' . \Piwik\Version::VERSION;
758 }
759 public static function getClientHintsFromServerVariables() : array
760 {
761 $clientHints = [];
762 foreach ($_SERVER as $key => $value) {
763 if (0 === strpos(strtolower($key), strtolower('HTTP_SEC_CH_UA')) || 'X_HTTP_REQUESTED_WITH' === strtoupper($key)) {
764 $clientHints[$key] = $value;
765 }
766 }
767 ksort($clientHints);
768 return $clientHints;
769 }
770 /**
771 * Fetches a file located at `$url` and saves it to `$destinationPath`.
772 *
773 * @param string $url The URL of the file to download.
774 * @param string $destinationPath The path to download the file to.
775 * @param int $tries (deprecated)
776 * @param int $timeout The amount of seconds to wait before aborting the HTTP request.
777 * @return string|bool
778 * @throws Exception if the response cannot be saved to `$destinationPath`, if the HTTP response cannot be sent,
779 * if there are more than 5 redirects or if the request times out.
780 * @phpstan-return ($destinationPath is null ? false|string : bool)
781 * @api
782 */
783 public static function fetchRemoteFile($url, $destinationPath = null, $tries = 0, $timeout = 10)
784 {
785 @ignore_user_abort(\true);
786 \Piwik\SettingsServer::setMaxExecutionTime(0);
787 return self::sendHttpRequest($url, $timeout, 'Update', $destinationPath);
788 }
789 /**
790 * Utility function, parses an HTTP header line into key/value & sets header
791 * array with them.
792 *
793 * @param array $headers
794 * @param string $line
795 */
796 private static function parseHeaderLine(&$headers, $line) : void
797 {
798 $parts = explode(':', $line, 2);
799 if (count($parts) == 1) {
800 return;
801 }
802 [$name, $value] = $parts;
803 $name = trim($name);
804 $headers[$name] = trim($value);
805 /**
806 * With HTTP/2 Cloudflare is passing headers in lowercase (e.g. 'content-type' instead of 'Content-Type')
807 * which breaks any code which uses the header data.
808 */
809 $camelName = ucwords($name, '-');
810 if ($camelName !== $name) {
811 $headers[$camelName] = trim($value);
812 }
813 }
814 /**
815 * Utility function that truncates a string to an arbitrary limit.
816 *
817 * @param string $str The string to truncate.
818 * @param int $limit The maximum length of the truncated string.
819 * @return string
820 */
821 private static function truncateStr($str, $limit)
822 {
823 if (strlen($str) > $limit) {
824 return substr($str, 0, $limit) . '...';
825 }
826 return $str;
827 }
828 /**
829 * Returns the If-Modified-Since HTTP header if it can be found. If it cannot be
830 * found, an empty string is returned.
831 *
832 * @return string
833 */
834 public static function getModifiedSinceHeader()
835 {
836 $modifiedSince = '';
837 if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
838 $modifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
839 // strip any trailing data appended to header
840 if (\false !== ($semicolonPos = strpos($modifiedSince, ';'))) {
841 $modifiedSince = substr($modifiedSince, 0, $semicolonPos);
842 }
843 }
844 return $modifiedSince;
845 }
846 /**
847 * Returns Proxy to use for connecting via HTTP to given URL
848 *
849 * @param string $url
850 * @return array{0: string|null, 1: string|null, 2: string|null, 3: string|null}
851 */
852 private static function getProxyConfiguration($url) : array
853 {
854 $hostname = \Piwik\UrlHelper::getHostFromUrl($url);
855 if (\Piwik\Url::isLocalHost($hostname)) {
856 return [null, null, null, null];
857 }
858 // proxy configuration
859 $proxyHost = \Piwik\Config::getInstance()->proxy['host'];
860 $proxyPort = \Piwik\Config::getInstance()->proxy['port'];
861 $proxyUser = \Piwik\Config::getInstance()->proxy['username'];
862 $proxyPassword = \Piwik\Config::getInstance()->proxy['password'];
863 $proxyExclude = \Piwik\Config::getInstance()->proxy['exclude'];
864 if (!empty($proxyExclude)) {
865 $excludes = explode(',', $proxyExclude);
866 $excludes = array_map('trim', $excludes);
867 $excludes = array_filter($excludes);
868 if (in_array($hostname, $excludes)) {
869 return [null, null, null, null];
870 }
871 }
872 return array($proxyHost, $proxyPort, $proxyUser, $proxyPassword);
873 }
874 /**
875 * Checks if HTTPS is available
876 *
877 * @return bool
878 */
879 public static function isUpdatingOverHttps()
880 {
881 $openSslEnabled = extension_loaded('openssl');
882 $usingMethodSupportingHttps = \Piwik\Http::getTransportMethod() !== 'socket';
883 return $openSslEnabled && $usingMethodSupportingHttps;
884 }
885 }
886