]> git.mxchange.org Git - friendica.git/commitdiff
First implementation of ActivityPub C2S
authorMichael <heluecht@pirati.ca>
Sun, 29 Jan 2023 14:41:14 +0000 (14:41 +0000)
committerMichael <heluecht@pirati.ca>
Sun, 29 Jan 2023 14:41:14 +0000 (14:41 +0000)
src/Module/ActivityPub/Inbox.php
src/Module/ActivityPub/Outbox.php
src/Module/ActivityPub/Whoami.php [new file with mode: 0644]
src/Module/OAuth/Token.php
src/Object/Api/Mastodon/Token.php
src/Protocol/ActivityPub/Transmitter.php
static/routes.config.php

index 6ee690d2beff58a47c5ac00b8bcab00a4280bf5b..4470040a9018ad4279323135c4419bcb22463d5e 100644 (file)
 
 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();
 
index d5fdb5490df7f86054e00378b88f9051974f9548..a8a02172767d4464489e2dce0892fb63a6b00172 100644 (file)
 
 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 (file)
index 0000000..ab72b4e
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program 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.
+ *
+ * This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+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');
+       }
+}
index ecb65048d02303c7bb4c1bc7f349daddcf0d11f5..f97a05bb008ba02600d44f24235d248993bf9d50 100644 (file)
@@ -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());
        }
index 40d5f666cffd746786817dc178cfe1c9cdcfd362..c9d6caac0db2584dc8814f6ff9169a55da22fa3e 100644 (file)
@@ -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;
        }
 }
index bcff0b53b13d08cf43319643fa817d35bc0f0d46..985976633d1aefcbf1ce841c0dbee64a9ebb6140 100644 (file)
@@ -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);
+       }
 
-               if (empty($item['uri-id'])) {
-                       Logger::warning('Item without uri-id', ['item' => $item]);
+       /**
+        * 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 (!$item['deleted']) {
+               return self::createActivityFromArray($item, $object_mode, $api_mode);
+       }
+
+       /**
+        * 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;
index 2219ea454444d2bbe9a450032a7fb51b446a4969..391a7dc5eea8838a634b2a525bb0d8af1798020f 100644 (file)
@@ -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 [