From 03dbf7026e8bdd6384ca9c84b58a981021401a6c Mon Sep 17 00:00:00 2001 From: murdle Date: Sun, 21 Dec 2025 15:08:51 +0200 Subject: [PATCH] add basic push notification provider --- assets/openapi.json | Bin 844532 -> 845294 bytes assets/schemas.json | Bin 3410594 -> 3410330 bytes src/api/Server.ts | 4 +- src/api/routes/users/@me/devices.ts | 72 ++++++++++---- src/api/util/handlers/Message.ts | 31 +++--- .../uncategorised/DeviceNotificationSchema.ts | 34 ------- .../uncategorised/DeviceRegisterSchema.ts | 28 ++++++ .../uncategorised/DeviceUnregisterSchema.ts | 8 ++ src/schemas/uncategorised/index.ts | 3 +- src/util/entities/PushSubscription.ts | 9 +- src/util/interfaces/Notification.ts | 11 ++- .../1766283926503-makeKeysNullable.ts | 39 ++++++++ src/util/util/PushNotifications.ts | 91 ++++++++++++++++++ src/util/util/WebPush.ts | 46 --------- src/util/util/index.ts | 2 +- 15 files changed, 260 insertions(+), 118 deletions(-) delete mode 100644 src/schemas/uncategorised/DeviceNotificationSchema.ts create mode 100644 src/schemas/uncategorised/DeviceRegisterSchema.ts create mode 100644 src/schemas/uncategorised/DeviceUnregisterSchema.ts create mode 100644 src/util/migration/postgres/1766283926503-makeKeysNullable.ts create mode 100644 src/util/util/PushNotifications.ts delete mode 100644 src/util/util/WebPush.ts diff --git a/assets/openapi.json b/assets/openapi.json index 4ec7febc35a5a7125d73f0891c5e884c5cb3a103..c035798ef9b24149979bc9841a0c24bb4e19b6a3 100644 GIT binary patch delta 333 zcmexz)cDF7M2#)Eo?5c*<4b~GLuuM`)jaeP5&{6O>O#xYm92lNr}bN z9hBH*CpRo$ovuBHO>%OACCBsvW=8GlAKDr9rf=|KVV&-9;4rM{$*^Olf_I_r^&FXPj~pk#5w&y1RKxx!wc9}vQAgjpp1EuVMfqi!Dbp8LF-e0QXfnN^jE%MZ!g99l7nZYs zmEZ_UP0uVYNiEub@c?^0({zXHZ0n~71hc=KzW)F_8yC UVh$kY1Y#~A=H6a#n5RV^0K+tW-2eap delta 453 zcmaEN+W5;+F7M2#)Eo?5cxdKXyGXje8%Q91@JK8ZyOy97dQJ%42dY~Dj z6jx$FUU7V8UP)@vG(i?=0R%TMF(C5By>i zon#|Dy)TSeaJs+}Hdbc8{F3P(i&?X$`z~YSoW6H9o9y%pDy;GxFc(ZVOfs0h-iw88 zy2CzJ)oD9fO7bLWsGA@D$PsDncnEa=sx|z3nq7nPaiC1)1O{Y z#>U#dd>PyJ<;&Q=N^k(fA~P*BIdOX8A6A9w0gddI(+|96^Vq(ik)4UDJ?j`d5OV-A QClGT1G57YYV>~VL0QWtlSpWb4 diff --git a/assets/schemas.json b/assets/schemas.json index 27a7594ae9a49b5f002c6716b3a9db22fd65a703..20c7771e0db79c1a3f7d8819c6ceb549e77f032e 100644 GIT binary patch delta 304 zcmb8oJxc;{9KdnR(@yiwv(ijEyX^VFhwDE?TSHSV5tXhLA~r;%G&g+TKpgdh#^wlu zz_ls3sp|z4LAcvHhmYRN1))nq3=l>mLX<&tV#FCDfq_X9ixk6*FiM&X zV`Ld8N1h363QXcKMUiP{m}QQ67FcA7WmYIrra~2$)hBiJ65VYyTkVbn9k+0^rj^C$ z>9z#lZb2&^9k%6j&&tVtpti(mx+&@JgtKzy$8E1~S@QAdCb}hG)gtnoPagc*n0T)$ xEl#?!VQw}qTE~t3)ARoq5598b-B5;n`6}}_N?@tFm_1#WYD%4Sx8GE4{RfU`Zhrs( delta 514 zcmb`Dze^i&7{)nsrx$JXqKPqHa>n>0TD4964yDi`(nSO{PNK_kF;Ne~-EntGak8}! zbk;*(=pbz;hnA*T5DGu z-~$f7{V|f zVg#cY!y`OK8c#6(jcuNYGp4IA>q{C{ZpI^&{?0$8>2sz~WxyZtmz>qCZI<=CH^C?9 z?wrY9HWc%&B}piox~0&g))Ad$rtmS1PXVuP7ol=gpj9;P5Co#G6#+ULA z(|zeXFvQXYcR}yvMC`x*J4Hv6e3&YW MO__riRW|?Z9{vuxZ2$lO diff --git a/src/api/Server.ts b/src/api/Server.ts index b08622e6..5ce37144 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -25,7 +25,7 @@ import path from "path"; import express from "express"; import { red } from "picocolors"; import { initInstance } from "./util/handlers/Instance"; -import { configurePush } from "../util/util/WebPush"; +import { configureWebPush } from "../util/util/PushNotifications"; const ASSETS_FOLDER = path.join(__dirname, "..", "..", "assets"); const PUBLIC_ASSETS_FOLDER = path.join(ASSETS_FOLDER, "public"); @@ -58,7 +58,7 @@ export class SpacebarServer extends Server { await ConnectionConfig.init(); await initInstance(); WebAuthn.init(); - configurePush(); + configureWebPush(); const logRequests = process.env["LOG_REQUESTS"] != undefined; if (logRequests) { diff --git a/src/api/routes/users/@me/devices.ts b/src/api/routes/users/@me/devices.ts index 89199375..7961d550 100644 --- a/src/api/routes/users/@me/devices.ts +++ b/src/api/routes/users/@me/devices.ts @@ -18,7 +18,7 @@ import { Router, Response, Request } from "express"; import { route } from "@spacebar/api"; -import { DeviceNotificationSchema } from "@spacebar/schemas"; +import { DeviceRegisterSchema, DeviceUnregisterSchema } from "@spacebar/schemas"; import { Config, PushSubscription, Snowflake, vapidConfigured } from "@spacebar/util"; import { HTTPError } from "lambert-server"; import webpush from "web-push"; @@ -28,7 +28,7 @@ const router = Router({ mergeParams: true }); router.post( "/", route({ - requestBody: "DeviceNotificationSchema", + requestBody: "DeviceRegisterSchema", responses: { 400: { body: "APIErrorResponse", @@ -36,16 +36,11 @@ router.post( }, }), async (req: Request, res: Response) => { - const body = req.body as DeviceNotificationSchema; - if (body.provider != "webpush") - return res.sendStatus(204); - - if (!Config.get().webPush.enabled || !vapidConfigured) - throw new HTTPError("WebPush notifications are not configured", 400); - - const subscription = body.webpush_subscription; + const body = req.body as DeviceRegisterSchema; + const subscription = body.subscription; + if (!subscription) - throw new HTTPError("Subscription is missing or invalid", 400); + throw new HTTPError("Subscription is missing", 400); const endpoint = subscription.endpoint.trim(); try { @@ -54,19 +49,36 @@ router.post( throw new HTTPError("Endpoint is not a valid url", 400); } - await webpush.sendNotification(subscription, JSON.stringify({ type: "test" })).catch(async (err) => { - console.error("[WebPush] Failed to send test notification:", err.message); - throw new HTTPError("Failed to send test notification", 400); - }); + if (body.provider === "webpush") { + const { keys } = subscription; + + if (!Config.get().webPush.enabled || !vapidConfigured) + throw new HTTPError("WebPush notifications are not configured", 400); + + if (!keys) + throw new HTTPError("Keys are missing from subscription", 400); + + await webpush.sendNotification({ ...subscription, keys }, JSON.stringify({ type: "test" })).catch(async (err) => { + console.error("[WebPush] Failed to send test notification:", err.message); + throw new HTTPError("Failed to send test notification", 400); + }); + } else { + const req = await fetch(endpoint, { + method: "POST", + body: "Notifications are working!" + }) + if (!req.ok) throw new HTTPError("Failed to send test notification", 400); + } await PushSubscription.upsert( { id: Snowflake.generate(), - user_id: req.user_id ?? null, + user_id: req.user_id, endpoint, expiration_time: subscription.expirationTime != null ? Number(subscription.expirationTime) : undefined, - auth: subscription.keys.auth, - p256dh: subscription.keys.p256dh, + auth: subscription.keys?.auth, + p256dh: subscription.keys?.p256dh, + type: body.provider }, { conflictPaths: ["endpoint"], @@ -78,4 +90,28 @@ router.post( }, ); +router.delete( + "/", + route({ + requestBody: "DeviceUnregisterSchema", + responses: { + 404: { + body: "APIErrorResponse" + }, + }, + }), + async (req: Request, res: Response) => { + const body = req.body as DeviceUnregisterSchema; + const result = await PushSubscription.delete({ + user_id: req.user_id, + endpoint: body.token, + type: body.provider, + }); + if (result.affected === 0) { + throw new HTTPError("Push subscription not found", 404); + } + return res.sendStatus(204); + }, +); + export default router; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index f9482fe3..402c7c4c 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -44,17 +44,24 @@ import { normalizeUrl, sendNotification, PushSubscription, + CloudAttachment, + ReadState, + Member, + Session } from "@spacebar/util"; import { HTTPError } from "lambert-server"; -import { In, Or, Equal, IsNull } from "typeorm"; +import { In, Or, Equal, IsNull, Not } from "typeorm"; import fetch from "node-fetch-commonjs"; -import { CloudAttachment } from "../../../util/entities/CloudAttachment"; -import { ReadState } from "../../../util/entities/ReadState"; -import { Member } from "../../../util/entities/Member"; -import { Session } from "../../../util/entities/Session"; -import { ChannelType } from "@spacebar/schemas"; -import { Embed, MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageType, Reaction } from "@spacebar/schemas"; -import { EmbedType } from "../../../schemas/api/messages/Embeds"; +import { + Embed, + MessageCreateAttachment, + MessageCreateCloudAttachment, + MessageCreateSchema, + MessageType, + Reaction, + EmbedType, + ChannelType +} from "@spacebar/schemas"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images @@ -431,7 +438,7 @@ export async function pushMessage(message: Message) { // murdle: Online users shouldn't get a notification const sessions = await Session.find({ - where: { user_id: Or(...userIds.map((id) => Equal(id))) }, + where: { user_id: Or(...userIds.map((id) => Equal(id))), status: Not("idle") }, }); sessions.forEach(({ user_id }) => users.delete(user_id)); @@ -446,9 +453,9 @@ export async function pushMessage(message: Message) { type: "message", data: { content: message.content ? message.content.slice(0, 200) : "", - avatar: message.author?.avatar ? `${Config.get().cdn.endpointPublic}/avatars/${message.author_id}/${message.author?.avatar}` : "", - author: message.author?.username, - channel: { id: channel.id, type: channel.type } + author: message.author?.username!, + channel: { id: channel.id, type: channel.type, name: channel.name }, + ...(message.author?.avatar ? { avatar: `${Config.get().cdn.endpointPublic}/avatars/${message.author_id}/${message.author?.avatar}` } : {}), } }); } diff --git a/src/schemas/uncategorised/DeviceNotificationSchema.ts b/src/schemas/uncategorised/DeviceNotificationSchema.ts deleted file mode 100644 index 956c4620..00000000 --- a/src/schemas/uncategorised/DeviceNotificationSchema.ts +++ /dev/null @@ -1,34 +0,0 @@ -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/DeviceRegisterSchema.ts b/src/schemas/uncategorised/DeviceRegisterSchema.ts new file mode 100644 index 00000000..52a40ea5 --- /dev/null +++ b/src/schemas/uncategorised/DeviceRegisterSchema.ts @@ -0,0 +1,28 @@ +export interface DeviceRegisterSchema { + provider: "webpush" | "basic"; + token?: string; // unused for our providers + 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; + }; + }; +} diff --git a/src/schemas/uncategorised/DeviceUnregisterSchema.ts b/src/schemas/uncategorised/DeviceUnregisterSchema.ts new file mode 100644 index 00000000..84e781de --- /dev/null +++ b/src/schemas/uncategorised/DeviceUnregisterSchema.ts @@ -0,0 +1,8 @@ +export interface DeviceUnregisterSchema { + provider: "webpush" | "basic"; + /** + * @minLength 1 + * @maxLength 2048 + */ + token: string; // is used as endpoint +} diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts index d8882d79..7fb0f11a 100644 --- a/src/schemas/uncategorised/index.ts +++ b/src/schemas/uncategorised/index.ts @@ -90,4 +90,5 @@ export * from "./WebhookCreateSchema"; export * from "./WebhookExecuteSchema"; export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema"; -export * from "./DeviceNotificationSchema"; +export * from "./DeviceRegisterSchema"; +export * from "./DeviceUnregisterSchema" \ No newline at end of file diff --git a/src/util/entities/PushSubscription.ts b/src/util/entities/PushSubscription.ts index a5914860..07841ca7 100644 --- a/src/util/entities/PushSubscription.ts +++ b/src/util/entities/PushSubscription.ts @@ -18,9 +18,12 @@ export class PushSubscription extends BaseClass { @Column({ type: "bigint", nullable: true }) expiration_time?: number; - @Column({ type: "varchar", nullable: false }) - auth: string; + @Column({ type: "varchar", nullable: true }) + auth?: string; + + @Column({ type: "varchar", nullable: true }) + p256dh?: string; @Column({ type: "varchar", nullable: false }) - p256dh: string; + type: "webpush" | "basic" = "webpush"; } diff --git a/src/util/interfaces/Notification.ts b/src/util/interfaces/Notification.ts index 1219388b..93cd9d14 100644 --- a/src/util/interfaces/Notification.ts +++ b/src/util/interfaces/Notification.ts @@ -1,4 +1,13 @@ +import { ChannelType } from "@spacebar/schemas"; + export interface Notification { type: "message" | "test"; - data?: object; + data?: MessageNotify; +} + +export interface MessageNotify { + content: string; + avatar?: string; + author: string; + channel: { id: string; type: ChannelType; name?: string } } \ No newline at end of file diff --git a/src/util/migration/postgres/1766283926503-makeKeysNullable.ts b/src/util/migration/postgres/1766283926503-makeKeysNullable.ts new file mode 100644 index 00000000..41ed2339 --- /dev/null +++ b/src/util/migration/postgres/1766283926503-makeKeysNullable.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MakeKeysNullable1766283926503 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + ALTER COLUMN "auth" DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + ALTER COLUMN "p256dh" DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + ADD COLUMN "type" text NOT NULL DEFAULT 'webpush'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + DROP COLUMN "type"; + `); + + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + ALTER COLUMN "auth" SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + ALTER COLUMN "p256dh" SET NOT NULL; + `); + } + +} \ No newline at end of file diff --git a/src/util/util/PushNotifications.ts b/src/util/util/PushNotifications.ts new file mode 100644 index 00000000..e398b5a2 --- /dev/null +++ b/src/util/util/PushNotifications.ts @@ -0,0 +1,91 @@ +import { Config, Notification } from "@spacebar/util"; +import { yellow } from "picocolors"; +import { PushSubscription } from "../entities/PushSubscription"; +import webpush, { PushSubscription as WebPushSubscription } from "web-push"; + +export let vapidConfigured = false; + +export function configureWebPush() { + const { enabled, subject, publicVapidKey, privateVapidKey } = Config.get().webPush; + + if (!enabled) { + vapidConfigured = false; + return; + } + + if (!subject || !publicVapidKey || !privateVapidKey) { + console.warn("[WebPush]", yellow("VAPID details are missing. Push notifications will be disabled.")); + vapidConfigured = false; + return; + } + + webpush.setVapidDetails(subject, publicVapidKey, privateVapidKey); + vapidConfigured = true; +} + +export async function sendNotification( + subscription: PushSubscription, + body: Notification +) { + switch (subscription.type) { + case "basic": + if (body.type !== "message") return; + return handleBasicPush(subscription, body); + case "webpush": + return handleWebPush(subscription, body); + } +} + +async function handleBasicPush( + subscription: PushSubscription, + body: Notification +) { + const author = body.data?.author; + const content = body.data?.content; + const avatar = body.data?.avatar; + if (!author || !content) return; + + try { + await fetch(subscription.endpoint, { + method: "POST", + headers: { + "Content-Type": "text/plain", + "Title": author, + ...(avatar ? { "Icon": avatar } : {}), + }, + body: content + }); + } catch (err) { + console.error("[BasicPush] Failed to send notification", err); + await PushSubscription.delete({ endpoint: subscription.endpoint }); + } +} + +async function handleWebPush( + subscription: PushSubscription, + body: Notification +) { + return webpush.sendNotification( + parseAsWebSubscription(subscription), + JSON.stringify(body), + { TTL: 60 } + ).catch(async (err) => { + if (err.statusCode && err.statusCode != 200) { + console.log(`[WebPush] Deleting subscription due to HTTP error`); + await PushSubscription.delete({ endpoint: subscription.endpoint }); + return; + } + throw err; + }); +} + +function parseAsWebSubscription(result: PushSubscription): WebPushSubscription { + return { + endpoint: result.endpoint, + expirationTime: result.expiration_time ?? null, + keys: { + p256dh: result.p256dh!, + auth: result.auth!, + }, + }; +} diff --git a/src/util/util/WebPush.ts b/src/util/util/WebPush.ts deleted file mode 100644 index 5343086b..00000000 --- a/src/util/util/WebPush.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Config, Notification } from "@spacebar/util"; -import { yellow } from "picocolors"; -import { PushSubscription } from "../entities/PushSubscription"; -import webpush, { PushSubscription as WebPushSubscription } from "web-push"; - -export let vapidConfigured = false; - -export function configurePush() { - const { enabled, subject, publicVapidKey, privateVapidKey } = Config.get().webPush; - - if (!enabled) { - vapidConfigured = false; - return; - } - - if (!subject || !publicVapidKey || !privateVapidKey) { - console.warn("[WebPush]", yellow("VAPID details are missing. Push notifications will be disabled.")); - vapidConfigured = false; - return; - } - - webpush.setVapidDetails(subject, publicVapidKey, privateVapidKey); - vapidConfigured = true; -} - -export async function sendNotification(subscription: PushSubscription, body: Notification) { - return webpush.sendNotification(parseSubscription(subscription), JSON.stringify(body), { TTL: 60 }).catch(async (err) => { - if (err.statusCode && err.statusCode != 200) { - 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, - keys: { - p256dh: result.p256dh, - auth: result.auth, - }, - }; -} diff --git a/src/util/util/index.ts b/src/util/util/index.ts index d49894b7..8515a037 100644 --- a/src/util/util/index.ts +++ b/src/util/util/index.ts @@ -49,5 +49,5 @@ export * from "./NameValidation"; export * from "../../schemas/HelperTypes"; export * from "./extensions"; export * from "./Random"; -export * from "./WebPush"; +export * from "./PushNotifications"; export * from "./Url"; \ No newline at end of file