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