ActivityPubPlugin.php 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  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. // Import plugin libs
  27. foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
  28. require_once $filename;
  29. }
  30. // Import plugin models
  31. foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'models' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
  32. require_once $filename;
  33. }
  34. // So that this isn't hardcoded everywhere
  35. define('ACTIVITYPUB_BASE_ACTOR_URI', common_root_url().'index.php/user/');
  36. const ACTIVITYPUB_PUBLIC_TO = ['https://www.w3.org/ns/activitystreams#Public',
  37. 'Public',
  38. 'as:Public'
  39. ];
  40. const ACTIVITYPUB_HTTP_CLIENT_HEADERS = [
  41. 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  42. 'User-Agent: GNUsocialBot ' . GNUSOCIAL_VERSION . ' - https://gnusocial.network'
  43. ];
  44. /**
  45. * @category Plugin
  46. * @package GNUsocial
  47. * @author Diogo Cordeiro <diogo@fc.up.pt>
  48. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  49. */
  50. class ActivityPubPlugin extends Plugin
  51. {
  52. const PLUGIN_VERSION = '0.4.0alpha0';
  53. /**
  54. * Returns a Actor's URI from its local $profile
  55. * Works both for local and remote users.
  56. *
  57. * @param Profile $profile Actor's local profile
  58. * @return string Actor's URI
  59. * @throws Exception
  60. * @author Diogo Cordeiro <diogo@fc.up.pt>
  61. */
  62. public static function actor_uri($profile)
  63. {
  64. if ($profile->isLocal()) {
  65. return ACTIVITYPUB_BASE_ACTOR_URI.$profile->getID();
  66. } else {
  67. return $profile->getUri();
  68. }
  69. }
  70. /**
  71. * Returns a Actor's URL from its local $profile
  72. * Works both for local and remote users.
  73. *
  74. * @param Profile $profile Actor's local profile
  75. * @return string Actor's URL
  76. * @throws Exception
  77. * @author Diogo Cordeiro <diogo@fc.up.pt>
  78. */
  79. public static function actor_url($profile)
  80. {
  81. return ActivityPubPlugin::actor_uri($profile)."/";
  82. }
  83. /**
  84. * Returns a notice from its URL.
  85. *
  86. * @param string $url Notice's URL
  87. * @param bool $grab_online whether to try online grabbing, defaults to true
  88. * @return Notice|null The Notice object
  89. * @throws Exception This function or provides a Notice, null, or fails with exception
  90. * @author Diogo Cordeiro <diogo@fc.up.pt>
  91. */
  92. public static function grab_notice_from_url(string $url, bool $grab_online = true): ?Notice
  93. {
  94. /* Offline Grabbing */
  95. try {
  96. // Look for a known remote notice
  97. return Notice::getByUri($url);
  98. } catch (Exception $e) {
  99. // Look for a local notice (unfortunately GNU social doesn't
  100. // provide this functionality natively)
  101. try {
  102. $candidate = Notice::getByID(intval(substr($url, (strlen(common_local_url('apNotice', ['id' => 0]))-1))));
  103. if (common_local_url('apNotice', ['id' => $candidate->getID()]) === $url) { // Sanity check
  104. return $candidate;
  105. } else {
  106. common_debug('ActivityPubPlugin Notice Grabber: '.$candidate->getUrl(). ' is different of '.$url);
  107. }
  108. } catch (Exception $e) {
  109. common_debug('ActivityPubPlugin Notice Grabber: failed to find: '.$url.' offline.');
  110. }
  111. }
  112. if ($grab_online) {
  113. /* Online Grabbing */
  114. $client = new HTTPClient();
  115. $response = $client->get($url, ACTIVITYPUB_HTTP_CLIENT_HEADERS);
  116. $object = json_decode($response->getBody(), true);
  117. if (Activitypub_notice::validate_note($object)) {
  118. return Activitypub_notice::create_notice($object);
  119. } else {
  120. throw new Exception("Valid ActivityPub Notice object but unsupported by GNU social.");
  121. }
  122. }
  123. common_debug('ActivityPubPlugin Notice Grabber: failed to find: '.$url);
  124. return null;
  125. }
  126. /**
  127. * Route/Reroute urls
  128. *
  129. * @param URLMapper $m
  130. * @return void
  131. * @throws Exception
  132. */
  133. public function onRouterInitialized(URLMapper $m)
  134. {
  135. $acceptHeaders = [
  136. 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' => 0,
  137. 'application/activity+json' => 1,
  138. 'application/json' => 2,
  139. 'application/ld+json' => 3
  140. ];
  141. $m->connect('user/:id',
  142. ['action' => 'apActorProfile'],
  143. ['id' => '[0-9]+'],
  144. true,
  145. $acceptHeaders);
  146. $m->connect(':nickname',
  147. ['action' => 'apActorProfile'],
  148. ['nickname' => Nickname::DISPLAY_FMT],
  149. true,
  150. $acceptHeaders);
  151. $m->connect(':nickname/',
  152. ['action' => 'apActorProfile'],
  153. ['nickname' => Nickname::DISPLAY_FMT],
  154. true,
  155. $acceptHeaders);
  156. $m->connect('notice/:id',
  157. ['action' => 'apNotice'],
  158. ['id' => '[0-9]+'],
  159. true,
  160. $acceptHeaders);
  161. $m->connect(
  162. 'user/:id/liked.json',
  163. ['action' => 'apActorLiked'],
  164. ['id' => '[0-9]+']
  165. );
  166. $m->connect(
  167. 'user/:id/followers.json',
  168. ['action' => 'apActorFollowers'],
  169. ['id' => '[0-9]+']
  170. );
  171. $m->connect(
  172. 'user/:id/following.json',
  173. ['action' => 'apActorFollowing'],
  174. ['id' => '[0-9]+']
  175. );
  176. $m->connect(
  177. 'user/:id/inbox.json',
  178. ['action' => 'apInbox'],
  179. ['id' => '[0-9]+']
  180. );
  181. $m->connect(
  182. 'user/:id/outbox.json',
  183. ['action' => 'apActorOutbox'],
  184. ['id' => '[0-9]+']
  185. );
  186. $m->connect(
  187. 'inbox.json',
  188. ['action' => 'apInbox']
  189. );
  190. }
  191. /**
  192. * Plugin version information
  193. *
  194. * @param array $versions
  195. * @return bool hook true
  196. */
  197. public function onPluginVersion(array &$versions): bool
  198. {
  199. $versions[] = [
  200. 'name' => 'ActivityPub',
  201. 'version' => self::PLUGIN_VERSION,
  202. 'author' => 'Diogo Cordeiro',
  203. 'homepage' => 'https://notabug.org/diogo/gnu-social/src/nightly/plugins/ActivityPub',
  204. // TRANS: Plugin description.
  205. 'rawdescription' => _m('Follow people across social networks that implement '.
  206. '<a href="https://activitypub.rocks/">ActivityPub</a>.')
  207. ];
  208. return true;
  209. }
  210. /**
  211. * Set up queue handlers for required interactions
  212. *
  213. * @param QueueManager $qm
  214. * @return bool event hook return
  215. */
  216. public function onEndInitializeQueueManager(QueueManager $qm): bool
  217. {
  218. // Notice distribution
  219. $qm->connect('activitypub', 'ActivityPubQueueHandler');
  220. return true;
  221. }
  222. /**
  223. * Enqueue saved notices for distribution
  224. *
  225. * @param Notice $notice notice to be distributed
  226. * @param Array &$transports list of transports to queue for
  227. * @return bool event hook return
  228. */
  229. public function onStartEnqueueNotice(Notice $notice, Array &$transports): bool
  230. {
  231. try {
  232. $id = $notice->getID();
  233. if ($id > 0) {
  234. $transports[] = 'activitypub';
  235. $this->log(LOG_INFO, "Notice:{$id} queued for distribution");
  236. }
  237. } catch (Exception $e) {
  238. $this->log(LOG_ERR, "Invalid notice, not queueing for distribution");
  239. }
  240. return true;
  241. }
  242. /**
  243. * Update notice before saving.
  244. * We'll use this as a hack to maintain replies to unlisted/followers-only
  245. * notices away from the public timelines.
  246. *
  247. * @param Notice &$notice notice to be saved
  248. * @return bool event hook return
  249. */
  250. public function onStartNoticeSave(Notice &$notice): bool {
  251. if ($notice->reply_to) {
  252. try {
  253. $parent = $notice->getParent();
  254. $is_local = (int)$parent->is_local;
  255. // if we're replying unlisted/followers-only notices received by AP
  256. // or replying to replies of such notices, then we make sure to set
  257. // the correct type flag.
  258. if ( ($parent->source === 'ActivityPub' && $is_local === Notice::GATEWAY) ||
  259. ($parent->source === 'web' && $is_local === Notice::LOCAL_NONPUBLIC) ) {
  260. $this->log(LOG_INFO, "Enforcing type flag LOCAL_NONPUBLIC for new notice");
  261. $notice->is_local = Notice::LOCAL_NONPUBLIC;
  262. }
  263. } catch (NoParentNoticeException $e) {
  264. // This is not a reply to something (has no parent)
  265. }
  266. }
  267. return true;
  268. }
  269. /**
  270. * Add AP-subscriptions for private messaging
  271. *
  272. * @param User $current current logged user
  273. * @param array &$recipients
  274. * @return void
  275. */
  276. public function onFillDirectMessageRecipients(User $current, array &$recipients): void {
  277. try {
  278. $subs = Activitypub_profile::getSubscribed($current->getProfile());
  279. foreach ($subs as $sub) {
  280. if (!$sub->isLocal()) { // AP plugin adds AP users
  281. try {
  282. $value = 'profile:'.$sub->getID();
  283. $recipients[$value] = substr($sub->getAcctUri(), 5) . " [{$sub->getBestName()}]";
  284. } catch (ProfileNoAcctUriException $e) {
  285. $recipients[$value] = "[?@?] " . $e->profile->getBestName();
  286. }
  287. }
  288. }
  289. } catch (NoResultException $e) {
  290. // let it go
  291. }
  292. }
  293. /**
  294. * Validate AP-recipients for profile page message action addition
  295. *
  296. * @param Profile $recipient
  297. * @return bool hook return value
  298. */
  299. public function onDirectMessageProfilePageActions(Profile $recipient): bool {
  300. $to = Activitypub_profile::getKV('profile_id', $recipient->getID());
  301. if ($to instanceof Activitypub_profile) {
  302. return false; // we can validate this profile, signal it
  303. }
  304. return true;
  305. }
  306. /**
  307. * Mark an ap_profile object for deletion
  308. *
  309. * @param Profile profile being deleted
  310. * @param array &$related objects with same profile_id to be deleted
  311. * @return void
  312. */
  313. public function onProfileDeleteRelated(Profile $profile, array &$related): void
  314. {
  315. if ($profile->isLocal()) {
  316. return;
  317. }
  318. try {
  319. $aprofile = Activitypub_profile::getKV('profile_id', $profile->getID());
  320. if ($aprofile instanceof Activitypub_profile) {
  321. // mark for deletion
  322. $related[] = 'Activitypub_profile';
  323. }
  324. } catch (Exception $e) {
  325. // nothing to do
  326. }
  327. }
  328. /**
  329. * Plugin Nodeinfo information
  330. *
  331. * @param array $protocols
  332. * @return bool hook true
  333. */
  334. public function onNodeInfoProtocols(array &$protocols)
  335. {
  336. $protocols[] = "activitypub";
  337. return true;
  338. }
  339. /**
  340. * Adds an indicator on Remote ActivityPub profiles.
  341. *
  342. * @param HTMLOutputter $out
  343. * @param Profile $profile
  344. * @return boolean hook return value
  345. * @throws Exception
  346. * @author Diogo Cordeiro <diogo@fc.up.pt>
  347. */
  348. public function onEndShowAccountProfileBlock(HTMLOutputter $out, Profile $profile)
  349. {
  350. if ($profile->isLocal()) {
  351. return true;
  352. }
  353. try {
  354. Activitypub_profile::from_profile($profile);
  355. } catch (Exception $e) {
  356. // Not a remote ActivityPub_profile! Maybe some other network
  357. // that has imported a non-local user (e.g.: OStatus)?
  358. return true;
  359. }
  360. $out->elementStart('dl', 'entity_tags activitypub_profile');
  361. $out->element('dt', null, 'ActivityPub');
  362. $out->element('dd', null, _m('Remote Profile'));
  363. $out->elementEnd('dl');
  364. return true;
  365. }
  366. /**
  367. * Hack the notice search-box and try to grab remote profiles or notices.
  368. *
  369. * Note that, on successful grabbing, this function will redirect to the
  370. * new profile/notice, so URL searching is directly affected. A good solution
  371. * for this is to store the URLs in the notice text without the https/http
  372. * prefixes. This would change the queries for URL searching and therefore we
  373. * could do both search and grab.
  374. *
  375. * @param string $query search query
  376. * @return bool hook
  377. * @author Bruno Casteleiro <up201505347@fc.up.pt>
  378. */
  379. public function onStartNoticeSearch(string $query): bool
  380. {
  381. if (!common_logged_in()) {
  382. // early return: Only allow logged users to import/search for remote actors or notes
  383. return true;
  384. }
  385. if (preg_match('!^((?:\w+\.)*\w+@(?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+)$!', $query)) { // WebFinger ID found!
  386. // Try to grab remote actor
  387. $aprofile = self::pull_remote_profile($query);
  388. if ($aprofile instanceof Activitypub_profile) {
  389. $url = common_local_url('userbyid', ['id' => $aprofile->getID()], null, null, false);
  390. common_redirect($url, 303);
  391. return false;
  392. }
  393. } elseif (filter_var($query, FILTER_VALIDATE_URL)) { // URL found!
  394. /* Is this an ActivityPub notice? */
  395. // If we already know it, just return
  396. try {
  397. $notice = self::grab_notice_from_url($query, false); // Only check locally
  398. if ($notice instanceof Notice) {
  399. return true;
  400. }
  401. } catch (Exception $e) {
  402. // We will next try online
  403. }
  404. // Otherwise, try to grab it
  405. try {
  406. $notice = self::grab_notice_from_url($query); // Unfortunately we will be trying locally again
  407. if ($notice instanceof Notice) {
  408. $url = common_local_url('shownotice', ['notice' => $notice->getID()]);
  409. common_redirect($url, 303);
  410. }
  411. } catch (Exception $e) {
  412. // We will next check if this URL is an actor
  413. }
  414. /* Is this an ActivityPub actor? */
  415. // If we already know it, just return
  416. try {
  417. $explorer = new Activitypub_explorer();
  418. $profile = $explorer->lookup($query, false)[0]; // Only check locally
  419. if ($profile instanceof Profile) {
  420. return true;
  421. }
  422. } catch (Exception $e) {
  423. // We will next try online
  424. }
  425. // Try to grab remote actor
  426. try {
  427. if (!isset($explorer)) {
  428. $explorer = new Activitypub_explorer();
  429. }
  430. $profile = $explorer->lookup($query)[0]; // Unfortunately we will be trying locally again
  431. if ($profile instanceof Profile) {
  432. $url = common_local_url('userbyid', ['id' => $profile->getID()], null, null, false);
  433. common_redirect($url, 303);
  434. return true;
  435. }
  436. } catch (Exception $e) {
  437. // Let the search run naturally
  438. }
  439. }
  440. return true;
  441. }
  442. /**
  443. * Make sure necessary tables are filled out.
  444. *
  445. * @return bool hook true
  446. */
  447. public function onCheckSchema()
  448. {
  449. $schema = Schema::get();
  450. $schema->ensureTable('activitypub_profile', Activitypub_profile::schemaDef());
  451. $schema->ensureTable('activitypub_rsa', Activitypub_rsa::schemaDef());
  452. $schema->ensureTable('activitypub_pending_follow_requests', Activitypub_pending_follow_requests::schemaDef());
  453. return true;
  454. }
  455. /********************************************************
  456. * WebFinger Events *
  457. ********************************************************/
  458. /**
  459. * Get remote user's ActivityPub_profile via a identifier
  460. *
  461. * @param string $arg A remote user identifier
  462. * @return Activitypub_profile|null Valid profile in success | null otherwise
  463. * @author GNU social
  464. * @author Diogo Cordeiro <diogo@fc.up.pt>
  465. */
  466. public static function pull_remote_profile($arg)
  467. {
  468. if (preg_match('!^((?:\w+\.)*\w+@(?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+)$!', $arg)) {
  469. // webfinger lookup
  470. try {
  471. return Activitypub_profile::ensure_webfinger($arg);
  472. } catch (Exception $e) {
  473. common_log(LOG_ERR, 'Webfinger lookup failed for ' .
  474. $arg . ': ' . $e->getMessage());
  475. }
  476. }
  477. // Look for profile URLs, with or without scheme:
  478. $urls = [];
  479. if (preg_match('!^https?://((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)$!', $arg)) {
  480. $urls[] = $arg;
  481. }
  482. if (preg_match('!^((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)$!', $arg)) {
  483. $schemes = array('http', 'https');
  484. foreach ($schemes as $scheme) {
  485. $urls[] = "$scheme://$arg";
  486. }
  487. }
  488. foreach ($urls as $url) {
  489. try {
  490. return Activitypub_profile::fromUri($url);
  491. } catch (Exception $e) {
  492. common_log(LOG_ERR, 'Profile lookup failed for ' .
  493. $arg . ': ' . $e->getMessage());
  494. }
  495. }
  496. return null;
  497. }
  498. /**
  499. * Webfinger matches: @user@example.com or even @user--one.george_orwell@1984.biz
  500. *
  501. * @author GNU social
  502. * @param string $text The text from which to extract webfinger IDs
  503. * @param string $preMention Character(s) that signals a mention ('@', '!'...)
  504. * @return array The matching IDs (without $preMention) and each respective position in the given string.
  505. */
  506. public static function extractWebfingerIds($text, $preMention='@')
  507. {
  508. $wmatches = [];
  509. $result = preg_match_all(
  510. '/(?<!\S)'.preg_quote($preMention, '/').'('.Nickname::WEBFINGER_FMT.')/',
  511. $text,
  512. $wmatches,
  513. PREG_OFFSET_CAPTURE
  514. );
  515. if ($result === false) {
  516. common_log(LOG_ERR, __METHOD__ . ': Error parsing webfinger IDs from text (preg_last_error=='.preg_last_error().').');
  517. return [];
  518. } elseif ($n_matches = count($wmatches)) {
  519. common_debug(sprintf('Found %d matches for WebFinger IDs: %s', $n_matches, _ve($wmatches)));
  520. }
  521. return $wmatches[1];
  522. }
  523. /**
  524. * Profile URL matches: @example.com/mublog/user
  525. *
  526. * @author GNU social
  527. * @param string $text The text from which to extract URL mentions
  528. * @param string $preMention Character(s) that signals a mention ('@', '!'...)
  529. * @return array The matching URLs (without @ or acct:) and each respective position in the given string.
  530. */
  531. public static function extractUrlMentions($text, $preMention='@')
  532. {
  533. $wmatches = [];
  534. // In the regexp below we need to match / _before_ URL_REGEX_VALID_PATH_CHARS because it otherwise gets merged
  535. // with the TLD before (but / is in URL_REGEX_VALID_PATH_CHARS anyway, it's just its positioning that is important)
  536. $result = preg_match_all(
  537. '/(?:^|\s+)'.preg_quote($preMention, '/').'('.URL_REGEX_DOMAIN_NAME.'(?:\/['.URL_REGEX_VALID_PATH_CHARS.']*)*)/',
  538. $text,
  539. $wmatches,
  540. PREG_OFFSET_CAPTURE
  541. );
  542. if ($result === false) {
  543. common_log(LOG_ERR, __METHOD__ . ': Error parsing profile URL mentions from text (preg_last_error=='.preg_last_error().').');
  544. return [];
  545. } elseif (count($wmatches)) {
  546. common_debug(sprintf('Found %d matches for profile URL mentions: %s', count($wmatches), _ve($wmatches)));
  547. }
  548. return $wmatches[1];
  549. }
  550. /**
  551. * Add activity+json mimetype on WebFinger
  552. *
  553. * @param XML_XRD $xrd
  554. * @param Managed_DataObject $object
  555. * @throws Exception
  556. * @author Diogo Cordeiro <diogo@fc.up.pt>
  557. */
  558. public function onEndWebFingerProfileLinks(XML_XRD $xrd, Managed_DataObject $object)
  559. {
  560. if ($object->isPerson()) {
  561. $link = new XML_XRD_Element_Link(
  562. 'self',
  563. ActivityPubPlugin::actor_uri($object->getProfile()),
  564. 'application/activity+json'
  565. );
  566. $xrd->links[] = clone($link);
  567. }
  568. }
  569. /**
  570. * Find any explicit remote mentions. Accepted forms:
  571. * Webfinger: @user@example.com
  572. * Profile link:
  573. * @param Profile $sender
  574. * @param string $text input markup text
  575. * @param $mentions
  576. * @return boolean hook return value
  577. * @throws InvalidUrlException
  578. * @author Diogo Cordeiro <diogo@fc.up.pt>
  579. * @example.com/mublog/user
  580. *
  581. * @author GNU social
  582. */
  583. public function onEndFindMentions(Profile $sender, $text, &$mentions)
  584. {
  585. $matches = [];
  586. foreach (self::extractWebfingerIds($text, '@') as $wmatch) {
  587. list($target, $pos) = $wmatch;
  588. $this->log(LOG_INFO, "Checking webfinger person '$target'");
  589. $profile = null;
  590. try {
  591. $aprofile = Activitypub_profile::ensure_webfinger($target);
  592. $profile = $aprofile->local_profile();
  593. } catch (Exception $e) {
  594. $this->log(LOG_ERR, "Webfinger check failed: " . $e->getMessage());
  595. continue;
  596. }
  597. assert($profile instanceof Profile);
  598. $displayName = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target)
  599. ? $profile->getNickname() // TODO: we could do getBestName() or getFullname() here
  600. : $target;
  601. $url = $profile->getUri();
  602. if (!common_valid_http_url($url)) {
  603. $url = $profile->getUrl();
  604. }
  605. $matches[$pos] = array('mentioned' => array($profile),
  606. 'type' => 'mention',
  607. 'text' => $displayName,
  608. 'position' => $pos,
  609. 'length' => mb_strlen($target),
  610. 'url' => $url);
  611. }
  612. foreach (self::extractUrlMentions($text) as $wmatch) {
  613. list($target, $pos) = $wmatch;
  614. $schemes = array('https', 'http');
  615. foreach ($schemes as $scheme) {
  616. $url = "$scheme://$target";
  617. $this->log(LOG_INFO, "Checking profile address '$url'");
  618. try {
  619. $aprofile = Activitypub_profile::fromUri($url);
  620. $profile = $aprofile->local_profile();
  621. $displayName = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target) ?
  622. $profile->nickname : $target;
  623. $matches[$pos] = array('mentioned' => array($profile),
  624. 'type' => 'mention',
  625. 'text' => $displayName,
  626. 'position' => $pos,
  627. 'length' => mb_strlen($target),
  628. 'url' => $profile->getUrl());
  629. break;
  630. } catch (Exception $e) {
  631. $this->log(LOG_ERR, "Profile check failed: " . $e->getMessage());
  632. }
  633. }
  634. }
  635. foreach ($mentions as $i => $other) {
  636. // If we share a common prefix with a local user, override it!
  637. $pos = $other['position'];
  638. if (isset($matches[$pos])) {
  639. $mentions[$i] = $matches[$pos];
  640. unset($matches[$pos]);
  641. }
  642. }
  643. foreach ($matches as $mention) {
  644. $mentions[] = $mention;
  645. }
  646. return true;
  647. }
  648. /**
  649. * Allow remote profile references to be used in commands:
  650. * sub update@status.net
  651. * whois evan@identi.ca
  652. * reply http://identi.ca/evan hey what's up
  653. *
  654. * @param Command $command
  655. * @param string $arg
  656. * @param Profile &$profile
  657. * @return boolean hook return code
  658. * @author GNU social
  659. * @author Diogo Cordeiro <diogo@fc.up.pt>
  660. */
  661. public function onStartCommandGetProfile($command, $arg, &$profile)
  662. {
  663. try {
  664. $aprofile = $this->pull_remote_profile($arg);
  665. $profile = $aprofile->local_profile();
  666. } catch (Exception $e) {
  667. // No remote ActivityPub profile found
  668. return true;
  669. }
  670. return false;
  671. }
  672. /********************************************************
  673. * Discovery Events *
  674. ********************************************************/
  675. /**
  676. * Profile URI for remote profiles.
  677. *
  678. * @author GNU social
  679. * @author Diogo Cordeiro <diogo@fc.up.pt>
  680. * @param Profile $profile
  681. * @param string $uri in/out
  682. * @return mixed hook return code
  683. */
  684. public function onStartGetProfileUri(Profile $profile, &$uri)
  685. {
  686. $aprofile = Activitypub_profile::getKV('profile_id', $profile->id);
  687. if ($aprofile instanceof Activitypub_profile) {
  688. $uri = $aprofile->getUri();
  689. return false;
  690. }
  691. return true;
  692. }
  693. /**
  694. * Profile from URI.
  695. *
  696. * @author GNU social
  697. * @author Diogo Cordeiro <diogo@fc.up.pt>
  698. * @param string $uri
  699. * @param Profile &$profile in/out param: Profile got from URI
  700. * @return mixed hook return code
  701. */
  702. public function onStartGetProfileFromURI($uri, &$profile)
  703. {
  704. try {
  705. $profile = Activitypub_explorer::get_profile_from_url($uri);
  706. return false;
  707. } catch (Exception $e) {
  708. return true; // It's not an ActivityPub profile as far as we know, continue event handling
  709. }
  710. }
  711. /**
  712. * Try to grab and store the remote profile by the given uri
  713. *
  714. * @param string $uri
  715. * @param Profile &$profile
  716. * @return bool
  717. */
  718. public function onRemoteFollowPullProfile(string $uri, ?Profile &$profile): bool
  719. {
  720. try {
  721. $aprofile = $this->pull_remote_profile($uri);
  722. if ($aprofile instanceof Activitypub_profile) {
  723. $profile = $aprofile->local_profile();
  724. }
  725. } catch (Exception $e) {
  726. // No remote ActivityPub profile found
  727. return true;
  728. }
  729. return is_null($profile);
  730. }
  731. /********************************************************
  732. * Delivery Events *
  733. ********************************************************/
  734. /**
  735. * Having established a remote subscription, send a notification to the
  736. * remote ActivityPub profile's endpoint.
  737. *
  738. * @param Profile $profile subscriber
  739. * @param Profile $other subscribee
  740. * @return bool return value
  741. * @throws HTTP_Request2_Exception
  742. * @author Diogo Cordeiro <diogo@fc.up.pt>
  743. */
  744. public function onStartSubscribe(Profile $profile, Profile $other) {
  745. if (!$profile->isLocal()) {
  746. return true;
  747. }
  748. $other = Activitypub_profile::getKV('profile_id', $other->getID());
  749. if (!$other instanceof Activitypub_profile) {
  750. return true;
  751. }
  752. $postman = new Activitypub_postman($profile, [$other]);
  753. $postman->follow();
  754. return true;
  755. }
  756. /**
  757. * Notify remote server on unsubscribe.
  758. *
  759. * @param Profile $profile
  760. * @param Profile $other
  761. * @return bool return value
  762. * @throws HTTP_Request2_Exception
  763. * @author Diogo Cordeiro <diogo@fc.up.pt>
  764. */
  765. public function onStartUnsubscribe(Profile $profile, Profile $other)
  766. {
  767. if (!$profile->isLocal()) {
  768. return true;
  769. }
  770. $other = Activitypub_profile::getKV('profile_id', $other->getID());
  771. if (!$other instanceof Activitypub_profile) {
  772. return true;
  773. }
  774. $postman = new Activitypub_postman($profile, [$other]);
  775. $postman->undo_follow();
  776. return true;
  777. }
  778. /**
  779. * Notify remote users when their notices get favourited.
  780. *
  781. * @param Profile $profile of local user doing the faving
  782. * @param Notice $notice Notice being favored
  783. * @return bool return value
  784. * @throws HTTP_Request2_Exception
  785. * @throws InvalidUrlException
  786. * @author Diogo Cordeiro <diogo@fc.up.pt>
  787. */
  788. public function onEndFavorNotice(Profile $profile, Notice $notice)
  789. {
  790. // Only distribute local users' favor actions, remote users
  791. // will have already distributed theirs.
  792. if (!$profile->isLocal()) {
  793. return true;
  794. }
  795. $other = [];
  796. try {
  797. $other[] = Activitypub_profile::from_profile($notice->getProfile());
  798. } catch (Exception $e) {
  799. // Local user can be ignored
  800. }
  801. $other = array_merge($other,
  802. Activitypub_profile::from_profile_collection(
  803. $notice->getAttentionProfiles()
  804. ));
  805. if ($notice->reply_to) {
  806. try {
  807. $parent_notice = $notice->getParent();
  808. try {
  809. $other[] = Activitypub_profile::from_profile($parent_notice->getProfile());
  810. } catch (Exception $e) {
  811. // Local user can be ignored
  812. }
  813. $other = array_merge($other,
  814. Activitypub_profile::from_profile_collection(
  815. $parent_notice->getAttentionProfiles()
  816. ));
  817. } catch (NoParentNoticeException $e) {
  818. // This is not a reply to something (has no parent)
  819. } catch (NoResultException $e) {
  820. // Parent author's profile not found! Complain louder?
  821. common_log(LOG_ERR, "Parent notice's author not found: ".$e->getMessage());
  822. }
  823. }
  824. $postman = new Activitypub_postman($profile, $other);
  825. $postman->like($notice);
  826. return true;
  827. }
  828. /**
  829. * Notify remote users when their notices get de-favourited.
  830. *
  831. * @param Profile $profile of local user doing the de-faving
  832. * @param Notice $notice Notice being favored
  833. * @return bool return value
  834. * @throws HTTP_Request2_Exception
  835. * @throws InvalidUrlException
  836. * @author Diogo Cordeiro <diogo@fc.up.pt>
  837. */
  838. public function onEndDisfavorNotice(Profile $profile, Notice $notice)
  839. {
  840. // Only distribute local users' favor actions, remote users
  841. // will have already distributed theirs.
  842. if (!$profile->isLocal()) {
  843. return true;
  844. }
  845. $other = [];
  846. try {
  847. $other[] = Activitypub_profile::from_profile($notice->getProfile());
  848. } catch (Exception $e) {
  849. // Local user can be ignored
  850. }
  851. $other = array_merge($other,
  852. Activitypub_profile::from_profile_collection(
  853. $notice->getAttentionProfiles()
  854. ));
  855. if ($notice->reply_to) {
  856. try {
  857. $parent_notice = $notice->getParent();
  858. try {
  859. $other[] = Activitypub_profile::from_profile($parent_notice->getProfile());
  860. } catch (Exception $e) {
  861. // Local user can be ignored
  862. }
  863. $other = array_merge($other,
  864. Activitypub_profile::from_profile_collection(
  865. $parent_notice->getAttentionProfiles()
  866. ));
  867. } catch (NoParentNoticeException $e) {
  868. // This is not a reply to something (has no parent)
  869. } catch (NoResultException $e) {
  870. // Parent author's profile not found! Complain louder?
  871. common_log(LOG_ERR, "Parent notice's author not found: ".$e->getMessage());
  872. }
  873. }
  874. $postman = new Activitypub_postman($profile, $other);
  875. $postman->undo_like($notice);
  876. return true;
  877. }
  878. /**
  879. * Notify remote users when their notices get deleted
  880. *
  881. * @param $user
  882. * @param $notice
  883. * @return boolean hook flag
  884. * @throws HTTP_Request2_Exception
  885. * @throws InvalidUrlException
  886. * @author Diogo Cordeiro <diogo@fc.up.pt>
  887. */
  888. public function onStartDeleteOwnNotice($user, $notice)
  889. {
  890. $profile = $user->getProfile();
  891. // Only distribute local users' delete actions, remote users
  892. // will have already distributed theirs.
  893. if (!$profile->isLocal()) {
  894. return true;
  895. }
  896. // Handle delete locally either because:
  897. // 1. There's no undo-share logic yet
  898. // 2. The deleting user has previleges to do so (locally)
  899. if ($notice->isRepeat() || ($notice->getProfile()->getID() != $profile->getID())) {
  900. return true;
  901. }
  902. $other = Activitypub_profile::from_profile_collection(
  903. $notice->getAttentionProfiles()
  904. );
  905. if ($notice->reply_to) {
  906. try {
  907. $parent_notice = $notice->getParent();
  908. try {
  909. $other[] = Activitypub_profile::from_profile($parent_notice->getProfile());
  910. } catch (Exception $e) {
  911. // Local user can be ignored
  912. }
  913. $other = array_merge($other,
  914. Activitypub_profile::from_profile_collection(
  915. $parent_notice->getAttentionProfiles()
  916. ));
  917. } catch (NoParentNoticeException $e) {
  918. // This is not a reply to something (has no parent)
  919. } catch (NoResultException $e) {
  920. // Parent author's profile not found! Complain louder?
  921. common_log(LOG_ERR, "Parent notice's author not found: ".$e->getMessage());
  922. }
  923. }
  924. $postman = new Activitypub_postman($profile, $other);
  925. $postman->delete_note($notice);
  926. return true;
  927. }
  928. /**
  929. * Notify remote followers when a user gets deleted
  930. *
  931. * @param Action $action
  932. * @param User $user user being deleted
  933. */
  934. public function onEndDeleteUser(Action $action, User $user): void
  935. {
  936. $postman = new Activitypub_postman($user->getProfile());
  937. $postman->delete_profile();
  938. }
  939. /**
  940. * Federate private message
  941. *
  942. * @param Notice $message
  943. * @return void
  944. */
  945. public function onSendDirectMessage(Notice $message): void {
  946. $from = $message->getProfile();
  947. if (!$from->isLocal()) {
  948. // nothing to do
  949. return;
  950. }
  951. $to = Activitypub_profile::from_profile_collection(
  952. $message->getAttentionProfiles()
  953. );
  954. if (!empty($to)) {
  955. $postman = new Activitypub_postman($from, $to);
  956. $postman->create_direct_note($message);
  957. }
  958. }
  959. /**
  960. * Override the "from ActivityPub" bit in notice lists to link to the
  961. * original post and show the domain it came from.
  962. *
  963. * @author Diogo Cordeiro <diogo@fc.up.pt>
  964. * @param $notice
  965. * @param $name
  966. * @param $url
  967. * @param $title
  968. * @return mixed hook return code
  969. * @throws Exception
  970. */
  971. public function onStartNoticeSourceLink($notice, &$name, &$url, &$title)
  972. {
  973. // If we don't handle this, keep the event handler going
  974. if (!in_array($notice->source, array('ActivityPub', 'share'))) {
  975. return true;
  976. }
  977. try {
  978. $url = $notice->getUrl();
  979. // If getUrl() throws exception, $url is never set
  980. $bits = parse_url($url);
  981. $domain = $bits['host'];
  982. if (substr($domain, 0, 4) == 'www.') {
  983. $name = substr($domain, 4);
  984. } else {
  985. $name = $domain;
  986. }
  987. // TRANS: Title. %s is a domain name.
  988. $title = sprintf(_m('Sent from %s via ActivityPub'), $domain);
  989. // Abort event handler, we have a name and URL!
  990. return false;
  991. } catch (InvalidUrlException $e) {
  992. // This just means we don't have the notice source data
  993. return true;
  994. }
  995. }
  996. }
  997. /**
  998. * Plugin return handler
  999. */
  1000. class ActivityPubReturn
  1001. {
  1002. /**
  1003. * Return a valid answer
  1004. *
  1005. * @param string $res
  1006. * @param int $code Status Code
  1007. * @return void
  1008. * @author Diogo Cordeiro <diogo@fc.up.pt>
  1009. */
  1010. public static function answer($res = '', $code = 202)
  1011. {
  1012. http_response_code($code);
  1013. header('Content-Type: application/activity+json');
  1014. echo json_encode($res, JSON_UNESCAPED_SLASHES | (isset($_GET["pretty"]) ? JSON_PRETTY_PRINT : null));
  1015. exit;
  1016. }
  1017. /**
  1018. * Return an error
  1019. *
  1020. * @param string $m
  1021. * @param int $code Status Code
  1022. * @return void
  1023. * @author Diogo Cordeiro <diogo@fc.up.pt>
  1024. */
  1025. public static function error($m, $code = 400)
  1026. {
  1027. http_response_code($code);
  1028. header('Content-Type: application/activity+json');
  1029. $res[] = Activitypub_error::error_message_to_array($m);
  1030. echo json_encode($res, JSON_UNESCAPED_SLASHES);
  1031. exit;
  1032. }
  1033. }