Merge pull request #7 from EKSDEEf/main
fix browser notifications + make them work when browser is closed
This commit is contained in:
@@ -142,6 +142,23 @@ db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_screenshots_ts ON notification_screenshots(ts DESC);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS auto_scripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -948,6 +965,59 @@ export function getEnrollmentStats(): {
|
||||
return stats;
|
||||
}
|
||||
|
||||
export interface PushSubscriptionRecord {
|
||||
id: number;
|
||||
userId: number;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function savePushSubscription(userId: number, endpoint: string, p256dh: string, auth: string): void {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
userId, endpoint, p256dh, auth, Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePushSubscription(endpoint: string): void {
|
||||
db.run(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint);
|
||||
}
|
||||
|
||||
export function deletePushSubscriptionsByUser(userId: number): void {
|
||||
db.run(`DELETE FROM push_subscriptions WHERE user_id=?`, userId);
|
||||
}
|
||||
|
||||
export function getPushSubscriptionsByUser(userId: number): PushSubscriptionRecord[] {
|
||||
return db
|
||||
.query<any>(`SELECT * FROM push_subscriptions WHERE user_id=?`)
|
||||
.all(userId)
|
||||
.map((r: any) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
endpoint: r.endpoint,
|
||||
p256dh: r.p256dh,
|
||||
auth: r.auth,
|
||||
createdAt: Number(r.created_at) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
|
||||
return db
|
||||
.query<any>(`SELECT * FROM push_subscriptions`)
|
||||
.all()
|
||||
.map((r: any) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
endpoint: r.endpoint,
|
||||
p256dh: r.p256dh,
|
||||
auth: r.auth,
|
||||
createdAt: Number(r.created_at) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getPendingClients(): {
|
||||
id: string;
|
||||
host: string | null;
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
handleVoiceViewerOpen,
|
||||
} from "./server/ws-voice";
|
||||
import { createNotificationPluginHandlers } from "./server/ws-notifications-plugin";
|
||||
import { loadOrGenerateVapidKeys } from "./server/web-push";
|
||||
import * as clientManager from "./clientManager";
|
||||
import * as sessionManager from "./sessions/sessionManager";
|
||||
import type { SocketData } from "./sessions/types";
|
||||
@@ -260,6 +261,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
||||
pluginState,
|
||||
getNotificationConfig,
|
||||
canUserAccessClient,
|
||||
getUserRole: (userId: number) => getUserById(userId)?.role,
|
||||
storeNotificationScreenshot: storeNotificationScreenshotForPending,
|
||||
deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord,
|
||||
savePluginState,
|
||||
@@ -526,7 +528,7 @@ async function startServer() {
|
||||
|
||||
markAllClientsOffline();
|
||||
clearNotificationScreenshots();
|
||||
|
||||
loadOrGenerateVapidKeys();
|
||||
|
||||
deleteExpiredBuilds();
|
||||
logger.info(`[db] Cleaned up expired builds`);
|
||||
|
||||
@@ -2,9 +2,13 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
getNotificationScreenshot,
|
||||
saveNotificationScreenshot,
|
||||
getAllPushSubscriptions,
|
||||
deletePushSubscription,
|
||||
type NotificationScreenshotRecord,
|
||||
type PushSubscriptionRecord,
|
||||
} from "../db";
|
||||
import { logger } from "../logger";
|
||||
import { sendWebPush } from "./web-push";
|
||||
|
||||
export type NotificationRecord = {
|
||||
id: string;
|
||||
@@ -17,7 +21,7 @@ export type NotificationRecord = {
|
||||
processPath?: string;
|
||||
pid?: number;
|
||||
keyword?: string;
|
||||
category: "active_window";
|
||||
category: "active_window" | "clipboard";
|
||||
ts: number;
|
||||
screenshotId?: string;
|
||||
};
|
||||
@@ -271,12 +275,113 @@ async function deliverToUser(
|
||||
]);
|
||||
}
|
||||
|
||||
async function deliverWebPushToAll(
|
||||
record: NotificationRecord,
|
||||
getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[],
|
||||
): Promise<void> {
|
||||
const subs = getAllPushSubscriptions();
|
||||
if (subs.length === 0) return;
|
||||
|
||||
const targets = getUserDeliveryTargets(record.clientId);
|
||||
const allowedUserIds = new Set(targets.map((t) => t.userId));
|
||||
|
||||
const title = record.keyword
|
||||
? `Overlord \u2014 ${record.keyword}`
|
||||
: "Overlord \u2014 Notification";
|
||||
const lines = [record.title];
|
||||
if (record.user) lines.push(`User: ${record.user}`);
|
||||
if (record.host) lines.push(`Host: ${record.host}`);
|
||||
if (record.process) lines.push(`Process: ${record.process}`);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: "notification",
|
||||
title,
|
||||
body: lines.filter(Boolean).join("\n"),
|
||||
tag: `overlord-${record.id || Date.now()}`,
|
||||
url: "/notifications",
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subs
|
||||
.filter((sub) => allowedUserIds.has(sub.userId))
|
||||
.map(async (sub) => {
|
||||
const result = await sendWebPush(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
);
|
||||
if (result.gone) {
|
||||
deletePushSubscription(sub.endpoint);
|
||||
logger.info(`[web-push] removed gone subscription for user ${sub.userId}`);
|
||||
} else if (!result.success) {
|
||||
logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deliverWebPushClientEvent(
|
||||
event: string,
|
||||
info: { id: string; host?: string; user?: string; os?: string },
|
||||
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
|
||||
getUserRole: (userId: number) => string | undefined,
|
||||
): Promise<void> {
|
||||
const subs = getAllPushSubscriptions();
|
||||
if (subs.length === 0) return;
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
client_online: "\u{1F7E2} Client Online",
|
||||
client_offline: "\u{1F534} Client Offline",
|
||||
client_purgatory: "\u{1F7E1} Client Awaiting Approval",
|
||||
};
|
||||
|
||||
const title = labels[event] || "Overlord \u2014 Client Event";
|
||||
const lines: string[] = [];
|
||||
if (info.host) lines.push(`Host: ${info.host}`);
|
||||
if (info.user) lines.push(`User: ${info.user}`);
|
||||
if (info.os) lines.push(`OS: ${info.os}`);
|
||||
if (info.id) lines.push(`ID: ${info.id}`);
|
||||
|
||||
const dest = event === "client_purgatory" ? "/purgatory" : "/";
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: "client_event",
|
||||
event,
|
||||
title,
|
||||
body: lines.join("\n") || info.id || "",
|
||||
tag: `overlord-client-${event}-${info.id || Date.now()}`,
|
||||
url: dest,
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subs.map(async (sub) => {
|
||||
const role = getUserRole(sub.userId);
|
||||
if (!role) return;
|
||||
if (event === "client_purgatory") {
|
||||
if (role !== "admin" && role !== "operator") return;
|
||||
} else if (role !== "admin") {
|
||||
if (!canUserAccessClient(sub.userId, role, info.id)) return;
|
||||
}
|
||||
|
||||
const result = await sendWebPush(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
);
|
||||
if (result.gone) {
|
||||
deletePushSubscription(sub.endpoint);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deliverNotificationWithScreenshot(
|
||||
record: NotificationRecord,
|
||||
getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[],
|
||||
): Promise<void> {
|
||||
const screenshot = await waitForNotificationScreenshot(record.id);
|
||||
const targets = getUserDeliveryTargets(record.clientId);
|
||||
await Promise.allSettled(targets.map((t) => deliverToUser(t, record, screenshot)));
|
||||
await Promise.allSettled([
|
||||
...targets.map((t) => deliverToUser(t, record, screenshot)),
|
||||
deliverWebPushToAll(record, getUserDeliveryTargets),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { getConfig, updateNotificationsConfig } from "../../config";
|
||||
import { savePushSubscription, deletePushSubscription, getPushSubscriptionsByUser } from "../../db";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import {
|
||||
getUserNotificationSettings,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
DEFAULT_TELEGRAM_TEMPLATE,
|
||||
renderNotificationTemplate,
|
||||
} from "../notification-delivery";
|
||||
import { getVapidPublicKey } from "../web-push";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -406,5 +408,63 @@ export async function handleNotificationsConfigRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/notifications/vapid-public-key") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return Response.json({ publicKey: getVapidPublicKey() });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/notifications/push-subscribe") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const endpoint = typeof body?.endpoint === "string" ? body.endpoint.trim() : "";
|
||||
const p256dh = typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : "";
|
||||
const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : "";
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
return Response.json({ error: "Missing subscription fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
savePushSubscription(user.userId, endpoint, p256dh, auth);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const endpoint = typeof body?.endpoint === "string" ? body.endpoint.trim() : "";
|
||||
if (!endpoint) {
|
||||
return Response.json({ error: "Missing endpoint" }, { status: 400 });
|
||||
}
|
||||
|
||||
deletePushSubscription(endpoint);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import webpush from "web-push";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { ensureDataDir } from "../paths";
|
||||
import { logger } from "../logger";
|
||||
|
||||
let vapidKeys: { publicKey: string; privateKey: string } | null = null;
|
||||
|
||||
function getVapidPath(): string {
|
||||
return resolve(ensureDataDir(), "vapid-keys.json");
|
||||
}
|
||||
|
||||
export function loadOrGenerateVapidKeys(): { publicKey: string; privateKey: string } {
|
||||
if (vapidKeys) return vapidKeys;
|
||||
|
||||
const vapidPath = getVapidPath();
|
||||
|
||||
if (existsSync(vapidPath)) {
|
||||
try {
|
||||
const raw = readFileSync(vapidPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.publicKey && parsed.privateKey) {
|
||||
vapidKeys = { publicKey: parsed.publicKey, privateKey: parsed.privateKey };
|
||||
logger.info("[web-push] loaded VAPID keys from " + vapidPath);
|
||||
return vapidKeys;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[web-push] failed to read vapid-keys.json, regenerating", err);
|
||||
}
|
||||
}
|
||||
|
||||
const generated = webpush.generateVAPIDKeys();
|
||||
vapidKeys = { publicKey: generated.publicKey, privateKey: generated.privateKey };
|
||||
|
||||
try {
|
||||
writeFileSync(vapidPath, JSON.stringify(vapidKeys, null, 2));
|
||||
logger.info("[web-push] generated and saved VAPID keys to " + vapidPath);
|
||||
} catch (err) {
|
||||
logger.warn("[web-push] failed to persist VAPID keys", err);
|
||||
}
|
||||
|
||||
return vapidKeys;
|
||||
}
|
||||
|
||||
export function getVapidPublicKey(): string {
|
||||
return loadOrGenerateVapidKeys().publicKey;
|
||||
}
|
||||
|
||||
export async function sendWebPush(
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||
payload: string,
|
||||
): Promise<{ success: boolean; gone?: boolean; error?: string }> {
|
||||
const keys = loadOrGenerateVapidKeys();
|
||||
webpush.setVapidDetails("mailto:overlord@localhost", keys.publicKey, keys.privateKey);
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(subscription, payload, { TTL: 60 * 60 });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const statusCode = err?.statusCode;
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
return { success: false, gone: true, error: `subscription gone (${statusCode})` };
|
||||
}
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { metrics } from "../metrics";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import { deliverWebPushClientEvent } from "./notification-delivery";
|
||||
|
||||
type NotificationRecord = {
|
||||
id: string;
|
||||
@@ -55,6 +56,7 @@ type CreateDeps = {
|
||||
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
|
||||
getNotificationConfig: () => NotificationConfigShape;
|
||||
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean;
|
||||
getUserRole: (userId: number) => string | undefined;
|
||||
storeNotificationScreenshot: (
|
||||
pending: PendingNotificationScreenshot,
|
||||
bytes: Uint8Array,
|
||||
@@ -350,6 +352,8 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
||||
}
|
||||
safeSendViewer(session.viewer, item);
|
||||
}
|
||||
|
||||
void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole);
|
||||
},
|
||||
|
||||
markPluginLoaded,
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
declare module "web-push" {
|
||||
interface VapidKeys {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}
|
||||
interface PushSubscription {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}
|
||||
interface RequestOptions {
|
||||
TTL?: number;
|
||||
}
|
||||
function generateVAPIDKeys(): VapidKeys;
|
||||
function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
|
||||
function sendNotification(subscription: PushSubscription, payload: string | Buffer | null, options?: RequestOptions): Promise<any>;
|
||||
export { generateVAPIDKeys, setVapidDetails, sendNotification };
|
||||
}
|
||||
Reference in New Issue
Block a user