diff --git a/assets/openapi.json b/assets/openapi.json index 03e1e49f..1caad631 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ 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 04c160a2..17c18c43 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/src/api/Server.ts b/src/api/Server.ts index b580fc78..e9829f62 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -18,7 +18,7 @@ import { Config, ConnectionConfig, ConnectionLoader, Email, JSONReplacer, WebAuthn, initDatabase, initEvent, registerRoutes } from "@spacebar/util"; import { Authentication, CORS, ImageProxy, BodyParser, ErrorHandler, initRateLimits, initTranslation } from "./middlewares"; -import { Request, Response, Router } from "express"; +import { Router } from "express"; import { Server, ServerOptions } from "lambert-server"; import morgan from "morgan"; import path from "path"; @@ -119,7 +119,7 @@ export class SpacebarServer extends Server { res.sendFile(path.join(ASSETS_FOLDER, "openapi.json")); }); - // current well-known location (new commit 22 nov 2025 from spacebar) + // current well-known location app.get("/.well-known/spacebar", (req, res) => { 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";