4 * @file src/Model/GServer.php
5 * @brief 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;
22 * @brief This class handles GServer related functions
27 * Detect server data (type, protocol, version number, ...)
28 * The detected data is then updated or inserted in the gserver table.
30 * @param string $url Server url
32 * @return boolean 'true' if server could be detected
34 public static function detect($url)
38 // - Pleroma version number
42 // When a nodeinfo is present, we don't need to dig further
43 $xrd_timeout = Config::get('system', 'xrd_timeout');
44 $curlResult = Network::curl($url . '/.well-known/nodeinfo', false, ['timeout' => $xrd_timeout]);
45 if ($curlResult->isTimeout()) {
46 DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]);
50 $nodeinfo = self::fetchNodeinfo($url, $curlResult);
52 // When nodeinfo isn't present, we use the older 'statistics.json' endpoint
53 if (empty($nodeinfo)) {
54 $nodeinfo = self::fetchStatistics($url);
57 // If that didn't work out well, we use some protocol specific endpoints
58 if (empty($nodeinfo) || ($nodeinfo['network'] == Protocol::DFRN)) {
59 // Fetch the landing page, possibly it reveals some data
60 $curlResult = Network::curl($url, false, ['timeout' => $xrd_timeout]);
61 if ($curlResult->isSuccess()) {
62 $serverdata = self::analyseRootHeader($curlResult, $serverdata);
63 $serverdata = self::analyseRootBody($curlResult, $serverdata);
66 if (!$curlResult->isSuccess() || empty($curlResult->getBody())) {
67 DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]);
71 if (empty($serverdata['network']) || ($serverdata['network'] == Protocol::DFRN)) {
72 $serverdata = self::detectFriendica($url, $serverdata);
75 if (empty($serverdata['network']) || ($serverdata['network'] == Protocol::ACTIVITYPUB)) {
76 $serverdata = self::detectMastodonAlikes($url, $serverdata);
79 // the 'siteinfo.json' is some specific endpoint of Hubzilla and Red
80 if (empty($serverdata['network']) || ($serverdata['network'] == Protocol::ZOT)) {
81 $serverdata = self::fetchSiteinfo($url, $serverdata);
84 // The 'siteinfo.json' doesn't seem to be present on older Hubzilla installations
85 if (empty($serverdata['network'])) {
86 $serverdata = self::detectHubzilla($url, $serverdata);
89 if (empty($serverdata['network'])) {
90 $serverdata = self::detectNextcloud($url, $serverdata);
93 if (empty($serverdata['network'])) {
94 $serverdata = self::detectGNUSocial($url, $serverdata);
97 $serverdata = $nodeinfo;
100 // We can't detect the network type. Possibly it is some system that we don't know yet
101 if (empty($serverdata['network'])) {
102 $serverdata['network'] = Protocol::PHANTOM;
105 $serverdata['url'] = $url;
106 $serverdata['nurl'] = Strings::normaliseLink($url);
108 // When we don't have the registered users, we simply count what we know
109 if (empty($serverdata['registered-users'])) {
110 $gcontacts = DBA::count('gcontact', ['server_url' => [$url, $serverdata['nurl']]]);
111 $apcontacts = DBA::count('apcontact', ['baseurl' => [$url, $serverdata['nurl']]]);
112 $contacts = DBA::count('contact', ['uid' => 0, 'baseurl' => [$url, $serverdata['nurl']]]);
113 $serverdata['registered-users'] = max($gcontacts, $apcontacts, $contacts);
116 $serverdata['last_contact'] = DateTimeFormat::utcNow();
118 if (!DBA::exists('gserver', ['nurl' => Strings::normaliseLink($url)])) {
119 $serverdata['created'] = DateTimeFormat::utcNow();
120 $ret = DBA::insert('gserver', $serverdata);
122 $ret = DBA::update('gserver', $serverdata, ['nurl' => $serverdata['nurl']]);
125 print_r($serverdata);
130 private static function fetchStatistics($url)
132 $curlResult = Network::curl($url . '/statistics.json');
133 if (!$curlResult->isSuccess()) {
137 $data = json_decode($curlResult->getBody(), true);
144 if (!empty($data['version'])) {
145 $serverdata['version'] = $data['version'];
146 // Version numbers on statistics.json are presented with additional info, e.g.:
147 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
148 $serverdata['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $serverdata['version']);
151 if (!empty($data['name'])) {
152 $serverdata['site_name'] = $data['name'];
155 if (!empty($data['network'])) {
156 $serverdata['platform'] = $data['network'];
158 if ($serverdata['platform'] == 'Diaspora') {
159 $serverdata['network'] = Protocol::DIASPORA;
160 } elseif ($serverdata['platform'] == 'Friendica') {
161 $serverdata['network'] = Protocol::DFRN;
162 } elseif ($serverdata['platform'] == 'hubzilla') {
163 $serverdata['network'] = Protocol::ZOT;
164 } elseif ($serverdata['platform'] == 'redmatrix') {
165 $serverdata['network'] = Protocol::ZOT;
170 if (!empty($data['registrations_open'])) {
171 $serverdata['register_policy'] = Register::OPEN;
173 $serverdata['register_policy'] = Register::CLOSED;
180 * @brief Detect server type by using the nodeinfo data
182 * @param string $url address of the server
183 * @return array Server data
184 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
186 private static function fetchNodeinfo($url, $curlResult)
188 $nodeinfo = json_decode($curlResult->getBody(), true);
190 if (!is_array($nodeinfo) || empty($nodeinfo['links'])) {
197 foreach ($nodeinfo['links'] as $link) {
198 if (!is_array($link) || empty($link['rel']) || empty($link['href'])) {
199 Logger::info('Invalid nodeinfo format', ['url' => $url]);
202 if ($link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/1.0') {
203 $nodeinfo1_url = $link['href'];
204 } elseif ($link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0') {
205 $nodeinfo2_url = $link['href'];
209 if ($nodeinfo1_url . $nodeinfo2_url == '') {
215 // When the nodeinfo url isn't on the same host, then there is obviously something wrong
216 if (!empty($nodeinfo2_url) && (parse_url($url, PHP_URL_HOST) == parse_url($nodeinfo2_url, PHP_URL_HOST))) {
217 $server = self::parseNodeinfo2($nodeinfo2_url);
220 // When the nodeinfo url isn't on the same host, then there is obviously something wrong
221 if (empty($server) && !empty($nodeinfo1_url) && (parse_url($url, PHP_URL_HOST) == parse_url($nodeinfo1_url, PHP_URL_HOST))) {
222 $server = self::parseNodeinfo1($nodeinfo1_url);
229 * @brief Parses Nodeinfo 1
231 * @param string $nodeinfo_url address of the nodeinfo path
232 * @return array Server data
233 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
235 private static function parseNodeinfo1($nodeinfo_url)
237 $curlResult = Network::curl($nodeinfo_url);
239 if (!$curlResult->isSuccess()) {
243 $nodeinfo = json_decode($curlResult->getBody(), true);
245 if (!is_array($nodeinfo)) {
251 $server['register_policy'] = Register::CLOSED;
253 if (!empty($nodeinfo['openRegistrations'])) {
254 $server['register_policy'] = Register::OPEN;
257 if (is_array($nodeinfo['software'])) {
258 if (!empty($nodeinfo['software']['name'])) {
259 $server['platform'] = $nodeinfo['software']['name'];
262 if (!empty($nodeinfo['software']['version'])) {
263 $server['version'] = $nodeinfo['software']['version'];
264 // Version numbers on Nodeinfo are presented with additional info, e.g.:
265 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
266 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
270 if (!empty($nodeinfo['metadata']['nodeName'])) {
271 $server['site_name'] = $nodeinfo['metadata']['nodeName'];
274 if (!empty($nodeinfo['usage']['users']['total'])) {
275 $server['registered-users'] = $nodeinfo['usage']['users']['total'];
278 if (!empty($nodeinfo['protocols']['inbound']) && is_array($nodeinfo['protocols']['inbound'])) {
280 foreach ($nodeinfo['protocols']['inbound'] as $protocol) {
281 $protocols[$protocol] = true;
284 if (!empty($protocols['friendica'])) {
285 $server['network'] = Protocol::DFRN;
286 } elseif (!empty($protocols['activitypub'])) {
287 $server['network'] = Protocol::ACTIVITYPUB;
288 } elseif (!empty($protocols['diaspora'])) {
289 $server['network'] = Protocol::DIASPORA;
290 } elseif (!empty($protocols['ostatus'])) {
291 $server['network'] = Protocol::OSTATUS;
292 } elseif (!empty($protocols['gnusocial'])) {
293 $server['network'] = Protocol::OSTATUS;
294 } elseif (!empty($protocols['zot'])) {
295 $server['network'] = Protocol::ZOT;
307 * @brief Parses Nodeinfo 2
309 * @param string $nodeinfo_url address of the nodeinfo path
310 * @return array Server data
311 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
313 private static function parseNodeinfo2($nodeinfo_url)
315 $curlResult = Network::curl($nodeinfo_url);
316 if (!$curlResult->isSuccess()) {
320 $nodeinfo = json_decode($curlResult->getBody(), true);
322 if (!is_array($nodeinfo)) {
328 $server['register_policy'] = Register::CLOSED;
330 if (!empty($nodeinfo['openRegistrations'])) {
331 $server['register_policy'] = Register::OPEN;
334 if (is_array($nodeinfo['software'])) {
335 if (!empty($nodeinfo['software']['name'])) {
336 $server['platform'] = $nodeinfo['software']['name'];
339 if (!empty($nodeinfo['software']['version'])) {
340 $server['version'] = $nodeinfo['software']['version'];
341 // Version numbers on Nodeinfo are presented with additional info, e.g.:
342 // 0.6.3.0-p1702cc1c, 0.6.99.0-p1b9ab160 or 3.4.3-2-1191.
343 $server['version'] = preg_replace('=(.+)-(.{4,})=ism', '$1', $server['version']);
347 if (!empty($nodeinfo['metadata']['nodeName'])) {
348 $server['site_name'] = $nodeinfo['metadata']['nodeName'];
351 if (!empty($nodeinfo['usage']['users']['total'])) {
352 $server['registered-users'] = $nodeinfo['usage']['users']['total'];
355 if (!empty($nodeinfo['protocols'])) {
357 foreach ($nodeinfo['protocols'] as $protocol) {
358 $protocols[$protocol] = true;
361 if (!empty($protocols['friendica'])) {
362 $server['network'] = Protocol::DFRN;
363 } elseif (!empty($protocols['activitypub'])) {
364 $server['network'] = Protocol::ACTIVITYPUB;
365 } elseif (!empty($protocols['diaspora'])) {
366 $server['network'] = Protocol::DIASPORA;
367 } elseif (!empty($protocols['ostatus'])) {
368 $server['network'] = Protocol::OSTATUS;
369 } elseif (!empty($protocols['gnusocial'])) {
370 $server['network'] = Protocol::OSTATUS;
371 } elseif (!empty($protocols['zot'])) {
372 $server['network'] = Protocol::ZOT;
376 if (empty($server)) {
383 private static function fetchSiteinfo($url, $serverdata)
385 $curlResult = Network::curl($url . '/siteinfo.json');
386 if (!$curlResult->isSuccess()) {
390 $data = json_decode($curlResult->getBody(), true);
395 if (!empty($data['url'])) {
396 $serverdata['platform'] = $data['platform'];
397 $serverdata['version'] = $data['version'];
400 if (!empty($data['plugins'])) {
401 if (in_array('pubcrawl', $data['plugins'])) {
402 $serverdata['network'] = Protocol::ACTIVITYPUB;
403 } elseif (in_array('diaspora', $data['plugins'])) {
404 $serverdata['network'] = Protocol::DIASPORA;
405 } elseif (in_array('gnusoc', $data['plugins'])) {
406 $serverdata['network'] = Protocol::OSTATUS;
408 $serverdata['network'] = Protocol::ZOT;
412 if (!empty($data['site_name'])) {
413 $serverdata['site_name'] = $data['site_name'];
416 if (!empty($data['channels_total'])) {
417 $serverdata['registered-users'] = $data['channels_total'];
420 if (!empty($data['register_policy'])) {
421 switch ($data['register_policy']) {
422 case 'REGISTER_OPEN':
423 $serverdata['register_policy'] = Register::OPEN;
426 case 'REGISTER_APPROVE':
427 $serverdata['register_policy'] = Register::APPROVE;
430 case 'REGISTER_CLOSED':
432 $serverdata['register_policy'] = Register::CLOSED;
440 private static function detectNextcloud($url, $serverdata)
442 $curlResult = Network::curl($url . '/status.php');
444 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
448 $data = json_decode($curlResult->getBody(), true);
453 if (!empty($data['version'])) {
454 $serverdata['platform'] = 'nextcloud';
455 $serverdata['version'] = $data['version'];
456 $serverdata['network'] = Protocol::ACTIVITYPUB;
462 private static function detectMastodonAlikes($url, $serverdata)
464 $curlResult = Network::curl($url . '/api/v1/instance');
466 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
470 $data = json_decode($curlResult->getBody(), true);
475 if (!empty($data['version'])) {
476 $serverdata['platform'] = 'mastodon';
477 $serverdata['version'] = defaults($data, 'version', '');
478 $serverdata['network'] = Protocol::ACTIVITYPUB;
481 if (!empty($data['title'])) {
482 $serverdata['site_name'] = $data['title'];
485 if (!empty($data['description'])) {
486 $serverdata['info'] = trim($data['description']);
489 if (!empty($data['stats']['user_count'])) {
490 $serverdata['registered-users'] = $data['stats']['user_count'];
493 if (!empty($serverdata['version']) && strstr($serverdata['version'], 'Pleroma')) {
494 $serverdata['platform'] = 'pleroma';
495 $serverdata['version'] = trim(str_replace('Pleroma', '', $serverdata['version'])); // 2.7.2 (compatible; Pleroma 1.0.0-1225-gf31ad554-develop)
501 private static function detectHubzilla($url, $serverdata)
503 $curlResult = Network::curl($url . '/api/statusnet/config.json');
504 if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) {
508 $data = json_decode($curlResult->getBody(), true);
513 if (!empty($data['site']['name'])) {
514 $serverdata['site_name'] = $data['site']['name'];
517 if (!empty($data['site']['platform'])) {
518 $serverdata['platform'] = $data['site']['platform']['PLATFORM_NAME'];
519 $serverdata['version'] = $data['site']['platform']['STD_VERSION'];
520 $serverdata['network'] = Protocol::ZOT;
523 if (!empty($data['site']['hubzilla'])) {
524 $serverdata['platform'] = $data['site']['hubzilla']['PLATFORM_NAME'];
525 $serverdata['version'] = $data['site']['hubzilla']['RED_VERSION'];
526 $serverdata['network'] = Protocol::ZOT;
529 if (!empty($data['site']['redmatrix'])) {
530 if (!empty($data['site']['redmatrix']['PLATFORM_NAME'])) {
531 $serverdata['platform'] = $data['site']['redmatrix']['PLATFORM_NAME'];
532 } elseif (!empty($data['site']['redmatrix']['RED_PLATFORM'])) {
533 $serverdata['platform'] = $data['site']['redmatrix']['RED_PLATFORM'];
536 $serverdata['version'] = $data['site']['redmatrix']['RED_VERSION'];
537 $serverdata['network'] = Protocol::ZOT;
544 if (!empty($data['site']['closed'])) {
545 $closed = self::toBoolean($data['site']['closed']);
548 if (!empty($data['site']['private'])) {
549 $private = self::toBoolean($data['site']['private']);
552 if (!empty($data['site']['inviteonly'])) {
553 $inviteonly = self::toBoolean($data['site']['inviteonly']);
556 if (!$closed && !$private and $inviteonly) {
557 $register_policy = Register::APPROVE;
558 } elseif (!$closed && !$private) {
559 $register_policy = Register::OPEN;
561 $register_policy = Register::CLOSED;
567 private static function toBoolean($val)
569 if (($val == 'true') || ($val == 1)) {
571 } elseif (($val == 'false') || ($val == 0)) {
578 private static function detectGNUSocial($url, $serverdata)
580 $curlResult = Network::curl($url . '/api/statusnet/version.json');
582 if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') &&
583 ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) {
584 $serverdata['platform'] = 'StatusNet';
585 // Remove junk that some GNU Social servers return
586 $serverdata['version'] = str_replace(chr(239).chr(187).chr(191), '', $curlResult->getBody());
587 $serverdata['version'] = trim($serverdata['version'], '"');
588 $serverdata['network'] = Protocol::OSTATUS;
591 // Test for GNU Social
592 $curlResult = Network::curl($url . '/api/gnusocial/version.json');
594 if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') &&
595 ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) {
596 $serverdata['platform'] = 'GNU Social';
597 // Remove junk that some GNU Social servers return
598 $serverdata['version'] = str_replace(chr(239) . chr(187) . chr(191), '', $curlResult->getBody());
599 $serverdata['version'] = trim($serverdata['version'], '"');
600 $serverdata['network'] = Protocol::OSTATUS;
606 private static function detectFriendica($url, $serverdata)
608 $curlResult = Network::curl($url . '/friendica/json');
609 if (!$curlResult->isSuccess()) {
610 $curlResult = Network::curl($url . '/friendika/json');
613 if (!$curlResult->isSuccess()) {
617 $data = json_decode($curlResult->getBody(), true);
618 if (empty($data) || empty($data['version'])) {
622 $serverdata['network'] = Protocol::DFRN;
623 $serverdata['version'] = $data['version'];
625 if (!empty($data['no_scrape_url'])) {
626 $serverdata['noscrape'] = $data['no_scrape_url'];
629 if (!empty($data['site_name'])) {
630 $serverdata['site_name'] = $data['site_name'];
633 if (!empty($data['info'])) {
634 $serverdata['info'] = trim($data['info']);
637 $register_policy = defaults($data, 'register_policy', 'REGISTER_CLOSED');
638 switch ($register_policy) {
639 case 'REGISTER_OPEN':
640 $serverdata['register_policy'] = Register::OPEN;
643 case 'REGISTER_APPROVE':
644 $serverdata['register_policy'] = Register::APPROVE;
647 case 'REGISTER_CLOSED':
648 case 'REGISTER_INVITATION':
649 $serverdata['register_policy'] = Register::CLOSED;
652 Logger::info('Register policy is invalid', ['policy' => $register_policy, 'server' => $url]);
653 $serverdata['register_policy'] = Register::CLOSED;
657 $serverdata['platform'] = defaults($data, 'platform', '');
662 private static function analyseRootBody($curlResult, $serverdata)
664 $doc = new DOMDocument();
665 @$doc->loadHTML($curlResult->getBody());
666 $xpath = new DOMXPath($doc);
668 $title = trim(XML::getFirstNodeValue($xpath, '//head/title/text()'));
669 if (!empty($title)) {
670 $serverdata['site_name'] = $title;
673 $list = $xpath->query('//meta[@name]');
675 foreach ($list as $node) {
677 if ($node->attributes->length) {
678 foreach ($node->attributes as $attribute) {
679 $attribute->value = trim($attribute->value);
680 if (empty($attribute->value)) {
684 $attr[$attribute->name] = $attribute->value;
687 if (empty($attr['name']) || empty($attr['content'])) {
692 if ($attr['name'] == 'description') {
693 $serverdata['info'] = $attr['content'];
696 if ($attr['name'] == 'application-name') {
697 $serverdata['platform'] = $attr['content'];
698 if (in_array($attr['content'], ['Misskey', 'Write.as'])) {
699 $serverdata['network'] = Protocol::ACTIVITYPUB;
703 if ($attr['name'] == 'generator') {
704 $serverdata['platform'] = $attr['content'];
706 $version_part = explode(' ', $attr['content']);
708 if (count($version_part) == 2) {
709 if (in_array($version_part[0], ['WordPress'])) {
710 $serverdata['platform'] = $version_part[0];
711 $serverdata['version'] = $version_part[1];
712 $serverdata['network'] = Protocol::ACTIVITYPUB;
714 if (in_array($version_part[0], ['Friendika', 'Friendica'])) {
715 $serverdata['platform'] = $version_part[0];
716 $serverdata['version'] = $version_part[1];
717 $serverdata['network'] = Protocol::DFRN;
723 $list = $xpath->query('//meta[@property]');
725 foreach ($list as $node) {
727 if ($node->attributes->length) {
728 foreach ($node->attributes as $attribute) {
729 $attribute->value = trim($attribute->value);
730 if (empty($attribute->value)) {
734 $attr[$attribute->name] = $attribute->value;
737 if (empty($attr['property']) || empty($attr['content'])) {
743 if ($attr['property'] == 'og:site_name') {
744 $serverdata['site_name'] = $attr['content'];
747 if ($attr['property'] == 'og:description') {
748 $serverdata['info'] = $attr['content'];
751 if ($attr['property'] == 'og:platform') {
752 $serverdata['platform'] = $attr['content'];
754 if (in_array($attr['content'], ['PeerTube'])) {
755 $serverdata['network'] = Protocol::ACTIVITYPUB;
759 if ($attr['property'] == 'generator') {
760 $serverdata['platform'] = $attr['content'];
762 if (in_array($attr['content'], ['hubzilla'])) {
763 // We later check which compatible protocol modules are loaded.
764 $serverdata['network'] = Protocol::ZOT;
772 private static function analyseRootHeader($curlResult, $serverdata)
774 if ($curlResult->getHeader('server') == 'Mastodon') {
775 $serverdata['platform'] = 'mastodon';
776 $serverdata['network'] = $network = Protocol::ACTIVITYPUB;
777 } elseif ($curlResult->inHeader('x-diaspora-version')) {
778 $serverdata['platform'] = 'diaspora';
779 $serverdata['network'] = $network = Protocol::DIASPORA;
780 $serverdata['version'] = $curlResult->getHeader('x-diaspora-version');
782 } elseif ($curlResult->inHeader('x-friendica-version')) {
783 $serverdata['platform'] = 'friendica';
784 $serverdata['network'] = $network = Protocol::DFRN;
785 $serverdata['version'] = $curlResult->getHeader('x-friendica-version');
788 //print_r($curlResult->getHeaderArray());