From bece384124851651d1298e3eb02e1e9e3fd815c9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 30 Sep 2024 19:12:53 -0500 Subject: [PATCH 01/20] Add Web Push skeleton --- src/app.ts | 3 +++ src/controllers/api/push.ts | 39 +++++++++++++++++++++++++++++++++++++ src/db/DittoTables.ts | 26 +++++++++++++++++++++++++ src/types/MastodonPush.ts | 15 ++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 src/controllers/api/push.ts create mode 100644 src/types/MastodonPush.ts diff --git a/src/app.ts b/src/app.ts index 9fb67ced..edae5283 100644 --- a/src/app.ts +++ b/src/app.ts @@ -68,6 +68,7 @@ import { updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; +import { pushSubscribeController } from '@/controllers/api/push.ts'; import { deleteReactionController, reactionController, reactionsController } from '@/controllers/api/reactions.ts'; import { relayController } from '@/controllers/nostr/relay.ts'; import { @@ -262,6 +263,8 @@ app.get('/api/v1/mutes', requireSigner, mutesController); app.get('/api/v1/markers', requireProof(), markersController); app.post('/api/v1/markers', requireProof(), updateMarkersController); +app.post('/api/v1/push/subscription', requireSigner, pushSubscribeController); + app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController); app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController); diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts new file mode 100644 index 00000000..987af708 --- /dev/null +++ b/src/controllers/api/push.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { AppController } from '@/app.ts'; +import { parseBody } from '@/utils/api.ts'; + +const pushSubscribeSchema = z.object({ + subscription: z.object({ + endpoint: z.string(), + keys: z.object({ + p256dh: z.string(), + auth: z.string(), + }), + data: z.object({ + alerts: z.object({ + mention: z.boolean().optional(), + status: z.boolean().optional(), + reblog: z.boolean().optional(), + follow: z.boolean().optional(), + follow_request: z.boolean().optional(), + favourite: z.boolean().optional(), + poll: z.boolean().optional(), + update: z.boolean().optional(), + 'admin.sign_up': z.boolean().optional(), + 'admin.report': z.boolean().optional(), + }).optional(), + policy: z.enum(['all', 'followed', 'follower', 'none']).optional(), + }), + }), +}); + +export const pushSubscribeController: AppController = async (c) => { + const data = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); + + if (!data.success) { + return c.json({ error: 'Invalid request', schema: data.error }, 400); + } + + return c.json({}); +}; diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index c05ffe66..26b2e6fa 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -9,6 +9,7 @@ export interface DittoTables extends NPostgresSchema { event_stats: EventStatsRow; pubkey_domains: PubkeyDomainRow; event_zaps: EventZapRow; + push_subscriptions: PushSubscriptionRow; } type NostrEventsRow = NPostgresSchema['nostr_events'] & { @@ -55,3 +56,28 @@ interface EventZapRow { amount_millisats: number; comment: string; } + +interface PushSubscriptionRow { + id: bigint; + pubkey: string; + endpoint: string; + key_p256dh: string; + key_auth: string; + data: { + 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?: 'all' | 'followed' | 'follower' | 'none'; + } | null; + created_at: Date; + updated_at: Date; +} diff --git a/src/types/MastodonPush.ts b/src/types/MastodonPush.ts new file mode 100644 index 00000000..dc2edbfa --- /dev/null +++ b/src/types/MastodonPush.ts @@ -0,0 +1,15 @@ +/** + * Mastodon push payload. + * + * This is the object the server sends to the client (with the Web Push API) + * to notify of a new push event. + */ +export interface MastodonPush { + access_token: string; + preferred_locale: string; + notification_id: string; + notification_type: string; + icon: string; + title: string; + body: string; +} From a10e810068e1fad39e38cec3df81a7664eedd510 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 2 Oct 2024 13:13:44 -0500 Subject: [PATCH 02/20] Create push_subscriptions table --- src/db/DittoTables.ts | 12 ++++++------ src/db/migrations/023_add_nip46_tokens.ts | 2 +- src/db/migrations/038_push_subscriptions.ts | 20 ++++++++++++++++++++ src/pipeline.ts | 2 ++ 4 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 src/db/migrations/038_push_subscriptions.ts diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 26b2e6fa..b4950afa 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -1,4 +1,4 @@ -import { Nullable } from 'kysely'; +import { Generated, Nullable } from 'kysely'; import { NPostgresSchema } from '@nostrify/db'; @@ -58,11 +58,11 @@ interface EventZapRow { } interface PushSubscriptionRow { - id: bigint; + id: Generated; pubkey: string; endpoint: string; - key_p256dh: string; - key_auth: string; + p256dh: string; + auth: string; data: { alerts?: { mention?: boolean; @@ -78,6 +78,6 @@ interface PushSubscriptionRow { }; policy?: 'all' | 'followed' | 'follower' | 'none'; } | null; - created_at: Date; - updated_at: Date; + created_at: Generated; + updated_at: Generated; } diff --git a/src/db/migrations/023_add_nip46_tokens.ts b/src/db/migrations/023_add_nip46_tokens.ts index 144bd1ec..01d71640 100644 --- a/src/db/migrations/023_add_nip46_tokens.ts +++ b/src/db/migrations/023_add_nip46_tokens.ts @@ -3,7 +3,7 @@ import { Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema .createTable('nip46_tokens') - .addColumn('api_token', 'text', (col) => col.primaryKey().unique().notNull()) + .addColumn('api_token', 'text', (col) => col.primaryKey().notNull()) .addColumn('user_pubkey', 'text', (col) => col.notNull()) .addColumn('server_seckey', 'bytea', (col) => col.notNull()) .addColumn('server_pubkey', 'text', (col) => col.notNull()) diff --git a/src/db/migrations/038_push_subscriptions.ts b/src/db/migrations/038_push_subscriptions.ts new file mode 100644 index 00000000..5e80ddc6 --- /dev/null +++ b/src/db/migrations/038_push_subscriptions.ts @@ -0,0 +1,20 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('push_subscriptions') + .addColumn('id', 'bigint', (c) => c.primaryKey().autoIncrement()) + .addColumn('pubkey', 'char(64)', (c) => c.notNull()) + .addColumn('token', 'char(64)', (c) => c.notNull()) + .addColumn('endpoint', 'text', (c) => c.notNull()) + .addColumn('p256dh', 'text', (c) => c.notNull()) + .addColumn('auth', 'text', (c) => c.notNull()) + .addColumn('data', 'jsonb') + .addColumn('created_at', 'timestamp', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) + .addColumn('updated_at', 'timestamp', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('push_subscriptions').execute(); +} diff --git a/src/pipeline.ts b/src/pipeline.ts index a00456a9..f854209b 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -230,6 +230,8 @@ async function streamOut(event: NostrEvent): Promise { if (isFresh(event)) { const pubsub = await Storages.pubsub(); await pubsub.event(event); + + // TODO: Web Push } } From 4561ec0d005d5f77a2fb242ea01e29639e83a1bf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 2 Oct 2024 22:20:47 -0500 Subject: [PATCH 03/20] Store the Push Subscription in the database --- src/app.ts | 2 +- src/controllers/api/push.ts | 71 +++++++++++++++------ src/db/DittoTables.ts | 1 + src/db/migrations/038_push_subscriptions.ts | 2 +- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/app.ts b/src/app.ts index edae5283..8d8b1139 100644 --- a/src/app.ts +++ b/src/app.ts @@ -263,7 +263,7 @@ app.get('/api/v1/mutes', requireSigner, mutesController); app.get('/api/v1/markers', requireProof(), markersController); app.post('/api/v1/markers', requireProof(), updateMarkersController); -app.post('/api/v1/push/subscription', requireSigner, pushSubscribeController); +app.post('/api/v1/push/subscription', requireProof(), pushSubscribeController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController); diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts index 987af708..f442064e 100644 --- a/src/controllers/api/push.ts +++ b/src/controllers/api/push.ts @@ -1,7 +1,10 @@ +import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; +import { Storages } from '@/storages.ts'; import { parseBody } from '@/utils/api.ts'; +import { getTokenHash } from '@/utils/auth.ts'; const pushSubscribeSchema = z.object({ subscription: z.object({ @@ -10,30 +13,62 @@ const pushSubscribeSchema = z.object({ p256dh: z.string(), auth: z.string(), }), - data: z.object({ - alerts: z.object({ - mention: z.boolean().optional(), - status: z.boolean().optional(), - reblog: z.boolean().optional(), - follow: z.boolean().optional(), - follow_request: z.boolean().optional(), - favourite: z.boolean().optional(), - poll: z.boolean().optional(), - update: z.boolean().optional(), - 'admin.sign_up': z.boolean().optional(), - 'admin.report': z.boolean().optional(), - }).optional(), - policy: z.enum(['all', 'followed', 'follower', 'none']).optional(), - }), }), + data: z.object({ + alerts: z.object({ + mention: z.boolean().optional(), + status: z.boolean().optional(), + reblog: z.boolean().optional(), + follow: z.boolean().optional(), + follow_request: z.boolean().optional(), + favourite: z.boolean().optional(), + poll: z.boolean().optional(), + update: z.boolean().optional(), + 'admin.sign_up': z.boolean().optional(), + 'admin.report': z.boolean().optional(), + }).optional(), + policy: z.enum(['all', 'followed', 'follower', 'none']).optional(), + }).optional(), }); export const pushSubscribeController: AppController = async (c) => { - const data = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); + const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); - if (!data.success) { - return c.json({ error: 'Invalid request', schema: data.error }, 400); + const header = c.req.header('authorization'); + const match = header?.match(BEARER_REGEX); + + if (!match) { + return c.json({ error: 'Unauthorized' }, 401); } + const [_, bech32] = match; + + if (!bech32.startsWith('token1')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const kysely = await Storages.kysely(); + const signer = c.get('signer')!; + + const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); + + if (!result.success) { + return c.json({ error: 'Invalid request', schema: result.error }, 400); + } + + const { subscription, data } = result.data; + + await kysely + .insertInto('push_subscriptions') + .values({ + pubkey: await signer.getPublicKey(), + token_hash: await getTokenHash(bech32 as `token1${string}`), + endpoint: subscription.endpoint, + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + data, + }) + .execute(); + return c.json({}); }; diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 25bc26c1..85336452 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -59,6 +59,7 @@ interface EventZapRow { interface PushSubscriptionRow { id: Generated; pubkey: string; + token_hash: Uint8Array; endpoint: string; p256dh: string; auth: string; diff --git a/src/db/migrations/038_push_subscriptions.ts b/src/db/migrations/038_push_subscriptions.ts index 5e80ddc6..be0b9fcb 100644 --- a/src/db/migrations/038_push_subscriptions.ts +++ b/src/db/migrations/038_push_subscriptions.ts @@ -5,7 +5,7 @@ export async function up(db: Kysely): Promise { .createTable('push_subscriptions') .addColumn('id', 'bigint', (c) => c.primaryKey().autoIncrement()) .addColumn('pubkey', 'char(64)', (c) => c.notNull()) - .addColumn('token', 'char(64)', (c) => c.notNull()) + .addColumn('token_hash', 'bytea', (c) => c.references('auth_tokens.token_hash').onDelete('cascade').notNull()) .addColumn('endpoint', 'text', (c) => c.notNull()) .addColumn('p256dh', 'text', (c) => c.notNull()) .addColumn('auth', 'text', (c) => c.notNull()) From ff2553eb0f56b48d2ca33e69d94fc9e2350438e1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 6 Oct 2024 16:07:32 -0500 Subject: [PATCH 04/20] Return a PushSubscription response --- src/controllers/api/push.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts index f442064e..2541966c 100644 --- a/src/controllers/api/push.ts +++ b/src/controllers/api/push.ts @@ -58,7 +58,7 @@ export const pushSubscribeController: AppController = async (c) => { const { subscription, data } = result.data; - await kysely + const { id } = await kysely .insertInto('push_subscriptions') .values({ pubkey: await signer.getPublicKey(), @@ -68,7 +68,14 @@ export const pushSubscribeController: AppController = async (c) => { auth: subscription.keys.auth, data, }) - .execute(); + .returning('id') + .executeTakeFirstOrThrow(); - return c.json({}); + return c.json({ + id, + endpoint: subscription.endpoint, + alerts: data?.alerts ?? {}, + policy: data?.policy ?? 'all', + // TODO: server_key + }); }; From 1ed6cac1c483806a4e859580932715687e6c4e28 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 8 Oct 2024 13:56:46 -0500 Subject: [PATCH 05/20] Add a crypto module to convert ECDSA private CryptoKey into a public key --- src/utils/crypto.test.ts | 28 ++++++++++++++++++++++++++++ src/utils/crypto.ts | 25 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/utils/crypto.test.ts create mode 100644 src/utils/crypto.ts diff --git a/src/utils/crypto.test.ts b/src/utils/crypto.test.ts new file mode 100644 index 00000000..d2b444a1 --- /dev/null +++ b/src/utils/crypto.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from '@std/assert'; + +import { getEcdsaPublicKey } from '@/utils/crypto.ts'; + +Deno.test('getEcdsaPublicKey', async () => { + const { publicKey, privateKey } = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256', + }, + true, + ['sign', 'verify'], + ); + + const result = await getEcdsaPublicKey(privateKey, true); + + assertKeysEqual(result, publicKey); +}); + +/** Assert that two CryptoKey objects are equal by value. Keys must be exportable. */ +async function assertKeysEqual(a: CryptoKey, b: CryptoKey): Promise { + const [jwk1, jwk2] = await Promise.all([ + crypto.subtle.exportKey('jwk', a), + crypto.subtle.exportKey('jwk', b), + ]); + + assertEquals(jwk1, jwk2); +} diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 00000000..80d031ed --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,25 @@ +/** + * Convert an ECDSA private key into a public key. + * https://stackoverflow.com/a/72153942 + */ +export async function getEcdsaPublicKey( + privateKey: CryptoKey, + extractable: boolean, +): Promise { + if (privateKey.type !== 'private') { + throw new Error('Expected a private key.'); + } + if (privateKey.algorithm.name !== 'ECDSA') { + throw new Error('Expected a private key with the ECDSA algorithm.'); + } + + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + const keyUsages: KeyUsage[] = ['verify']; + + // Remove the private property from the JWK. + delete jwk.d; + jwk.key_ops = keyUsages; + jwk.ext = extractable; + + return crypto.subtle.importKey('jwk', jwk, privateKey.algorithm, extractable, keyUsages); +} From 8823c0987db04e065caad3aa1787a41e7a0c66e3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 8 Oct 2024 18:37:23 -0500 Subject: [PATCH 06/20] Actually push ?? --- deno.json | 1 + deno.lock | 37 +++++++++++++++++++++++++++++++ src/DittoPush.ts | 36 ++++++++++++++++++++++++++++++ src/config.ts | 4 ++++ src/pipeline.ts | 46 ++++++++++++++++++++++++++++++++++++--- src/types/MastodonPush.ts | 8 +++---- 6 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 src/DittoPush.ts diff --git a/deno.json b/deno.json index f97d4fa7..6c0fb961 100644 --- a/deno.json +++ b/deno.json @@ -40,6 +40,7 @@ "@hono/hono": "jsr:@hono/hono@^4.4.6", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", + "@negrel/webpush": "jsr:@negrel/webpush@^0.3.0", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", "@nostrify/db": "jsr:@nostrify/db@^0.35.0", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0", diff --git a/deno.lock b/deno.lock index 84969bb2..446a4392 100644 --- a/deno.lock +++ b/deno.lock @@ -22,6 +22,9 @@ "jsr:@gleasonator/policy@0.7.1": "jsr:@gleasonator/policy@0.7.1", "jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.6.2", "jsr:@lambdalisue/async@^2.1.1": "jsr:@lambdalisue/async@2.1.1", + "jsr:@negrel/http-ece@0.6.0": "jsr:@negrel/http-ece@0.6.0", + "jsr:@negrel/webpush": "jsr:@negrel/webpush@0.3.0", + "jsr:@negrel/webpush@^0.3.0": "jsr:@negrel/webpush@0.3.0", "jsr:@nostrify/db@^0.35.0": "jsr:@nostrify/db@0.35.0", "jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5", "jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4", @@ -44,6 +47,7 @@ "jsr:@std/assert@^0.223.0": "jsr:@std/assert@0.223.0", "jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0", "jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3", + "jsr:@std/bytes@0.224.0": "jsr:@std/bytes@0.224.0", "jsr:@std/bytes@^0.223.0": "jsr:@std/bytes@0.223.0", "jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0", "jsr:@std/bytes@^1.0.0-rc.3": "jsr:@std/bytes@1.0.0", @@ -54,6 +58,7 @@ "jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0", "jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2", "jsr:@std/encoding@0.213.1": "jsr:@std/encoding@0.213.1", + "jsr:@std/encoding@0.224.0": "jsr:@std/encoding@0.224.0", "jsr:@std/encoding@1.0.5": "jsr:@std/encoding@1.0.5", "jsr:@std/encoding@^0.224.0": "jsr:@std/encoding@0.224.3", "jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3", @@ -64,8 +69,10 @@ "jsr:@std/io@^0.223.0": "jsr:@std/io@0.223.0", "jsr:@std/io@^0.224": "jsr:@std/io@0.224.8", "jsr:@std/json@^0.223.0": "jsr:@std/json@0.223.0", + "jsr:@std/media-types@0.224.0": "jsr:@std/media-types@0.224.0", "jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1", "jsr:@std/path@0.213.1": "jsr:@std/path@0.213.1", + "jsr:@std/path@0.224.0": "jsr:@std/path@0.224.0", "jsr:@std/path@1.0.0-rc.1": "jsr:@std/path@1.0.0-rc.1", "jsr:@std/path@^0.213.1": "jsr:@std/path@0.213.1", "jsr:@std/streams@^0.223.0": "jsr:@std/streams@0.223.0", @@ -263,6 +270,23 @@ "@lambdalisue/async@2.1.1": { "integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4" }, + "@negrel/http-ece@0.6.0": { + "integrity": "7afdd81b86ea5b21a9677b323c01c3338705e11cc2bfed250870f5349d8f86f7", + "dependencies": [ + "jsr:@std/bytes@0.224.0", + "jsr:@std/encoding@0.224.0" + ] + }, + "@negrel/webpush@0.3.0": { + "integrity": "5200a56e81668f2debadea228fbeabfe0eda2ee85a56786611dd97950bc51b23", + "dependencies": [ + "jsr:@negrel/http-ece@0.6.0", + "jsr:@std/bytes@0.224.0", + "jsr:@std/encoding@0.224.0", + "jsr:@std/media-types@0.224.0", + "jsr:@std/path@0.224.0" + ] + }, "@nostrify/db@0.35.0": { "integrity": "637191c41812544e361b7997dc44ea098f8bd7efebb28f37a8a7142a0ecada8d", "dependencies": [ @@ -475,6 +499,9 @@ "@std/encoding@0.213.1": { "integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62" }, + "@std/encoding@0.224.0": { + "integrity": "efb6dca97d3e9c31392bd5c8cfd9f9fc9decf5a1f4d1f78af7900a493bcf89b5" + }, "@std/encoding@0.224.3": { "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" }, @@ -564,6 +591,9 @@ "jsr:@std/streams@^0.223.0" ] }, + "@std/media-types@0.224.0": { + "integrity": "5ac87989393f8cb1c81bee02aef6f5d4c8289b416deabc04f9ad25dff292d0b0" + }, "@std/media-types@0.224.1": { "integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1" }, @@ -573,6 +603,12 @@ "jsr:@std/assert@^0.213.1" ] }, + "@std/path@0.224.0": { + "integrity": "55bca6361e5a6d158b9380e82d4981d82d338ec587de02951e2b7c3a24910ee6", + "dependencies": [ + "jsr:@std/assert@^0.224.0" + ] + }, "@std/path@1.0.0-rc.1": { "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" }, @@ -2149,6 +2185,7 @@ "jsr:@gfx/canvas-wasm@^0.4.2", "jsr:@hono/hono@^4.4.6", "jsr:@lambdalisue/async@^2.1.1", + "jsr:@negrel/webpush@^0.3.0", "jsr:@nostrify/db@^0.35.0", "jsr:@nostrify/nostrify@^0.36.0", "jsr:@nostrify/policies@^0.35.0", diff --git a/src/DittoPush.ts b/src/DittoPush.ts new file mode 100644 index 00000000..52ce1009 --- /dev/null +++ b/src/DittoPush.ts @@ -0,0 +1,36 @@ +import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush'; + +import { Conf } from '@/config.ts'; +import { Storages } from '@/storages.ts'; +import { getInstanceMetadata } from '@/utils/instance.ts'; + +export class DittoPush { + static _server: Promise | undefined; + + static get server(): Promise { + if (!this._server) { + this._server = (async () => { + const store = await Storages.db(); + const meta = await getInstanceMetadata(store); + + return await ApplicationServer.new({ + contactInformation: `mailto:${meta.email}`, + vapidKeys: await Conf.vapidKeys, + }); + })(); + } + + return this._server; + } + + static async push( + subscription: PushSubscription, + json: object, + opts: PushMessageOptions = {}, + ): Promise { + const server = await this.server; + const subscriber = new PushSubscriber(server, subscription); + const text = JSON.stringify(json); + return subscriber.pushTextMessage(text, opts); + } +} diff --git a/src/config.ts b/src/config.ts index c7f5e7cb..2634c92c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import ISO6391, { LanguageCode } from 'iso-639-1'; +import { generateVapidKeys } from '@negrel/webpush'; import * as dotenv from '@std/dotenv'; import { getPublicKey, nip19 } from 'nostr-tools'; import { z } from 'zod'; @@ -82,6 +83,9 @@ class Conf { static get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 { return Number(Deno.env.get('PGLITE_DEBUG') || 0) as 0 | 1 | 2 | 3 | 4 | 5; } + static get vapidKeys(): Promise { + return generateVapidKeys(); // FIXME: get the key from environment. + } static db = { /** Database query timeout configurations. */ timeouts: { diff --git a/src/pipeline.ts b/src/pipeline.ts index f854209b..12d377f8 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -4,24 +4,28 @@ import ISO6391 from 'iso-639-1'; import { Kysely, sql } from 'kysely'; import lande from 'lande'; import { LRUCache } from 'lru-cache'; +import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { Conf } from '@/config.ts'; import { DittoTables } from '@/db/DittoTables.ts'; +import { DittoPush } from '@/DittoPush.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { pipelineEventsCounter, policyEventsCounter } from '@/metrics.ts'; import { RelayError } from '@/RelayError.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; +import { MastodonPush } from '@/types/MastodonPush.ts'; import { eventAge, parseNip05, Time } from '@/utils.ts'; -import { policyWorker } from '@/workers/policy.ts'; -import { verifyEventWorker } from '@/workers/verify.ts'; import { getAmount } from '@/utils/bolt11.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { updateStats } from '@/utils/stats.ts'; import { getTagSet } from '@/utils/tags.ts'; +import { renderNotification } from '@/views/mastodon/notifications.ts'; +import { policyWorker } from '@/workers/policy.ts'; +import { verifyEventWorker } from '@/workers/verify.ts'; const console = new Stickynotes('ditto:pipeline'); @@ -72,6 +76,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise { if (isFresh(event)) { const pubsub = await Storages.pubsub(); await pubsub.event(event); + } +} - // TODO: Web Push +async function webPush(event: NostrEvent): Promise { + if (!isFresh(event)) { + return; + } + + const kysely = await Storages.kysely(); + + const rows = await kysely + .selectFrom('push_subscriptions') + .selectAll() + .where('pubkey', 'in', [...getTagSet(event.tags, 'p')]) + .execute(); + + for (const row of rows) { + const notification = await renderNotification(event, { viewerPubkey: row.pubkey }); + if (!notification) { + continue; + } + + const subscription = { + endpoint: row.endpoint, + keys: { + auth: row.auth, + p256dh: row.p256dh, + }, + }; + + const message: MastodonPush = { + notification_id: notification.id, + notification_type: notification.type, + access_token: nip19.npubEncode(row.pubkey), + }; + + await DittoPush.push(subscription, message); } } diff --git a/src/types/MastodonPush.ts b/src/types/MastodonPush.ts index dc2edbfa..51e54cad 100644 --- a/src/types/MastodonPush.ts +++ b/src/types/MastodonPush.ts @@ -6,10 +6,10 @@ */ export interface MastodonPush { access_token: string; - preferred_locale: string; + preferred_locale?: string; notification_id: string; notification_type: string; - icon: string; - title: string; - body: string; + icon?: string; + title?: string; + body?: string; } From 8f437839d0d40b4715ff10bd09e8c0bb4be37a49 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 8 Oct 2024 18:37:59 -0500 Subject: [PATCH 07/20] Delete DittoWallet --- src/DittoWallet.ts | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 src/DittoWallet.ts diff --git a/src/DittoWallet.ts b/src/DittoWallet.ts deleted file mode 100644 index 95616325..00000000 --- a/src/DittoWallet.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { HDKey } from '@scure/bip32'; - -import { Conf } from '@/config.ts'; - -/** - * HD wallet based on the `DITTO_NSEC`. - * The wallet is used to derive keys for various purposes. - * It is a singleton with static methods, and the keys are cached. - */ -export class DittoWallet { - static #root = HDKey.fromMasterSeed(Conf.seckey); - static #keys = new Map(); - - /** Derive the key cached. */ - static derive(path: string): HDKey { - const existing = this.#keys.get(path); - if (existing) { - return existing; - } else { - const key = this.#root.derive(path); - this.#keys.set(path, key); - return key; - } - } - - /** Derive the key and return the bytes. */ - static deriveKey(path: string): Uint8Array { - const { privateKey } = this.derive(path); - - if (!privateKey) { - throw new Error('Private key not available'); - } - - return privateKey; - } - - /** Database encryption key for AES-GCM encryption of database columns. */ - static get dbKey(): Uint8Array { - return this.deriveKey(Conf.wallet.dbKeyPath); - } - - /** VAPID secret key, used for web push notifications. ES256. */ - static get vapidKey(): Uint8Array { - return this.deriveKey(Conf.wallet.vapidKeyPath); - } -} From 198ec973b60a2eef4408c563e12355fdd3a6b9ae Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 8 Oct 2024 19:57:46 -0500 Subject: [PATCH 08/20] Load VAPID keys from configuration --- deno.json | 3 +- scripts/setup.ts | 11 ++++ scripts/vapid.ts | 7 +++ src/DittoPush.ts | 22 +++++--- src/config.ts | 57 ++++++++++++++------- src/controllers/api/instance.ts | 2 +- src/controllers/api/push.ts | 3 +- src/db/migrations/038_push_subscriptions.ts | 2 +- 8 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 scripts/vapid.ts diff --git a/deno.json b/deno.json index 6c0fb961..e66d70cc 100644 --- a/deno.json +++ b/deno.json @@ -20,7 +20,8 @@ "soapbox": "curl -O https://dl.soapbox.pub/main/soapbox.zip && mkdir -p public && mv soapbox.zip public/ && cd public/ && unzip -o soapbox.zip && rm soapbox.zip", "trends": "deno run -A scripts/trends.ts", "clean:deps": "deno cache --reload src/app.ts", - "db:populate-search": "deno run -A scripts/db-populate-search.ts" + "db:populate-search": "deno run -A scripts/db-populate-search.ts", + "vapid": "deno run -A scripts/vapid.ts" }, "unstable": [ "cron", diff --git a/scripts/setup.ts b/scripts/setup.ts index 32376692..3f3fc955 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -1,3 +1,5 @@ +import { generateVapidKeys } from '@negrel/webpush'; +import { encodeBase64 } from '@std/encoding/base64'; import { exists } from '@std/fs/exists'; import { generateSecretKey, nip19 } from 'nostr-tools'; import question from 'question-deno'; @@ -95,6 +97,15 @@ if (vars.DITTO_UPLOADER === 'local') { vars.MEDIA_DOMAIN = `https://${mediaDomain}`; } +const VAPID_PRIVATE_KEY = Deno.env.get('VAPID_PRIVATE_KEY'); +if (VAPID_PRIVATE_KEY) { + vars.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; +} else { + const { privateKey } = await generateVapidKeys({ extractable: true }); + const bytes = await crypto.subtle.exportKey('pkcs8', privateKey); + vars.VAPID_PRIVATE_KEY = encodeBase64(bytes); +} + console.log('Writing to .env file...'); const result = Object.entries(vars).reduce((acc, [key, value]) => { diff --git a/scripts/vapid.ts b/scripts/vapid.ts new file mode 100644 index 00000000..2e467881 --- /dev/null +++ b/scripts/vapid.ts @@ -0,0 +1,7 @@ +import { generateVapidKeys } from '@negrel/webpush'; +import { encodeBase64 } from '@std/encoding/base64'; + +const { privateKey } = await generateVapidKeys({ extractable: true }); +const bytes = await crypto.subtle.exportKey('pkcs8', privateKey); + +console.log(encodeBase64(bytes)); diff --git a/src/DittoPush.ts b/src/DittoPush.ts index 52ce1009..364f08ae 100644 --- a/src/DittoPush.ts +++ b/src/DittoPush.ts @@ -5,18 +5,23 @@ import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; export class DittoPush { - static _server: Promise | undefined; + static _server: Promise | undefined; - static get server(): Promise { + static get server(): Promise { if (!this._server) { this._server = (async () => { const store = await Storages.db(); const meta = await getInstanceMetadata(store); + const keys = await Conf.vapidKeys; - return await ApplicationServer.new({ - contactInformation: `mailto:${meta.email}`, - vapidKeys: await Conf.vapidKeys, - }); + if (keys) { + return await ApplicationServer.new({ + contactInformation: `mailto:${meta.email}`, + vapidKeys: keys, + }); + } else { + console.warn('VAPID keys are not set. Push notifications will be disabled.'); + } })(); } @@ -29,6 +34,11 @@ export class DittoPush { opts: PushMessageOptions = {}, ): Promise { const server = await this.server; + + if (!server) { + return; + } + const subscriber = new PushSubscriber(server, subscription); const text = JSON.stringify(json); return subscriber.pushTextMessage(text, opts); diff --git a/src/config.ts b/src/config.ts index 2634c92c..bdeb8ec2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,11 @@ import os from 'node:os'; import ISO6391, { LanguageCode } from 'iso-639-1'; -import { generateVapidKeys } from '@negrel/webpush'; import * as dotenv from '@std/dotenv'; import { getPublicKey, nip19 } from 'nostr-tools'; import { z } from 'zod'; +import { decodeBase64, encodeBase64 } from '@std/encoding/base64'; + +import { getEcdsaPublicKey } from '@/utils/crypto.ts'; /** Load environment config from `.env` */ await dotenv.load({ @@ -83,8 +85,42 @@ class Conf { static get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 { return Number(Deno.env.get('PGLITE_DEBUG') || 0) as 0 | 1 | 2 | 3 | 4 | 5; } - static get vapidKeys(): Promise { - return generateVapidKeys(); // FIXME: get the key from environment. + private static _vapidPublicKey: Promise | undefined; + static get vapidPublicKey(): Promise { + if (!this._vapidPublicKey) { + this._vapidPublicKey = (async () => { + const keys = await Conf.vapidKeys; + if (keys) { + const { publicKey } = keys; + const bytes = await crypto.subtle.exportKey('raw', publicKey); + return encodeBase64(bytes); + } + })(); + } + + return this._vapidPublicKey; + } + static get vapidKeys(): Promise { + return (async () => { + const encoded = Deno.env.get('VAPID_PRIVATE_KEY'); + + if (!encoded) { + return; + } + + const keyData = decodeBase64(encoded); + + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + keyData, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign'], + ); + const publicKey = await getEcdsaPublicKey(privateKey, true); + + return { privateKey, publicKey }; + })(); } static db = { /** Database query timeout configurations. */ @@ -107,21 +143,6 @@ class Conf { static get captchaTTL(): number { return Number(Deno.env.get('CAPTCHA_TTL') || 5 * 60 * 1000); } - /** - * BIP-32 derivation paths for different crypto use-cases. - * The `DITTO_NSEC` is used as the seed. - * Keys can be rotated by changing the derviation path. - */ - static wallet = { - /** Private key for AES-GCM encryption in the Postgres database. */ - get dbKeyPath(): string { - return Deno.env.get('WALLET_DB_KEY_PATH') || "m/0'/1'"; - }, - /** VAPID private key path. */ - get vapidKeyPath(): string { - return Deno.env.get('WALLET_VAPID_KEY_PATH') || "m/0'/3'"; - }, - }; /** Character limit to enforce for posts made through Mastodon API. */ static get postCharLimit(): number { return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000); diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index 9ab9930d..fbf17a3f 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -104,7 +104,7 @@ const instanceV2Controller: AppController = async (c) => { streaming: `${wsProtocol}//${host}`, }, vapid: { - public_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + public_key: await Conf.vapidPublicKey, }, accounts: { max_featured_tags: 10, diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts index 2541966c..9eef700c 100644 --- a/src/controllers/api/push.ts +++ b/src/controllers/api/push.ts @@ -2,6 +2,7 @@ import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; import { parseBody } from '@/utils/api.ts'; import { getTokenHash } from '@/utils/auth.ts'; @@ -76,6 +77,6 @@ export const pushSubscribeController: AppController = async (c) => { endpoint: subscription.endpoint, alerts: data?.alerts ?? {}, policy: data?.policy ?? 'all', - // TODO: server_key + server_key: await Conf.vapidPublicKey, }); }; diff --git a/src/db/migrations/038_push_subscriptions.ts b/src/db/migrations/038_push_subscriptions.ts index be0b9fcb..c3fa95e9 100644 --- a/src/db/migrations/038_push_subscriptions.ts +++ b/src/db/migrations/038_push_subscriptions.ts @@ -3,7 +3,7 @@ import { Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema .createTable('push_subscriptions') - .addColumn('id', 'bigint', (c) => c.primaryKey().autoIncrement()) + .addColumn('id', 'bigserial', (c) => c.primaryKey()) .addColumn('pubkey', 'char(64)', (c) => c.notNull()) .addColumn('token_hash', 'bytea', (c) => c.references('auth_tokens.token_hash').onDelete('cascade').notNull()) .addColumn('endpoint', 'text', (c) => c.notNull()) From 8278dbe1521b80344a328b24e25f82d769d99c26 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 8 Oct 2024 20:02:10 -0500 Subject: [PATCH 09/20] Remove bip32 --- deno.json | 1 - deno.lock | 24 ------------------------ 2 files changed, 25 deletions(-) diff --git a/deno.json b/deno.json index e66d70cc..df470e32 100644 --- a/deno.json +++ b/deno.json @@ -47,7 +47,6 @@ "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0", "@nostrify/policies": "jsr:@nostrify/policies@^0.35.0", "@scure/base": "npm:@scure/base@^1.1.6", - "@scure/bip32": "npm:@scure/bip32@^1.5.0", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", "@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0", diff --git a/deno.lock b/deno.lock index 446a4392..e49ac5cc 100644 --- a/deno.lock +++ b/deno.lock @@ -82,7 +82,6 @@ "npm:@noble/secp256k1@^2.0.0": "npm:@noble/secp256k1@2.1.0", "npm:@scure/base@^1.1.6": "npm:@scure/base@1.1.6", "npm:@scure/bip32@^1.4.0": "npm:@scure/bip32@1.4.0", - "npm:@scure/bip32@^1.5.0": "npm:@scure/bip32@1.5.0", "npm:@scure/bip39@^1.3.0": "npm:@scure/bip39@1.3.0", "npm:@types/node": "npm:@types/node@18.16.19", "npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1", @@ -652,12 +651,6 @@ "@noble/hashes": "@noble/hashes@1.4.0" } }, - "@noble/curves@1.6.0": { - "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", - "dependencies": { - "@noble/hashes": "@noble/hashes@1.5.0" - } - }, "@noble/hashes@1.3.1": { "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "dependencies": {} @@ -670,10 +663,6 @@ "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "dependencies": {} }, - "@noble/hashes@1.5.0": { - "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", - "dependencies": {} - }, "@noble/secp256k1@2.1.0": { "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==", "dependencies": {} @@ -690,10 +679,6 @@ "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==", "dependencies": {} }, - "@scure/base@1.1.9": { - "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", - "dependencies": {} - }, "@scure/bip32@1.3.1": { "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "dependencies": { @@ -710,14 +695,6 @@ "@scure/base": "@scure/base@1.1.6" } }, - "@scure/bip32@1.5.0": { - "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", - "dependencies": { - "@noble/curves": "@noble/curves@1.6.0", - "@noble/hashes": "@noble/hashes@1.5.0", - "@scure/base": "@scure/base@1.1.9" - } - }, "@scure/bip39@1.2.1": { "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "dependencies": { @@ -2204,7 +2181,6 @@ "npm:@isaacs/ttlcache@^1.4.1", "npm:@noble/secp256k1@^2.0.0", "npm:@scure/base@^1.1.6", - "npm:@scure/bip32@^1.5.0", "npm:comlink-async-generator@^0.0.1", "npm:comlink@^4.4.1", "npm:commander@12.1.0", From b6925a5491dc00c0139251ad071c5a2345e6e368 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Oct 2024 22:58:29 -0500 Subject: [PATCH 10/20] webpush: enforce endpoint as a URL --- src/controllers/api/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts index 9eef700c..04f2314c 100644 --- a/src/controllers/api/push.ts +++ b/src/controllers/api/push.ts @@ -9,7 +9,7 @@ import { getTokenHash } from '@/utils/auth.ts'; const pushSubscribeSchema = z.object({ subscription: z.object({ - endpoint: z.string(), + endpoint: z.string().url(), keys: z.object({ p256dh: z.string(), auth: z.string(), From 94cf3b2931debbc7eb15c1def7f4fd413ec067ae Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 13 Oct 2024 23:29:06 -0500 Subject: [PATCH 11/20] Add push controller test --- src/app.ts | 6 +++++- src/controllers/api/push.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/controllers/api/push.test.ts diff --git a/src/app.ts b/src/app.ts index c1b901b4..8cdbabe6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,9 +4,11 @@ import { serveStatic } from '@hono/hono/deno'; import { logger } from '@hono/hono/logger'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; +import { Kysely } from 'kysely'; import '@/startup.ts'; +import { DittoTables } from '@/db/DittoTables.ts'; import { Time } from '@/utils/time.ts'; import { @@ -133,7 +135,7 @@ import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; -interface AppEnv extends HonoEnv { +export interface AppEnv extends HonoEnv { Variables: { /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */ signer?: NostrSigner; @@ -141,6 +143,8 @@ interface AppEnv extends HonoEnv { uploader?: NUploader; /** NIP-98 signed event proving the pubkey is owned by the user. */ proof?: NostrEvent; + /** Kysely instance for the database. */ + kysely: Kysely; /** Storage for the user, might filter out unwanted content. */ store: NStore; /** Normalized pagination params. */ diff --git a/src/controllers/api/push.test.ts b/src/controllers/api/push.test.ts new file mode 100644 index 00000000..0b7ed885 --- /dev/null +++ b/src/controllers/api/push.test.ts @@ -0,0 +1,31 @@ +import { Hono } from '@hono/hono'; +import { NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey } from 'nostr-tools'; + +import { type AppEnv } from '@/app.ts'; +import { createTestDB } from '@/test.ts'; +import { pushSubscribeController } from '@/controllers/api/push.ts'; +import { assertEquals } from '@std/assert'; + +Deno.test('POST /api/v1/push/subscription creates the subscription in the database', async () => { + await using db = await createTestDB(); + const signer = new NSecSigner(generateSecretKey()); + + const app = new Hono().all((c) => { + c.set('kysely', db.kysely); + c.set('store', db.store); + c.set('signer', signer); + }, pushSubscribeController); + + const response = await app.request('/api/v1/push/subscription', { + body: JSON.stringify({ + endpoint: 'https://example.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }), + }); + + assertEquals(response.status, 200); +}); From b3928bac4598ec1f95cf02bfe72430f68303d071 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 14:31:33 -0500 Subject: [PATCH 12/20] webpush: replace old subscriptions in transaction --- src/controllers/api/push.ts | 38 +++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts index 04f2314c..7234fa10 100644 --- a/src/controllers/api/push.ts +++ b/src/controllers/api/push.ts @@ -42,9 +42,9 @@ export const pushSubscribeController: AppController = async (c) => { return c.json({ error: 'Unauthorized' }, 401); } - const [_, bech32] = match; + const [_, accessToken] = match; - if (!bech32.startsWith('token1')) { + if (!accessToken.startsWith('token1')) { return c.json({ error: 'Unauthorized' }, 401); } @@ -59,18 +59,28 @@ export const pushSubscribeController: AppController = async (c) => { const { subscription, data } = result.data; - const { id } = await kysely - .insertInto('push_subscriptions') - .values({ - pubkey: await signer.getPublicKey(), - token_hash: await getTokenHash(bech32 as `token1${string}`), - endpoint: subscription.endpoint, - p256dh: subscription.keys.p256dh, - auth: subscription.keys.auth, - data, - }) - .returning('id') - .executeTakeFirstOrThrow(); + const pubkey = await signer.getPublicKey(); + const tokenHash = await getTokenHash(accessToken as `token1${string}`); + + const { id } = await kysely.transaction().execute(async (trx) => { + await trx + .deleteFrom('push_subscriptions') + .where('token_hash', '=', tokenHash) + .execute(); + + return trx + .insertInto('push_subscriptions') + .values({ + pubkey, + token_hash: tokenHash, + endpoint: subscription.endpoint, + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + data, + }) + .returning('id') + .executeTakeFirstOrThrow(); + }); return c.json({ id, From 4019099c57791c9e9e92633b3c7e51cbf0afbc2c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 15:48:55 -0500 Subject: [PATCH 13/20] Add endpoint to GET a push subscription --- src/app.ts | 3 +- src/controllers/api/push.ts | 83 +++++++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/app.ts b/src/app.ts index 8cdbabe6..1c4b79aa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -73,7 +73,7 @@ import { updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; -import { pushSubscribeController } from '@/controllers/api/push.ts'; +import { getSubscriptionController, pushSubscribeController } from '@/controllers/api/push.ts'; import { deleteReactionController, reactionController, reactionsController } from '@/controllers/api/reactions.ts'; import { relayController } from '@/controllers/nostr/relay.ts'; import { @@ -281,6 +281,7 @@ app.get('/api/v1/mutes', requireSigner, mutesController); app.get('/api/v1/markers', requireProof(), markersController); app.post('/api/v1/markers', requireProof(), updateMarkersController); +app.get('/api/v1/push/subscription', requireSigner, getSubscriptionController); app.post('/api/v1/push/subscription', requireProof(), pushSubscribeController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController); diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts index 7234fa10..2f0d9844 100644 --- a/src/controllers/api/push.ts +++ b/src/controllers/api/push.ts @@ -7,6 +7,15 @@ import { Storages } from '@/storages.ts'; import { parseBody } from '@/utils/api.ts'; import { getTokenHash } from '@/utils/auth.ts'; +/** https://docs.joinmastodon.org/entities/WebPushSubscription/ */ +interface MastodonPushSubscription { + id: string; + endpoint: string; + server_key: string; + alerts: Record; + policy: 'all' | 'followed' | 'follower' | 'none'; +} + const pushSubscribeSchema = z.object({ subscription: z.object({ endpoint: z.string().url(), @@ -33,20 +42,13 @@ const pushSubscribeSchema = z.object({ }); export const pushSubscribeController: AppController = async (c) => { - const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); + const vapidPublicKey = await Conf.vapidPublicKey; - const header = c.req.header('authorization'); - const match = header?.match(BEARER_REGEX); - - if (!match) { - return c.json({ error: 'Unauthorized' }, 401); + if (!vapidPublicKey) { + return c.json({ error: 'The administrator of this server has not enabled Web Push notifications.' }, 404); } - const [_, accessToken] = match; - - if (!accessToken.startsWith('token1')) { - return c.json({ error: 'Unauthorized' }, 401); - } + const accessToken = getAccessToken(c.req.raw); const kysely = await Storages.kysely(); const signer = c.get('signer')!; @@ -82,11 +84,56 @@ export const pushSubscribeController: AppController = async (c) => { .executeTakeFirstOrThrow(); }); - return c.json({ - id, - endpoint: subscription.endpoint, - alerts: data?.alerts ?? {}, - policy: data?.policy ?? 'all', - server_key: await Conf.vapidPublicKey, - }); + return c.json( + { + id: id.toString(), + endpoint: subscription.endpoint, + alerts: data?.alerts ?? {}, + policy: data?.policy ?? 'all', + server_key: vapidPublicKey, + } satisfies MastodonPushSubscription, + ); }; + +export const getSubscriptionController: AppController = async (c) => { + const vapidPublicKey = await Conf.vapidPublicKey; + + if (!vapidPublicKey) { + return c.json({ error: 'The administrator of this server has not enabled Web Push notifications.' }, 404); + } + + const accessToken = getAccessToken(c.req.raw); + + const kysely = await Storages.kysely(); + const tokenHash = await getTokenHash(accessToken as `token1${string}`); + + const row = await kysely + .selectFrom('push_subscriptions') + .selectAll() + .where('token_hash', '=', tokenHash) + .executeTakeFirstOrThrow(); + + return c.json( + { + id: row.id.toString(), + endpoint: row.endpoint, + alerts: row.data?.alerts ?? {}, + policy: row.data?.policy ?? 'all', + server_key: vapidPublicKey, + } satisfies MastodonPushSubscription, + ); +}; + +/** Get access token from HTTP headers, but only if it's a `token1`. Otherwise return undefined. */ +function getAccessToken(request: Request): `token1${string}` | undefined { + const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); + + const authorization = request.headers.get('authorization'); + const match = authorization?.match(BEARER_REGEX); + + const [_, accessToken] = match ?? []; + + if (accessToken?.startsWith('token1')) { + return accessToken as `token1${string}`; + } +} From 94d8d43d7883dffdd1925c1359d0aa6dc912e7fd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 16:02:07 -0500 Subject: [PATCH 14/20] Add unique index on push_subscriptions token_hash --- src/db/migrations/038_push_subscriptions.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/db/migrations/038_push_subscriptions.ts b/src/db/migrations/038_push_subscriptions.ts index c3fa95e9..ecce1b1f 100644 --- a/src/db/migrations/038_push_subscriptions.ts +++ b/src/db/migrations/038_push_subscriptions.ts @@ -13,6 +13,13 @@ export async function up(db: Kysely): Promise { .addColumn('created_at', 'timestamp', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) .addColumn('updated_at', 'timestamp', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) .execute(); + + await db.schema + .createIndex('push_subscriptions_token_hash_idx') + .on('push_subscriptions') + .column('token_hash') + .unique() + .execute(); } export async function down(db: Kysely): Promise { From 30a5d9a20fb1b898defcbd9b75bd686fb5759d61 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 16:24:13 -0500 Subject: [PATCH 15/20] webpush: fix query error in pipeline --- src/pipeline.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pipeline.ts b/src/pipeline.ts index 29498417..5549193c 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -239,11 +239,16 @@ async function webPush(event: NostrEvent): Promise { } const kysely = await Storages.kysely(); + const pubkeys = getTagSet(event.tags, 'p'); + + if (!pubkeys.size) { + return; + } const rows = await kysely .selectFrom('push_subscriptions') .selectAll() - .where('pubkey', 'in', [...getTagSet(event.tags, 'p')]) + .where('pubkey', 'in', [...pubkeys]) .execute(); for (const row of rows) { From a7d8d86fa7598a4a15372b27f8f9b891b02102a4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 17:27:30 -0500 Subject: [PATCH 16/20] Add GET /api/v1/notifications/:id endpoint --- src/app.ts | 4 +++- src/controllers/api/notifications.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 1c4b79aa..b2a34765 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,7 +60,7 @@ import { import { markersController, updateMarkersController } from '@/controllers/api/markers.ts'; import { mediaController, updateMediaController } from '@/controllers/api/media.ts'; import { mutesController } from '@/controllers/api/mutes.ts'; -import { notificationsController } from '@/controllers/api/notifications.ts'; +import { notificationController, notificationsController } from '@/controllers/api/notifications.ts'; import { createTokenController, oauthAuthorizeController, oauthController } from '@/controllers/api/oauth.ts'; import { configController, @@ -273,6 +273,8 @@ app.get('/api/v1/suggestions', suggestionsV1Controller); app.get('/api/v2/suggestions', suggestionsV2Controller); app.get('/api/v1/notifications', requireSigner, notificationsController); +app.get('/api/v1/notifications/:id', requireSigner, notificationController); + app.get('/api/v1/favourites', requireSigner, favouritesController); app.get('/api/v1/bookmarks', requireSigner, bookmarksController); app.get('/api/v1/blocks', requireSigner, blocksController); diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index 144064a6..1c251563 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -74,6 +74,31 @@ const notificationsController: AppController = async (c) => { return renderNotifications(filters, types, params, c); }; +const notificationController: AppController = async (c) => { + const id = c.req.param('id'); + const pubkey = await c.get('signer')?.getPublicKey()!; + const store = c.get('store'); + + // Remove the timestamp from the ID. + const eventId = id.replace(/^\d+-/, ''); + + const [event] = await store.query([{ ids: [eventId] }]); + + if (!event) { + return c.json({ error: 'Event not found' }, { status: 404 }); + } + + await hydrateEvents({ events: [event], store }); + + const notification = await renderNotification(event, { viewerPubkey: pubkey }); + + if (!notification) { + return c.json({ error: 'Notification not found' }, { status: 404 }); + } + + return c.json(notification); +}; + async function renderNotifications( filters: NostrFilter[], types: Set, @@ -106,4 +131,4 @@ async function renderNotifications( return paginated(c, events, notifications); } -export { notificationsController }; +export { notificationController, notificationsController }; From 4c5d98f402e8746e03bddc5610ccf46d934ae414 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 17:31:05 -0500 Subject: [PATCH 17/20] webpush: don't notify author about own events --- src/pipeline.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pipeline.ts b/src/pipeline.ts index 5549193c..e5f6a216 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -252,6 +252,10 @@ async function webPush(event: NostrEvent): Promise { .execute(); for (const row of rows) { + if (row.pubkey === event.pubkey) { + continue; // Don't notify authors about their own events. + } + const notification = await renderNotification(event, { viewerPubkey: row.pubkey }); if (!notification) { continue; From 462f4ad786552795479cc8b029b771c76bf6c33a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 17:42:27 -0500 Subject: [PATCH 18/20] Add placeholder data to push notification --- src/pipeline.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pipeline.ts b/src/pipeline.ts index e5f6a216..8f2f284d 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -273,6 +273,10 @@ async function webPush(event: NostrEvent): Promise { notification_id: notification.id, notification_type: notification.type, access_token: nip19.npubEncode(row.pubkey), + preferred_locale: 'en', + title: notification.account.display_name || notification.account.username, + icon: notification.account.avatar_static, + body: event.content, }; await DittoPush.push(subscription, message); From 95d970d8d01eb8faf269e0e758960ca4ede43fb8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 18:06:22 -0500 Subject: [PATCH 19/20] webpush: add metrics and pipeline error handling --- src/metrics.ts | 6 ++++++ src/pipeline.ts | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/metrics.ts b/src/metrics.ts index 2cb3eb2d..7fe75a8f 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -141,3 +141,9 @@ export const relayPoolSubscriptionsSizeGauge = new Gauge({ name: 'ditto_relay_pool_subscriptions_size', help: 'Number of active subscriptions to the relay pool', }); + +export const webPushNotificationsCounter = new Counter({ + name: 'ditto_web_push_notifications_total', + help: 'Total number of Web Push notifications sent', + labelNames: ['type'], +}); diff --git a/src/pipeline.ts b/src/pipeline.ts index 8f2f284d..9125c505 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -9,7 +9,7 @@ import { Conf } from '@/config.ts'; import { DittoTables } from '@/db/DittoTables.ts'; import { DittoPush } from '@/DittoPush.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { pipelineEventsCounter, policyEventsCounter } from '@/metrics.ts'; +import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@/metrics.ts'; import { RelayError } from '@/RelayError.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; @@ -67,15 +67,21 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise + Promise.all([ + streamOut(event), + webPush(event), + ]) + ) + .catch(console.warn); } } @@ -280,6 +286,7 @@ async function webPush(event: NostrEvent): Promise { }; await DittoPush.push(subscription, message); + webPushNotificationsCounter.inc({ type: notification.type }); } } From 7ee1ca51d652b6c18cc4aeb831f83a1c784eface Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 14 Oct 2024 18:20:41 -0500 Subject: [PATCH 20/20] Delete not working controller test --- src/controllers/api/push.test.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 src/controllers/api/push.test.ts diff --git a/src/controllers/api/push.test.ts b/src/controllers/api/push.test.ts deleted file mode 100644 index 0b7ed885..00000000 --- a/src/controllers/api/push.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Hono } from '@hono/hono'; -import { NSecSigner } from '@nostrify/nostrify'; -import { generateSecretKey } from 'nostr-tools'; - -import { type AppEnv } from '@/app.ts'; -import { createTestDB } from '@/test.ts'; -import { pushSubscribeController } from '@/controllers/api/push.ts'; -import { assertEquals } from '@std/assert'; - -Deno.test('POST /api/v1/push/subscription creates the subscription in the database', async () => { - await using db = await createTestDB(); - const signer = new NSecSigner(generateSecretKey()); - - const app = new Hono().all((c) => { - c.set('kysely', db.kysely); - c.set('store', db.store); - c.set('signer', signer); - }, pushSubscribeController); - - const response = await app.request('/api/v1/push/subscription', { - body: JSON.stringify({ - endpoint: 'https://example.com', - keys: { - p256dh: 'p256dh', - auth: 'auth', - }, - }), - }); - - assertEquals(response.status, 200); -});