ActivityPub.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. <?php
  2. declare(strict_types = 1);
  3. // {{{ License
  4. // This file is part of GNU social - https://www.gnu.org/software/social
  5. //
  6. // GNU social is free software: you can redistribute it and/or modify
  7. // it under the terms of the GNU Affero General Public License as published by
  8. // the Free Software Foundation, either version 3 of the License, or
  9. // (at your option) any later version.
  10. //
  11. // GNU social is distributed in the hope that it will be useful,
  12. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. // GNU Affero General Public License for more details.
  15. //
  16. // You should have received a copy of the GNU Affero General Public License
  17. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  18. // }}}
  19. /**
  20. * ActivityPub implementation for GNU social
  21. *
  22. * @package GNUsocial
  23. * @category ActivityPub
  24. *
  25. * @author Diogo Peralta Cordeiro <@diogo.site>
  26. * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
  27. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  28. */
  29. namespace Plugin\ActivityPub;
  30. use ActivityPhp\Type;
  31. use ActivityPhp\Type\AbstractObject;
  32. use App\Core\DB\DB;
  33. use App\Core\Event;
  34. use App\Core\HTTPClient;
  35. use function App\Core\I18n\_m;
  36. use App\Core\Log;
  37. use App\Core\Modules\Plugin;
  38. use App\Core\Queue\Queue;
  39. use App\Core\Router\RouteLoader;
  40. use App\Core\Router\Router;
  41. use App\Entity\Activity;
  42. use App\Entity\Actor;
  43. use App\Entity\Note;
  44. use App\Util\Common;
  45. use App\Util\Exception\BugFoundException;
  46. use Component\Collection\Util\Controller\OrderedCollection;
  47. use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
  48. use Component\FreeNetwork\Util\Discovery;
  49. use Exception;
  50. use InvalidArgumentException;
  51. use Plugin\ActivityPub\Controller\Inbox;
  52. use Plugin\ActivityPub\Controller\Outbox;
  53. use Plugin\ActivityPub\Entity\ActivitypubActivity;
  54. use Plugin\ActivityPub\Entity\ActivitypubActor;
  55. use Plugin\ActivityPub\Entity\ActivitypubObject;
  56. use Plugin\ActivityPub\Util\Explorer;
  57. use Plugin\ActivityPub\Util\HTTPSignature;
  58. use Plugin\ActivityPub\Util\Model;
  59. use Plugin\ActivityPub\Util\OrderedCollectionController;
  60. use Plugin\ActivityPub\Util\Response\ActivityResponse;
  61. use Plugin\ActivityPub\Util\Response\ActorResponse;
  62. use Plugin\ActivityPub\Util\Response\NoteResponse;
  63. use Plugin\ActivityPub\Util\TypeResponse;
  64. use Plugin\ActivityPub\Util\Validator\contentLangModelValidator;
  65. use Plugin\ActivityPub\Util\Validator\manuallyApprovesFollowersModelValidator;
  66. use Symfony\Component\HttpFoundation\JsonResponse;
  67. use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
  68. use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
  69. use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
  70. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  71. use Symfony\Contracts\HttpClient\ResponseInterface;
  72. use XML_XRD;
  73. use XML_XRD_Element_Link;
  74. /**
  75. * Adds ActivityPub support to GNU social when enabled
  76. *
  77. * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
  78. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  79. */
  80. class ActivityPub extends Plugin
  81. {
  82. // ActivityStreams 2.0 Accept Headers
  83. public static array $accept_headers = [
  84. 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  85. 'application/activity+json',
  86. 'application/json',
  87. 'application/ld+json',
  88. ];
  89. // So that this isn't hardcoded everywhere
  90. public const PUBLIC_TO = [
  91. 'https://www.w3.org/ns/activitystreams#Public',
  92. 'Public',
  93. 'as:Public',
  94. ];
  95. public const HTTP_CLIENT_HEADERS = [
  96. 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  97. 'User-Agent' => 'GNUsocialBot ' . GNUSOCIAL_VERSION . ' - ' . GNUSOCIAL_PROJECT_URL,
  98. ];
  99. public function version(): string
  100. {
  101. return '3.0.0';
  102. }
  103. public static array $activity_streams_two_context = [
  104. 'https://www.w3.org/ns/activitystreams',
  105. 'https://w3id.org/security/v1',
  106. ['gs' => 'https://www.gnu.org/software/social/ns#'],
  107. ['litepub' => 'http://litepub.social/ns#'],
  108. ['chatMessage' => 'litepub:chatMessage'],
  109. [
  110. 'inConversation' => [
  111. '@id' => 'gs:inConversation',
  112. '@type' => '@id',
  113. ],
  114. ],
  115. ];
  116. public function onInitializePlugin(): bool
  117. {
  118. Event::handle('ActivityStreamsTwoContext', [&self::$activity_streams_two_context]);
  119. self::$activity_streams_two_context = array_unique(self::$activity_streams_two_context, \SORT_REGULAR);
  120. return Event::next;
  121. }
  122. public function onQueueActivitypubInbox(ActivitypubActor $ap_actor, Actor $actor, string|AbstractObject $type): bool
  123. {
  124. // TODO: Check if Actor has authority over payload
  125. // Store Activity
  126. $ap_act = Model\Activity::fromJson($type, ['source' => 'ActivityPub']);
  127. FreeNetworkActorProtocol::protocolSucceeded(
  128. 'activitypub',
  129. $ap_actor->getActorId(),
  130. Discovery::normalize($actor->getNickname() . '@' . parse_url($ap_actor->getInboxUri(), \PHP_URL_HOST)),
  131. );
  132. DB::flush();
  133. if ($ap_act->getToNotifyTargets() !== []) {
  134. if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $ap_act->getToNotifyTargets(), _m('{actor_id} triggered a notification via ActivityPub.', ['{actor_id}' => $actor->getId()])]) === Event::next) {
  135. Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $ap_act->getToNotifyTargets(), _m('{actor_id} triggered a notification via ActivityPub.', ['{nickname}' => $actor->getId()])]);
  136. }
  137. }
  138. return Event::stop;
  139. }
  140. /**
  141. * This code executes when GNU social creates the page routing, and we hook
  142. * on this event to add our Inbox and Outbox handler for ActivityPub.
  143. *
  144. * @param RouteLoader $r the router that was initialized
  145. */
  146. public function onAddRoute(RouteLoader $r): bool
  147. {
  148. $r->connect(
  149. 'activitypub_inbox',
  150. '/inbox.json',
  151. Inbox::class,
  152. options: ['format' => self::$accept_headers[0]],
  153. );
  154. $r->connect(
  155. 'activitypub_actor_inbox',
  156. '/actor/{gsactor_id<\d+>}/inbox.json',
  157. [Inbox::class, 'handle'],
  158. options: ['format' => self::$accept_headers[0]],
  159. );
  160. $r->connect(
  161. 'activitypub_actor_outbox',
  162. '/actor/{gsactor_id<\d+>}/outbox.json',
  163. [Outbox::class, 'viewOutboxByActorId'],
  164. options: ['accept' => self::$accept_headers, 'format' => self::$accept_headers[0]],
  165. );
  166. return Event::next;
  167. }
  168. /**
  169. * Fill Actor->getUrl() calls with correct URL coming from ActivityPub
  170. */
  171. public function onStartGetActorUri(Actor $actor, int $type, ?string &$url): bool
  172. {
  173. if (
  174. // Is remote?
  175. !$actor->getIsLocal()
  176. // Is in ActivityPub?
  177. && !\is_null($ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))
  178. // We can only provide a full URL (anything else wouldn't make sense)
  179. && $type === Router::ABSOLUTE_URL
  180. ) {
  181. $url = $ap_actor->getUri();
  182. return Event::stop;
  183. }
  184. return Event::next;
  185. }
  186. /**
  187. * Fill Actor->canAdmin() for Actors that came from ActivityPub
  188. */
  189. public function onFreeNetworkActorCanAdmin(Actor $actor, Actor $other, bool &$canAdmin): bool
  190. {
  191. // Are both in AP?
  192. if (
  193. !\is_null($ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))
  194. && !\is_null($ap_other = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $other->getId()], return_null: true))
  195. ) {
  196. // Are they both in the same server?
  197. $canAdmin = parse_url($ap_actor->getUri(), \PHP_URL_HOST) === parse_url($ap_other->getUri(), \PHP_URL_HOST);
  198. return Event::stop;
  199. }
  200. return Event::next;
  201. }
  202. /**
  203. * Overload core endpoints to make resources available in ActivityStreams 2.0
  204. *
  205. * @throws Exception
  206. */
  207. public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool
  208. {
  209. if (\count(array_intersect(self::$accept_headers, $accept_header)) === 0) {
  210. return Event::next;
  211. }
  212. switch ($route) {
  213. case 'actor_view_id':
  214. case 'person_actor_view_id':
  215. case 'person_actor_view_nickname':
  216. case 'group_actor_view_id':
  217. case 'group_actor_view_nickname':
  218. case 'bot_actor_view_id':
  219. case 'bot_actor_view_nickname':
  220. $response = ActorResponse::handle($vars['actor']);
  221. break;
  222. case 'activity_view':
  223. $response = ActivityResponse::handle($vars['activity']);
  224. break;
  225. case 'note_view':
  226. $response = NoteResponse::handle($vars['note']);
  227. break;
  228. case 'activitypub_actor_outbox':
  229. $response = new TypeResponse($vars['type']);
  230. break;
  231. default:
  232. if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) !== Event::stop) {
  233. if (is_subclass_of($vars['controller'][0], OrderedCollection::class)) {
  234. $response = new TypeResponse(OrderedCollectionController::fromControllerVars($vars)['type']);
  235. } else {
  236. $response = new JsonResponse(['error' => 'Unknown Object cannot be represented.']);
  237. }
  238. }
  239. }
  240. return Event::stop;
  241. }
  242. /**
  243. * Add ActivityStreams 2 Extensions
  244. */
  245. public function onActivityPubValidateActivityStreamsTwoData(string $type_name, array &$validators): bool
  246. {
  247. switch ($type_name) {
  248. case 'Person':
  249. $validators['manuallyApprovesFollowers'] = manuallyApprovesFollowersModelValidator::class;
  250. break;
  251. case 'Note':
  252. $validators['contentLang'] = contentLangModelValidator::class;
  253. break;
  254. }
  255. return Event::next;
  256. }
  257. // FreeNetworkComponent Events
  258. /**
  259. * Let FreeNetwork Component know we exist and which class to use to call the freeNetworkDistribute method
  260. */
  261. public function onAddFreeNetworkProtocol(array &$protocols): bool
  262. {
  263. $protocols[] = '\Plugin\ActivityPub\ActivityPub';
  264. return Event::next;
  265. }
  266. /**
  267. * The FreeNetwork component will call this function to pull ActivityPub objects by URI
  268. *
  269. * @param string $uri Query
  270. *
  271. * @return bool true if imported, false otherwise
  272. */
  273. public static function freeNetworkGrabRemote(string $uri): bool
  274. {
  275. if (Common::isValidHttpUrl($uri)) {
  276. try {
  277. $object = self::getObjectByUri($uri);
  278. if (!\is_null($object)) {
  279. if ($object instanceof Type\AbstractObject) {
  280. if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) {
  281. DB::wrapInTransaction(fn () => Model\Actor::fromJson($object));
  282. } else {
  283. DB::wrapInTransaction(fn () => Model\Activity::fromJson($object));
  284. }
  285. }
  286. return true;
  287. }
  288. } catch (Exception|Throwable) {
  289. // May be invalid input, we can safely ignore in this case
  290. }
  291. }
  292. return false;
  293. }
  294. public function onQueueActivitypubPostman(
  295. Actor $sender,
  296. Activity $activity,
  297. string $inbox,
  298. array $to_actors,
  299. array &$retry_args,
  300. ): bool {
  301. try {
  302. $data = Model::toJson($activity);
  303. if ($sender->isGroup()) {
  304. // When the sender is a group, we have to wrap it in an Announce activity
  305. $data = Type::create('Announce', ['object' => $data])->toJson();
  306. }
  307. $res = self::postman($sender, $data, $inbox);
  308. // accumulate errors for later use, if needed
  309. $status_code = $res->getStatusCode();
  310. if (!($status_code === 200 || $status_code === 202 || $status_code === 409)) {
  311. $res_body = json_decode($res->getContent(), true);
  312. $retry_args['reason'] ??= [];
  313. $retry_args['reason'][] = $res_body['error'] ?? 'An unknown error occurred.';
  314. return Event::next;
  315. } else {
  316. foreach ($to_actors as $actor) {
  317. if ($actor->isPerson()) {
  318. FreeNetworkActorProtocol::protocolSucceeded(
  319. 'activitypub',
  320. $actor,
  321. Discovery::normalize($actor->getNickname() . '@' . parse_url($inbox, \PHP_URL_HOST)),
  322. );
  323. }
  324. }
  325. }
  326. return Event::stop;
  327. } catch (Exception $e) {
  328. Log::error('ActivityPub @ freeNetworkDistribute: ' . $e->getMessage(), [$e]);
  329. $retry_args['reason'] ??= [];
  330. $retry_args['reason'][] = "Got an exception: {$e->getMessage()}";
  331. $retry_args['exception'] ??= [];
  332. $retry_args['exception'][] = $e;
  333. return Event::next;
  334. }
  335. }
  336. /**
  337. * The FreeNetwork component will call this function to distribute this instance's activities
  338. *
  339. * @throws ClientExceptionInterface
  340. * @throws RedirectionExceptionInterface
  341. * @throws ServerExceptionInterface
  342. * @throws TransportExceptionInterface
  343. */
  344. public static function freeNetworkDistribute(Actor $sender, Activity $activity, array $targets, ?string $reason = null): void
  345. {
  346. $to_addr = [];
  347. foreach ($targets as $actor) {
  348. if (FreeNetworkActorProtocol::canIActor('activitypub', $actor->getId())) {
  349. // Sometimes FreeNetwork can allow us to actor even though we don't have an internal representation of
  350. // the actor, that could for example mean that OStatus handled this actor while we were deactivated
  351. // On next interaction this should be resolved, for now continue
  352. if (\is_null($ap_target = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))) {
  353. continue;
  354. }
  355. $to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor;
  356. } else {
  357. continue;
  358. }
  359. }
  360. foreach ($to_addr as $inbox => $to_actors) {
  361. Queue::enqueue(
  362. payload: [$sender, $activity, $inbox, $to_actors],
  363. queue: 'ActivitypubPostman',
  364. priority: false,
  365. );
  366. }
  367. }
  368. /**
  369. * Internal tool to sign and send activities out
  370. *
  371. * @throws Exception
  372. */
  373. private static function postman(Actor $sender, string $json_activity, string $inbox, string $method = 'post'): ResponseInterface
  374. {
  375. Log::debug('ActivityPub Postman: Delivering ' . $json_activity . ' to ' . $inbox);
  376. $headers = HTTPSignature::sign($sender, $inbox, $json_activity);
  377. Log::debug('ActivityPub Postman: Delivery headers were: ' . print_r($headers, true));
  378. $response = HTTPClient::$method($inbox, ['headers' => $headers, 'body' => $json_activity]);
  379. Log::debug('ActivityPub Postman: Delivery result with status code ' . $response->getStatusCode() . ': ' . $response->getContent());
  380. return $response;
  381. }
  382. // WebFinger Events
  383. /**
  384. * Add activity+json mimetype to WebFinger
  385. */
  386. public function onEndWebFingerProfileLinks(XML_XRD $xrd, Actor $object): bool
  387. {
  388. if ($object->isPerson()) {
  389. $link = new XML_XRD_Element_Link(
  390. rel: 'self',
  391. href: $object->getUri(Router::ABSOLUTE_URL),//Router::url('actor_view_id', ['id' => $object->getId()], Router::ABSOLUTE_URL),
  392. type: 'application/activity+json',
  393. );
  394. $xrd->links[] = clone $link;
  395. }
  396. return Event::next;
  397. }
  398. /**
  399. * When FreeNetwork component asks us to help with identifying Actors from XRDs
  400. */
  401. public function onFreeNetworkFoundXrd(XML_XRD $xrd, ?Actor &$actor = null): bool
  402. {
  403. $addr = null;
  404. foreach ($xrd->aliases as $alias) {
  405. if (Discovery::isAcct($alias)) {
  406. $addr = Discovery::normalize($alias);
  407. }
  408. }
  409. if (\is_null($addr)) {
  410. return Event::next;
  411. } else {
  412. if (!FreeNetworkActorProtocol::canIAddr('activitypub', $addr)) {
  413. return Event::next;
  414. }
  415. }
  416. try {
  417. $ap_actor = ActivitypubActor::fromXrd($addr, $xrd);
  418. $actor = Actor::getById($ap_actor->getActorId());
  419. FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor, $addr);
  420. return Event::stop;
  421. } catch (Exception $e) {
  422. Log::error('ActivityPub Actor from URL Mention check failed: ' . $e->getMessage());
  423. return Event::next;
  424. }
  425. }
  426. // Discovery Events
  427. /**
  428. * When FreeNetwork component asks us to help with identifying Actors from URIs
  429. */
  430. public function onFreeNetworkFindMentions(string $target, ?Actor &$actor = null): bool
  431. {
  432. try {
  433. if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) {
  434. $ap_actor = DB::wrapInTransaction(fn () => ActivitypubActor::getByAddr($addr));
  435. $actor = Actor::getById($ap_actor->getActorId());
  436. FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor->getId(), $addr);
  437. return Event::stop;
  438. } else {
  439. return Event::next;
  440. }
  441. } catch (Exception $e) {
  442. Log::error('ActivityPub WebFinger Mention check failed.', [$e]);
  443. return Event::next;
  444. }
  445. }
  446. /**
  447. * @return string got from URI
  448. */
  449. public static function getUriByObject(mixed $object): string
  450. {
  451. switch ($object::class) {
  452. case Note::class:
  453. if ($object->getIsLocal()) {
  454. return $object->getUrl();
  455. } else {
  456. // Try known remote objects
  457. $known_object = DB::findOneBy(ActivitypubObject::class, ['object_type' => 'note', 'object_id' => $object->getId()], return_null: true);
  458. if ($known_object instanceof ActivitypubObject) {
  459. return $known_object->getObjectUri();
  460. } else {
  461. throw new BugFoundException('ActivityPub cannot generate an URI for a stored note.', [$object, $known_object]);
  462. }
  463. }
  464. break;
  465. case Actor::class:
  466. return $object->getUri();
  467. break;
  468. case Activity::class:
  469. // Try known remote activities
  470. $known_activity = DB::findOneBy(ActivitypubActivity::class, ['activity_id' => $object->getId()], return_null: true);
  471. if (!\is_null($known_activity)) {
  472. return $known_activity->getActivityUri();
  473. } else {
  474. return Router::url('activity_view', ['id' => $object->getId()], Router::ABSOLUTE_URL);
  475. }
  476. break;
  477. default:
  478. throw new InvalidArgumentException('ActivityPub::getUriByObject found a limitation with: ' . var_export($object, true));
  479. }
  480. }
  481. /**
  482. * Get a Note from ActivityPub URI, if it doesn't exist, attempt to fetch it
  483. * This should only be necessary internally.
  484. *
  485. * @throws ClientExceptionInterface
  486. * @throws RedirectionExceptionInterface
  487. * @throws ServerExceptionInterface
  488. * @throws TransportExceptionInterface
  489. *
  490. * @return null|Actor|mixed|Note got from URI
  491. */
  492. public static function getObjectByUri(string $resource, bool $try_online = true)
  493. {
  494. // Try known object
  495. $known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
  496. if (!\is_null($known_object)) {
  497. return $known_object->getObject();
  498. }
  499. // Try known activity
  500. $known_activity = DB::findOneBy(ActivitypubActivity::class, ['activity_uri' => $resource], return_null: true);
  501. if (!\is_null($known_activity)) {
  502. return $known_activity->getActivity();
  503. }
  504. // Try local Note
  505. if (Common::isValidHttpUrl($resource)) {
  506. $resource_parts = parse_url($resource);
  507. // TODO: Use URLMatcher
  508. if ($resource_parts['host'] === Common::config('site', 'server')) {
  509. $local_note = DB::findOneBy('note', ['url' => $resource], return_null: true);
  510. if (!\is_null($local_note)) {
  511. return $local_note;
  512. }
  513. }
  514. }
  515. // Try Actor
  516. try {
  517. return Explorer::getOneFromUri($resource, try_online: false);
  518. } catch (\Exception) {
  519. // Ignore, this is brute forcing, it's okay not to find
  520. }
  521. // Try remote
  522. if (!$try_online) {
  523. return;
  524. }
  525. $response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]);
  526. // If it was deleted
  527. if ($response->getStatusCode() == 410) {
  528. //$obj = Type::create('Tombstone', ['id' => $resource]);
  529. return;
  530. } elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
  531. throw new Exception('Non Ok Status Code for given Object id.');
  532. } else {
  533. return Model::jsonToType($response->getContent());
  534. }
  535. }
  536. }