ActivityPubPlugin.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  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. * Updates local notice
  203. *
  204. * @param Notice notice
  205. * @throws Exception
  206. * @return bool default true
  207. */
  208. public static function update_local_notice(Notice $notice): bool {
  209. try {
  210. if ($notice=>is_local) {
  211. $members = ['profile_id', 'content', 'source', 'object_type', 'verb'];
  212. $orig = clone($notice);
  213. foreach ($members as $m) {
  214. $notice->$m = $orig->$m;
  215. }
  216. if ($notice->id) {
  217. common_debug('Updating local Notice: ' . $notice->id . ' from remote ActivityPub profile');
  218. $notice->modified = common_sql_now();
  219. $notice->update($orig);
  220. }
  221. }
  222. }
  223. catch (Exception $e) {
  224. return false;
  225. }
  226. return true;
  227. }
  228. /**
  229. * Set up queue handlers for required interactions
  230. *
  231. * @param QueueManager $qm
  232. * @return bool event hook return
  233. */
  234. public function onEndInitializeQueueManager(QueueManager $qm): bool
  235. {
  236. // Notice distribution
  237. $qm->connect('activitypub', 'ActivityPubQueueHandler');
  238. return true;
  239. }
  240. /**
  241. * Enqueue saved notices for distribution
  242. *
  243. * @param Notice $notice notice to be distributed
  244. * @param Array &$transports list of transports to queue for
  245. * @return bool event hook return
  246. */
  247. public function onStartEnqueueNotice(Notice $notice, Array &$transports): bool
  248. {
  249. try {
  250. $id = $notice->getID();
  251. if ($id > 0) {
  252. $transports[] = 'activitypub';
  253. $this->log(LOG_INFO, "Notice:{$id} queued for distribution");
  254. }
  255. } catch (Exception $e) {
  256. $this->log(LOG_ERR, "Invalid notice, not queueing for distribution");
  257. }
  258. return true;
  259. }
  260. /**
  261. * Update notice before saving.
  262. * We'll use this as a hack to maintain replies to unlisted/followers-only
  263. * notices away from the public timelines.
  264. *
  265. * @param Notice &$notice notice to be saved
  266. * @return bool event hook return
  267. */
  268. public function onStartNoticeSave(Notice &$notice): bool {
  269. if ($notice->reply_to) {
  270. try {
  271. $parent = $notice->getParent();
  272. $is_local = (int)$parent->is_local;
  273. // if we're replying unlisted/followers-only notices received by AP
  274. // or replying to replies of such notices, then we make sure to set
  275. // the correct type flag.
  276. if ( ($parent->source === 'ActivityPub' && $is_local === Notice::GATEWAY) ||
  277. ($parent->source === 'web' && $is_local === Notice::LOCAL_NONPUBLIC) ) {
  278. $this->log(LOG_INFO, "Enforcing type flag LOCAL_NONPUBLIC for new notice");
  279. $notice->is_local = Notice::LOCAL_NONPUBLIC;
  280. }
  281. } catch (NoParentNoticeException $e) {
  282. // This is not a reply to something (has no parent)
  283. }
  284. }
  285. return true;
  286. }
  287. /**
  288. * Add AP-subscriptions for private messaging
  289. *
  290. * @param User $current current logged user
  291. * @param array &$recipients
  292. * @return void
  293. */
  294. public function onFillDirectMessageRecipients(User $current, array &$recipients): void {
  295. try {
  296. $subs = Activitypub_profile::getSubscribed($current->getProfile());
  297. foreach ($subs as $sub) {
  298. if (!$sub->isLocal()) { // AP plugin adds AP users
  299. try {
  300. $value = 'profile:'.$sub->getID();
  301. $recipients[$value] = substr($sub->getAcctUri(), 5) . " [{$sub->getBestName()}]";
  302. } catch (ProfileNoAcctUriException $e) {
  303. $recipients[$value] = "[?@?] " . $e->profile->getBestName();
  304. }
  305. }
  306. }
  307. } catch (NoResultException $e) {
  308. // let it go
  309. }
  310. }
  311. /**
  312. * Validate AP-recipients for profile page message action addition
  313. *
  314. * @param Profile $recipient
  315. * @return bool hook return value
  316. */
  317. public function onDirectMessageProfilePageActions(Profile $recipient): bool {
  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. /**
  730. * Plugin return handler
  731. */
  732. class ActivityPubReturn
  733. {
  734. /**
  735. * Return a valid answer
  736. *
  737. * @param string $res
  738. * @param int $code Status Code
  739. * @return void
  740. * @author Diogo Cordeiro <diogo@fc.up.pt>
  741. */
  742. public static function answer($res = '', $code = 202)
  743. {
  744. http_response_code($code);
  745. header('Content-Type: application/activity+json');
  746. echo json_encode($res, JSON_UNESCAPED_SLASHES | (isset($_GET["pretty"]) ? JSON_PRETTY_PRINT : null));
  747. exit;
  748. }
  749. /**
  750. * Return an error
  751. *
  752. * @param string $m
  753. * @param int $code Status Code
  754. * @return void
  755. * @author Diogo Cordeiro <diogo@fc.up.pt>
  756. */
  757. public static function error($m, $code = 400)
  758. {
  759. http_response_code($code);
  760. header('Content-Type: application/activity+json');
  761. $res[] = Activitypub_error::error_message_to_array($m);
  762. echo json_encode($res, JSON_UNESCAPED_SLASHES);
  763. exit;
  764. }
  765. }