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