add basic push notification provider

This commit is contained in:
murdle 2025-12-21 15:08:51 +02:00
parent e4a29f3c5e
commit 03dbf7026e
15 changed files with 260 additions and 118 deletions

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
export interface DeviceUnregisterSchema {
provider: "webpush" | "basic";
/**
* @minLength 1
* @maxLength 2048
*/
token: string; // is used as endpoint
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MakeKeysNullable1766283926503 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

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

View File

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

View File

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