ActivityPubPlugin.php 38 KB

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