499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
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,
|
|
updateUserNotificationSettings,
|
|
} from "../../users";
|
|
import {
|
|
DEFAULT_WEBHOOK_TEMPLATE,
|
|
DEFAULT_TELEGRAM_TEMPLATE,
|
|
renderNotificationTemplate,
|
|
} from "../notification-delivery";
|
|
import { getVapidPublicKey } from "../web-push";
|
|
|
|
type RequestIpProvider = {
|
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
|
};
|
|
|
|
type NotificationsRouteDeps = {
|
|
getNotificationScreenshot: (notificationId: string) => { format?: string; bytes: Uint8Array } | null;
|
|
secureHeaders: (contentType?: string) => HeadersInit;
|
|
};
|
|
|
|
export async function handleNotificationsConfigRoutes(
|
|
req: Request,
|
|
url: URL,
|
|
server: RequestIpProvider,
|
|
deps: NotificationsRouteDeps,
|
|
): Promise<Response | null> {
|
|
const screenshotMatch = url.pathname.match(/^\/api\/notifications\/([^/]+)\/screenshot$/);
|
|
if (req.method === "GET" && screenshotMatch) {
|
|
const user = await authenticateRequest(req);
|
|
if (!user) {
|
|
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
const notificationId = decodeURIComponent(screenshotMatch[1]);
|
|
const screenshot = deps.getNotificationScreenshot(notificationId);
|
|
if (!screenshot) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
|
|
const format = (screenshot.format || "jpeg").toLowerCase();
|
|
const contentType = format === "jpg" || format === "jpeg"
|
|
? "image/jpeg"
|
|
: format === "png"
|
|
? "image/png"
|
|
: format === "webp"
|
|
? "image/webp"
|
|
: "application/octet-stream";
|
|
|
|
return new Response(screenshot.bytes as unknown as BodyInit, {
|
|
headers: deps.secureHeaders(contentType),
|
|
});
|
|
}
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/notifications/config") {
|
|
const user = await authenticateRequest(req);
|
|
if (!user) {
|
|
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (user.role === "viewer") {
|
|
return new Response("Forbidden: Viewer access denied", { status: 403 });
|
|
}
|
|
return Response.json({ notifications: getConfig().notifications });
|
|
}
|
|
|
|
if (req.method === "PUT" && url.pathname === "/api/notifications/config") {
|
|
const user = await authenticateRequest(req);
|
|
if (!user) {
|
|
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (user.role !== "admin") {
|
|
return new Response("Forbidden: Admin access required", {
|
|
status: 403,
|
|
});
|
|
}
|
|
|
|
let body: any = {};
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const currentConfig = getConfig().notifications;
|
|
const hasKeywords = Array.isArray(body?.keywords);
|
|
const rawKeywords = hasKeywords ? body.keywords : currentConfig.keywords || [];
|
|
const keywords = rawKeywords
|
|
.map((k: any) => String(k).trim())
|
|
.filter(Boolean)
|
|
.slice(0, 200);
|
|
|
|
for (const kw of keywords) {
|
|
if (kw.startsWith("/")) {
|
|
const lastSlash = kw.lastIndexOf("/", kw.length - 1);
|
|
if (lastSlash <= 0) {
|
|
return Response.json(
|
|
{ error: `Invalid regex keyword (missing closing /): ${kw}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const pattern = kw.slice(1, lastSlash);
|
|
const flags = kw.slice(lastSlash + 1);
|
|
const invalidFlags = flags.replace(/[gimsuy]/g, "");
|
|
if (invalidFlags.length > 0) {
|
|
return Response.json(
|
|
{ error: `Invalid regex flags '${invalidFlags}' in keyword: ${kw}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
try {
|
|
new RegExp(pattern, flags);
|
|
} catch (e: any) {
|
|
return Response.json(
|
|
{ error: `Invalid regex in keyword '${kw}': ${e?.message ?? e}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const webhookEnabled =
|
|
typeof body?.webhookEnabled === "boolean"
|
|
? body.webhookEnabled
|
|
: currentConfig.webhookEnabled;
|
|
const webhookUrl =
|
|
typeof body?.webhookUrl === "string"
|
|
? body.webhookUrl.trim()
|
|
: currentConfig.webhookUrl || "";
|
|
const telegramEnabled =
|
|
typeof body?.telegramEnabled === "boolean"
|
|
? body.telegramEnabled
|
|
: currentConfig.telegramEnabled;
|
|
const telegramBotToken =
|
|
typeof body?.telegramBotToken === "string"
|
|
? body.telegramBotToken.trim()
|
|
: currentConfig.telegramBotToken || "";
|
|
const telegramChatId =
|
|
typeof body?.telegramChatId === "string"
|
|
? body.telegramChatId.trim()
|
|
: currentConfig.telegramChatId || "";
|
|
const clipboardEnabled =
|
|
typeof body?.clipboardEnabled === "boolean"
|
|
? body.clipboardEnabled
|
|
: currentConfig.clipboardEnabled || false;
|
|
|
|
if (webhookUrl) {
|
|
try {
|
|
const parsed = new URL(webhookUrl);
|
|
if (!/^https?:$/.test(parsed.protocol)) {
|
|
return Response.json(
|
|
{ error: "Webhook URL must be http(s)" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
} catch {
|
|
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
const antiSpamMaxHits =
|
|
typeof body?.antiSpamMaxHits === "number"
|
|
? Math.max(1, Math.min(10000, Math.floor(body.antiSpamMaxHits)))
|
|
: currentConfig.antiSpamMaxHits;
|
|
const antiSpamWindowMs =
|
|
typeof body?.antiSpamWindowMs === "number"
|
|
? Math.max(5000, Math.min(86400000, Math.floor(body.antiSpamWindowMs)))
|
|
: currentConfig.antiSpamWindowMs;
|
|
const antiSpamCooldownMs =
|
|
typeof body?.antiSpamCooldownMs === "number"
|
|
? Math.max(5000, Math.min(86400000, Math.floor(body.antiSpamCooldownMs)))
|
|
: currentConfig.antiSpamCooldownMs;
|
|
|
|
const updated = await updateNotificationsConfig({
|
|
keywords,
|
|
webhookEnabled,
|
|
webhookUrl,
|
|
telegramEnabled,
|
|
telegramBotToken,
|
|
telegramChatId,
|
|
clipboardEnabled,
|
|
antiSpamMaxHits,
|
|
antiSpamWindowMs,
|
|
antiSpamCooldownMs,
|
|
});
|
|
|
|
for (const client of clientManager.getAllClients().values()) {
|
|
if (client.role !== "client") continue;
|
|
try {
|
|
client.ws.send(
|
|
encodeMessage({
|
|
type: "notification_config",
|
|
keywords: updated.keywords || [],
|
|
minIntervalMs: updated.minIntervalMs || 8000,
|
|
clipboardEnabled: updated.clipboardEnabled || false,
|
|
}),
|
|
);
|
|
} catch {}
|
|
}
|
|
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: user.username,
|
|
ip: server.requestIP(req)?.address || "unknown",
|
|
action: AuditAction.COMMAND,
|
|
details: `Updated notification keywords (${updated.keywords.length})`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ ok: true, notifications: updated });
|
|
}
|
|
|
|
if (url.pathname === "/api/notifications/my-settings") {
|
|
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 === "GET") {
|
|
const settings = getUserNotificationSettings(user.userId) ?? {
|
|
webhook_enabled: 0,
|
|
webhook_url: null,
|
|
webhook_template: null,
|
|
telegram_enabled: 0,
|
|
telegram_bot_token: null,
|
|
telegram_chat_id: null,
|
|
telegram_template: null,
|
|
client_event_webhook: 1,
|
|
client_event_telegram: 1,
|
|
client_event_push: 1,
|
|
};
|
|
return Response.json({
|
|
settings,
|
|
defaults: {
|
|
webhookTemplate: DEFAULT_WEBHOOK_TEMPLATE,
|
|
telegramTemplate: DEFAULT_TELEGRAM_TEMPLATE,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (req.method === "PUT") {
|
|
let body: any = {};
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const patch: Record<string, any> = {};
|
|
|
|
if (typeof body?.webhook_enabled === "boolean" || typeof body?.webhook_enabled === "number") {
|
|
patch.webhook_enabled = body.webhook_enabled ? 1 : 0;
|
|
}
|
|
if (typeof body?.webhook_url === "string") {
|
|
const url_val = body.webhook_url.trim();
|
|
if (url_val) {
|
|
try {
|
|
const parsed = new URL(url_val);
|
|
if (!/^https?:$/.test(parsed.protocol)) {
|
|
return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
|
|
}
|
|
} catch {
|
|
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
|
}
|
|
}
|
|
patch.webhook_url = url_val || null;
|
|
}
|
|
if (typeof body?.webhook_template === "string") {
|
|
patch.webhook_template = body.webhook_template.trim() || null;
|
|
}
|
|
if (typeof body?.telegram_enabled === "boolean" || typeof body?.telegram_enabled === "number") {
|
|
patch.telegram_enabled = body.telegram_enabled ? 1 : 0;
|
|
}
|
|
if (typeof body?.telegram_bot_token === "string") {
|
|
patch.telegram_bot_token = body.telegram_bot_token.trim() || null;
|
|
}
|
|
if (typeof body?.telegram_chat_id === "string") {
|
|
patch.telegram_chat_id = body.telegram_chat_id.trim() || null;
|
|
}
|
|
if (typeof body?.telegram_template === "string") {
|
|
patch.telegram_template = body.telegram_template.trim() || null;
|
|
}
|
|
if (typeof body?.client_event_webhook === "boolean" || typeof body?.client_event_webhook === "number") {
|
|
patch.client_event_webhook = body.client_event_webhook ? 1 : 0;
|
|
}
|
|
if (typeof body?.client_event_telegram === "boolean" || typeof body?.client_event_telegram === "number") {
|
|
patch.client_event_telegram = body.client_event_telegram ? 1 : 0;
|
|
}
|
|
if (typeof body?.client_event_push === "boolean" || typeof body?.client_event_push === "number") {
|
|
patch.client_event_push = body.client_event_push ? 1 : 0;
|
|
}
|
|
|
|
const result = updateUserNotificationSettings(user.userId, patch);
|
|
if (!result.success) {
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
const updated = getUserNotificationSettings(user.userId);
|
|
return Response.json({ ok: true, settings: updated });
|
|
}
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/notifications/my-settings/preview/webhook") {
|
|
const user = await authenticateRequest(req);
|
|
if (!user) {
|
|
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: any = {};
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const webhookUrl = typeof body?.webhookUrl === "string" ? body.webhookUrl.trim() : "";
|
|
const webhookTemplate = typeof body?.webhookTemplate === "string" ? body.webhookTemplate : "";
|
|
|
|
if (!webhookUrl) {
|
|
return Response.json({ error: "Webhook URL is required" }, { status: 400 });
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(webhookUrl);
|
|
if (!/^https?:$/.test(parsed.protocol)) {
|
|
return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
|
|
}
|
|
} catch {
|
|
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
|
}
|
|
|
|
const sampleRecord = {
|
|
id: "preview-notification",
|
|
clientId: "abc123def456",
|
|
host: "DESKTOP-7G2ABK1",
|
|
user: "john.doe",
|
|
os: "windows",
|
|
title: "Online Banking - Secure Login",
|
|
process: "chrome.exe",
|
|
processPath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
pid: 4392,
|
|
keyword: "bank",
|
|
category: "active_window" as const,
|
|
ts: Date.now(),
|
|
};
|
|
|
|
const payload = renderNotificationTemplate(
|
|
webhookTemplate,
|
|
sampleRecord,
|
|
DEFAULT_WEBHOOK_TEMPLATE,
|
|
);
|
|
|
|
const canonicalPayload = JSON.stringify({ type: "notification", data: sampleRecord });
|
|
const customTemplate = webhookTemplate.trim();
|
|
let jsonPayloadToSend = canonicalPayload;
|
|
if (customTemplate) {
|
|
try {
|
|
jsonPayloadToSend = JSON.stringify(JSON.parse(payload));
|
|
} catch {
|
|
jsonPayloadToSend = canonicalPayload;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname);
|
|
let response: Response;
|
|
let sentPayload = jsonPayloadToSend;
|
|
let sentMode: "json" | "discord" = "json";
|
|
|
|
if (isDiscord) {
|
|
sentMode = "discord";
|
|
const discordPayload = {
|
|
content: `🔔 Notification: ${sampleRecord.title}`,
|
|
embeds: [
|
|
{
|
|
title: sampleRecord.keyword ? `Keyword: ${sampleRecord.keyword}` : "Active Window",
|
|
description: sampleRecord.title,
|
|
fields: [
|
|
{ name: "Client", value: sampleRecord.clientId || "unknown", inline: true },
|
|
{ name: "User", value: sampleRecord.user || "unknown", inline: true },
|
|
{ name: "Host", value: sampleRecord.host || "unknown", inline: true },
|
|
{ name: "Process", value: sampleRecord.process || "unknown", inline: true },
|
|
],
|
|
timestamp: new Date(sampleRecord.ts).toISOString(),
|
|
},
|
|
],
|
|
};
|
|
sentPayload = JSON.stringify(discordPayload);
|
|
response = await fetch(parsed.toString(), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: sentPayload,
|
|
});
|
|
} else {
|
|
response = await fetch(parsed.toString(), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: jsonPayloadToSend,
|
|
});
|
|
}
|
|
|
|
const responseText = await response.text().catch(() => "");
|
|
|
|
return Response.json({
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
mode: sentMode,
|
|
payload: sentPayload,
|
|
responseBody: responseText ? responseText.slice(0, 500) : "",
|
|
});
|
|
} catch (err: any) {
|
|
return Response.json(
|
|
{ error: err?.message || "Failed to POST preview webhook" },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|