ActivityPubPlugin.php 35 KB

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