ActivityPubPlugin.php 36 KB

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