]> git.mxchange.org Git - friendica.git/blob - src/Util/Network.php
Ensure ParseUrl::getSiteinfo always returns the url and type keys
[friendica.git] / src / Util / Network.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  */
21
22 namespace Friendica\Util;
23
24 use DOMDocument;
25 use DomXPath;
26 use Friendica\Core\Hook;
27 use Friendica\Core\Logger;
28 use Friendica\Core\System;
29 use Friendica\DI;
30 use Friendica\Network\CurlResult;
31
32 class Network
33 {
34         /**
35          * Curl wrapper
36          *
37          * If binary flag is true, return binary results.
38          * Set the cookiejar argument to a string (e.g. "/tmp/friendica-cookies.txt")
39          * to preserve cookies from one request to the next.
40          *
41          * @param string  $url            URL to fetch
42          * @param bool    $binary         default false
43          *                                TRUE if asked to return binary results (file download)
44          * @param int     $timeout        Timeout in seconds, default system config value or 60 seconds
45          * @param string  $accept_content supply Accept: header with 'accept_content' as the value
46          * @param string  $cookiejar      Path to cookie jar file
47          * @param int     $redirects      The recursion counter for internal use - default 0
48          *
49          * @return string The fetched content
50          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
51          */
52         public static function fetchUrl(string $url, bool $binary = false, int $timeout = 0, string $accept_content = '', string $cookiejar = '', int &$redirects = 0)
53         {
54                 $ret = self::fetchUrlFull($url, $binary, $timeout, $accept_content, $cookiejar, $redirects);
55
56                 return $ret->getBody();
57         }
58
59         /**
60          * Curl wrapper with array of return values.
61          *
62          * Inner workings and parameters are the same as @ref fetchUrl but returns an array with
63          * all the information collected during the fetch.
64          *
65          * @param string  $url            URL to fetch
66          * @param bool    $binary         default false
67          *                                TRUE if asked to return binary results (file download)
68          * @param int     $timeout        Timeout in seconds, default system config value or 60 seconds
69          * @param string  $accept_content supply Accept: header with 'accept_content' as the value
70          * @param string  $cookiejar      Path to cookie jar file
71          * @param int     $redirects      The recursion counter for internal use - default 0
72          *
73          * @return CurlResult With all relevant information, 'body' contains the actual fetched content.
74          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
75          */
76         public static function fetchUrlFull(string $url, bool $binary = false, int $timeout = 0, string $accept_content = '', string $cookiejar = '', int &$redirects = 0)
77         {
78                 return self::curl(
79                         $url,
80                         $binary,
81                         [
82                                 'timeout'        => $timeout,
83                                 'accept_content' => $accept_content,
84                                 'cookiejar'      => $cookiejar
85                         ],
86                         $redirects
87                 );
88         }
89
90         /**
91          * fetches an URL.
92          *
93          * @param string  $url       URL to fetch
94          * @param bool    $binary    default false
95          *                           TRUE if asked to return binary results (file download)
96          * @param array   $opts      (optional parameters) assoziative array with:
97          *                           'accept_content' => supply Accept: header with 'accept_content' as the value
98          *                           'timeout' => int Timeout in seconds, default system config value or 60 seconds
99          *                           'http_auth' => username:password
100          *                           'novalidate' => do not validate SSL certs, default is to validate using our CA list
101          *                           'nobody' => only return the header
102          *                           'cookiejar' => path to cookie jar file
103          *                           'header' => header array
104          * @param int     $redirects The recursion counter for internal use - default 0
105          *
106          * @return CurlResult
107          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
108          */
109         public static function curl(string $url, bool $binary = false, array $opts = [], int &$redirects = 0)
110         {
111                 $stamp1 = microtime(true);
112
113                 $a = DI::app();
114
115                 if (strlen($url) > 1000) {
116                         Logger::log('URL is longer than 1000 characters. Callstack: ' . System::callstack(20), Logger::DEBUG);
117                         return CurlResult::createErrorCurl(substr($url, 0, 200));
118                 }
119
120                 $parts2 = [];
121                 $parts = parse_url($url);
122                 $path_parts = explode('/', $parts['path'] ?? '');
123                 foreach ($path_parts as $part) {
124                         if (strlen($part) <> mb_strlen($part)) {
125                                 $parts2[] = rawurlencode($part);
126                         } else {
127                                 $parts2[] = $part;
128                         }
129                 }
130                 $parts['path'] = implode('/', $parts2);
131                 $url = self::unparseURL($parts);
132
133                 if (self::isUrlBlocked($url)) {
134                         Logger::log('domain of ' . $url . ' is blocked', Logger::DATA);
135                         return CurlResult::createErrorCurl($url);
136                 }
137
138                 $ch = @curl_init($url);
139
140                 if (($redirects > 8) || (!$ch)) {
141                         return CurlResult::createErrorCurl($url);
142                 }
143
144                 @curl_setopt($ch, CURLOPT_HEADER, true);
145
146                 if (!empty($opts['cookiejar'])) {
147                         curl_setopt($ch, CURLOPT_COOKIEJAR, $opts["cookiejar"]);
148                         curl_setopt($ch, CURLOPT_COOKIEFILE, $opts["cookiejar"]);
149                 }
150
151                 // These settings aren't needed. We're following the location already.
152                 //      @curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
153                 //      @curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
154
155                 if (!empty($opts['accept_content'])) {
156                         curl_setopt(
157                                 $ch,
158                                 CURLOPT_HTTPHEADER,
159                                 ['Accept: ' . $opts['accept_content']]
160                         );
161                 }
162
163                 if (!empty($opts['header'])) {
164                         curl_setopt($ch, CURLOPT_HTTPHEADER, $opts['header']);
165                 }
166
167                 @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
168                 @curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent());
169
170                 $range = intval(DI::config()->get('system', 'curl_range_bytes', 0));
171
172                 if ($range > 0) {
173                         @curl_setopt($ch, CURLOPT_RANGE, '0-' . $range);
174                 }
175
176                 // Without this setting it seems as if some webservers send compressed content
177                 // This seems to confuse curl so that it shows this uncompressed.
178                 /// @todo  We could possibly set this value to "gzip" or something similar
179                 curl_setopt($ch, CURLOPT_ENCODING, '');
180
181                 if (!empty($opts['headers'])) {
182                         @curl_setopt($ch, CURLOPT_HTTPHEADER, $opts['headers']);
183                 }
184
185                 if (!empty($opts['nobody'])) {
186                         @curl_setopt($ch, CURLOPT_NOBODY, $opts['nobody']);
187                 }
188
189                 if (!empty($opts['timeout'])) {
190                         @curl_setopt($ch, CURLOPT_TIMEOUT, $opts['timeout']);
191                 } else {
192                         $curl_time = DI::config()->get('system', 'curl_timeout', 60);
193                         @curl_setopt($ch, CURLOPT_TIMEOUT, intval($curl_time));
194                 }
195
196                 // by default we will allow self-signed certs
197                 // but you can override this
198
199                 $check_cert = DI::config()->get('system', 'verifyssl');
200                 @curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (($check_cert) ? true : false));
201
202                 if ($check_cert) {
203                         @curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
204                 }
205
206                 $proxy = DI::config()->get('system', 'proxy');
207
208                 if (strlen($proxy)) {
209                         @curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, 1);
210                         @curl_setopt($ch, CURLOPT_PROXY, $proxy);
211                         $proxyuser = @DI::config()->get('system', 'proxyuser');
212
213                         if (strlen($proxyuser)) {
214                                 @curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuser);
215                         }
216                 }
217
218                 if (DI::config()->get('system', 'ipv4_resolve', false)) {
219                         curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
220                 }
221
222                 if ($binary) {
223                         @curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1);
224                 }
225
226                 // don't let curl abort the entire application
227                 // if it throws any errors.
228
229                 $s = @curl_exec($ch);
230                 $curl_info = @curl_getinfo($ch);
231
232                 // Special treatment for HTTP Code 416
233                 // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416
234                 if (($curl_info['http_code'] == 416) && ($range > 0)) {
235                         @curl_setopt($ch, CURLOPT_RANGE, '');
236                         $s = @curl_exec($ch);
237                         $curl_info = @curl_getinfo($ch);
238                 }
239
240                 $curlResponse = new CurlResult($url, $s, $curl_info, curl_errno($ch), curl_error($ch));
241
242                 if ($curlResponse->isRedirectUrl()) {
243                         $redirects++;
244                         Logger::log('curl: redirect ' . $url . ' to ' . $curlResponse->getRedirectUrl());
245                         @curl_close($ch);
246                         return self::curl($curlResponse->getRedirectUrl(), $binary, $opts, $redirects);
247                 }
248
249                 @curl_close($ch);
250
251                 DI::profiler()->saveTimestamp($stamp1, 'network', System::callstack());
252
253                 return $curlResponse;
254         }
255
256         /**
257          * Send POST request to $url
258          *
259          * @param string  $url       URL to post
260          * @param mixed   $params    array of POST variables
261          * @param array   $headers   HTTP headers
262          * @param int     $redirects Recursion counter for internal use - default = 0
263          * @param int     $timeout   The timeout in seconds, default system config value or 60 seconds
264          *
265          * @return CurlResult The content
266          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
267          */
268         public static function post(string $url, $params, array $headers = [], int $timeout = 0, int &$redirects = 0)
269         {
270                 $stamp1 = microtime(true);
271
272                 if (self::isUrlBlocked($url)) {
273                         Logger::log('post_url: domain of ' . $url . ' is blocked', Logger::DATA);
274                         return CurlResult::createErrorCurl($url);
275                 }
276
277                 $a = DI::app();
278                 $ch = curl_init($url);
279
280                 if (($redirects > 8) || (!$ch)) {
281                         return CurlResult::createErrorCurl($url);
282                 }
283
284                 Logger::log('post_url: start ' . $url, Logger::DATA);
285
286                 curl_setopt($ch, CURLOPT_HEADER, true);
287                 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
288                 curl_setopt($ch, CURLOPT_POST, 1);
289                 curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
290                 curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent());
291
292                 if (DI::config()->get('system', 'ipv4_resolve', false)) {
293                         curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
294                 }
295
296                 if (intval($timeout)) {
297                         curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
298                 } else {
299                         $curl_time = DI::config()->get('system', 'curl_timeout', 60);
300                         curl_setopt($ch, CURLOPT_TIMEOUT, intval($curl_time));
301                 }
302
303                 if (!empty($headers)) {
304                         curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
305                 }
306
307                 $check_cert = DI::config()->get('system', 'verifyssl');
308                 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (($check_cert) ? true : false));
309
310                 if ($check_cert) {
311                         @curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
312                 }
313
314                 $proxy = DI::config()->get('system', 'proxy');
315
316                 if (strlen($proxy)) {
317                         curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, 1);
318                         curl_setopt($ch, CURLOPT_PROXY, $proxy);
319                         $proxyuser = DI::config()->get('system', 'proxyuser');
320                         if (strlen($proxyuser)) {
321                                 curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuser);
322                         }
323                 }
324
325                 // don't let curl abort the entire application
326                 // if it throws any errors.
327
328                 $s = @curl_exec($ch);
329
330                 $curl_info = curl_getinfo($ch);
331
332                 $curlResponse = new CurlResult($url, $s, $curl_info, curl_errno($ch), curl_error($ch));
333
334                 if ($curlResponse->isRedirectUrl()) {
335                         $redirects++;
336                         Logger::log('post_url: redirect ' . $url . ' to ' . $curlResponse->getRedirectUrl());
337                         curl_close($ch);
338                         return self::post($curlResponse->getRedirectUrl(), $params, $headers, $redirects, $timeout);
339                 }
340
341                 curl_close($ch);
342
343                 DI::profiler()->saveTimestamp($stamp1, 'network', System::callstack());
344
345                 // Very old versions of Lighttpd don't like the "Expect" header, so we remove it when needed
346                 if ($curlResponse->getReturnCode() == 417) {
347                         $redirects++;
348
349                         if (empty($headers)) {
350                                 $headers = ['Expect:'];
351                         } else {
352                                 if (!in_array('Expect:', $headers)) {
353                                         array_push($headers, 'Expect:');
354                                 }
355                         }
356                         Logger::info('Server responds with 417, applying workaround', ['url' => $url]);
357                         return self::post($url, $params, $headers, $redirects, $timeout);
358                 }
359
360                 Logger::log('post_url: end ' . $url, Logger::DATA);
361
362                 return $curlResponse;
363         }
364
365         /**
366          * Return raw post data from a post request
367          *
368          * @return string post data
369          */
370         public static function postdata()
371         {
372                 return file_get_contents('php://input');
373         }
374
375         /**
376          * Check URL to see if it's real
377          *
378          * Take a URL from the wild, prepend http:// if necessary
379          * and check DNS to see if it's real (or check if is a valid IP address)
380          *
381          * @param string $url The URL to be validated
382          * @return string|boolean The actual working URL, false else
383          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
384          */
385         public static function isUrlValid(string $url)
386         {
387                 if (DI::config()->get('system', 'disable_url_validation')) {
388                         return $url;
389                 }
390
391                 // no naked subdomains (allow localhost for tests)
392                 if (strpos($url, '.') === false && strpos($url, '/localhost/') === false) {
393                         return false;
394                 }
395
396                 if (substr($url, 0, 4) != 'http') {
397                         $url = 'http://' . $url;
398                 }
399
400                 /// @TODO Really suppress function outcomes? Why not find them + debug them?
401                 $h = @parse_url($url);
402
403                 if (!empty($h['host']) && (@dns_get_record($h['host'], DNS_A + DNS_CNAME) || filter_var($h['host'], FILTER_VALIDATE_IP))) {
404                         return $url;
405                 }
406
407                 return false;
408         }
409
410         /**
411          * Checks that email is an actual resolvable internet address
412          *
413          * @param string $addr The email address
414          * @return boolean True if it's a valid email address, false if it's not
415          */
416         public static function isEmailDomainValid(string $addr)
417         {
418                 if (DI::config()->get('system', 'disable_email_validation')) {
419                         return true;
420                 }
421
422                 if (! strpos($addr, '@')) {
423                         return false;
424                 }
425
426                 $h = substr($addr, strpos($addr, '@') + 1);
427
428                 // Concerning the @ see here: https://stackoverflow.com/questions/36280957/dns-get-record-a-temporary-server-error-occurred
429                 if ($h && (@dns_get_record($h, DNS_A + DNS_MX) || filter_var($h, FILTER_VALIDATE_IP))) {
430                         return true;
431                 }
432                 if ($h && @dns_get_record($h, DNS_CNAME + DNS_MX)) {
433                         return true;
434                 }
435                 return false;
436         }
437
438         /**
439          * Check if URL is allowed
440          *
441          * Check $url against our list of allowed sites,
442          * wildcards allowed. If allowed_sites is unset return true;
443          *
444          * @param string $url URL which get tested
445          * @return boolean True if url is allowed otherwise return false
446          */
447         public static function isUrlAllowed(string $url)
448         {
449                 $h = @parse_url($url);
450
451                 if (! $h) {
452                         return false;
453                 }
454
455                 $str_allowed = DI::config()->get('system', 'allowed_sites');
456                 if (! $str_allowed) {
457                         return true;
458                 }
459
460                 $found = false;
461
462                 $host = strtolower($h['host']);
463
464                 // always allow our own site
465                 if ($host == strtolower($_SERVER['SERVER_NAME'])) {
466                         return true;
467                 }
468
469                 $fnmatch = function_exists('fnmatch');
470                 $allowed = explode(',', $str_allowed);
471
472                 if (count($allowed)) {
473                         foreach ($allowed as $a) {
474                                 $pat = strtolower(trim($a));
475                                 if (($fnmatch && fnmatch($pat, $host)) || ($pat == $host)) {
476                                         $found = true;
477                                         break;
478                                 }
479                         }
480                 }
481                 return $found;
482         }
483
484         /**
485          * Checks if the provided url domain is on the domain blocklist.
486          * Returns true if it is or malformed URL, false if not.
487          *
488          * @param string $url The url to check the domain from
489          *
490          * @return boolean
491          */
492         public static function isUrlBlocked(string $url)
493         {
494                 $host = @parse_url($url, PHP_URL_HOST);
495                 if (!$host) {
496                         return false;
497                 }
498
499                 $domain_blocklist = DI::config()->get('system', 'blocklist', []);
500                 if (!$domain_blocklist) {
501                         return false;
502                 }
503
504                 foreach ($domain_blocklist as $domain_block) {
505                         if (fnmatch(strtolower($domain_block['domain']), strtolower($host))) {
506                                 return true;
507                         }
508                 }
509
510                 return false;
511         }
512
513         /**
514          * Check if email address is allowed to register here.
515          *
516          * Compare against our list (wildcards allowed).
517          *
518          * @param  string $email email address
519          * @return boolean False if not allowed, true if allowed
520          *                       or if allowed list is not configured
521          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
522          */
523         public static function isEmailDomainAllowed(string $email)
524         {
525                 $domain = strtolower(substr($email, strpos($email, '@') + 1));
526                 if (!$domain) {
527                         return false;
528                 }
529
530                 $str_allowed = DI::config()->get('system', 'allowed_email', '');
531                 if (empty($str_allowed)) {
532                         return true;
533                 }
534
535                 $allowed = explode(',', $str_allowed);
536
537                 return self::isDomainAllowed($domain, $allowed);
538         }
539
540         /**
541          * Checks for the existence of a domain in a domain list
542          *
543          * @param string $domain
544          * @param array  $domain_list
545          * @return boolean
546          */
547         public static function isDomainAllowed(string $domain, array $domain_list)
548         {
549                 $found = false;
550
551                 foreach ($domain_list as $item) {
552                         $pat = strtolower(trim($item));
553                         if (fnmatch($pat, $domain) || ($pat == $domain)) {
554                                 $found = true;
555                                 break;
556                         }
557                 }
558
559                 return $found;
560         }
561
562         public static function lookupAvatarByEmail(string $email)
563         {
564                 $avatar['size'] = 300;
565                 $avatar['email'] = $email;
566                 $avatar['url'] = '';
567                 $avatar['success'] = false;
568
569                 Hook::callAll('avatar_lookup', $avatar);
570
571                 if (! $avatar['success']) {
572                         $avatar['url'] = DI::baseUrl() . '/images/person-300.jpg';
573                 }
574
575                 Logger::log('Avatar: ' . $avatar['email'] . ' ' . $avatar['url'], Logger::DEBUG);
576                 return $avatar['url'];
577         }
578
579         /**
580          * Remove Google Analytics and other tracking platforms params from URL
581          *
582          * @param string $url Any user-submitted URL that may contain tracking params
583          * @return string The same URL stripped of tracking parameters
584          */
585         public static function stripTrackingQueryParams(string $url)
586         {
587                 $urldata = parse_url($url);
588                 if (!empty($urldata["query"])) {
589                         $query = $urldata["query"];
590                         parse_str($query, $querydata);
591
592                         if (is_array($querydata)) {
593                                 foreach ($querydata as $param => $value) {
594                                         if (in_array(
595                                                 $param,
596                                                 [
597                                                         "utm_source", "utm_medium", "utm_term", "utm_content", "utm_campaign",
598                                                         "wt_mc", "pk_campaign", "pk_kwd", "mc_cid", "mc_eid",
599                                                         "fb_action_ids", "fb_action_types", "fb_ref",
600                                                         "awesm", "wtrid",
601                                                         "woo_campaign", "woo_source", "woo_medium", "woo_content", "woo_term"]
602                                                 )
603                                         ) {
604                                                 $pair = $param . "=" . urlencode($value);
605                                                 $url = str_replace($pair, "", $url);
606
607                                                 // Second try: if the url isn't encoded completely
608                                                 $pair = $param . "=" . str_replace(" ", "+", $value);
609                                                 $url = str_replace($pair, "", $url);
610
611                                                 // Third try: Maybey the url isn't encoded at all
612                                                 $pair = $param . "=" . $value;
613                                                 $url = str_replace($pair, "", $url);
614
615                                                 $url = str_replace(["?&", "&&"], ["?", ""], $url);
616                                         }
617                                 }
618                         }
619
620                         if (substr($url, -1, 1) == "?") {
621                                 $url = substr($url, 0, -1);
622                         }
623                 }
624
625                 return $url;
626         }
627
628         /**
629          * Add a missing base path (scheme and host) to a given url
630          *
631          * @param string $url
632          * @param string $basepath
633          * @return string url
634          */
635         public static function addBasePath(string $url, string $basepath)
636         {
637                 if (!empty(parse_url($url, PHP_URL_SCHEME)) || empty(parse_url($basepath, PHP_URL_SCHEME)) || empty($url) || empty(parse_url($url))) {
638                         return $url;
639                 }
640
641                 $base = ['scheme' => parse_url($basepath, PHP_URL_SCHEME),
642                         'host' => parse_url($basepath, PHP_URL_HOST)];
643
644                 $parts = array_merge($base, parse_url('/' . ltrim($url, '/')));
645                 return self::unparseURL($parts);
646         }
647
648         /**
649          * Returns the original URL of the provided URL
650          *
651          * This function strips tracking query params and follows redirections, either
652          * through HTTP code or meta refresh tags. Stops after 10 redirections.
653          *
654          * @todo  Remove the $fetchbody parameter that generates an extraneous HEAD request
655          *
656          * @see   ParseUrl::getSiteinfo
657          *
658          * @param string $url       A user-submitted URL
659          * @param int    $depth     The current redirection recursion level (internal)
660          * @param bool   $fetchbody Wether to fetch the body or not after the HEAD requests
661          * @return string A canonical URL
662          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
663          */
664         public static function finalUrl(string $url, int $depth = 1, bool $fetchbody = false)
665         {
666                 $a = DI::app();
667
668                 $url = self::stripTrackingQueryParams($url);
669
670                 if ($depth > 10) {
671                         return $url;
672                 }
673
674                 $url = trim($url, "'");
675
676                 $stamp1 = microtime(true);
677
678                 $ch = curl_init();
679                 curl_setopt($ch, CURLOPT_URL, $url);
680                 curl_setopt($ch, CURLOPT_HEADER, 1);
681                 curl_setopt($ch, CURLOPT_NOBODY, 1);
682                 curl_setopt($ch, CURLOPT_TIMEOUT, 10);
683                 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
684                 curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent());
685
686                 curl_exec($ch);
687                 $curl_info = @curl_getinfo($ch);
688                 $http_code = $curl_info['http_code'];
689                 curl_close($ch);
690
691                 DI::profiler()->saveTimestamp($stamp1, "network", System::callstack());
692
693                 if ($http_code == 0) {
694                         return $url;
695                 }
696
697                 if (in_array($http_code, ['301', '302'])) {
698                         if (!empty($curl_info['redirect_url'])) {
699                                 return self::finalUrl($curl_info['redirect_url'], ++$depth, $fetchbody);
700                         } elseif (!empty($curl_info['location'])) {
701                                 return self::finalUrl($curl_info['location'], ++$depth, $fetchbody);
702                         }
703                 }
704
705                 // Check for redirects in the meta elements of the body if there are no redirects in the header.
706                 if (!$fetchbody) {
707                         return(self::finalUrl($url, ++$depth, true));
708                 }
709
710                 // if the file is too large then exit
711                 if ($curl_info["download_content_length"] > 1000000) {
712                         return $url;
713                 }
714
715                 // if it isn't a HTML file then exit
716                 if (!empty($curl_info["content_type"]) && !strstr(strtolower($curl_info["content_type"]), "html")) {
717                         return $url;
718                 }
719
720                 $stamp1 = microtime(true);
721
722                 $ch = curl_init();
723                 curl_setopt($ch, CURLOPT_URL, $url);
724                 curl_setopt($ch, CURLOPT_HEADER, 0);
725                 curl_setopt($ch, CURLOPT_NOBODY, 0);
726                 curl_setopt($ch, CURLOPT_TIMEOUT, 10);
727                 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
728                 curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent());
729
730                 $body = curl_exec($ch);
731                 curl_close($ch);
732
733                 DI::profiler()->saveTimestamp($stamp1, "network", System::callstack());
734
735                 if (trim($body) == "") {
736                         return $url;
737                 }
738
739                 // Check for redirect in meta elements
740                 $doc = new DOMDocument();
741                 @$doc->loadHTML($body);
742
743                 $xpath = new DomXPath($doc);
744
745                 $list = $xpath->query("//meta[@content]");
746                 foreach ($list as $node) {
747                         $attr = [];
748                         if ($node->attributes->length) {
749                                 foreach ($node->attributes as $attribute) {
750                                         $attr[$attribute->name] = $attribute->value;
751                                 }
752                         }
753
754                         if (@$attr["http-equiv"] == 'refresh') {
755                                 $path = $attr["content"];
756                                 $pathinfo = explode(";", $path);
757                                 foreach ($pathinfo as $value) {
758                                         if (substr(strtolower($value), 0, 4) == "url=") {
759                                                 return self::finalUrl(substr($value, 4), ++$depth);
760                                         }
761                                 }
762                         }
763                 }
764
765                 return $url;
766         }
767
768         /**
769          * Find the matching part between two url
770          *
771          * @param string $url1
772          * @param string $url2
773          * @return string The matching part
774          */
775         public static function getUrlMatch(string $url1, string $url2)
776         {
777                 if (($url1 == "") || ($url2 == "")) {
778                         return "";
779                 }
780
781                 $url1 = Strings::normaliseLink($url1);
782                 $url2 = Strings::normaliseLink($url2);
783
784                 $parts1 = parse_url($url1);
785                 $parts2 = parse_url($url2);
786
787                 if (!isset($parts1["host"]) || !isset($parts2["host"])) {
788                         return "";
789                 }
790
791                 if (empty($parts1["scheme"])) {
792                         $parts1["scheme"] = '';
793                 }
794                 if (empty($parts2["scheme"])) {
795                         $parts2["scheme"] = '';
796                 }
797
798                 if ($parts1["scheme"] != $parts2["scheme"]) {
799                         return "";
800                 }
801
802                 if (empty($parts1["host"])) {
803                         $parts1["host"] = '';
804                 }
805                 if (empty($parts2["host"])) {
806                         $parts2["host"] = '';
807                 }
808
809                 if ($parts1["host"] != $parts2["host"]) {
810                         return "";
811                 }
812
813                 if (empty($parts1["port"])) {
814                         $parts1["port"] = '';
815                 }
816                 if (empty($parts2["port"])) {
817                         $parts2["port"] = '';
818                 }
819
820                 if ($parts1["port"] != $parts2["port"]) {
821                         return "";
822                 }
823
824                 $match = $parts1["scheme"]."://".$parts1["host"];
825
826                 if ($parts1["port"]) {
827                         $match .= ":".$parts1["port"];
828                 }
829
830                 if (empty($parts1["path"])) {
831                         $parts1["path"] = '';
832                 }
833                 if (empty($parts2["path"])) {
834                         $parts2["path"] = '';
835                 }
836
837                 $pathparts1 = explode("/", $parts1["path"]);
838                 $pathparts2 = explode("/", $parts2["path"]);
839
840                 $i = 0;
841                 $path = "";
842                 do {
843                         $path1 = $pathparts1[$i] ?? '';
844                         $path2 = $pathparts2[$i] ?? '';
845
846                         if ($path1 == $path2) {
847                                 $path .= $path1."/";
848                         }
849                 } while (($path1 == $path2) && ($i++ <= count($pathparts1)));
850
851                 $match .= $path;
852
853                 return Strings::normaliseLink($match);
854         }
855
856         /**
857          * Glue url parts together
858          *
859          * @param array $parsed URL parts
860          *
861          * @return string The glued URL
862          */
863         public static function unparseURL(array $parsed)
864         {
865                 $get = function ($key) use ($parsed) {
866                         return isset($parsed[$key]) ? $parsed[$key] : null;
867                 };
868
869                 $pass      = $get('pass');
870                 $user      = $get('user');
871                 $userinfo  = $pass !== null ? "$user:$pass" : $user;
872                 $port      = $get('port');
873                 $scheme    = $get('scheme');
874                 $query     = $get('query');
875                 $fragment  = $get('fragment');
876                 $authority = ($userinfo !== null ? $userinfo."@" : '') .
877                                                 $get('host') .
878                                                 ($port ? ":$port" : '');
879
880                 return  (strlen($scheme) ? $scheme.":" : '') .
881                         (strlen($authority) ? "//".$authority : '') .
882                         $get('path') .
883                         (strlen($query) ? "?".$query : '') .
884                         (strlen($fragment) ? "#".$fragment : '');
885         }
886
887
888         /**
889          * Switch the scheme of an url between http and https
890          *
891          * @param string $url URL
892          *
893          * @return string switched URL
894          */
895         public static function switchScheme(string $url)
896         {
897                 $scheme = parse_url($url, PHP_URL_SCHEME);
898                 if (empty($scheme)) {
899                         return $url;
900                 }
901
902                 if ($scheme === 'http') {
903                         $url = str_replace('http://', 'https://', $url);
904                 } elseif ($scheme === 'https') {
905                         $url = str_replace('https://', 'http://', $url);
906                 }
907
908                 return $url;
909         }
910
911         /**
912          * Adds query string parameters to the provided URI. Replace the value of existing keys.
913          *
914          * @param string $path
915          * @param array  $additionalParams Associative array of parameters
916          * @return string
917          */
918         public static function appendQueryParam(string $path, array $additionalParams)
919         {
920                 $parsed = parse_url($path);
921
922                 $params = [];
923                 if (!empty($parsed['query'])) {
924                         parse_str($parsed['query'], $params);
925                 }
926
927                 $params = array_merge($params, $additionalParams);
928
929                 $parsed['query'] = http_build_query($params);
930
931                 return self::unparseURL($parsed);
932         }
933
934         /**
935          * Generates ETag and Last-Modified response headers and checks them against
936          * If-None-Match and If-Modified-Since request headers if present.
937          *
938          * Blocking function, sends 304 headers and exits if check passes.
939          *
940          * @param string $etag          The page etag
941          * @param string $last_modified The page last modification UTC date
942          * @throws \Exception
943          */
944         public static function checkEtagModified(string $etag, string $last_modified)
945         {
946                 $last_modified = DateTimeFormat::utc($last_modified, 'D, d M Y H:i:s') . ' GMT';
947
948                 /**
949                  * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
950                  */
951                 $if_none_match     = filter_input(INPUT_SERVER, 'HTTP_IF_NONE_MATCH');
952                 $if_modified_since = filter_input(INPUT_SERVER, 'HTTP_IF_MODIFIED_SINCE');
953                 $flag_not_modified = null;
954                 if ($if_none_match) {
955                         $result = [];
956                         preg_match('/^(?:W\/")?([^"]+)"?$/i', $etag, $result);
957                         $etagTrimmed = $result[1];
958                         // Lazy exact ETag match, could check weak/strong ETags
959                         $flag_not_modified = $if_none_match == '*' || strpos($if_none_match, $etagTrimmed) !== false;
960                 }
961
962                 if ($if_modified_since && (!$if_none_match || $flag_not_modified)) {
963                         // Lazy exact Last-Modified match, could check If-Modified-Since validity
964                         $flag_not_modified = $if_modified_since == $last_modified;
965                 }
966
967                 header('Etag: ' . $etag);
968                 header('Last-Modified: ' . $last_modified);
969
970                 if ($flag_not_modified) {
971                         header("HTTP/1.1 304 Not Modified");
972                         exit;
973                 }
974         }
975 }