4 * @file src/Model/GServer.php
5 * This file includes the GServer class to handle with servers
7 namespace Friendica\Model;
11 use Friendica\Core\Config;
12 use Friendica\Core\Protocol;
13 use Friendica\Database\DBA;
14 use Friendica\Module\Register;
15 use Friendica\Util\Network;
16 use Friendica\Util\DateTimeFormat;
17 use Friendica\Util\Strings;
18 use Friendica\Util\XML;
19 use Friendica\Core\Logger;
20 use Friendica\Protocol\PortableContact;
21 use Friendica\Protocol\Diaspora;
22 use Friendica\Network\Probe;
25 * This class handles GServer related functions
32 const DT_MASTODON = 2;
34 * Checks if the given server is reachable
36 * @param string $profile URL of the given profile
37 * @param string $server URL of the given server (If empty, taken from profile)
38 * @param string $network Network value that is used, when detection failed
39 * @param boolean $force Force an update.
41 * @return boolean 'true' if server seems vital
43 public static function reachable(string $profile, string $server = '', string $network = '', bool $force = false)
46 $server = Contact::getBasepath($profile);
53 return self::check($server, $network, $force);
57 * Decides if a server needs to be updated, based upon several date fields
59 * @param date $created Creation date of that server entry
60 * @param date $updated When had the server entry be updated
61 * @param date $last_failure Last failure when contacting that server
62 * @param date $last_contact Last time the server had been contacted
64 * @return boolean Does the server record needs an update?
66 public static function updateNeeded($created, $updated, $last_failure, $last_contact)
68 $now = strtotime(DateTimeFormat::utcNow());
70 if ($updated > $last_contact) {
71 $contact_time = strtotime($updated);
73 $contact_time = strtotime($last_contact);
76 $failure_time = strtotime($last_failure);
77 $created_time = strtotime($created);
79 // If there is no "created" time then use the current time
80 if ($created_time <= 0) {
84 // If the last contact was less than 24 hours then don't update
85 if (($now - $contact_time) < (60 * 60 * 24)) {
89 // If the last failure was less than 24 hours then don't update
90 if (($now - $failure_time) < (60 * 60 * 24)) {
94 // If the last contact was less than a week ago and the last failure is older than a week then don't update
95 //if ((($now - $contact_time) < (60 * 60 * 24 * 7)) && ($contact_time > $failure_time))
98 // If the last contact time was more than a week ago and the contact was created more than a week ago, then only try once a week
99 if ((($now - $contact_time) > (60 * 60 * 24 * 7)) && (($now - $created_time) > (60 * 60 * 24 * 7)) && (($now - $failure_time) < (60 * 60 * 24 * 7))) {
103 // If the last contact time was more than a month ago and the contact was created more than a month ago, then only try once a month
104 if ((($now - $contact_time) > (60 * 60 * 24 * 30)) && (($now - $created_time) > (60 * 60 * 24 * 30)) && (($now - $failure_time) < (60 * 60 * 24 * 30))) {
112 * Checks the state of the given server.
114 * @param string $server_url URL of the given server
115 * @param string $network Network value that is used, when detection failed
116 * @param boolean $force Force an update.
118 * @return boolean 'true' if server seems vital
120 public static function check(string $server_url, string $network = '', bool $force = false)
122 // Unify the server address
123 $server_url = trim($server_url, '/');
124 $server_url = str_replace('/index.php', '', $server_url);
126 if ($server_url == '') {
130 $gserver = DBA::selectFirst('gserver', [], ['nurl' => Strings::normaliseLink($server_url)]);
131 if (DBA::isResult($gserver)) {
132 if ($gserver['created'] <= DBA::NULL_DATETIME) {
133 $fields = ['created' => DateTimeFormat::utcNow()];
134 $condition = ['nurl' => Strings::normaliseLink($server_url)];
135 DBA::update('gserver', $fields, $condition);
138 $last_contact = $gserver['last_contact'];
139 $last_failure = $gserver['last_failure'];
141 // See discussion under https://forum.friendi.ca/display/0b6b25a8135aabc37a5a0f5684081633
142 // It can happen that a zero date is in the database, but storing it again is forbidden.
143 if ($last_contact < DBA::NULL_DATETIME) {
144 $last_contact = DBA::NULL_DATETIME;
147 if ($last_failure < DBA::NULL_DATETIME) {
148 $last_failure = DBA::NULL_DATETIME;
151 if (!$force && !self::updateNeeded($gserver['created'], '', $last_failure, $last_contact)) {
152 Logger::info('No update needed', ['server' => $server_url]);
153 return ($last_contact >= $last_failure);
155 Logger::info('Server is outdated. Start discovery.', ['Server' => $server_url, 'Force' => $force, 'Created' => $gserver['created'], 'Failure' => $last_failure, 'Contact' => $last_contact]);
157 Logger::info('Server is unknown. Start discovery.', ['Server' => $server_url]);
160 return self::detect($server_url, $network);
164 * Detect server data (type, protocol, version number, ...)
165 * The detected data is then updated or inserted in the gserver table.
167 * @param string $url URL of the given server
168 * @param string $network Network value that is used, when detection failed
170 * @return boolean 'true' if server could be detected
172 public static function detect(string $url, string $network = '')
174 Logger::info('Detect server type', ['server' => $url]);
177 $original_url = $url;
179 // Remove URL content that is not supposed to exist for a server url
180 $urlparts = parse_url($url);
181 unset($urlparts['user']);
182 unset($urlparts['pass']);
183 unset($urlparts['query']);
184 unset($urlparts['fragment']);
185 $url = Network::unparseURL($urlparts);
187 // If the URL missmatches, then we mark the old entry as failure
188 if ($url != $original_url) {
189 DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($original_url)]);
192 // When a nodeinfo is present, we don't need to dig further
193 $xrd_timeout = Config::get('system', 'xrd_timeout');
194 $curlResult = Network::curl($url . '/.well-known/nodeinfo', false, ['timeout' => $xrd_timeout]);
195 if ($curlResult->isTimeout()) {
196 DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]);
200 $nodeinfo = self::fetchNodeinfo($url, $curlResult);
202 // When nodeinfo isn't present, we use the older 'statistics.json' endpoint
203 if (empty($nodeinfo)) {
204 $nodeinfo = self::fetchStatistics($url);
207 // If that didn't work out well, we use some protocol specific endpoints
208 // For Friendica and Zot based networks we have to dive deeper to reveal more details
209 if (empty($nodeinfo['network']) || in_array($nodeinfo['network'], [Protocol::DFRN, Protocol::ZOT])) {
210 // Fetch the landing page, possibly it reveals some data
211 if (empty($nodeinfo['network'])) {
212 $curlResult = Network::curl($url, false, ['timeout' => $xrd_timeout]);
213 if ($curlResult->isSuccess()) {
214 $serverdata = self::analyseRootHeader($curlResult, $serverdata);
215 $serverdata = self::analyseRootBody($curlResult, $serverdata, $url);
218 if (!$curlResult->isSuccess() || empty($curlResult->getBody())) {
219 DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]);
224 if (empty($serverdata['network']) || ($serverdata['network'] == Protocol::ACTIVITYPUB)) {
225 $serverdata = self::detectMastodonAlikes($url, $serverdata);
228 // All following checks are done for systems that always have got a "host-meta" endpoint.
229 // With this check we don't have to waste time and ressources for dead systems.
230 // Also this hopefully prevents us from receiving abuse messages.
231 if (empty($serverdata['network']) && !self::validHostMeta($url)) {
232 DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]);
236 if (empty($serverdata['network']) || in_array($serverdata['network'], [Protocol::DFRN, Protocol::ACTIVITYPUB])) {
237 $serverdata = self::detectFriendica($url, $serverdata);
240 // the 'siteinfo.json' is some specific endpoint of Hubzilla and Red
241 if (empty($serverdata['network']) || ($serverdata['network'] == Protocol::ZOT)) {
242 $serverdata = self::fetchSiteinfo($url, $serverdata);
245 // The 'siteinfo.json' doesn't seem to be present on older Hubzilla installations
246 if (empty($serverdata['network'])) {
247 $serverdata = self::detectHubzilla($url, $serverdata);
250 if (empty($serverdata['network'])) {
251 $serverdata = self::detectNextcloud($url, $serverdata);
254 if (empty($serverdata['network'])) {
255 $serverdata = self::detectGNUSocial($url, $serverdata);
258 $serverdata = $nodeinfo;
261 // Detect the directory type
262 $serverdata['directory-type'] = self::DT_NONE;
263 $serverdata = self::checkPoCo($url, $serverdata);
264 $serverdata = self::checkMastodonDirectory($url, $serverdata);
266 // We can't detect the network type. Possibly it is some system that we don't know yet
267 if (empty($serverdata['network'])) {
268 $serverdata['network'] = Protocol::PHANTOM;
271 // When we hadn't been able to detect the network type, we use the hint from the parameter
272 if (($serverdata['network'] == Protocol::PHANTOM) && !empty($network)) {
273 $serverdata['network'] = $network;
276 $serverdata['url'] = $url;
277 $serverdata['nurl'] = Strings::normaliseLink($url);
279 // We take the highest number that we do find
280 $registeredUsers = $serverdata['registered-users'] ?? 0;
282 // On an active server there has to be at least a single user
283 if (($serverdata['network'] != Protocol::PHANTOM) && ($registeredUsers == 0)) {
284 $registeredUsers = 1;
287 if ($serverdata['network'] != Protocol::PHANTOM) {
288 $gcontacts = DBA::count('gcontact', ['server_url' => [$url, $serverdata['nurl']]]);
289 $apcontacts = DBA::count('apcontact', ['baseurl' => [$url, $serverdata['nurl']]]);
290 $contacts = DBA::count('contact', ['uid' => 0, 'baseurl' => [$url, $serverdata['nurl']]]);
291 $serverdata['registered-users'] = max($gcontacts, $apcontacts, $contacts, $registeredUsers);
293 $serverdata['registered-users'] = $registeredUsers;
294 $serverdata = self::detectNetworkViaContacts($url, $serverdata);
297 $serverdata['last_contact'] = DateTimeFormat::utcNow();
299 $gserver = DBA::selectFirst('gserver', ['network'], ['nurl' => Strings::normaliseLink($url)]);
300 if (!DBA::isResult($gserver)) {
301 $serverdata['created'] = DateTimeFormat::utcNow();
302 $ret = DBA::insert('gserver', $serverdata);
304 // Don't override the network with 'unknown' when there had been a valid entry before
305 if (($serverdata['network'] == Protocol::PHANTOM) && !empty($gserver['network'])) {
306 unset($serverdata['network']);
309 $ret = DBA::update('gserver', $serverdata, ['nurl' => $serverdata['nurl']]);
312 if (!empty($serverdata['network']) && in_array($serverdata['network'], [Protocol::DFRN, Protocol::DIASPORA])) {
313 self::discoverRelay($url);
320 * Fetch relay data from a given server url
322 * @param string $server_url address of the server
323 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
325 private static function discoverRelay(string $server_url)
327 Logger::info('Discover relay data', ['server' => $server_url]);
329 $curlResult = Network::curl($server_url . '/.well-known/x-social-relay');
330 if (!$curlResult->isSuccess()) {
334 $data = json_decode($curlResult->getBody(), true);
335 if (!is_array($data)) {
339 $gserver = DBA::selectFirst('gserver', ['id', 'relay-subscribe', 'relay-scope'], ['nurl' => Strings::normaliseLink($server_url)]);
340 if (!DBA::isResult($gserver)) {
344 if (($gserver['relay-subscribe'] != $data['subscribe']) || ($gserver['relay-scope'] != $data['scope'])) {
345 $fields = ['relay-subscribe' => $data['subscribe'], 'relay-scope' => $data['scope']];
346 DBA::update('gserver', $fields, ['id' => $gserver['id']]);
349 DBA::delete('gserver-tag', ['gserver-id' => $gserver['id']]);
351 if ($data['scope'] == 'tags') {
354 foreach ($data['tags'] as $tag) {
355 $tag = mb_strtolower($tag);
356 if (strlen($tag) < 100) {
361 foreach ($tags as $tag) {
362 DBA::insert('gserver-tag', ['gserver-id' => $gserver['id'], 'tag' => $tag], true);
366 // Create or update the relay contact
368 if (isset($data['protocols'])) {
369 if (isset($data['protocols']['diaspora'])) {
370 $fields['network'] = Protocol::DIASPORA;
372 if (isset($data['protocols']['diaspora']['receive'])) {
373 $fields['batch'] = $data['protocols']['diaspora']['receive'];
374 } elseif (is_string($data['protocols']['diaspora'])) {
375 $fields['batch'] = $data['protocols']['diaspora'];
379 if (isset($data['protocols']['dfrn'])) {
380 $fields['network'] = Protocol::DFRN;
382 if (isset($data['protocols']['dfrn']['receive'])) {
383 $fields['batch'] = $data['protocols']['dfrn']['receive'];
384 } elseif (is_string($data['protocols']['dfrn'])) {
385 $fields['batch'] = $data['protocols']['dfrn'];
389 Diaspora::setRelayContact($server_url, $fields);
393 * Fetch server data from '/statistics.json' on the given server
395 * @param string $url URL of the given server
397 * @return array server data
399 private static function fetchStatistics(string $url)
401 $curlResult = Network::curl($url . '/statistics.json');
402 if (!$curlResult->isSuccess()) {
406 $data = json_decode($curlResult->getBody(), true);
413 if (!empty($data['version'])) {
414 $serverdata['version'] = $data['version'];
415 // Version numbers on statistics.json are presented with additional info, e.g.:
416 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
417 $serverdata['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $serverdata['version']);
420 if (!empty($data['name'])) {
421 $serverdata['site_name'] = $data['name'];
424 if (!empty($data['network'])) {
425 $serverdata['platform'] = $data['network'];
427 if ($serverdata['platform'] == 'Diaspora') {
428 $serverdata['network'] = Protocol::DIASPORA;
429 } elseif ($serverdata['platform'] == 'Friendica') {
430 $serverdata['network'] = Protocol::DFRN;
431 } elseif ($serverdata['platform'] == 'hubzilla') {
432 $serverdata['network'] = Protocol::ZOT;
433 } elseif ($serverdata['platform'] == 'redmatrix') {
434 $serverdata['network'] = Protocol::ZOT;
439 if (!empty($data['registrations_open'])) {
440 $serverdata['register_policy'] = Register::OPEN;
442 $serverdata['register_policy'] = Register::CLOSED;
449 * Detect server type by using the nodeinfo data
451 * @param string $url address of the server
452 * @return array Server data
453 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
455 private static function fetchNodeinfo(string $url, $curlResult)
457 $nodeinfo = json_decode($curlResult->getBody(), true);
459 if (!is_array($nodeinfo) || empty($nodeinfo['links'])) {
466 foreach ($nodeinfo['links'] as $link) {
467 if (!is_array($link) || empty($link['rel']) || empty($link['href'])) {
468 Logger::info('Invalid nodeinfo format', ['url' => $url]);
471 if ($link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/1.0') {
472 $nodeinfo1_url = $link['href'];
473 } elseif ($link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0') {
474 $nodeinfo2_url = $link['href'];
478 if ($nodeinfo1_url . $nodeinfo2_url == '') {
484 // When the nodeinfo url isn't on the same host, then there is obviously something wrong
485 if (!empty($nodeinfo2_url) && (parse_url($url, PHP_URL_HOST) == parse_url($nodeinfo2_url, PHP_URL_HOST))) {
486 $server = self::parseNodeinfo2($nodeinfo2_url);
489 // When the nodeinfo url isn't on the same host, then there is obviously something wrong
490 if (empty($server) && !empty($nodeinfo1_url) && (parse_url($url, PHP_URL_HOST) == parse_url($nodeinfo1_url, PHP_URL_HOST))) {
491 $server = self::parseNodeinfo1($nodeinfo1_url);
500 * @param string $nodeinfo_url address of the nodeinfo path
501 * @return array Server data
502 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
504 private static function parseNodeinfo1(string $nodeinfo_url)
506 $curlResult = Network::curl($nodeinfo_url);
508 if (!$curlResult->isSuccess()) {
512 $nodeinfo = json_decode($curlResult->getBody(), true);
514 if (!is_array($nodeinfo)) {
520 $server['register_policy'] = Register::CLOSED;
522 if (!empty($nodeinfo['openRegistrations'])) {
523 $server['register_policy'] = Register::OPEN;
526 if (is_array($nodeinfo['software'])) {
527 if (!empty($nodeinfo['software']['name'])) {
528 $server['platform'] = $nodeinfo['software']['name'];
531 if (!empty($nodeinfo['software']['version'])) {
532 $server['version'] = $nodeinfo['software']['version'];
533 // Version numbers on Nodeinfo are presented with additional info, e.g.:
534 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
535 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
539 if (!empty($nodeinfo['metadata']['nodeName'])) {
540 $server['site_name'] = $nodeinfo['metadata']['nodeName'];
543 if (!empty($nodeinfo['usage']['users']['total'])) {
544 $server['registered-users'] = $nodeinfo['usage']['users']['total'];
547 if (!empty($nodeinfo['protocols']['inbound']) && is_array($nodeinfo['protocols']['inbound'])) {
549 foreach ($nodeinfo['protocols']['inbound'] as $protocol) {
550 $protocols[$protocol] = true;
553 if (!empty($protocols['friendica'])) {
554 $server['network'] = Protocol::DFRN;
555 } elseif (!empty($protocols['activitypub'])) {
556 $server['network'] = Protocol::ACTIVITYPUB;
557 } elseif (!empty($protocols['diaspora'])) {
558 $server['network'] = Protocol::DIASPORA;
559 } elseif (!empty($protocols['ostatus'])) {
560 $server['network'] = Protocol::OSTATUS;
561 } elseif (!empty($protocols['gnusocial'])) {
562 $server['network'] = Protocol::OSTATUS;
563 } elseif (!empty($protocols['zot'])) {
564 $server['network'] = Protocol::ZOT;
568 if (empty($server)) {
578 * @param string $nodeinfo_url address of the nodeinfo path
579 * @return array Server data
580 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
582 private static function parseNodeinfo2(string $nodeinfo_url)
584 $curlResult = Network::curl($nodeinfo_url);
585 if (!$curlResult->isSuccess()) {
589 $nodeinfo = json_decode($curlResult->getBody(), true);
591 if (!is_array($nodeinfo)) {
597 $server['register_policy'] = Register::CLOSED;
599 if (!empty($nodeinfo['openRegistrations'])) {
600 $server['register_policy'] = Register::OPEN;
603 if (is_array($nodeinfo['software'])) {
604 if (!empty($nodeinfo['software']['name'])) {
605 $server['platform'] = $nodeinfo['software']['name'];
608 if (!empty($nodeinfo['software']['version'])) {
609 $server['version'] = $nodeinfo['software']['version'];
610 // Version numbers on Nodeinfo are presented with additional info, e.g.:
611 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
612 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
616 if (!empty($nodeinfo['metadata']['nodeName'])) {
617 $server['site_name'] = $nodeinfo['metadata']['nodeName'];
620 if (!empty($nodeinfo['usage']['users']['total'])) {
621 $server['registered-users'] = $nodeinfo['usage']['users']['total'];
624 if (!empty($nodeinfo['protocols'])) {
626 foreach ($nodeinfo['protocols'] as $protocol) {
627 $protocols[$protocol] = true;
630 if (!empty($protocols['dfrn'])) {
631 $server['network'] = Protocol::DFRN;
632 } elseif (!empty($protocols['activitypub'])) {
633 $server['network'] = Protocol::ACTIVITYPUB;
634 } elseif (!empty($protocols['diaspora'])) {
635 $server['network'] = Protocol::DIASPORA;
636 } elseif (!empty($protocols['ostatus'])) {
637 $server['network'] = Protocol::OSTATUS;
638 } elseif (!empty($protocols['gnusocial'])) {
639 $server['network'] = Protocol::OSTATUS;
640 } elseif (!empty($protocols['zot'])) {
641 $server['network'] = Protocol::ZOT;
645 if (empty($server)) {
653 * Fetch server information from a 'siteinfo.json' file on the given server
655 * @param string $url URL of the given server
656 * @param array $serverdata array with server data
658 * @return array server data
660 private static function fetchSiteinfo(string $url, array $serverdata)
662 $curlResult = Network::curl($url . '/siteinfo.json');
663 if (!$curlResult->isSuccess()) {
667 $data = json_decode($curlResult->getBody(), true);
672 if (!empty($data['url'])) {
673 $serverdata['platform'] = $data['platform'];
674 $serverdata['version'] = $data['version'];
677 if (!empty($data['plugins'])) {
678 if (in_array('pubcrawl', $data['plugins'])) {
679 $serverdata['network'] = Protocol::ACTIVITYPUB;
680 } elseif (in_array('diaspora', $data['plugins'])) {
681 $serverdata['network'] = Protocol::DIASPORA;
682 } elseif (in_array('gnusoc', $data['plugins'])) {
683 $serverdata['network'] = Protocol::OSTATUS;
685 $serverdata['network'] = Protocol::ZOT;
689 if (!empty($data['site_name'])) {
690 $serverdata['site_name'] = $data['site_name'];
693 if (!empty($data['channels_total'])) {
694 $serverdata['registered-users'] = $data['channels_total'];
697 if (!empty($data['register_policy'])) {
698 switch ($data['register_policy']) {
699 case 'REGISTER_OPEN':
700 $serverdata['register_policy'] = Register::OPEN;
703 case 'REGISTER_APPROVE':
704 $serverdata['register_policy'] = Register::APPROVE;
707 case 'REGISTER_CLOSED':
709 $serverdata['register_policy'] = Register::CLOSED;
718 * Checks if the server contains a valid host meta file
720 * @param string $url URL of the given server
722 * @return boolean 'true' if the server seems to be vital
724 private static function validHostMeta(string $url)
726 $xrd_timeout = Config::get('system', 'xrd_timeout');
727 $curlResult = Network::curl($url . '/.well-known/host-meta', false, ['timeout' => $xrd_timeout]);
728 if (!$curlResult->isSuccess()) {
732 $xrd = XML::parseString($curlResult->getBody(), false);
733 if (!is_object($xrd)) {
737 $elements = XML::elementToArray($xrd);
738 if (empty($elements) || empty($elements['xrd']) || empty($elements['xrd']['link'])) {
743 foreach ($elements['xrd']['link'] as $link) {
744 // When there is more than a single "link" element, the array looks slightly different
745 if (!empty($link['@attributes'])) {
746 $link = $link['@attributes'];
749 if (empty($link['rel']) || empty($link['template'])) {
753 if ($link['rel'] == 'lrdd') {
754 // When the webfinger host is the same like the system host, it should be ok.
755 $valid = (parse_url($url, PHP_URL_HOST) == parse_url($link['template'], PHP_URL_HOST));
763 * Detect the network of the given server via their known contacts
765 * @param string $url URL of the given server
766 * @param array $serverdata array with server data
768 * @return array server data
770 private static function detectNetworkViaContacts(string $url, array $serverdata)
774 $gcontacts = DBA::select('gcontact', ['url', 'nurl'], ['server_url' => [$url, $serverdata['nurl']]]);
775 while ($gcontact = DBA::fetch($gcontacts)) {
776 $contacts[$gcontact['nurl']] = $gcontact['url'];
778 DBA::close($gcontacts);
780 $apcontacts = DBA::select('apcontact', ['url'], ['baseurl' => [$url, $serverdata['nurl']]]);
781 while ($gcontact = DBA::fetch($gcontacts)) {
782 $contacts[Strings::normaliseLink($apcontact['url'])] = $apcontact['url'];
784 DBA::close($apcontacts);
786 $pcontacts = DBA::select('contact', ['url', 'nurl'], ['uid' => 0, 'baseurl' => [$url, $serverdata['nurl']]]);
787 while ($gcontact = DBA::fetch($gcontacts)) {
788 $contacts[$pcontact['nurl']] = $pcontact['url'];
790 DBA::close($pcontacts);
792 if (empty($contacts)) {
796 foreach ($contacts as $contact) {
797 $probed = Probe::uri($contact);
798 if (in_array($probed['network'], Protocol::FEDERATED)) {
799 $serverdata['network'] = $probed['network'];
804 $serverdata['registered-users'] = max($serverdata['registered-users'], count($contacts));
810 * Checks if the given server does have a '/poco' endpoint.
811 * This is used for the 'PortableContact' functionality,
812 * which is used by both Friendica and Hubzilla.
814 * @param string $url URL of the given server
815 * @param array $serverdata array with server data
817 * @return array server data
819 private static function checkPoCo(string $url, array $serverdata)
821 $serverdata['poco'] = '';
823 $curlResult = Network::curl($url. '/poco');
824 if (!$curlResult->isSuccess()) {
828 $data = json_decode($curlResult->getBody(), true);
833 if (!empty($data['totalResults'])) {
834 $registeredUsers = $serverdata['registered-users'] ?? 0;
835 $serverdata['registered-users'] = max($data['totalResults'], $registeredUsers);
836 $serverdata['directory-type'] = self::DT_POCO;
837 $serverdata['poco'] = $url . '/poco';
844 * Checks if the given server does have a Mastodon style directory endpoint.
846 * @param string $url URL of the given server
847 * @param array $serverdata array with server data
849 * @return array server data
851 public static function checkMastodonDirectory(string $url, array $serverdata)
853 $curlResult = Network::curl($url . '/api/v1/directory?limit=1');
854 if (!$curlResult->isSuccess()) {
858 $data = json_decode($curlResult->getBody(), true);
863 if (count($data) == 1) {
864 $serverdata['directory-type'] = self::DT_MASTODON;
871 * Detects the version number of a given server when it was a NextCloud installation
873 * @param string $url URL of the given server
874 * @param array $serverdata array with server data
876 * @return array server data
878 private static function detectNextcloud(string $url, array $serverdata)
880 $curlResult = Network::curl($url . '/status.php');
882 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
886 $data = json_decode($curlResult->getBody(), true);
891 if (!empty($data['version'])) {
892 $serverdata['platform'] = 'nextcloud';
893 $serverdata['version'] = $data['version'];
894 $serverdata['network'] = Protocol::ACTIVITYPUB;
901 * Detects data from a given server url if it was a mastodon alike system
903 * @param string $url URL of the given server
904 * @param array $serverdata array with server data
906 * @return array server data
908 private static function detectMastodonAlikes(string $url, array $serverdata)
910 $curlResult = Network::curl($url . '/api/v1/instance');
912 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
916 $data = json_decode($curlResult->getBody(), true);
921 if (!empty($data['version'])) {
922 $serverdata['platform'] = 'mastodon';
923 $serverdata['version'] = $data['version'] ?? '';
924 $serverdata['network'] = Protocol::ACTIVITYPUB;
927 if (!empty($data['title'])) {
928 $serverdata['site_name'] = $data['title'];
931 if (!empty($data['description'])) {
932 $serverdata['info'] = trim($data['description']);
935 if (!empty($data['stats']['user_count'])) {
936 $serverdata['registered-users'] = $data['stats']['user_count'];
939 if (!empty($serverdata['version']) && preg_match('/.*?\(compatible;\s(.*)\s(.*)\)/ism', $serverdata['version'], $matches)) {
940 $serverdata['platform'] = $matches[1];
941 $serverdata['version'] = $matches[2];
944 if (!empty($serverdata['version']) && strstr($serverdata['version'], 'Pleroma')) {
945 $serverdata['platform'] = 'pleroma';
946 $serverdata['version'] = trim(str_replace('Pleroma', '', $serverdata['version']));
953 * Detects data from typical Hubzilla endpoints
955 * @param string $url URL of the given server
956 * @param array $serverdata array with server data
958 * @return array server data
960 private static function detectHubzilla(string $url, array $serverdata)
962 $curlResult = Network::curl($url . '/api/statusnet/config.json');
963 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
967 $data = json_decode($curlResult->getBody(), true);
972 if (!empty($data['site']['name'])) {
973 $serverdata['site_name'] = $data['site']['name'];
976 if (!empty($data['site']['platform'])) {
977 $serverdata['platform'] = $data['site']['platform']['PLATFORM_NAME'];
978 $serverdata['version'] = $data['site']['platform']['STD_VERSION'];
979 $serverdata['network'] = Protocol::ZOT;
982 if (!empty($data['site']['hubzilla'])) {
983 $serverdata['platform'] = $data['site']['hubzilla']['PLATFORM_NAME'];
984 $serverdata['version'] = $data['site']['hubzilla']['RED_VERSION'];
985 $serverdata['network'] = Protocol::ZOT;
988 if (!empty($data['site']['redmatrix'])) {
989 if (!empty($data['site']['redmatrix']['PLATFORM_NAME'])) {
990 $serverdata['platform'] = $data['site']['redmatrix']['PLATFORM_NAME'];
991 } elseif (!empty($data['site']['redmatrix']['RED_PLATFORM'])) {
992 $serverdata['platform'] = $data['site']['redmatrix']['RED_PLATFORM'];
995 $serverdata['version'] = $data['site']['redmatrix']['RED_VERSION'];
996 $serverdata['network'] = Protocol::ZOT;
1000 $inviteonly = false;
1003 if (!empty($data['site']['closed'])) {
1004 $closed = self::toBoolean($data['site']['closed']);
1007 if (!empty($data['site']['private'])) {
1008 $private = self::toBoolean($data['site']['private']);
1011 if (!empty($data['site']['inviteonly'])) {
1012 $inviteonly = self::toBoolean($data['site']['inviteonly']);
1015 if (!$closed && !$private and $inviteonly) {
1016 $register_policy = Register::APPROVE;
1017 } elseif (!$closed && !$private) {
1018 $register_policy = Register::OPEN;
1020 $register_policy = Register::CLOSED;
1027 * Converts input value to a boolean value
1029 * @param string|integer $val
1033 private static function toBoolean($val)
1035 if (($val == 'true') || ($val == 1)) {
1037 } elseif (($val == 'false') || ($val == 0)) {
1045 * Detect if the URL belongs to a GNU Social server
1047 * @param string $url URL of the given server
1048 * @param array $serverdata array with server data
1050 * @return array server data
1052 private static function detectGNUSocial(string $url, array $serverdata)
1054 // Test for GNU Social
1055 $curlResult = Network::curl($url . '/api/gnusocial/version.json');
1056 if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') &&
1057 ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) {
1058 $serverdata['platform'] = 'gnusocial';
1059 // Remove junk that some GNU Social servers return
1060 $serverdata['version'] = str_replace(chr(239) . chr(187) . chr(191), '', $curlResult->getBody());
1061 $serverdata['version'] = trim($serverdata['version'], '"');
1062 $serverdata['network'] = Protocol::OSTATUS;
1066 // Test for Statusnet
1067 $curlResult = Network::curl($url . '/api/statusnet/version.json');
1068 if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') &&
1069 ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) {
1070 $serverdata['platform'] = 'statusnet';
1071 // Remove junk that some GNU Social servers return
1072 $serverdata['version'] = str_replace(chr(239).chr(187).chr(191), '', $curlResult->getBody());
1073 $serverdata['version'] = trim($serverdata['version'], '"');
1074 $serverdata['network'] = Protocol::OSTATUS;
1081 * Detect if the URL belongs to a Friendica server
1083 * @param string $url URL of the given server
1084 * @param array $serverdata array with server data
1086 * @return array server data
1088 private static function detectFriendica(string $url, array $serverdata)
1090 $curlResult = Network::curl($url . '/friendica/json');
1091 if (!$curlResult->isSuccess()) {
1092 $curlResult = Network::curl($url . '/friendika/json');
1095 if (!$curlResult->isSuccess()) {
1099 $data = json_decode($curlResult->getBody(), true);
1100 if (empty($data) || empty($data['version'])) {
1104 $serverdata['network'] = Protocol::DFRN;
1105 $serverdata['version'] = $data['version'];
1107 if (!empty($data['no_scrape_url'])) {
1108 $serverdata['noscrape'] = $data['no_scrape_url'];
1111 if (!empty($data['site_name'])) {
1112 $serverdata['site_name'] = $data['site_name'];
1115 if (!empty($data['info'])) {
1116 $serverdata['info'] = trim($data['info']);
1119 $register_policy = ($data['register_policy'] ?? '') ?: 'REGISTER_CLOSED';
1120 switch ($register_policy) {
1121 case 'REGISTER_OPEN':
1122 $serverdata['register_policy'] = Register::OPEN;
1125 case 'REGISTER_APPROVE':
1126 $serverdata['register_policy'] = Register::APPROVE;
1129 case 'REGISTER_CLOSED':
1130 case 'REGISTER_INVITATION':
1131 $serverdata['register_policy'] = Register::CLOSED;
1134 Logger::info('Register policy is invalid', ['policy' => $register_policy, 'server' => $url]);
1135 $serverdata['register_policy'] = Register::CLOSED;
1139 $serverdata['platform'] = $data['platform'] ?? '';
1145 * Analyses the landing page of a given server for hints about type and system of that server
1147 * @param object $curlResult result of curl execution
1148 * @param array $serverdata array with server data
1149 * @param string $url Server URL
1151 * @return array server data
1153 private static function analyseRootBody($curlResult, array $serverdata, string $url)
1155 $doc = new DOMDocument();
1156 @$doc->loadHTML($curlResult->getBody());
1157 $xpath = new DOMXPath($doc);
1159 $title = trim(XML::getFirstNodeValue($xpath, '//head/title/text()'));
1160 if (!empty($title)) {
1161 $serverdata['site_name'] = $title;
1164 $list = $xpath->query('//meta[@name]');
1166 foreach ($list as $node) {
1168 if ($node->attributes->length) {
1169 foreach ($node->attributes as $attribute) {
1170 $value = trim($attribute->value);
1171 if (empty($value)) {
1175 $attr[$attribute->name] = $value;
1178 if (empty($attr['name']) || empty($attr['content'])) {
1183 if ($attr['name'] == 'description') {
1184 $serverdata['info'] = $attr['content'];
1187 if ($attr['name'] == 'application-name') {
1188 $serverdata['platform'] = $attr['content'];
1189 if (in_array($attr['content'], ['Misskey', 'Write.as'])) {
1190 $serverdata['network'] = Protocol::ACTIVITYPUB;
1194 if ($attr['name'] == 'generator') {
1195 $serverdata['platform'] = $attr['content'];
1197 $version_part = explode(' ', $attr['content']);
1199 if (count($version_part) == 2) {
1200 if (in_array($version_part[0], ['WordPress'])) {
1201 $serverdata['platform'] = $version_part[0];
1202 $serverdata['version'] = $version_part[1];
1204 // We still do need a reliable test if some AP plugin is activated
1205 if (DBA::exists('apcontact', ['baseurl' => $url])) {
1206 $serverdata['network'] = Protocol::ACTIVITYPUB;
1208 $serverdata['network'] = Protocol::FEED;
1211 if (in_array($version_part[0], ['Friendika', 'Friendica'])) {
1212 $serverdata['platform'] = $version_part[0];
1213 $serverdata['version'] = $version_part[1];
1214 $serverdata['network'] = Protocol::DFRN;
1220 $list = $xpath->query('//meta[@property]');
1222 foreach ($list as $node) {
1224 if ($node->attributes->length) {
1225 foreach ($node->attributes as $attribute) {
1226 $value = trim($attribute->value);
1227 if (empty($value)) {
1231 $attr[$attribute->name] = $value;
1234 if (empty($attr['property']) || empty($attr['content'])) {
1239 if ($attr['property'] == 'og:site_name') {
1240 $serverdata['site_name'] = $attr['content'];
1243 if ($attr['property'] == 'og:description') {
1244 $serverdata['info'] = $attr['content'];
1247 if ($attr['property'] == 'og:platform') {
1248 $serverdata['platform'] = $attr['content'];
1250 if (in_array($attr['content'], ['PeerTube'])) {
1251 $serverdata['network'] = Protocol::ACTIVITYPUB;
1255 if ($attr['property'] == 'generator') {
1256 $serverdata['platform'] = $attr['content'];
1258 if (in_array($attr['content'], ['hubzilla'])) {
1259 // We later check which compatible protocol modules are loaded.
1260 $serverdata['network'] = Protocol::ZOT;
1269 * Analyses the header data of a given server for hints about type and system of that server
1271 * @param object $curlResult result of curl execution
1272 * @param array $serverdata array with server data
1274 * @return array server data
1276 private static function analyseRootHeader($curlResult, array $serverdata)
1278 if ($curlResult->getHeader('server') == 'Mastodon') {
1279 $serverdata['platform'] = 'mastodon';
1280 $serverdata['network'] = $network = Protocol::ACTIVITYPUB;
1281 } elseif ($curlResult->inHeader('x-diaspora-version')) {
1282 $serverdata['platform'] = 'diaspora';
1283 $serverdata['network'] = $network = Protocol::DIASPORA;
1284 $serverdata['version'] = $curlResult->getHeader('x-diaspora-version');
1286 } elseif ($curlResult->inHeader('x-friendica-version')) {
1287 $serverdata['platform'] = 'friendica';
1288 $serverdata['network'] = $network = Protocol::DFRN;
1289 $serverdata['version'] = $curlResult->getHeader('x-friendica-version');
1295 * Update the user directory of a given gserver record
1297 * @param array $gserver gserver record
1299 public static function updateDirectory(array $gserver)
1301 /// @todo Add Mastodon API directory
1303 if (!empty($gserver['poco'])) {
1304 PortableContact::discoverSingleServer($gserver['id']);