. // }}} /** * 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 { $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): bool { if (Common::isValidHttpUrl($uri)) { try { $object = self::getObjectByUri($uri); 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): 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); } 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 = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]); // 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; } }