ActivityPubPlugin.php 33 KB

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