more branding options and fix gitignore
This commit is contained in:
+4
-1
@@ -517,4 +517,7 @@ save.txt
|
|||||||
Albedo/*
|
Albedo/*
|
||||||
plink/*
|
plink/*
|
||||||
plugins/plink/*
|
plugins/plink/*
|
||||||
plugins/plink-static/*
|
plugins/plink-static/*
|
||||||
|
|
||||||
|
Overlord-Client/third_party/amf
|
||||||
|
Overlord-Client/third_party/onevpl
|
||||||
@@ -33,7 +33,16 @@ const loginSupportLink = document.getElementById("login-support-link");
|
|||||||
if (loginBrand && data.productName) loginBrand.textContent = data.productName;
|
if (loginBrand && data.productName) loginBrand.textContent = data.productName;
|
||||||
if (loginTitle && data.title) loginTitle.textContent = data.title;
|
if (loginTitle && data.title) loginTitle.textContent = data.title;
|
||||||
if (loginSubtitle && data.subtitle) loginSubtitle.textContent = data.subtitle;
|
if (loginSubtitle && data.subtitle) loginSubtitle.textContent = data.subtitle;
|
||||||
if (data.productName) document.title = `${data.productName} Login`;
|
document.title = data.tabName || (data.productName ? `${data.productName} Login` : document.title);
|
||||||
|
if (data.faviconUrl) {
|
||||||
|
let favicon = document.querySelector('link[rel~="icon"]');
|
||||||
|
if (!favicon) {
|
||||||
|
favicon = document.createElement("link");
|
||||||
|
favicon.rel = "icon";
|
||||||
|
document.head.appendChild(favicon);
|
||||||
|
}
|
||||||
|
favicon.href = data.faviconUrl;
|
||||||
|
}
|
||||||
if (data.accentColor) document.documentElement.style.setProperty("--brand-accent", data.accentColor);
|
if (data.accentColor) document.documentElement.style.setProperty("--brand-accent", data.accentColor);
|
||||||
|
|
||||||
if (loginIcon && data.iconClass) {
|
if (loginIcon && data.iconClass) {
|
||||||
|
|||||||
@@ -244,7 +244,12 @@ if (host) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
installPageResourceTracker();
|
installPageResourceTracker();
|
||||||
setupSoftNavigation(host, { onPathChange: applyActivePath });
|
setupSoftNavigation(host, {
|
||||||
|
onPathChange: (path) => {
|
||||||
|
applyActivePath(path);
|
||||||
|
applyBranding();
|
||||||
|
},
|
||||||
|
});
|
||||||
startPageTracking();
|
startPageTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +258,27 @@ async function applyBranding() {
|
|||||||
const res = await fetch("/api/login/branding");
|
const res = await fetch("/api/login/branding");
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const brand = await res.json();
|
const brand = await res.json();
|
||||||
|
if (brand.tabName) document.title = brand.tabName;
|
||||||
|
if (brand.faviconUrl) {
|
||||||
|
let favicon = document.querySelector('link[rel~="icon"]');
|
||||||
|
if (!favicon) {
|
||||||
|
favicon = document.createElement("link");
|
||||||
|
favicon.rel = "icon";
|
||||||
|
document.head.appendChild(favicon);
|
||||||
|
}
|
||||||
|
favicon.href = brand.faviconUrl;
|
||||||
|
}
|
||||||
|
const isDashboard = location.pathname === "/" || location.pathname === "/index.html";
|
||||||
|
document.body.style.backgroundImage = "";
|
||||||
|
document.body.style.backgroundSize = "";
|
||||||
|
document.body.style.backgroundPosition = "";
|
||||||
|
document.body.style.backgroundAttachment = "";
|
||||||
|
if (isDashboard && brand.dashboardBackgroundUrl) {
|
||||||
|
document.body.style.backgroundImage = `linear-gradient(rgba(2, 6, 23, 0.72), rgba(2, 6, 23, 0.82)), url("${brand.dashboardBackgroundUrl.replace(/["\\\n\r]/g, "")}")`;
|
||||||
|
document.body.style.backgroundSize = "cover";
|
||||||
|
document.body.style.backgroundPosition = "center";
|
||||||
|
document.body.style.backgroundAttachment = "fixed";
|
||||||
|
}
|
||||||
if (brand.accentColor) {
|
if (brand.accentColor) {
|
||||||
document.documentElement.style.setProperty("--brand-accent", brand.accentColor);
|
document.documentElement.style.setProperty("--brand-accent", brand.accentColor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ const appearancePermissionNote = document.getElementById("appearance-permission-
|
|||||||
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
|
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
|
||||||
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");
|
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");
|
||||||
const brandProductNameInput = document.getElementById("brand-product-name");
|
const brandProductNameInput = document.getElementById("brand-product-name");
|
||||||
|
const brandTabNameInput = document.getElementById("brand-tab-name");
|
||||||
|
const brandFaviconUrlInput = document.getElementById("brand-favicon-url");
|
||||||
|
const brandFaviconFileInput = document.getElementById("brand-favicon-file");
|
||||||
|
const brandDashboardBackgroundUrlInput = document.getElementById("brand-dashboard-background-url");
|
||||||
|
const brandDashboardBackgroundFileInput = document.getElementById("brand-dashboard-background-file");
|
||||||
const brandNavNameInput = document.getElementById("brand-nav-name");
|
const brandNavNameInput = document.getElementById("brand-nav-name");
|
||||||
const brandAccentColorInput = document.getElementById("brand-accent-color");
|
const brandAccentColorInput = document.getElementById("brand-accent-color");
|
||||||
const brandIconClassInput = document.getElementById("brand-icon-class");
|
const brandIconClassInput = document.getElementById("brand-icon-class");
|
||||||
@@ -1379,6 +1384,11 @@ function initSettingsSidebar() {
|
|||||||
|
|
||||||
const brandInputs = [
|
const brandInputs = [
|
||||||
brandProductNameInput,
|
brandProductNameInput,
|
||||||
|
brandTabNameInput,
|
||||||
|
brandFaviconUrlInput,
|
||||||
|
brandFaviconFileInput,
|
||||||
|
brandDashboardBackgroundUrlInput,
|
||||||
|
brandDashboardBackgroundFileInput,
|
||||||
brandNavNameInput,
|
brandNavNameInput,
|
||||||
brandAccentColorInput,
|
brandAccentColorInput,
|
||||||
brandIconClassInput,
|
brandIconClassInput,
|
||||||
@@ -1406,6 +1416,9 @@ function setAppearanceFormDisabled(disabled) {
|
|||||||
|
|
||||||
function applyBrandingForm(loginBranding = {}) {
|
function applyBrandingForm(loginBranding = {}) {
|
||||||
if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord";
|
if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord";
|
||||||
|
if (brandTabNameInput) brandTabNameInput.value = loginBranding.tabName || loginBranding.productName || "Overlord";
|
||||||
|
if (brandFaviconUrlInput) brandFaviconUrlInput.value = loginBranding.faviconUrl || "";
|
||||||
|
if (brandDashboardBackgroundUrlInput) brandDashboardBackgroundUrlInput.value = loginBranding.dashboardBackgroundUrl || "";
|
||||||
if (brandNavNameInput) brandNavNameInput.value = loginBranding.navName || loginBranding.productName || "Overlord";
|
if (brandNavNameInput) brandNavNameInput.value = loginBranding.navName || loginBranding.productName || "Overlord";
|
||||||
if (brandAccentColorInput) brandAccentColorInput.value = /^#[0-9a-fA-F]{6}$/.test(loginBranding.accentColor || "") ? loginBranding.accentColor : "#7a5bff";
|
if (brandAccentColorInput) brandAccentColorInput.value = /^#[0-9a-fA-F]{6}$/.test(loginBranding.accentColor || "") ? loginBranding.accentColor : "#7a5bff";
|
||||||
if (brandIconClassInput) brandIconClassInput.value = loginBranding.iconClass || "fa-solid fa-crown";
|
if (brandIconClassInput) brandIconClassInput.value = loginBranding.iconClass || "fa-solid fa-crown";
|
||||||
@@ -1425,6 +1438,9 @@ function applyBrandingForm(loginBranding = {}) {
|
|||||||
function collectBrandingForm() {
|
function collectBrandingForm() {
|
||||||
return {
|
return {
|
||||||
productName: String(brandProductNameInput?.value || "").trim(),
|
productName: String(brandProductNameInput?.value || "").trim(),
|
||||||
|
tabName: String(brandTabNameInput?.value || "").trim(),
|
||||||
|
faviconUrl: String(brandFaviconUrlInput?.value || "").trim(),
|
||||||
|
dashboardBackgroundUrl: String(brandDashboardBackgroundUrlInput?.value || "").trim(),
|
||||||
navName: String(brandNavNameInput?.value || "").trim(),
|
navName: String(brandNavNameInput?.value || "").trim(),
|
||||||
accentColor: String(brandAccentColorInput?.value || "").trim(),
|
accentColor: String(brandAccentColorInput?.value || "").trim(),
|
||||||
iconClass: String(brandIconClassInput?.value || "").trim(),
|
iconClass: String(brandIconClassInput?.value || "").trim(),
|
||||||
@@ -1491,6 +1507,18 @@ async function uploadBrandingImage(fileInput, targetInput, kind, label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initBrandingUploads() {
|
function initBrandingUploads() {
|
||||||
|
if (brandDashboardBackgroundFileInput) {
|
||||||
|
brandDashboardBackgroundFileInput.addEventListener("change", () => {
|
||||||
|
setBrandingUploadName(brandDashboardBackgroundFileInput);
|
||||||
|
uploadBrandingImage(brandDashboardBackgroundFileInput, brandDashboardBackgroundUrlInput, "dashboard-background", "Dashboard background");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (brandFaviconFileInput) {
|
||||||
|
brandFaviconFileInput.addEventListener("change", () => {
|
||||||
|
setBrandingUploadName(brandFaviconFileInput);
|
||||||
|
uploadBrandingImage(brandFaviconFileInput, brandFaviconUrlInput, "tab-icon", "Browser tab icon");
|
||||||
|
});
|
||||||
|
}
|
||||||
if (brandNavLogoFileInput) {
|
if (brandNavLogoFileInput) {
|
||||||
brandNavLogoFileInput.addEventListener("change", () => {
|
brandNavLogoFileInput.addEventListener("change", () => {
|
||||||
setBrandingUploadName(brandNavLogoFileInput);
|
setBrandingUploadName(brandNavLogoFileInput);
|
||||||
|
|||||||
@@ -1269,6 +1269,21 @@
|
|||||||
<span class="text-sm text-slate-300">Product name</span>
|
<span class="text-sm text-slate-300">Product name</span>
|
||||||
<input id="brand-product-name" type="text" placeholder="Overlord" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
<input id="brand-product-name" type="text" placeholder="Overlord" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-slate-300">Browser tab name</span>
|
||||||
|
<input id="brand-tab-name" type="text" placeholder="Overlord" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
||||||
|
</label>
|
||||||
|
<div class="block">
|
||||||
|
<span class="text-sm text-slate-300">Browser tab icon URL</span>
|
||||||
|
<input id="brand-favicon-url" type="text" inputmode="url" placeholder="/api/branding/image/tab-icon" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
||||||
|
<div class="mt-2 flex items-center gap-2 min-w-0">
|
||||||
|
<input id="brand-favicon-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,.ico" class="sr-only" />
|
||||||
|
<label for="brand-favicon-file" 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-100 transition-colors cursor-pointer">
|
||||||
|
<i class="fa-solid fa-upload"></i>Upload image
|
||||||
|
</label>
|
||||||
|
<span id="brand-favicon-file-name" class="min-w-0 truncate text-xs text-slate-500">No file chosen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm text-slate-300">Navigation name</span>
|
<span class="text-sm text-slate-300">Navigation name</span>
|
||||||
<input id="brand-nav-name" type="text" placeholder="Overlord" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
<input id="brand-nav-name" type="text" placeholder="Overlord" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
||||||
@@ -1284,6 +1299,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div class="block">
|
||||||
|
<span class="text-sm text-slate-300">Dashboard background image URL</span>
|
||||||
|
<input id="brand-dashboard-background-url" type="text" inputmode="url" placeholder="/api/branding/image/dashboard-background" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
||||||
|
<div class="mt-2 flex items-center gap-2 min-w-0">
|
||||||
|
<input id="brand-dashboard-background-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" class="sr-only" />
|
||||||
|
<label for="brand-dashboard-background-file" 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-100 transition-colors cursor-pointer">
|
||||||
|
<i class="fa-solid fa-upload"></i>Upload image
|
||||||
|
</label>
|
||||||
|
<span id="brand-dashboard-background-file-name" class="min-w-0 truncate text-xs text-slate-500">No file chosen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<span class="text-sm text-slate-300">Navigation logo URL</span>
|
<span class="text-sm text-slate-300">Navigation logo URL</span>
|
||||||
<input id="brand-nav-logo-url" type="text" inputmode="url" placeholder="/assets/company-logo.png" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
<input id="brand-nav-logo-url" type="text" inputmode="url" placeholder="/assets/company-logo.png" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500" />
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|||||||
import { extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
|
import { extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
|
||||||
import { getConfig, updateAppearanceConfig, type Config } from "./config";
|
import { getConfig, updateAppearanceConfig, type Config } from "./config";
|
||||||
import { generateTotpCode } from "./mfa";
|
import { generateTotpCode } from "./mfa";
|
||||||
import * as fs from "fs/promises";
|
import { getBrandingImage } from "./db";
|
||||||
import path from "path";
|
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
@@ -19,7 +18,7 @@ const mockServer = {
|
|||||||
requestIP: () => ({ address: "127.0.0.1" }),
|
requestIP: () => ({ address: "127.0.0.1" }),
|
||||||
};
|
};
|
||||||
let originalAppearance: Config["appearance"];
|
let originalAppearance: Config["appearance"];
|
||||||
const uploadPublicRoot = path.resolve(".test-data", "branding-upload-public");
|
const uploadPublicRoot = ".";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
originalAppearance = JSON.parse(JSON.stringify(getConfig().appearance));
|
originalAppearance = JSON.parse(JSON.stringify(getConfig().appearance));
|
||||||
@@ -29,7 +28,6 @@ afterAll(async () => {
|
|||||||
if (originalAppearance) {
|
if (originalAppearance) {
|
||||||
await updateAppearanceConfig(originalAppearance.customCSS, originalAppearance.loginBranding);
|
await updateAppearanceConfig(originalAppearance.customCSS, originalAppearance.loginBranding);
|
||||||
}
|
}
|
||||||
await fs.rm(uploadPublicRoot, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("auth token extraction", () => {
|
describe("auth token extraction", () => {
|
||||||
@@ -104,7 +102,6 @@ describe("branding uploads", () => {
|
|||||||
expect(created.success).toBe(true);
|
expect(created.success).toBe(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(uploadPublicRoot, { recursive: true });
|
|
||||||
const user = getUserById(created.userId!);
|
const user = getUserById(created.userId!);
|
||||||
expect(user).toBeTruthy();
|
expect(user).toBeTruthy();
|
||||||
const token = await generateToken(user!);
|
const token = await generateToken(user!);
|
||||||
@@ -138,8 +135,10 @@ describe("branding uploads", () => {
|
|||||||
|
|
||||||
expect(res?.status).toBe(200);
|
expect(res?.status).toBe(200);
|
||||||
const body = (await res!.json()) as any;
|
const body = (await res!.json()) as any;
|
||||||
expect(body.url).toMatch(/^\/assets\/branding\/nav-logo-[a-z0-9]+-[0-9a-f-]+\.png$/);
|
expect(body.url).toBe("/api/branding/image/nav-logo");
|
||||||
expect(await Bun.file(path.join(uploadPublicRoot, body.url.replace("/assets/", "assets/"))).exists()).toBe(true);
|
const stored = getBrandingImage("nav-logo");
|
||||||
|
expect(stored?.contentType).toBe("image/png");
|
||||||
|
expect(Array.from(stored?.bytes || [])).toEqual(Array.from(pngBytes));
|
||||||
} finally {
|
} finally {
|
||||||
deleteUser(created.userId!);
|
deleteUser(created.userId!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export interface Config {
|
|||||||
customCSS: string;
|
customCSS: string;
|
||||||
loginBranding: {
|
loginBranding: {
|
||||||
productName: string;
|
productName: string;
|
||||||
|
tabName: string;
|
||||||
|
faviconUrl: string;
|
||||||
|
dashboardBackgroundUrl: string;
|
||||||
navName: string;
|
navName: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -214,6 +217,9 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
customCSS: "",
|
customCSS: "",
|
||||||
loginBranding: {
|
loginBranding: {
|
||||||
productName: "Overlord",
|
productName: "Overlord",
|
||||||
|
tabName: "Overlord",
|
||||||
|
faviconUrl: "",
|
||||||
|
dashboardBackgroundUrl: "",
|
||||||
navName: "Overlord",
|
navName: "Overlord",
|
||||||
title: "Welcome back",
|
title: "Welcome back",
|
||||||
subtitle: "Sign in to your control plane",
|
subtitle: "Sign in to your control plane",
|
||||||
@@ -335,6 +341,9 @@ function cleanLoginBranding(
|
|||||||
const navName = cleanText(value?.navName, productName, 80);
|
const navName = cleanText(value?.navName, productName, 80);
|
||||||
return {
|
return {
|
||||||
productName,
|
productName,
|
||||||
|
tabName: cleanText(value?.tabName, productName, 80),
|
||||||
|
faviconUrl: cleanLogoUrl(value?.faviconUrl),
|
||||||
|
dashboardBackgroundUrl: cleanLogoUrl(value?.dashboardBackgroundUrl),
|
||||||
navName,
|
navName,
|
||||||
title: cleanText(value?.title, fallback.title, 120),
|
title: cleanText(value?.title, fallback.title, 120),
|
||||||
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
|
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
|
||||||
@@ -795,6 +804,15 @@ export function loadConfig(): Config {
|
|||||||
process.env.OVERLORD_LOGIN_BRAND_NAME ||
|
process.env.OVERLORD_LOGIN_BRAND_NAME ||
|
||||||
process.env.OVERLORD_BRAND_NAME ||
|
process.env.OVERLORD_BRAND_NAME ||
|
||||||
fileConfig.appearance?.loginBranding?.productName,
|
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:
|
navName:
|
||||||
process.env.OVERLORD_NAV_BRAND_NAME ||
|
process.env.OVERLORD_NAV_BRAND_NAME ||
|
||||||
process.env.OVERLORD_BRAND_NAME ||
|
process.env.OVERLORD_BRAND_NAME ||
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS saved_scripts (
|
CREATE TABLE IF NOT EXISTS saved_scripts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { getConfig } from "../../config";
|
import { getConfig } from "../../config";
|
||||||
|
import { getBrandingImage } from "../../db";
|
||||||
|
|
||||||
type AssetsRouteDeps = {
|
type AssetsRouteDeps = {
|
||||||
PUBLIC_ROOT: string;
|
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") {
|
if (req.method === "GET" && url.pathname === "/favicon.ico") {
|
||||||
const file = Bun.file(path.join(deps.PUBLIC_ROOT, "assets", "favicon.ico"));
|
const file = Bun.file(path.join(deps.PUBLIC_ROOT, "assets", "favicon.ico"));
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { authenticateRequest } from "../../auth";
|
|||||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
|
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 {
|
import {
|
||||||
|
saveBrandingImage,
|
||||||
getClientMetricsSummary,
|
getClientMetricsSummary,
|
||||||
getClientMetricsSummaryForUser,
|
getClientMetricsSummaryForUser,
|
||||||
getDatabaseFileSizeBytes,
|
getDatabaseFileSizeBytes,
|
||||||
@@ -43,7 +42,7 @@ type MiscRouteDeps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BRAND_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
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 {
|
function detectBrandImage(bytes: Uint8Array, contentType: string): { ext: string; type: string } | null {
|
||||||
const type = contentType.toLowerCase().split(";")[0].trim();
|
const type = contentType.toLowerCase().split(";")[0].trim();
|
||||||
@@ -57,11 +56,16 @@ function detectBrandImage(bytes: Uint8Array, contentType: string): { ext: string
|
|||||||
const isWebp = bytes.length >= 12 &&
|
const isWebp = bytes.length >= 12 &&
|
||||||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
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 (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 (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 (isGif && (!type || type === "image/gif")) return { ext: "gif", type: "image/gif" };
|
||||||
if (isWebp && (!type || type === "image/webp")) return { ext: "webp", type: "image/webp" };
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,15 +1314,11 @@ export async function handleMiscRoutes(
|
|||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
const image = detectBrandImage(bytes, file.type || "");
|
const image = detectBrandImage(bytes, file.type || "");
|
||||||
if (!image) {
|
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");
|
saveBrandingImage(kind, image.type, bytes);
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
const assetUrl = `/api/branding/image/${kind}`;
|
||||||
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({
|
logAudit({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
Reference in New Issue
Block a user