123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611 |
- <?php
- declare(strict_types = 1);
- // {{{ License
- // This file is part of GNU social - https://www.gnu.org/software/social
- //
- // GNU social is free software: you can redistribute it and/or modify
- // it under the terms of the GNU Affero General Public License as published by
- // the Free Software Foundation, either version 3 of the License, or
- // (at your option) any later version.
- //
- // GNU social is distributed in the hope that it will be useful,
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- // GNU Affero General Public License for more details.
- //
- // You should have received a copy of the GNU Affero General Public License
- // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
- // }}}
- /**
- * ActivityPub implementation for GNU social
- *
- * @package GNUsocial
- * @category ActivityPub
- *
- * @author Diogo Peralta Cordeiro <@diogo.site>
- * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
- * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
- */
- namespace Plugin\ActivityPub;
- use ActivityPhp\Type;
- use ActivityPhp\Type\AbstractObject;
- use App\Core\DB;
- use App\Core\Event;
- use App\Core\HTTPClient;
- use function App\Core\I18n\_m;
- use App\Core\Log;
- use App\Core\Modules\Plugin;
- use App\Core\Queue;
- use App\Core\Router;
- use App\Entity\Activity;
- use App\Entity\Actor;
- use App\Entity\Note;
- use App\Util\Common;
- use App\Util\Exception\BugFoundException;
- use Component\Collection\Util\Controller\OrderedCollection;
- use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
- use Component\FreeNetwork\Util\Discovery;
- use EventResult;
- use Exception;
- use InvalidArgumentException;
- use Plugin\ActivityPub\Controller\Inbox;
- use Plugin\ActivityPub\Controller\Outbox;
- use Plugin\ActivityPub\Entity\ActivitypubActivity;
- use Plugin\ActivityPub\Entity\ActivitypubActor;
- use Plugin\ActivityPub\Entity\ActivitypubObject;
- use Plugin\ActivityPub\Util\Explorer;
- use Plugin\ActivityPub\Util\HTTPSignature;
- use Plugin\ActivityPub\Util\Model;
- use Plugin\ActivityPub\Util\OrderedCollectionController;
- use Plugin\ActivityPub\Util\Response\ActivityResponse;
- use Plugin\ActivityPub\Util\Response\ActorResponse;
- use Plugin\ActivityPub\Util\Response\NoteResponse;
- use Plugin\ActivityPub\Util\TypeResponse;
- use Plugin\ActivityPub\Util\Validator\contentLangModelValidator;
- use Plugin\ActivityPub\Util\Validator\manuallyApprovesFollowersModelValidator;
- use Symfony\Component\HttpFoundation\JsonResponse;
- use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
- use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
- use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
- use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
- use Symfony\Contracts\HttpClient\ResponseInterface;
- use XML_XRD;
- use XML_XRD_Element_Link;
- /**
- * Adds ActivityPub support to GNU social when enabled
- *
- * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
- * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
- */
- class ActivityPub extends Plugin
- {
- // ActivityStreams 2.0 Accept Headers
- public static array $accept_headers = [
- 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- 'application/activity+json',
- 'application/json',
- 'application/ld+json',
- ];
- // So that this isn't hardcoded everywhere
- public const PUBLIC_TO = [
- 'https://www.w3.org/ns/activitystreams#Public',
- 'Public',
- 'as:Public',
- ];
- public const HTTP_CLIENT_HEADERS = [
- 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- 'User-Agent' => 'GNUsocialBot ' . GNUSOCIAL_VERSION . ' - ' . GNUSOCIAL_PROJECT_URL,
- ];
- public static function version(): string
- {
- return '3.0.0';
- }
- public static array $activity_streams_two_context = [
- 'https://www.w3.org/ns/activitystreams',
- 'https://w3id.org/security/v1',
- ['gs' => 'https://www.gnu.org/software/social/ns#'],
- ['litepub' => 'http://litepub.social/ns#'],
- ['chatMessage' => 'litepub:chatMessage'],
- [
- 'inConversation' => [
- '@id' => 'gs:inConversation',
- '@type' => '@id',
- ],
- ],
- ];
- public function onInitializePlugin(): EventResult
- {
- Event::handle('ActivityStreamsTwoContext', [&self::$activity_streams_two_context]);
- self::$activity_streams_two_context = array_unique(self::$activity_streams_two_context, \SORT_REGULAR);
- return Event::next;
- }
- public function onQueueActivitypubInbox(ActivitypubActor $ap_actor, Actor $actor, string|AbstractObject $type): EventResult
- {
- // TODO: Check if Actor has authority over payload
- // Store Activity
- $ap_act = Model\Activity::fromJson($type, ['source' => 'ActivityPub']);
- FreeNetworkActorProtocol::protocolSucceeded(
- 'activitypub',
- $ap_actor->getActorId(),
- Discovery::normalize($actor->getNickname() . '@' . parse_url($ap_actor->getInboxUri(), \PHP_URL_HOST)),
- );
- DB::flush();
- if (($att_targets = $ap_act->getAttentionTargets()) !== []) {
- if (Event::handle('ActivityPubNewNotification', [$actor, ($act = $ap_act->getActivity()), $att_targets, _m('{actor_id} triggered a notification via ActivityPub.', ['{actor_id}' => $actor->getId()])]) === Event::next) {
- Event::handle('NewNotification', [$actor, $act, $att_targets, _m('{actor_id} triggered a notification via ActivityPub.', ['{nickname}' => $actor->getId()])]);
- }
- }
- return Event::stop;
- }
- /**
- * This code executes when GNU social creates the page routing, and we hook
- * on this event to add our Inbox and Outbox handler for ActivityPub.
- *
- * @param Router $r the router that was initialized
- */
- public function onAddRoute(Router $r): EventResult
- {
- $r->connect(
- 'activitypub_inbox',
- '/inbox.json',
- Inbox::class,
- options: ['format' => self::$accept_headers[0]],
- );
- $r->connect(
- 'activitypub_actor_inbox',
- '/actor/{gsactor_id<\d+>}/inbox.json',
- [Inbox::class, 'handle'],
- options: ['format' => self::$accept_headers[0]],
- );
- $r->connect(
- 'activitypub_actor_outbox',
- '/actor/{gsactor_id<\d+>}/outbox.json',
- [Outbox::class, 'viewOutboxByActorId'],
- options: ['accept' => self::$accept_headers, 'format' => self::$accept_headers[0]],
- );
- return Event::next;
- }
- /**
- * Fill Actor->getUrl() calls with correct URL coming from ActivityPub
- */
- public function onStartGetActorUri(Actor $actor, int $type, ?string &$url): EventResult
- {
- if (
- // Is remote?
- !$actor->getIsLocal()
- // Is in ActivityPub?
- && !\is_null($ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))
- // We can only provide a full URL (anything else wouldn't make sense)
- && $type === Router::ABSOLUTE_URL
- ) {
- $url = $ap_actor->getUri();
- return Event::stop;
- }
- return Event::next;
- }
- /**
- * Fill Actor->canAdmin() for Actors that came from ActivityPub
- */
- public function onFreeNetworkActorCanAdmin(Actor $actor, Actor $other, bool &$canAdmin): EventResult
- {
- // Are both in AP?
- if (
- !\is_null($ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))
- && !\is_null($ap_other = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $other->getId()], return_null: true))
- ) {
- // Are they both in the same server?
- $canAdmin = parse_url($ap_actor->getUri(), \PHP_URL_HOST) === parse_url($ap_other->getUri(), \PHP_URL_HOST);
- return Event::stop;
- }
- return Event::next;
- }
- /**
- * Overload core endpoints to make resources available in ActivityStreams 2.0
- *
- * @throws Exception
- */
- public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): EventResult
- {
- if (\count(array_intersect(self::$accept_headers, $accept_header)) === 0) {
- return Event::next;
- }
- switch ($route) {
- case 'actor_view_id':
- case 'person_actor_view_id':
- case 'person_actor_view_nickname':
- case 'group_actor_view_id':
- case 'group_actor_view_nickname':
- case 'bot_actor_view_id':
- case 'bot_actor_view_nickname':
- $response = ActorResponse::handle($vars['actor']);
- break;
- case 'activity_view':
- $response = ActivityResponse::handle($vars['activity']);
- break;
- case 'note_view':
- $response = NoteResponse::handle($vars['note']);
- break;
- case 'activitypub_actor_outbox':
- $response = new TypeResponse($vars['type']);
- break;
- default:
- if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) !== Event::stop) {
- if (is_subclass_of($vars['controller'][0], OrderedCollection::class)) {
- $response = new TypeResponse(OrderedCollectionController::fromControllerVars($vars)['type']);
- } else {
- $response = new JsonResponse(['error' => 'Unknown Object cannot be represented.']);
- }
- }
- }
- return Event::stop;
- }
- /**
- * Add ActivityStreams 2 Extensions
- */
- public function onActivityPubValidateActivityStreamsTwoData(string $type_name, array &$validators): EventResult
- {
- switch ($type_name) {
- case 'Person':
- $validators['manuallyApprovesFollowers'] = manuallyApprovesFollowersModelValidator::class;
- break;
- case 'Note':
- $validators['contentLang'] = contentLangModelValidator::class;
- break;
- }
- return Event::next;
- }
- // FreeNetworkComponent Events
- /**
- * Let FreeNetwork Component know we exist and which class to use to call the freeNetworkDistribute method
- */
- public function onAddFreeNetworkProtocol(array &$protocols): EventResult
- {
- if (!\in_array('\Plugin\ActivityPub\ActivityPub', $protocols)) {
- $protocols[] = '\Plugin\ActivityPub\ActivityPub';
- }
- return Event::next;
- }
- /**
- * The FreeNetwork component will call this function to pull ActivityPub objects by URI
- *
- * @param string $uri Query
- *
- * @return bool true if imported, false otherwise
- */
- public static function freeNetworkGrabRemote(string $uri, ?Actor $on_behalf_of = null): bool
- {
- if (Common::isValidHttpUrl($uri)) {
- try {
- $object = self::getObjectByUri($uri, try_online: true, on_behalf_of: $on_behalf_of);
- if (!\is_null($object)) {
- if ($object instanceof Type\AbstractObject) {
- if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) {
- DB::wrapInTransaction(fn () => Model\Actor::fromJson($object));
- } else {
- DB::wrapInTransaction(fn () => Model\Activity::fromJson($object));
- }
- }
- return true;
- }
- } catch (Exception|Throwable) {
- // May be invalid input, we can safely ignore in this case
- }
- }
- return false;
- }
- public function onQueueActivitypubPostman(
- Actor $sender,
- Activity $activity,
- string $inbox,
- array $to_actors,
- array &$retry_args,
- ): EventResult {
- try {
- $data = Model::toType($activity);
- if ($sender->isGroup()) { // When the sender is a group,
- if ($activity->getVerb() === 'subscribe') {
- // Regular postman happens
- } elseif ($activity->getVerb() === 'undo' && $data->get('object')->get('type') === 'Follow') {
- // Regular postman happens
- } else {
- // For every other activity sent by a Group, we have to wrap it in a transient Announce activity
- $data = Type::create('Announce', [
- '@context' => 'https:\/\/www.w3.org\/ns\/activitystreams',
- 'actor' => $sender->getUri(type: Router::ABSOLUTE_URL),
- 'object' => $data,
- ]);
- }
- }
- $res = self::postman($sender, $data->toJson(), $inbox);
- // accumulate errors for later use, if needed
- $status_code = $res->getStatusCode();
- if (!($status_code === 200 || $status_code === 202 || $status_code === 409)) {
- $res_body = json_decode($res->getContent(), true);
- $retry_args['reason'] ??= [];
- $retry_args['reason'][] = $res_body['error'] ?? 'An unknown error occurred.';
- return Event::next;
- } else {
- foreach ($to_actors as $actor) {
- if ($actor->isPerson()) {
- FreeNetworkActorProtocol::protocolSucceeded(
- 'activitypub',
- $actor,
- Discovery::normalize($actor->getNickname() . '@' . parse_url($inbox, \PHP_URL_HOST)),
- );
- }
- }
- }
- return Event::stop;
- } catch (Exception $e) {
- Log::error('ActivityPub @ freeNetworkDistribute: ' . $e->getMessage(), [$e]);
- $retry_args['reason'] ??= [];
- $retry_args['reason'][] = "Got an exception: {$e->getMessage()}";
- $retry_args['exception'] ??= [];
- $retry_args['exception'][] = $e;
- return Event::next;
- }
- }
- /**
- * The FreeNetwork component will call this function to distribute this instance's activities
- *
- * @throws ClientExceptionInterface
- * @throws RedirectionExceptionInterface
- * @throws ServerExceptionInterface
- * @throws TransportExceptionInterface
- */
- public static function freeNetworkDistribute(Actor $sender, Activity $activity, array $targets, ?string $reason = null): void
- {
- $to_addr = [];
- foreach ($targets as $actor) {
- if (FreeNetworkActorProtocol::canIActor('activitypub', $actor->getId())) {
- // Sometimes FreeNetwork can allow us to actor even though we don't have an internal representation of
- // the actor, that could for example mean that OStatus handled this actor while we were deactivated
- // On next interaction this should be resolved, for now continue
- if (\is_null($ap_target = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))) {
- Log::info('FreeNetwork wrongly told ActivityPub that it can handle actor id: ' . $actor->getId() . ' you might want to keep an eye on it.');
- continue;
- }
- $to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor;
- } else {
- continue;
- }
- }
- foreach ($to_addr as $inbox => $to_actors) {
- Queue::enqueue(
- payload: [$sender, $activity, $inbox, $to_actors],
- queue: 'ActivitypubPostman',
- priority: false,
- );
- }
- }
- /**
- * Internal tool to sign and send activities out
- *
- * @throws Exception
- */
- private static function postman(Actor $sender, string $json_activity, string $inbox, string $method = 'post'): ResponseInterface
- {
- Log::debug('ActivityPub Postman: Delivering ' . $json_activity . ' to ' . $inbox);
- $headers = HTTPSignature::sign($sender, $inbox, $json_activity);
- Log::debug('ActivityPub Postman: Delivery headers were: ' . print_r($headers, true));
- $response = HTTPClient::$method($inbox, ['headers' => $headers, 'body' => $json_activity]);
- Log::debug('ActivityPub Postman: Delivery result with status code ' . $response->getStatusCode() . ': ' . $response->getContent());
- return $response;
- }
- // WebFinger Events
- /**
- * Add activity+json mimetype to WebFinger
- */
- public function onEndWebFingerProfileLinks(XML_XRD $xrd, Actor $object): EventResult
- {
- if ($object->isPerson()) {
- $link = new XML_XRD_Element_Link(
- rel: 'self',
- href: $object->getUri(Router::ABSOLUTE_URL),//Router::url('actor_view_id', ['id' => $object->getId()], Router::ABSOLUTE_URL),
- type: 'application/activity+json',
- );
- $xrd->links[] = clone $link;
- }
- return Event::next;
- }
- /**
- * When FreeNetwork component asks us to help with identifying Actors from XRDs
- */
- public function onFreeNetworkFoundXrd(XML_XRD $xrd, ?Actor &$actor = null): EventResult
- {
- $addr = null;
- foreach ($xrd->aliases as $alias) {
- if (Discovery::isAcct($alias)) {
- $addr = Discovery::normalize($alias);
- }
- }
- if (\is_null($addr)) {
- return Event::next;
- } else {
- if (!FreeNetworkActorProtocol::canIAddr('activitypub', $addr)) {
- return Event::next;
- }
- }
- try {
- $ap_actor = ActivitypubActor::fromXrd($addr, $xrd);
- $actor = Actor::getById($ap_actor->getActorId());
- FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor, $addr);
- return Event::stop;
- } catch (Exception $e) {
- Log::error('ActivityPub Actor from URL Mention check failed: ' . $e->getMessage());
- return Event::next;
- }
- }
- // Discovery Events
- /**
- * When FreeNetwork component asks us to help with identifying Actors from URIs
- */
- public function onFreeNetworkFindMentions(string $target, ?Actor &$actor = null): EventResult
- {
- try {
- if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) {
- $ap_actor = DB::wrapInTransaction(fn () => ActivitypubActor::getByAddr($addr));
- $actor = Actor::getById($ap_actor->getActorId());
- FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor->getId(), $addr);
- return Event::stop;
- } else {
- return Event::next;
- }
- } catch (Exception $e) {
- Log::error('ActivityPub WebFinger Mention check failed.', [$e]);
- return Event::next;
- }
- }
- /**
- * @return string got from URI
- */
- public static function getUriByObject(mixed $object): string
- {
- switch ($object::class) {
- case Note::class:
- if ($object->getIsLocal()) {
- return $object->getUrl();
- } else {
- // Try known remote objects
- $known_object = DB::findOneBy(ActivitypubObject::class, ['object_type' => 'note', 'object_id' => $object->getId()], return_null: true);
- if ($known_object instanceof ActivitypubObject) {
- return $known_object->getObjectUri();
- } else {
- throw new BugFoundException('ActivityPub cannot generate an URI for a stored note.', [$object, $known_object]);
- }
- }
- break;
- case Actor::class:
- return $object->getUri();
- break;
- case Activity::class:
- // Try known remote activities
- $known_activity = DB::findOneBy(ActivitypubActivity::class, ['activity_id' => $object->getId()], return_null: true);
- if (!\is_null($known_activity)) {
- return $known_activity->getActivityUri();
- } else {
- return Router::url('activity_view', ['id' => $object->getId()], Router::ABSOLUTE_URL);
- }
- break;
- default:
- throw new InvalidArgumentException('ActivityPub::getUriByObject found a limitation with: ' . var_export($object, true));
- }
- }
- /**
- * Get a Note from ActivityPub URI, if it doesn't exist, attempt to fetch it
- * This should only be necessary internally.
- *
- * @throws ClientExceptionInterface
- * @throws RedirectionExceptionInterface
- * @throws ServerExceptionInterface
- * @throws TransportExceptionInterface
- *
- * @return null|Actor|mixed|Note got from URI
- */
- public static function getObjectByUri(string $resource, bool $try_online = true, ?Actor $on_behalf_of = null): mixed
- {
- // Try known object
- $known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
- if (!\is_null($known_object)) {
- return $known_object->getObject();
- }
- // Try known activity
- $known_activity = DB::findOneBy(ActivitypubActivity::class, ['activity_uri' => $resource], return_null: true);
- if (!\is_null($known_activity)) {
- return $known_activity->getActivity();
- }
- // Try Actor
- try {
- return Explorer::getOneFromUri($resource, try_online: false, on_behalf_of: $on_behalf_of);
- } catch (\Exception) {
- // Ignore, this is brute forcing, it's okay not to find
- }
- // Is it a HTTP URL?
- if (Common::isValidHttpUrl($resource)) {
- $resource_parts = parse_url($resource);
- // If it is local
- if ($resource_parts['host'] === Common::config('site', 'server')) {
- // Try Local Note
- $local_note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true);
- if (!\is_null($local_note)) {
- return $local_note;
- }
- // Try local Activity
- try {
- $match = Router::match($resource_parts['path']);
- $local_activity = DB::findOneBy(Activity::class, ['id' => $match['id']], return_null: true);
- if (!\is_null($local_activity)) {
- return $local_activity;
- } else {
- throw new InvalidArgumentException('Tried to retrieve a non-existent local activity.');
- }
- } catch (\Exception) {
- // Ignore, this is brute forcing, it's okay not to find
- }
- throw new BugFoundException('ActivityPub failed to retrieve local resource: "' . $resource . '". This is a big issue.');
- } else {
- // Then it's remote
- if (!$try_online) {
- throw new Exception("Remote resource {$resource} not found without online resources.");
- }
- $response = Explorer::get($resource, $on_behalf_of);
- // If it was deleted
- if ($response->getStatusCode() == 410) {
- //$obj = Type::create('Tombstone', ['id' => $resource]);
- return null;
- } elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
- throw new Exception('Non Ok Status Code for given Object id.');
- } else {
- return Model::jsonToType($response->getContent());
- }
- }
- }
- return null;
- }
- }
|