add push registration

This commit is contained in:
murdle 2025-12-07 23:21:40 +02:00
parent 8557a5d8bf
commit 2ec7855081
14 changed files with 137 additions and 14 deletions

Binary file not shown.

View File

@ -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));
});

Binary file not shown.

View File

@ -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,
});
});

View File

@ -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;

View File

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

View File

@ -90,3 +90,4 @@ export * from "./WebhookCreateSchema";
export * from "./WebhookExecuteSchema";
export * from "./WebhookUpdateSchema";
export * from "./WidgetModifySchema";
export * from "./DeviceNotificationSchema";

View File

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

View File

@ -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 })

View File

@ -61,3 +61,4 @@ export * from "./UserSettingsProtos";
export * from "./ValidRegistrationTokens";
export * from "./VoiceState";
export * from "./Webhook";
export * from "./PushSubscription";

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MakeEndpointUnique1765129731012 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "idx_push_subscriptions_endpoint"
ON "push_subscriptions" ("endpoint");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "idx_push_subscriptions_endpoint";
`);
}
}

View File

@ -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,

View File

@ -48,4 +48,5 @@ export * from "./Application";
export * from "./NameValidation";
export * from "../../schemas/HelperTypes";
export * from "./extensions";
export * from "./Random";
export * from "./Random";
export * from "./WebPush";