From e28a4265c5b7228033b94e897d7643c1741e43db Mon Sep 17 00:00:00 2001
From: Michael <heluecht@pirati.ca>
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 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2021, 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\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 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2021, 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\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 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2021, 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\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 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2021, 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\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]],