add basic push notification provider
This commit is contained in:
parent
e4a29f3c5e
commit
03dbf7026e
Binary file not shown.
Binary file not shown.
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}` } : {}),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
28
src/schemas/uncategorised/DeviceRegisterSchema.ts
Normal file
28
src/schemas/uncategorised/DeviceRegisterSchema.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
8
src/schemas/uncategorised/DeviceUnregisterSchema.ts
Normal file
8
src/schemas/uncategorised/DeviceUnregisterSchema.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface DeviceUnregisterSchema {
|
||||
provider: "webpush" | "basic";
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 2048
|
||||
*/
|
||||
token: string; // is used as endpoint
|
||||
}
|
||||
@ -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"
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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;
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
91
src/util/util/PushNotifications.ts
Normal file
91
src/util/util/PushNotifications.ts
Normal 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!,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
Reference in New Issue
Block a user