From 101b3c9703ebbb4ddfc95b7dd51584d43effda46 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 29 Jan 2023 14:41:14 +0000 Subject: [PATCH] First implementation of ActivityPub C2S --- src/Module/ActivityPub/Inbox.php | 31 ++++- src/Module/ActivityPub/Outbox.php | 13 +- src/Module/ActivityPub/Whoami.php | 105 ++++++++++++++ src/Module/OAuth/Token.php | 8 +- src/Object/Api/Mastodon/Token.php | 22 ++- src/Protocol/ActivityPub/Transmitter.php | 170 +++++++++++++++++------ static/routes.config.php | 1 + 7 files changed, 295 insertions(+), 55 deletions(-) create mode 100644 src/Module/ActivityPub/Whoami.php diff --git a/src/Module/ActivityPub/Inbox.php b/src/Module/ActivityPub/Inbox.php index 6ee690d2be..4470040a90 100644 --- a/src/Module/ActivityPub/Inbox.php +++ b/src/Module/ActivityPub/Inbox.php @@ -21,11 +21,12 @@ namespace Friendica\Module\ActivityPub; -use Friendica\BaseModule; use Friendica\Core\Logger; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\BaseApi; use Friendica\Protocol\ActivityPub; use Friendica\Util\HTTPSignature; use Friendica\Util\Network; @@ -33,9 +34,35 @@ use Friendica\Util\Network; /** * ActivityPub Inbox */ -class Inbox extends BaseModule +class Inbox extends BaseApi { protected function rawContent(array $request = []) + { + self::checkAllowedScope(self::SCOPE_READ); + $uid = self::getCurrentUserID(); + $page = $request['page'] ?? null; + + if (empty($page) && empty($request['max_id'])) { + $page = 1; + } + + if (!empty($this->parameters['nickname'])) { + $owner = User::getOwnerDataByNick($this->parameters['nickname']); + if (empty($owner)) { + throw new \Friendica\Network\HTTPException\NotFoundException(); + } + if ($owner['uid'] != $uid) { + throw new \Friendica\Network\HTTPException\ForbiddenException(); + } + $outbox = ActivityPub\Transmitter::getInbox($uid, $page, $request['max_id'] ?? null); + } else { + $outbox = ActivityPub\Transmitter::getPublicInbox($uid, $page, $request['max_id'] ?? null); + } + + System::jsonExit($outbox, 'application/activity+json'); + } + + protected function post(array $request = []) { $postdata = Network::postdata(); diff --git a/src/Module/ActivityPub/Outbox.php b/src/Module/ActivityPub/Outbox.php index d5fdb5490d..a8a0217276 100644 --- a/src/Module/ActivityPub/Outbox.php +++ b/src/Module/ActivityPub/Outbox.php @@ -21,16 +21,16 @@ namespace Friendica\Module\ActivityPub; -use Friendica\BaseModule; use Friendica\Core\System; use Friendica\Model\User; +use Friendica\Module\BaseApi; use Friendica\Protocol\ActivityPub; use Friendica\Util\HTTPSignature; /** * ActivityPub Outbox */ -class Outbox extends BaseModule +class Outbox extends BaseApi { protected function rawContent(array $request = []) { @@ -43,10 +43,15 @@ class Outbox extends BaseModule throw new \Friendica\Network\HTTPException\NotFoundException(); } - $page = !empty($request['page']) ? (int)$request['page'] : null; + $uid = self::getCurrentUserID(); + $page = $request['page'] ?? null; + + if (empty($page) && empty($request['max_id']) && !empty($uid)) { + $page = 1; + } $requester = HTTPSignature::getSigner('', $_SERVER); - $outbox = ActivityPub\Transmitter::getOutbox($owner, $page, $requester); + $outbox = ActivityPub\Transmitter::getOutbox($owner, $page, $request['max_id'] ?? null, $requester); System::jsonExit($outbox, 'application/activity+json'); } diff --git a/src/Module/ActivityPub/Whoami.php b/src/Module/ActivityPub/Whoami.php new file mode 100644 index 0000000000..ab72b4e152 --- /dev/null +++ b/src/Module/ActivityPub/Whoami.php @@ -0,0 +1,105 @@ +. + * + */ + +namespace Friendica\Module\ActivityPub; + +use Friendica\Content\Text\BBCode; +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\BaseApi; +use Friendica\Protocol\ActivityPub; + +/** + * Dummy class for all currently unimplemented endpoints + */ +class Whoami extends BaseApi +{ + /** + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + protected function rawContent(array $request = []) + { + self::checkAllowedScope(self::SCOPE_READ); + $uid = self::getCurrentUserID(); + + $owner = User::getOwnerDataById($uid); + + $data = ['@context' => ActivityPub::CONTEXT]; + + $data['id'] = $owner['url']; + $data['url'] = $owner['url']; + $data['type'] = ActivityPub::ACCOUNT_TYPES[$owner['account-type']]; + $data['name'] = $owner['name']; + $data['preferredUsername'] = $owner['nick']; + $data['alsoKnownAs'] = []; + $data['manuallyApprovesFollowers'] = in_array($owner['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]); + $data['discoverable'] = (bool)$owner['net-publish']; + $data['tag'] = []; + + $data['icon'] = [ + 'type' => 'Image', + 'url' => User::getAvatarUrl($owner) + ]; + + if (!empty($owner['about'])) { + $data['summary'] = BBCode::convertForUriId($owner['uri-id'] ?? 0, $owner['about'], BBCode::EXTERNAL); + } + + $custom_fields = []; + + foreach (DI::profileField()->selectByContactId(0, $uid) as $profile_field) { + $custom_fields[] = [ + 'type' => 'PropertyValue', + 'name' => $profile_field->label, + 'value' => BBCode::convertForUriId($owner['uri-id'], $profile_field->value) + ]; + }; + + if (!empty($custom_fields)) { + $data['attachment'] = $custom_fields; + } + + $data['publicKey'] = [ + 'id' => $owner['url'] . '#main-key', + 'owner' => $owner['url'], + 'publicKeyPem' => $owner['pubkey'] + ]; + + $data['capabilities'] = []; + $data['inbox'] = DI::baseUrl() . '/inbox/' . $owner['nick']; + $data['outbox'] = DI::baseUrl() . '/outbox/' . $owner['nick']; + $data['featured'] = DI::baseUrl() . '/featured/' . $owner['nick']; + $data['followers'] = DI::baseUrl() . '/followers/' . $owner['nick']; + $data['following'] = DI::baseUrl() . '/following/' . $owner['nick']; + + $data['endpoints'] = [ + 'oauthAuthorizationEndpoint' => DI::baseUrl() . '/oauth/authorize', + 'oauthRegistrationEndpoint' => DI::baseUrl() . '/api/v1/apps', + 'oauthTokenEndpoint' => DI::baseUrl() . '/oauth/token', + 'sharedInbox' => DI::baseUrl() . '/inbox', + 'uploadMedia' => DI::baseUrl() . '/api/upload_media' // @todo Endpoint does not exist at the moment + ]; + + $data['generator'] = ActivityPub\Transmitter::getService(); + System::jsonExit($data, 'application/activity+json'); + } +} diff --git a/src/Module/OAuth/Token.php b/src/Module/OAuth/Token.php index ecb65048d0..f97a05bb00 100644 --- a/src/Module/OAuth/Token.php +++ b/src/Module/OAuth/Token.php @@ -25,6 +25,7 @@ use Friendica\Core\Logger; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\User; use Friendica\Module\BaseApi; use Friendica\Module\Special\HTTPException; use Friendica\Security\OAuth; @@ -85,22 +86,25 @@ class Token extends BaseApi // the "client_credentials" are used as a token for the application itself. // see https://aaronparecki.com/oauth-2-simplified/#client-credentials $token = OAuth::createTokenForUser($application, 0, ''); + $me = null; } elseif ($request['grant_type'] == 'authorization_code') { // For security reasons only allow freshly created tokens $condition = ["`redirect_uri` = ? AND `id` = ? AND `code` = ? AND `created_at` > ?", $request['redirect_uri'], $application['id'], $request['code'], DateTimeFormat::utc('now - 5 minutes')]; - $token = DBA::selectFirst('application-view', ['access_token', 'created_at'], $condition); + $token = DBA::selectFirst('application-view', ['access_token', 'created_at', 'uid'], $condition); if (!DBA::isResult($token)) { Logger::notice('Token not found or outdated', $condition); DI::mstdnError()->Unauthorized(); } + $owner = User::getOwnerDataById($token['uid']); + $me = $owner['url']; } else { Logger::warning('Unsupported or missing grant type', ['request' => $_REQUEST]); DI::mstdnError()->UnprocessableEntity(DI::l10n()->t('Unsupported or missing grant type')); } - $object = new \Friendica\Object\Api\Mastodon\Token($token['access_token'], 'Bearer', $application['scopes'], $token['created_at']); + $object = new \Friendica\Object\Api\Mastodon\Token($token['access_token'], 'Bearer', $application['scopes'], $token['created_at'], $me); System::jsonExit($object->toArray()); } diff --git a/src/Object/Api/Mastodon/Token.php b/src/Object/Api/Mastodon/Token.php index 40d5f666cf..c9d6caac0d 100644 --- a/src/Object/Api/Mastodon/Token.php +++ b/src/Object/Api/Mastodon/Token.php @@ -38,6 +38,8 @@ class Token extends BaseDataTransferObject protected $scope; /** @var int (timestamp) */ protected $created_at; + /** @var string */ + protected $me; /** * Creates a token record @@ -46,12 +48,30 @@ class Token extends BaseDataTransferObject * @param string $token_type * @param string $scope * @param string $created_at + * @param string $me */ - public function __construct(string $access_token, string $token_type, string $scope, string $created_at) + public function __construct(string $access_token, string $token_type, string $scope, string $created_at, string $me = null) { $this->access_token = $access_token; $this->token_type = $token_type; $this->scope = $scope; $this->created_at = strtotime($created_at); + $this->me = $me; + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray(): array + { + $token = parent::toArray(); + + if (empty($token['me'])) { + unset($token['me']); + } + + return $token; } } diff --git a/src/Protocol/ActivityPub/Transmitter.php b/src/Protocol/ActivityPub/Transmitter.php index bcff0b53b1..985976633d 100644 --- a/src/Protocol/ActivityPub/Transmitter.php +++ b/src/Protocol/ActivityPub/Transmitter.php @@ -244,15 +244,16 @@ class Transmitter * * @param array $owner Owner array * @param integer $page Page number + * @param integer $max_id Maximum ID * @param string $requester URL of requesting account * @param boolean $nocache Wether to bypass caching * @return array of posts * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function getOutbox(array $owner, int $page = null, string $requester = '', bool $nocache = false): array + public static function getOutbox(array $owner, int $page = null, int $max_id = null, string $requester = ''): array { - $condition = ['private' => [Item::PUBLIC, Item::UNLISTED]]; + $condition = ['gravity' => [Item::GRAVITY_PARENT, Item::GRAVITY_COMMENT], 'private' => [Item::PUBLIC, Item::UNLISTED]]; if (!empty($requester)) { $requester_id = Contact::getIdForURL($requester, $owner['uid']); @@ -278,44 +279,86 @@ class Transmitter $apcontact = APContact::getByURL($owner['url']); + return self::getCollection($condition, DI::baseUrl() . '/outbox/' . $owner['nickname'], $page, $max_id, null, $apcontact['statuses_count']); + } + + public static function getInbox(int $uid, int $page = null, int $max_id = null) + { + $owner = User::getOwnerDataById($uid); + + $condition = ['gravity' => [Item::GRAVITY_PARENT, Item::GRAVITY_COMMENT], [Protocol::ACTIVITYPUB, Protocol::DFRN], 'uid' => $uid]; + + return self::getCollection($condition, DI::baseUrl() . '/inbox/' . $owner['nickname'], $page, $max_id, $uid, null); + } + + public static function getPublicInbox(int $uid, int $page = null, int $max_id = null) + { + $condition = ['gravity' => [Item::GRAVITY_PARENT, Item::GRAVITY_COMMENT], 'private' => Item::PUBLIC, + 'network' => [Protocol::ACTIVITYPUB, Protocol::DFRN], 'author-blocked' => false, 'author-hidden' => false]; + + return self::getCollection($condition, DI::baseUrl() . '/inbox', $page, $max_id, $uid, null); + } + + private static function getCollection(array $condition, string $path, int $page = null, int $max_id = null, int $uid = null, int $total_items = null) + { $data = ['@context' => ActivityPub::CONTEXT]; - $data['id'] = DI::baseUrl() . '/outbox/' . $owner['nickname']; + $data['id'] = $path; $data['type'] = 'OrderedCollection'; - $data['totalItems'] = $apcontact['statuses_count'] ?? 0; + + if (!is_null($total_items)) { + $data['totalItems'] = $total_items; + } if (!empty($page)) { $data['id'] .= '?' . http_build_query(['page' => $page]); } - if (empty($page)) { - $data['first'] = DI::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=1'; + if (empty($page) && empty($max_id)) { + $data['first'] = $path . '?page=1'; } else { $data['type'] = 'OrderedCollectionPage'; $list = []; - $items = Post::select(['id'], $condition, ['limit' => [($page - 1) * 20, 20], 'order' => ['created' => true]]); - while ($item = Post::fetch($items)) { - $activity = self::createActivityFromItem($item['id'], true); - $activity['type'] = $activity['type'] == 'Update' ? 'Create' : $activity['type']; + if (!empty($max_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` < ?", $max_id]); + } + + if (!empty($page)) { + $params = ['limit' => [($page - 1) * 20, 20], 'order' => ['uri-id' => true]]; + } else { + $params = ['limit' => 20, 'order' => ['uri-id' => true]]; + } - // Only list "Create" activity objects here, no reshares - if (!empty($activity['object']) && ($activity['type'] == 'Create')) { - $list[] = $activity['object']; + if (!is_null($uid)) { + $items = Post::selectForUser($uid, ['id', 'uri-id'], $condition, $params); + } else { + $items = Post::select(['id', 'uri-id'], $condition, $params); + } + + $last_id = 0; + while ($item = Post::fetch($items)) { + $activity = self::createActivityFromItem($item['id'], false, !is_null($uid)); + if (!empty($activity)) { + $list[] = $activity; + $last_id = $item['uri-id']; + continue; } } DBA::close($items); if (count($list) == 20) { - $data['next'] = DI::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=' . ($page + 1); + $data['next'] = $path . '?max_id=' . $last_id; } // Fix the cached total item count when it is lower than the real count - $total = (($page - 1) * 20) + $data['totalItems']; - if ($total > $data['totalItems']) { - $data['totalItems'] = $total; + if (!is_null($total_items)) { + $total = (($page - 1) * 20) + $data['totalItems']; + if ($total > $data['totalItems']) { + $data['totalItems'] = $total; + } } - $data['partOf'] = DI::baseUrl() . '/outbox/' . $owner['nickname']; + $data['partOf'] = $path; $data['orderedItems'] = $list; } @@ -382,11 +425,8 @@ class Transmitter while ($item = Post::fetch($items)) { $activity = self::createActivityFromItem($item['id'], true); - $activity['type'] = $activity['type'] == 'Update' ? 'Create' : $activity['type']; - - // Only list "Create" activity objects here, no reshares - if (!empty($activity['object']) && ($activity['type'] == 'Create')) { - $list[] = $activity['object']; + if (!empty($activity)) { + $list[] = $activity; } } DBA::close($items); @@ -413,7 +453,7 @@ class Transmitter * * @return array with service data */ - private static function getService(): array + public static function getService(): array { return [ 'type' => 'Service', @@ -1231,36 +1271,75 @@ class Transmitter * * @param integer $item_id * @param boolean $object_mode Is the activity item is used inside another object? + * @param boolean $api_mode "true" if used for the API * @return false|array * @throws \Exception */ - public static function createActivityFromItem(int $item_id, bool $object_mode = false) + public static function createActivityFromItem(int $item_id, bool $object_mode = false, $api_mode = false) { - Logger::info('Fetching activity', ['item' => $item_id]); - $item = Post::selectFirst(Item::DELIVER_FIELDLIST, ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]); + $condition = ['id' => $item_id]; + if (!$api_mode) { + $condition['parent-network'] = Protocol::NATIVE_SUPPORT; + } + Logger::info('Fetching activity', $condition); + $item = Post::selectFirst(Item::DELIVER_FIELDLIST, $condition); + if (!DBA::isResult($item)) { + return false; + } + return self::createActivityFromArray($item, $object_mode, $api_mode); + } + + /** + * Creates an activity array for a given URI-Id and uid + * + * @param integer $uri_id + * @param integer $uid + * @param boolean $object_mode Is the activity item is used inside another object? + * @param boolean $api_mode "true" if used for the API + * @return false|array + * @throws \Exception + */ + public static function createActivityFromUriId(int $uri_id, int $uid, bool $object_mode = false, $api_mode = false) + { + $condition = ['uri-id' => $uri_id, 'uid' => [0, $uid]]; + if (!$api_mode) { + $condition['parent-network'] = Protocol::NATIVE_SUPPORT; + } + Logger::info('Fetching activity', $condition); + $item = Post::selectFirst(Item::DELIVER_FIELDLIST, $condition, ['order' => ['uid' => true]]); if (!DBA::isResult($item)) { return false; } - if (empty($item['uri-id'])) { - Logger::warning('Item without uri-id', ['item' => $item]); - return false; - } + return self::createActivityFromArray($item, $object_mode, $api_mode); + } - if (!$item['deleted']) { + /** + * Creates an activity array for a given item id + * + * @param integer $item_id + * @param boolean $object_mode Is the activity item is used inside another object? + * @param boolean $api_mode "true" if used for the API + * @return false|array + * @throws \Exception + */ + private static function createActivityFromArray(array $item, bool $object_mode = false, $api_mode = false) + { + if (!$item['deleted'] && $item['network'] == Protocol::ACTIVITYPUB) { $data = Post\Activity::getByURIId($item['uri-id']); if (!$item['origin'] && !empty($data)) { - if ($object_mode) { - unset($data['@context']); - unset($data['signature']); + if (!$object_mode) { + Logger::info('Return stored conversation', ['item' => $item['id']]); + return $data; + } elseif (!empty($data['object'])) { + Logger::info('Return stored conversation object', ['item' => $item['id']]); + return $data['object']; } - Logger::info('Return stored conversation', ['item' => $item_id]); - return $data; } } - if (!$item['origin'] && empty($object)) { - Logger::debug('Post is not ours and is not stored', ['id' => $item_id, 'uri-id' => $item['uri-id']]); + if (!$api_mode && !$item['origin']) { + Logger::debug('Post is not ours and is not stored', ['id' => $item['id'], 'uri-id' => $item['uri-id']]); return false; } @@ -1301,7 +1380,7 @@ class Transmitter $data = array_merge($data, self::createPermissionBlockForItem($item, false)); if (in_array($data['type'], ['Create', 'Update', 'Delete'])) { - $data['object'] = $object ?? self::createNote($item); + $data['object'] = self::createNote($item); $data['published'] = DateTimeFormat::utcNow(DateTimeFormat::ATOM); } elseif ($data['type'] == 'Add') { $data = self::createAddTag($item, $data); @@ -1314,7 +1393,7 @@ class Transmitter } elseif ($data['type'] == 'Follow') { $data['object'] = $item['parent-uri']; } elseif ($data['type'] == 'Undo') { - $data['object'] = self::createActivityFromItem($item_id, true); + $data['object'] = self::createActivityFromItem($item['id'], true); } else { $data['diaspora:guid'] = $item['guid']; if (!empty($item['signed_text'])) { @@ -1329,12 +1408,11 @@ class Transmitter $uid = $item['uid']; } - $owner = User::getOwnerDataById($uid); + Logger::info('Fetched activity', ['item' => $item['id'], 'uid' => $uid]); - Logger::info('Fetched activity', ['item' => $item_id, 'uid' => $uid]); - - // We don't sign if we aren't the actor. This is important for relaying content especially for forums - if (!$object_mode && !empty($owner) && ($data['actor'] == $owner['url'])) { + // We only sign our own activities + if (!$api_mode && !$object_mode && $item['origin']) { + $owner = User::getOwnerDataById($uid); return LDSignature::sign($data, $owner); } else { return $data; diff --git a/static/routes.config.php b/static/routes.config.php index 2219ea4544..391a7dc5ee 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -156,6 +156,7 @@ $apiRoutes = [ '/show[.{extension:json|xml|rss|atom}]' => [Module\Api\Twitter\Users\Show::class, [R::GET ]], '/show/{id:\d+}[.{extension:json|xml|rss|atom}]' => [Module\Api\Twitter\Users\Show::class, [R::GET ]], ], + '/whoami' => [Module\ActivityPub\Whoami::class, [R::GET ]], ]; return [