From 2ec785508112a79bcbad412dec99061a77d53269 Mon Sep 17 00:00:00 2001 From: murdle Date: Sun, 7 Dec 2025 23:21:40 +0200 Subject: [PATCH] add push registration --- assets/openapi.json | Bin 842013 -> 845873 bytes assets/public/client/service-worker.js | 9 +++ assets/schemas.json | Bin 3412009 -> 3414761 bytes src/api/Server.ts | 5 +- src/api/routes/users/@me/devices.ts | 57 ++++++++++++++++-- src/api/routes/users/@me/devices/index.ts | 0 .../uncategorised/DeviceNotificationSchema.ts | 34 +++++++++++ src/schemas/uncategorised/index.ts | 1 + src/util/config/types/WebPushConfiguration.ts | 2 +- src/util/entities/PushSubscription.ts | 2 +- src/util/entities/index.ts | 1 + .../1765129731012-makeEndpointUnique.ts | 16 +++++ src/util/util/WebPush.ts | 21 +++++-- src/util/util/index.ts | 3 +- 14 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 assets/public/client/service-worker.js delete mode 100644 src/api/routes/users/@me/devices/index.ts create mode 100644 src/schemas/uncategorised/DeviceNotificationSchema.ts create mode 100644 src/util/migration/postgres/1765129731012-makeEndpointUnique.ts diff --git a/assets/openapi.json b/assets/openapi.json index 03e1e49f0fd0a7f9c00bb0f88567c704a5fe8542..1caad6310f00080b33baebde6f88b342a2408658 100644 GIT binary patch delta 809 zcma))O=uHQ5XX7h_ug)rRFXr}Hr6H$F%e6fH2pvYORR+=NKMeQn~!eWrOjr|ZX&rz zErA{sX?fB?iju1yN~%IE2->R`a}ZA|=}{1=<{(~*>uxO-Jb3uSd&A6selyI&r^8!~ z;iU?y*eYBFSIBevwc3^_YV(Ogz9i4Ug>lpcwO7c8R7j2?uQRPGnkXx}R8Z0|beZdR z9gQrCr93pwBQI}84?ecJEK9%W=5I>vH;&d zq7XD=#3vjwtUbX)uo|WW8dvZHi}Ib0QeIX?)kI{wKm5>mkHQvVH^jFD``?&r`&{2Y zF|FjItX9MV?lP_ZP%}64g36(OmYNtKCIIZ+L!#qgDp4zDAo+!hfd3PkX6++`5V=9a zrUxW~)XBx7Bg}oh6I@%ggEh5DNL4{07P7jTea#H3@8}rxUBV$~blBQq zHAzpc>rMjEAPz%yz<9kwq{njnpGptJBiIG6@6#YGJ)`66Zv-c#_Bo5!a=c|D9@CMk zztQI5+Zhr7*H6v|tD9)u*p3}qEz*kv3sMF7M2#)Eo|==OxN#bWNoin%eK90E&ELI=?%|#j!*xvoZYQ` Y?Fn`u<^W<&Am##M?(J(&@U+MS0CJ5WfdBvi diff --git a/assets/public/client/service-worker.js b/assets/public/client/service-worker.js new file mode 100644 index 00000000..f7418b9f --- /dev/null +++ b/assets/public/client/service-worker.js @@ -0,0 +1,9 @@ +self.addEventListener('push', function (event) { + if (!event.data) return; + const data = event.data.json(); + + const title = data.title || "Discord"; + const options = { body: data.body }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); diff --git a/assets/schemas.json b/assets/schemas.json index 04c160a2f1637cd9c167efdf005e4044c69e6203..17c18c43152c24eab73ab27a1ba5e61276a0f31e 100644 GIT binary patch delta 751 zcmcJMPiWHs6vj!Hq<^+)V_Va%%bIMf)BS6ctvW>5)S)nN_8@rdx~^`)HHk@EnFvn! zz=M~L9Nx88*<}O&;6WTBh&xR|=0#8u!IKjvdUWlEM{geA$M@dv@qG{KTg~93&m-(tY6a$^!>henJ!&VN;I=2ih*p!S;*RERiAUu zvNFxoBRthUtCA}#y600YR(9D=zv1L`qg=ENS`W8&2zl$TkYCL0 z?YP3dn6IfG44(M{sb9P?@6F<@wFe|+oC(p4t;QP|wt*of>WkCFv`?bidY=&3H|Ctm zEU9n#F0v&5MfX`cNy8ycruw^xpLR^Ojn&elV{~Jci@VwI8IqG;j?b5M)3`z_O991S z(ha>-DRrNwH!r+V3Qk4aDOg}KwDvlzG>&#RZ@C+{202U1oSdT{nNT04LRx~>zV-@D zh)2ti@|zLenx~Cw-u_{de??^~of|s%%_+f;5GP*tT GbL@`c@+Bx+O+mKe1neKPJ8Ix{rkQIU&$pn2&6nn z3q8tsl9iKJ@T{n$>_tUYO { res.json({ api: Config.get().api.endpointPublic, @@ -152,6 +152,7 @@ export class SpacebarServer extends Server { encoding: [...(erlpackSupported ? ["etf"] : []), "json"], compression: ["zstd-stream", "zlib-stream", null], }, + publicVapidKey: Config.get().webPush.publicVapidKey, }); }); diff --git a/src/api/routes/users/@me/devices.ts b/src/api/routes/users/@me/devices.ts index 644302cd..8cfacd81 100644 --- a/src/api/routes/users/@me/devices.ts +++ b/src/api/routes/users/@me/devices.ts @@ -18,12 +18,61 @@ import { Router, Response, Request } from "express"; import { route } from "@spacebar/api"; +import { DeviceNotificationSchema } from "@spacebar/schemas"; +import { Config, PushSubscription, Snowflake, vapidConfigured } from "@spacebar/util"; +import { HTTPError } from "lambert-server"; +import webpush from "web-push"; const router = Router({ mergeParams: true }); -router.post("/", route({}), (req: Request, res: Response) => { - // TODO: - res.sendStatus(204); -}); +router.post( + "/", + route({ + requestBody: "DeviceNotificationSchema", + responses: { + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const body = req.body as DeviceNotificationSchema; + if (body.provider != "webpush") throw new HTTPError("Provider is not supported", 400); + + if (!Config.get().webPush.enabled || !vapidConfigured) throw new HTTPError("WebPush notifications are not configured", 400); + + const subscription = body.webpush_subscription; + if (!subscription) throw new HTTPError("Subscription is missing or invalid", 400); + + const endpoint = subscription.endpoint.trim(); + try { + new URL(endpoint); + } catch { + throw new HTTPError("Endpoint is not a valid url", 400); + } + + await webpush.sendNotification(subscription, JSON.stringify({ body: "Swoosh. Notifications are a go!" })).catch(async (err) => { + console.error("[WebPush] Failed to send test notification:", err.message); + throw new HTTPError("Failed to send test notification", 400); + }); + + await PushSubscription.upsert( + { + id: Snowflake.generate(), + user_id: req.user_id ?? null, + endpoint, + expiration_time: subscription.expirationTime != null ? Number(subscription.expirationTime) : undefined, + auth: subscription.keys.auth, + p256dh: subscription.keys.p256dh, + }, + { + conflictPaths: ["endpoint"], + skipUpdateIfNoValuesChanged: true, + }, + ); + + res.sendStatus(204); + }, +); export default router; diff --git a/src/api/routes/users/@me/devices/index.ts b/src/api/routes/users/@me/devices/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/schemas/uncategorised/DeviceNotificationSchema.ts b/src/schemas/uncategorised/DeviceNotificationSchema.ts new file mode 100644 index 00000000..956c4620 --- /dev/null +++ b/src/schemas/uncategorised/DeviceNotificationSchema.ts @@ -0,0 +1,34 @@ +export type PushProvider = "gcm" | "apns" | "apns_internal" | "apns_voip" | "apns_internal_voip" | "webpush"; + +export interface DeviceNotificationSchema { + provider: PushProvider; + token?: string; + voip_provider?: PushProvider; + voip_token?: string; + webpush_subscription?: { + /** + * @minLength 1 + * @maxLength 2048 + */ + endpoint: string; + /** + * @minimum 0 + * @TJS-type integer + */ + expirationTime?: number; + keys: { + /** + * @minLength 1 + * @maxLength 256 + */ + p256dh: string; + /** + * @minLength 1 + * @maxLength 256 + */ + auth: string; + }; + }; + bypass_server_throttling_supported?: boolean; + bundle_id?: string; +} diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts index 8a23b16a..d8882d79 100644 --- a/src/schemas/uncategorised/index.ts +++ b/src/schemas/uncategorised/index.ts @@ -90,3 +90,4 @@ export * from "./WebhookCreateSchema"; export * from "./WebhookExecuteSchema"; export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema"; +export * from "./DeviceNotificationSchema"; diff --git a/src/util/config/types/WebPushConfiguration.ts b/src/util/config/types/WebPushConfiguration.ts index 36dd2665..24518182 100644 --- a/src/util/config/types/WebPushConfiguration.ts +++ b/src/util/config/types/WebPushConfiguration.ts @@ -18,7 +18,7 @@ export class WebPushConfiguration { enabled: boolean = false; - email: string | null = null; + subject: string | null = null; publicVapidKey: string | null = null; privateVapidKey: string | null = null; } diff --git a/src/util/entities/PushSubscription.ts b/src/util/entities/PushSubscription.ts index 905fa183..a5914860 100644 --- a/src/util/entities/PushSubscription.ts +++ b/src/util/entities/PushSubscription.ts @@ -12,7 +12,7 @@ export class PushSubscription extends BaseClass { @ManyToOne(() => User, { onDelete: "SET NULL" }) user: User; - @Column({ type: "varchar", nullable: false }) + @Column({ type: "varchar", nullable: false, unique: true }) endpoint: string; @Column({ type: "bigint", nullable: true }) diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index 11494d00..b8bb6acf 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -61,3 +61,4 @@ export * from "./UserSettingsProtos"; export * from "./ValidRegistrationTokens"; export * from "./VoiceState"; export * from "./Webhook"; +export * from "./PushSubscription"; diff --git a/src/util/migration/postgres/1765129731012-makeEndpointUnique.ts b/src/util/migration/postgres/1765129731012-makeEndpointUnique.ts new file mode 100644 index 00000000..41b2c684 --- /dev/null +++ b/src/util/migration/postgres/1765129731012-makeEndpointUnique.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MakeEndpointUnique1765129731012 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "idx_push_subscriptions_endpoint" + ON "push_subscriptions" ("endpoint"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "idx_push_subscriptions_endpoint"; + `); + } +} diff --git a/src/util/util/WebPush.ts b/src/util/util/WebPush.ts index 1868c847..1f663c24 100644 --- a/src/util/util/WebPush.ts +++ b/src/util/util/WebPush.ts @@ -3,27 +3,38 @@ import { yellow } from "picocolors"; import { PushSubscription } from "../entities/PushSubscription"; import webpush, { PushSubscription as WebPushSubscription } from "web-push"; -let vapidConfigured = false; +export let vapidConfigured = false; export function configurePush() { - const { enabled, email, publicVapidKey, privateVapidKey } = Config.get().webPush; + const { enabled, subject, publicVapidKey, privateVapidKey } = Config.get().webPush; if (!enabled) { vapidConfigured = false; return; } - if (!email || !publicVapidKey || !privateVapidKey) { + if (!subject || !publicVapidKey || !privateVapidKey) { console.warn("[WebPush]", yellow("VAPID details are missing. Push notifications will be disabled.")); vapidConfigured = false; return; } - webpush.setVapidDetails(email, publicVapidKey, privateVapidKey); + webpush.setVapidDetails(subject, publicVapidKey, privateVapidKey); vapidConfigured = true; } -export function parseSubscription(result: PushSubscription): WebPushSubscription { +export async function sendNotification(subscription: PushSubscription, title: string, body: string) { + return webpush.sendNotification(parseSubscription(subscription), JSON.stringify({ title, body }), { TTL: 60 }).catch(async (err) => { + if (err.statusCode === 404 || err.statusCode === 410) { + console.log(`[WebPush] Deleting subscription due to HTTP error`); + await PushSubscription.delete({ endpoint: subscription.endpoint }); + return; + } + throw err; + }); +} + +function parseSubscription(result: PushSubscription): WebPushSubscription { return { endpoint: result.endpoint, expirationTime: result.expiration_time ?? null, diff --git a/src/util/util/index.ts b/src/util/util/index.ts index 11c4899c..7e429fd0 100644 --- a/src/util/util/index.ts +++ b/src/util/util/index.ts @@ -48,4 +48,5 @@ export * from "./Application"; export * from "./NameValidation"; export * from "../../schemas/HelperTypes"; export * from "./extensions"; -export * from "./Random"; \ No newline at end of file +export * from "./Random"; +export * from "./WebPush";