Merge branch 'push' into 'main'

Add Web Push API

See merge request soapbox-pub/ditto!524
This commit is contained in:
Alex Gleason 2024-10-15 15:41:21 +00:00
commit 5b6cacc2ed
18 changed files with 523 additions and 15 deletions

View file

@ -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", "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", "trends": "deno run -A scripts/trends.ts",
"clean:deps": "deno cache --reload src/app.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": [ "unstable": [
"cron", "cron",
@ -41,6 +42,7 @@
"@hono/hono": "jsr:@hono/hono@^4.4.6", "@hono/hono": "jsr:@hono/hono@^4.4.6",
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.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", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
"@nostrify/db": "jsr:@nostrify/db@^0.36.1", "@nostrify/db": "jsr:@nostrify/db@^0.36.1",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0",

36
deno.lock generated
View file

@ -25,6 +25,8 @@
"jsr:@gleasonator/policy@0.9.0": "0.9.0", "jsr:@gleasonator/policy@0.9.0": "0.9.0",
"jsr:@hono/hono@^4.4.6": "4.6.2", "jsr:@hono/hono@^4.4.6": "4.6.2",
"jsr:@lambdalisue/async@^2.1.1": "2.1.1", "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/db@~0.36.1": "0.36.1",
"jsr:@nostrify/nostrify@0.31": "0.31.0", "jsr:@nostrify/nostrify@0.31": "0.31.0",
"jsr:@nostrify/nostrify@0.32": "0.32.0", "jsr:@nostrify/nostrify@0.32": "0.32.0",
@ -50,6 +52,7 @@
"jsr:@std/assert@~0.225.1": "0.225.3", "jsr:@std/assert@~0.225.1": "0.225.3",
"jsr:@std/bytes@0.223": "0.223.0", "jsr:@std/bytes@0.223": "0.223.0",
"jsr:@std/bytes@0.224": "0.224.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.0-rc.3": "1.0.0",
"jsr:@std/bytes@^1.0.1-rc.3": "1.0.2", "jsr:@std/bytes@^1.0.1-rc.3": "1.0.2",
"jsr:@std/bytes@^1.0.2": "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/dotenv@0.224": "0.224.2",
"jsr:@std/encoding@0.213.1": "0.213.1", "jsr:@std/encoding@0.213.1": "0.213.1",
"jsr:@std/encoding@0.224": "0.224.3", "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@1.0.5": "1.0.5",
"jsr:@std/encoding@~0.224.1": "0.224.3", "jsr:@std/encoding@~0.224.1": "0.224.3",
"jsr:@std/fmt@0.213.1": "0.213.1", "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.223": "0.223.0",
"jsr:@std/io@0.224": "0.224.8", "jsr:@std/io@0.224": "0.224.8",
"jsr:@std/json@0.223": "0.223.0", "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/media-types@~0.224.1": "0.224.1",
"jsr:@std/path@0.213.1": "0.213.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@1.0.0-rc.1": "1.0.0-rc.1",
"jsr:@std/path@~0.213.1": "0.213.1", "jsr:@std/path@~0.213.1": "0.213.1",
"jsr:@std/streams@0.223": "0.223.0", "jsr:@std/streams@0.223": "0.223.0",
@ -290,6 +296,23 @@
"@lambdalisue/async@2.1.1": { "@lambdalisue/async@2.1.1": {
"integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4" "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": { "@nostrify/db@0.36.1": {
"integrity": "b65b89ca6fe98d9dbcc0402b5c9c07b8430c2c91f84ba4128ff2eeed70c3d49f", "integrity": "b65b89ca6fe98d9dbcc0402b5c9c07b8430c2c91f84ba4128ff2eeed70c3d49f",
"dependencies": [ "dependencies": [
@ -510,6 +533,9 @@
"@std/encoding@0.213.1": { "@std/encoding@0.213.1": {
"integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62" "integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62"
}, },
"@std/encoding@0.224.0": {
"integrity": "efb6dca97d3e9c31392bd5c8cfd9f9fc9decf5a1f4d1f78af7900a493bcf89b5"
},
"@std/encoding@0.224.3": { "@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
}, },
@ -599,6 +625,9 @@
"jsr:@std/streams" "jsr:@std/streams"
] ]
}, },
"@std/media-types@0.224.0": {
"integrity": "5ac87989393f8cb1c81bee02aef6f5d4c8289b416deabc04f9ad25dff292d0b0"
},
"@std/media-types@0.224.1": { "@std/media-types@0.224.1": {
"integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1" "integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1"
}, },
@ -608,6 +637,12 @@
"jsr:@std/assert@~0.213.1" "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": { "@std/path@1.0.0-rc.1": {
"integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6"
}, },
@ -2057,6 +2092,7 @@
"jsr:@gfx/canvas-wasm@~0.4.2", "jsr:@gfx/canvas-wasm@~0.4.2",
"jsr:@hono/hono@^4.4.6", "jsr:@hono/hono@^4.4.6",
"jsr:@lambdalisue/async@^2.1.1", "jsr:@lambdalisue/async@^2.1.1",
"jsr:@negrel/webpush@0.3",
"jsr:@nostrify/db@~0.36.1", "jsr:@nostrify/db@~0.36.1",
"jsr:@nostrify/nostrify@0.36", "jsr:@nostrify/nostrify@0.36",
"jsr:@nostrify/policies@0.35", "jsr:@nostrify/policies@0.35",

View file

@ -1,3 +1,5 @@
import { generateVapidKeys } from '@negrel/webpush';
import { encodeBase64 } from '@std/encoding/base64';
import { exists } from '@std/fs/exists'; import { exists } from '@std/fs/exists';
import { generateSecretKey, nip19 } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import question from 'question-deno'; import question from 'question-deno';
@ -95,6 +97,15 @@ if (vars.DITTO_UPLOADER === 'local') {
vars.MEDIA_DOMAIN = `https://${mediaDomain}`; 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...'); console.log('Writing to .env file...');
const result = Object.entries(vars).reduce((acc, [key, value]) => { const result = Object.entries(vars).reduce((acc, [key, value]) => {

7
scripts/vapid.ts Normal file
View file

@ -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));

46
src/DittoPush.ts Normal file
View file

@ -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<ApplicationServer | undefined> | undefined;
static get server(): Promise<ApplicationServer | undefined> {
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<void> {
const server = await this.server;
if (!server) {
return;
}
const subscriber = new PushSubscriber(server, subscription);
const text = JSON.stringify(json);
return subscriber.pushTextMessage(text, opts);
}
}

View file

@ -4,9 +4,11 @@ import { serveStatic } from '@hono/hono/deno';
import { logger } from '@hono/hono/logger'; import { logger } from '@hono/hono/logger';
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import { Kysely } from 'kysely';
import '@/startup.ts'; import '@/startup.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { import {
@ -58,7 +60,7 @@ import {
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts'; import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
import { mediaController, updateMediaController } from '@/controllers/api/media.ts'; import { mediaController, updateMediaController } from '@/controllers/api/media.ts';
import { mutesController } from '@/controllers/api/mutes.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 { createTokenController, oauthAuthorizeController, oauthController } from '@/controllers/api/oauth.ts';
import { import {
configController, configController,
@ -71,6 +73,7 @@ import {
updateConfigController, updateConfigController,
} from '@/controllers/api/pleroma.ts'; } from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.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 { deleteReactionController, reactionController, reactionsController } from '@/controllers/api/reactions.ts';
import { relayController } from '@/controllers/nostr/relay.ts'; import { relayController } from '@/controllers/nostr/relay.ts';
import { import {
@ -132,7 +135,7 @@ import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
interface AppEnv extends HonoEnv { export interface AppEnv extends HonoEnv {
Variables: { 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 to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */
signer?: NostrSigner; signer?: NostrSigner;
@ -140,6 +143,8 @@ interface AppEnv extends HonoEnv {
uploader?: NUploader; uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */ /** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent; proof?: NostrEvent;
/** Kysely instance for the database. */
kysely: Kysely<DittoTables>;
/** Storage for the user, might filter out unwanted content. */ /** Storage for the user, might filter out unwanted content. */
store: NStore; store: NStore;
/** Normalized pagination params. */ /** Normalized pagination params. */
@ -268,6 +273,8 @@ app.get('/api/v1/suggestions', suggestionsV1Controller);
app.get('/api/v2/suggestions', suggestionsV2Controller); app.get('/api/v2/suggestions', suggestionsV2Controller);
app.get('/api/v1/notifications', requireSigner, notificationsController); 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/favourites', requireSigner, favouritesController);
app.get('/api/v1/bookmarks', requireSigner, bookmarksController); app.get('/api/v1/bookmarks', requireSigner, bookmarksController);
app.get('/api/v1/blocks', requireSigner, blocksController); 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.get('/api/v1/markers', requireProof(), markersController);
app.post('/api/v1/markers', requireProof(), updateMarkersController); 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', reactionsController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', 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); app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController);

View file

@ -3,6 +3,9 @@ import ISO6391, { LanguageCode } from 'iso-639-1';
import * as dotenv from '@std/dotenv'; import * as dotenv from '@std/dotenv';
import { getPublicKey, nip19 } from 'nostr-tools'; import { getPublicKey, nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { decodeBase64, encodeBase64 } from '@std/encoding/base64';
import { getEcdsaPublicKey } from '@/utils/crypto.ts';
/** Load environment config from `.env` */ /** Load environment config from `.env` */
await dotenv.load({ await dotenv.load({
@ -82,6 +85,43 @@ class Conf {
static get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 { static get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 {
return Number(Deno.env.get('PGLITE_DEBUG') || 0) as 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<string | undefined> | undefined;
static get vapidPublicKey(): Promise<string | undefined> {
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<CryptoKeyPair | undefined> {
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 = { static db = {
/** Database query timeout configurations. */ /** Database query timeout configurations. */
timeouts: { timeouts: {

View file

@ -104,7 +104,7 @@ const instanceV2Controller: AppController = async (c) => {
streaming: `${wsProtocol}//${host}`, streaming: `${wsProtocol}//${host}`,
}, },
vapid: { vapid: {
public_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', public_key: await Conf.vapidPublicKey,
}, },
accounts: { accounts: {
max_featured_tags: 10, max_featured_tags: 10,

View file

@ -74,6 +74,31 @@ const notificationsController: AppController = async (c) => {
return renderNotifications(filters, types, params, 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( async function renderNotifications(
filters: NostrFilter[], filters: NostrFilter[],
types: Set<string>, types: Set<string>,
@ -106,4 +131,4 @@ async function renderNotifications(
return paginated(c, events, notifications); return paginated(c, events, notifications);
} }
export { notificationsController }; export { notificationController, notificationsController };

139
src/controllers/api/push.ts Normal file
View file

@ -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<string, boolean>;
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}`;
}
}

View file

@ -1,3 +1,5 @@
import { Generated } from 'kysely';
import { NPostgresSchema } from '@nostrify/db'; import { NPostgresSchema } from '@nostrify/db';
export interface DittoTables extends NPostgresSchema { export interface DittoTables extends NPostgresSchema {
@ -7,6 +9,7 @@ export interface DittoTables extends NPostgresSchema {
event_stats: EventStatsRow; event_stats: EventStatsRow;
pubkey_domains: PubkeyDomainRow; pubkey_domains: PubkeyDomainRow;
event_zaps: EventZapRow; event_zaps: EventZapRow;
push_subscriptions: PushSubscriptionRow;
} }
type NostrEventsRow = NPostgresSchema['nostr_events'] & { type NostrEventsRow = NPostgresSchema['nostr_events'] & {
@ -52,3 +55,29 @@ interface EventZapRow {
amount_millisats: number; amount_millisats: number;
comment: string; comment: string;
} }
interface PushSubscriptionRow {
id: Generated<bigint>;
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<Date>;
updated_at: Generated<Date>;
}

View file

@ -3,7 +3,7 @@ import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema
.createTable('nip46_tokens') .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('user_pubkey', 'text', (col) => col.notNull())
.addColumn('server_seckey', 'bytea', (col) => col.notNull()) .addColumn('server_seckey', 'bytea', (col) => col.notNull())
.addColumn('server_pubkey', 'text', (col) => col.notNull()) .addColumn('server_pubkey', 'text', (col) => col.notNull())

View file

@ -0,0 +1,27 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable('push_subscriptions').execute();
}

View file

@ -141,3 +141,9 @@ export const relayPoolSubscriptionsSizeGauge = new Gauge({
name: 'ditto_relay_pool_subscriptions_size', name: 'ditto_relay_pool_subscriptions_size',
help: 'Number of active subscriptions to the relay pool', 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'],
});

View file

@ -2,25 +2,29 @@ import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { Stickynotes } from '@soapbox/stickynotes'; import { Stickynotes } from '@soapbox/stickynotes';
import { Kysely, sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { DittoPush } from '@/DittoPush.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.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 { RelayError } from '@/RelayError.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { MastodonPush } from '@/types/MastodonPush.ts';
import { eventAge, parseNip05, Time } from '@/utils.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 { getAmount } from '@/utils/bolt11.ts';
import { detectLanguage } from '@/utils/language.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { purifyEvent } from '@/utils/purify.ts'; import { purifyEvent } from '@/utils/purify.ts';
import { updateStats } from '@/utils/stats.ts'; import { updateStats } from '@/utils/stats.ts';
import { getTagSet } from '@/utils/tags.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'); const console = new Stickynotes('ditto:pipeline');
@ -63,14 +67,21 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
try { try {
await storeEvent(purifyEvent(event), signal); await storeEvent(purifyEvent(event), signal);
await Promise.all([ } finally {
// This needs to run in steps, and should not block the API from responding.
Promise.all([
handleZaps(kysely, event), handleZaps(kysely, event),
parseMetadata(event, signal), parseMetadata(event, signal),
setLanguage(event), setLanguage(event),
]); generateSetEvents(event),
} finally { ])
await generateSetEvents(event); .then(() =>
await streamOut(event); Promise.all([
streamOut(event),
webPush(event),
])
)
.catch(console.warn);
} }
} }
@ -228,6 +239,57 @@ async function streamOut(event: NostrEvent): Promise<void> {
} }
} }
async function webPush(event: NostrEvent): Promise<void> {
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<void> { async function generateSetEvents(event: NostrEvent): Promise<void> {
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey); const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);

15
src/types/MastodonPush.ts Normal file
View file

@ -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;
}

28
src/utils/crypto.test.ts Normal file
View file

@ -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<void> {
const [jwk1, jwk2] = await Promise.all([
crypto.subtle.exportKey('jwk', a),
crypto.subtle.exportKey('jwk', b),
]);
assertEquals(jwk1, jwk2);
}

25
src/utils/crypto.ts Normal file
View file

@ -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<CryptoKey> {
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);
}