more branding options and fix gitignore

This commit is contained in:
KingKDot
2026-07-12 15:23:23 -05:00
parent 26702510eb
commit ddc8b970d5
11 changed files with 183 additions and 20 deletions
+6 -7
View File
@@ -2,8 +2,7 @@ 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 { getBrandingImage } from "./db";
import {
createUser,
deleteUser,
@@ -19,7 +18,7 @@ const mockServer = {
requestIP: () => ({ address: "127.0.0.1" }),
};
let originalAppearance: Config["appearance"];
const uploadPublicRoot = path.resolve(".test-data", "branding-upload-public");
const uploadPublicRoot = ".";
beforeAll(() => {
originalAppearance = JSON.parse(JSON.stringify(getConfig().appearance));
@@ -29,7 +28,6 @@ afterAll(async () => {
if (originalAppearance) {
await updateAppearanceConfig(originalAppearance.customCSS, originalAppearance.loginBranding);
}
await fs.rm(uploadPublicRoot, { recursive: true, force: true });
});
describe("auth token extraction", () => {
@@ -104,7 +102,6 @@ describe("branding uploads", () => {
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!);
@@ -138,8 +135,10 @@ describe("branding uploads", () => {
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);
expect(body.url).toBe("/api/branding/image/nav-logo");
const stored = getBrandingImage("nav-logo");
expect(stored?.contentType).toBe("image/png");
expect(Array.from(stored?.bytes || [])).toEqual(Array.from(pngBytes));
} finally {
deleteUser(created.userId!);
}
+18
View File
@@ -85,6 +85,9 @@ export interface Config {
customCSS: string;
loginBranding: {
productName: string;
tabName: string;
faviconUrl: string;
dashboardBackgroundUrl: string;
navName: string;
title: string;
subtitle: string;
@@ -214,6 +217,9 @@ const DEFAULT_CONFIG: Config = {
customCSS: "",
loginBranding: {
productName: "Overlord",
tabName: "Overlord",
faviconUrl: "",
dashboardBackgroundUrl: "",
navName: "Overlord",
title: "Welcome back",
subtitle: "Sign in to your control plane",
@@ -335,6 +341,9 @@ function cleanLoginBranding(
const navName = cleanText(value?.navName, productName, 80);
return {
productName,
tabName: cleanText(value?.tabName, productName, 80),
faviconUrl: cleanLogoUrl(value?.faviconUrl),
dashboardBackgroundUrl: cleanLogoUrl(value?.dashboardBackgroundUrl),
navName,
title: cleanText(value?.title, fallback.title, 120),
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
@@ -795,6 +804,15 @@ export function loadConfig(): Config {
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.productName,
tabName:
process.env.OVERLORD_TAB_NAME ||
fileConfig.appearance?.loginBranding?.tabName,
faviconUrl:
process.env.OVERLORD_FAVICON_URL ||
fileConfig.appearance?.loginBranding?.faviconUrl,
dashboardBackgroundUrl:
process.env.OVERLORD_DASHBOARD_BACKGROUND_URL ||
fileConfig.appearance?.loginBranding?.dashboardBackgroundUrl,
navName:
process.env.OVERLORD_NAV_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
+30
View File
@@ -2216,3 +2216,33 @@ export function getDatabaseFileSizeBytes(): number {
}
}
export type BrandingImage = {
kind: string;
contentType: string;
bytes: Uint8Array;
updatedAt: number;
};
export function getBrandingImage(kind: string): BrandingImage | null {
const row = db.query<any>(
`SELECT kind, content_type, bytes, updated_at FROM branding_images WHERE kind=?`,
).get(kind);
if (!row) return null;
return {
kind: String(row.kind),
contentType: String(row.content_type),
bytes: new Uint8Array(row.bytes),
updatedAt: Number(row.updated_at),
};
}
export function saveBrandingImage(kind: string, contentType: string, bytes: Uint8Array): BrandingImage {
const updatedAt = Date.now();
db.run(
`INSERT INTO branding_images (kind, content_type, bytes, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(kind) DO UPDATE SET content_type=excluded.content_type, bytes=excluded.bytes, updated_at=excluded.updated_at`,
kind, contentType, bytes, updatedAt,
);
return { kind, contentType, bytes, updatedAt };
}
+9
View File
@@ -380,6 +380,15 @@ db.run(`
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS branding_images (
kind TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
bytes BLOB NOT NULL,
updated_at INTEGER NOT NULL
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS saved_scripts (
id TEXT PRIMARY KEY,
@@ -1,5 +1,6 @@
import path from "path";
import { getConfig } from "../../config";
import { getBrandingImage } from "../../db";
type AssetsRouteDeps = {
PUBLIC_ROOT: string;
@@ -115,6 +116,20 @@ export async function handleAssetsRoutes(
});
}
const brandingMatch = req.method === "GET" && url.pathname.match(/^\/api\/branding\/image\/(nav-logo|login-logo|hero-image|tab-icon|dashboard-background)$/);
if (brandingMatch) {
const image = getBrandingImage(brandingMatch[1]);
if (!image) return new Response("Not found", { status: 404 });
const etag = `\"${image.updatedAt.toString(36)}-${image.bytes.byteLength.toString(36)}\"`;
const headers = {
...deps.secureHeaders(image.contentType),
"Cache-Control": NO_CACHE,
"ETag": etag,
};
if (req.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
return new Response(toExactArrayBuffer(image.bytes), { headers });
}
if (req.method === "GET" && url.pathname === "/favicon.ico") {
const file = Bun.file(path.join(deps.PUBLIC_ROOT, "assets", "favicon.ico"));
if (await file.exists()) {
@@ -2,9 +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 {
saveBrandingImage,
getClientMetricsSummary,
getClientMetricsSummaryForUser,
getDatabaseFileSizeBytes,
@@ -43,7 +42,7 @@ type MiscRouteDeps = {
};
const BRAND_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
const BRAND_IMAGE_KINDS = new Set(["nav-logo", "login-logo", "hero-image"]);
const BRAND_IMAGE_KINDS = new Set(["nav-logo", "login-logo", "hero-image", "tab-icon", "dashboard-background"]);
function detectBrandImage(bytes: Uint8Array, contentType: string): { ext: string; type: string } | null {
const type = contentType.toLowerCase().split(";")[0].trim();
@@ -57,11 +56,16 @@ function detectBrandImage(bytes: Uint8Array, contentType: string): { ext: string
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;
const isIco = bytes.length >= 6 &&
bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00;
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" };
if (isIco && (!type || type === "image/x-icon" || type === "image/vnd.microsoft.icon")) {
return { ext: "ico", type: "image/x-icon" };
}
return null;
}
@@ -1310,15 +1314,11 @@ export async function handleMiscRoutes(
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 });
return Response.json({ error: "Only PNG, JPEG, GIF, WebP, or ICO 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}`;
saveBrandingImage(kind, image.type, bytes);
const assetUrl = `/api/branding/image/${kind}`;
logAudit({
timestamp: Date.now(),