add push registration
This commit is contained in:
parent
8557a5d8bf
commit
2ec7855081
Binary file not shown.
9
assets/public/client/service-worker.js
Normal file
9
assets/public/client/service-worker.js
Normal 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.
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
34
src/schemas/uncategorised/DeviceNotificationSchema.ts
Normal file
34
src/schemas/uncategorised/DeviceNotificationSchema.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 })
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -48,4 +48,5 @@ export * from "./Application";
|
|||||||
export * from "./NameValidation";
|
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";
|
||||||
|
|||||||
Reference in New Issue
Block a user