ActivityPub.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  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 App\Core\DB\DB;
  31. use App\Core\Event;
  32. use App\Core\HTTPClient;
  33. use App\Core\Log;
  34. use App\Core\Modules\Plugin;
  35. use App\Core\Router\RouteLoader;
  36. use App\Core\Router\Router;
  37. use App\Entity\Activity;
  38. use App\Entity\Actor;
  39. use App\Entity\LocalUser;
  40. use App\Entity\Note;
  41. use App\Util\Common;
  42. use App\Util\Exception\BugFoundException;
  43. use App\Util\Exception\NoSuchActorException;
  44. use App\Util\Nickname;
  45. use Component\Collection\Util\Controller\OrderedCollection;
  46. use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
  47. use Component\FreeNetwork\Util\Discovery;
  48. use Exception;
  49. use InvalidArgumentException;
  50. use const PHP_URL_HOST;
  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\HTTPSignature;
  57. use Plugin\ActivityPub\Util\Model;
  58. use Plugin\ActivityPub\Util\OrderedCollectionController;
  59. use Plugin\ActivityPub\Util\Response\ActorResponse;
  60. use Plugin\ActivityPub\Util\Response\NoteResponse;
  61. use Plugin\ActivityPub\Util\TypeResponse;
  62. use Plugin\ActivityPub\Util\Validator\contentLangModelValidator;
  63. use Plugin\ActivityPub\Util\Validator\manuallyApprovesFollowersModelValidator;
  64. use const PREG_SET_ORDER;
  65. use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
  66. use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
  67. use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
  68. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  69. use Symfony\Contracts\HttpClient\ResponseInterface;
  70. use XML_XRD;
  71. use XML_XRD_Element_Link;
  72. /**
  73. * Adds ActivityPub support to GNU social when enabled
  74. *
  75. * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
  76. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  77. */
  78. class ActivityPub extends Plugin
  79. {
  80. // ActivityStreams 2.0 Accept Headers
  81. public static array $accept_headers = [
  82. 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  83. 'application/activity+json',
  84. 'application/json',
  85. 'application/ld+json',
  86. ];
  87. // So that this isn't hardcoded everywhere
  88. public const PUBLIC_TO = [
  89. 'https://www.w3.org/ns/activitystreams#Public',
  90. 'Public',
  91. 'as:Public',
  92. ];
  93. public const HTTP_CLIENT_HEADERS = [
  94. 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  95. 'User-Agent' => 'GNUsocialBot ' . GNUSOCIAL_VERSION . ' - ' . GNUSOCIAL_PROJECT_URL,
  96. ];
  97. public function version(): string
  98. {
  99. return '3.0.0';
  100. }
  101. /**
  102. * This code executes when GNU social creates the page routing, and we hook
  103. * on this event to add our Inbox and Outbox handler for ActivityPub.
  104. *
  105. * @param RouteLoader $r the router that was initialized
  106. */
  107. public function onAddRoute(RouteLoader $r): bool
  108. {
  109. $r->connect(
  110. 'activitypub_inbox',
  111. '/inbox.json',
  112. [Inbox::class, 'handle'],
  113. options: ['format' => self::$accept_headers[0]],
  114. );
  115. $r->connect(
  116. 'activitypub_actor_inbox',
  117. '/actor/{gsactor_id<\d+>}/inbox.json',
  118. [Inbox::class, 'handle'],
  119. options: ['format' => self::$accept_headers[0]],
  120. );
  121. $r->connect(
  122. 'activitypub_actor_outbox',
  123. '/actor/{gsactor_id<\d+>}/outbox.json',
  124. [Outbox::class, 'viewOutboxByActorId'],
  125. options: ['accept' => self::$accept_headers, 'format' => self::$accept_headers[0]],
  126. );
  127. return Event::next;
  128. }
  129. /**
  130. * Fill Actor->getUrl() calls with correct URL coming from ActivityPub
  131. */
  132. public function onStartGetActorUri(Actor $actor, int $type, ?string &$url): bool
  133. {
  134. if (
  135. // Is remote?
  136. !$actor->getIsLocal()
  137. // Is in ActivityPub?
  138. && !\is_null($ap_actor = ActivitypubActor::getByPK(['actor_id' => $actor->getId()]))
  139. // We can only provide a full URL (anything else wouldn't make sense)
  140. && $type === Router::ABSOLUTE_URL
  141. ) {
  142. $url = $ap_actor->getUri();
  143. return Event::stop;
  144. }
  145. return Event::next;
  146. }
  147. /**
  148. * Fill Actor->canAdmin() for Actors that came from ActivityPub
  149. */
  150. public function onFreeNetworkActorCanAdmin(Actor $actor, Actor $other, bool &$canAdmin): bool
  151. {
  152. // Are both in AP?
  153. if (
  154. !\is_null($ap_actor = ActivitypubActor::getByPK(['actor_id' => $actor->getId()]))
  155. && !\is_null($ap_other = ActivitypubActor::getByPK(['actor_id' => $other->getId()]))
  156. ) {
  157. // Are they both in the same server?
  158. $canAdmin = parse_url($ap_actor->getUri(), PHP_URL_HOST) === parse_url($ap_other->getUri(), PHP_URL_HOST);
  159. return Event::stop;
  160. }
  161. return Event::next;
  162. }
  163. /**
  164. * Overload core endpoints to make resources available in ActivityStreams 2.0
  165. *
  166. * @throws Exception
  167. */
  168. public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool
  169. {
  170. if (\count(array_intersect(self::$accept_headers, $accept_header)) === 0) {
  171. return Event::next;
  172. }
  173. switch ($route) {
  174. case 'actor_view_id':
  175. case 'actor_view_nickname':
  176. $response = ActorResponse::handle($vars['actor']);
  177. break;
  178. case 'note_view':
  179. $response = NoteResponse::handle($vars['note']);
  180. break;
  181. case 'activitypub_actor_outbox':
  182. $response = new TypeResponse($vars['type']);
  183. break;
  184. default:
  185. if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) !== Event::stop) {
  186. if (is_subclass_of($vars['controller'][0], OrderedCollection::class)) {
  187. $response = new TypeResponse(OrderedCollectionController::fromControllerVars($vars)['type']);
  188. }
  189. }
  190. }
  191. return Event::stop;
  192. }
  193. /**
  194. * Add ActivityStreams 2 Extensions
  195. */
  196. public function onActivityPubValidateActivityStreamsTwoData(string $type_name, array &$validators): bool
  197. {
  198. switch ($type_name) {
  199. case 'Person':
  200. $validators['manuallyApprovesFollowers'] = manuallyApprovesFollowersModelValidator::class;
  201. break;
  202. case 'Note':
  203. $validators['contentLang'] = contentLangModelValidator::class;
  204. break;
  205. }
  206. return Event::next;
  207. }
  208. // FreeNetworkComponent Events
  209. /**
  210. * Let FreeNetwork Component know we exist and which class to use to call the freeNetworkDistribute method
  211. */
  212. public function onAddFreeNetworkProtocol(array &$protocols): bool
  213. {
  214. $protocols[] = '\Plugin\ActivityPub\ActivityPub';
  215. return Event::next;
  216. }
  217. /**
  218. * The FreeNetwork component will call this function to distribute this instance's activities
  219. *
  220. * @throws ClientExceptionInterface
  221. * @throws RedirectionExceptionInterface
  222. * @throws ServerExceptionInterface
  223. * @throws TransportExceptionInterface
  224. */
  225. public static function freeNetworkDistribute(Actor $sender, Activity $activity, array $targets, ?string $reason = null, array &$delivered = []): bool
  226. {
  227. $to_addr = [];
  228. foreach ($targets as $actor) {
  229. if (FreeNetworkActorProtocol::canIActor('activitypub', $actor->getId())) {
  230. if (\is_null($ap_target = ActivitypubActor::getByPK(['actor_id' => $actor->getId()]))) {
  231. continue;
  232. }
  233. $to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor;
  234. } else {
  235. return Event::next;
  236. }
  237. }
  238. $errors = [];
  239. //$to_failed = [];
  240. foreach ($to_addr as $inbox => $dummy) {
  241. try {
  242. $res = self::postman($sender, Model::toJson($activity), $inbox);
  243. // accumulate errors for later use, if needed
  244. $status_code = $res->getStatusCode();
  245. if (!($status_code === 200 || $status_code === 202 || $status_code === 409)) {
  246. $res_body = json_decode($res->getContent(), true);
  247. $errors[] = $res_body['error'] ?? 'An unknown error occurred.';
  248. //$to_failed[$inbox] = $activity;
  249. } else {
  250. array_push($delivered, ...$dummy);
  251. foreach ($dummy as $actor) {
  252. FreeNetworkActorProtocol::protocolSucceeded(
  253. 'activitypub',
  254. $actor,
  255. Discovery::normalize($actor->getNickname() . '@' . parse_url($inbox, PHP_URL_HOST)),
  256. );
  257. }
  258. }
  259. } catch (Exception $e) {
  260. Log::error('ActivityPub @ freeNetworkDistribute: ' . $e->getMessage(), [$e]);
  261. //$to_failed[$inbox] = $activity;
  262. }
  263. }
  264. if (!empty($errors)) {
  265. Log::error(sizeof($errors) . ' instance/s failed to handle our activity!');
  266. return false;
  267. }
  268. return true;
  269. }
  270. /**
  271. * Internal tool to sign and send activities out
  272. *
  273. * @throws Exception
  274. */
  275. private static function postman(Actor $sender, string $json_activity, string $inbox, string $method = 'post'): ResponseInterface
  276. {
  277. Log::debug('ActivityPub Postman: Delivering ' . $json_activity . ' to ' . $inbox);
  278. $headers = HTTPSignature::sign($sender, $inbox, $json_activity);
  279. Log::debug('ActivityPub Postman: Delivery headers were: ' . print_r($headers, true));
  280. $response = HTTPClient::$method($inbox, ['headers' => $headers, 'body' => $json_activity]);
  281. Log::debug('ActivityPub Postman: Delivery result with status code ' . $response->getStatusCode() . ': ' . $response->getContent());
  282. return $response;
  283. }
  284. // WebFinger Events
  285. /**
  286. * Add activity+json mimetype to WebFinger
  287. */
  288. public function onEndWebFingerProfileLinks(XML_XRD $xrd, Actor $object): bool
  289. {
  290. if ($object->isPerson()) {
  291. $link = new XML_XRD_Element_Link(
  292. rel: 'self',
  293. href: $object->getUri(Router::ABSOLUTE_URL),//Router::url('actor_view_id', ['id' => $object->getId()], Router::ABSOLUTE_URL),
  294. type: 'application/activity+json',
  295. );
  296. $xrd->links[] = clone $link;
  297. }
  298. return Event::next;
  299. }
  300. /**
  301. * When FreeNetwork component asks us to help with identifying Actors from XRDs
  302. */
  303. public function onFreeNetworkFoundXrd(XML_XRD $xrd, ?Actor &$actor = null): bool
  304. {
  305. $addr = null;
  306. foreach ($xrd->aliases as $alias) {
  307. if (Discovery::isAcct($alias)) {
  308. $addr = Discovery::normalize($alias);
  309. }
  310. }
  311. if (\is_null($addr)) {
  312. return Event::next;
  313. } else {
  314. if (!FreeNetworkActorProtocol::canIAddr('activitypub', $addr)) {
  315. return Event::next;
  316. }
  317. }
  318. try {
  319. $ap_actor = ActivitypubActor::fromXrd($addr, $xrd);
  320. $actor = Actor::getById($ap_actor->getActorId());
  321. FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor, $addr);
  322. return Event::stop;
  323. } catch (Exception $e) {
  324. Log::error('ActivityPub Actor from URL Mention check failed: ' . $e->getMessage());
  325. return Event::next;
  326. }
  327. }
  328. // Discovery Events
  329. /**
  330. * When FreeNetwork component asks us to help with identifying Actors from URIs
  331. */
  332. public function onFreeNetworkFindMentions(string $target, ?Actor &$actor = null): bool
  333. {
  334. try {
  335. if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) {
  336. $ap_actor = ActivitypubActor::getByAddr($addr);
  337. $actor = Actor::getById($ap_actor->getActorId());
  338. FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor->getId(), $addr);
  339. return Event::stop;
  340. } else {
  341. return Event::next;
  342. }
  343. } catch (Exception $e) {
  344. Log::error('ActivityPub Webfinger Mention check failed: ' . $e->getMessage());
  345. return Event::next;
  346. }
  347. }
  348. /**
  349. * @return string got from URI
  350. */
  351. public static function getUriByObject(mixed $object): string
  352. {
  353. switch ($object::class) {
  354. case Note::class:
  355. if ($object->getIsLocal()) {
  356. return $object->getUrl();
  357. } else {
  358. // Try known remote objects
  359. $known_object = ActivitypubObject::getByPK(['object_type' => 'note', 'object_id' => $object->getId()]);
  360. if ($known_object instanceof ActivitypubObject) {
  361. return $known_object->getObjectUri();
  362. } else {
  363. throw new BugFoundException('ActivityPub cannot generate an URI for a stored note.', [$object, $known_object]);
  364. }
  365. }
  366. break;
  367. case Actor::class:
  368. return $object->getUri();
  369. break;
  370. case Activity::class:
  371. // Try known remote activities
  372. $known_activity = ActivitypubActivity::getByPK(['activity_id' => $object->getId()]);
  373. if ($known_activity instanceof ActivitypubActivity) {
  374. return $known_activity->getActivityUri();
  375. } else {
  376. return Router::url('activity_view', ['id' => $object->getId()], Router::ABSOLUTE_URL);
  377. }
  378. break;
  379. default:
  380. throw new InvalidArgumentException('ActivityPub::getUriByObject found a limitation with: ' . var_export($object, true));
  381. }
  382. }
  383. /**
  384. * Get a Note from ActivityPub URI, if it doesn't exist, attempt to fetch it
  385. * This should only be necessary internally.
  386. *
  387. * @throws ClientExceptionInterface
  388. * @throws RedirectionExceptionInterface
  389. * @throws ServerExceptionInterface
  390. * @throws TransportExceptionInterface
  391. *
  392. * @return null|mixed|Note got from URI
  393. */
  394. public static function getObjectByUri(string $resource, bool $try_online = true)
  395. {
  396. // Try known object
  397. $known_object = ActivitypubObject::getByPK(['object_uri' => $resource]);
  398. if ($known_object instanceof ActivitypubObject) {
  399. return $known_object->getObject();
  400. }
  401. // Try known activity
  402. $known_activity = ActivitypubActivity::getByPK(['activity_uri' => $resource]);
  403. if ($known_activity instanceof ActivitypubActivity) {
  404. return $known_activity->getActivity();
  405. }
  406. // Try local Note
  407. if (Common::isValidHttpUrl($resource)) {
  408. // This means $resource is a valid url
  409. $resource_parts = parse_url($resource);
  410. // TODO: Use URLMatcher
  411. if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
  412. $local_note = DB::findOneBy('note', ['url' => $resource], return_null: true);
  413. if ($local_note instanceof Note) {
  414. return $local_note;
  415. }
  416. }
  417. }
  418. // Try Actor
  419. try {
  420. return self::getActorByUri($resource, try_online: false);
  421. } catch (Exception) {
  422. // Ignore, this is brute forcing, it's okay not to find
  423. }
  424. // Try remote
  425. if (!$try_online) {
  426. return;
  427. }
  428. $response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]);
  429. // If it was deleted
  430. if ($response->getStatusCode() == 410) {
  431. //$obj = Type::create('Tombstone', ['id' => $resource]);
  432. return;
  433. } elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
  434. throw new Exception('Non Ok Status Code for given Object id.');
  435. } else {
  436. return Model::jsonToType($response->getContent());
  437. }
  438. }
  439. /**
  440. * Get an Actor from ActivityPub URI, if it doesn't exist, attempt to fetch it
  441. * This should only be necessary internally.
  442. *
  443. * @throws NoSuchActorException
  444. *
  445. * @return Actor got from URI
  446. */
  447. public static function getActorByUri(string $resource, bool $try_online = true): Actor
  448. {
  449. // Try local
  450. if (Common::isValidHttpUrl($resource)) {
  451. // This means $resource is a valid url
  452. $resource_parts = parse_url($resource);
  453. // TODO: Use URLMatcher
  454. if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
  455. $str = $resource_parts['path'];
  456. // actor_view_nickname
  457. $renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
  458. // actor_view_id
  459. $reuri = '/\/actor\/(\d+)\/?/m';
  460. if (preg_match_all($renick, $str, $matches, PREG_SET_ORDER, 0) === 1) {
  461. return LocalUser::getByPK(['nickname' => $matches[0][1]])->getActor();
  462. } elseif (preg_match_all($reuri, $str, $matches, PREG_SET_ORDER, 0) === 1) {
  463. return Actor::getById((int) $matches[0][1]);
  464. }
  465. }
  466. }
  467. // Try known remote
  468. $aprofile = DB::findOneBy(ActivitypubActor::class, ['uri' => $resource], return_null: true);
  469. if (!\is_null($aprofile)) {
  470. return Actor::getById($aprofile->getActorId());
  471. }
  472. // Try remote
  473. if ($try_online) {
  474. $aprofile = ActivitypubActor::getByAddr($resource);
  475. if ($aprofile instanceof ActivitypubActor) {
  476. return Actor::getById($aprofile->getActorId());
  477. }
  478. }
  479. throw new NoSuchActorException("From URI: {$resource}");
  480. }
  481. }