ActivityPub.php 33 KB

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