From e28a4265c5b7228033b94e897d7643c1741e43db Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 15 Aug 2021 00:30:41 +0000 Subject: [PATCH] API: First steps to support subscriptions --- database.sql | 26 ++++- doc/API-Mastodon.md | 10 +- doc/database.md | 1 + doc/database/db_subscription.md | 42 ++++++++ src/DI.php | 8 ++ src/Factory/Api/Mastodon/Subscription.php | 41 ++++++++ src/Model/Subscription.php | 66 +++++++++++++ .../Api/Mastodon/Accounts/Followers.php | 2 - .../Api/Mastodon/Accounts/Following.php | 2 - src/Module/Api/Mastodon/Blocks.php | 2 - src/Module/Api/Mastodon/Favourited.php | 2 - src/Module/Api/Mastodon/Lists/Accounts.php | 2 - src/Module/Api/Mastodon/Mutes.php | 2 - src/Module/Api/Mastodon/PushSubscription.php | 97 +++++++++++++++++++ src/Object/Api/Mastodon/Subscription.php | 63 ++++++++++++ src/Util/Crypto.php | 43 ++++++++ static/dbstructure.config.php | 25 ++++- static/routes.config.php | 2 +- 18 files changed, 416 insertions(+), 20 deletions(-) create mode 100644 doc/database/db_subscription.md create mode 100644 src/Factory/Api/Mastodon/Subscription.php create mode 100644 src/Model/Subscription.php create mode 100644 src/Module/Api/Mastodon/PushSubscription.php create mode 100644 src/Object/Api/Mastodon/Subscription.php diff --git a/database.sql b/database.sql index 4d444cbe50..eff8f83faf 100644 --- a/database.sql +++ b/database.sql @@ -1,6 +1,6 @@ -- ------------------------------------------ -- Friendica 2021.09-dev (Siberian Iris) --- DB_UPDATE_VERSION 1433 +-- DB_UPDATE_VERSION 1434 -- ------------------------------------------ @@ -1472,6 +1472,30 @@ CREATE TABLE IF NOT EXISTS `storage` ( PRIMARY KEY(`id`) ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Data stored by Database storage backend'; +-- +-- TABLE subscription +-- +CREATE TABLE IF NOT EXISTS `subscription` ( + `id` int unsigned NOT NULL auto_increment COMMENT 'Auto incremented image data id', + `application-id` int unsigned NOT NULL COMMENT '', + `uid` mediumint unsigned NOT NULL COMMENT 'Owner User id', + `endpoint` varchar(511) COMMENT 'Endpoint URL', + `pubkey` varchar(127) COMMENT 'User agent public key', + `secret` varchar(32) COMMENT 'Auth secret', + `follow` boolean COMMENT '', + `favourite` boolean COMMENT '', + `reblog` boolean COMMENT '', + `mention` boolean COMMENT '', + `poll` boolean COMMENT '', + `follow_request` boolean COMMENT '', + `status` boolean COMMENT '', + PRIMARY KEY(`id`), + UNIQUE INDEX `application-id_uid` (`application-id`,`uid`), + INDEX `uid_application-id` (`uid`,`application-id`), + FOREIGN KEY (`application-id`) REFERENCES `application` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Push Subscription for the API'; + -- -- TABLE userd -- diff --git a/doc/API-Mastodon.md b/doc/API-Mastodon.md index 006b8da473..dd82a5144d 100644 --- a/doc/API-Mastodon.md +++ b/doc/API-Mastodon.md @@ -93,7 +93,12 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/en - [`POST /api/v1/notifications/clear`](https://docs.joinmastodon.org/methods/notifications/) - [`POST /api/v1/notifications/:id/dismiss`](https://docs.joinmastodon.org/methods/notifications/) - [`GET /api/v1/preferences`](https://docs.joinmastodon.org/methods/accounts/preferences/) +- [`DELETE /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) +- [`GET /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) +- [`PUSH /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) - [`GET /api/v1/scheduled_statuses`](https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/) +- [`DELETE /api/v1/scheduled_statuses/:id`](https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/) +- [`GET /api/v1/scheduled_statuses/:id`](https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/) - [`GET /api/v1/search`](https://docs.joinmastodon.org/methods/search/) - [`POST /api/v1/statuses`](https://docs.joinmastodon.org/methods/statuses/) - [`GET /api/v1/statuses/:id`](https://docs.joinmastodon.org/methods/statuses/) @@ -173,13 +178,8 @@ They refer to features or data that don't exist in Friendica yet. - [`POST /api/v1/markers`](https://docs.joinmastodon.org/methods/timelines/markers/) - [`GET /api/v1/polls/:id`](https://docs.joinmastodon.org/methods/statuses/polls/) - [`POST /api/v1/polls/:id/votes`](https://docs.joinmastodon.org/methods/statuses/polls/) -- [`DELETE /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) -- [`GET /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) -- [`PUSH /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) - [`PUT /api/v1/push/subscription`](https://docs.joinmastodon.org/methods/notifications/push/) - [`POST /api/v1/reports`](https://docs.joinmastodon.org/methods/accounts/reports/) -- [`GET /api/v1/scheduled_statuses/:id`](https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/) - [`PUT /api/v1/scheduled_statuses/:id`](https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/) -- [`DELETE /api/v1/scheduled_statuses/:id`](https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/) - [`GET /api/v1/streaming`](https://docs.joinmastodon.org/methods/timelines/streaming/) - [`DELETE /api/v1/suggestions/:id`](https://docs.joinmastodon.org/methods/accounts/suggestions/) diff --git a/doc/database.md b/doc/database.md index 194f30eb6e..bc690c6cd2 100644 --- a/doc/database.md +++ b/doc/database.md @@ -65,6 +65,7 @@ Database Tables | [search](help/database/db_search) | | | [session](help/database/db_session) | web session storage | | [storage](help/database/db_storage) | Data stored by Database storage backend | +| [subscription](help/database/db_subscription) | Push Subscription for the API | | [tag](help/database/db_tag) | tags and mentions | | [user](help/database/db_user) | The local users | | [user-contact](help/database/db_user-contact) | User specific public contact data | diff --git a/doc/database/db_subscription.md b/doc/database/db_subscription.md new file mode 100644 index 0000000000..8bcf4f2379 --- /dev/null +++ b/doc/database/db_subscription.md @@ -0,0 +1,42 @@ +Table subscription +=========== + +Push Subscription for the API + +Fields +------ + +| Field | Description | Type | Null | Key | Default | Extra | +| -------------- | ------------------------------ | ------------------ | ---- | --- | ------- | -------------- | +| id | Auto incremented image data id | int unsigned | NO | PRI | NULL | auto_increment | +| application-id | | int unsigned | NO | | NULL | | +| uid | Owner User id | mediumint unsigned | NO | | NULL | | +| endpoint | Endpoint URL | varchar(511) | YES | | NULL | | +| pubkey | User agent public key | varchar(127) | YES | | NULL | | +| secret | Auth secret | varchar(32) | YES | | NULL | | +| follow | | boolean | YES | | NULL | | +| favourite | | boolean | YES | | NULL | | +| reblog | | boolean | YES | | NULL | | +| mention | | boolean | YES | | NULL | | +| poll | | boolean | YES | | NULL | | +| follow_request | | boolean | YES | | NULL | | +| status | | boolean | YES | | NULL | | + +Indexes +------------ + +| Name | Fields | +| ------------------ | --------------------------- | +| PRIMARY | id | +| application-id_uid | UNIQUE, application-id, uid | +| uid_application-id | uid, application-id | + +Foreign Keys +------------ + +| Field | Target Table | Target Field | +|-------|--------------|--------------| +| application-id | [application](help/database/db_application) | id | +| uid | [user](help/database/db_user) | uid | + +Return to [database documentation](help/database) diff --git a/src/DI.php b/src/DI.php index 0bfaacf89a..02620ea11e 100644 --- a/src/DI.php +++ b/src/DI.php @@ -319,6 +319,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\ScheduledStatus::class); } + /** + * @return Factory\Api\Mastodon\Subscription + */ + public static function mstdnSubscription() + { + return self::$dice->create(Factory\Api\Mastodon\Subscription::class); + } + /** * @return Factory\Api\Mastodon\ListEntity */ diff --git a/src/Factory/Api/Mastodon/Subscription.php b/src/Factory/Api/Mastodon/Subscription.php new file mode 100644 index 0000000000..3b8f343e17 --- /dev/null +++ b/src/Factory/Api/Mastodon/Subscription.php @@ -0,0 +1,41 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\BaseFactory; +use Friendica\Database\DBA; +use Friendica\Model\Subscription as ModelSubscription; + +class Subscription extends BaseFactory +{ + /** + * @param int $applicationid Application Id + * @param int $uid Item user + * + * @return \Friendica\Object\Api\Mastodon\Status + */ + public function createForApplicationIdAndUserId(int $applicationid, int $uid): \Friendica\Object\Api\Mastodon\Subscription + { + $subscription = DBA::selectFirst('subscription', ['application-id' => $applicationid, 'uid' => $uid]); + return new \Friendica\Object\Api\Mastodon\Subscription($subscription, ModelSubscription::getVapidKey()); + } +} diff --git a/src/Model/Subscription.php b/src/Model/Subscription.php new file mode 100644 index 0000000000..173d5a9ebf --- /dev/null +++ b/src/Model/Subscription.php @@ -0,0 +1,66 @@ +. + * + */ + +namespace Friendica\Model; + +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Util\Crypto; + +class Subscription +{ + /** + * Insert an Subscription record + * + * @param array $fields subscription fields + * + * @return bool result of replace + */ + public static function replace(array $fields) + { + return DBA::replace('subscription', $fields); + } + + /** + * Delete a subscription record + * @param int $applicationid + * @param int $uid + * @return bool + */ + public static function delete(int $applicationid, int $uid) + { + return DBA::delete('subscription', ['application-id' => $applicationid, 'uid' => $uid]); + } + + /** + * Fetch a VAPID key + * @return string + */ + public static function getVapidKey():string + { + $keypair = DI::config()->get('system', 'ec_keypair'); + if (empty($keypair)) { + $keypair = Crypto::newECKeypair(); + DI::config()->set('system', 'ec_keypair', $keypair); + } + return $keypair['vapid']; + } +} diff --git a/src/Module/Api/Mastodon/Accounts/Followers.php b/src/Module/Api/Mastodon/Accounts/Followers.php index c495d6ca1e..e1f864acbe 100644 --- a/src/Module/Api/Mastodon/Accounts/Followers.php +++ b/src/Module/Api/Mastodon/Accounts/Followers.php @@ -49,8 +49,6 @@ class Followers extends BaseApi DI::mstdnError()->RecordNotFound(); } - // @todo provide HTTP link header - $request = self::getRequest([ 'max_id' => 0, // Return results older than this id 'since_id' => 0, // Return results newer than this id diff --git a/src/Module/Api/Mastodon/Accounts/Following.php b/src/Module/Api/Mastodon/Accounts/Following.php index b6a8f7f75c..e2b963e0c3 100644 --- a/src/Module/Api/Mastodon/Accounts/Following.php +++ b/src/Module/Api/Mastodon/Accounts/Following.php @@ -49,8 +49,6 @@ class Following extends BaseApi DI::mstdnError()->RecordNotFound(); } - // @todo provide HTTP link header - $request = self::getRequest([ 'max_id' => 0, // Return results older than this id 'since_id' => 0, // Return results newer than this id diff --git a/src/Module/Api/Mastodon/Blocks.php b/src/Module/Api/Mastodon/Blocks.php index 37ab61af52..305914c1aa 100644 --- a/src/Module/Api/Mastodon/Blocks.php +++ b/src/Module/Api/Mastodon/Blocks.php @@ -49,8 +49,6 @@ class Blocks extends BaseApi DI::mstdnError()->RecordNotFound(); } - // @todo provide HTTP link header - $request = self::getRequest([ 'max_id' => 0, // Return results older than this id 'since_id' => 0, // Return results newer than this id diff --git a/src/Module/Api/Mastodon/Favourited.php b/src/Module/Api/Mastodon/Favourited.php index 239257e833..31e760d3b8 100644 --- a/src/Module/Api/Mastodon/Favourited.php +++ b/src/Module/Api/Mastodon/Favourited.php @@ -43,8 +43,6 @@ class Favourited extends BaseApi self::checkAllowedScope(self::SCOPE_READ); $uid = self::getCurrentUserID(); - // @todo provide HTTP link header - $request = self::getRequest([ 'limit' => 20, // Maximum number of results to return. Defaults to 20. 'min_id' => 0, // Return results immediately newer than id diff --git a/src/Module/Api/Mastodon/Lists/Accounts.php b/src/Module/Api/Mastodon/Lists/Accounts.php index 013a9f4aa9..6e11235630 100644 --- a/src/Module/Api/Mastodon/Lists/Accounts.php +++ b/src/Module/Api/Mastodon/Lists/Accounts.php @@ -62,8 +62,6 @@ class Accounts extends BaseApi DI::mstdnError()->RecordNotFound(); } - // @todo provide HTTP link header - $request = self::getRequest([ 'max_id' => 0, // Return results older than this id 'since_id' => 0, // Return results newer than this id diff --git a/src/Module/Api/Mastodon/Mutes.php b/src/Module/Api/Mastodon/Mutes.php index ea890d9fdb..7939da114b 100644 --- a/src/Module/Api/Mastodon/Mutes.php +++ b/src/Module/Api/Mastodon/Mutes.php @@ -49,8 +49,6 @@ class Mutes extends BaseApi DI::mstdnError()->RecordNotFound(); } - // @todo provide HTTP link header - $request = self::getRequest([ 'max_id' => 0, // Return results older than this id 'since_id' => 0, // Return results newer than this id diff --git a/src/Module/Api/Mastodon/PushSubscription.php b/src/Module/Api/Mastodon/PushSubscription.php new file mode 100644 index 0000000000..68b1b46b13 --- /dev/null +++ b/src/Module/Api/Mastodon/PushSubscription.php @@ -0,0 +1,97 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\App\Router; +use Friendica\Core\Logger; +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\Subscription; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/notifications/push/ + */ +class PushSubscription extends BaseApi +{ + public static function post(array $parameters = []) + { + self::checkAllowedScope(self::SCOPE_PUSH); + $uid = self::getCurrentUserID(); + $application = self::getCurrentApplication(); + + $request = self::getRequest([ + 'subscription' => [], + 'data' => [], + ]); + + $subscription = [ + 'application-id' => $application['id'], + 'uid' => $uid, + 'endpoint' => $request['subscription']['endpoint'] ?? '', + 'pubkey' => $request['subscription']['keys']['p256dh'] ?? '', + 'secret' => $request['subscription']['keys']['auth'] ?? '', + 'follow' => $request['data']['alerts']['follow'] ?? false, + 'favourite' => $request['data']['alerts']['favourite'] ?? false, + 'reblog' => $request['data']['alerts']['reblog'] ?? false, + 'mention' => $request['data']['alerts']['mention'] ?? false, + 'poll' => $request['data']['alerts']['poll'] ?? false, + 'follow_request' => $request['data']['alerts']['follow_request'] ?? false, + 'status' => $request['data']['alerts']['status'] ?? false, + ]; + + $ret = Subscription::replace($subscription); + + Logger::info('Subscription stored', ['ret' => $ret, 'subscription' => $subscription]); + + return DI::mstdnSubscription()->createForApplicationIdAndUserId($application['id'], $uid)->toArray(); + } + + public static function put(array $parameters = []) + { + self::checkAllowedScope(self::SCOPE_PUSH); + $uid = self::getCurrentUserID(); + $application = self::getCurrentApplication(); + + self::unsupported(Router::PUT); + } + + public static function delete(array $parameters = []) + { + self::checkAllowedScope(self::SCOPE_PUSH); + $uid = self::getCurrentUserID(); + $application = self::getCurrentApplication(); + + Subscription::delete($application['id'], $uid); + + System::jsonExit([]); + } + + public static function rawContent(array $parameters = []) + { + self::checkAllowedScope(self::SCOPE_PUSH); + $uid = self::getCurrentUserID(); + $application = self::getCurrentApplication(); + + return DI::mstdnSubscription()->createForApplicationIdAndUserId($application['id'], $uid)->toArray(); + } +} diff --git a/src/Object/Api/Mastodon/Subscription.php b/src/Object/Api/Mastodon/Subscription.php new file mode 100644 index 0000000000..dcd0a6982f --- /dev/null +++ b/src/Object/Api/Mastodon/Subscription.php @@ -0,0 +1,63 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseDataTransferObject; + +/** + * Class Subscription + * + * @see https://docs.joinmastodon.org/entities/pushsubscription + */ +class Subscription extends BaseDataTransferObject +{ + /** @var string */ + protected $id; + /** @var string|null (URL)*/ + protected $endpoint; + /** @var array */ + protected $alerts; + /** @var string */ + protected $server_key; + + /** + * Creates a subscription record from an item record. + * + * @param array $subscription + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(array $subscription, string $vapid) + { + $this->id = (string)$subscription['id']; + $this->endpoint = $subscription['endpoint']; + $this->alerts = [ + 'follow' => $subscription['follow'], + 'favourite' => $subscription['favourite'], + 'reblog' => $subscription['reblog'], + 'mention' => $subscription['mention'], + 'mention' => $subscription['mention'], + 'poll' => $subscription['poll'], + ]; + + $this->server_key = $vapid; + } +} diff --git a/src/Util/Crypto.php b/src/Util/Crypto.php index 0187079e39..1ff0e19c3c 100644 --- a/src/Util/Crypto.php +++ b/src/Util/Crypto.php @@ -25,6 +25,7 @@ use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\System; use Friendica\DI; +use ParagonIE\ConstantTime\Base64UrlSafe; use phpseclib\Crypt\RSA; use phpseclib\Math\BigInteger; @@ -150,6 +151,48 @@ class Crypto return $response; } + /** + * Create a new elliptic curve key pair + * + * @return array with the elements "prvkey", "vapid" and "pubkey" + */ + public static function newECKeypair() + { + $openssl_options = [ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC + ]; + + $conf = DI::config()->get('system', 'openssl_conf_file'); + if ($conf) { + $openssl_options['config'] = $conf; + } + $result = openssl_pkey_new($openssl_options); + + if (empty($result)) { + Logger::notice('new_keypair: failed'); + return []; + } + + $response = ['prvkey' => '', 'pubkey' => '', 'vapid' => '']; + + // Get private key + openssl_pkey_export($result, $response['prvkey']); + + // Get public key + $pkey = openssl_pkey_get_details($result); + $response['pubkey'] = $pkey['key']; + + // Create VAPID key + // @see https://github.com/web-push-libs/web-push-php/blob/256a18b2a2411469c94943725fb6eccb9681bd75/src/Utils.php#L60-L62 + $hexString = '04'; + $hexString .= str_pad(bin2hex($pkey['ec']['x']), 64, '0', STR_PAD_LEFT); + $hexString .= str_pad(bin2hex($pkey['ec']['y']), 64, '0', STR_PAD_LEFT); + $response['vapid'] = Base64UrlSafe::encode(hex2bin($hexString)); + + return $response; + } + /** * Encrypt a string with 'aes-256-cbc' cipher method. * diff --git a/static/dbstructure.config.php b/static/dbstructure.config.php index f820401a19..6f455d14e9 100644 --- a/static/dbstructure.config.php +++ b/static/dbstructure.config.php @@ -55,7 +55,7 @@ use Friendica\Database\DBA; if (!defined('DB_UPDATE_VERSION')) { - define('DB_UPDATE_VERSION', 1433); + define('DB_UPDATE_VERSION', 1434); } return [ @@ -1492,6 +1492,29 @@ return [ "PRIMARY" => ["id"] ] ], + "subscription" => [ + "comment" => "Push Subscription for the API", + "fields" => [ + "id" => ["type" => "int unsigned", "not null" => "1", "extra" => "auto_increment", "primary" => "1", "comment" => "Auto incremented image data id"], + "application-id" => ["type" => "int unsigned", "not null" => "1", "foreign" => ["application" => "id"], "comment" => ""], + "uid" => ["type" => "mediumint unsigned", "not null" => "1", "foreign" => ["user" => "uid"], "comment" => "Owner User id"], + "endpoint" => ["type" => "varchar(511)", "comment" => "Endpoint URL"], + "pubkey" => ["type" => "varchar(127)", "comment" => "User agent public key"], + "secret" => ["type" => "varchar(32)", "comment" => "Auth secret"], + "follow" => ["type" => "boolean", "comment" => ""], + "favourite" => ["type" => "boolean", "comment" => ""], + "reblog" => ["type" => "boolean", "comment" => ""], + "mention" => ["type" => "boolean", "comment" => ""], + "poll" => ["type" => "boolean", "comment" => ""], + "follow_request" => ["type" => "boolean", "comment" => ""], + "status" => ["type" => "boolean", "comment" => ""], + ], + "indexes" => [ + "PRIMARY" => ["id"], + "application-id_uid" => ["UNIQUE", "application-id", "uid"], + "uid_application-id" => ["uid", "application-id"], + ] + ], "userd" => [ "comment" => "Deleted usernames", "fields" => [ diff --git a/static/routes.config.php b/static/routes.config.php index ac4ec53917..f47051e321 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -126,7 +126,7 @@ return [ '/polls/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not supported '/polls/{id:\d+}/votes' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // not supported '/preferences' => [Module\Api\Mastodon\Preferences::class, [R::GET ]], - '/push/subscription' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::POST, R::PUT, R::DELETE]], // not supported + '/push/subscription' => [Module\Api\Mastodon\PushSubscription::class, [R::GET, R::POST, R::PUT, R::DELETE]], '/reports' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // not supported '/scheduled_statuses' => [Module\Api\Mastodon\ScheduledStatuses::class, [R::GET ]], '/scheduled_statuses/{id:\d+}' => [Module\Api\Mastodon\ScheduledStatuses::class, [R::GET, R::PUT, R::DELETE]],