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 { Config, ConnectionConfig, ConnectionLoader, Email, JSONReplacer, WebAuthn, initDatabase, initEvent, registerRoutes } from "@spacebar/util";
import { Authentication, CORS, ImageProxy, BodyParser, ErrorHandler, initRateLimits, initTranslation } from "./middlewares"; 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 { Server, ServerOptions } from "lambert-server";
import morgan from "morgan"; import morgan from "morgan";
import path from "path"; import path from "path";
@ -119,7 +119,7 @@ export class SpacebarServer extends Server {
res.sendFile(path.join(ASSETS_FOLDER, "openapi.json")); 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) => { app.get("/.well-known/spacebar", (req, res) => {
res.json({ res.json({
api: Config.get().api.endpointPublic, api: Config.get().api.endpointPublic,
@ -152,6 +152,7 @@ export class SpacebarServer extends Server {
encoding: [...(erlpackSupported ? ["etf"] : []), "json"], encoding: [...(erlpackSupported ? ["etf"] : []), "json"],
compression: ["zstd-stream", "zlib-stream", null], compression: ["zstd-stream", "zlib-stream", null],
}, },
publicVapidKey: Config.get().webPush.publicVapidKey,
}); });
}); });

View File

@ -18,12 +18,61 @@
import { Router, Response, Request } from "express"; import { Router, Response, Request } from "express";
import { route } from "@spacebar/api"; 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 }); const router = Router({ mergeParams: true });
router.post("/", route({}), (req: Request, res: Response) => { router.post(
// TODO: "/",
res.sendStatus(204); 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; 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 "./WebhookExecuteSchema";
export * from "./WebhookUpdateSchema"; export * from "./WebhookUpdateSchema";
export * from "./WidgetModifySchema"; export * from "./WidgetModifySchema";
export * from "./DeviceNotificationSchema";

View File

@ -18,7 +18,7 @@
export class WebPushConfiguration { export class WebPushConfiguration {
enabled: boolean = false; enabled: boolean = false;
email: string | null = null; subject: string | null = null;
publicVapidKey: string | null = null; publicVapidKey: string | null = null;
privateVapidKey: string | null = null; privateVapidKey: string | null = null;
} }

View File

@ -12,7 +12,7 @@ export class PushSubscription extends BaseClass {
@ManyToOne(() => User, { onDelete: "SET NULL" }) @ManyToOne(() => User, { onDelete: "SET NULL" })
user: User; user: User;
@Column({ type: "varchar", nullable: false }) @Column({ type: "varchar", nullable: false, unique: true })
endpoint: string; endpoint: string;
@Column({ type: "bigint", nullable: true }) @Column({ type: "bigint", nullable: true })

View File

@ -61,3 +61,4 @@ export * from "./UserSettingsProtos";
export * from "./ValidRegistrationTokens"; export * from "./ValidRegistrationTokens";
export * from "./VoiceState"; export * from "./VoiceState";
export * from "./Webhook"; 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 { PushSubscription } from "../entities/PushSubscription";
import webpush, { PushSubscription as WebPushSubscription } from "web-push"; import webpush, { PushSubscription as WebPushSubscription } from "web-push";
let vapidConfigured = false; export let vapidConfigured = false;
export function configurePush() { export function configurePush() {
const { enabled, email, publicVapidKey, privateVapidKey } = Config.get().webPush; const { enabled, subject, publicVapidKey, privateVapidKey } = Config.get().webPush;
if (!enabled) { if (!enabled) {
vapidConfigured = false; vapidConfigured = false;
return; return;
} }
if (!email || !publicVapidKey || !privateVapidKey) { if (!subject || !publicVapidKey || !privateVapidKey) {
console.warn("[WebPush]", yellow("VAPID details are missing. Push notifications will be disabled.")); console.warn("[WebPush]", yellow("VAPID details are missing. Push notifications will be disabled."));
vapidConfigured = false; vapidConfigured = false;
return; return;
} }
webpush.setVapidDetails(email, publicVapidKey, privateVapidKey); webpush.setVapidDetails(subject, publicVapidKey, privateVapidKey);
vapidConfigured = true; 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 { return {
endpoint: result.endpoint, endpoint: result.endpoint,
expirationTime: result.expiration_time ?? null, expirationTime: result.expiration_time ?? null,

View File

@ -49,3 +49,4 @@ export * from "./NameValidation";
export * from "../../schemas/HelperTypes"; export * from "../../schemas/HelperTypes";
export * from "./extensions"; export * from "./extensions";
export * from "./Random"; export * from "./Random";
export * from "./WebPush";