ActivityPubPlugin.php 36 KB

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