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..cec90bea4c 100644 --- a/doc/API-Mastodon.md +++ b/doc/API-Mastodon.md @@ -93,7 +93,13 @@ 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/) +- [`PUT /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 +179,7 @@ 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/Notification.php b/src/Factory/Api/Mastodon/Notification.php index 2376e58cc9..82ef67d618 100644 --- a/src/Factory/Api/Mastodon/Notification.php +++ b/src/Factory/Api/Mastodon/Notification.php @@ -23,10 +23,7 @@ namespace Friendica\Factory\Api\Mastodon; use Friendica\BaseFactory; use Friendica\Database\Database; -use Friendica\Model\Contact; -use Friendica\Model\Post; -use Friendica\Model\Verb; -use Friendica\Protocol\Activity; +use Friendica\Model\Notification as ModelNotification; use Psr\Log\LoggerInterface; class Notification extends BaseFactory @@ -62,22 +59,8 @@ class Notification extends BaseFactory status = Someone you enabled notifications for has posted a status */ - if (($notification['vid'] == Verb::getID(Activity::FOLLOW)) && ($notification['type'] == Post\UserNotification::NOTIF_NONE)) { - $contact = Contact::getById($notification['actor-id'], ['pending']); - $type = $contact['pending'] ? $type = 'follow_request' : 'follow'; - } elseif (($notification['vid'] == Verb::getID(Activity::ANNOUNCE)) && - in_array($notification['type'], [Post\UserNotification::NOTIF_DIRECT_COMMENT, Post\UserNotification::NOTIF_DIRECT_THREAD_COMMENT])) { - $type = 'reblog'; - } elseif (in_array($notification['vid'], [Verb::getID(Activity::LIKE), Verb::getID(Activity::DISLIKE)]) && - in_array($notification['type'], [Post\UserNotification::NOTIF_DIRECT_COMMENT, Post\UserNotification::NOTIF_DIRECT_THREAD_COMMENT])) { - $type = 'favourite'; - } elseif ($notification['type'] == Post\UserNotification::NOTIF_SHARED) { - $type = 'status'; - } elseif (in_array($notification['type'], [Post\UserNotification::NOTIF_EXPLICIT_TAGGED, - Post\UserNotification::NOTIF_IMPLICIT_TAGGED, Post\UserNotification::NOTIF_DIRECT_COMMENT, - Post\UserNotification::NOTIF_DIRECT_THREAD_COMMENT, Post\UserNotification::NOTIF_THREAD_COMMENT])) { - $type = 'mention'; - } else { + $type = ModelNotification::getType($notification); + if (empty($type)) { return null; } diff --git a/src/Factory/Api/Mastodon/Subscription.php b/src/Factory/Api/Mastodon/Subscription.php new file mode 100644 index 0000000000..08dbcf0d17 --- /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/Notification.php b/src/Model/Notification.php index cf2c754631..39fd6a9289 100644 --- a/src/Model/Notification.php +++ b/src/Model/Notification.php @@ -25,6 +25,7 @@ use Friendica\BaseModel; use Friendica\Content\Text\BBCode; use Friendica\Database\Database; use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Protocol\Activity; use Psr\Log\LoggerInterface; /** @@ -123,4 +124,34 @@ class Notification extends BaseModel return $message; } + + /** + * Fetch the notification type for the given notification + * + * @param array $notification + * @return string + */ + public static function getType(array $notification): string + { + if (($notification['vid'] == Verb::getID(Activity::FOLLOW)) && ($notification['type'] == Post\UserNotification::NOTIF_NONE)) { + $contact = Contact::getById($notification['actor-id'], ['pending']); + $contact['pending'] ? $type = 'follow_request' : 'follow'; + } elseif (($notification['vid'] == Verb::getID(Activity::ANNOUNCE)) && + in_array($notification['type'], [Post\UserNotification::NOTIF_DIRECT_COMMENT, Post\UserNotification::NOTIF_DIRECT_THREAD_COMMENT])) { + $type = 'reblog'; + } elseif (in_array($notification['vid'], [Verb::getID(Activity::LIKE), Verb::getID(Activity::DISLIKE)]) && + in_array($notification['type'], [Post\UserNotification::NOTIF_DIRECT_COMMENT, Post\UserNotification::NOTIF_DIRECT_THREAD_COMMENT])) { + $type = 'favourite'; + } elseif ($notification['type'] == Post\UserNotification::NOTIF_SHARED) { + $type = 'status'; + } elseif (in_array($notification['type'], [Post\UserNotification::NOTIF_EXPLICIT_TAGGED, + Post\UserNotification::NOTIF_IMPLICIT_TAGGED, Post\UserNotification::NOTIF_DIRECT_COMMENT, + Post\UserNotification::NOTIF_DIRECT_THREAD_COMMENT, Post\UserNotification::NOTIF_THREAD_COMMENT])) { + $type = 'mention'; + } else { + return ''; + } + + return $type; + } } diff --git a/src/Model/Post/UserNotification.php b/src/Model/Post/UserNotification.php index 2c942d2cb9..f4bcb3e402 100644 --- a/src/Model/Post/UserNotification.php +++ b/src/Model/Post/UserNotification.php @@ -30,6 +30,7 @@ use Friendica\Database\DBStructure; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Post; +use Friendica\Model\Subscription; use Friendica\Util\Strings; use Friendica\Model\Tag; use Friendica\Protocol\Activity; @@ -297,7 +298,14 @@ class UserNotification $fields['target-uri-id'] = $item['uri-id']; } - return DBA::insert('notification', $fields, Database::INSERT_IGNORE); + $ret = DBA::insert('notification', $fields, Database::INSERT_IGNORE); + if ($ret) { + $id = DBA::lastInsertId(); + if (!empty($id)) { + Subscription::pushByNotificationId($id); + } + } + return $ret; } /** @@ -318,7 +326,14 @@ class UserNotification 'created' => DateTimeFormat::utcNow(), ]; - return DBA::insert('notification', $fields, Database::INSERT_IGNORE); + $ret = DBA::insert('notification', $fields, Database::INSERT_IGNORE); + if ($ret) { + $id = DBA::lastInsertId(); + if (!empty($id)) { + Subscription::pushByNotificationId($id); + } + } + return $ret; } /** diff --git a/src/Model/Subscription.php b/src/Model/Subscription.php new file mode 100644 index 0000000000..aaaa7db7af --- /dev/null +++ b/src/Model/Subscription.php @@ -0,0 +1,137 @@ +. + * + */ + + /** + * @see https://github.com/web-push-libs/web-push-php + * Possibly we should simply use this. + */ + +namespace Friendica\Model; + +use Friendica\Core\Logger; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Util\Crypto; + +class Subscription +{ + /** + * Select a subscription record exists + * + * @param int $applicationid + * @param int $uid + * @param array $fields + * + * @return bool Does it exist? + */ + public static function select(int $applicationid, int $uid, array $fields = []) + { + return DBA::selectFirst('subscription', $fields, ['application-id' => $applicationid, 'uid' => $uid]); + } + + /** + * Check if a subscription record exists + * + * @param int $applicationid + * @param int $uid + * + * @return bool Does it exist? + */ + public static function exists(int $applicationid, int $uid) + { + return DBA::exists('subscription', ['application-id' => $applicationid, 'uid' => $uid]); + } + + /** + * Update a subscription record + * + * @param int $applicationid + * @param int $uid + * @param array $fields subscription fields + * + * @return bool result of update + */ + public static function update(int $applicationid, int $uid, array $fields) + { + return DBA::update('subscription', $fields, ['application-id' => $applicationid, 'uid' => $uid]); + } + + /** + * Insert or replace a 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-public']; + } + + /** + * Prepare push notification + * + * @param int $nid + * @return void + */ + public static function pushByNotificationId(int $nid) + { + $notification = DBA::selectFirst('notification', [], ['id' => $nid]); + + $type = Notification::getType($notification); + if (empty($type)) { + return; + } + + $subscriptions = DBA::select('subscription', [], ['uid' => $notification['uid'], $type => true]); + while ($subscription = DBA::fetch($subscriptions)) { + Logger::info('Push notification', ['id' => $subscription['id'], 'uid' => $subscription['uid'], 'type' => $type]); + } + DBA::close($subscriptions); + } +} 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..411d5391f5 --- /dev/null +++ b/src/Module/Api/Mastodon/PushSubscription.php @@ -0,0 +1,129 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +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(); + + $request = self::getRequest([ + 'data' => [], + ]); + + $subscription = Subscription::select($application['id'], $uid, ['id']); + if (empty($subscription)) { + Logger::info('Subscription not found', ['application-id' => $application['id'], 'uid' => $uid]); + DI::mstdnError()->RecordNotFound(); + } + + $fields = [ + '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::update($application['id'], $uid, $fields); + + Logger::info('Subscription updated', ['result' => $ret, 'application-id' => $application['id'], 'uid' => $uid, 'fields' => $fields]); + + return DI::mstdnSubscription()->createForApplicationIdAndUserId($application['id'], $uid)->toArray(); + } + + public static function delete(array $parameters = []) + { + self::checkAllowedScope(self::SCOPE_PUSH); + $uid = self::getCurrentUserID(); + $application = self::getCurrentApplication(); + + $ret = Subscription::delete($application['id'], $uid); + + Logger::info('Subscription deleted', ['result' => $ret, 'application-id' => $application['id'], 'uid' => $uid]); + + System::jsonExit([]); + } + + public static function rawContent(array $parameters = []) + { + self::checkAllowedScope(self::SCOPE_PUSH); + $uid = self::getCurrentUserID(); + $application = self::getCurrentApplication(); + + if (!Subscription::exists($application['id'], $uid)) { + Logger::info('Subscription not found', ['application-id' => $application['id'], 'uid' => $uid]); + DI::mstdnError()->RecordNotFound(); + } + + Logger::info('Fetch subscription', ['application-id' => $application['id'], 'uid' => $uid]); + + 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..b3ae2d69b8 100644 --- a/src/Util/Crypto.php +++ b/src/Util/Crypto.php @@ -21,10 +21,12 @@ namespace Friendica\Util; +use Exception; 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 +152,50 @@ class Crypto return $response; } + /** + * Create a new elliptic curve key pair + * + * @return array with the elements "prvkey", "pubkey", "vapid-public" and "vapid-private" + */ + 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)) { + throw new Exception('Key creation failed'); + } + + $response = ['prvkey' => '', 'pubkey' => '']; + + // Get private key + openssl_pkey_export($result, $response['prvkey']); + + // Get public key + $pkey = openssl_pkey_get_details($result); + $response['pubkey'] = $pkey['key']; + + // Create VAPID keys + // @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-public'] = Base64UrlSafe::encode(hex2bin($hexString)); + + // @see https://github.com/web-push-libs/web-push-php/blob/256a18b2a2411469c94943725fb6eccb9681bd75/src/VAPID.php + $response['vapid-private'] = Base64UrlSafe::encode(hex2bin(str_pad(bin2hex($pkey['ec']['d']), 64, '0', STR_PAD_LEFT))); + + 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]],