custom branding
This commit is contained in:
@@ -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!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user