custom branding

This commit is contained in:
KingKDot
2026-07-08 21:47:57 -05:00
parent faac5b6029
commit b29d5b4291
13 changed files with 735 additions and 31 deletions
+104 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import { extractTokenFromCookie, extractTokenFromHeader } from "./auth";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
import { getConfig, updateAppearanceConfig, type Config } from "./config";
import { generateTotpCode } from "./mfa";
import * as fs from "fs/promises";
import path from "path";
import {
createUser,
deleteUser,
@@ -9,11 +12,25 @@ import {
setUserMfaSecret,
} from "./users";
import { handleAuthRoutes } from "./server/routes/auth-routes";
import { handleMiscRoutes } from "./server/routes/misc-routes";
const PASSWORD = "Aa1!AuthMfaTest_2026";
const mockServer = {
requestIP: () => ({ address: "127.0.0.1" }),
};
let originalAppearance: Config["appearance"];
const uploadPublicRoot = path.resolve(".test-data", "branding-upload-public");
beforeAll(() => {
originalAppearance = JSON.parse(JSON.stringify(getConfig().appearance));
});
afterAll(async () => {
if (originalAppearance) {
await updateAppearanceConfig(originalAppearance.customCSS, originalAppearance.loginBranding);
}
await fs.rm(uploadPublicRoot, { recursive: true, force: true });
});
describe("auth token extraction", () => {
test("extractTokenFromHeader returns bearer token", () => {
@@ -39,8 +56,93 @@ describe("login branding", () => {
const body = (await res!.json()) as any;
expect(body.productName).toBe("Overlord");
expect(body.navName).toBe("Overlord");
expect(body.title).toBe("Welcome back");
expect(body.logoUrl).toBe("");
expect(body.navLogoUrl).toBe("");
expect(body.heroImageUrl).toBe("");
expect(body.accentColor).toBe("#7a5bff");
});
test("returns sanitized enterprise branding fields", async () => {
await updateAppearanceConfig("", {
productName: "Acme SOC",
navName: "Acme Console",
title: "Welcome to Acme",
subtitle: "Use your Acme identity",
iconClass: "fa-solid fa-shield-halved<script>",
logoUrl: "javascript:alert(1)",
navLogoUrl: "/assets/acme-nav.png",
heroImageUrl: "https://cdn.example.test/login.jpg",
accentColor: "#14B8A6",
footerText: "Authorized access only",
supportText: "Need help?",
supportUrl: "https://help.example.test",
});
const url = new URL("https://localhost/api/login/branding");
const res = await handleAuthRoutes(new Request(url), url, mockServer);
expect(res?.status).toBe(200);
const body = (await res!.json()) as any;
expect(body.productName).toBe("Acme SOC");
expect(body.navName).toBe("Acme Console");
expect(body.iconClass).toBe("fa-solid fa-shield-halvedscript");
expect(body.logoUrl).toBe("");
expect(body.navLogoUrl).toBe("/assets/acme-nav.png");
expect(body.heroImageUrl).toBe("https://cdn.example.test/login.jpg");
expect(body.accentColor).toBe("#14b8a6");
expect(body.footerText).toBe("Authorized access only");
expect(body.supportUrl).toBe("https://help.example.test/");
});
});
describe("branding uploads", () => {
test("accepts admin image upload and returns a branding asset URL", async () => {
const username = `branding_upload_${Date.now().toString(36)}`;
const created = await createUser(username, PASSWORD, "admin", "test");
expect(created.success).toBe(true);
try {
await fs.mkdir(uploadPublicRoot, { recursive: true });
const user = getUserById(created.userId!);
expect(user).toBeTruthy();
const token = await generateToken(user!);
const pngBytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d,
]);
const form = new FormData();
form.append("kind", "nav-logo");
form.append("file", new File([pngBytes], "logo.png", { type: "image/png" }));
const url = new URL("https://localhost/api/settings/appearance/image");
const res = await handleMiscRoutes(
new Request(url, {
method: "POST",
headers: { Cookie: `overlord_token=${token}` },
body: form,
}),
url,
{
CORS_HEADERS: {},
SERVER_VERSION: "test",
PUBLIC_ROOT: uploadPublicRoot,
requestIP: () => ({ address: "127.0.0.1" }),
getConsoleSessionCount: () => 0,
getRdSessionCount: () => 0,
getFileBrowserSessionCount: () => 0,
getProcessSessionCount: () => 0,
},
);
expect(res?.status).toBe(200);
const body = (await res!.json()) as any;
expect(body.url).toMatch(/^\/assets\/branding\/nav-logo-[a-z0-9]+-[0-9a-f-]+\.png$/);
expect(await Bun.file(path.join(uploadPublicRoot, body.url.replace("/assets/", "assets/"))).exists()).toBe(true);
} finally {
deleteUser(created.userId!);
}
});
});
+72 -1
View File
@@ -85,11 +85,20 @@ export interface Config {
customCSS: string;
loginBranding: {
productName: string;
navName: string;
title: string;
subtitle: string;
iconClass: string;
logoUrl: string;
logoAlt: string;
navLogoUrl: string;
navLogoAlt: string;
heroImageUrl: string;
heroImageAlt: string;
accentColor: string;
footerText: string;
supportText: string;
supportUrl: string;
};
};
plugins: {
@@ -205,11 +214,20 @@ const DEFAULT_CONFIG: Config = {
customCSS: "",
loginBranding: {
productName: "Overlord",
navName: "Overlord",
title: "Welcome back",
subtitle: "Sign in to your control plane",
iconClass: "fa-solid fa-crown",
logoUrl: "",
logoAlt: "Overlord logo",
navLogoUrl: "",
navLogoAlt: "Overlord logo",
heroImageUrl: "",
heroImageAlt: "Overlord sign-in background",
accentColor: "#7a5bff",
footerText: "",
supportText: "",
supportUrl: "",
},
},
plugins: {
@@ -304,18 +322,33 @@ function cleanLogoUrl(value: unknown): string {
return "";
}
function cleanBrandColor(value: unknown, fallback: string): string {
const raw = typeof value === "string" ? value.trim() : "";
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toLowerCase() : fallback;
}
function cleanLoginBranding(
value: Partial<Config["appearance"]["loginBranding"]> | undefined,
fallback: Config["appearance"]["loginBranding"],
): Config["appearance"]["loginBranding"] {
const productName = cleanText(value?.productName, fallback.productName, 80);
const navName = cleanText(value?.navName, productName, 80);
return {
productName,
navName,
title: cleanText(value?.title, fallback.title, 120),
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
iconClass: cleanIconClass(value?.iconClass, fallback.iconClass),
logoUrl: cleanLogoUrl(value?.logoUrl),
logoAlt: cleanText(value?.logoAlt, `${productName} logo`, 120),
navLogoUrl: cleanLogoUrl(value?.navLogoUrl),
navLogoAlt: cleanText(value?.navLogoAlt, `${navName} logo`, 120),
heroImageUrl: cleanLogoUrl(value?.heroImageUrl),
heroImageAlt: cleanText(value?.heroImageAlt, `${productName} sign-in image`, 160),
accentColor: cleanBrandColor(value?.accentColor, fallback.accentColor),
footerText: cleanText(value?.footerText, "", 160),
supportText: cleanText(value?.supportText, "", 120),
supportUrl: cleanLogoUrl(value?.supportUrl),
};
}
@@ -760,7 +793,12 @@ export function loadConfig(): Config {
...(fileConfig.appearance?.loginBranding || {}),
productName:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.productName,
navName:
process.env.OVERLORD_NAV_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.navName,
title:
process.env.OVERLORD_LOGIN_TITLE ||
fileConfig.appearance?.loginBranding?.title,
@@ -776,6 +814,32 @@ export function loadConfig(): Config {
logoAlt:
process.env.OVERLORD_LOGIN_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.logoAlt,
navLogoUrl:
process.env.OVERLORD_NAV_LOGO_URL ||
process.env.OVERLORD_BRAND_LOGO_URL ||
fileConfig.appearance?.loginBranding?.navLogoUrl,
navLogoAlt:
process.env.OVERLORD_NAV_LOGO_ALT ||
process.env.OVERLORD_BRAND_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.navLogoAlt,
heroImageUrl:
process.env.OVERLORD_LOGIN_HERO_IMAGE_URL ||
fileConfig.appearance?.loginBranding?.heroImageUrl,
heroImageAlt:
process.env.OVERLORD_LOGIN_HERO_IMAGE_ALT ||
fileConfig.appearance?.loginBranding?.heroImageAlt,
accentColor:
process.env.OVERLORD_BRAND_ACCENT_COLOR ||
fileConfig.appearance?.loginBranding?.accentColor,
footerText:
process.env.OVERLORD_LOGIN_FOOTER_TEXT ||
fileConfig.appearance?.loginBranding?.footerText,
supportText:
process.env.OVERLORD_LOGIN_SUPPORT_TEXT ||
fileConfig.appearance?.loginBranding?.supportText,
supportUrl:
process.env.OVERLORD_LOGIN_SUPPORT_URL ||
fileConfig.appearance?.loginBranding?.supportUrl,
},
DEFAULT_CONFIG.appearance.loginBranding,
),
@@ -1420,7 +1484,14 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
tls: process.env.OVERLORD_TLS_CERT || process.env.OVERLORD_TLS_CERTBOT_ENABLED,
auth: process.env.JWT_SECRET || process.env.OVERLORD_AGENT_TOKEN,
oidc: process.env.OVERLORD_OIDC_ENABLED || process.env.OVERLORD_OIDC_ISSUER || process.env.OVERLORD_OIDC_CLIENT_ID,
appearance: process.env.OVERLORD_LOGIN_BRAND_NAME || process.env.OVERLORD_LOGIN_LOGO_URL || process.env.OVERLORD_LOGIN_TITLE,
appearance:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
process.env.OVERLORD_NAV_LOGO_URL ||
process.env.OVERLORD_BRAND_LOGO_URL ||
process.env.OVERLORD_LOGIN_LOGO_URL ||
process.env.OVERLORD_LOGIN_HERO_IMAGE_URL ||
process.env.OVERLORD_LOGIN_TITLE,
};
if (data.notifications && typeof data.notifications === "object") {
+1
View File
@@ -551,6 +551,7 @@ async function startServer() {
misc: {
CORS_HEADERS,
SERVER_VERSION,
PUBLIC_ROOT,
getConsoleSessionCount: sessionManager.getConsoleSessionCount,
getRdSessionCount: sessionManager.getRdSessionCount,
getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount,
@@ -2,6 +2,8 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import path from "path";
import * as fs from "fs/promises";
import {
getClientMetricsSummary,
getClientMetricsSummaryForUser,
@@ -30,6 +32,7 @@ let inspectorRuntimePromise: Promise<any> | null | undefined;
type MiscRouteDeps = {
CORS_HEADERS: Record<string, string>;
SERVER_VERSION: string;
PUBLIC_ROOT: string;
requestIP?: (req: Request) => { address?: string } | null | undefined;
getConsoleSessionCount: () => number;
getRdSessionCount: () => number;
@@ -39,6 +42,29 @@ type MiscRouteDeps = {
tlsSource?: "certbot" | "configured" | "self-signed";
};
const BRAND_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
const BRAND_IMAGE_KINDS = new Set(["nav-logo", "login-logo", "hero-image"]);
function detectBrandImage(bytes: Uint8Array, contentType: string): { ext: string; type: string } | null {
const type = contentType.toLowerCase().split(";")[0].trim();
const isPng = bytes.length >= 8 &&
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
const isGif = bytes.length >= 6 &&
bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 &&
bytes[3] === 0x38 && (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61;
const isWebp = bytes.length >= 12 &&
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
if (isPng && (!type || type === "image/png")) return { ext: "png", type: "image/png" };
if (isJpeg && (!type || type === "image/jpeg" || type === "image/jpg")) return { ext: "jpg", type: "image/jpeg" };
if (isGif && (!type || type === "image/gif")) return { ext: "gif", type: "image/gif" };
if (isWebp && (!type || type === "image/webp")) return { ext: "webp", type: "image/webp" };
return null;
}
function maskOidcSettings() {
const oidc = getConfig().oidc;
return {
@@ -1246,6 +1272,69 @@ export async function handleMiscRoutes(
}
}
if (req.method === "POST" && url.pathname === "/api/settings/appearance/image") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:appearance");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ error: "Invalid upload form" }, { status: 400 });
}
const kind = String(form.get("kind") || "");
if (!BRAND_IMAGE_KINDS.has(kind)) {
return Response.json({ error: "Invalid branding image type" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) {
return Response.json({ error: "Missing image file" }, { status: 400 });
}
if (file.size <= 0) {
return Response.json({ error: "Image file is empty" }, { status: 400 });
}
if (file.size > BRAND_IMAGE_MAX_BYTES) {
return Response.json({ error: "Image exceeds 5 MB limit" }, { status: 400 });
}
const bytes = new Uint8Array(await file.arrayBuffer());
const image = detectBrandImage(bytes, file.type || "");
if (!image) {
return Response.json({ error: "Only PNG, JPEG, GIF, or WebP images are allowed" }, { status: 400 });
}
const uploadDir = path.join(deps.PUBLIC_ROOT, "assets", "branding");
await fs.mkdir(uploadDir, { recursive: true });
const filename = `${kind}-${Date.now().toString(36)}-${crypto.randomUUID()}.${image.ext}`;
const targetPath = path.join(uploadDir, filename);
await fs.writeFile(targetPath, bytes);
const assetUrl = `/assets/branding/${filename}`;
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Uploaded branding image (${kind}, ${bytes.length} bytes)`,
success: true,
});
return Response.json(
{ ok: true, url: assetUrl, contentType: image.type, size: bytes.length },
{ headers: deps.CORS_HEADERS },
);
}
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
const user = await authenticateRequest(req);
if (!user) {