ActivityPubPlugin.php 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000
  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. /**
  41. * @category Plugin
  42. * @package GNUsocial
  43. * @author Diogo Cordeiro <diogo@fc.up.pt>
  44. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  45. */
  46. class ActivityPubPlugin extends Plugin
  47. {
  48. const PLUGIN_VERSION = '0.1.0alpha0';
  49. /**
  50. * Returns a Actor's URI from its local $profile
  51. * Works both for local and remote users.
  52. *
  53. * @param Profile $profile Actor's local profile
  54. * @return string Actor's URI
  55. * @throws Exception
  56. * @author Diogo Cordeiro <diogo@fc.up.pt>
  57. */
  58. public static function actor_uri($profile)
  59. {
  60. if ($profile->isLocal()) {
  61. return ACTIVITYPUB_BASE_ACTOR_URI.$profile->getID();
  62. } else {
  63. return $profile->getUri();
  64. }
  65. }
  66. /**
  67. * Returns a Actor's URL from its local $profile
  68. * Works both for local and remote users.
  69. *
  70. * @param Profile $profile Actor's local profile
  71. * @return string Actor's URL
  72. * @throws Exception
  73. * @author Diogo Cordeiro <diogo@fc.up.pt>
  74. */
  75. public static function actor_url($profile)
  76. {
  77. return ActivityPubPlugin::actor_uri($profile)."/";
  78. }
  79. /**
  80. * Returns a notice from its URL.
  81. *
  82. * @author Diogo Cordeiro <diogo@fc.up.pt>
  83. * @param string $url Notice's URL
  84. * @return Notice The Notice object
  85. * @throws Exception This function or provides a Notice or fails with exception
  86. */
  87. public static function grab_notice_from_url($url)
  88. {
  89. /* Offline Grabbing */
  90. try {
  91. // Look for a known remote notice
  92. return Notice::getByUri($url);
  93. } catch (Exception $e) {
  94. // Look for a local notice (unfortunately GNU social doesn't
  95. // provide this functionality natively)
  96. try {
  97. $candidate = Notice::getByID(intval(substr($url, (strlen(common_local_url('apNotice', ['id' => 0]))-1))));
  98. if (common_local_url('apNotice', ['id' => $candidate->getID()]) === $url) { // Sanity check
  99. return $candidate;
  100. } else {
  101. common_debug('ActivityPubPlugin Notice Grabber: '.$candidate->getUrl(). ' is different of '.$url);
  102. }
  103. } catch (Exception $e) {
  104. common_debug('ActivityPubPlugin Notice Grabber: failed to find: '.$url.' offline.');
  105. }
  106. }
  107. /* Online Grabbing */
  108. $client = new HTTPClient();
  109. $headers = [];
  110. $headers[] = 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
  111. $headers[] = 'User-Agent: GNUSocialBot v0.1 - https://gnu.io/social';
  112. $response = $client->get($url, $headers);
  113. $object = json_decode($response->getBody(), true);
  114. Activitypub_notice::validate_note($object);
  115. return Activitypub_notice::create_notice($object);
  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. * Plugin Nodeinfo information
  299. *
  300. * @param array $protocols
  301. * @return bool hook true
  302. */
  303. public function onNodeInfoProtocols(array &$protocols)
  304. {
  305. $protocols[] = "activitypub";
  306. return true;
  307. }
  308. /**
  309. * Adds an indicator on Remote ActivityPub profiles.
  310. *
  311. * @param HTMLOutputter $out
  312. * @param Profile $profile
  313. * @return boolean hook return value
  314. * @throws Exception
  315. * @author Diogo Cordeiro <diogo@fc.up.pt>
  316. */
  317. public function onEndShowAccountProfileBlock(HTMLOutputter $out, Profile $profile)
  318. {
  319. if ($profile->isLocal()) {
  320. return true;
  321. }
  322. try {
  323. Activitypub_profile::from_profile($profile);
  324. } catch (Exception $e) {
  325. // Not a remote ActivityPub_profile! Maybe some other network
  326. // that has imported a non-local user (e.g.: OStatus)?
  327. return true;
  328. }
  329. $out->elementStart('dl', 'entity_tags activitypub_profile');
  330. $out->element('dt', null, 'ActivityPub');
  331. $out->element('dd', null, _m('Remote Profile'));
  332. $out->elementEnd('dl');
  333. return true;
  334. }
  335. /**
  336. * Make sure necessary tables are filled out.
  337. *
  338. * @return boolean hook true
  339. */
  340. public function onCheckSchema()
  341. {
  342. $schema = Schema::get();
  343. $schema->ensureTable('activitypub_profile', Activitypub_profile::schemaDef());
  344. $schema->ensureTable('activitypub_rsa', Activitypub_rsa::schemaDef());
  345. $schema->ensureTable('activitypub_pending_follow_requests', Activitypub_pending_follow_requests::schemaDef());
  346. return true;
  347. }
  348. /********************************************************
  349. * WebFinger Events *
  350. ********************************************************/
  351. /**
  352. * Get remote user's ActivityPub_profile via a identifier
  353. *
  354. * @author GNU social
  355. * @author Diogo Cordeiro <diogo@fc.up.pt>
  356. * @param string $arg A remote user identifier
  357. * @return Activitypub_profile|null Valid profile in success | null otherwise
  358. */
  359. public static function pull_remote_profile($arg)
  360. {
  361. if (preg_match('!^((?:\w+\.)*\w+@(?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+)$!', $arg)) {
  362. // webfinger lookup
  363. try {
  364. return Activitypub_profile::ensure_web_finger($arg);
  365. } catch (Exception $e) {
  366. common_log(LOG_ERR, 'Webfinger lookup failed for ' .
  367. $arg . ': ' . $e->getMessage());
  368. }
  369. }
  370. // Look for profile URLs, with or without scheme:
  371. $urls = [];
  372. if (preg_match('!^https?://((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)$!', $arg)) {
  373. $urls[] = $arg;
  374. }
  375. if (preg_match('!^((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)$!', $arg)) {
  376. $schemes = array('http', 'https');
  377. foreach ($schemes as $scheme) {
  378. $urls[] = "$scheme://$arg";
  379. }
  380. }
  381. foreach ($urls as $url) {
  382. try {
  383. return Activitypub_profile::fromUri($url);
  384. } catch (Exception $e) {
  385. common_log(LOG_ERR, 'Profile lookup failed for ' .
  386. $arg . ': ' . $e->getMessage());
  387. }
  388. }
  389. return null;
  390. }
  391. /**
  392. * Webfinger matches: @user@example.com or even @user--one.george_orwell@1984.biz
  393. *
  394. * @author GNU social
  395. * @param string $text The text from which to extract webfinger IDs
  396. * @param string $preMention Character(s) that signals a mention ('@', '!'...)
  397. * @return array The matching IDs (without $preMention) and each respective position in the given string.
  398. */
  399. public static function extractWebfingerIds($text, $preMention='@')
  400. {
  401. $wmatches = [];
  402. $result = preg_match_all(
  403. '/(?<!\S)'.preg_quote($preMention, '/').'('.Nickname::WEBFINGER_FMT.')/',
  404. $text,
  405. $wmatches,
  406. PREG_OFFSET_CAPTURE
  407. );
  408. if ($result === false) {
  409. common_log(LOG_ERR, __METHOD__ . ': Error parsing webfinger IDs from text (preg_last_error=='.preg_last_error().').');
  410. return [];
  411. } elseif ($n_matches = count($wmatches)) {
  412. common_debug(sprintf('Found %d matches for WebFinger IDs: %s', $n_matches, _ve($wmatches)));
  413. }
  414. return $wmatches[1];
  415. }
  416. /**
  417. * Profile URL matches: @example.com/mublog/user
  418. *
  419. * @author GNU social
  420. * @param string $text The text from which to extract URL mentions
  421. * @param string $preMention Character(s) that signals a mention ('@', '!'...)
  422. * @return array The matching URLs (without @ or acct:) and each respective position in the given string.
  423. */
  424. public static function extractUrlMentions($text, $preMention='@')
  425. {
  426. $wmatches = [];
  427. // In the regexp below we need to match / _before_ URL_REGEX_VALID_PATH_CHARS because it otherwise gets merged
  428. // with the TLD before (but / is in URL_REGEX_VALID_PATH_CHARS anyway, it's just its positioning that is important)
  429. $result = preg_match_all(
  430. '/(?:^|\s+)'.preg_quote($preMention, '/').'('.URL_REGEX_DOMAIN_NAME.'(?:\/['.URL_REGEX_VALID_PATH_CHARS.']*)*)/',
  431. $text,
  432. $wmatches,
  433. PREG_OFFSET_CAPTURE
  434. );
  435. if ($result === false) {
  436. common_log(LOG_ERR, __METHOD__ . ': Error parsing profile URL mentions from text (preg_last_error=='.preg_last_error().').');
  437. return [];
  438. } elseif (count($wmatches)) {
  439. common_debug(sprintf('Found %d matches for profile URL mentions: %s', count($wmatches), _ve($wmatches)));
  440. }
  441. return $wmatches[1];
  442. }
  443. /**
  444. * Add activity+json mimetype on WebFinger
  445. *
  446. * @param XML_XRD $xrd
  447. * @param Managed_DataObject $object
  448. * @throws Exception
  449. * @author Diogo Cordeiro <diogo@fc.up.pt>
  450. */
  451. public function onEndWebFingerProfileLinks(XML_XRD $xrd, Managed_DataObject $object)
  452. {
  453. if ($object->isPerson()) {
  454. $link = new XML_XRD_Element_Link(
  455. 'self',
  456. ActivityPubPlugin::actor_uri($object->getProfile()),
  457. 'application/activity+json'
  458. );
  459. $xrd->links[] = clone($link);
  460. }
  461. }
  462. /**
  463. * Find any explicit remote mentions. Accepted forms:
  464. * Webfinger: @user@example.com
  465. * Profile link:
  466. * @param Profile $sender
  467. * @param string $text input markup text
  468. * @param $mentions
  469. * @return boolean hook return value
  470. * @throws InvalidUrlException
  471. * @author Diogo Cordeiro <diogo@fc.up.pt>
  472. * @example.com/mublog/user
  473. *
  474. * @author GNU social
  475. */
  476. public function onEndFindMentions(Profile $sender, $text, &$mentions)
  477. {
  478. $matches = [];
  479. foreach (self::extractWebfingerIds($text, '@') as $wmatch) {
  480. list($target, $pos) = $wmatch;
  481. $this->log(LOG_INFO, "Checking webfinger person '$target'");
  482. $profile = null;
  483. try {
  484. $aprofile = Activitypub_profile::ensure_web_finger($target);
  485. $profile = $aprofile->local_profile();
  486. } catch (Exception $e) {
  487. $this->log(LOG_ERR, "Webfinger check failed: " . $e->getMessage());
  488. continue;
  489. }
  490. assert($profile instanceof Profile);
  491. $displayName = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target)
  492. ? $profile->getNickname() // TODO: we could do getBestName() or getFullname() here
  493. : $target;
  494. $url = $profile->getUri();
  495. if (!common_valid_http_url($url)) {
  496. $url = $profile->getUrl();
  497. }
  498. $matches[$pos] = array('mentioned' => array($profile),
  499. 'type' => 'mention',
  500. 'text' => $displayName,
  501. 'position' => $pos,
  502. 'length' => mb_strlen($target),
  503. 'url' => $url);
  504. }
  505. foreach (self::extractUrlMentions($text) as $wmatch) {
  506. list($target, $pos) = $wmatch;
  507. $schemes = array('https', 'http');
  508. foreach ($schemes as $scheme) {
  509. $url = "$scheme://$target";
  510. $this->log(LOG_INFO, "Checking profile address '$url'");
  511. try {
  512. $aprofile = Activitypub_profile::fromUri($url);
  513. $profile = $aprofile->local_profile();
  514. $displayName = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target) ?
  515. $profile->nickname : $target;
  516. $matches[$pos] = array('mentioned' => array($profile),
  517. 'type' => 'mention',
  518. 'text' => $displayName,
  519. 'position' => $pos,
  520. 'length' => mb_strlen($target),
  521. 'url' => $profile->getUrl());
  522. break;
  523. } catch (Exception $e) {
  524. $this->log(LOG_ERR, "Profile check failed: " . $e->getMessage());
  525. }
  526. }
  527. }
  528. foreach ($mentions as $i => $other) {
  529. // If we share a common prefix with a local user, override it!
  530. $pos = $other['position'];
  531. if (isset($matches[$pos])) {
  532. $mentions[$i] = $matches[$pos];
  533. unset($matches[$pos]);
  534. }
  535. }
  536. foreach ($matches as $mention) {
  537. $mentions[] = $mention;
  538. }
  539. return true;
  540. }
  541. /**
  542. * Allow remote profile references to be used in commands:
  543. * sub update@status.net
  544. * whois evan@identi.ca
  545. * reply http://identi.ca/evan hey what's up
  546. *
  547. * @param Command $command
  548. * @param string $arg
  549. * @param Profile &$profile
  550. * @return boolean hook return code
  551. * @author GNU social
  552. * @author Diogo Cordeiro <diogo@fc.up.pt>
  553. */
  554. public function onStartCommandGetProfile($command, $arg, &$profile)
  555. {
  556. try {
  557. $aprofile = $this->pull_remote_profile($arg);
  558. $profile = $aprofile->local_profile();
  559. } catch (Exception $e) {
  560. // No remote ActivityPub profile found
  561. return true;
  562. }
  563. return false;
  564. }
  565. /********************************************************
  566. * Discovery Events *
  567. ********************************************************/
  568. /**
  569. * Profile URI for remote profiles.
  570. *
  571. * @author GNU social
  572. * @author Diogo Cordeiro <diogo@fc.up.pt>
  573. * @param Profile $profile
  574. * @param string $uri in/out
  575. * @return mixed hook return code
  576. */
  577. public function onStartGetProfileUri(Profile $profile, &$uri)
  578. {
  579. $aprofile = Activitypub_profile::getKV('profile_id', $profile->id);
  580. if ($aprofile instanceof Activitypub_profile) {
  581. $uri = $aprofile->getUri();
  582. return false;
  583. }
  584. return true;
  585. }
  586. /**
  587. * Profile from URI.
  588. *
  589. * @author GNU social
  590. * @author Diogo Cordeiro <diogo@fc.up.pt>
  591. * @param string $uri
  592. * @param Profile &$profile in/out param: Profile got from URI
  593. * @return mixed hook return code
  594. */
  595. public function onStartGetProfileFromURI($uri, &$profile)
  596. {
  597. try {
  598. $explorer = new Activitypub_explorer();
  599. $profile = $explorer->lookup($uri)[0];
  600. return false;
  601. } catch (Exception $e) {
  602. return true; // It's not an ActivityPub profile as far as we know, continue event handling
  603. }
  604. }
  605. /********************************************************
  606. * Delivery Events *
  607. ********************************************************/
  608. /**
  609. * Having established a remote subscription, send a notification to the
  610. * remote ActivityPub profile's endpoint.
  611. *
  612. * @param Profile $profile subscriber
  613. * @param Profile $other subscribee
  614. * @return bool return value
  615. * @throws HTTP_Request2_Exception
  616. * @author Diogo Cordeiro <diogo@fc.up.pt>
  617. */
  618. public function onStartSubscribe(Profile $profile, Profile $other) {
  619. if (!$profile->isLocal() && $other->isLocal()) {
  620. return true;
  621. }
  622. try {
  623. $other = Activitypub_profile::from_profile($other);
  624. } catch (Exception $e) {
  625. return true; // Let other plugin handle this instead
  626. }
  627. $postman = new Activitypub_postman($profile, array($other));
  628. $postman->follow();
  629. return true;
  630. }
  631. /**
  632. * Notify remote server on unsubscribe.
  633. *
  634. * @param Profile $profile
  635. * @param Profile $other
  636. * @return bool return value
  637. * @throws HTTP_Request2_Exception
  638. * @author Diogo Cordeiro <diogo@fc.up.pt>
  639. */
  640. public function onStartUnsubscribe(Profile $profile, Profile $other)
  641. {
  642. if (!$profile->isLocal() && $other->isLocal()) {
  643. return true;
  644. }
  645. try {
  646. $other = Activitypub_profile::from_profile($other);
  647. } catch (Exception $e) {
  648. return true; // Let other plugin handle this instead
  649. }
  650. $postman = new Activitypub_postman($profile, array($other));
  651. $postman->undo_follow();
  652. return true;
  653. }
  654. /**
  655. * Notify remote users when their notices get favourited.
  656. *
  657. * @param Profile $profile of local user doing the faving
  658. * @param Notice $notice Notice being favored
  659. * @return bool return value
  660. * @throws HTTP_Request2_Exception
  661. * @throws InvalidUrlException
  662. * @author Diogo Cordeiro <diogo@fc.up.pt>
  663. */
  664. public function onEndFavorNotice(Profile $profile, Notice $notice)
  665. {
  666. // Only distribute local users' favor actions, remote users
  667. // will have already distributed theirs.
  668. if (!$profile->isLocal()) {
  669. return true;
  670. }
  671. $other = [];
  672. try {
  673. $other[] = Activitypub_profile::from_profile($notice->getProfile());
  674. } catch (Exception $e) {
  675. // Local user can be ignored
  676. }
  677. $other = array_merge($other,
  678. Activitypub_profile::from_profile_collection(
  679. $notice->getAttentionProfiles()
  680. ));
  681. if ($notice->reply_to) {
  682. try {
  683. $parent_notice = $notice->getParent();
  684. try {
  685. $other[] = Activitypub_profile::from_profile($parent_notice->getProfile());
  686. } catch (Exception $e) {
  687. // Local user can be ignored
  688. }
  689. $other = array_merge($other,
  690. Activitypub_profile::from_profile_collection(
  691. $parent_notice->getAttentionProfiles()
  692. ));
  693. } catch (NoParentNoticeException $e) {
  694. // This is not a reply to something (has no parent)
  695. } catch (NoResultException $e) {
  696. // Parent author's profile not found! Complain louder?
  697. common_log(LOG_ERR, "Parent notice's author not found: ".$e->getMessage());
  698. }
  699. }
  700. $postman = new Activitypub_postman($profile, $other);
  701. $postman->like($notice);
  702. return true;
  703. }
  704. /**
  705. * Notify remote users when their notices get de-favourited.
  706. *
  707. * @param Profile $profile of local user doing the de-faving
  708. * @param Notice $notice Notice being favored
  709. * @return bool return value
  710. * @throws HTTP_Request2_Exception
  711. * @throws InvalidUrlException
  712. * @author Diogo Cordeiro <diogo@fc.up.pt>
  713. */
  714. public function onEndDisfavorNotice(Profile $profile, Notice $notice)
  715. {
  716. // Only distribute local users' favor actions, remote users
  717. // will have already distributed theirs.
  718. if (!$profile->isLocal()) {
  719. return true;
  720. }
  721. $other = [];
  722. try {
  723. $other[] = Activitypub_profile::from_profile($notice->getProfile());
  724. } catch (Exception $e) {
  725. // Local user can be ignored
  726. }
  727. $other = array_merge($other,
  728. Activitypub_profile::from_profile_collection(
  729. $notice->getAttentionProfiles()
  730. ));
  731. if ($notice->reply_to) {
  732. try {
  733. $parent_notice = $notice->getParent();
  734. try {
  735. $other[] = Activitypub_profile::from_profile($parent_notice->getProfile());
  736. } catch (Exception $e) {
  737. // Local user can be ignored
  738. }
  739. $other = array_merge($other,
  740. Activitypub_profile::from_profile_collection(
  741. $parent_notice->getAttentionProfiles()
  742. ));
  743. } catch (NoParentNoticeException $e) {
  744. // This is not a reply to something (has no parent)
  745. } catch (NoResultException $e) {
  746. // Parent author's profile not found! Complain louder?
  747. common_log(LOG_ERR, "Parent notice's author not found: ".$e->getMessage());
  748. }
  749. }
  750. $postman = new Activitypub_postman($profile, $other);
  751. $postman->undo_like($notice);
  752. return true;
  753. }
  754. /**
  755. * Notify remote users when their notices get deleted
  756. *
  757. * @param $user
  758. * @param $notice
  759. * @return boolean hook flag
  760. * @throws HTTP_Request2_Exception
  761. * @throws InvalidUrlException
  762. * @author Diogo Cordeiro <diogo@fc.up.pt>
  763. */
  764. public function onStartDeleteOwnNotice($user, $notice)
  765. {
  766. $profile = $user->getProfile();
  767. // Only distribute local users' delete actions, remote users
  768. // will have already distributed theirs.
  769. if (!$profile->isLocal()) {
  770. return true;
  771. }
  772. // Handle delete locally either because:
  773. // 1. There's no undo-share logic yet
  774. // 2. The deleting user has previleges to do so (locally)
  775. if ($notice->isRepeat() || ($notice->getProfile()->getID() != $profile->getID())) {
  776. return true;
  777. }
  778. $other = Activitypub_profile::from_profile_collection(
  779. $notice->getAttentionProfiles()
  780. );
  781. if ($notice->reply_to) {
  782. try {
  783. $parent_notice = $notice->getParent();
  784. try {
  785. $other[] = Activitypub_profile::from_profile($parent_notice->getProfile());
  786. } catch (Exception $e) {
  787. // Local user can be ignored
  788. }
  789. $other = array_merge($other,
  790. Activitypub_profile::from_profile_collection(
  791. $parent_notice->getAttentionProfiles()
  792. ));
  793. } catch (NoParentNoticeException $e) {
  794. // This is not a reply to something (has no parent)
  795. } catch (NoResultException $e) {
  796. // Parent author's profile not found! Complain louder?
  797. common_log(LOG_ERR, "Parent notice's author not found: ".$e->getMessage());
  798. }
  799. }
  800. $postman = new Activitypub_postman($profile, $other);
  801. $postman->delete($notice);
  802. return true;
  803. }
  804. /**
  805. * Federate private message
  806. *
  807. * @param Notice $message
  808. * @return void
  809. */
  810. public function onSendDirectMessage(Notice $message): void {
  811. $from = $message->getProfile();
  812. if (!$from->isLocal()) {
  813. // nothing to do
  814. return;
  815. }
  816. $to = Activitypub_profile::from_profile_collection(
  817. $message->getAttentionProfiles()
  818. );
  819. if (!empty($to)) {
  820. $postman = new Activitypub_postman($from, $to);
  821. $postman->create_direct_note($message);
  822. }
  823. }
  824. /**
  825. * Override the "from ActivityPub" bit in notice lists to link to the
  826. * original post and show the domain it came from.
  827. *
  828. * @author Diogo Cordeiro <diogo@fc.up.pt>
  829. * @param $notice
  830. * @param $name
  831. * @param $url
  832. * @param $title
  833. * @return mixed hook return code
  834. * @throws Exception
  835. */
  836. public function onStartNoticeSourceLink($notice, &$name, &$url, &$title)
  837. {
  838. // If we don't handle this, keep the event handler going
  839. if (!in_array($notice->source, array('ActivityPub', 'share'))) {
  840. return true;
  841. }
  842. try {
  843. $url = $notice->getUrl();
  844. // If getUrl() throws exception, $url is never set
  845. $bits = parse_url($url);
  846. $domain = $bits['host'];
  847. if (substr($domain, 0, 4) == 'www.') {
  848. $name = substr($domain, 4);
  849. } else {
  850. $name = $domain;
  851. }
  852. // TRANS: Title. %s is a domain name.
  853. $title = sprintf(_m('Sent from %s via ActivityPub'), $domain);
  854. // Abort event handler, we have a name and URL!
  855. return false;
  856. } catch (InvalidUrlException $e) {
  857. // This just means we don't have the notice source data
  858. return true;
  859. }
  860. }
  861. }
  862. /**
  863. * Plugin return handler
  864. */
  865. class ActivityPubReturn
  866. {
  867. /**
  868. * Return a valid answer
  869. *
  870. * @param string $res
  871. * @param int $code Status Code
  872. * @return void
  873. * @author Diogo Cordeiro <diogo@fc.up.pt>
  874. */
  875. public static function answer($res = '', $code = 202)
  876. {
  877. http_response_code($code);
  878. header('Content-Type: application/activity+json');
  879. echo json_encode($res, JSON_UNESCAPED_SLASHES | (isset($_GET["pretty"]) ? JSON_PRETTY_PRINT : null));
  880. exit;
  881. }
  882. /**
  883. * Return an error
  884. *
  885. * @param string $m
  886. * @param int $code Status Code
  887. * @return void
  888. * @author Diogo Cordeiro <diogo@fc.up.pt>
  889. */
  890. public static function error($m, $code = 400)
  891. {
  892. http_response_code($code);
  893. header('Content-Type: application/activity+json');
  894. $res[] = Activitypub_error::error_message_to_array($m);
  895. echo json_encode($res, JSON_UNESCAPED_SLASHES);
  896. exit;
  897. }
  898. }