explorer.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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(string $url, bool $grab_online = true): Profile
  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): array
  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(string $uri, bool $online = false): bool
  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(string $url): bool
  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 ($response->getStatus() == 410) { // If it was deleted
  205. return true; // Nothing to add.
  206. } elseif (!$response->isOk()) { // If it is unavailable
  207. return false; // Try to add at another time.
  208. }
  209. if (is_null($res)) {
  210. common_debug('ActivityPub Explorer: Invalid JSON returned from given Actor URL: ' . $response->getBody());
  211. return true; // Nothing to add.
  212. }
  213. if (isset($res['type']) && $res['type'] === 'OrderedCollection' && isset($res['first'])) { // It's a potential collection of actors!!!
  214. common_debug('ActivityPub Explorer: Found a collection of actors for ' . $url);
  215. $this->travel_collection($res['first']);
  216. return true;
  217. } elseif (self::validate_remote_response($res)) {
  218. common_debug('ActivityPub Explorer: Found a valid remote actor for ' . $url);
  219. $this->discovered_actor_profiles[] = $this->store_profile($res);
  220. return true;
  221. } else {
  222. common_debug('ActivityPub Explorer: Invalid potential remote actor while grabbing remotely: ' . $url . '. He returned the following: ' . json_encode($res, JSON_UNESCAPED_SLASHES));
  223. return false;
  224. }
  225. return false;
  226. }
  227. /**
  228. * Save remote user profile in local instance
  229. *
  230. * @param array $res remote response
  231. * @return Profile remote Profile object
  232. * @throws NoProfileException
  233. * @throws ServerException
  234. * @throws Exception
  235. * @author Diogo Cordeiro <diogo@fc.up.pt>
  236. */
  237. private function store_profile(array $res): Profile
  238. {
  239. // ActivityPub Profile
  240. $aprofile = new Activitypub_profile;
  241. $aprofile->uri = $res['id'];
  242. $aprofile->nickname = $res['preferredUsername'];
  243. $aprofile->fullname = $res['name'] ?? null;
  244. $aprofile->bio = isset($res['summary']) ? substr(strip_tags($res['summary']), 0, 1000) : null;
  245. $aprofile->inboxuri = $res['inbox'];
  246. $aprofile->sharedInboxuri = $res['endpoints']['sharedInbox'] ?? $res['inbox'];
  247. $aprofile->profileurl = $res['url'] ?? $aprofile->uri;
  248. $aprofile->do_insert();
  249. $profile = $aprofile->local_profile();
  250. // Public Key
  251. $apRSA = new Activitypub_rsa();
  252. $apRSA->profile_id = $profile->getID();
  253. $apRSA->public_key = $res['publicKey']['publicKeyPem'];
  254. $apRSA->store_keys();
  255. // Avatar
  256. if (isset($res['icon']['url'])) {
  257. try {
  258. $this->update_avatar($profile, $res['icon']['url']);
  259. } catch (Exception $e) {
  260. // Let the exception go, it isn't a serious issue
  261. common_debug('ActivityPub Explorer: An error ocurred while grabbing remote avatar: ' . $e->getMessage());
  262. }
  263. }
  264. return $profile;
  265. }
  266. /**
  267. * Validates a remote response in order to determine whether this
  268. * response is a valid profile or not
  269. *
  270. * @param array $res remote response
  271. * @return bool success state
  272. * @author Diogo Cordeiro <diogo@fc.up.pt>
  273. */
  274. public static function validate_remote_response(array $res): bool
  275. {
  276. if (!isset($res['id'], $res['preferredUsername'], $res['inbox'], $res['publicKey']['publicKeyPem'])) {
  277. return false;
  278. }
  279. return true;
  280. }
  281. /**
  282. * Get a ActivityPub Profile from it's uri
  283. * Unfortunately GNU social cache is not truly reliable when handling
  284. * potential ActivityPub remote profiles, as so it is important to use
  285. * this hacky workaround (at least for now)
  286. *
  287. * @param string $v URL
  288. * @return bool|Activitypub_profile false if fails | Aprofile object if successful
  289. * @author Diogo Cordeiro <diogo@fc.up.pt>
  290. */
  291. public static function get_aprofile_by_url(string $v)
  292. {
  293. $i = Managed_DataObject::getcached("Activitypub_profile", "uri", $v);
  294. if (empty($i)) { // false = cache miss
  295. $i = new Activitypub_profile;
  296. $result = $i->get("uri", $v);
  297. if ($result) {
  298. // Hit!
  299. $i->encache();
  300. } else {
  301. return false;
  302. }
  303. }
  304. return $i;
  305. }
  306. /**
  307. * Given a valid actor profile url returns its inboxes
  308. *
  309. * @param string $url of Actor profile
  310. * @return bool|array false if fails to validate the answer | array with inbox and shared inbox if successful
  311. * @throws HTTP_Request2_Exception
  312. * @throws Exception If an irregular error happens (status code, body format or GONE)
  313. * @author Diogo Cordeiro <diogo@fc.up.pt>
  314. */
  315. public static function get_actor_inboxes_uri(string $url)
  316. {
  317. $client = new HTTPClient();
  318. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  319. if ($response->getStatus() == 410) { // If it was deleted
  320. throw new Exception('This actor is GONE.');
  321. } elseif (!$response->isOk()) { // If it is unavailable
  322. throw new Exception('Non Ok Status Code for given Actor URL.');
  323. }
  324. $res = json_decode($response->getBody(), true);
  325. if (is_null($res)) { // If it is in an unexpected format
  326. common_debug('ActivityPub Explorer: Invalid JSON returned from given Actor URL: ' . $response->getBody());
  327. throw new Exception('Given Actor URL didn\'t return a valid JSON.');
  328. }
  329. if (self::validate_remote_response($res)) {
  330. return [
  331. 'inbox' => $res['inbox'],
  332. 'sharedInbox' => isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : $res['inbox']
  333. ];
  334. }
  335. return false;
  336. }
  337. /**
  338. * Download and update given avatar image
  339. * TODO: Avoid updating an avatar if its URL didn't change. (this is something OStatus already does)
  340. * TODO: Should be in AProfile instead?
  341. *
  342. * @param Profile $profile
  343. * @param string $url
  344. * @return Avatar The Avatar we have on disk. (seldom used)
  345. * @throws Exception in various failure cases
  346. * @author Diogo Cordeiro <diogo@fc.up.pt>
  347. */
  348. public static function update_avatar(Profile $profile, string $url): Avatar
  349. {
  350. common_debug('ActivityPub Explorer: Started grabbing remote avatar from: ' . $url);
  351. // ImageFile throws exception if something goes wrong, which we'll let go on its merry way
  352. $imagefile = ImageFile::fromURL($url);
  353. $id = $profile->getID();
  354. $type = $imagefile->preferredType();
  355. $filename = Avatar::filename(
  356. $id,
  357. image_type_to_extension($type),
  358. null,
  359. 'tmp' . common_timestamp()
  360. );
  361. $filepath = Avatar::path($filename);
  362. /*$imagefile = */$imagefile->copyTo($filepath);
  363. common_debug('ActivityPub Explorer: Stored avatar in: ' . $filepath);
  364. // XXX: Do we need this?
  365. chmod($filepath, 0644);
  366. $profile->setOriginal($filename);
  367. common_debug('ActivityPub Explorer: Seted Avatar from: ' . $url . ' to profile.');
  368. return Avatar::getUploaded($profile);
  369. }
  370. /**
  371. * Allows the Explorer to transverse a collection of persons.
  372. *
  373. * @param string $url
  374. * @return bool
  375. * @throws HTTP_Request2_Exception
  376. * @throws NoProfileException
  377. * @throws ServerException
  378. * @author Diogo Cordeiro <diogo@fc.up.pt>
  379. */
  380. private function travel_collection(string $url): bool
  381. {
  382. $client = new HTTPClient();
  383. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  384. $res = json_decode($response->getBody(), true);
  385. if (!isset($res['orderedItems'])) {
  386. return false;
  387. }
  388. foreach ($res["orderedItems"] as $profile) {
  389. if ($this->_lookup($profile) == false) {
  390. common_debug('ActivityPub Explorer: Found an invalid actor for ' . $profile);
  391. // TODO: Invalid actor found, fallback to OStatus
  392. }
  393. }
  394. // Go through entire collection
  395. if (!is_null($res["next"])) {
  396. $this->travel_collection($res["next"]);
  397. }
  398. return true;
  399. }
  400. /**
  401. * Get a remote user array from its URL (this function is only used for
  402. * profile updating and shall not be used for anything else)
  403. *
  404. * @param string $url User's url
  405. * @return array|false If it is able to fetch, false if it's gone
  406. * @throws Exception Either network issues or unsupported Activity format
  407. * @author Diogo Cordeiro <diogo@fc.up.pt>
  408. */
  409. public static function get_remote_user_activity(string $url)
  410. {
  411. $client = new HTTPClient();
  412. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  413. // If it was deleted
  414. if ($response->getStatus() == 410) {
  415. return false;
  416. } elseif (!$response->isOk()) { // If it is unavailable
  417. throw new Exception('Non Ok Status Code for given Actor URL.');
  418. }
  419. $res = json_decode($response->getBody(), true);
  420. if (is_null($res)) {
  421. common_debug('ActivityPub Explorer: Invalid JSON returned from given Actor URL: ' . $response->getBody());
  422. throw new Exception('Given Actor URL didn\'t return a valid JSON.');
  423. }
  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. }