ditto/src/controllers/api/push.ts
2024-10-14 15:48:55 -05:00

139 lines
4 KiB
TypeScript

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