Friendica Communications Platform (please note that this is a clone of the repository at github, issues are handled there) https://friendi.ca
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1734 lines
48KB

  1. <?php
  2. /**
  3. * @file src/Network/Probe.php
  4. */
  5. namespace Friendica\Network;
  6. /**
  7. * @file src/Network/Probe.php
  8. * @brief Functions for probing URL
  9. */
  10. use DOMDocument;
  11. use Friendica\Core\Cache;
  12. use Friendica\Core\Config;
  13. use Friendica\Core\Logger;
  14. use Friendica\Core\Protocol;
  15. use Friendica\Core\System;
  16. use Friendica\Database\DBA;
  17. use Friendica\Model\Contact;
  18. use Friendica\Model\Profile;
  19. use Friendica\Protocol\Email;
  20. use Friendica\Protocol\Feed;
  21. use Friendica\Protocol\ActivityPub;
  22. use Friendica\Util\Crypto;
  23. use Friendica\Util\DateTimeFormat;
  24. use Friendica\Util\Network;
  25. use Friendica\Util\Strings;
  26. use Friendica\Util\XML;
  27. use DomXPath;
  28. /**
  29. * @brief This class contain functions for probing URL
  30. *
  31. */
  32. class Probe
  33. {
  34. private static $baseurl;
  35. /**
  36. * @brief Rearrange the array so that it always has the same order
  37. *
  38. * @param array $data Unordered data
  39. *
  40. * @return array Ordered data
  41. */
  42. private static function rearrangeData($data)
  43. {
  44. $fields = ["name", "nick", "guid", "url", "addr", "alias",
  45. "photo", "community", "keywords", "location", "about",
  46. "batch", "notify", "poll", "request", "confirm", "poco",
  47. "priority", "network", "pubkey", "baseurl"];
  48. $newdata = [];
  49. foreach ($fields as $field) {
  50. if (isset($data[$field])) {
  51. $newdata[$field] = $data[$field];
  52. } else {
  53. $newdata[$field] = "";
  54. }
  55. }
  56. // We don't use the "priority" field anymore and replace it with a dummy.
  57. $newdata["priority"] = 0;
  58. return $newdata;
  59. }
  60. /**
  61. * @brief Check if the hostname belongs to the own server
  62. *
  63. * @param string $host The hostname that is to be checked
  64. *
  65. * @return bool Does the testes hostname belongs to the own server?
  66. */
  67. private static function ownHost($host)
  68. {
  69. $own_host = get_app()->getHostName();
  70. $parts = parse_url($host);
  71. if (!isset($parts['scheme'])) {
  72. $parts = parse_url('http://'.$host);
  73. }
  74. if (!isset($parts['host'])) {
  75. return false;
  76. }
  77. return $parts['host'] == $own_host;
  78. }
  79. /**
  80. * @brief Probes for webfinger path via "host-meta"
  81. *
  82. * We have to check if the servers in the future still will offer this.
  83. * It seems as if it was dropped from the standard.
  84. *
  85. * @param string $host The host part of an url
  86. *
  87. * @return array with template and type of the webfinger template for JSON or XML
  88. */
  89. private static function hostMeta($host)
  90. {
  91. // Reset the static variable
  92. self::$baseurl = '';
  93. $ssl_url = "https://".$host."/.well-known/host-meta";
  94. $url = "http://".$host."/.well-known/host-meta";
  95. $xrd_timeout = Config::get('system', 'xrd_timeout', 20);
  96. $redirects = 0;
  97. Logger::log("Probing for ".$host, Logger::DEBUG);
  98. $xrd = null;
  99. $curlResult = Network::curl($ssl_url, false, $redirects, ['timeout' => $xrd_timeout, 'accept_content' => 'application/xrd+xml']);
  100. if ($curlResult->isSuccess()) {
  101. $xml = $curlResult->getBody();
  102. $xrd = XML::parseString($xml, false);
  103. $host_url = 'https://'.$host;
  104. }
  105. if (!is_object($xrd)) {
  106. $curlResult = Network::curl($url, false, $redirects, ['timeout' => $xrd_timeout, 'accept_content' => 'application/xrd+xml']);
  107. if ($curlResult->isTimeout()) {
  108. Logger::log("Probing timeout for " . $url, Logger::DEBUG);
  109. return false;
  110. }
  111. $xml = $curlResult->getBody();
  112. $xrd = XML::parseString($xml, false);
  113. $host_url = 'http://'.$host;
  114. }
  115. if (!is_object($xrd)) {
  116. Logger::log("No xrd object found for ".$host, Logger::DEBUG);
  117. return [];
  118. }
  119. $links = XML::elementToArray($xrd);
  120. if (!isset($links["xrd"]["link"])) {
  121. Logger::log("No xrd data found for ".$host, Logger::DEBUG);
  122. return [];
  123. }
  124. $lrdd = [];
  125. // The following webfinger path is defined in RFC 7033 https://tools.ietf.org/html/rfc7033
  126. // Problem is that Hubzilla currently doesn't provide all data in the JSON webfinger
  127. // compared to the XML webfinger. So this is commented out by now.
  128. // $lrdd = array("application/jrd+json" => $host_url.'/.well-known/webfinger?resource={uri}');
  129. foreach ($links["xrd"]["link"] as $value => $link) {
  130. if (!empty($link["@attributes"])) {
  131. $attributes = $link["@attributes"];
  132. } elseif ($value == "@attributes") {
  133. $attributes = $link;
  134. } else {
  135. continue;
  136. }
  137. if (($attributes["rel"] == "lrdd") && !empty($attributes["template"])) {
  138. $type = (empty($attributes["type"]) ? '' : $attributes["type"]);
  139. $lrdd[$type] = $attributes["template"];
  140. }
  141. }
  142. self::$baseurl = $host_url;
  143. Logger::log("Probing successful for ".$host, Logger::DEBUG);
  144. return $lrdd;
  145. }
  146. /**
  147. * @brief Perform Webfinger lookup and return DFRN data
  148. *
  149. * Given an email style address, perform webfinger lookup and
  150. * return the resulting DFRN profile URL, or if no DFRN profile URL
  151. * is located, returns an OStatus subscription template (prefixed
  152. * with the string 'stat:' to identify it as on OStatus template).
  153. * If this isn't an email style address just return $webbie.
  154. * Return an empty string if email-style addresses but webfinger fails,
  155. * or if the resultant personal XRD doesn't contain a supported
  156. * subscription/friend-request attribute.
  157. *
  158. * amended 7/9/2011 to return an hcard which could save potentially loading
  159. * a lengthy content page to scrape dfrn attributes
  160. *
  161. * @param string $webbie Address that should be probed
  162. * @param string $hcard_url Link to the hcard - is returned by reference
  163. *
  164. * @return string profile link
  165. */
  166. public static function webfingerDfrn($webbie, &$hcard_url)
  167. {
  168. $profile_link = '';
  169. $links = self::lrdd($webbie);
  170. Logger::log('webfingerDfrn: '.$webbie.':'.print_r($links, true), Logger::DATA);
  171. if (count($links)) {
  172. foreach ($links as $link) {
  173. if ($link['@attributes']['rel'] === NAMESPACE_DFRN) {
  174. $profile_link = $link['@attributes']['href'];
  175. }
  176. if (($link['@attributes']['rel'] === NAMESPACE_OSTATUSSUB) && ($profile_link == "")) {
  177. $profile_link = 'stat:'.$link['@attributes']['template'];
  178. }
  179. if ($link['@attributes']['rel'] === 'http://microformats.org/profile/hcard') {
  180. $hcard_url = $link['@attributes']['href'];
  181. }
  182. }
  183. }
  184. return $profile_link;
  185. }
  186. /**
  187. * @brief Check an URI for LRDD data
  188. *
  189. * this is a replacement for the "lrdd" function.
  190. * It isn't used in this class and has some redundancies in the code.
  191. * When time comes we can check the existing calls for "lrdd" if we can rework them.
  192. *
  193. * @param string $uri Address that should be probed
  194. *
  195. * @return array uri data
  196. */
  197. public static function lrdd($uri)
  198. {
  199. $lrdd = self::hostMeta($uri);
  200. $webfinger = null;
  201. if (is_bool($lrdd)) {
  202. return [];
  203. }
  204. if (!$lrdd) {
  205. $parts = @parse_url($uri);
  206. if (!$parts || empty($parts["host"]) || empty($parts["path"])) {
  207. return [];
  208. }
  209. $host = $parts["host"];
  210. if (!empty($parts["port"])) {
  211. $host .= ':'.$parts["port"];
  212. }
  213. $path_parts = explode("/", trim($parts["path"], "/"));
  214. $nick = array_pop($path_parts);
  215. do {
  216. $lrdd = self::hostMeta($host);
  217. $host .= "/".array_shift($path_parts);
  218. } while (!$lrdd && (sizeof($path_parts) > 0));
  219. }
  220. if (!$lrdd) {
  221. Logger::log("No lrdd data found for ".$uri, Logger::DEBUG);
  222. return [];
  223. }
  224. foreach ($lrdd as $type => $template) {
  225. if ($webfinger) {
  226. continue;
  227. }
  228. $path = str_replace('{uri}', urlencode($uri), $template);
  229. $webfinger = self::webfinger($path, $type);
  230. if (!$webfinger && (strstr($uri, "@"))) {
  231. $path = str_replace('{uri}', urlencode("acct:".$uri), $template);
  232. $webfinger = self::webfinger($path, $type);
  233. }
  234. // Special treatment for Mastodon
  235. // Problem is that Mastodon uses an URL format like http://domain.tld/@nick
  236. // But the webfinger for this format fails.
  237. if (!$webfinger && !empty($nick)) {
  238. // Mastodon uses a "@" as prefix for usernames in their url format
  239. $nick = ltrim($nick, '@');
  240. $addr = $nick."@".$host;
  241. $path = str_replace('{uri}', urlencode("acct:".$addr), $template);
  242. $webfinger = self::webfinger($path, $type);
  243. }
  244. }
  245. if (!is_array($webfinger["links"])) {
  246. Logger::log("No webfinger links found for ".$uri, Logger::DEBUG);
  247. return false;
  248. }
  249. $data = [];
  250. foreach ($webfinger["links"] as $link) {
  251. $data[] = ["@attributes" => $link];
  252. }
  253. if (is_array($webfinger["aliases"])) {
  254. foreach ($webfinger["aliases"] as $alias) {
  255. $data[] = ["@attributes" =>
  256. ["rel" => "alias",
  257. "href" => $alias]];
  258. }
  259. }
  260. return $data;
  261. }
  262. /**
  263. * @brief Fetch information (protocol endpoints and user information) about a given uri
  264. *
  265. * @param string $uri Address that should be probed
  266. * @param string $network Test for this specific network
  267. * @param integer $uid User ID for the probe (only used for mails)
  268. * @param boolean $cache Use cached values?
  269. *
  270. * @return array uri data
  271. */
  272. public static function uri($uri, $network = '', $uid = -1, $cache = true)
  273. {
  274. if ($cache) {
  275. $result = Cache::get('Probe::uri:' . $network . ':' . $uri);
  276. if (!is_null($result)) {
  277. return $result;
  278. }
  279. }
  280. if ($uid == -1) {
  281. $uid = local_user();
  282. }
  283. if ($network != Protocol::ACTIVITYPUB) {
  284. $data = self::detect($uri, $network, $uid);
  285. } else {
  286. $data = null;
  287. }
  288. $ap_profile = ActivityPub::probeProfile($uri);
  289. if (!empty($ap_profile) && (defaults($data, 'network', '') != Protocol::DFRN)) {
  290. $data = $ap_profile;
  291. }
  292. if (!isset($data['url'])) {
  293. $data['url'] = $uri;
  294. }
  295. if (!empty($data['photo'])) {
  296. $data['baseurl'] = Network::getUrlMatch(Strings::normaliseLink(defaults($data, 'baseurl', '')), Strings::normaliseLink($data['photo']));
  297. } else {
  298. $data['photo'] = System::baseUrl() . '/images/person-300.jpg';
  299. }
  300. if (empty($data['name'])) {
  301. if (!empty($data['nick'])) {
  302. $data['name'] = $data['nick'];
  303. }
  304. if (empty($data['name'])) {
  305. $data['name'] = $data['url'];
  306. }
  307. }
  308. if (empty($data['nick'])) {
  309. $data['nick'] = strtolower($data['name']);
  310. if (strpos($data['nick'], ' ')) {
  311. $data['nick'] = trim(substr($data['nick'], 0, strpos($data['nick'], ' ')));
  312. }
  313. }
  314. if (!empty(self::$baseurl)) {
  315. $data['baseurl'] = self::$baseurl;
  316. }
  317. if (empty($data['network'])) {
  318. $data['network'] = Protocol::PHANTOM;
  319. }
  320. $data = self::rearrangeData($data);
  321. // Only store into the cache if the value seems to be valid
  322. if (!in_array($data['network'], [Protocol::PHANTOM, Protocol::MAIL])) {
  323. Cache::set('Probe::uri:' . $network . ':' . $uri, $data, Cache::DAY);
  324. /// @todo temporary fix - we need a real contact update function that updates only changing fields
  325. /// The biggest problem is the avatar picture that could have a reduced image size.
  326. /// It should only be updated if the existing picture isn't existing anymore.
  327. /// We only update the contact when it is no probing for a specific network.
  328. if (($data['network'] != Protocol::FEED)
  329. && ($network == '')
  330. && $data['name']
  331. && $data['nick']
  332. && $data['url']
  333. && $data['addr']
  334. && $data['poll']
  335. ) {
  336. $fields = [
  337. 'name' => $data['name'],
  338. 'nick' => $data['nick'],
  339. 'url' => $data['url'],
  340. 'addr' => $data['addr'],
  341. 'photo' => $data['photo'],
  342. 'keywords' => $data['keywords'],
  343. 'location' => $data['location'],
  344. 'about' => $data['about'],
  345. 'notify' => $data['notify'],
  346. 'network' => $data['network'],
  347. 'server_url' => $data['baseurl']
  348. ];
  349. // This doesn't cover the case when a community isn't a community anymore
  350. if (!empty($data['community']) && $data['community']) {
  351. $fields['community'] = $data['community'];
  352. $fields['contact-type'] = Contact::ACCOUNT_TYPE_COMMUNITY;
  353. }
  354. $fieldnames = [];
  355. foreach ($fields as $key => $val) {
  356. if (empty($val)) {
  357. unset($fields[$key]);
  358. } else {
  359. $fieldnames[] = $key;
  360. }
  361. }
  362. $fields['updated'] = DateTimeFormat::utcNow();
  363. $condition = ['nurl' => Strings::normaliseLink($data['url'])];
  364. $old_fields = DBA::selectFirst('gcontact', $fieldnames, $condition);
  365. // When the gcontact doesn't exist, the value "true" will trigger an insert.
  366. // In difference to the public contacts we want to have every contact
  367. // in the world in our global contacts.
  368. if (!$old_fields) {
  369. $old_fields = true;
  370. // These values have to be set only on insert
  371. $fields['photo'] = $data['photo'];
  372. $fields['created'] = DateTimeFormat::utcNow();
  373. }
  374. DBA::update('gcontact', $fields, $condition, $old_fields);
  375. $fields = [
  376. 'name' => $data['name'],
  377. 'nick' => $data['nick'],
  378. 'url' => $data['url'],
  379. 'addr' => $data['addr'],
  380. 'alias' => $data['alias'],
  381. 'keywords' => $data['keywords'],
  382. 'location' => $data['location'],
  383. 'about' => $data['about'],
  384. 'batch' => $data['batch'],
  385. 'notify' => $data['notify'],
  386. 'poll' => $data['poll'],
  387. 'request' => $data['request'],
  388. 'confirm' => $data['confirm'],
  389. 'poco' => $data['poco'],
  390. 'network' => $data['network'],
  391. 'pubkey' => $data['pubkey'],
  392. 'priority' => $data['priority'],
  393. 'writable' => true,
  394. 'rel' => Contact::SHARING
  395. ];
  396. $fieldnames = [];
  397. foreach ($fields as $key => $val) {
  398. if (empty($val)) {
  399. unset($fields[$key]);
  400. } else {
  401. $fieldnames[] = $key;
  402. }
  403. }
  404. $condition = ['nurl' => Strings::normaliseLink($data['url']), 'self' => false, 'uid' => 0];
  405. // "$old_fields" will return a "false" when the contact doesn't exist.
  406. // This won't trigger an insert. This is intended, since we only need
  407. // public contacts for everyone we store items from.
  408. // We don't need to store every contact on the planet.
  409. $old_fields = DBA::selectFirst('contact', $fieldnames, $condition);
  410. $fields['name-date'] = DateTimeFormat::utcNow();
  411. $fields['uri-date'] = DateTimeFormat::utcNow();
  412. $fields['success_update'] = DateTimeFormat::utcNow();
  413. DBA::update('contact', $fields, $condition, $old_fields);
  414. }
  415. }
  416. return $data;
  417. }
  418. /**
  419. * @brief Switch the scheme of an url between http and https
  420. *
  421. * @param string $url URL
  422. *
  423. * @return string switched URL
  424. */
  425. private static function switchScheme($url)
  426. {
  427. $parts = parse_url($url);
  428. if (!isset($parts['scheme'])) {
  429. return $url;
  430. }
  431. if ($parts['scheme'] == 'http') {
  432. $url = str_replace('http://', 'https://', $url);
  433. } elseif ($parts['scheme'] == 'https') {
  434. $url = str_replace('https://', 'http://', $url);
  435. }
  436. return $url;
  437. }
  438. /**
  439. * @brief Checks if a profile url should be OStatus but only provides partial information
  440. *
  441. * @param array $webfinger Webfinger data
  442. * @param string $lrdd Path template for webfinger request
  443. * @param string $type type
  444. *
  445. * @return array fixed webfinger data
  446. */
  447. private static function fixOStatus($webfinger, $lrdd, $type)
  448. {
  449. if (empty($webfinger['links']) || empty($webfinger['subject'])) {
  450. return $webfinger;
  451. }
  452. $is_ostatus = false;
  453. $has_key = false;
  454. foreach ($webfinger['links'] as $link) {
  455. if ($link['rel'] == NAMESPACE_OSTATUSSUB) {
  456. $is_ostatus = true;
  457. }
  458. if ($link['rel'] == 'magic-public-key') {
  459. $has_key = true;
  460. }
  461. }
  462. if (!$is_ostatus || $has_key) {
  463. return $webfinger;
  464. }
  465. $url = self::switchScheme($webfinger['subject']);
  466. $path = str_replace('{uri}', urlencode($url), $lrdd);
  467. $webfinger2 = self::webfinger($path, $type);
  468. // Is the new webfinger detectable as OStatus?
  469. if (self::ostatus($webfinger2, true)) {
  470. $webfinger = $webfinger2;
  471. }
  472. return $webfinger;
  473. }
  474. /**
  475. * @brief Fetch information (protocol endpoints and user information) about a given uri
  476. *
  477. * This function is only called by the "uri" function that adds caching and rearranging of data.
  478. *
  479. * @param string $uri Address that should be probed
  480. * @param string $network Test for this specific network
  481. * @param integer $uid User ID for the probe (only used for mails)
  482. *
  483. * @return array uri data
  484. */
  485. private static function detect($uri, $network, $uid)
  486. {
  487. $parts = parse_url($uri);
  488. if (!empty($parts["scheme"]) && !empty($parts["host"])) {
  489. $host = $parts["host"];
  490. if (!empty($parts["port"])) {
  491. $host .= ':'.$parts["port"];
  492. }
  493. if ($host == 'twitter.com') {
  494. return ["network" => Protocol::TWITTER];
  495. }
  496. $lrdd = self::hostMeta($host);
  497. if (is_bool($lrdd)) {
  498. return [];
  499. }
  500. $path_parts = explode("/", trim(defaults($parts, 'path', ''), "/"));
  501. while (!$lrdd && (sizeof($path_parts) > 1)) {
  502. $host .= "/".array_shift($path_parts);
  503. $lrdd = self::hostMeta($host);
  504. }
  505. if (!$lrdd) {
  506. Logger::log('No XRD data was found for '.$uri, Logger::DEBUG);
  507. return self::feed($uri);
  508. }
  509. $nick = array_pop($path_parts);
  510. // Mastodon uses a "@" as prefix for usernames in their url format
  511. $nick = ltrim($nick, '@');
  512. $addr = $nick."@".$host;
  513. } elseif (strstr($uri, '@')) {
  514. // If the URI starts with "mailto:" then jump directly to the mail detection
  515. if (strpos($uri, 'mailto:') !== false) {
  516. $uri = str_replace('mailto:', '', $uri);
  517. return self::mail($uri, $uid);
  518. }
  519. if ($network == Protocol::MAIL) {
  520. return self::mail($uri, $uid);
  521. }
  522. // Remove "acct:" from the URI
  523. $uri = str_replace('acct:', '', $uri);
  524. $host = substr($uri, strpos($uri, '@') + 1);
  525. $nick = substr($uri, 0, strpos($uri, '@'));
  526. if (strpos($uri, '@twitter.com')) {
  527. return ["network" => Protocol::TWITTER];
  528. }
  529. $lrdd = self::hostMeta($host);
  530. if (is_bool($lrdd)) {
  531. return [];
  532. }
  533. if (!$lrdd) {
  534. Logger::log('No XRD data was found for '.$uri, Logger::DEBUG);
  535. return self::mail($uri, $uid);
  536. }
  537. $addr = $uri;
  538. } else {
  539. Logger::log("Uri ".$uri." was not detectable", Logger::DEBUG);
  540. return false;
  541. }
  542. $webfinger = false;
  543. /// @todo Do we need the prefix "acct:" or "acct://"?
  544. foreach ($lrdd as $type => $template) {
  545. if ($webfinger) {
  546. continue;
  547. }
  548. // At first try it with the given uri
  549. $path = str_replace('{uri}', urlencode($uri), $template);
  550. $webfinger = self::webfinger($path, $type);
  551. // Fix possible problems with GNU Social probing to wrong scheme
  552. $webfinger = self::fixOStatus($webfinger, $template, $type);
  553. // We cannot be sure that the detected address was correct, so we don't use the values
  554. if ($webfinger && ($uri != $addr)) {
  555. $nick = "";
  556. $addr = "";
  557. }
  558. // Try webfinger with the address (user@domain.tld)
  559. if (!$webfinger) {
  560. $path = str_replace('{uri}', urlencode($addr), $template);
  561. $webfinger = self::webfinger($path, $type);
  562. }
  563. // Mastodon needs to have it with "acct:"
  564. if (!$webfinger) {
  565. $path = str_replace('{uri}', urlencode("acct:".$addr), $template);
  566. $webfinger = self::webfinger($path, $type);
  567. }
  568. }
  569. if (!$webfinger) {
  570. return self::feed($uri);
  571. }
  572. $result = false;
  573. Logger::log("Probing ".$uri, Logger::DEBUG);
  574. if (in_array($network, ["", Protocol::DFRN])) {
  575. $result = self::dfrn($webfinger);
  576. }
  577. if ((!$result && ($network == "")) || ($network == Protocol::DIASPORA)) {
  578. $result = self::diaspora($webfinger);
  579. }
  580. if ((!$result && ($network == "")) || ($network == Protocol::OSTATUS)) {
  581. $result = self::ostatus($webfinger);
  582. }
  583. if ((!$result && ($network == "")) || ($network == Protocol::PUMPIO)) {
  584. $result = self::pumpio($webfinger, $addr);
  585. }
  586. if ((!$result && ($network == "")) || ($network == Protocol::FEED)) {
  587. $result = self::feed($uri);
  588. } else {
  589. // We overwrite the detected nick with our try if the previois routines hadn't detected it.
  590. // Additionally it is overwritten when the nickname doesn't make sense (contains spaces).
  591. if ((empty($result["nick"]) || (strstr($result["nick"], " "))) && ($nick != "")) {
  592. $result["nick"] = $nick;
  593. }
  594. if (empty($result["addr"]) && ($addr != "")) {
  595. $result["addr"] = $addr;
  596. }
  597. }
  598. if (empty($result["network"])) {
  599. $result["network"] = Protocol::PHANTOM;
  600. }
  601. if (empty($result["url"])) {
  602. $result["url"] = $uri;
  603. }
  604. Logger::log($uri." is ".$result["network"], Logger::DEBUG);
  605. if (empty($result["baseurl"])) {
  606. $pos = strpos($result["url"], $host);
  607. if ($pos) {
  608. $result["baseurl"] = substr($result["url"], 0, $pos).$host;
  609. }
  610. }
  611. return $result;
  612. }
  613. /**
  614. * @brief Perform a webfinger request.
  615. *
  616. * For details see RFC 7033: <https://tools.ietf.org/html/rfc7033>
  617. *
  618. * @param string $url Address that should be probed
  619. * @param string $type type
  620. *
  621. * @return array webfinger data
  622. */
  623. private static function webfinger($url, $type)
  624. {
  625. $xrd_timeout = Config::get('system', 'xrd_timeout', 20);
  626. $redirects = 0;
  627. $curlResult = Network::curl($url, false, $redirects, ['timeout' => $xrd_timeout, 'accept_content' => $type]);
  628. if ($curlResult->isTimeout()) {
  629. return false;
  630. }
  631. $data = $curlResult->getBody();
  632. $webfinger = json_decode($data, true);
  633. if (is_array($webfinger)) {
  634. if (!isset($webfinger["links"])) {
  635. Logger::log("No json webfinger links for ".$url, Logger::DEBUG);
  636. return false;
  637. }
  638. return $webfinger;
  639. }
  640. // If it is not JSON, maybe it is XML
  641. $xrd = XML::parseString($data, false);
  642. if (!is_object($xrd)) {
  643. Logger::log("No webfinger data retrievable for ".$url, Logger::DEBUG);
  644. return false;
  645. }
  646. $xrd_arr = XML::elementToArray($xrd);
  647. if (!isset($xrd_arr["xrd"]["link"])) {
  648. Logger::log("No XML webfinger links for ".$url, Logger::DEBUG);
  649. return false;
  650. }
  651. $webfinger = [];
  652. if (!empty($xrd_arr["xrd"]["subject"])) {
  653. $webfinger["subject"] = $xrd_arr["xrd"]["subject"];
  654. }
  655. if (!empty($xrd_arr["xrd"]["alias"])) {
  656. $webfinger["aliases"] = $xrd_arr["xrd"]["alias"];
  657. }
  658. $webfinger["links"] = [];
  659. foreach ($xrd_arr["xrd"]["link"] as $value => $data) {
  660. if (!empty($data["@attributes"])) {
  661. $attributes = $data["@attributes"];
  662. } elseif ($value == "@attributes") {
  663. $attributes = $data;
  664. } else {
  665. continue;
  666. }
  667. $webfinger["links"][] = $attributes;
  668. }
  669. return $webfinger;
  670. }
  671. /**
  672. * @brief Poll the Friendica specific noscrape page.
  673. *
  674. * "noscrape" is a faster alternative to fetch the data from the hcard.
  675. * This functionality was originally created for the directory.
  676. *
  677. * @param string $noscrape_url Link to the noscrape page
  678. * @param array $data The already fetched data
  679. *
  680. * @return array noscrape data
  681. */
  682. private static function pollNoscrape($noscrape_url, $data)
  683. {
  684. $curlResult = Network::curl($noscrape_url);
  685. if ($curlResult->isTimeout()) {
  686. return false;
  687. }
  688. $content = $curlResult->getBody();
  689. if (!$content) {
  690. Logger::log("Empty body for ".$noscrape_url, Logger::DEBUG);
  691. return false;
  692. }
  693. $json = json_decode($content, true);
  694. if (!is_array($json)) {
  695. Logger::log("No json data for ".$noscrape_url, Logger::DEBUG);
  696. return false;
  697. }
  698. if (!empty($json["fn"])) {
  699. $data["name"] = $json["fn"];
  700. }
  701. if (!empty($json["addr"])) {
  702. $data["addr"] = $json["addr"];
  703. }
  704. if (!empty($json["nick"])) {
  705. $data["nick"] = $json["nick"];
  706. }
  707. if (!empty($json["guid"])) {
  708. $data["guid"] = $json["guid"];
  709. }
  710. if (!empty($json["comm"])) {
  711. $data["community"] = $json["comm"];
  712. }
  713. if (!empty($json["tags"])) {
  714. $keywords = implode(" ", $json["tags"]);
  715. if ($keywords != "") {
  716. $data["keywords"] = $keywords;
  717. }
  718. }
  719. $location = Profile::formatLocation($json);
  720. if ($location) {
  721. $data["location"] = $location;
  722. }
  723. if (!empty($json["about"])) {
  724. $data["about"] = $json["about"];
  725. }
  726. if (!empty($json["key"])) {
  727. $data["pubkey"] = $json["key"];
  728. }
  729. if (!empty($json["photo"])) {
  730. $data["photo"] = $json["photo"];
  731. }
  732. if (!empty($json["dfrn-request"])) {
  733. $data["request"] = $json["dfrn-request"];
  734. }
  735. if (!empty($json["dfrn-confirm"])) {
  736. $data["confirm"] = $json["dfrn-confirm"];
  737. }
  738. if (!empty($json["dfrn-notify"])) {
  739. $data["notify"] = $json["dfrn-notify"];
  740. }
  741. if (!empty($json["dfrn-poll"])) {
  742. $data["poll"] = $json["dfrn-poll"];
  743. }
  744. return $data;
  745. }
  746. /**
  747. * @brief Check for valid DFRN data
  748. *
  749. * @param array $data DFRN data
  750. *
  751. * @return int Number of errors
  752. */
  753. public static function validDfrn($data)
  754. {
  755. $errors = 0;
  756. if (!isset($data['key'])) {
  757. $errors ++;
  758. }
  759. if (!isset($data['dfrn-request'])) {
  760. $errors ++;
  761. }
  762. if (!isset($data['dfrn-confirm'])) {
  763. $errors ++;
  764. }
  765. if (!isset($data['dfrn-notify'])) {
  766. $errors ++;
  767. }
  768. if (!isset($data['dfrn-poll'])) {
  769. $errors ++;
  770. }
  771. return $errors;
  772. }
  773. /**
  774. * @brief Fetch data from a DFRN profile page and via "noscrape"
  775. *
  776. * @param string $profile_link Link to the profile page
  777. *
  778. * @return array profile data
  779. */
  780. public static function profile($profile_link)
  781. {
  782. $data = [];
  783. Logger::log("Check profile ".$profile_link, Logger::DEBUG);
  784. // Fetch data via noscrape - this is faster
  785. $noscrape_url = str_replace(["/hcard/", "/profile/"], "/noscrape/", $profile_link);
  786. $data = self::pollNoscrape($noscrape_url, $data);
  787. if (!isset($data["notify"])
  788. || !isset($data["confirm"])
  789. || !isset($data["request"])
  790. || !isset($data["poll"])
  791. || !isset($data["name"])
  792. || !isset($data["photo"])
  793. ) {
  794. $data = self::pollHcard($profile_link, $data, true);
  795. }
  796. $prof_data = [];
  797. if (empty($data["addr"]) || empty($data["nick"])) {
  798. $probe_data = self::uri($profile_link);
  799. $data["addr"] = defaults($data, "addr", $probe_data["addr"]);
  800. $data["nick"] = defaults($data, "nick", $probe_data["nick"]);
  801. }
  802. $prof_data["addr"] = $data["addr"];
  803. $prof_data["nick"] = $data["nick"];
  804. $prof_data["dfrn-request"] = defaults($data, 'request', null);
  805. $prof_data["dfrn-confirm"] = defaults($data, 'confirm', null);
  806. $prof_data["dfrn-notify"] = defaults($data, 'notify' , null);
  807. $prof_data["dfrn-poll"] = defaults($data, 'poll' , null);
  808. $prof_data["photo"] = defaults($data, 'photo' , null);
  809. $prof_data["fn"] = defaults($data, 'name' , null);
  810. $prof_data["key"] = defaults($data, 'pubkey' , null);
  811. Logger::log("Result for profile ".$profile_link.": ".print_r($prof_data, true), Logger::DEBUG);
  812. return $prof_data;
  813. }
  814. /**
  815. * @brief Check for DFRN contact
  816. *
  817. * @param array $webfinger Webfinger data
  818. *
  819. * @return array DFRN data
  820. */
  821. private static function dfrn($webfinger)
  822. {
  823. $hcard_url = "";
  824. $data = [];
  825. // The array is reversed to take into account the order of preference for same-rel links
  826. // See: https://tools.ietf.org/html/rfc7033#section-4.4.4
  827. foreach (array_reverse($webfinger["links"]) as $link) {
  828. if (($link["rel"] == NAMESPACE_DFRN) && !empty($link["href"])) {
  829. $data["network"] = Protocol::DFRN;
  830. } elseif (($link["rel"] == NAMESPACE_FEED) && !empty($link["href"])) {
  831. $data["poll"] = $link["href"];
  832. } elseif (($link["rel"] == "http://webfinger.net/rel/profile-page") && (defaults($link, "type", "") == "text/html") && !empty($link["href"])) {
  833. $data["url"] = $link["href"];
  834. } elseif (($link["rel"] == "http://microformats.org/profile/hcard") && !empty($link["href"])) {
  835. $hcard_url = $link["href"];
  836. } elseif (($link["rel"] == NAMESPACE_POCO) && !empty($link["href"])) {
  837. $data["poco"] = $link["href"];
  838. } elseif (($link["rel"] == "http://webfinger.net/rel/avatar") && !empty($link["href"])) {
  839. $data["photo"] = $link["href"];
  840. } elseif (($link["rel"] == "http://joindiaspora.com/seed_location") && !empty($link["href"])) {
  841. $data["baseurl"] = trim($link["href"], '/');
  842. } elseif (($link["rel"] == "http://joindiaspora.com/guid") && !empty($link["href"])) {
  843. $data["guid"] = $link["href"];
  844. } elseif (($link["rel"] == "diaspora-public-key") && !empty($link["href"])) {
  845. $data["pubkey"] = base64_decode($link["href"]);
  846. //if (strstr($data["pubkey"], 'RSA ') || ($link["type"] == "RSA"))
  847. if (strstr($data["pubkey"], 'RSA ')) {
  848. $data["pubkey"] = Crypto::rsaToPem($data["pubkey"]);
  849. }
  850. }
  851. }
  852. if (!empty($webfinger["aliases"]) && is_array($webfinger["aliases"])) {
  853. foreach ($webfinger["aliases"] as $alias) {
  854. if (empty($data["url"]) && !strstr($alias, "@")) {
  855. $data["url"] = $alias;
  856. } elseif (!strstr($alias, "@") && Strings::normaliseLink($alias) != Strings::normaliseLink($data["url"])) {
  857. $data["alias"] = $alias;
  858. } elseif (substr($alias, 0, 5) == 'acct:') {
  859. $data["addr"] = substr($alias, 5);
  860. }
  861. }
  862. }
  863. if (!empty($webfinger["subject"]) && (substr($webfinger["subject"], 0, 5) == "acct:")) {
  864. $data["addr"] = substr($webfinger["subject"], 5);
  865. }
  866. if (!isset($data["network"]) || ($hcard_url == "")) {
  867. return false;
  868. }
  869. // Fetch data via noscrape - this is faster
  870. $noscrape_url = str_replace("/hcard/", "/noscrape/", $hcard_url);
  871. $data = self::pollNoscrape($noscrape_url, $data);
  872. if (isset($data["notify"])
  873. && isset($data["confirm"])
  874. && isset($data["request"])
  875. && isset($data["poll"])
  876. && isset($data["name"])
  877. && isset($data["photo"])
  878. ) {
  879. return $data;
  880. }
  881. $data = self::pollHcard($hcard_url, $data, true);
  882. return $data;
  883. }
  884. /**
  885. * @brief Poll the hcard page (Diaspora and Friendica specific)
  886. *
  887. * @param string $hcard_url Link to the hcard page
  888. * @param array $data The already fetched data
  889. * @param boolean $dfrn Poll DFRN specific data
  890. *
  891. * @return array hcard data
  892. */
  893. private static function pollHcard($hcard_url, $data, $dfrn = false)
  894. {
  895. $curlResult = Network::curl($hcard_url);
  896. if ($curlResult->isTimeout()) {
  897. return false;
  898. }
  899. $content = $curlResult->getBody();
  900. if (!$content) {
  901. return false;
  902. }
  903. $doc = new DOMDocument();
  904. if (!@$doc->loadHTML($content)) {
  905. return false;
  906. }
  907. $xpath = new DomXPath($doc);
  908. $vcards = $xpath->query("//div[contains(concat(' ', @class, ' '), ' vcard ')]");
  909. if (!is_object($vcards)) {
  910. return false;
  911. }
  912. if (!isset($data["baseurl"])) {
  913. $data["baseurl"] = "";
  914. }
  915. if ($vcards->length > 0) {
  916. $vcard = $vcards->item(0);
  917. // We have to discard the guid from the hcard in favour of the guid from lrdd
  918. // Reason: Hubzilla doesn't use the value "uid" in the hcard like Diaspora does.
  919. $search = $xpath->query("//*[contains(concat(' ', @class, ' '), ' uid ')]", $vcard); // */
  920. if (($search->length > 0) && empty($data["guid"])) {
  921. $data["guid"] = $search->item(0)->nodeValue;
  922. }
  923. $search = $xpath->query("//*[contains(concat(' ', @class, ' '), ' nickname ')]", $vcard); // */
  924. if ($search->length > 0) {
  925. $data["nick"] = $search->item(0)->nodeValue;
  926. }
  927. $search = $xpath->query("//*[contains(concat(' ', @class, ' '), ' fn ')]", $vcard); // */
  928. if ($search->length > 0) {
  929. $data["name"] = $search->item(0)->nodeValue;
  930. }
  931. $search = $xpath->query("//*[contains(concat(' ', @class, ' '), ' searchable ')]", $vcard); // */
  932. if ($search->length > 0) {
  933. $data["searchable"] = $search->item(0)->nodeValue;
  934. }
  935. $search = $xpath->query("//*[contains(concat(' ', @class, ' '), ' key ')]", $vcard); // */
  936. if ($search->length > 0) {
  937. $data["pubkey"] = $search->item(0)->nodeValue;
  938. if (strstr($data["pubkey"], 'RSA ')) {
  939. $data["pubkey"] = Crypto::rsaToPem($data["pubkey"]);
  940. }
  941. }
  942. $search = $xpath->query("//*[@id='pod_location']", $vcard); // */
  943. if ($search->length > 0) {
  944. $data["baseurl"] = trim($search->item(0)->nodeValue, "/");
  945. }
  946. }
  947. $avatar = [];
  948. if (!empty($vcard)) {
  949. $photos = $xpath->query("//*[contains(concat(' ', @class, ' '), ' photo ') or contains(concat(' ', @class, ' '), ' avatar ')]", $vcard); // */
  950. foreach ($photos as $photo) {
  951. $attr = [];
  952. foreach ($photo->attributes as $attribute) {
  953. $attr[$attribute->name] = trim($attribute->value);
  954. }
  955. if (isset($attr["src"]) && isset($attr["width"])) {
  956. $avatar[$attr["width"]] = $attr["src"];
  957. }
  958. // We don't have a width. So we just take everything that we got.
  959. // This is a Hubzilla workaround which doesn't send a width.
  960. if ((sizeof($avatar) == 0) && !empty($attr["src"])) {
  961. $avatar[] = $attr["src"];
  962. }
  963. }
  964. }
  965. if (sizeof($avatar)) {
  966. ksort($avatar);
  967. $data["photo"] = self::fixAvatar(array_pop($avatar), $data["baseurl"]);
  968. }
  969. if ($dfrn) {
  970. // Poll DFRN specific data
  971. $search = $xpath->query("//link[contains(concat(' ', @rel), ' dfrn-')]");
  972. if ($search->length > 0) {
  973. foreach ($search as $link) {
  974. //$data["request"] = $search->item(0)->nodeValue;
  975. $attr = [];
  976. foreach ($link->attributes as $attribute) {
  977. $attr[$attribute->name] = trim($attribute->value);
  978. }
  979. $data[substr($attr["rel"], 5)] = $attr["href"];
  980. }
  981. }
  982. // Older Friendica versions had used the "uid" field differently than newer versions
  983. if (!empty($data["nick"]) && !empty($data["guid"]) && ($data["nick"] == $data["guid"])) {
  984. unset($data["guid"]);
  985. }
  986. }
  987. return $data;
  988. }
  989. /**
  990. * @brief Check for Diaspora contact
  991. *
  992. * @param array $webfinger Webfinger data
  993. *
  994. * @return array Diaspora data
  995. */
  996. private static function diaspora($webfinger)
  997. {
  998. $hcard_url = "";
  999. $data = [];
  1000. // The array is reversed to take into account the order of preference for same-rel links
  1001. // See: https://tools.ietf.org/html/rfc7033#section-4.4.4
  1002. foreach (array_reverse($webfinger["links"]) as $link) {
  1003. if (($link["rel"] == "http://microformats.org/profile/hcard") && !empty($link["href"])) {
  1004. $hcard_url = $link["href"];
  1005. } elseif (($link["rel"] == "http://joindiaspora.com/seed_location") && !empty($link["href"])) {
  1006. $data["baseurl"] = trim($link["href"], '/');
  1007. } elseif (($link["rel"] == "http://joindiaspora.com/guid") && !empty($link["href"])) {
  1008. $data["guid"] = $link["href"];
  1009. } elseif (($link["rel"] == "http://webfinger.net/rel/profile-page") && (defaults($link, "type", "") == "text/html") && !empty($link["href"])) {
  1010. $data["url"] = $link["href"];
  1011. } elseif (($link["rel"] == NAMESPACE_FEED) && !empty($link["href"])) {
  1012. $data["poll"] = $link["href"];
  1013. } elseif (($link["rel"] == NAMESPACE_POCO) && !empty($link["href"])) {
  1014. $data["poco"] = $link["href"];
  1015. } elseif (($link["rel"] == "salmon") && !empty($link["href"])) {
  1016. $data["notify"] = $link["href"];
  1017. } elseif (($link["rel"] == "diaspora-public-key") && !empty($link["href"])) {
  1018. $data["pubkey"] = base64_decode($link["href"]);
  1019. //if (strstr($data["pubkey"], 'RSA ') || ($link["type"] == "RSA"))
  1020. if (strstr($data["pubkey"], 'RSA ')) {
  1021. $data["pubkey"] = Crypto::rsaToPem($data["pubkey"]);
  1022. }
  1023. }
  1024. }
  1025. if (!isset($data["url"]) || ($hcard_url == "")) {
  1026. return false;
  1027. }
  1028. if (!empty($webfinger["aliases"]) && is_array($webfinger["aliases"])) {
  1029. foreach ($webfinger["aliases"] as $alias) {
  1030. if (Strings::normaliseLink($alias) != Strings::normaliseLink($data["url"]) && ! strstr($alias, "@")) {
  1031. $data["alias"] = $alias;
  1032. } elseif (substr($alias, 0, 5) == 'acct:') {
  1033. $data["addr"] = substr($alias, 5);
  1034. }
  1035. }
  1036. }
  1037. if (!empty($webfinger["subject"]) && (substr($webfinger["subject"], 0, 5) == 'acct:')) {
  1038. $data["addr"] = substr($webfinger["subject"], 5);
  1039. }
  1040. // Fetch further information from the hcard
  1041. $data = self::pollHcard($hcard_url, $data);
  1042. if (!$data) {
  1043. return false;
  1044. }
  1045. if (isset($data["url"])
  1046. && isset($data["guid"])
  1047. && isset($data["baseurl"])
  1048. && isset($data["pubkey"])
  1049. && ($hcard_url != "")
  1050. ) {
  1051. $data["network"] = Protocol::DIASPORA;
  1052. // The Diaspora handle must always be lowercase
  1053. if (!empty($data["addr"])) {
  1054. $data["addr"] = strtolower($data["addr"]);
  1055. }
  1056. // We have to overwrite the detected value for "notify" since Hubzilla doesn't send it
  1057. $data["notify"] = $data["baseurl"] . "/receive/users/" . $data["guid"];
  1058. $data["batch"] = $data["baseurl"] . "/receive/public";
  1059. } else {
  1060. return false;
  1061. }
  1062. return $data;
  1063. }
  1064. /**
  1065. * @brief Check for OStatus contact
  1066. *
  1067. * @param array $webfinger Webfinger data
  1068. * @param bool $short Short detection mode
  1069. *
  1070. * @return array|bool OStatus data or "false" on error or "true" on short mode
  1071. */
  1072. private static function ostatus($webfinger, $short = false)
  1073. {
  1074. $data = [];
  1075. if (!empty($webfinger["aliases"]) && is_array($webfinger["aliases"])) {
  1076. foreach ($webfinger["aliases"] as $alias) {
  1077. if (strstr($alias, "@") && !strstr(Strings::normaliseLink($alias), "http://")) {
  1078. $data["addr"] = str_replace('acct:', '', $alias);
  1079. }
  1080. }
  1081. }
  1082. if (!empty($webfinger["subject"]) && strstr($webfinger["subject"], "@")
  1083. && !strstr(Strings::normaliseLink($webfinger["subject"]), "http://")
  1084. ) {
  1085. $data["addr"] = str_replace('acct:', '', $webfinger["subject"]);
  1086. }
  1087. $pubkey = "";
  1088. if (is_array($webfinger["links"])) {
  1089. // The array is reversed to take into account the order of preference for same-rel links
  1090. // See: https://tools.ietf.org/html/rfc7033#section-4.4.4
  1091. foreach (array_reverse($webfinger["links"]) as $link) {
  1092. if (($link["rel"] == "http://webfinger.net/rel/profile-page")
  1093. && (defaults($link, "type", "") == "text/html")
  1094. && ($link["href"] != "")
  1095. ) {
  1096. $data["url"] = $link["href"];
  1097. } elseif (($link["rel"] == "salmon") && !empty($link["href"])) {
  1098. $data["notify"] = $link["href"];
  1099. } elseif (($link["rel"] == NAMESPACE_FEED) && !empty($link["href"])) {
  1100. $data["poll"] = $link["href"];
  1101. } elseif (($link["rel"] == "magic-public-key") && !empty($link["href"])) {
  1102. $pubkey = $link["href"];
  1103. if (substr($pubkey, 0, 5) === 'data:') {
  1104. if (strstr($pubkey, ',')) {
  1105. $pubkey = substr($pubkey, strpos($pubkey, ',') + 1);
  1106. } else {
  1107. $pubkey = substr($pubkey, 5);
  1108. }
  1109. } elseif (Strings::normaliseLink($pubkey) == 'http://') {
  1110. $curlResult = Network::curl($pubkey);
  1111. if ($curlResult->isTimeout()) {
  1112. return false;
  1113. }
  1114. $pubkey = $curlResult->getBody();
  1115. }
  1116. $key = explode(".", $pubkey);
  1117. if (sizeof($key) >= 3) {
  1118. $m = Strings::base64UrlDecode($key[1]);
  1119. $e = Strings::base64UrlDecode($key[2]);
  1120. $data["pubkey"] = Crypto::meToPem($m, $e);
  1121. }
  1122. }
  1123. }
  1124. }
  1125. if (isset($data["notify"]) && isset($data["pubkey"])
  1126. && isset($data["poll"])
  1127. && isset($data["url"])
  1128. ) {
  1129. $data["network"] = Protocol::OSTATUS;
  1130. } else {
  1131. return false;
  1132. }
  1133. if ($short) {
  1134. return true;
  1135. }
  1136. // Fetch all additional data from the feed
  1137. $curlResult = Network::curl($data["poll"]);
  1138. if ($curlResult->isTimeout()) {
  1139. return false;
  1140. }
  1141. $feed = $curlResult->getBody();
  1142. $dummy1 = null;
  1143. $dummy2 = null;
  1144. $dummy2 = null;
  1145. $feed_data = Feed::import($feed, $dummy1, $dummy2, $dummy3, true);
  1146. if (!$feed_data) {
  1147. return false;
  1148. }
  1149. if (!empty($feed_data["header"]["author-name"])) {
  1150. $data["name"] = $feed_data["header"]["author-name"];
  1151. }
  1152. if (!empty($feed_data["header"]["author-nick"])) {
  1153. $data["nick"] = $feed_data["header"]["author-nick"];
  1154. }
  1155. if (!empty($feed_data["header"]["author-avatar"])) {
  1156. $data["photo"] = self::fixAvatar($feed_data["header"]["author-avatar"], $data["url"]);
  1157. }
  1158. if (!empty($feed_data["header"]["author-id"])) {
  1159. $data["alias"] = $feed_data["header"]["author-id"];
  1160. }
  1161. if (!empty($feed_data["header"]["author-location"])) {
  1162. $data["location"] = $feed_data["header"]["author-location"];
  1163. }
  1164. if (!empty($feed_data["header"]["author-about"])) {
  1165. $data["about"] = $feed_data["header"]["author-about"];
  1166. }
  1167. // OStatus has serious issues when the the url doesn't fit (ssl vs. non ssl)
  1168. // So we take the value that we just fetched, although the other one worked as well
  1169. if (!empty($feed_data["header"]["author-link"])) {
  1170. $data["url"] = $feed_data["header"]["author-link"];
  1171. }
  1172. if (($data['poll'] == $data['url']) && ($data["alias"] != '')) {
  1173. $data['url'] = $data["alias"];
  1174. $data["alias"] = '';
  1175. }
  1176. /// @todo Fetch location and "about" from the feed as well
  1177. return $data;
  1178. }
  1179. /**
  1180. * @brief Fetch data from a pump.io profile page
  1181. *
  1182. * @param string $profile_link Link to the profile page
  1183. *
  1184. * @return array profile data
  1185. */
  1186. private static function pumpioProfileData($profile_link)
  1187. {
  1188. $doc = new DOMDocument();
  1189. if (!@$doc->loadHTMLFile($profile_link)) {
  1190. return false;
  1191. }
  1192. $xpath = new DomXPath($doc);
  1193. $data = [];
  1194. $data["name"] = $xpath->query("//span[contains(@class, 'p-name')]")->item(0)->nodeValue;
  1195. if ($data["name"] == '') {
  1196. // This is ugly - but pump.io doesn't seem to know a better way for it
  1197. $data["name"] = trim($xpath->query("//h1[@class='media-header']")->item(0)->nodeValue);
  1198. $pos = strpos($data["name"], chr(10));
  1199. if ($pos) {
  1200. $data["name"] = trim(substr($data["name"], 0, $pos));
  1201. }
  1202. }
  1203. $data["location"] = XML::getFirstNodeValue($xpath, "//p[contains(@class, 'p-locality')]");
  1204. if ($data["location"] == '') {
  1205. $data["location"] = XML::getFirstNodeValue($xpath, "//p[contains(@class, 'location')]");
  1206. }
  1207. $data["about"] = XML::getFirstNodeValue($xpath, "//p[contains(@class, 'p-note')]");
  1208. if ($data["about"] == '') {
  1209. $data["about"] = XML::getFirstNodeValue($xpath, "//p[contains(@class, 'summary')]");
  1210. }
  1211. $avatar = $xpath->query("//img[contains(@class, 'u-photo')]")->item(0);
  1212. if (!$avatar) {
  1213. $avatar = $xpath->query("//img[@class='img-rounded media-object']")->item(0);
  1214. }
  1215. if ($avatar) {
  1216. foreach ($avatar->attributes as $attribute) {
  1217. if ($attribute->name == "src") {
  1218. $data["photo"] = trim($attribute->value);
  1219. }
  1220. }
  1221. }
  1222. return $data;
  1223. }
  1224. /**
  1225. * @brief Check for pump.io contact
  1226. *
  1227. * @param array $webfinger Webfinger data
  1228. *
  1229. * @return array pump.io data
  1230. */
  1231. private static function pumpio($webfinger, $addr)
  1232. {
  1233. $data = [];
  1234. // The array is reversed to take into account the order of preference for same-rel links
  1235. // See: https://tools.ietf.org/html/rfc7033#section-4.4.4
  1236. foreach (array_reverse($webfinger["links"]) as $link) {
  1237. if (($link["rel"] == "http://webfinger.net/rel/profile-page")
  1238. && (defaults($link, "type", "") == "text/html")
  1239. && ($link["href"] != "")
  1240. ) {
  1241. $data["url"] = $link["href"];
  1242. } elseif (($link["rel"] == "activity-inbox") && ($link["href"] != "")) {
  1243. $data["notify"] = $link["href"];
  1244. } elseif (($link["rel"] == "activity-outbox") && ($link["href"] != "")) {
  1245. $data["poll"] = $link["href"];
  1246. } elseif (($link["rel"] == "dialback") && ($link["href"] != "")) {
  1247. $data["dialback"] = $link["href"];
  1248. }
  1249. }
  1250. if (isset($data["poll"]) && isset($data["notify"])
  1251. && isset($data["dialback"])
  1252. && isset($data["url"])
  1253. ) {
  1254. // by now we use these fields only for the network type detection
  1255. // So we unset all data that isn't used at the moment
  1256. unset($data["dialback"]);
  1257. $data["network"] = Protocol::PUMPIO;
  1258. } else {
  1259. return false;
  1260. }
  1261. $profile_data = self::pumpioProfileData($data["url"]);
  1262. if (!$profile_data) {
  1263. return false;
  1264. }
  1265. $data = array_merge($data, $profile_data);
  1266. if (($addr != '') && ($data['name'] != '')) {
  1267. $name = trim(str_replace($addr, '', $data['name']));
  1268. if ($name != '') {
  1269. $data['name'] = $name;
  1270. }
  1271. }
  1272. return $data;
  1273. }
  1274. /**
  1275. * @brief Check page for feed link
  1276. *
  1277. * @param string $url Page link
  1278. *
  1279. * @return string feed link
  1280. */
  1281. private static function getFeedLink($url)
  1282. {
  1283. $doc = new DOMDocument();
  1284. if (!@$doc->loadHTMLFile($url)) {
  1285. return false;
  1286. }
  1287. $xpath = new DomXPath($doc);
  1288. //$feeds = $xpath->query("/html/head/link[@type='application/rss+xml']");
  1289. $feeds = $xpath->query("/html/head/link[@type='application/rss+xml' and @rel='alternate']");
  1290. if (!is_object($feeds)) {
  1291. return false;
  1292. }
  1293. if ($feeds->length == 0) {
  1294. return false;
  1295. }
  1296. $feed_url = "";
  1297. foreach ($feeds as $feed) {
  1298. $attr = [];
  1299. foreach ($feed->attributes as $attribute) {
  1300. $attr[$attribute->name] = trim($attribute->value);
  1301. }
  1302. if ($feed_url == "") {
  1303. $feed_url = $attr["href"];
  1304. }
  1305. }
  1306. return $feed_url;
  1307. }
  1308. /**
  1309. * @brief Check for feed contact
  1310. *
  1311. * @param string $url Profile link
  1312. * @param boolean $probe Do a probe if the page contains a feed link
  1313. *
  1314. * @return array feed data
  1315. */
  1316. private static function feed($url, $probe = true)
  1317. {
  1318. $curlResult = Network::curl($url);
  1319. if ($curlResult->isTimeout()) {
  1320. return false;
  1321. }
  1322. $feed = $curlResult->getBody();
  1323. $dummy1 = $dummy2 = $dummy3 = null;
  1324. $feed_data = Feed::import($feed, $dummy1, $dummy2, $dummy3, true);
  1325. if (!$feed_data) {
  1326. if (!$probe) {
  1327. return false;
  1328. }
  1329. $feed_url = self::getFeedLink($url);
  1330. if (!$feed_url) {
  1331. return false;
  1332. }
  1333. return self::feed($feed_url, false);
  1334. }
  1335. if (!empty($feed_data["header"]["author-name"])) {
  1336. $data["name"] = $feed_data["header"]["author-name"];
  1337. }
  1338. if (!empty($feed_data["header"]["author-nick"])) {
  1339. $data["nick"] = $feed_data["header"]["author-nick"];
  1340. }
  1341. if (!empty($feed_data["header"]["author-avatar"])) {
  1342. $data["photo"] = $feed_data["header"]["author-avatar"];
  1343. }
  1344. if (!empty($feed_data["header"]["author-id"])) {
  1345. $data["alias"] = $feed_data["header"]["author-id"];
  1346. }
  1347. $data["url"] = $url;
  1348. $data["poll"] = $url;
  1349. if (!empty($feed_data["header"]["author-link"])) {
  1350. $data["baseurl"] = $feed_data["header"]["author-link"];
  1351. } else {
  1352. $data["baseurl"] = $data["url"];
  1353. }
  1354. $data["network"] = Protocol::FEED;
  1355. return $data;
  1356. }
  1357. /**
  1358. * @brief Check for mail contact
  1359. *
  1360. * @param string $uri Profile link
  1361. * @param integer $uid User ID
  1362. *
  1363. * @return array mail data
  1364. */
  1365. private static function mail($uri, $uid)
  1366. {
  1367. if (!Network::isEmailDomainValid($uri)) {
  1368. return false;
  1369. }
  1370. if ($uid == 0) {
  1371. return false;
  1372. }
  1373. $user = DBA::selectFirst('user', ['prvkey'], ['uid' => $uid]);
  1374. $condition = ["`uid` = ? AND `server` != ''", $uid];
  1375. $fields = ['pass', 'user', 'server', 'port', 'ssltype', 'mailbox'];
  1376. $mailacct = DBA::selectFirst('mailacct', $fields, $condition);
  1377. if (!DBA::isResult($user) || !DBA::isResult($mailacct)) {
  1378. return false;
  1379. }
  1380. $mailbox = Email::constructMailboxName($mailacct);
  1381. $password = '';
  1382. openssl_private_decrypt(hex2bin($mailacct['pass']), $password, $user['prvkey']);
  1383. $mbox = Email::connect($mailbox, $mailacct['user'], $password);
  1384. if (!$mbox) {
  1385. return false;
  1386. }
  1387. $msgs = Email::poll($mbox, $uri);
  1388. Logger::log('searching '.$uri.', '.count($msgs).' messages found.', Logger::DEBUG);
  1389. if (!count($msgs)) {
  1390. return false;
  1391. }
  1392. $phost = substr($uri, strpos($uri, '@') + 1);
  1393. $data = [];
  1394. $data["addr"] = $uri;
  1395. $data["network"] = Protocol::MAIL;
  1396. $data["name"] = substr($uri, 0, strpos($uri, '@'));
  1397. $data["nick"] = $data["name"];
  1398. $data["photo"] = Network::lookupAvatarByEmail($uri);
  1399. $data["url"] = 'mailto:'.$uri;
  1400. $data["notify"] = 'smtp ' . Strings::getRandomHex();
  1401. $data["poll"] = 'email ' . Strings::getRandomHex();
  1402. $x = Email::messageMeta($mbox, $msgs[0]);
  1403. if (stristr($x[0]->from, $uri)) {
  1404. $adr = imap_rfc822_parse_adrlist($x[0]->from, '');
  1405. } elseif (stristr($x[0]->to, $uri)) {
  1406. $adr = imap_rfc822_parse_adrlist($x[0]->to, '');
  1407. }
  1408. if (isset($adr)) {
  1409. foreach ($adr as $feadr) {
  1410. if ((strcasecmp($feadr->mailbox, $data["name"]) == 0)
  1411. &&(strcasecmp($feadr->host, $phost) == 0)
  1412. && (strlen($feadr->personal))
  1413. ) {
  1414. $personal = imap_mime_header_decode($feadr->personal);
  1415. $data["name"] = "";
  1416. foreach ($personal as $perspart) {
  1417. if ($perspart->charset != "default") {
  1418. $data["name"] .= iconv($perspart->charset, 'UTF-8//IGNORE', $perspart->text);
  1419. } else {
  1420. $data["name"] .= $perspart->text;
  1421. }
  1422. }
  1423. $data["name"] = Strings::escapeTags($data["name"]);
  1424. }
  1425. }
  1426. }
  1427. if (!empty($mbox)) {
  1428. imap_close($mbox);
  1429. }
  1430. return $data;
  1431. }
  1432. /**
  1433. * @brief Mix two paths together to possibly fix missing parts
  1434. *
  1435. * @param string $avatar Path to the avatar
  1436. * @param string $base Another path that is hopefully complete
  1437. *
  1438. * @return string fixed avatar path
  1439. */
  1440. public static function fixAvatar($avatar, $base)
  1441. {
  1442. $base_parts = parse_url($base);
  1443. // Remove all parts that could create a problem
  1444. unset($base_parts['path']);
  1445. unset($base_parts['query']);
  1446. unset($base_parts['fragment']);
  1447. $avatar_parts = parse_url($avatar);
  1448. // Now we mix them
  1449. $parts = array_merge($base_parts, $avatar_parts);
  1450. // And put them together again
  1451. $scheme = isset($parts['scheme']) ? $parts['scheme'] . '://' : '';
  1452. $host = isset($parts['host']) ? $parts['host'] : '';
  1453. $port = isset($parts['port']) ? ':' . $parts['port'] : '';
  1454. $path = isset($parts['path']) ? $parts['path'] : '';
  1455. $query = isset($parts['query']) ? '?' . $parts['query'] : '';
  1456. $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
  1457. $fixed = $scheme.$host.$port.$path.$query.$fragment;
  1458. Logger::log('Base: '.$base.' - Avatar: '.$avatar.' - Fixed: '.$fixed, Logger::DATA);
  1459. return $fixed;
  1460. }
  1461. }