mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'push' into 'main'
Add Web Push API See merge request soapbox-pub/ditto!524
This commit is contained in:
commit
5b6cacc2ed
18 changed files with 523 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
36
deno.lock
generated
36
deno.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
7
scripts/vapid.ts
Normal file
7
scripts/vapid.ts
Normal 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
46
src/DittoPush.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/app.ts
14
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<DittoTables>;
|
||||
/** 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);
|
||||
|
|
|
|||
|
|
@ -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<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 = {
|
||||
/** Database query timeout configurations. */
|
||||
timeouts: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
|
|
@ -106,4 +131,4 @@ async function renderNotifications(
|
|||
return paginated(c, events, notifications);
|
||||
}
|
||||
|
||||
export { notificationsController };
|
||||
export { notificationController, notificationsController };
|
||||
|
|
|
|||
139
src/controllers/api/push.ts
Normal file
139
src/controllers/api/push.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Kysely, sql } from 'kysely';
|
|||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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())
|
||||
|
|
|
|||
27
src/db/migrations/038_push_subscriptions.ts
Normal file
27
src/db/migrations/038_push_subscriptions.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void
|
|||
|
||||
try {
|
||||
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),
|
||||
parseMetadata(event, signal),
|
||||
setLanguage(event),
|
||||
]);
|
||||
} finally {
|
||||
await generateSetEvents(event);
|
||||
await streamOut(event);
|
||||
generateSetEvents(event),
|
||||
])
|
||||
.then(() =>
|
||||
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> {
|
||||
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);
|
||||
|
||||
|
|
|
|||
15
src/types/MastodonPush.ts
Normal file
15
src/types/MastodonPush.ts
Normal 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
28
src/utils/crypto.test.ts
Normal 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
25
src/utils/crypto.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue