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