From 39046061db5efa24d482508a8dd4b59b55e47746 Mon Sep 17 00:00:00 2001 From: Sven Sauleau Date: Wed, 15 Feb 2023 11:39:15 +0000 Subject: [PATCH] subscription: remove hardcoded and switch to Int --- backend/src/mastodon/subscription.ts | 68 +++++--- backend/test/mastodon.spec.ts | 123 +------------- backend/test/mastodon/subscription.spec.ts | 171 ++++++++++++++++++++ backend/test/utils.ts | 10 ++ functions/api/v1/push/subscription.ts | 44 +++-- migrations/0007_change_subscriptions_id.sql | 28 ++++ 6 files changed, 274 insertions(+), 170 deletions(-) create mode 100644 backend/test/mastodon/subscription.spec.ts create mode 100644 migrations/0007_change_subscriptions_id.sql diff --git a/backend/src/mastodon/subscription.ts b/backend/src/mastodon/subscription.ts index 8246c04..4117b0f 100644 --- a/backend/src/mastodon/subscription.ts +++ b/backend/src/mastodon/subscription.ts @@ -31,8 +31,23 @@ export interface CreateRequest { } export type Subscription = { - id: string + // While the spec says to use a string as id (https://docs.joinmastodon.org/entities/WebPushSubscription/#id), Mastodon's android app decided to violate that (https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java#LL11). + id: number + gateway: PushSubscription + alerts: { + mention: boolean + status: boolean + reblog: boolean + follow: boolean + follow_request: boolean + favourite: boolean + poll: boolean + update: boolean + admin_sign_up: boolean + admin_report: boolean + } + policy: string } export async function createSubscription( @@ -41,39 +56,33 @@ export async function createSubscription( client: Client, req: CreateRequest ): Promise { - const id = crypto.randomUUID() - const query = ` - INSERT INTO subscriptions (id, actor_id, client_id, endpoint, key_p256dh, key_auth, alert_mention, alert_status, alert_reblog, alert_follow, alert_follow_request, alert_favourite, alert_poll, alert_update, alert_admin_sign_up, alert_admin_report, policy) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO subscriptions (actor_id, client_id, endpoint, key_p256dh, key_auth, alert_mention, alert_status, alert_reblog, alert_follow, alert_follow_request, alert_favourite, alert_poll, alert_update, alert_admin_sign_up, alert_admin_report, policy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING * ` - const out = await db + const row = await db .prepare(query) .bind( - id, actor.id.toString(), client.id, req.subscription.endpoint, req.subscription.keys.p256dh, req.subscription.keys.auth, - req.data.alerts.mention ? 1 : 0, - req.data.alerts.status ? 1 : 0, - req.data.alerts.reblog ? 1 : 0, - req.data.alerts.follow ? 1 : 0, - req.data.alerts.follow_request ? 1 : 0, - req.data.alerts.favourite ? 1 : 0, - req.data.alerts.poll ? 1 : 0, - req.data.alerts.update ? 1 : 0, - req.data.alerts.admin_sign_up ? 1 : 0, - req.data.alerts.admin_report ? 1 : 0, + req.data.alerts.mention === false ? 0 : 1, + req.data.alerts.status === false ? 0 : 1, + req.data.alerts.reblog === false ? 0 : 1, + req.data.alerts.follow === false ? 0 : 1, + req.data.alerts.follow_request === false ? 0 : 1, + req.data.alerts.favourite === false ? 0 : 1, + req.data.alerts.poll === false ? 0 : 1, + req.data.alerts.update === false ? 0 : 1, + req.data.alerts.admin_sign_up === false ? 0 : 1, + req.data.alerts.admin_report === false ? 0 : 1, req.data.policy ) - .run() - if (!out.success) { - throw new Error('SQL error: ' + out.error) - } - - return { id, gateway: req.subscription } + .first() + return subscriptionFromRow(row) } export async function getSubscription(db: D1Database, actor: Actor, client: Client): Promise { @@ -121,6 +130,19 @@ function subscriptionFromRow(row: any): Subscription { auth: row.key_auth, }, }, + alerts: { + mention: row.alert_mention === 1, + status: row.alert_status === 1, + reblog: row.alert_reblog === 1, + follow: row.alert_follow === 1, + follow_request: row.alert_follow_request === 1, + favourite: row.alert_favourite === 1, + poll: row.alert_poll === 1, + update: row.alert_update === 1, + admin_sign_up: row.alert_admin_sign_up === 1, + admin_report: row.alert_admin_report === 1, + }, + policy: row.policy, } } diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index 6118d9b..2f1d575 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -1,5 +1,4 @@ import { strict as assert } from 'node:assert/strict' -import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import type { Env } from 'wildebeest/backend/src/types/env' import * as v1_instance from 'wildebeest/functions/api/v1/instance' import * as v2_instance from 'wildebeest/functions/api/v2/instance' @@ -7,24 +6,11 @@ import * as apps from 'wildebeest/functions/api/v1/apps' import * as custom_emojis from 'wildebeest/functions/api/v1/custom_emojis' import * as mutes from 'wildebeest/functions/api/v1/mutes' import * as blocks from 'wildebeest/functions/api/v1/blocks' -import { makeDB, assertCORS, assertJSON, assertCache, createTestClient } from './utils' -import { createPerson } from 'wildebeest/backend/src/activitypub/actors' -import { createSubscription } from '../src/mastodon/subscription' -import * as subscription from 'wildebeest/functions/api/v1/push/subscription' +import { makeDB, assertCORS, assertJSON, assertCache, generateVAPIDKeys } from './utils' import { enrichStatus } from 'wildebeest/backend/src/mastodon/microformats' -const userKEK = 'test_kek' const domain = 'cloudflare.com' -async function generateVAPIDKeys(): Promise { - const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ - 'sign', - 'verify', - ])) as CryptoKeyPair - const jwk = (await crypto.subtle.exportKey('jwk', keyPair.privateKey)) as JWK - return jwk -} - describe('Mastodon APIs', () => { describe('instance', () => { type Data = { @@ -158,113 +144,6 @@ describe('Mastodon APIs', () => { }) }) - describe('subscriptions', () => { - test('get non existing subscription', async () => { - const db = await makeDB() - const req = new Request('https://example.com') - const client = await createTestClient(db) - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const res = await subscription.handleGetRequest(db, req, connectedActor, client.id) - assert.equal(res.status, 404) - }) - - test('get existing subscription', async () => { - const db = await makeDB() - const req = new Request('https://example.com') - const client = await createTestClient(db) - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const data: any = { - subscription: { - endpoint: 'https://endpoint.com', - keys: { - p256dh: 'p256dh', - auth: 'auth', - }, - }, - data: { - alerts: {}, - policy: 'all', - }, - } - await createSubscription(db, connectedActor, client, data) - - const res = await subscription.handleGetRequest(db, req, connectedActor, client.id) - assert.equal(res.status, 200) - - const out = await res.json() - assert.equal(typeof out.id, 'number') - assert.equal(out.endpoint, data.subscription.endpoint) - }) - - test('create subscription', async () => { - const db = await makeDB() - const client = await createTestClient(db) - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const vapidKeys = await generateVAPIDKeys() - - const data: any = { - subscription: { - endpoint: 'https://endpoint.com', - keys: { - p256dh: 'p256dh', - auth: 'auth', - }, - }, - data: { - alerts: {}, - policy: 'all', - }, - } - const req = new Request('https://example.com', { - method: 'POST', - body: JSON.stringify(data), - }) - - const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) - assert.equal(res.status, 200) - - const row: any = await db.prepare('SELECT * FROM subscriptions').first() - assert.equal(row.actor_id, connectedActor.id.toString()) - assert.equal(row.client_id, client.id) - assert.equal(row.endpoint, data.subscription.endpoint) - }) - - test('create subscriptions only creates one', async () => { - const db = await makeDB() - const client = await createTestClient(db) - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const vapidKeys = await generateVAPIDKeys() - - const data: any = { - subscription: { - endpoint: 'https://endpoint.com', - keys: { - p256dh: 'p256dh', - auth: 'auth', - }, - }, - data: { - alerts: {}, - policy: 'all', - }, - } - await createSubscription(db, connectedActor, client, data) - - const req = new Request('https://example.com', { - method: 'POST', - body: JSON.stringify(data), - }) - - const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) - assert.equal(res.status, 200) - - const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>() - assert.equal(count, 1) - }) - }) - test('mutes returns an empty array', async () => { const res = await mutes.onRequest() assert.equal(res.status, 200) diff --git a/backend/test/mastodon/subscription.spec.ts b/backend/test/mastodon/subscription.spec.ts new file mode 100644 index 0000000..4c8f12a --- /dev/null +++ b/backend/test/mastodon/subscription.spec.ts @@ -0,0 +1,171 @@ +import { createSubscription } from '../../src/mastodon/subscription' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { strict as assert } from 'node:assert/strict' +import { makeDB, createTestClient, generateVAPIDKeys } from '../utils' +import * as subscription from 'wildebeest/functions/api/v1/push/subscription' + +const userKEK = 'test_kek21' +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('subscriptions', () => { + test('get non existing subscription', async () => { + const db = await makeDB() + const vapidKeys = await generateVAPIDKeys() + const req = new Request('https://example.com') + const client = await createTestClient(db) + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const res = await subscription.handleGetRequest(db, req, connectedActor, client.id, vapidKeys) + assert.equal(res.status, 404) + }) + + test('get existing subscription', async () => { + const db = await makeDB() + const vapidKeys = await generateVAPIDKeys() + const req = new Request('https://example.com') + const client = await createTestClient(db) + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: { + follow: false, + favourite: true, + reblog: false, + poll: true, + }, + policy: 'followed', + }, + } + await createSubscription(db, connectedActor, client, data) + + const res = await subscription.handleGetRequest(db, req, connectedActor, client.id, vapidKeys) + assert.equal(res.status, 200) + + const out = await res.json() + assert.equal(typeof out.id, 'number') + assert.equal(out.endpoint, data.subscription.endpoint) + assert.equal(out.alerts.follow, false) + assert.equal(out.alerts.favourite, true) + assert.equal(out.alerts.reblog, false) + assert.equal(out.alerts.poll, true) + assert.equal(out.policy, 'followed') + }) + + test('create subscription', async () => { + const db = await makeDB() + const vapidKeys = await generateVAPIDKeys() + const client = await createTestClient(db) + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: { + poll: false, + status: true, + }, + policy: 'all', + }, + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(data), + }) + + const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) + assert.equal(res.status, 200) + + const out = await res.json() + assert.equal(out.alerts.mention, true) + assert.equal(out.alerts.status, true) // default to true + assert.equal(out.alerts.poll, false) + + const row: any = await db.prepare('SELECT * FROM subscriptions').first() + assert.equal(row.actor_id, connectedActor.id.toString()) + assert.equal(row.client_id, client.id) + assert.equal(row.endpoint, data.subscription.endpoint) + assert.equal(row.alert_poll, 0) + assert.equal(row.alert_mention, 1) + assert.equal(row.alert_status, 1) // default to true + }) + + test('create subscriptions only creates one', async () => { + const db = await makeDB() + const vapidKeys = await generateVAPIDKeys() + const client = await createTestClient(db) + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: {}, + policy: 'all', + }, + } + await createSubscription(db, connectedActor, client, data) + + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(data), + }) + + const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) + assert.equal(res.status, 200) + + const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>() + assert.equal(count, 1) + }) + + test('subscriptions auto increment', async () => { + const db = await makeDB() + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: {}, + policy: 'all', + }, + } + + const client1 = await createTestClient(db) + const sub1 = await createSubscription(db, connectedActor, client1, data) + assert.equal(sub1.id, 1) + + const client2 = await createTestClient(db) + const sub2 = await createSubscription(db, connectedActor, client2, data) + assert.equal(sub2.id, 2) + + const client3 = await createTestClient(db) + const sub3 = await createSubscription(db, connectedActor, client3, data) + assert.equal(sub3.id, 3) + }) + }) +}) diff --git a/backend/test/utils.ts b/backend/test/utils.ts index c033a24..864ceb2 100644 --- a/backend/test/utils.ts +++ b/backend/test/utils.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'node:assert/strict' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import type { Cache } from 'wildebeest/backend/src/cache' import type { Queue } from 'wildebeest/backend/src/types/queue' import { createClient } from 'wildebeest/backend/src/mastodon/client' @@ -117,3 +118,12 @@ export function isUUID(v: string): boolean { } return true } + +export async function generateVAPIDKeys(): Promise { + const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair + const jwk = (await crypto.subtle.exportKey('jwk', keyPair.privateKey)) as JWK + return jwk +} diff --git a/functions/api/v1/push/subscription.ts b/functions/api/v1/push/subscription.ts index fe0ea8a..2510d60 100644 --- a/functions/api/v1/push/subscription.ts +++ b/functions/api/v1/push/subscription.ts @@ -11,7 +11,7 @@ import * as errors from 'wildebeest/backend/src/errors' import { VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription' export const onRequestGet: PagesFunction = async ({ request, env, data }) => { - return handleGetRequest(env.DATABASE, request, data.connectedActor, data.clientId) + return handleGetRequest(env.DATABASE, request, data.connectedActor, data.clientId, getVAPIDKeys(env)) } export const onRequestPost: PagesFunction = async ({ request, env, data }) => { @@ -23,7 +23,13 @@ const headers = { 'content-type': 'application/json; charset=utf-8', } -export async function handleGetRequest(db: D1Database, request: Request, connectedActor: Actor, clientId: string) { +export async function handleGetRequest( + db: D1Database, + request: Request, + connectedActor: Actor, + clientId: string, + vapidKeys: JWK +) { const client = await getClientById(db, clientId) if (client === null) { return errors.clientUnknown() @@ -35,20 +41,14 @@ export async function handleGetRequest(db: D1Database, request: Request, connect return new Response('', { status: 404 }) } - const res = { - id: 4, - endpoint: subscription.gateway.endpoint, - alerts: { - follow: true, - favourite: true, - reblog: true, - mention: true, - poll: true, - }, - policy: 'all', + const vapidKey = VAPIDPublicKey(vapidKeys) - // FIXME: stub value - server_key: 'TODO', + const res = { + id: subscription.id, + endpoint: subscription.gateway.endpoint, + alerts: subscription.alerts, + policy: subscription.policy, + server_key: vapidKey, } return new Response(JSON.stringify(res), { headers }) @@ -77,16 +77,10 @@ export async function handlePostRequest( const vapidKey = VAPIDPublicKey(vapidKeys) const res = { - id: 4, - endpoint: data.subscription.endpoint, - alerts: { - follow: true, - favourite: true, - reblog: true, - mention: true, - poll: true, - }, - policy: 'all', + id: subscription.id, + endpoint: subscription.gateway.endpoint, + alerts: subscription.alerts, + policy: subscription.policy, server_key: vapidKey, } diff --git a/migrations/0007_change_subscriptions_id.sql b/migrations/0007_change_subscriptions_id.sql new file mode 100644 index 0000000..7fba134 --- /dev/null +++ b/migrations/0007_change_subscriptions_id.sql @@ -0,0 +1,28 @@ +-- Migration number: 0007 2023-02-15T11:01:46.585Z + +DROP table subscriptions; + +CREATE TABLE subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id TEXT NOT NULL, + client_id TEXT NOT NULL, + endpoint TEXT NULL NOT NULL, + key_p256dh TEXT NOT NULL, + key_auth TEXT NOT NULL, + alert_mention INTEGER NOT NULL, + alert_status INTEGER NOT NULL, + alert_reblog INTEGER NOT NULL, + alert_follow INTEGER NOT NULL, + alert_follow_request INTEGER NOT NULL, + alert_favourite INTEGER NOT NULL, + alert_poll INTEGER NOT NULL, + alert_update INTEGER NOT NULL, + alert_admin_sign_up INTEGER NOT NULL, + alert_admin_report INTEGER NOT NULL, + policy TEXT NOT NULL, + cdate DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + + UNIQUE(actor_id, client_id) + FOREIGN KEY(actor_id) REFERENCES actors(id), + FOREIGN KEY(client_id) REFERENCES clients(id) +);