ActivityPubPlugin.php 39 KB

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