explorer.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. <?php
  2. // This file is part of GNU social - https://www.gnu.org/software/social
  3. //
  4. // GNU social is free software: you can redistribute it and/or modify
  5. // it under the terms of the GNU Affero General Public License as published by
  6. // the Free Software Foundation, either version 3 of the License, or
  7. // (at your option) any later version.
  8. //
  9. // GNU social is distributed in the hope that it will be useful,
  10. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. // GNU Affero General Public License for more details.
  13. //
  14. // You should have received a copy of the GNU Affero General Public License
  15. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  16. /**
  17. * ActivityPub implementation for GNU social
  18. *
  19. * @package GNUsocial
  20. * @author Diogo Cordeiro <diogo@fc.up.pt>
  21. * @copyright 2018-2019 Free Software Foundation, Inc http://www.fsf.org
  22. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  23. * @link http://www.gnu.org/software/social/
  24. */
  25. defined('GNUSOCIAL') || die();
  26. /**
  27. * ActivityPub's own Explorer
  28. *
  29. * Allows to discovery new (or the same) Profiles (both local or remote)
  30. *
  31. * @category Plugin
  32. * @package GNUsocial
  33. * @author Diogo Cordeiro <diogo@fc.up.pt>
  34. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  35. */
  36. class Activitypub_explorer
  37. {
  38. private $discovered_actor_profiles = [];
  39. /**
  40. * Shortcut function to get a single profile from its URL.
  41. *
  42. * @param string $url
  43. * @param bool $grab_online whether to try online grabbing, defaults to true
  44. * @return Profile
  45. * @throws HTTP_Request2_Exception Network issues
  46. * @throws NoProfileException This won't happen
  47. * @throws Exception Invalid request
  48. * @throws ServerException Error storing remote actor
  49. * @author Diogo Cordeiro <diogo@fc.up.pt>
  50. */
  51. public static function get_profile_from_url($url, $grab_online = true)
  52. {
  53. $discovery = new Activitypub_explorer();
  54. // Get valid Actor object
  55. $actor_profile = $discovery->lookup($url, $grab_online);
  56. if (!empty($actor_profile)) {
  57. return $actor_profile[0];
  58. }
  59. throw new Exception('Invalid Actor.');
  60. }
  61. /**
  62. * Get every profile from the given URL
  63. * This function cleans the $this->discovered_actor_profiles array
  64. * so that there is no erroneous data
  65. *
  66. * @param string $url User's url
  67. * @param bool $grab_online whether to try online grabbing, defaults to true
  68. * @return array of Profile objects
  69. * @throws HTTP_Request2_Exception
  70. * @throws NoProfileException
  71. * @throws Exception
  72. * @throws ServerException
  73. * @author Diogo Cordeiro <diogo@fc.up.pt>
  74. */
  75. public function lookup(string $url, bool $grab_online = true)
  76. {
  77. if (in_array($url, ACTIVITYPUB_PUBLIC_TO)) {
  78. return [];
  79. }
  80. common_debug('ActivityPub Explorer: Started now looking for ' . $url);
  81. $this->discovered_actor_profiles = [];
  82. return $this->_lookup($url, $grab_online);
  83. }
  84. /**
  85. * Get every profile from the given URL
  86. * This is a recursive function that will accumulate the results on
  87. * $discovered_actor_profiles array
  88. *
  89. * @param string $url User's url
  90. * @param bool $grab_online whether to try online grabbing, defaults to true
  91. * @return array of Profile objects
  92. * @throws HTTP_Request2_Exception
  93. * @throws NoProfileException
  94. * @throws ServerException
  95. * @throws Exception
  96. * @author Diogo Cordeiro <diogo@fc.up.pt>
  97. */
  98. private function _lookup(string $url, bool $grab_online = true)
  99. {
  100. $grab_local = $this->grab_local_user($url);
  101. // First check if we already have it locally and, if so, return it.
  102. // If the local fetch fails and remote grab is required: store locally and return.
  103. if (!$grab_local && (!$grab_online || !$this->grab_remote_user($url))) {
  104. throw new Exception('User not found.');
  105. }
  106. return $this->discovered_actor_profiles;
  107. }
  108. /**
  109. * Get a local user profile from its URL and joins it on
  110. * $this->discovered_actor_profiles
  111. *
  112. * @param string $uri Actor's uri
  113. * @param bool $online
  114. * @return bool success state
  115. * @throws NoProfileException
  116. * @throws Exception
  117. * @author Diogo Cordeiro <diogo@fc.up.pt>
  118. */
  119. private function grab_local_user($uri, $online = false)
  120. {
  121. if ($online) {
  122. common_debug('ActivityPub Explorer: Searching locally for ' . $uri . ' with online resources.');
  123. $all_ids = LRDDPlugin::grab_profile_aliases($uri);
  124. } else {
  125. common_debug('ActivityPub Explorer: Searching locally for ' . $uri . ' offline.');
  126. $all_ids = [$uri];
  127. }
  128. if (is_null($all_ids)) {
  129. common_debug('AcvitityPub Explorer: Unable to find a local profile for ' . $uri);
  130. return false;
  131. }
  132. foreach ($all_ids as $alias) {
  133. // Try standard ActivityPub route
  134. // Is this a known filthy little mudblood?
  135. $aprofile = self::get_aprofile_by_url($alias);
  136. if ($aprofile instanceof Activitypub_profile) {
  137. common_debug('ActivityPub Explorer: Found a local Aprofile for ' . $alias);
  138. // double check to confirm this alias as a legitimate one
  139. if ($online) {
  140. common_debug('ActivityPub Explorer: Double-checking ' . $alias . ' to confirm it as a legitimate alias');
  141. $disco = new Discovery();
  142. $xrd = $disco->lookup($aprofile->getUri());
  143. $doublecheck_aliases = array_merge(array($xrd->subject), $xrd->aliases);
  144. if (in_array($uri, $doublecheck_aliases)) {
  145. // the original URI is present, we're sure now!
  146. // update aprofile's URI and proceed
  147. common_debug('ActivityPub Explorer: ' . $alias . ' is a legitimate alias');
  148. $aprofile->updateUri($uri);
  149. } else {
  150. common_debug('ActivityPub Explorer: ' . $alias . ' is not an alias we can trust');
  151. continue;
  152. }
  153. }
  154. // Assert: This AProfile has a Profile, no try catch.
  155. $profile = $aprofile->local_profile();
  156. // We found something!
  157. $this->discovered_actor_profiles[] = $profile;
  158. return true;
  159. } else {
  160. common_debug('ActivityPub Explorer: Unable to find a local Aprofile for ' . $alias . ' - looking for a Profile instead.');
  161. // Well, maybe it is a pure blood?
  162. // Iff, we are in the same instance:
  163. $ACTIVITYPUB_BASE_ACTOR_URI = common_local_url('userbyid', ['id' => null], null, null, false, true); // @FIXME: Could this be too hardcoded?
  164. $ACTIVITYPUB_BASE_ACTOR_URI_length = strlen($ACTIVITYPUB_BASE_ACTOR_URI);
  165. if (substr($alias, 0, $ACTIVITYPUB_BASE_ACTOR_URI_length) === $ACTIVITYPUB_BASE_ACTOR_URI) {
  166. try {
  167. $profile = Profile::getByID((int)substr($alias, $ACTIVITYPUB_BASE_ACTOR_URI_length));
  168. common_debug('ActivityPub Explorer: Found a Profile for ' . $alias);
  169. // We found something!
  170. $this->discovered_actor_profiles[] = $profile;
  171. return true;
  172. } catch (Exception $e) {
  173. // Let the exception go on its merry way.
  174. common_debug('ActivityPub Explorer: Unable to find a Profile for ' . $alias);
  175. }
  176. }
  177. }
  178. }
  179. // If offline grabbing failed, attempt again with online resources
  180. if (!$online) {
  181. common_debug('ActivityPub Explorer: Will try everything again with online resources against: ' . $uri);
  182. return $this->grab_local_user($uri, true);
  183. }
  184. return false;
  185. }
  186. /**
  187. * Get a remote user(s) profile(s) from its URL and joins it on
  188. * $this->discovered_actor_profiles
  189. *
  190. * @param string $url User's url
  191. * @return bool success state
  192. * @throws HTTP_Request2_Exception
  193. * @throws NoProfileException
  194. * @throws ServerException
  195. * @throws Exception
  196. * @author Diogo Cordeiro <diogo@fc.up.pt>
  197. */
  198. private function grab_remote_user($url)
  199. {
  200. common_debug('ActivityPub Explorer: Trying to grab a remote actor for ' . $url);
  201. $client = new HTTPClient();
  202. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  203. $res = json_decode($response->getBody(), true);
  204. if (isset($res['type']) && $res['type'] === 'OrderedCollection' && isset($res['first'])) { // It's a potential collection of actors!!!
  205. common_debug('ActivityPub Explorer: Found a collection of actors for ' . $url);
  206. $this->travel_collection($res['first']);
  207. return true;
  208. } elseif (self::validate_remote_response($res)) {
  209. common_debug('ActivityPub Explorer: Found a valid remote actor for ' . $url);
  210. $this->discovered_actor_profiles[] = $this->store_profile($res);
  211. return true;
  212. } else {
  213. common_debug('ActivityPub Explorer: Invalid potential remote actor while grabbing remotely: ' . $url . '. He returned the following: ' . json_encode($res, JSON_UNESCAPED_SLASHES));
  214. }
  215. return false;
  216. }
  217. /**
  218. * Save remote user profile in local instance
  219. *
  220. * @param array $res remote response
  221. * @return Profile remote Profile object
  222. * @throws NoProfileException
  223. * @throws ServerException
  224. * @throws Exception
  225. * @author Diogo Cordeiro <diogo@fc.up.pt>
  226. */
  227. private function store_profile($res)
  228. {
  229. // ActivityPub Profile
  230. $aprofile = new Activitypub_profile;
  231. $aprofile->uri = $res['id'];
  232. $aprofile->nickname = $res['preferredUsername'];
  233. $aprofile->fullname = $res['name'] ?? null;
  234. $aprofile->bio = isset($res['summary']) ? substr(strip_tags($res['summary']), 0, 1000) : null;
  235. $aprofile->inboxuri = $res['inbox'];
  236. $aprofile->sharedInboxuri = $res['endpoints']['sharedInbox'] ?? $res['inbox'];
  237. $aprofile->profileurl = $res['url'] ?? $aprofile->uri;
  238. $aprofile->do_insert();
  239. $profile = $aprofile->local_profile();
  240. // Public Key
  241. $apRSA = new Activitypub_rsa();
  242. $apRSA->profile_id = $profile->getID();
  243. $apRSA->public_key = $res['publicKey']['publicKeyPem'];
  244. $apRSA->store_keys();
  245. // Avatar
  246. if (isset($res['icon']['url'])) {
  247. try {
  248. $this->update_avatar($profile, $res['icon']['url']);
  249. } catch (Exception $e) {
  250. // Let the exception go, it isn't a serious issue
  251. common_debug('ActivityPub Explorer: An error ocurred while grabbing remote avatar: ' . $e->getMessage());
  252. }
  253. }
  254. return $profile;
  255. }
  256. /**
  257. * Download and update given avatar image
  258. *
  259. * @param Profile $profile
  260. * @param string $url
  261. * @return Avatar The Avatar we have on disk.
  262. * @throws Exception in various failure cases
  263. * @author GNU social
  264. */
  265. public static function update_avatar(Profile $profile, $url)
  266. {
  267. common_debug('ActivityPub Explorer: Started grabbing remote avatar from: ' . $url);
  268. if (!filter_var($url, FILTER_VALIDATE_URL)) {
  269. // TRANS: Server exception. %s is a URL.
  270. common_debug('ActivityPub Explorer: Failed because it is an invalid url: ' . $url);
  271. throw new ServerException(sprintf('Invalid avatar URL %s.', $url));
  272. }
  273. // @todo FIXME: This should be better encapsulated
  274. // ripped from oauthstore.php (for old OMB client)
  275. $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
  276. try {
  277. $imgData = HTTPClient::quickGet($url);
  278. // Make sure it's at least an image file. ImageFile can do the rest.
  279. if (false === getimagesizefromstring($imgData)) {
  280. common_debug('ActivityPub Explorer: Failed because the downloaded avatar: ' . $url . 'is not a valid image.');
  281. throw new UnsupportedMediaException('Downloaded avatar was not an image.');
  282. }
  283. file_put_contents($temp_filename, $imgData);
  284. unset($imgData); // No need to carry this in memory.
  285. common_debug('ActivityPub Explorer: Stored dowloaded avatar in: ' . $temp_filename);
  286. $id = $profile->getID();
  287. $imagefile = new ImageFile(null, $temp_filename);
  288. $filename = Avatar::filename(
  289. $id,
  290. image_type_to_extension($imagefile->type),
  291. null,
  292. common_timestamp()
  293. );
  294. rename($temp_filename, Avatar::path($filename));
  295. common_debug('ActivityPub Explorer: Moved avatar from: ' . $temp_filename . ' to ' . $filename);
  296. } catch (Exception $e) {
  297. common_debug('ActivityPub Explorer: Something went wrong while processing the avatar from: ' . $url . ' details: ' . $e->getMessage());
  298. unlink($temp_filename);
  299. throw $e;
  300. }
  301. // @todo FIXME: Hardcoded chmod is lame, but seems to be necessary to
  302. // keep from accidentally saving images from command-line (queues)
  303. // that can't be read from web server, which causes hard-to-notice
  304. // problems later on:
  305. //
  306. // http://status.net/open-source/issues/2663
  307. chmod(Avatar::path($filename), 0644);
  308. $profile->setOriginal($filename);
  309. $orig = clone($profile);
  310. $profile->avatar = $url;
  311. $profile->update($orig);
  312. common_debug('ActivityPub Explorer: Seted Avatar from: ' . $url . ' to profile.');
  313. return Avatar::getUploaded($profile);
  314. }
  315. /**
  316. * Validates a remote response in order to determine whether this
  317. * response is a valid profile or not
  318. *
  319. * @param array $res remote response
  320. * @return bool success state
  321. * @author Diogo Cordeiro <diogo@fc.up.pt>
  322. */
  323. public static function validate_remote_response($res)
  324. {
  325. if (!isset($res['id'], $res['preferredUsername'], $res['inbox'], $res['publicKey']['publicKeyPem'])) {
  326. return false;
  327. }
  328. return true;
  329. }
  330. /**
  331. * Get a ActivityPub Profile from it's uri
  332. * Unfortunately GNU social cache is not truly reliable when handling
  333. * potential ActivityPub remote profiles, as so it is important to use
  334. * this hacky workaround (at least for now)
  335. *
  336. * @param string $v URL
  337. * @return bool|Activitypub_profile false if fails | Aprofile object if successful
  338. * @author Diogo Cordeiro <diogo@fc.up.pt>
  339. */
  340. public static function get_aprofile_by_url($v)
  341. {
  342. $i = Managed_DataObject::getcached("Activitypub_profile", "uri", $v);
  343. if (empty($i)) { // false = cache miss
  344. $i = new Activitypub_profile;
  345. $result = $i->get("uri", $v);
  346. if ($result) {
  347. // Hit!
  348. $i->encache();
  349. } else {
  350. return false;
  351. }
  352. }
  353. return $i;
  354. }
  355. /**
  356. * Given a valid actor profile url returns its inboxes
  357. *
  358. * @param string $url of Actor profile
  359. * @return bool|array false if fails | array with inbox and shared inbox if successful
  360. * @throws HTTP_Request2_Exception
  361. * @throws Exception
  362. * @author Diogo Cordeiro <diogo@fc.up.pt>
  363. */
  364. public static function get_actor_inboxes_uri($url)
  365. {
  366. $client = new HTTPClient();
  367. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  368. if (!$response->isOk()) {
  369. throw new Exception('Invalid Actor URL.');
  370. }
  371. $res = json_decode($response->getBody(), true);
  372. if (self::validate_remote_response($res)) {
  373. return [
  374. 'inbox' => $res['inbox'],
  375. 'sharedInbox' => isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : $res['inbox']
  376. ];
  377. }
  378. return false;
  379. }
  380. /**
  381. * Allows the Explorer to transverse a collection of persons.
  382. *
  383. * @param string $url
  384. * @return bool
  385. * @throws HTTP_Request2_Exception
  386. * @throws NoProfileException
  387. * @throws ServerException
  388. * @author Diogo Cordeiro <diogo@fc.up.pt>
  389. */
  390. private function travel_collection($url)
  391. {
  392. $client = new HTTPClient();
  393. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  394. $res = json_decode($response->getBody(), true);
  395. if (!isset($res['orderedItems'])) {
  396. return false;
  397. }
  398. foreach ($res["orderedItems"] as $profile) {
  399. if ($this->_lookup($profile) == false) {
  400. common_debug('ActivityPub Explorer: Found an invalid actor for ' . $profile);
  401. // TODO: Invalid actor found, fallback to OStatus
  402. }
  403. }
  404. // Go through entire collection
  405. if (!is_null($res["next"])) {
  406. $this->travel_collection($res["next"]);
  407. }
  408. return true;
  409. }
  410. /**
  411. * Get a remote user array from its URL (this function is only used for
  412. * profile updating and shall not be used for anything else)
  413. *
  414. * @param string $url User's url
  415. * @return array
  416. * @throws Exception Either network issues or unsupported Activity format
  417. * @author Diogo Cordeiro <diogo@fc.up.pt>
  418. */
  419. public static function get_remote_user_activity($url)
  420. {
  421. $client = new HTTPClient();
  422. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  423. $res = json_decode($response->getBody(), true);
  424. if (Activitypub_explorer::validate_remote_response($res)) {
  425. common_debug('ActivityPub Explorer: Found a valid remote actor for ' . $url);
  426. return $res;
  427. }
  428. throw new Exception('ActivityPub Explorer: Failed to get activity.');
  429. }
  430. }