ActivityPubPlugin.php 38 KB

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