diff --git a/deno.json b/deno.json index c32768d5..57d90ab9 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", @@ -41,6 +42,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.36.1", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0", diff --git a/deno.lock b/deno.lock index cd27e889..24c4ed15 100644 --- a/deno.lock +++ b/deno.lock @@ -25,6 +25,8 @@ "jsr:@gleasonator/policy@0.9.0": "0.9.0", "jsr:@hono/hono@^4.4.6": "4.6.2", "jsr:@lambdalisue/async@^2.1.1": "2.1.1", + "jsr:@negrel/http-ece@0.6.0": "0.6.0", + "jsr:@negrel/webpush@0.3": "0.3.0", "jsr:@nostrify/db@~0.36.1": "0.36.1", "jsr:@nostrify/nostrify@0.31": "0.31.0", "jsr:@nostrify/nostrify@0.32": "0.32.0", @@ -50,6 +52,7 @@ "jsr:@std/assert@~0.225.1": "0.225.3", "jsr:@std/bytes@0.223": "0.223.0", "jsr:@std/bytes@0.224": "0.224.0", + "jsr:@std/bytes@0.224.0": "0.224.0", "jsr:@std/bytes@^1.0.0-rc.3": "1.0.0", "jsr:@std/bytes@^1.0.1-rc.3": "1.0.2", "jsr:@std/bytes@^1.0.2": "1.0.2", @@ -59,6 +62,7 @@ "jsr:@std/dotenv@0.224": "0.224.2", "jsr:@std/encoding@0.213.1": "0.213.1", "jsr:@std/encoding@0.224": "0.224.3", + "jsr:@std/encoding@0.224.0": "0.224.0", "jsr:@std/encoding@1.0.5": "1.0.5", "jsr:@std/encoding@~0.224.1": "0.224.3", "jsr:@std/fmt@0.213.1": "0.213.1", @@ -68,8 +72,10 @@ "jsr:@std/io@0.223": "0.223.0", "jsr:@std/io@0.224": "0.224.8", "jsr:@std/json@0.223": "0.223.0", + "jsr:@std/media-types@0.224.0": "0.224.0", "jsr:@std/media-types@~0.224.1": "0.224.1", "jsr:@std/path@0.213.1": "0.213.1", + "jsr:@std/path@0.224.0": "0.224.0", "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", "jsr:@std/path@~0.213.1": "0.213.1", "jsr:@std/streams@0.223": "0.223.0", @@ -290,6 +296,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", + "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.36.1": { "integrity": "b65b89ca6fe98d9dbcc0402b5c9c07b8430c2c91f84ba4128ff2eeed70c3d49f", "dependencies": [ @@ -510,6 +533,9 @@ "@std/encoding@0.213.1": { "integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62" }, + "@std/encoding@0.224.0": { + "integrity": "efb6dca97d3e9c31392bd5c8cfd9f9fc9decf5a1f4d1f78af7900a493bcf89b5" + }, "@std/encoding@0.224.3": { "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" }, @@ -599,6 +625,9 @@ "jsr:@std/streams" ] }, + "@std/media-types@0.224.0": { + "integrity": "5ac87989393f8cb1c81bee02aef6f5d4c8289b416deabc04f9ad25dff292d0b0" + }, "@std/media-types@0.224.1": { "integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1" }, @@ -608,6 +637,12 @@ "jsr:@std/assert@~0.213.1" ] }, + "@std/path@0.224.0": { + "integrity": "55bca6361e5a6d158b9380e82d4981d82d338ec587de02951e2b7c3a24910ee6", + "dependencies": [ + "jsr:@std/assert@0.224" + ] + }, "@std/path@1.0.0-rc.1": { "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" }, @@ -2057,6 +2092,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", "jsr:@nostrify/db@~0.36.1", "jsr:@nostrify/nostrify@0.36", "jsr:@nostrify/policies@0.35", 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 new file mode 100644 index 00000000..364f08ae --- /dev/null +++ b/src/DittoPush.ts @@ -0,0 +1,46 @@ +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); + const keys = 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.'); + } + })(); + } + + return this._server; + } + + static async push( + subscription: PushSubscription, + json: object, + 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/app.ts b/src/app.ts index ad80cbb1..b2a34765 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 { @@ -58,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, @@ -71,6 +73,7 @@ import { updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.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 { @@ -132,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; @@ -140,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. */ @@ -268,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); @@ -276,6 +283,9 @@ 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); 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/config.ts b/src/config.ts index 7558c988..1aa4adfb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,9 @@ import ISO6391, { LanguageCode } from 'iso-639-1'; 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({ @@ -82,6 +85,43 @@ 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; } + 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. */ timeouts: { diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index 56719715..78a72dd4 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/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 }; diff --git a/src/controllers/api/push.ts b/src/controllers/api/push.ts new file mode 100644 index 00000000..2f0d9844 --- /dev/null +++ b/src/controllers/api/push.ts @@ -0,0 +1,139 @@ +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'; + +/** 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(), + 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(), + }).optional(), +}); + +export const pushSubscribeController: 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 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; + + 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: 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}`; + } +} diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 46eeeab9..6046cf71 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -1,3 +1,5 @@ +import { Generated } from 'kysely'; + import { NPostgresSchema } from '@nostrify/db'; export interface DittoTables extends NPostgresSchema { @@ -7,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'] & { @@ -52,3 +55,29 @@ interface EventZapRow { amount_millisats: number; comment: string; } + +interface PushSubscriptionRow { + id: Generated; + pubkey: string; + token_hash: Uint8Array; + endpoint: string; + p256dh: string; + 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: 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..ecce1b1f --- /dev/null +++ b/src/db/migrations/038_push_subscriptions.ts @@ -0,0 +1,27 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('push_subscriptions') + .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()) + .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(); + + await db.schema + .createIndex('push_subscriptions_token_hash_idx') + .on('push_subscriptions') + .column('token_hash') + .unique() + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('push_subscriptions').execute(); +} 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 7c81a484..9125c505 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -2,25 +2,29 @@ import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { Stickynotes } from '@soapbox/stickynotes'; import { Kysely, sql } from 'kysely'; 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 { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } 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 { detectLanguage } from '@/utils/language.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 { detectLanguage } from '@/utils/language.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'); @@ -63,14 +67,21 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise + Promise.all([ + streamOut(event), + webPush(event), + ]) + ) + .catch(console.warn); } } @@ -228,6 +239,57 @@ async function streamOut(event: NostrEvent): Promise { } } +async function webPush(event: NostrEvent): Promise { + if (!isFresh(event)) { + return; + } + + 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', [...pubkeys]) + .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; + } + + 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), + preferred_locale: 'en', + title: notification.account.display_name || notification.account.username, + icon: notification.account.avatar_static, + body: event.content, + }; + + await DittoPush.push(subscription, message); + webPushNotificationsCounter.inc({ type: notification.type }); + } +} + async function generateSetEvents(event: NostrEvent): Promise { const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey); diff --git a/src/types/MastodonPush.ts b/src/types/MastodonPush.ts new file mode 100644 index 00000000..51e54cad --- /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; +} 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); +}