]> git.mxchange.org Git - friendica.git/blob - src/Model/GServer.php
Merge remote-tracking branch 'upstream/develop' into move-delivery
[friendica.git] / src / Model / GServer.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2022, the Friendica project
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\Model;
23
24 use DOMDocument;
25 use DOMXPath;
26 use Exception;
27 use Friendica\Core\Logger;
28 use Friendica\Core\Protocol;
29 use Friendica\Core\System;
30 use Friendica\Core\Worker;
31 use Friendica\Database\Database;
32 use Friendica\Database\DBA;
33 use Friendica\DI;
34 use Friendica\Module\Register;
35 use Friendica\Network\HTTPClient\Client\HttpClientAccept;
36 use Friendica\Network\HTTPClient\Client\HttpClientOptions;
37 use Friendica\Network\HTTPClient\Capability\ICanHandleHttpResponses;
38 use Friendica\Network\Probe;
39 use Friendica\Protocol\ActivityPub;
40 use Friendica\Protocol\Relay;
41 use Friendica\Util\DateTimeFormat;
42 use Friendica\Util\JsonLD;
43 use Friendica\Util\Network;
44 use Friendica\Util\Strings;
45 use Friendica\Util\XML;
46 use Friendica\Network\HTTPException;
47 use GuzzleHttp\Psr7\Uri;
48
49 /**
50  * This class handles GServer related functions
51  */
52 class GServer
53 {
54         // Directory types
55         const DT_NONE = 0;
56         const DT_POCO = 1;
57         const DT_MASTODON = 2;
58
59         // Methods to detect server types
60
61         // Non endpoint specific methods
62         const DETECT_MANUAL = 0;
63         const DETECT_HEADER = 1;
64         const DETECT_BODY = 2;
65         const DETECT_HOST_META = 3;
66         const DETECT_CONTACTS = 4;
67         const DETECT_AP_ACTOR = 5;
68         const DETECT_AP_COLLECTION = 6;
69
70         const DETECT_UNSPECIFIC = [self::DETECT_MANUAL, self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_HOST_META, self::DETECT_CONTACTS, self::DETECT_AP_ACTOR];
71
72         // Implementation specific endpoints
73         // @todo Possibly add Lemmy detection via the endpoint /api/v3/site
74         const DETECT_FRIENDIKA = 10;
75         const DETECT_FRIENDICA = 11;
76         const DETECT_STATUSNET = 12;
77         const DETECT_GNUSOCIAL = 13;
78         const DETECT_CONFIG_JSON = 14; // Statusnet, GNU Social, Older Hubzilla/Redmatrix
79         const DETECT_SITEINFO_JSON = 15; // Newer Hubzilla
80         const DETECT_MASTODON_API = 16;
81         const DETECT_STATUS_PHP = 17; // Nextcloud
82         const DETECT_V1_CONFIG = 18;
83         const DETECT_PUMPIO = 19; // Deprecated
84         const DETECT_SYSTEM_ACTOR = 20; // Mistpark, Osada, Roadhouse, Zap
85
86         // Standardized endpoints
87         const DETECT_STATISTICS_JSON = 100;
88         const DETECT_NODEINFO_1 = 101;
89         const DETECT_NODEINFO_2 = 102;
90         const DETECT_NODEINFO_210 = 103;
91
92         /**
93          * Check for the existance of a server and adds it in the background if not existant
94          *
95          * @param string $url
96          * @param boolean $only_nodeinfo
97          *
98          * @return void
99          */
100         public static function add(string $url, bool $only_nodeinfo = false)
101         {
102                 if (self::getID($url, false)) {
103                         return;
104                 }
105
106                 Worker::add(Worker::PRIORITY_LOW, 'UpdateGServer', $url, $only_nodeinfo);
107         }
108
109         /**
110          * Get the ID for the given server URL
111          *
112          * @param string $url
113          * @param boolean $no_check Don't check if the server hadn't been found
114          *
115          * @return int|null gserver id or NULL on empty URL or failed check
116          */
117         public static function getID(string $url, bool $no_check = false): ?int
118         {
119                 $url = self::cleanURL($url);
120
121                 if (empty($url)) {
122                         return null;
123                 }
124
125                 $gserver = DBA::selectFirst('gserver', ['id'], ['nurl' => Strings::normaliseLink($url)]);
126                 if (DBA::isResult($gserver)) {
127                         Logger::debug('Got ID for URL', ['id' => $gserver['id'], 'url' => $url, 'callstack' => System::callstack(20)]);
128                         return $gserver['id'];
129                 }
130
131                 if ($no_check || !self::check($url)) {
132                         return null;
133                 }
134
135                 return self::getID($url, true);
136         }
137
138         /**
139          * Retrieves all the servers which base domain are matching the provided domain pattern
140          *
141          * The pattern is a simple fnmatch() pattern with ? for single wildcard and * for multiple wildcard
142          *
143          * @param string $pattern
144          *
145          * @return array
146          *
147          * @throws Exception
148          */
149         public static function listByDomainPattern(string $pattern): array
150         {
151                 $likePattern = 'http://' . strtr($pattern, ['_' => '\_', '%' => '\%', '?' => '_', '*' => '%']);
152
153                 // The SUBSTRING_INDEX returns everything before the eventual third /, which effectively trims an
154                 // eventual server path and keep only the server domain which we're matching against the pattern.
155                 $sql = "SELECT `gserver`.*, COUNT(*) AS `contacts`
156                         FROM `gserver`
157                         LEFT JOIN `contact` ON `gserver`.`id` = `contact`.`gsid`
158                         WHERE SUBSTRING_INDEX(`gserver`.`nurl`, '/', 3) LIKE ?
159                         AND NOT `gserver`.`failed`
160                         GROUP BY `gserver`.`id`";
161
162                 $stmt = DI::dba()->p($sql, $likePattern);
163
164                 return DI::dba()->toArray($stmt);
165         }
166
167         /**
168          * Checks if the given server is reachable
169          *
170          * @param array $contact Contact that should be checked
171          *
172          * @return boolean 'true' if server seems vital
173          */
174         public static function reachable(array $contact): bool
175         {
176                 if (!empty($contact['gsid'])) {
177                         $gsid = $contact['gsid'];
178                 } elseif (!empty($contact['baseurl'])) {
179                         $server = $contact['baseurl'];
180                 } elseif ($contact['network'] == Protocol::DIASPORA) {
181                         $parts = parse_url($contact['url']);
182                         unset($parts['path']);
183                         $server = (string)Uri::fromParts($parts);
184                 } else {
185                         return true;
186                 }
187
188                 if (!empty($gsid)) {
189                         $condition = ['id' => $gsid];
190                 } else {
191                         $condition = ['nurl' => Strings::normaliseLink($server)];
192                 }
193
194                 $gserver = DBA::selectFirst('gserver', ['url', 'next_contact', 'failed', 'network'], $condition);
195                 if (empty($gserver)) {
196                         $reachable = true;
197                 } else {
198                         $reachable = !$gserver['failed'] && in_array($gserver['network'], Protocol::FEDERATED);
199                         $server    = $gserver['url'];
200                 }
201
202                 if (!empty($server) && (empty($gserver) || strtotime($gserver['next_contact']) < time())) {
203                         Worker::add(Worker::PRIORITY_LOW, 'UpdateGServer', $server, false);
204                 }
205
206                 return $reachable;
207         }
208
209         /**
210          * Calculate the next update day
211          *
212          * @param bool $success
213          * @param string $created
214          * @param string $last_contact
215          * @param bool $undetected
216          *
217          * @return string
218          *
219          * @throws Exception
220          */
221         public static function getNextUpdateDate(bool $success, string $created = '', string $last_contact = '', bool $undetected = false): string
222         {
223                 // On successful contact process check again next week when it is a detected system.
224                 // When we haven't detected the system, it could be a static website or a really old system.
225                 if ($success) {
226                         return DateTimeFormat::utc($undetected ? 'now +1 month' : 'now +7 day');
227                 }
228
229                 $now = strtotime(DateTimeFormat::utcNow());
230
231                 if ($created > $last_contact) {
232                         $contact_time = strtotime($created);
233                 } else {
234                         $contact_time = strtotime($last_contact);
235                 }
236
237                 // If the last contact was less than 6 hours before then try again in 6 hours
238                 if (($now - $contact_time) < (60 * 60 * 6)) {
239                         return DateTimeFormat::utc('now +6 hour');
240                 }
241
242                 // If the last contact was less than 12 hours before then try again in 12 hours
243                 if (($now - $contact_time) < (60 * 60 * 12)) {
244                         return DateTimeFormat::utc('now +12 hour');
245                 }
246
247                 // If the last contact was less than 24 hours before then try tomorrow again
248                 if (($now - $contact_time) < (60 * 60 * 24)) {
249                         return DateTimeFormat::utc('now +1 day');
250                 }
251
252                 // If the last contact was less than a week before then try again in a week
253                 if (($now - $contact_time) < (60 * 60 * 24 * 7)) {
254                         return DateTimeFormat::utc('now +1 week');
255                 }
256
257                 // If the last contact was less than two weeks before then try again in two week
258                 if (($now - $contact_time) < (60 * 60 * 24 * 14)) {
259                         return DateTimeFormat::utc('now +2 week');
260                 }
261
262                 // If the last contact was less than a month before then try again in a month
263                 if (($now - $contact_time) < (60 * 60 * 24 * 30)) {
264                         return DateTimeFormat::utc('now +1 month');
265                 }
266
267                 // The system hadn't been successul contacted for more than a month, so try again in three months
268                 return DateTimeFormat::utc('now +3 month');
269         }
270
271         /**
272          * Checks the state of the given server.
273          *
274          * @param string  $server_url    URL of the given server
275          * @param string  $network       Network value that is used, when detection failed
276          * @param boolean $force         Force an update.
277          * @param boolean $only_nodeinfo Only use nodeinfo for server detection
278          *
279          * @return boolean 'true' if server seems vital
280          */
281         public static function check(string $server_url, string $network = '', bool $force = false, bool $only_nodeinfo = false): bool
282         {
283                 $server_url = self::cleanURL($server_url);
284                 if ($server_url == '') {
285                         return false;
286                 }
287
288                 $gserver = DBA::selectFirst('gserver', [], ['nurl' => Strings::normaliseLink($server_url)]);
289                 if (DBA::isResult($gserver)) {
290                         if ($gserver['created'] <= DBA::NULL_DATETIME) {
291                                 $fields = ['created' => DateTimeFormat::utcNow()];
292                                 $condition = ['nurl' => Strings::normaliseLink($server_url)];
293                                 self::update($fields, $condition);
294                         }
295
296                         if (!$force && (strtotime($gserver['next_contact']) > time())) {
297                                 Logger::info('No update needed', ['server' => $server_url]);
298                                 return (!$gserver['failed']);
299                         }
300                         Logger::info('Server is outdated. Start discovery.', ['Server' => $server_url, 'Force' => $force]);
301                 } else {
302                         Logger::info('Server is unknown. Start discovery.', ['Server' => $server_url]);
303                 }
304
305                 return self::detect($server_url, $network, $only_nodeinfo);
306         }
307
308         /**
309          * Set failed server status
310          *
311          * @param string $url
312          */
313         public static function setFailure(string $url)
314         {
315                 $gserver = DBA::selectFirst('gserver', [], ['nurl' => Strings::normaliseLink($url)]);
316                 if (DBA::isResult($gserver)) {
317                         $next_update = self::getNextUpdateDate(false, $gserver['created'], $gserver['last_contact']);
318                         self::update(['url' => $url, 'failed' => true, 'last_failure' => DateTimeFormat::utcNow(),
319                         'next_contact' => $next_update, 'network' => Protocol::PHANTOM, 'detection-method' => null],
320                         ['nurl' => Strings::normaliseLink($url)]);
321                         Logger::info('Set failed status for existing server', ['url' => $url]);
322                         return;
323                 }
324                 self::insert(['url' => $url, 'nurl' => Strings::normaliseLink($url),
325                         'network' => Protocol::PHANTOM, 'created' => DateTimeFormat::utcNow(),
326                         'failed' => true, 'last_failure' => DateTimeFormat::utcNow()]);
327                 Logger::info('Set failed status for new server', ['url' => $url]);
328         }
329
330         /**
331          * Remove unwanted content from the given URL
332          *
333          * @param string $dirtyUrl
334          *
335          * @return string cleaned URL
336          * @throws Exception
337          */
338         public static function cleanURL(string $dirtyUrl): string
339         {
340                 try {
341                         $url = str_replace('/index.php', '', trim($dirtyUrl, '/'));
342                         return (string)(new Uri($url))->withUserInfo('')->withQuery('')->withFragment('');
343                 } catch (\Throwable $e) {
344                         Logger::warning('Invalid URL', ['dirtyUrl' => $dirtyUrl, 'url' => $url]);
345                         return '';
346                 }
347         }
348
349         /**
350          * Detect server data (type, protocol, version number, ...)
351          * The detected data is then updated or inserted in the gserver table.
352          *
353          * @param string  $url           URL of the given server
354          * @param string  $network       Network value that is used, when detection failed
355          * @param boolean $only_nodeinfo Only use nodeinfo for server detection
356          *
357          * @return boolean 'true' if server could be detected
358          */
359         public static function detect(string $url, string $network = '', bool $only_nodeinfo = false): bool
360         {
361                 Logger::info('Detect server type', ['server' => $url]);
362
363                 $original_url = $url;
364
365                 // Remove URL content that is not supposed to exist for a server url
366                 $url = rtrim(self::cleanURL($url), '/');
367                 if (empty($url)) {
368                         Logger::notice('Empty URL.');
369                         return false;
370                 }
371
372                 // If the URL missmatches, then we mark the old entry as failure
373                 if (!Strings::compareLink($url, $original_url)) {
374                         self::setFailure($original_url);
375                         if (!self::getID($url, true)) {
376                                 self::detect($url, $network, $only_nodeinfo);
377                         }
378                         return false;
379                 }
380
381                 $valid_url = Network::isUrlValid($url);
382                 if (!$valid_url) {
383                         self::setFailure($url);
384                         return false;
385                 } else {
386                         $valid_url = rtrim($valid_url, '/');
387                 }
388
389                 if (!Strings::compareLink($url, $valid_url)) {
390                         // We only follow redirects when the path stays the same or the target url has no path.
391                         // Some systems have got redirects on their landing page to a single account page. This check handles it.
392                         if (((parse_url($url, PHP_URL_HOST) != parse_url($valid_url, PHP_URL_HOST)) && (parse_url($url, PHP_URL_PATH) == parse_url($valid_url, PHP_URL_PATH))) ||
393                                 (((parse_url($url, PHP_URL_HOST) != parse_url($valid_url, PHP_URL_HOST)) || (parse_url($url, PHP_URL_PATH) != parse_url($valid_url, PHP_URL_PATH))) && empty(parse_url($valid_url, PHP_URL_PATH)))) {
394                                 Logger::debug('Found redirect. Mark old entry as failure', ['old' => $url, 'new' => $valid_url]);
395                                 self::setFailure($url);
396                                 if (!self::getID($valid_url, true)) {
397                                         self::detect($valid_url, $network, $only_nodeinfo);
398                                 }
399                                 return false;
400                         }
401
402                         if ((parse_url($url, PHP_URL_HOST) != parse_url($valid_url, PHP_URL_HOST)) && (parse_url($url, PHP_URL_PATH) != parse_url($valid_url, PHP_URL_PATH)) &&
403                                 (parse_url($url, PHP_URL_PATH) == '')) {
404                                 Logger::debug('Found redirect. Mark old entry as failure and redirect to the basepath.', ['old' => $url, 'new' => $valid_url]);
405                                 $parts = parse_url($valid_url);
406                                 unset($parts['path']);
407                                 $valid_url = (string)Uri::fromParts($parts);
408
409                                 self::setFailure($url);
410                                 if (!self::getID($valid_url, true)) {
411                                         self::detect($valid_url, $network, $only_nodeinfo);
412                                 }
413                                 return false;
414                         }
415                         Logger::debug('Found redirect, but ignore it.', ['old' => $url, 'new' => $valid_url]);
416                 }
417
418                 if ((parse_url($url, PHP_URL_HOST) == parse_url($valid_url, PHP_URL_HOST)) &&
419                         (parse_url($url, PHP_URL_PATH) == parse_url($valid_url, PHP_URL_PATH)) &&
420                         (parse_url($url, PHP_URL_SCHEME) != parse_url($valid_url, PHP_URL_SCHEME))) {
421                         $url = $valid_url;
422                 }
423
424                 $in_webroot = empty(parse_url($url, PHP_URL_PATH));
425
426                 // When a nodeinfo is present, we don't need to dig further
427                 $curlResult = DI::httpClient()->get($url . '/.well-known/x-nodeinfo2', HttpClientAccept::JSON);
428                 if ($curlResult->isTimeout()) {
429                         self::setFailure($url);
430                         return false;
431                 }
432
433                 $serverdata = self::parseNodeinfo210($curlResult);
434                 if (empty($serverdata)) {
435                         $curlResult = DI::httpClient()->get($url . '/.well-known/nodeinfo', HttpClientAccept::JSON);
436                         $serverdata = self::fetchNodeinfo($url, $curlResult);
437                 }
438
439                 if ($only_nodeinfo && empty($serverdata)) {
440                         Logger::info('Invalid nodeinfo in nodeinfo-mode, server is marked as failure', ['url' => $url]);
441                         self::setFailure($url);
442                         return false;
443                 } elseif (empty($serverdata)) {
444                         $serverdata = ['detection-method' => self::DETECT_MANUAL, 'network' => Protocol::PHANTOM, 'platform' => '', 'version' => '', 'site_name' => '', 'info' => ''];
445                 }
446
447                 // When there is no Nodeinfo, then use some protocol specific endpoints
448                 if ($serverdata['network'] == Protocol::PHANTOM) {
449                         if ($in_webroot) {
450                                 // Fetch the landing page, possibly it reveals some data
451                                 $accept = 'application/activity+json,application/ld+json,application/json,*/*;q=0.9';
452                                 $curlResult = DI::httpClient()->get($url, $accept);
453                                 if (!$curlResult->isSuccess() && $curlResult->getReturnCode() == '406') {
454                                         $curlResult = DI::httpClient()->get($url, HttpClientAccept::HTML);
455                                         $html_fetched = true;
456                                 } else {
457                                         $html_fetched = false;
458                                 }
459
460                                 if ($curlResult->isSuccess()) {
461                                         $json = json_decode($curlResult->getBody(), true);
462                                         if (!empty($json) && is_array($json)) {
463                                                 $data = self::fetchDataFromSystemActor($json, $serverdata);
464                                                 $serverdata = $data['server'];
465                                                 $systemactor = $data['actor'];
466                                                 if (!$html_fetched && !in_array($serverdata['detection-method'], [self::DETECT_SYSTEM_ACTOR, self::DETECT_AP_COLLECTION])) {
467                                                         $curlResult = DI::httpClient()->get($url, HttpClientAccept::HTML);
468                                                 }
469                                         } elseif (!$html_fetched && (strlen($curlResult->getBody()) < 1000)) {
470                                                 $curlResult = DI::httpClient()->get($url, HttpClientAccept::HTML);
471                                         }
472
473                                         if ($serverdata['detection-method'] != self::DETECT_SYSTEM_ACTOR) {
474                                                 $serverdata = self::analyseRootHeader($curlResult, $serverdata);
475                                                 $serverdata = self::analyseRootBody($curlResult, $serverdata);
476                                         }
477                                 }
478
479                                 if (!$curlResult->isSuccess() || empty($curlResult->getBody())) {
480                                         self::setFailure($url);
481                                         return false;
482                                 }
483
484                                 if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
485                                         $serverdata = self::detectMastodonAlikes($url, $serverdata);
486                                 }
487                         }
488
489                         // All following checks are done for systems that always have got a "host-meta" endpoint.
490                         // With this check we don't have to waste time and ressources for dead systems.
491                         // Also this hopefully prevents us from receiving abuse messages.
492                         if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
493                                 $validHostMeta = self::validHostMeta($url);
494                         } else {
495                                 $validHostMeta = false;
496                         }
497
498                         if ($validHostMeta) {
499                                 if (in_array($serverdata['detection-method'], [self::DETECT_MANUAL, self::DETECT_HEADER, self::DETECT_BODY])) {
500                                         $serverdata['detection-method'] = self::DETECT_HOST_META;
501                                 }
502
503                                 if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
504                                         $serverdata = self::detectFriendica($url, $serverdata);
505                                 }
506
507                                 // The following systems have to be installed in the root directory.
508                                 if ($in_webroot) {
509                                         // the 'siteinfo.json' is some specific endpoint of Hubzilla and Red
510                                         if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
511                                                 $serverdata = self::fetchSiteinfo($url, $serverdata);
512                                         }
513
514                                         // The 'siteinfo.json' doesn't seem to be present on older Hubzilla installations, so we check other endpoints as well
515                                         if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
516                                                 $serverdata = self::detectHubzilla($url, $serverdata);
517                                         }
518
519                                         if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
520                                                 $serverdata = self::detectPeertube($url, $serverdata);
521                                         }
522
523                                         if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
524                                                 $serverdata = self::detectGNUSocial($url, $serverdata);
525                                         }
526                                 }
527                         } elseif (in_array($serverdata['platform'], ['friendica', 'friendika']) && in_array($serverdata['detection-method'], array_merge(self::DETECT_UNSPECIFIC, [self::DETECT_SYSTEM_ACTOR]))) {
528                                 $serverdata = self::detectFriendica($url, $serverdata);
529                         }
530
531                         if (($serverdata['network'] == Protocol::PHANTOM) || in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
532                                 $serverdata = self::detectNextcloud($url, $serverdata, $validHostMeta);
533                         }
534
535                         // When nodeinfo isn't present, we use the older 'statistics.json' endpoint
536                         // Since this endpoint is only rarely used, we query it at a later time
537                         if (in_array($serverdata['detection-method'], array_merge(self::DETECT_UNSPECIFIC, [self::DETECT_FRIENDICA, self::DETECT_CONFIG_JSON]))) {
538                                 $serverdata = self::fetchStatistics($url, $serverdata);
539                         }
540                 }
541
542                 // When we hadn't been able to detect the network type, we use the hint from the parameter
543                 if (($serverdata['network'] == Protocol::PHANTOM) && !empty($network)) {
544                         $serverdata['network'] = $network;
545                 }
546
547                 // Most servers aren't installed in a subdirectory, so we declare this entry as failed
548                 if (($serverdata['network'] == Protocol::PHANTOM) && !empty(parse_url($url, PHP_URL_PATH)) && in_array($serverdata['detection-method'], [self::DETECT_MANUAL])) {
549                         self::setFailure($url);
550                         return false;
551                 }
552
553                 $serverdata['url'] = $url;
554                 $serverdata['nurl'] = Strings::normaliseLink($url);
555
556                 // We have to prevent an endless loop here.
557                 // When a server is new, then there is no gserver entry yet.
558                 // But in "detectNetworkViaContacts" it could happen that a contact is updated,
559                 // and this can call this function here as well.
560                 if (self::getID($url, true) && (in_array($serverdata['network'], [Protocol::PHANTOM, Protocol::FEED]) ||
561                         in_array($serverdata['detection-method'], [self::DETECT_MANUAL, self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_HOST_META]))) {
562                         $serverdata = self::detectNetworkViaContacts($url, $serverdata);
563                 }
564
565                 // Detect the directory type
566                 $serverdata['directory-type'] = self::DT_NONE;
567
568                 if (in_array($serverdata['network'], Protocol::FEDERATED)) {
569                         $serverdata = self::checkMastodonDirectory($url, $serverdata);
570
571                         if ($serverdata['directory-type'] == self::DT_NONE) {
572                                 $serverdata = self::checkPoCo($url, $serverdata);
573                         }
574                 }
575
576                 if ($serverdata['network'] == Protocol::ACTIVITYPUB) {
577                         $serverdata = self::fetchWeeklyUsage($url, $serverdata);
578                 }
579
580                 $serverdata['registered-users'] = $serverdata['registered-users'] ?? 0;
581
582                 // Numbers above a reasonable value (10 millions) are ignored
583                 if ($serverdata['registered-users'] > 10000000) {
584                         $serverdata['registered-users'] = 0;
585                 }
586
587                 // On an active server there has to be at least a single user
588                 if (!in_array($serverdata['network'], [Protocol::PHANTOM, Protocol::FEED]) && ($serverdata['registered-users'] <= 0)) {
589                         $serverdata['registered-users'] = 1;
590                 } elseif (in_array($serverdata['network'], [Protocol::PHANTOM, Protocol::FEED])) {
591                         $serverdata['registered-users'] = 0;
592                 }
593
594                 $serverdata['next_contact'] = self::getNextUpdateDate(true, '', '', in_array($serverdata['network'], [Protocol::PHANTOM, Protocol::FEED]));
595
596                 $serverdata['last_contact'] = DateTimeFormat::utcNow();
597                 $serverdata['failed'] = false;
598
599                 $gserver = DBA::selectFirst('gserver', ['network'], ['nurl' => Strings::normaliseLink($url)]);
600                 if (!DBA::isResult($gserver)) {
601                         $serverdata['created'] = DateTimeFormat::utcNow();
602                         $ret = self::insert($serverdata);
603                         $id = DBA::lastInsertId();
604                 } else {
605                         $ret = self::update($serverdata, ['nurl' => $serverdata['nurl']]);
606                         $gserver = DBA::selectFirst('gserver', ['id'], ['nurl' => $serverdata['nurl']]);
607                         if (DBA::isResult($gserver)) {
608                                 $id = $gserver['id'];
609                         }
610                 }
611
612                 // Count the number of known contacts from this server
613                 if (!empty($id) && !in_array($serverdata['network'], [Protocol::PHANTOM, Protocol::FEED])) {
614                         $apcontacts = DBA::count('apcontact', ['gsid' => $id]);
615                         $contacts = DBA::count('contact', ['uid' => 0, 'gsid' => $id, 'failed' => false]);
616                         $max_users = max($apcontacts, $contacts);
617                         if ($max_users > $serverdata['registered-users']) {
618                                 Logger::info('Update registered users', ['id' => $id, 'url' => $serverdata['nurl'], 'registered-users' => $max_users]);
619                                 self::update(['registered-users' => $max_users], ['id' => $id]);
620                         }
621
622                         if (empty($serverdata['active-month-users'])) {
623                                 $contacts = DBA::count('contact', ["`uid` = ? AND `gsid` = ? AND NOT `failed` AND `last-item` > ?", 0, $id, DateTimeFormat::utc('now - 30 days')]);
624                                 if ($contacts > 0) {
625                                         Logger::info('Update monthly users', ['id' => $id, 'url' => $serverdata['nurl'], 'monthly-users' => $contacts]);
626                                         self::update(['active-month-users' => $contacts], ['id' => $id]);
627                                 }
628                         }
629
630                         if (empty($serverdata['active-halfyear-users'])) {
631                                 $contacts = DBA::count('contact', ["`uid` = ? AND `gsid` = ? AND NOT `failed` AND `last-item` > ?", 0, $id, DateTimeFormat::utc('now - 180 days')]);
632                                 if ($contacts > 0) {
633                                         Logger::info('Update halfyear users', ['id' => $id, 'url' => $serverdata['nurl'], 'halfyear-users' => $contacts]);
634                                         self::update(['active-halfyear-users' => $contacts], ['id' => $id]);
635                                 }
636                         }
637                 }
638
639                 if (in_array($serverdata['network'], [Protocol::DFRN, Protocol::DIASPORA])) {
640                         self::discoverRelay($url);
641                 }
642
643                 if (!empty($systemactor)) {
644                         $contact = Contact::getByURL($systemactor, true, ['gsid', 'baseurl', 'id', 'network', 'url', 'name']);
645                         Logger::debug('Fetched system actor',  ['url' => $url, 'gsid' => $id, 'contact' => $contact]);
646                 }
647
648                 return $ret;
649         }
650
651         /**
652          * Fetch relay data from a given server url
653          *
654          * @param string $server_url address of the server
655          *
656          * @return void
657          *
658          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
659          */
660         private static function discoverRelay(string $server_url)
661         {
662                 Logger::info('Discover relay data', ['server' => $server_url]);
663
664                 $curlResult = DI::httpClient()->get($server_url . '/.well-known/x-social-relay', HttpClientAccept::JSON);
665                 if (!$curlResult->isSuccess()) {
666                         return;
667                 }
668
669                 $data = json_decode($curlResult->getBody(), true);
670                 if (!is_array($data)) {
671                         return;
672                 }
673
674                 // Sanitize incoming data, see https://github.com/friendica/friendica/issues/8565
675                 $data['subscribe'] = (bool)$data['subscribe'] ?? false;
676
677                 if (!$data['subscribe'] || empty($data['scope']) || !in_array(strtolower($data['scope']), ['all', 'tags'])) {
678                         $data['scope'] = '';
679                         $data['subscribe'] = false;
680                         $data['tags'] = [];
681                 }
682
683                 $gserver = DBA::selectFirst('gserver', ['id', 'url', 'network', 'relay-subscribe', 'relay-scope'], ['nurl' => Strings::normaliseLink($server_url)]);
684                 if (!DBA::isResult($gserver)) {
685                         return;
686                 }
687
688                 if (($gserver['relay-subscribe'] != $data['subscribe']) || ($gserver['relay-scope'] != $data['scope'])) {
689                         $fields = ['relay-subscribe' => $data['subscribe'], 'relay-scope' => $data['scope']];
690                         self::update($fields, ['id' => $gserver['id']]);
691                 }
692
693                 DBA::delete('gserver-tag', ['gserver-id' => $gserver['id']]);
694
695                 if ($data['scope'] == 'tags') {
696                         // Avoid duplicates
697                         $tags = [];
698                         foreach ($data['tags'] as $tag) {
699                                 $tag = mb_strtolower($tag);
700                                 if (strlen($tag) < 100) {
701                                         $tags[$tag] = $tag;
702                                 }
703                         }
704
705                         foreach ($tags as $tag) {
706                                 DBA::insert('gserver-tag', ['gserver-id' => $gserver['id'], 'tag' => $tag], Database::INSERT_IGNORE);
707                         }
708                 }
709
710                 // Create or update the relay contact
711                 $fields = [];
712                 if (isset($data['protocols'])) {
713                         if (isset($data['protocols']['diaspora'])) {
714                                 $fields['network'] = Protocol::DIASPORA;
715
716                                 if (isset($data['protocols']['diaspora']['receive'])) {
717                                         $fields['batch'] = $data['protocols']['diaspora']['receive'];
718                                 } elseif (is_string($data['protocols']['diaspora'])) {
719                                         $fields['batch'] = $data['protocols']['diaspora'];
720                                 }
721                         }
722
723                         if (isset($data['protocols']['dfrn'])) {
724                                 $fields['network'] = Protocol::DFRN;
725
726                                 if (isset($data['protocols']['dfrn']['receive'])) {
727                                         $fields['batch'] = $data['protocols']['dfrn']['receive'];
728                                 } elseif (is_string($data['protocols']['dfrn'])) {
729                                         $fields['batch'] = $data['protocols']['dfrn'];
730                                 }
731                         }
732
733                         if (isset($data['protocols']['activitypub'])) {
734                                 $fields['network'] = Protocol::ACTIVITYPUB;
735
736                                 if (!empty($data['protocols']['activitypub']['actor'])) {
737                                         $fields['url'] = $data['protocols']['activitypub']['actor'];
738                                 }
739                                 if (!empty($data['protocols']['activitypub']['receive'])) {
740                                         $fields['batch'] = $data['protocols']['activitypub']['receive'];
741                                 }
742                         }
743                 }
744
745                 Logger::info('Discovery ended', ['server' => $server_url, 'data' => $fields]);
746
747                 Relay::updateContact($gserver, $fields);
748         }
749
750         /**
751          * Fetch server data from '/statistics.json' on the given server
752          *
753          * @param string $url URL of the given server
754          *
755          * @return array server data
756          */
757         private static function fetchStatistics(string $url, array $serverdata): array
758         {
759                 $curlResult = DI::httpClient()->get($url . '/statistics.json', HttpClientAccept::JSON);
760                 if (!$curlResult->isSuccess()) {
761                         return $serverdata;
762                 }
763
764                 $data = json_decode($curlResult->getBody(), true);
765                 if (empty($data)) {
766                         return $serverdata;
767                 }
768
769                 // Some AP enabled systems return activity data that we don't expect here.
770                 if (strpos($curlResult->getContentType(), 'application/activity+json') !== false) {
771                         return $serverdata;
772                 }
773
774                 $valid = false;
775                 $old_serverdata = $serverdata;
776
777                 $serverdata['detection-method'] = self::DETECT_STATISTICS_JSON;
778
779                 if (!empty($data['version'])) {
780                         $valid = true;
781                         $serverdata['version'] = $data['version'];
782                         // Version numbers on statistics.json are presented with additional info, e.g.:
783                         // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
784                         $serverdata['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $serverdata['version']);
785                 }
786
787                 if (!empty($data['name'])) {
788                         $valid = true;
789                         $serverdata['site_name'] = $data['name'];
790                 }
791
792                 if (!empty($data['network'])) {
793                         $valid = true;
794                         $serverdata['platform'] = strtolower($data['network']);
795
796                         if ($serverdata['platform'] == 'diaspora') {
797                                 $serverdata['network'] = Protocol::DIASPORA;
798                         } elseif ($serverdata['platform'] == 'friendica') {
799                                 $serverdata['network'] = Protocol::DFRN;
800                         } elseif ($serverdata['platform'] == 'hubzilla') {
801                                 $serverdata['network'] = Protocol::ZOT;
802                         } elseif ($serverdata['platform'] == 'redmatrix') {
803                                 $serverdata['network'] = Protocol::ZOT;
804                         }
805                 }
806
807                 if (!empty($data['total_users'])) {
808                         $valid = true;
809                         $serverdata['registered-users'] = max($data['total_users'], 1);
810                 }
811
812                 if (!empty($data['active_users_monthly'])) {
813                         $valid = true;
814                         $serverdata['active-month-users'] = max($data['active_users_monthly'], 0);
815                 }
816
817                 if (!empty($data['active_users_halfyear'])) {
818                         $valid = true;
819                         $serverdata['active-halfyear-users'] = max($data['active_users_halfyear'], 0);
820                 }
821
822                 if (!empty($data['local_posts'])) {
823                         $valid = true;
824                         $serverdata['local-posts'] = max($data['local_posts'], 0);
825                 }
826
827                 if (!empty($data['registrations_open'])) {
828                         $serverdata['register_policy'] = Register::OPEN;
829                 } else {
830                         $serverdata['register_policy'] = Register::CLOSED;
831                 }
832
833                 if (!$valid) {
834                         return $old_serverdata;
835                 }
836
837                 return $serverdata;
838         }
839
840         /**
841          * Detect server type by using the nodeinfo data
842          *
843          * @param string                  $url        address of the server
844          * @param ICanHandleHttpResponses $httpResult
845          *
846          * @return array Server data
847          *
848          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
849          */
850         private static function fetchNodeinfo(string $url, ICanHandleHttpResponses $httpResult): array
851         {
852                 if (!$httpResult->isSuccess()) {
853                         return [];
854                 }
855
856                 $nodeinfo = json_decode($httpResult->getBody(), true);
857
858                 if (!is_array($nodeinfo) || empty($nodeinfo['links'])) {
859                         return [];
860                 }
861
862                 $nodeinfo1_url = '';
863                 $nodeinfo2_url = '';
864
865                 foreach ($nodeinfo['links'] as $link) {
866                         if (!is_array($link) || empty($link['rel']) || empty($link['href'])) {
867                                 Logger::info('Invalid nodeinfo format', ['url' => $url]);
868                                 continue;
869                         }
870                         if ($link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/1.0') {
871                                 $nodeinfo1_url = $link['href'];
872                         } elseif ($link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0') {
873                                 $nodeinfo2_url = $link['href'];
874                         }
875                 }
876
877                 if ($nodeinfo1_url . $nodeinfo2_url == '') {
878                         return [];
879                 }
880
881                 $server = [];
882
883                 if (!empty($nodeinfo2_url)) {
884                         $server = self::parseNodeinfo2($nodeinfo2_url);
885                 }
886
887                 if (empty($server) && !empty($nodeinfo1_url)) {
888                         $server = self::parseNodeinfo1($nodeinfo1_url);
889                 }
890
891                 return $server;
892         }
893
894         /**
895          * Parses Nodeinfo 1
896          *
897          * @param string $nodeinfo_url address of the nodeinfo path
898          *
899          * @return array Server data
900          *
901          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
902          */
903         private static function parseNodeinfo1(string $nodeinfo_url): array
904         {
905                 $curlResult = DI::httpClient()->get($nodeinfo_url, HttpClientAccept::JSON);
906                 if (!$curlResult->isSuccess()) {
907                         return [];
908                 }
909
910                 $nodeinfo = json_decode($curlResult->getBody(), true);
911
912                 if (!is_array($nodeinfo)) {
913                         return [];
914                 }
915
916                 $server = ['detection-method' => self::DETECT_NODEINFO_1,
917                         'register_policy' => Register::CLOSED];
918
919                 if (!empty($nodeinfo['openRegistrations'])) {
920                         $server['register_policy'] = Register::OPEN;
921                 }
922
923                 if (is_array($nodeinfo['software'])) {
924                         if (!empty($nodeinfo['software']['name'])) {
925                                 $server['platform'] = strtolower($nodeinfo['software']['name']);
926                         }
927
928                         if (!empty($nodeinfo['software']['version'])) {
929                                 $server['version'] = $nodeinfo['software']['version'];
930                                 // Version numbers on Nodeinfo are presented with additional info, e.g.:
931                                 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
932                                 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
933                         }
934                 }
935
936                 if (!empty($nodeinfo['metadata']['nodeName'])) {
937                         $server['site_name'] = $nodeinfo['metadata']['nodeName'];
938                 }
939
940                 if (!empty($nodeinfo['usage']['users']['total'])) {
941                         $server['registered-users'] = max($nodeinfo['usage']['users']['total'], 1);
942                 }
943
944                 if (!empty($nodeinfo['usage']['users']['activeMonth'])) {
945                         $server['active-month-users'] = max($nodeinfo['usage']['users']['activeMonth'], 0);
946                 }
947
948                 if (!empty($nodeinfo['usage']['users']['activeHalfyear'])) {
949                         $server['active-halfyear-users'] = max($nodeinfo['usage']['users']['activeHalfyear'], 0);
950                 }
951
952                 if (!empty($nodeinfo['usage']['localPosts'])) {
953                         $server['local-posts'] = max($nodeinfo['usage']['localPosts'], 0);
954                 }
955
956                 if (!empty($nodeinfo['usage']['localComments'])) {
957                         $server['local-comments'] = max($nodeinfo['usage']['localComments'], 0);
958                 }
959
960                 if (!empty($nodeinfo['protocols']['inbound']) && is_array($nodeinfo['protocols']['inbound'])) {
961                         $protocols = [];
962                         foreach ($nodeinfo['protocols']['inbound'] as $protocol) {
963                                 $protocols[$protocol] = true;
964                         }
965
966                         if (!empty($protocols['friendica'])) {
967                                 $server['network'] = Protocol::DFRN;
968                         } elseif (!empty($protocols['activitypub'])) {
969                                 $server['network'] = Protocol::ACTIVITYPUB;
970                         } elseif (!empty($protocols['diaspora'])) {
971                                 $server['network'] = Protocol::DIASPORA;
972                         } elseif (!empty($protocols['ostatus'])) {
973                                 $server['network'] = Protocol::OSTATUS;
974                         } elseif (!empty($protocols['gnusocial'])) {
975                                 $server['network'] = Protocol::OSTATUS;
976                         } elseif (!empty($protocols['zot'])) {
977                                 $server['network'] = Protocol::ZOT;
978                         }
979                 }
980
981                 if (empty($server)) {
982                         return [];
983                 }
984
985                 if (empty($server['network'])) {
986                         $server['network'] = Protocol::PHANTOM;
987                 }
988
989                 return $server;
990         }
991
992         /**
993          * Parses Nodeinfo 2
994          *
995          * @see https://git.feneas.org/jaywink/nodeinfo2
996          *
997          * @param string $nodeinfo_url address of the nodeinfo path
998          *
999          * @return array Server data
1000          *
1001          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1002          */
1003         private static function parseNodeinfo2(string $nodeinfo_url): array
1004         {
1005                 $curlResult = DI::httpClient()->get($nodeinfo_url, HttpClientAccept::JSON);
1006                 if (!$curlResult->isSuccess()) {
1007                         return [];
1008                 }
1009
1010                 $nodeinfo = json_decode($curlResult->getBody(), true);
1011                 if (!is_array($nodeinfo)) {
1012                         return [];
1013                 }
1014
1015                 $server = [
1016                         'detection-method' => self::DETECT_NODEINFO_2,
1017                         'register_policy' => Register::CLOSED,
1018                         'platform' => 'unknown',
1019                 ];
1020
1021                 if (!empty($nodeinfo['openRegistrations'])) {
1022                         $server['register_policy'] = Register::OPEN;
1023                 }
1024
1025                 if (!empty($nodeinfo['software'])) {
1026                         if (isset($nodeinfo['software']['name'])) {
1027                                 $server['platform'] = strtolower($nodeinfo['software']['name']);
1028                         }
1029
1030                         if (!empty($nodeinfo['software']['version']) && isset($server['platform'])) {
1031                                 $server['version'] = $nodeinfo['software']['version'];
1032                                 // Version numbers on Nodeinfo are presented with additional info, e.g.:
1033                                 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
1034                                 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
1035
1036                                 // qoto advertises itself as Mastodon
1037                                 if (($server['platform'] == 'mastodon') && substr($nodeinfo['software']['version'], -5) == '-qoto') {
1038                                         $server['platform'] = 'qoto';
1039                                 }
1040                         }
1041                 }
1042
1043                 if (!empty($nodeinfo['metadata']['nodeName'])) {
1044                         $server['site_name'] = $nodeinfo['metadata']['nodeName'];
1045                 }
1046
1047                 if (!empty($nodeinfo['usage']['users']['total'])) {
1048                         $server['registered-users'] = max($nodeinfo['usage']['users']['total'], 1);
1049                 }
1050
1051                 if (!empty($nodeinfo['usage']['users']['activeMonth'])) {
1052                         $server['active-month-users'] = max($nodeinfo['usage']['users']['activeMonth'], 0);
1053                 }
1054
1055                 if (!empty($nodeinfo['usage']['users']['activeHalfyear'])) {
1056                         $server['active-halfyear-users'] = max($nodeinfo['usage']['users']['activeHalfyear'], 0);
1057                 }
1058
1059                 if (!empty($nodeinfo['usage']['localPosts'])) {
1060                         $server['local-posts'] = max($nodeinfo['usage']['localPosts'], 0);
1061                 }
1062
1063                 if (!empty($nodeinfo['usage']['localComments'])) {
1064                         $server['local-comments'] = max($nodeinfo['usage']['localComments'], 0);
1065                 }
1066
1067                 if (!empty($nodeinfo['protocols'])) {
1068                         $protocols = [];
1069                         foreach ($nodeinfo['protocols'] as $protocol) {
1070                                 if (is_string($protocol)) {
1071                                         $protocols[$protocol] = true;
1072                                 }
1073                         }
1074
1075                         if (!empty($protocols['dfrn'])) {
1076                                 $server['network'] = Protocol::DFRN;
1077                         } elseif (!empty($protocols['activitypub'])) {
1078                                 $server['network'] = Protocol::ACTIVITYPUB;
1079                         } elseif (!empty($protocols['diaspora'])) {
1080                                 $server['network'] = Protocol::DIASPORA;
1081                         } elseif (!empty($protocols['ostatus'])) {
1082                                 $server['network'] = Protocol::OSTATUS;
1083                         } elseif (!empty($protocols['gnusocial'])) {
1084                                 $server['network'] = Protocol::OSTATUS;
1085                         } elseif (!empty($protocols['zot'])) {
1086                                 $server['network'] = Protocol::ZOT;
1087                         }
1088                 }
1089
1090                 if (empty($server)) {
1091                         return [];
1092                 }
1093
1094                 if (empty($server['network'])) {
1095                         $server['network'] = Protocol::PHANTOM;
1096                 }
1097
1098                 return $server;
1099         }
1100
1101         /**
1102          * Parses NodeInfo2 protocol 1.0
1103          *
1104          * @see https://github.com/jaywink/nodeinfo2/blob/master/PROTOCOL.md
1105          *
1106          * @param string $nodeinfo_url address of the nodeinfo path
1107          *
1108          * @return array Server data
1109          *
1110          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1111          */
1112         private static function parseNodeinfo210(ICanHandleHttpResponses $httpResult): array
1113         {
1114                 if (!$httpResult->isSuccess()) {
1115                         return [];
1116                 }
1117
1118                 $nodeinfo = json_decode($httpResult->getBody(), true);
1119
1120                 if (!is_array($nodeinfo)) {
1121                         return [];
1122                 }
1123
1124                 $server = ['detection-method' => self::DETECT_NODEINFO_210,
1125                         'register_policy' => Register::CLOSED];
1126
1127                 if (!empty($nodeinfo['openRegistrations'])) {
1128                         $server['register_policy'] = Register::OPEN;
1129                 }
1130
1131                 if (!empty($nodeinfo['server'])) {
1132                         if (!empty($nodeinfo['server']['software'])) {
1133                                 $server['platform'] = strtolower($nodeinfo['server']['software']);
1134                         }
1135
1136                         if (!empty($nodeinfo['server']['version'])) {
1137                                 $server['version'] = $nodeinfo['server']['version'];
1138                                 // Version numbers on Nodeinfo are presented with additional info, e.g.:
1139                                 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
1140                                 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
1141                         }
1142
1143                         if (!empty($nodeinfo['server']['name'])) {
1144                                 $server['site_name'] = $nodeinfo['server']['name'];
1145                         }
1146                 }
1147
1148                 if (!empty($nodeinfo['usage']['users']['total'])) {
1149                         $server['registered-users'] = max($nodeinfo['usage']['users']['total'], 1);
1150                 }
1151
1152                 if (!empty($nodeinfo['usage']['users']['activeMonth'])) {
1153                         $server['active-month-users'] = max($nodeinfo['usage']['users']['activeMonth'], 0);
1154                 }
1155
1156                 if (!empty($nodeinfo['usage']['users']['activeHalfyear'])) {
1157                         $server['active-halfyear-users'] = max($nodeinfo['usage']['users']['activeHalfyear'], 0);
1158                 }
1159
1160                 if (!empty($nodeinfo['usage']['localPosts'])) {
1161                         $server['local-posts'] = max($nodeinfo['usage']['localPosts'], 0);
1162                 }
1163
1164                 if (!empty($nodeinfo['usage']['localComments'])) {
1165                         $server['local-comments'] = max($nodeinfo['usage']['localComments'], 0);
1166                 }
1167
1168                 if (!empty($nodeinfo['protocols'])) {
1169                         $protocols = [];
1170                         foreach ($nodeinfo['protocols'] as $protocol) {
1171                                 if (is_string($protocol)) {
1172                                         $protocols[$protocol] = true;
1173                                 }
1174                         }
1175
1176                         if (!empty($protocols['dfrn'])) {
1177                                 $server['network'] = Protocol::DFRN;
1178                         } elseif (!empty($protocols['activitypub'])) {
1179                                 $server['network'] = Protocol::ACTIVITYPUB;
1180                         } elseif (!empty($protocols['diaspora'])) {
1181                                 $server['network'] = Protocol::DIASPORA;
1182                         } elseif (!empty($protocols['ostatus'])) {
1183                                 $server['network'] = Protocol::OSTATUS;
1184                         } elseif (!empty($protocols['gnusocial'])) {
1185                                 $server['network'] = Protocol::OSTATUS;
1186                         } elseif (!empty($protocols['zot'])) {
1187                                 $server['network'] = Protocol::ZOT;
1188                         }
1189                 }
1190
1191                 if (empty($server) || empty($server['platform'])) {
1192                         return [];
1193                 }
1194
1195                 if (empty($server['network'])) {
1196                         $server['network'] = Protocol::PHANTOM;
1197                 }
1198
1199                 return $server;
1200         }
1201
1202         /**
1203          * Fetch server information from a 'siteinfo.json' file on the given server
1204          *
1205          * @param string $url        URL of the given server
1206          * @param array  $serverdata array with server data
1207          *
1208          * @return array server data
1209          */
1210         private static function fetchSiteinfo(string $url, array $serverdata): array
1211         {
1212                 $curlResult = DI::httpClient()->get($url . '/siteinfo.json', HttpClientAccept::JSON);
1213                 if (!$curlResult->isSuccess()) {
1214                         return $serverdata;
1215                 }
1216
1217                 $data = json_decode($curlResult->getBody(), true);
1218                 if (empty($data)) {
1219                         return $serverdata;
1220                 }
1221
1222                 if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1223                         $serverdata['detection-method'] = self::DETECT_SITEINFO_JSON;
1224                 }
1225
1226                 if (!empty($data['url'])) {
1227                         $serverdata['platform'] = strtolower($data['platform']);
1228                         $serverdata['version'] = $data['version'] ?? 'N/A';
1229                 }
1230
1231                 if (!empty($data['plugins'])) {
1232                         if (in_array('pubcrawl', $data['plugins'])) {
1233                                 $serverdata['network'] = Protocol::ACTIVITYPUB;
1234                         } elseif (in_array('diaspora', $data['plugins'])) {
1235                                 $serverdata['network'] = Protocol::DIASPORA;
1236                         } elseif (in_array('gnusoc', $data['plugins'])) {
1237                                 $serverdata['network'] = Protocol::OSTATUS;
1238                         } else {
1239                                 $serverdata['network'] = Protocol::ZOT;
1240                         }
1241                 }
1242
1243                 if (!empty($data['site_name'])) {
1244                         $serverdata['site_name'] = $data['site_name'];
1245                 }
1246
1247                 if (!empty($data['channels_total'])) {
1248                         $serverdata['registered-users'] = max($data['channels_total'], 1);
1249                 }
1250
1251                 if (!empty($data['channels_active_monthly'])) {
1252                         $serverdata['active-month-users'] = max($data['channels_active_monthly'], 0);
1253                 }
1254
1255                 if (!empty($data['channels_active_halfyear'])) {
1256                         $serverdata['active-halfyear-users'] = max($data['channels_active_halfyear'], 0);
1257                 }
1258
1259                 if (!empty($data['local_posts'])) {
1260                         $serverdata['local-posts'] = max($data['local_posts'], 0);
1261                 }
1262
1263                 if (!empty($data['local_comments'])) {
1264                         $serverdata['local-comments'] = max($data['local_comments'], 0);
1265                 }
1266
1267                 if (!empty($data['register_policy'])) {
1268                         switch ($data['register_policy']) {
1269                                 case 'REGISTER_OPEN':
1270                                         $serverdata['register_policy'] = Register::OPEN;
1271                                         break;
1272
1273                                 case 'REGISTER_APPROVE':
1274                                         $serverdata['register_policy'] = Register::APPROVE;
1275                                         break;
1276
1277                                 case 'REGISTER_CLOSED':
1278                                 default:
1279                                         $serverdata['register_policy'] = Register::CLOSED;
1280                                         break;
1281                         }
1282                 }
1283
1284                 return $serverdata;
1285         }
1286
1287         /**
1288          * Fetches server data via an ActivityPub account with url of that server
1289          *
1290          * @param string $url        URL of the given server
1291          * @param array  $serverdata array with server data
1292          *
1293          * @return array server data
1294          *
1295          * @throws Exception
1296          */
1297         private static function fetchDataFromSystemActor(array $data, array $serverdata): array
1298         {
1299                 if (empty($data)) {
1300                         return ['server' => $serverdata, 'actor' => ''];
1301                 }
1302
1303                 $actor = JsonLD::compact($data, false);
1304                 if (in_array(JsonLD::fetchElement($actor, '@type'), ActivityPub\Receiver::ACCOUNT_TYPES)) {
1305                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1306                         $serverdata['site_name'] = JsonLD::fetchElement($actor, 'as:name', '@value');
1307                         $serverdata['info'] = JsonLD::fetchElement($actor, 'as:summary', '@value');
1308                         if (!empty($actor['as:generator'])) {
1309                                 $generator = explode(' ', JsonLD::fetchElement($actor['as:generator'], 'as:name', '@value'));
1310                                 $serverdata['platform'] = strtolower(array_shift($generator));
1311                                 $serverdata['detection-method'] = self::DETECT_SYSTEM_ACTOR;
1312                         } else {
1313                                 $serverdata['detection-method'] = self::DETECT_AP_ACTOR;
1314                         }
1315                         return ['server' => $serverdata, 'actor' => $actor['@id']];
1316                 } elseif ((JsonLD::fetchElement($actor, '@type') == 'as:Collection')) {
1317                         // By now only Ktistec seems to provide collections this way
1318                         $serverdata['platform'] = 'ktistec';
1319                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1320                         $serverdata['detection-method'] = self::DETECT_AP_COLLECTION;
1321
1322                         $actors = JsonLD::fetchElementArray($actor, 'as:items');
1323                         if (!empty($actors) && !empty($actors[0]['@id'])) {
1324                                 $actor_url = $actor['@id'] . $actors[0]['@id'];
1325                         } else {
1326                                 $actor_url = '';
1327                         }
1328
1329                         return ['server' => $serverdata, 'actor' => $actor_url];
1330                 }
1331                 return ['server' => $serverdata, 'actor' => ''];
1332         }
1333
1334         /**
1335          * Checks if the server contains a valid host meta file
1336          *
1337          * @param string $url URL of the given server
1338          *
1339          * @return boolean 'true' if the server seems to be vital
1340          */
1341         private static function validHostMeta(string $url): bool
1342         {
1343                 $xrd_timeout = DI::config()->get('system', 'xrd_timeout');
1344                 $curlResult = DI::httpClient()->get($url . Probe::HOST_META, HttpClientAccept::XRD_XML, [HttpClientOptions::TIMEOUT => $xrd_timeout]);
1345                 if (!$curlResult->isSuccess()) {
1346                         return false;
1347                 }
1348
1349                 $xrd = XML::parseString($curlResult->getBody(), true);
1350                 if (!is_object($xrd)) {
1351                         return false;
1352                 }
1353
1354                 $elements = XML::elementToArray($xrd);
1355                 if (empty($elements) || empty($elements['xrd']) || empty($elements['xrd']['link'])) {
1356                         return false;
1357                 }
1358
1359                 $valid = false;
1360                 foreach ($elements['xrd']['link'] as $link) {
1361                         // When there is more than a single "link" element, the array looks slightly different
1362                         if (!empty($link['@attributes'])) {
1363                                 $link = $link['@attributes'];
1364                         }
1365
1366                         if (empty($link['rel']) || empty($link['template'])) {
1367                                 continue;
1368                         }
1369
1370                         if ($link['rel'] == 'lrdd') {
1371                                 // When the webfinger host is the same like the system host, it should be ok.
1372                                 $valid = (parse_url($url, PHP_URL_HOST) == parse_url($link['template'], PHP_URL_HOST));
1373                         }
1374                 }
1375
1376                 return $valid;
1377         }
1378
1379         /**
1380          * Detect the network of the given server via their known contacts
1381          *
1382          * @param string $url        URL of the given server
1383          * @param array  $serverdata array with server data
1384          *
1385          * @return array server data
1386          */
1387         private static function detectNetworkViaContacts(string $url, array $serverdata): array
1388         {
1389                 $contacts = [];
1390
1391                 $nurl = Strings::normaliseLink($url);
1392
1393                 $apcontacts = DBA::select('apcontact', ['url'], ['baseurl' => [$url, $nurl]]);
1394                 while ($apcontact = DBA::fetch($apcontacts)) {
1395                         $contacts[Strings::normaliseLink($apcontact['url'])] = $apcontact['url'];
1396                 }
1397                 DBA::close($apcontacts);
1398
1399                 $pcontacts = DBA::select('contact', ['url', 'nurl'], ['uid' => 0, 'baseurl' => [$url, $nurl]]);
1400                 while ($pcontact = DBA::fetch($pcontacts)) {
1401                         $contacts[$pcontact['nurl']] = $pcontact['url'];
1402                 }
1403                 DBA::close($pcontacts);
1404
1405                 if (empty($contacts)) {
1406                         return $serverdata;
1407                 }
1408
1409                 $time = time();
1410                 foreach ($contacts as $contact) {
1411                         // Endlosschleife verhindern wegen gsid!
1412                         $data = Probe::uri($contact);
1413                         if (in_array($data['network'], Protocol::FEDERATED)) {
1414                                 $serverdata['network'] = $data['network'];
1415
1416                                 if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1417                                         $serverdata['detection-method'] = self::DETECT_CONTACTS;
1418                                 }
1419                                 break;
1420                         } elseif ((time() - $time) > 10) {
1421                                 // To reduce the stress on remote systems we probe a maximum of 10 seconds
1422                                 break;
1423                         }
1424                 }
1425
1426                 return $serverdata;
1427         }
1428
1429         /**
1430          * Checks if the given server does have a '/poco' endpoint.
1431          * This is used for the 'PortableContact' functionality,
1432          * which is used by both Friendica and Hubzilla.
1433          *
1434          * @param string $url        URL of the given server
1435          * @param array  $serverdata array with server data
1436          *
1437          * @return array server data
1438          */
1439         private static function checkPoCo(string $url, array $serverdata): array
1440         {
1441                 $serverdata['poco'] = '';
1442
1443                 $curlResult = DI::httpClient()->get($url . '/poco', HttpClientAccept::JSON);
1444                 if (!$curlResult->isSuccess()) {
1445                         return $serverdata;
1446                 }
1447
1448                 $data = json_decode($curlResult->getBody(), true);
1449                 if (empty($data)) {
1450                         return $serverdata;
1451                 }
1452
1453                 if (!empty($data['totalResults'])) {
1454                         $registeredUsers = $serverdata['registered-users'] ?? 0;
1455                         $serverdata['registered-users'] = max($data['totalResults'], $registeredUsers, 1);
1456                         $serverdata['directory-type'] = self::DT_POCO;
1457                         $serverdata['poco'] = $url . '/poco';
1458                 }
1459
1460                 return $serverdata;
1461         }
1462
1463         /**
1464          * Checks if the given server does have a Mastodon style directory endpoint.
1465          *
1466          * @param string $url        URL of the given server
1467          * @param array  $serverdata array with server data
1468          *
1469          * @return array server data
1470          */
1471         public static function checkMastodonDirectory(string $url, array $serverdata): array
1472         {
1473                 $curlResult = DI::httpClient()->get($url . '/api/v1/directory?limit=1', HttpClientAccept::JSON);
1474                 if (!$curlResult->isSuccess()) {
1475                         return $serverdata;
1476                 }
1477
1478                 $data = json_decode($curlResult->getBody(), true);
1479                 if (empty($data)) {
1480                         return $serverdata;
1481                 }
1482
1483                 if (count($data) == 1) {
1484                         $serverdata['directory-type'] = self::DT_MASTODON;
1485                 }
1486
1487                 return $serverdata;
1488         }
1489
1490         /**
1491          * Detects Peertube via their known endpoint
1492          *
1493          * @param string $url        URL of the given server
1494          * @param array  $serverdata array with server data
1495          *
1496          * @return array server data
1497          */
1498         private static function detectPeertube(string $url, array $serverdata): array
1499         {
1500                 $curlResult = DI::httpClient()->get($url . '/api/v1/config', HttpClientAccept::JSON);
1501                 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
1502                         return $serverdata;
1503                 }
1504
1505                 $data = json_decode($curlResult->getBody(), true);
1506                 if (empty($data)) {
1507                         return $serverdata;
1508                 }
1509
1510                 if (!empty($data['instance']) && !empty($data['serverVersion'])) {
1511                         $serverdata['platform'] = 'peertube';
1512                         $serverdata['version'] = $data['serverVersion'];
1513                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1514
1515                         if (!empty($data['instance']['name'])) {
1516                                 $serverdata['site_name'] = $data['instance']['name'];
1517                         }
1518
1519                         if (!empty($data['instance']['shortDescription'])) {
1520                                 $serverdata['info'] = $data['instance']['shortDescription'];
1521                         }
1522
1523                         if (!empty($data['signup'])) {
1524                                 if (!empty($data['signup']['allowed'])) {
1525                                         $serverdata['register_policy'] = Register::OPEN;
1526                                 }
1527                         }
1528
1529                         if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1530                                 $serverdata['detection-method'] = self::DETECT_V1_CONFIG;
1531                         }
1532                 }
1533
1534                 return $serverdata;
1535         }
1536
1537         /**
1538          * Detects the version number of a given server when it was a NextCloud installation
1539          *
1540          * @param string $url        URL of the given server
1541          * @param array  $serverdata array with server data
1542          * @param bool   $validHostMeta
1543          *
1544          * @return array server data
1545          */
1546         private static function detectNextcloud(string $url, array $serverdata, bool $validHostMeta): array
1547         {
1548                 $curlResult = DI::httpClient()->get($url . '/status.php', HttpClientAccept::JSON);
1549                 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
1550                         return $serverdata;
1551                 }
1552
1553                 $data = json_decode($curlResult->getBody(), true);
1554                 if (empty($data)) {
1555                         return $serverdata;
1556                 }
1557
1558                 if (!empty($data['version'])) {
1559                         $serverdata['platform'] = 'nextcloud';
1560                         $serverdata['version'] = $data['version'];
1561
1562                         if ($validHostMeta) {
1563                                 $serverdata['network'] = Protocol::ACTIVITYPUB;
1564                         }
1565
1566                         if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1567                                 $serverdata['detection-method'] = self::DETECT_STATUS_PHP;
1568                         }
1569                 }
1570
1571                 return $serverdata;
1572         }
1573
1574         /**
1575          * Fetches weekly usage data
1576          *
1577          * @param string $url        URL of the given server
1578          * @param array  $serverdata array with server data
1579          *
1580          * @return array server data
1581          */
1582         private static function fetchWeeklyUsage(string $url, array $serverdata): array
1583         {
1584                 $curlResult = DI::httpClient()->get($url . '/api/v1/instance/activity', HttpClientAccept::JSON);
1585                 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
1586                         return $serverdata;
1587                 }
1588
1589                 $data = json_decode($curlResult->getBody(), true);
1590                 if (empty($data)) {
1591                         return $serverdata;
1592                 }
1593
1594                 $current_week = [];
1595                 foreach ($data as $week) {
1596                         // Use only data from a full week
1597                         if (empty($week['week']) || (time() - $week['week']) < 7 * 24 * 60 * 60) {
1598                                 continue;
1599                         }
1600
1601                         // Most likely the data is sorted correctly. But we better are safe than sorry
1602                         if (empty($current_week['week']) || ($current_week['week'] < $week['week'])) {
1603                                 $current_week = $week;
1604                         }
1605                 }
1606
1607                 if (!empty($current_week['logins'])) {
1608                         $serverdata['active-week-users'] = max($current_week['logins'], 0);
1609                 }
1610
1611                 return $serverdata;
1612         }
1613
1614         /**
1615          * Detects data from a given server url if it was a mastodon alike system
1616          *
1617          * @param string $url        URL of the given server
1618          * @param array  $serverdata array with server data
1619          *
1620          * @return array server data
1621          */
1622         private static function detectMastodonAlikes(string $url, array $serverdata): array
1623         {
1624                 $curlResult = DI::httpClient()->get($url . '/api/v1/instance', HttpClientAccept::JSON);
1625                 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
1626                         return $serverdata;
1627                 }
1628
1629                 $data = json_decode($curlResult->getBody(), true);
1630                 if (empty($data)) {
1631                         return $serverdata;
1632                 }
1633
1634                 $valid = false;
1635
1636                 if (!empty($data['version'])) {
1637                         $serverdata['platform'] = 'mastodon';
1638                         $serverdata['version'] = $data['version'] ?? '';
1639                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1640                         $valid = true;
1641                 }
1642
1643                 if (!empty($data['title'])) {
1644                         $serverdata['site_name'] = $data['title'];
1645                 }
1646
1647                 if (!empty($data['title']) && empty($serverdata['platform']) && ($serverdata['network'] == Protocol::PHANTOM)) {
1648                         $serverdata['platform'] = 'mastodon';
1649                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1650                         $valid = true;
1651                 }
1652
1653                 if (!empty($data['description'])) {
1654                         $serverdata['info'] = trim($data['description']);
1655                 }
1656
1657                 if (!empty($data['stats']['user_count'])) {
1658                         $serverdata['registered-users'] = max($data['stats']['user_count'], 1);
1659                 }
1660
1661                 if (!empty($serverdata['version']) && preg_match('/.*?\(compatible;\s(.*)\s(.*)\)/ism', $serverdata['version'], $matches)) {
1662                         $serverdata['platform'] = strtolower($matches[1]);
1663                         $serverdata['version'] = $matches[2];
1664                         $valid = true;
1665                 }
1666
1667                 if (!empty($serverdata['version']) && strstr(strtolower($serverdata['version']), 'pleroma')) {
1668                         $serverdata['platform'] = 'pleroma';
1669                         $serverdata['version'] = trim(str_ireplace('pleroma', '', $serverdata['version']));
1670                         $valid = true;
1671                 }
1672
1673                 if (!empty($serverdata['platform']) && strstr($serverdata['platform'], 'pleroma')) {
1674                         $serverdata['version'] = trim(str_ireplace('pleroma', '', $serverdata['platform']));
1675                         $serverdata['platform'] = 'pleroma';
1676                         $valid = true;
1677                 }
1678
1679                 if ($valid && in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1680                         $serverdata['detection-method'] = self::DETECT_MASTODON_API;
1681                 }
1682
1683                 return $serverdata;
1684         }
1685
1686         /**
1687          * Detects data from typical Hubzilla endpoints
1688          *
1689          * @param string $url        URL of the given server
1690          * @param array  $serverdata array with server data
1691          *
1692          * @return array server data
1693          */
1694         private static function detectHubzilla(string $url, array $serverdata): array
1695         {
1696                 $curlResult = DI::httpClient()->get($url . '/api/statusnet/config.json', HttpClientAccept::JSON);
1697                 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
1698                         return $serverdata;
1699                 }
1700
1701                 $data = json_decode($curlResult->getBody(), true);
1702                 if (empty($data) || empty($data['site'])) {
1703                         return $serverdata;
1704                 }
1705
1706                 if (!empty($data['site']['name'])) {
1707                         $serverdata['site_name'] = $data['site']['name'];
1708                 }
1709
1710                 if (!empty($data['site']['platform'])) {
1711                         $serverdata['platform'] = strtolower($data['site']['platform']['PLATFORM_NAME']);
1712                         $serverdata['version'] = $data['site']['platform']['STD_VERSION'];
1713                         $serverdata['network'] = Protocol::ZOT;
1714                 }
1715
1716                 if (!empty($data['site']['hubzilla'])) {
1717                         $serverdata['platform'] = strtolower($data['site']['hubzilla']['PLATFORM_NAME']);
1718                         $serverdata['version'] = $data['site']['hubzilla']['RED_VERSION'];
1719                         $serverdata['network'] = Protocol::ZOT;
1720                 }
1721
1722                 if (!empty($data['site']['redmatrix'])) {
1723                         if (!empty($data['site']['redmatrix']['PLATFORM_NAME'])) {
1724                                 $serverdata['platform'] = strtolower($data['site']['redmatrix']['PLATFORM_NAME']);
1725                         } elseif (!empty($data['site']['redmatrix']['RED_PLATFORM'])) {
1726                                 $serverdata['platform'] = strtolower($data['site']['redmatrix']['RED_PLATFORM']);
1727                         }
1728
1729                         $serverdata['version'] = $data['site']['redmatrix']['RED_VERSION'];
1730                         $serverdata['network'] = Protocol::ZOT;
1731                 }
1732
1733                 $private = false;
1734                 $inviteonly = false;
1735                 $closed = false;
1736
1737                 if (!empty($data['site']['closed'])) {
1738                         $closed = self::toBoolean($data['site']['closed']);
1739                 }
1740
1741                 if (!empty($data['site']['private'])) {
1742                         $private = self::toBoolean($data['site']['private']);
1743                 }
1744
1745                 if (!empty($data['site']['inviteonly'])) {
1746                         $inviteonly = self::toBoolean($data['site']['inviteonly']);
1747                 }
1748
1749                 if (!$closed && !$private and $inviteonly) {
1750                         $serverdata['register_policy'] = Register::APPROVE;
1751                 } elseif (!$closed && !$private) {
1752                         $serverdata['register_policy'] = Register::OPEN;
1753                 } else {
1754                         $serverdata['register_policy'] = Register::CLOSED;
1755                 }
1756
1757                 if (($serverdata['network'] != Protocol::PHANTOM) && in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1758                         $serverdata['detection-method'] = self::DETECT_CONFIG_JSON;
1759                 }
1760
1761                 return $serverdata;
1762         }
1763
1764         /**
1765          * Converts input value to a boolean value
1766          *
1767          * @param string|integer $val
1768          *
1769          * @return boolean
1770          */
1771         private static function toBoolean($val): bool
1772         {
1773                 if (($val == 'true') || ($val == 1)) {
1774                         return true;
1775                 } elseif (($val == 'false') || ($val == 0)) {
1776                         return false;
1777                 }
1778
1779                 return $val;
1780         }
1781
1782         /**
1783          * Detect if the URL belongs to a GNU Social server
1784          *
1785          * @param string $url        URL of the given server
1786          * @param array  $serverdata array with server data
1787          *
1788          * @return array server data
1789          */
1790         private static function detectGNUSocial(string $url, array $serverdata): array
1791         {
1792                 // Test for GNU Social
1793                 $curlResult = DI::httpClient()->get($url . '/api/gnusocial/version.json', HttpClientAccept::JSON);
1794                 if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') &&
1795                         ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) {
1796                         $serverdata['platform'] = 'gnusocial';
1797                         // Remove junk that some GNU Social servers return
1798                         $serverdata['version'] = str_replace(chr(239) . chr(187) . chr(191), '', $curlResult->getBody());
1799                         $serverdata['version'] = str_replace(["\r", "\n", "\t"], '', $serverdata['version']);
1800                         $serverdata['version'] = trim($serverdata['version'], '"');
1801                         $serverdata['network'] = Protocol::OSTATUS;
1802
1803                         if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1804                                 $serverdata['detection-method'] = self::DETECT_GNUSOCIAL;
1805                         }
1806
1807                         return $serverdata;
1808                 }
1809
1810                 // Test for Statusnet
1811                 $curlResult = DI::httpClient()->get($url . '/api/statusnet/version.json', HttpClientAccept::JSON);
1812                 if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') &&
1813                         ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) {
1814
1815                         // Remove junk that some GNU Social servers return
1816                         $serverdata['version'] = str_replace(chr(239).chr(187).chr(191), '', $curlResult->getBody());
1817                         $serverdata['version'] = str_replace(["\r", "\n", "\t"], '', $serverdata['version']);
1818                         $serverdata['version'] = trim($serverdata['version'], '"');
1819
1820                         if (!empty($serverdata['version']) && strtolower(substr($serverdata['version'], 0, 7)) == 'pleroma') {
1821                                 $serverdata['platform'] = 'pleroma';
1822                                 $serverdata['version'] = trim(str_ireplace('pleroma', '', $serverdata['version']));
1823                                 $serverdata['network'] = Protocol::ACTIVITYPUB;
1824                         } else {
1825                                 $serverdata['platform'] = 'statusnet';
1826                                 $serverdata['network'] = Protocol::OSTATUS;
1827                         }
1828
1829                         if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1830                                 $serverdata['detection-method'] = self::DETECT_STATUSNET;
1831                         }
1832                 }
1833
1834                 return $serverdata;
1835         }
1836
1837         /**
1838          * Detect if the URL belongs to a Friendica server
1839          *
1840          * @param string $url        URL of the given server
1841          * @param array  $serverdata array with server data
1842          *
1843          * @return array server data
1844          */
1845         private static function detectFriendica(string $url, array $serverdata): array
1846         {
1847                 // There is a bug in some versions of Friendica that will return an ActivityStream actor when the content type "application/json" is requested.
1848                 // Because of this me must not use ACCEPT_JSON here.
1849                 $curlResult = DI::httpClient()->get($url . '/friendica/json');
1850                 if (!$curlResult->isSuccess()) {
1851                         $curlResult = DI::httpClient()->get($url . '/friendika/json');
1852                         $friendika = true;
1853                         $platform = 'Friendika';
1854                 } else {
1855                         $friendika = false;
1856                         $platform = 'Friendica';
1857                 }
1858
1859                 if (!$curlResult->isSuccess()) {
1860                         return $serverdata;
1861                 }
1862
1863                 $data = json_decode($curlResult->getBody(), true);
1864                 if (empty($data) || empty($data['version'])) {
1865                         return $serverdata;
1866                 }
1867
1868                 if (in_array($serverdata['detection-method'], self::DETECT_UNSPECIFIC)) {
1869                         $serverdata['detection-method'] = $friendika ? self::DETECT_FRIENDIKA : self::DETECT_FRIENDICA;
1870                 }
1871
1872                 $serverdata['network'] = Protocol::DFRN;
1873                 $serverdata['version'] = $data['version'];
1874
1875                 if (!empty($data['no_scrape_url'])) {
1876                         $serverdata['noscrape'] = $data['no_scrape_url'];
1877                 }
1878
1879                 if (!empty($data['site_name'])) {
1880                         $serverdata['site_name'] = $data['site_name'];
1881                 }
1882
1883                 if (!empty($data['info'])) {
1884                         $serverdata['info'] = trim($data['info']);
1885                 }
1886
1887                 $register_policy = ($data['register_policy'] ?? '') ?: 'REGISTER_CLOSED';
1888                 switch ($register_policy) {
1889                         case 'REGISTER_OPEN':
1890                                 $serverdata['register_policy'] = Register::OPEN;
1891                                 break;
1892
1893                         case 'REGISTER_APPROVE':
1894                                 $serverdata['register_policy'] = Register::APPROVE;
1895                                 break;
1896
1897                         case 'REGISTER_CLOSED':
1898                         case 'REGISTER_INVITATION':
1899                                 $serverdata['register_policy'] = Register::CLOSED;
1900                                 break;
1901                         default:
1902                                 Logger::info('Register policy is invalid', ['policy' => $register_policy, 'server' => $url]);
1903                                 $serverdata['register_policy'] = Register::CLOSED;
1904                                 break;
1905                 }
1906
1907                 $serverdata['platform'] = strtolower($data['platform'] ?? $platform);
1908
1909                 return $serverdata;
1910         }
1911
1912         /**
1913          * Analyses the landing page of a given server for hints about type and system of that server
1914          *
1915          * @param object $curlResult result of curl execution
1916          * @param array  $serverdata array with server data
1917          *
1918          * @return array server data
1919          */
1920         private static function analyseRootBody($curlResult, array $serverdata): array
1921         {
1922                 if (empty($curlResult->getBody())) {
1923                         return $serverdata;
1924                 }
1925
1926                 if (file_exists(__DIR__ . '/../../static/platforms.config.php')) {
1927                         require __DIR__ . '/../../static/platforms.config.php';
1928                 } else {
1929                         throw new HTTPException\InternalServerErrorException('Invalid platform file');
1930                 }
1931
1932                 $platforms = array_merge($ap_platforms, $dfrn_platforms, $zap_platforms, $platforms);
1933
1934                 $doc = new DOMDocument();
1935                 @$doc->loadHTML($curlResult->getBody());
1936                 $xpath = new DOMXPath($doc);
1937                 $assigned = false;
1938
1939                 // We can only detect honk via some HTML element on their page
1940                 if ($xpath->query('//div[@id="honksonpage"]')->count() == 1) {
1941                         $serverdata['platform'] = 'honk';
1942                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1943                         $assigned = true;
1944                 }
1945
1946                 $title = trim(XML::getFirstNodeValue($xpath, '//head/title/text()'));
1947                 if (!empty($title)) {
1948                         $serverdata['site_name'] = $title;
1949                 }
1950
1951                 $list = $xpath->query('//meta[@name]');
1952
1953                 foreach ($list as $node) {
1954                         $attr = [];
1955                         if ($node->attributes->length) {
1956                                 foreach ($node->attributes as $attribute) {
1957                                         $value = trim($attribute->value);
1958                                         if (empty($value)) {
1959                                                 continue;
1960                                         }
1961
1962                                         $attr[$attribute->name] = $value;
1963                                 }
1964
1965                                 if (empty($attr['name']) || empty($attr['content'])) {
1966                                         continue;
1967                                 }
1968                         }
1969
1970                         if ($attr['name'] == 'description') {
1971                                 $serverdata['info'] = $attr['content'];
1972                         }
1973
1974                         if (in_array($attr['name'], ['application-name', 'al:android:app_name', 'al:ios:app_name',
1975                                 'twitter:app:name:googleplay', 'twitter:app:name:iphone', 'twitter:app:name:ipad', 'generator'])) {
1976                                 $platform = str_ireplace(array_keys($platforms), array_values($platforms), $attr['content']);
1977                                 $platform = str_replace('/', ' ', $platform);
1978                                 $platform_parts = explode(' ', $platform);
1979                                 if ((count($platform_parts) >= 2) && in_array(strtolower($platform_parts[0]), array_values($platforms))) {
1980                                         $platform = $platform_parts[0];
1981                                         $serverdata['version'] = $platform_parts[1];
1982                                 }
1983                                 if (in_array($platform, array_values($dfrn_platforms))) {
1984                                         $serverdata['network'] = Protocol::DFRN;
1985                                 } elseif (in_array($platform, array_values($ap_platforms))) {
1986                                         $serverdata['network'] = Protocol::ACTIVITYPUB;
1987                                 } elseif (in_array($platform, array_values($zap_platforms))) {
1988                                         $serverdata['network'] = Protocol::ZOT;
1989                                 }
1990                                 if (in_array($platform, array_values($platforms))) {
1991                                         $serverdata['platform'] = $platform;
1992                                         $assigned = true;
1993                                 }
1994                         }
1995                 }
1996
1997                 $list = $xpath->query('//meta[@property]');
1998
1999                 foreach ($list as $node) {
2000                         $attr = [];
2001                         if ($node->attributes->length) {
2002                                 foreach ($node->attributes as $attribute) {
2003                                         $value = trim($attribute->value);
2004                                         if (empty($value)) {
2005                                                 continue;
2006                                         }
2007
2008                                         $attr[$attribute->name] = $value;
2009                                 }
2010
2011                                 if (empty($attr['property']) || empty($attr['content'])) {
2012                                         continue;
2013                                 }
2014                         }
2015
2016                         if ($attr['property'] == 'og:site_name') {
2017                                 $serverdata['site_name'] = $attr['content'];
2018                         }
2019
2020                         if ($attr['property'] == 'og:description') {
2021                                 $serverdata['info'] = $attr['content'];
2022                         }
2023
2024                         if (in_array($attr['property'], ['og:platform', 'generator'])) {
2025                                 if (in_array($attr['content'], array_keys($platforms))) {
2026                                         $serverdata['platform'] = $platforms[$attr['content']];
2027                                         $assigned = true;
2028                                 }
2029
2030                                 if (in_array($attr['content'], array_keys($ap_platforms))) {
2031                                         $serverdata['network'] = Protocol::ACTIVITYPUB;
2032                                 } elseif (in_array($attr['content'], array_values($zap_platforms))) {
2033                                         $serverdata['network'] = Protocol::ZOT;
2034                                 }
2035                         }
2036                 }
2037
2038                 $list = $xpath->query('//link[@rel="me"]');
2039                 foreach ($list as $node) {
2040                         foreach ($node->attributes as $attribute) {
2041                                 if (parse_url(trim($attribute->value), PHP_URL_HOST) == 'micro.blog') {
2042                                         $serverdata['version'] = trim($serverdata['platform'] . ' ' . $serverdata['version']);
2043                                         $serverdata['platform'] = 'microblog';
2044                                         $serverdata['network'] = Protocol::ACTIVITYPUB;
2045                                         $assigned = true;
2046                                 }
2047                         }
2048                 }
2049
2050                 if ($serverdata['platform'] != 'microblog') {
2051                         $list = $xpath->query('//link[@rel="micropub"]');
2052                         foreach ($list as $node) {
2053                                 foreach ($node->attributes as $attribute) {
2054                                         if (trim($attribute->value) == 'https://micro.blog/micropub') {
2055                                                 $serverdata['version'] = trim($serverdata['platform'] . ' ' . $serverdata['version']);
2056                                                 $serverdata['platform'] = 'microblog';
2057                                                 $serverdata['network'] = Protocol::ACTIVITYPUB;
2058                                                 $assigned = true;
2059                                         }
2060                                 }
2061                         }
2062                 }
2063
2064                 if ($assigned && in_array($serverdata['detection-method'], [self::DETECT_MANUAL, self::DETECT_HEADER])) {
2065                         $serverdata['detection-method'] = self::DETECT_BODY;
2066                 }
2067
2068                 return $serverdata;
2069         }
2070
2071         /**
2072          * Analyses the header data of a given server for hints about type and system of that server
2073          *
2074          * @param object $curlResult result of curl execution
2075          * @param array  $serverdata array with server data
2076          *
2077          * @return array server data
2078          */
2079         private static function analyseRootHeader($curlResult, array $serverdata): array
2080         {
2081                 if ($curlResult->getHeader('server') == 'Mastodon') {
2082                         $serverdata['platform'] = 'mastodon';
2083                         $serverdata['network'] = Protocol::ACTIVITYPUB;
2084                 } elseif ($curlResult->inHeader('x-diaspora-version')) {
2085                         $serverdata['platform'] = 'diaspora';
2086                         $serverdata['network'] = Protocol::DIASPORA;
2087                         $serverdata['version'] = $curlResult->getHeader('x-diaspora-version')[0] ?? '';
2088                 } elseif ($curlResult->inHeader('x-friendica-version')) {
2089                         $serverdata['platform'] = 'friendica';
2090                         $serverdata['network'] = Protocol::DFRN;
2091                         $serverdata['version'] = $curlResult->getHeader('x-friendica-version')[0] ?? '';
2092                 } else {
2093                         return $serverdata;
2094                 }
2095
2096                 if ($serverdata['detection-method'] == self::DETECT_MANUAL) {
2097                         $serverdata['detection-method'] = self::DETECT_HEADER;
2098                 }
2099
2100                 return $serverdata;
2101         }
2102
2103         /**
2104          * Update GServer entries
2105          */
2106         public static function discover()
2107         {
2108                 // Update the server list
2109                 self::discoverFederation();
2110
2111                 $no_of_queries = 5;
2112
2113                 $requery_days = intval(DI::config()->get('system', 'poco_requery_days'));
2114
2115                 if ($requery_days == 0) {
2116                         $requery_days = 7;
2117                 }
2118
2119                 $last_update = date('c', time() - (60 * 60 * 24 * $requery_days));
2120
2121                 $gservers = DBA::select('gserver', ['id', 'url', 'nurl', 'network', 'poco', 'directory-type'],
2122                         ["NOT `failed` AND `directory-type` != ? AND `last_poco_query` < ?", GServer::DT_NONE, $last_update],
2123                         ['order' => ['RAND()']]);
2124
2125                 while ($gserver = DBA::fetch($gservers)) {
2126                         Logger::info('Update peer list', ['server' => $gserver['url'], 'id' => $gserver['id']]);
2127                         Worker::add(Worker::PRIORITY_LOW, 'UpdateServerPeers', $gserver['url']);
2128
2129                         Logger::info('Update directory', ['server' => $gserver['url'], 'id' => $gserver['id']]);
2130                         Worker::add(Worker::PRIORITY_LOW, 'UpdateServerDirectory', $gserver);
2131
2132                         $fields = ['last_poco_query' => DateTimeFormat::utcNow()];
2133                         self::update($fields, ['nurl' => $gserver['nurl']]);
2134
2135                         if (--$no_of_queries == 0) {
2136                                 break;
2137                         }
2138                 }
2139
2140                 DBA::close($gservers);
2141         }
2142
2143         /**
2144          * Discover federated servers
2145          */
2146         private static function discoverFederation()
2147         {
2148                 $last = DI::keyValue()->get('poco_last_federation_discovery');
2149
2150                 if ($last) {
2151                         $next = $last + (24 * 60 * 60);
2152
2153                         if ($next > time()) {
2154                                 return;
2155                         }
2156                 }
2157
2158                 // Discover federated servers
2159                 $protocols = ['activitypub', 'diaspora', 'dfrn', 'ostatus'];
2160                 foreach ($protocols as $protocol) {
2161                         $query = '{nodes(protocol:"' . $protocol . '"){host}}';
2162                         $curlResult = DI::httpClient()->fetch('https://the-federation.info/graphql?query=' . urlencode($query), HttpClientAccept::JSON);
2163                         if (!empty($curlResult)) {
2164                                 $data = json_decode($curlResult, true);
2165                                 if (!empty($data['data']['nodes'])) {
2166                                         foreach ($data['data']['nodes'] as $server) {
2167                                                 // Using "only_nodeinfo" since servers that are listed on that page should always have it.
2168                                                 self::add('https://' . $server['host'], true);
2169                                         }
2170                                 }
2171                         }
2172                 }
2173
2174                 // Disvover Mastodon servers
2175                 $accesstoken = DI::config()->get('system', 'instances_social_key');
2176
2177                 if (!empty($accesstoken)) {
2178                         $api = 'https://instances.social/api/1.0/instances/list?count=0';
2179                         $curlResult = DI::httpClient()->get($api, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $accesstoken]]]);
2180                         if ($curlResult->isSuccess()) {
2181                                 $servers = json_decode($curlResult->getBody(), true);
2182
2183                                 if (!empty($servers['instances'])) {
2184                                         foreach ($servers['instances'] as $server) {
2185                                                 $url = (is_null($server['https_score']) ? 'http' : 'https') . '://' . $server['name'];
2186                                                 self::add($url);
2187                                         }
2188                                 }
2189                         }
2190                 }
2191
2192                 DI::keyValue()->set('poco_last_federation_discovery', time());
2193         }
2194
2195         /**
2196          * Set the protocol for the given server
2197          *
2198          * @param int $gsid     Server id
2199          * @param int $protocol Protocol id
2200          *
2201          * @throws Exception
2202          */
2203         public static function setProtocol(int $gsid, int $protocol)
2204         {
2205                 if (empty($gsid)) {
2206                         return;
2207                 }
2208
2209                 $gserver = DBA::selectFirst('gserver', ['protocol', 'url'], ['id' => $gsid]);
2210                 if (!DBA::isResult($gserver)) {
2211                         return;
2212                 }
2213
2214                 $old = $gserver['protocol'];
2215
2216                 if (!is_null($old)) {
2217                         /*
2218                         The priority for the protocols is:
2219                                 1. ActivityPub
2220                                 2. DFRN via Diaspora
2221                                 3. Legacy DFRN
2222                                 4. Diaspora
2223                                 5. OStatus
2224                         */
2225
2226                         // We don't need to change it when nothing is to be changed
2227                         if ($old == $protocol) {
2228                                 return;
2229                         }
2230
2231                         // We don't want to mark a server as OStatus when it had been marked with any other protocol before
2232                         if ($protocol == Post\DeliveryData::OSTATUS) {
2233                                 return;
2234                         }
2235
2236                         // If the server is marked as ActivityPub then we won't change it to anything different
2237                         if ($old == Post\DeliveryData::ACTIVITYPUB) {
2238                                 return;
2239                         }
2240
2241                         // Don't change it to anything lower than DFRN if the new one wasn't ActivityPub
2242                         if (($old == Post\DeliveryData::DFRN) && ($protocol != Post\DeliveryData::ACTIVITYPUB)) {
2243                                 return;
2244                         }
2245
2246                         // Don't change it to Diaspora when it is a legacy DFRN server
2247                         if (($old == Post\DeliveryData::LEGACY_DFRN) && ($protocol == Post\DeliveryData::DIASPORA)) {
2248                                 return;
2249                         }
2250                 }
2251
2252                 Logger::info('Protocol for server', ['protocol' => $protocol, 'old' => $old, 'id' => $gsid, 'url' => $gserver['url'], 'callstack' => System::callstack(20)]);
2253                 self::update(['protocol' => $protocol], ['id' => $gsid]);
2254         }
2255
2256         /**
2257          * Fetch the protocol of the given server
2258          *
2259          * @param int $gsid Server id
2260          *
2261          * @return ?int One of Post\DeliveryData protocol constants or null if unknown or gserver is missing
2262          *
2263          * @throws Exception
2264          */
2265         public static function getProtocol(int $gsid): ?int
2266         {
2267                 if (empty($gsid)) {
2268                         return null;
2269                 }
2270
2271                 $gserver = DBA::selectFirst('gserver', ['protocol'], ['id' => $gsid]);
2272                 if (DBA::isResult($gserver)) {
2273                         return $gserver['protocol'];
2274                 }
2275
2276                 return null;
2277         }
2278
2279         /**
2280          * Update rows in the gserver table.
2281          * Enforces gserver table field maximum sizes to avoid "Data too long" database errors
2282          *
2283          * @param array $fields
2284          * @param array $condition
2285          *
2286          * @return bool
2287          *
2288          * @throws Exception
2289          */
2290         public static function update(array $fields, array $condition): bool
2291         {
2292                 $fields = DI::dbaDefinition()->truncateFieldsForTable('gserver', $fields);
2293
2294                 return DBA::update('gserver', $fields, $condition);
2295         }
2296
2297         /**
2298          * Insert a row into the gserver table.
2299          * Enforces gserver table field maximum sizes to avoid "Data too long" database errors
2300          *
2301          * @param array $fields
2302          * @param int   $duplicate_mode What to do on a duplicated entry
2303          *
2304          * @return bool
2305          *
2306          * @throws Exception
2307          */
2308         public static function insert(array $fields, int $duplicate_mode = Database::INSERT_DEFAULT): bool
2309         {
2310                 $fields = DI::dbaDefinition()->truncateFieldsForTable('gserver', $fields);
2311
2312                 return DBA::insert('gserver', $fields, $duplicate_mode);
2313         }
2314 }