allow easy custom branding in settings + fix some auth shit
This commit is contained in:
@@ -108,6 +108,20 @@ const appearanceForm = document.getElementById("appearance-form");
|
||||
const appearancePermissionNote = document.getElementById("appearance-permission-note");
|
||||
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
|
||||
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");
|
||||
const simpleThemeEnabledInput = document.getElementById("simple-theme-enabled");
|
||||
const simpleThemeControls = document.getElementById("simple-theme-controls");
|
||||
const simpleThemeAccentInput = document.getElementById("simple-theme-accent");
|
||||
const simpleThemeBackgroundInput = document.getElementById("simple-theme-background");
|
||||
const simpleThemeSurfaceInput = document.getElementById("simple-theme-surface");
|
||||
const simpleThemeTextInput = document.getElementById("simple-theme-text");
|
||||
const simpleThemeCornersInput = document.getElementById("simple-theme-corners");
|
||||
const simpleThemeResetBtn = document.getElementById("simple-theme-reset");
|
||||
const simpleThemePreview = document.getElementById("simple-theme-preview");
|
||||
const simpleThemePreviewBar = document.getElementById("simple-theme-preview-bar");
|
||||
const simpleThemePreviewBody = document.getElementById("simple-theme-preview-body");
|
||||
const simpleThemePreviewDot = document.getElementById("simple-theme-preview-dot");
|
||||
const simpleThemePreviewMuted = document.getElementById("simple-theme-preview-muted");
|
||||
const simpleThemePreviewButton = document.getElementById("simple-theme-preview-button");
|
||||
const brandProductNameInput = document.getElementById("brand-product-name");
|
||||
const brandTabNameInput = document.getElementById("brand-tab-name");
|
||||
const brandFaviconUrlInput = document.getElementById("brand-favicon-url");
|
||||
@@ -1409,11 +1423,218 @@ const brandInputs = [
|
||||
];
|
||||
|
||||
function setAppearanceFormDisabled(disabled) {
|
||||
for (const input of [...brandInputs, appearanceCustomCssInput, appearanceSaveBtn]) {
|
||||
const simpleThemeInputs = [
|
||||
simpleThemeEnabledInput,
|
||||
simpleThemeAccentInput,
|
||||
simpleThemeBackgroundInput,
|
||||
simpleThemeSurfaceInput,
|
||||
simpleThemeTextInput,
|
||||
simpleThemeCornersInput,
|
||||
simpleThemeResetBtn,
|
||||
];
|
||||
for (const input of [...brandInputs, ...simpleThemeInputs, appearanceCustomCssInput, appearanceSaveBtn]) {
|
||||
if (input) input.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
const SIMPLE_THEME_START = "/* === OVERLORD SIMPLE THEME START === */";
|
||||
const SIMPLE_THEME_END = "/* === OVERLORD SIMPLE THEME END === */";
|
||||
const SIMPLE_THEME_BLOCK_RE = /\/\* === OVERLORD SIMPLE THEME START === \*\/[\s\S]*?\/\* === OVERLORD SIMPLE THEME END === \*\/\s*/;
|
||||
const SIMPLE_THEME_DEFAULTS = Object.freeze({
|
||||
accent: "#7a5bff",
|
||||
background: "#020617",
|
||||
surface: "#0f172a",
|
||||
text: "#f1f5f9",
|
||||
corners: "soft",
|
||||
});
|
||||
|
||||
function normalizeThemeColor(value, fallback) {
|
||||
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value).toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function mixThemeColors(colorA, colorB, amount) {
|
||||
const parse = (color) => [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16));
|
||||
const a = parse(colorA);
|
||||
const b = parse(colorB);
|
||||
return `#${a.map((channel, i) => Math.round(channel + ((b[i] - channel) * amount)).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
function themeColorRgba(color, alpha) {
|
||||
const channels = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16));
|
||||
return `rgba(${channels.join(", ")}, ${alpha})`;
|
||||
}
|
||||
|
||||
function getSimpleThemeSettings() {
|
||||
return {
|
||||
accent: normalizeThemeColor(simpleThemeAccentInput?.value, SIMPLE_THEME_DEFAULTS.accent),
|
||||
background: normalizeThemeColor(simpleThemeBackgroundInput?.value, SIMPLE_THEME_DEFAULTS.background),
|
||||
surface: normalizeThemeColor(simpleThemeSurfaceInput?.value, SIMPLE_THEME_DEFAULTS.surface),
|
||||
text: normalizeThemeColor(simpleThemeTextInput?.value, SIMPLE_THEME_DEFAULTS.text),
|
||||
corners: ["sharp", "soft", "round"].includes(simpleThemeCornersInput?.value)
|
||||
? simpleThemeCornersInput.value
|
||||
: SIMPLE_THEME_DEFAULTS.corners,
|
||||
};
|
||||
}
|
||||
|
||||
function applySimpleThemeSettings(settings = SIMPLE_THEME_DEFAULTS) {
|
||||
const normalized = {
|
||||
accent: normalizeThemeColor(settings.accent, SIMPLE_THEME_DEFAULTS.accent),
|
||||
background: normalizeThemeColor(settings.background, SIMPLE_THEME_DEFAULTS.background),
|
||||
surface: normalizeThemeColor(settings.surface, SIMPLE_THEME_DEFAULTS.surface),
|
||||
text: normalizeThemeColor(settings.text, SIMPLE_THEME_DEFAULTS.text),
|
||||
corners: ["sharp", "soft", "round"].includes(settings.corners) ? settings.corners : SIMPLE_THEME_DEFAULTS.corners,
|
||||
};
|
||||
if (simpleThemeAccentInput) simpleThemeAccentInput.value = normalized.accent;
|
||||
if (simpleThemeBackgroundInput) simpleThemeBackgroundInput.value = normalized.background;
|
||||
if (simpleThemeSurfaceInput) simpleThemeSurfaceInput.value = normalized.surface;
|
||||
if (simpleThemeTextInput) simpleThemeTextInput.value = normalized.text;
|
||||
if (simpleThemeCornersInput) simpleThemeCornersInput.value = normalized.corners;
|
||||
updateSimpleThemePreview();
|
||||
}
|
||||
|
||||
function buildSimpleThemeCss(settings) {
|
||||
const { accent, background, surface, text, corners } = settings;
|
||||
const accentShades = {
|
||||
50: mixThemeColors(accent, "#ffffff", 0.92),
|
||||
100: mixThemeColors(accent, "#ffffff", 0.82),
|
||||
200: mixThemeColors(accent, "#ffffff", 0.68),
|
||||
300: mixThemeColors(accent, "#ffffff", 0.48),
|
||||
400: mixThemeColors(accent, "#ffffff", 0.24),
|
||||
500: accent,
|
||||
600: mixThemeColors(accent, "#000000", 0.15),
|
||||
700: mixThemeColors(accent, "#000000", 0.3),
|
||||
800: mixThemeColors(accent, "#000000", 0.45),
|
||||
900: mixThemeColors(accent, "#000000", 0.58),
|
||||
950: mixThemeColors(accent, "#000000", 0.72),
|
||||
};
|
||||
const neutralShades = {
|
||||
50: mixThemeColors(text, "#ffffff", 0.35),
|
||||
100: text,
|
||||
200: mixThemeColors(surface, text, 0.86),
|
||||
300: mixThemeColors(surface, text, 0.72),
|
||||
400: mixThemeColors(surface, text, 0.55),
|
||||
500: mixThemeColors(surface, text, 0.4),
|
||||
600: mixThemeColors(surface, text, 0.26),
|
||||
700: mixThemeColors(surface, text, 0.16),
|
||||
800: mixThemeColors(surface, text, 0.08),
|
||||
900: surface,
|
||||
950: background,
|
||||
};
|
||||
const radii = {
|
||||
sharp: ["2px", "3px", "4px", "5px"],
|
||||
soft: ["8px", "10px", "14px", "18px"],
|
||||
round: ["14px", "18px", "24px", "30px"],
|
||||
}[corners];
|
||||
const paletteNames = ["sky", "blue", "cyan", "indigo", "violet", "purple", "fuchsia"];
|
||||
const accentVariables = paletteNames.flatMap((name) => (
|
||||
Object.entries(accentShades).map(([shade, value]) => ` --color-${name}-${shade}: ${value};`)
|
||||
));
|
||||
const neutralVariables = Object.entries(neutralShades).map(([shade, value]) => ` --color-slate-${shade}: ${value};`);
|
||||
|
||||
return [
|
||||
SIMPLE_THEME_START,
|
||||
`/* settings: ${JSON.stringify(settings)} */`,
|
||||
":root {",
|
||||
...accentVariables,
|
||||
...neutralVariables,
|
||||
` --color-primary: ${accent};`,
|
||||
` --color-primary-hover: ${accentShades[400]};`,
|
||||
` --color-text: ${text};`,
|
||||
` --color-text-bright: ${neutralShades[50]};`,
|
||||
` --color-text-muted: ${neutralShades[400]};`,
|
||||
` --color-surface: ${themeColorRgba(surface, 0.72)};`,
|
||||
` --color-surface-dark: ${themeColorRgba(background, 0.88)};`,
|
||||
` --color-border: ${themeColorRgba(text, 0.12)};`,
|
||||
` --ui-border-strong: ${themeColorRgba(accent, 0.45)};`,
|
||||
` --ui-ring: ${themeColorRgba(accent, 0.28)};`,
|
||||
` --radius-sm: ${radii[0]};`,
|
||||
` --radius-md: ${radii[1]};`,
|
||||
` --radius-lg: ${radii[2]};`,
|
||||
` --radius-xl: ${radii[3]};`,
|
||||
` --radius-2xl: ${radii[3]};`,
|
||||
"}",
|
||||
"html, body {",
|
||||
` background-color: ${background} !important;`,
|
||||
` color: ${text};`,
|
||||
"}",
|
||||
SIMPLE_THEME_END,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function replaceSimpleThemeBlock(css, replacement = "") {
|
||||
const withoutManagedBlock = String(css || "").replace(SIMPLE_THEME_BLOCK_RE, "").trim();
|
||||
return [replacement, withoutManagedBlock].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
function syncSimpleThemeCss() {
|
||||
if (!appearanceCustomCssInput) return;
|
||||
const enabled = !!simpleThemeEnabledInput?.checked;
|
||||
const settings = getSimpleThemeSettings();
|
||||
const replacement = enabled ? buildSimpleThemeCss(settings) : "";
|
||||
if (enabled && brandAccentColorInput) brandAccentColorInput.value = settings.accent;
|
||||
appearanceCustomCssInput.value = replaceSimpleThemeBlock(appearanceCustomCssInput.value, replacement);
|
||||
if (simpleThemeControls) simpleThemeControls.classList.toggle("opacity-50", !enabled);
|
||||
}
|
||||
|
||||
function loadSimpleThemeFromCss(css) {
|
||||
const block = String(css || "").match(SIMPLE_THEME_BLOCK_RE)?.[0] || "";
|
||||
if (simpleThemeEnabledInput) simpleThemeEnabledInput.checked = !!block;
|
||||
if (block) {
|
||||
const settingsText = block.match(/\/\* settings: (\{.*\}) \*\//)?.[1];
|
||||
try {
|
||||
applySimpleThemeSettings(settingsText ? JSON.parse(settingsText) : SIMPLE_THEME_DEFAULTS);
|
||||
} catch {
|
||||
applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS);
|
||||
}
|
||||
} else {
|
||||
applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS);
|
||||
}
|
||||
if (simpleThemeControls) simpleThemeControls.classList.toggle("opacity-50", !block);
|
||||
}
|
||||
|
||||
function updateSimpleThemePreview() {
|
||||
const { accent, background, surface, text, corners } = getSimpleThemeSettings();
|
||||
const radius = { sharp: "4px", soft: "12px", round: "22px" }[corners];
|
||||
if (simpleThemePreview) {
|
||||
simpleThemePreview.style.borderColor = mixThemeColors(surface, text, 0.2);
|
||||
simpleThemePreview.style.borderRadius = radius;
|
||||
}
|
||||
if (simpleThemePreviewBar) {
|
||||
simpleThemePreviewBar.style.backgroundColor = mixThemeColors(surface, background, 0.35);
|
||||
simpleThemePreviewBar.style.color = text;
|
||||
}
|
||||
if (simpleThemePreviewBody) {
|
||||
simpleThemePreviewBody.style.backgroundColor = surface;
|
||||
simpleThemePreviewBody.style.color = text;
|
||||
}
|
||||
if (simpleThemePreviewDot) simpleThemePreviewDot.style.backgroundColor = accent;
|
||||
if (simpleThemePreviewMuted) simpleThemePreviewMuted.style.color = mixThemeColors(surface, text, 0.6);
|
||||
if (simpleThemePreviewButton) {
|
||||
simpleThemePreviewButton.style.backgroundColor = accent;
|
||||
simpleThemePreviewButton.style.borderRadius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
function initSimpleThemeBuilder() {
|
||||
const inputs = [simpleThemeAccentInput, simpleThemeBackgroundInput, simpleThemeSurfaceInput, simpleThemeTextInput, simpleThemeCornersInput];
|
||||
for (const input of inputs) {
|
||||
input?.addEventListener("input", () => {
|
||||
if (simpleThemeEnabledInput && !simpleThemeEnabledInput.checked) simpleThemeEnabledInput.checked = true;
|
||||
if (input === simpleThemeAccentInput && brandAccentColorInput) brandAccentColorInput.value = simpleThemeAccentInput.value;
|
||||
updateSimpleThemePreview();
|
||||
syncSimpleThemeCss();
|
||||
});
|
||||
}
|
||||
simpleThemeEnabledInput?.addEventListener("change", syncSimpleThemeCss);
|
||||
simpleThemeResetBtn?.addEventListener("click", () => {
|
||||
applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS);
|
||||
if (simpleThemeEnabledInput) simpleThemeEnabledInput.checked = true;
|
||||
if (brandAccentColorInput) brandAccentColorInput.value = SIMPLE_THEME_DEFAULTS.accent;
|
||||
syncSimpleThemeCss();
|
||||
});
|
||||
updateSimpleThemePreview();
|
||||
}
|
||||
|
||||
function applyBrandingForm(loginBranding = {}) {
|
||||
if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord";
|
||||
if (brandTabNameInput) brandTabNameInput.value = loginBranding.tabName || loginBranding.productName || "Overlord";
|
||||
@@ -1558,6 +1779,7 @@ async function loadAppearanceSettings() {
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (appearanceCustomCssInput) appearanceCustomCssInput.value = data.customCSS || "";
|
||||
loadSimpleThemeFromCss(data.customCSS || "");
|
||||
applyBrandingForm(data.loginBranding || {});
|
||||
setAppearanceFormDisabled(false);
|
||||
} catch {
|
||||
@@ -1571,6 +1793,7 @@ async function saveAppearanceSettings(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncSimpleThemeCss();
|
||||
const customCSS = appearanceCustomCssInput ? appearanceCustomCssInput.value : "";
|
||||
if (customCSS.length > 51200) {
|
||||
showMessage("CSS exceeds the 50 KB size limit.", "error");
|
||||
@@ -2720,6 +2943,7 @@ async function init() {
|
||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||
if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings);
|
||||
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
||||
initSimpleThemeBuilder();
|
||||
initBrandingUploads();
|
||||
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
|
||||
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
|
||||
|
||||
@@ -1405,6 +1405,67 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="rounded-xl border border-slate-700 bg-slate-950/50 p-4 space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-200">Simple theme</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Pick the main colors and corner style. Overlord builds the CSS for you.</p>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input id="simple-theme-enabled" type="checkbox" class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-fuchsia-500 focus:ring-fuchsia-500" />
|
||||
Use simple theme
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="simple-theme-controls" class="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">Accent</span>
|
||||
<input id="simple-theme-accent" type="color" value="#7a5bff" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">Page background</span>
|
||||
<input id="simple-theme-background" type="color" value="#020617" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">Cards & panels</span>
|
||||
<input id="simple-theme-surface" type="color" value="#0f172a" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">Text</span>
|
||||
<input id="simple-theme-text" type="color" value="#f1f5f9" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
|
||||
</label>
|
||||
<label class="block col-span-2 lg:col-span-1">
|
||||
<span class="text-xs text-slate-400">Corners</span>
|
||||
<select id="simple-theme-corners" class="mt-1 h-10 w-full px-3 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500 text-sm">
|
||||
<option value="sharp">Sharp</option>
|
||||
<option value="soft" selected>Soft</option>
|
||||
<option value="round">Round</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="simple-theme-preview" class="overflow-hidden rounded-xl border border-slate-700">
|
||||
<div id="simple-theme-preview-bar" class="flex items-center gap-2 px-3 py-2">
|
||||
<span id="simple-theme-preview-dot" class="h-2.5 w-2.5 rounded-full"></span>
|
||||
<span class="text-xs font-medium">Theme preview</span>
|
||||
</div>
|
||||
<div id="simple-theme-preview-body" class="flex items-center justify-between gap-3 p-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Cards, text, and buttons</div>
|
||||
<div id="simple-theme-preview-muted" class="mt-0.5 text-xs">Your custom palette is shown here.</div>
|
||||
</div>
|
||||
<button id="simple-theme-preview-button" type="button" tabindex="-1" class="px-3 py-1.5 text-xs font-medium text-white">Primary</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button id="simple-theme-reset" type="button" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800 hover:bg-slate-700 border border-slate-700 text-xs text-slate-200 transition-colors">
|
||||
<i class="fa-solid fa-rotate-left"></i>Reset colors
|
||||
</button>
|
||||
<p class="text-xs text-slate-500">Changes are added to the managed block below. Your handwritten CSS is preserved.</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Custom CSS</span>
|
||||
<textarea
|
||||
|
||||
@@ -135,6 +135,12 @@ export async function handleClientCommandRoute(
|
||||
metrics.recordCommand("screenshot");
|
||||
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.SCREENSHOT, targetClientId: targetId, success: true });
|
||||
} else if (action === "desktop_start") {
|
||||
try {
|
||||
requireFeatureAccess(user, "remote_desktop");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
|
||||
metrics.recordCommand("desktop_start");
|
||||
} else if (action === "darwin_request_permissions") {
|
||||
|
||||
@@ -138,6 +138,23 @@ describe("clients:disconnect gates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote desktop alternate command gates", () => {
|
||||
test("operator with remote_desktop feature OFF cannot start the desktop stream", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
|
||||
const res = await command(auth.token, clientId, "desktop_start");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("operator with remote_desktop feature ON can start the desktop stream", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
const res = await command(auth.token, clientId, "desktop_start");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clients:reconnect gates", () => {
|
||||
test("operator (default) can reconnect", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../../auth";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { handleWebrtcRoutes } from "./webrtc-routes";
|
||||
|
||||
const PASSWORD = "Aa1!WebrtcAuthTestPass_2026";
|
||||
const createdUserIds: number[] = [];
|
||||
|
||||
async function makeOperator() {
|
||||
const username = `wa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const result = await createUser(username, PASSWORD, "operator", "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserById(result.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
setUserClientAccessScope(user!.id, "all");
|
||||
return { user: user!, token: await generateToken(user!) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (createdUserIds.length) deleteUser(createdUserIds.pop()!);
|
||||
});
|
||||
|
||||
describe("WebRTC viewer authorization", () => {
|
||||
test("denies WHEP negotiation when the matching feature is disabled", async () => {
|
||||
const auth = await makeOperator();
|
||||
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
|
||||
const url = new URL("https://localhost/api/webrtc/agents/client-a/desktop/whep");
|
||||
const response = await handleWebrtcRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: "offer",
|
||||
}),
|
||||
url,
|
||||
);
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("uses the feature corresponding to each media kind", async () => {
|
||||
const auth = await makeOperator();
|
||||
setUserFeaturePermission(auth.user.id, "voice", false);
|
||||
const url = new URL("https://localhost/api/webrtc/agents/client-a/audio/whep");
|
||||
const response = await handleWebrtcRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: "offer",
|
||||
}),
|
||||
url,
|
||||
);
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { requireClientAccess } from "../../rbac";
|
||||
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
|
||||
import { logger } from "../../logger";
|
||||
import { issueTurnIceServers } from "../turn-credentials";
|
||||
|
||||
@@ -27,6 +27,13 @@ export function issueWebrtcPublishToken(clientId: string): string {
|
||||
|
||||
export type WebrtcKind = "desktop" | "backstage" | "webcam" | "audio";
|
||||
|
||||
const WEBRTC_FEATURES = {
|
||||
desktop: "remote_desktop",
|
||||
backstage: "backstage",
|
||||
webcam: "webcam",
|
||||
audio: "voice",
|
||||
} as const;
|
||||
|
||||
export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string {
|
||||
return `agents/${clientId}/${kind}`;
|
||||
}
|
||||
@@ -78,6 +85,8 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try {
|
||||
requireClientAccess(user, clientId);
|
||||
const streamKind = streamPath.split("/")[2] as WebrtcKind;
|
||||
requireFeatureAccess(user, WEBRTC_FEATURES[streamKind]);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
|
||||
@@ -24,6 +24,12 @@ import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
||||
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
||||
import { clearThumbnail } from "../../thumbnails";
|
||||
import { stopRemoteDesktopRecording } from "../rd-recording";
|
||||
import {
|
||||
isAuthenticatedViewerRole,
|
||||
registerViewerSocket,
|
||||
unregisterViewerSocket,
|
||||
validateViewerAuthorization,
|
||||
} from "../viewer-authorization";
|
||||
|
||||
const OFFLINE_GRACE_MS = (() => {
|
||||
const raw = process.env.OVERLORD_OFFLINE_GRACE_MS;
|
||||
@@ -324,6 +330,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
||||
const role = ws.data.role as string;
|
||||
const clientId = ws.data.clientId;
|
||||
const ip = ws.data.ip;
|
||||
if (isAuthenticatedViewerRole(ws.data.role) && !registerViewerSocket(ws)) return;
|
||||
if (role === "dashboard_viewer") return deps.handleDashboardViewerOpen(ws);
|
||||
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
||||
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
||||
@@ -375,6 +382,7 @@ export async function handleWebSocketMessage(
|
||||
}
|
||||
|
||||
const socketRole = ws.data.role as string;
|
||||
if (isAuthenticatedViewerRole(ws.data.role) && !validateViewerAuthorization(ws)) return;
|
||||
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
|
||||
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
|
||||
if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message);
|
||||
@@ -973,6 +981,7 @@ export function handleWebSocketClose(
|
||||
reason: unknown,
|
||||
deps: WsLifecycleDeps,
|
||||
): void {
|
||||
unregisterViewerSocket(ws);
|
||||
const clientId = ws.data.clientId;
|
||||
const role = ws.data.role as string;
|
||||
const sessionId = ws.data.sessionId;
|
||||
|
||||
@@ -226,6 +226,8 @@ async function tryGlobalViewerUpgrade(
|
||||
const denied = route.authorize?.(user);
|
||||
if (denied) return denied;
|
||||
|
||||
const token = extractTokenFromRequest(req);
|
||||
|
||||
return upgradeOrFail(req, server, {
|
||||
role: route.role,
|
||||
clientId: "",
|
||||
@@ -233,6 +235,7 @@ async function tryGlobalViewerUpgrade(
|
||||
userRole: user.role,
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
...(token ? { authTokenHash: hashTokenForSession(token) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../auth";
|
||||
import { hashTokenForSession, revokeSessionByTokenHash } from "../db";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../users";
|
||||
import { validateViewerAuthorization } from "./viewer-authorization";
|
||||
|
||||
const PASSWORD = "Aa1!ViewerRevalidationTest_2026";
|
||||
const createdUserIds: number[] = [];
|
||||
|
||||
async function makeViewerSocket(role: SocketData["role"], clientId = "client-a") {
|
||||
const username = `wr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const result = await createUser(username, PASSWORD, "operator", "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserById(result.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
setUserClientAccessScope(user!.id, "all");
|
||||
const token = await generateToken(user!);
|
||||
const closes: Array<{ code: number; reason: string }> = [];
|
||||
const ws = {
|
||||
data: {
|
||||
role,
|
||||
clientId,
|
||||
userId: user!.id,
|
||||
userRole: user!.role,
|
||||
username: user!.username,
|
||||
authTokenHash: hashTokenForSession(token),
|
||||
},
|
||||
close(code: number, reason: string) {
|
||||
closes.push({ code, reason });
|
||||
},
|
||||
} as any;
|
||||
return { user: user!, token, ws, closes };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (createdUserIds.length) deleteUser(createdUserIds.pop()!);
|
||||
});
|
||||
|
||||
describe("live viewer WebSocket authorization", () => {
|
||||
test("closes a socket after its login session is revoked", async () => {
|
||||
const auth = await makeViewerSocket("rd_viewer");
|
||||
expect(validateViewerAuthorization(auth.ws)).toBe(true);
|
||||
revokeSessionByTokenHash(hashTokenForSession(auth.token));
|
||||
expect(validateViewerAuthorization(auth.ws)).toBe(false);
|
||||
expect(auth.closes).toEqual([{ code: 1008, reason: "Authentication expired" }]);
|
||||
});
|
||||
|
||||
test("closes a socket when its feature is revoked after connection", async () => {
|
||||
const auth = await makeViewerSocket("rd_viewer");
|
||||
expect(validateViewerAuthorization(auth.ws)).toBe(true);
|
||||
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
|
||||
expect(validateViewerAuthorization(auth.ws)).toBe(false);
|
||||
expect(auth.closes[0]?.reason).toBe("Forbidden: feature access denied");
|
||||
});
|
||||
|
||||
test("closes a socket when client scope is removed after connection", async () => {
|
||||
const auth = await makeViewerSocket("console_viewer");
|
||||
expect(validateViewerAuthorization(auth.ws)).toBe(true);
|
||||
setUserClientAccessScope(auth.user.id, "none");
|
||||
expect(validateViewerAuthorization(auth.ws)).toBe(false);
|
||||
expect(auth.closes[0]?.reason).toBe("Forbidden: client access denied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { getSessionByTokenHash } from "../db";
|
||||
import { hasPermission } from "../rbac";
|
||||
import type { SocketData, SocketRole } from "../sessions/types";
|
||||
import {
|
||||
canUserAccessClient,
|
||||
canUserAccessFeature,
|
||||
getUserById,
|
||||
type FeatureName,
|
||||
} from "../users";
|
||||
|
||||
const VIEWER_FEATURES: Partial<Record<SocketRole, FeatureName>> = {
|
||||
console_viewer: "console",
|
||||
rd_viewer: "remote_desktop",
|
||||
backstage_viewer: "backstage",
|
||||
webcam_viewer: "webcam",
|
||||
file_browser_viewer: "file_browser",
|
||||
process_viewer: "processes",
|
||||
keylogger_viewer: "keylogger",
|
||||
voice_viewer: "voice",
|
||||
desktop_audio_viewer: "voice",
|
||||
};
|
||||
|
||||
const GLOBAL_VIEWER_ROLES = new Set<SocketRole>([
|
||||
"notifications_viewer",
|
||||
"dashboard_viewer",
|
||||
"chat_viewer",
|
||||
]);
|
||||
|
||||
const activeViewerSockets = new Set<ServerWebSocket<SocketData>>();
|
||||
|
||||
function deny(ws: ServerWebSocket<SocketData>, reason: string): false {
|
||||
activeViewerSockets.delete(ws);
|
||||
try {
|
||||
ws.close(1008, reason);
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAuthenticatedViewerRole(role: SocketRole): boolean {
|
||||
return VIEWER_FEATURES[role] !== undefined || GLOBAL_VIEWER_ROLES.has(role);
|
||||
}
|
||||
|
||||
export function validateViewerAuthorization(ws: ServerWebSocket<SocketData>): boolean {
|
||||
const role = ws.data?.role;
|
||||
if (!isAuthenticatedViewerRole(role)) return true;
|
||||
|
||||
const { authTokenHash, userId } = ws.data;
|
||||
if (!authTokenHash || userId === undefined) {
|
||||
return deny(ws, "Authentication expired");
|
||||
}
|
||||
|
||||
const session = getSessionByTokenHash(authTokenHash);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!session || session.userId !== userId || session.expiresAt <= now) {
|
||||
return deny(ws, "Authentication expired");
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) return deny(ws, "Authentication expired");
|
||||
|
||||
ws.data.userRole = user.role;
|
||||
ws.data.username = user.username;
|
||||
|
||||
const feature = VIEWER_FEATURES[role];
|
||||
if (feature) {
|
||||
if (!canUserAccessClient(userId, user.role, ws.data.clientId)) {
|
||||
return deny(ws, "Forbidden: client access denied");
|
||||
}
|
||||
if (!canUserAccessFeature(userId, user.role, feature)) {
|
||||
return deny(ws, "Forbidden: feature access denied");
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "chat_viewer" && !hasPermission(user.role, "chat:write", userId)) {
|
||||
return deny(ws, "Forbidden: chat access denied");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerViewerSocket(ws: ServerWebSocket<SocketData>): boolean {
|
||||
if (!validateViewerAuthorization(ws)) return false;
|
||||
activeViewerSockets.add(ws);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unregisterViewerSocket(ws: ServerWebSocket<SocketData>): void {
|
||||
activeViewerSockets.delete(ws);
|
||||
}
|
||||
|
||||
export function revalidateActiveViewerSockets(): void {
|
||||
for (const ws of activeViewerSockets) validateViewerAuthorization(ws);
|
||||
}
|
||||
|
||||
const viewerAuthTimer = setInterval(revalidateActiveViewerSockets, 5_000);
|
||||
(viewerAuthTimer as any).unref?.();
|
||||
Reference in New Issue
Block a user