Merge branch 'feat/appearance-tab-whitelabel-v2' into release/v3.4.2

This commit is contained in:
diegosouzapw
2026-04-01 02:18:27 -03:00
8 changed files with 426 additions and 19 deletions

1
package-lock.json generated
View File

@@ -10709,6 +10709,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

@@ -21,6 +21,7 @@ export default function AppearanceTab() {
const tSidebar = useTranslations("sidebar");
const [settings, setSettings] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [uploadError, setUploadError] = useState<string | null>(null);
const [customThemeColor, setCustomThemeColor] = useState(customColor || "#3b82f6");
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
@@ -282,6 +283,227 @@ export default function AppearanceTab() {
/>
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
badge
</span>
</div>
<div>
<h4 className="font-semibold">{t("whitelabeling")}</h4>
<p className="text-sm text-text-muted">{t("whitelabelingDesc")}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{t("appName")}</p>
<p className="text-sm text-text-muted">{t("appNameDesc")}</p>
</div>
<input
type="text"
value={settings.instanceName || "OmniRoute"}
onChange={(e) => updateSetting("instanceName", e.target.value)}
placeholder="OmniRoute"
maxLength={100}
className="h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary w-48"
/>
</div>
<div className="flex flex-col gap-2">
<div>
<p className="font-medium">{t("customLogo")}</p>
<p className="text-sm text-text-muted">{t("customLogoDesc")}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={settings.customLogoUrl || ""}
onChange={(e) => updateSetting("customLogoUrl", e.target.value)}
className="flex-1 h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary"
placeholder="https://example.com/logo.png"
maxLength={2000}
/>
{(settings.customLogoUrl || settings.customLogoBase64) && (
<img
src={settings.customLogoBase64 || settings.customLogoUrl}
alt="Logo preview"
className="h-10 w-10 rounded border border-border object-contain bg-surface"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<p className="font-medium">{t("uploadLogo")}</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface border border-border text-sm text-text-main cursor-pointer hover:bg-surface/80 transition-colors">
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/gif,image/webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 500 * 1024) {
setUploadError("Logo file must be less than 500KB");
return;
}
const validTypes = [
"image/png",
"image/jpeg",
"image/svg+xml",
"image/gif",
"image/webp",
];
if (!validTypes.includes(file.type)) {
setUploadError(
"Invalid file type. Please upload PNG, JPG, SVG, GIF, or WebP."
);
return;
}
setUploadError(null);
const reader = new FileReader();
reader.onerror = () => {
setUploadError("Failed to read file");
};
reader.onload = (event) => {
const base64 = event.target?.result as string;
updateSetting("customLogoBase64", base64);
};
reader.readAsDataURL(file);
}
}}
className="hidden"
/>
<span className="material-symbols-outlined text-[18px]">upload</span>
<span>{t("uploadLogo")}</span>
</label>
<Button
variant="secondary"
onClick={() => {
updateSetting("customLogoUrl", "");
updateSetting("customLogoBase64", "");
}}
>
{t("resetLogo")}
</Button>
</div>
{uploadError && <p className="text-sm text-red-500">{uploadError}</p>}
{(settings.customLogoBase64 || settings.customLogoUrl) && (
<div className="mt-2 p-3 bg-black/5 dark:bg-white/5 rounded-lg">
<p className="text-xs text-text-muted mb-2">{t("logoPreview")}</p>
<img
src={settings.customLogoBase64 || settings.customLogoUrl}
alt="Logo preview"
className="h-12 w-auto max-w-full rounded"
/>
</div>
)}
</div>
<div className="flex flex-col gap-2 pt-4 border-t border-border">
<div>
<p className="font-medium">{t("customFavicon")}</p>
<p className="text-sm text-text-muted">{t("customFaviconDesc")}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={settings.customFaviconUrl || ""}
onChange={(e) => updateSetting("customFaviconUrl", e.target.value)}
className="flex-1 h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary"
placeholder="https://example.com/favicon.ico"
maxLength={2000}
/>
{(settings.customFaviconUrl || settings.customFaviconBase64) && (
<img
src={settings.customFaviconBase64 || settings.customFaviconUrl}
alt="Favicon preview"
className="h-10 w-10 rounded border border-border object-contain bg-surface"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<p className="font-medium">{t("uploadFavicon")}</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface border border-border text-sm text-text-main cursor-pointer hover:bg-surface/80 transition-colors">
<input
type="file"
accept="image/png,image/x-icon,image/svg+xml,image/gif,image/webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 50 * 1024) {
setUploadError("Favicon file must be less than 50KB");
return;
}
const validTypes = [
"image/png",
"image/x-icon",
"image/svg+xml",
"image/gif",
"image/webp",
];
if (!validTypes.includes(file.type)) {
setUploadError(
"Invalid file type. Please upload PNG, ICO, SVG, GIF, or WebP."
);
return;
}
setUploadError(null);
const reader = new FileReader();
reader.onerror = () => {
setUploadError("Failed to read file");
};
reader.onload = (event) => {
const base64 = event.target?.result as string;
updateSetting("customFaviconBase64", base64);
};
reader.readAsDataURL(file);
}
}}
className="hidden"
/>
<span className="material-symbols-outlined text-[18px]">upload</span>
<span>{t("uploadFavicon")}</span>
</label>
<Button
variant="secondary"
onClick={() => {
updateSetting("customFaviconUrl", "");
updateSetting("customFaviconBase64", "");
}}
>
{t("resetFavicon")}
</Button>
</div>
{uploadError && !uploadError.includes("Logo") && (
<p className="text-sm text-red-500">{uploadError}</p>
)}
{(settings.customFaviconBase64 || settings.customFaviconUrl) && (
<div className="mt-2 p-3 bg-black/5 dark:bg-white/5 rounded-lg">
<p className="text-xs text-text-muted mb-2">{t("faviconPreview")}</p>
<img
src={settings.customFaviconBase64 || settings.customFaviconUrl}
alt="Favicon preview"
className="h-8 w-8 rounded"
/>
</div>
)}
</div>
</div>
</div>
</div>
</Card>
);

View File

@@ -23,6 +23,7 @@ import ResilienceTab from "./components/ResilienceTab";
const tabs = [
{ id: "general", labelKey: "general", icon: "settings" },
{ id: "appearance", labelKey: "appearance", icon: "palette" },
{ id: "ai", labelKey: "ai", icon: "smart_toy" },
{ id: "security", labelKey: "security", icon: "shield" },
{ id: "routing", labelKey: "routing", icon: "route" },
@@ -75,14 +76,9 @@ export default function SettingsPage() {
role="tabpanel"
aria-label={t(tabs.find((t2) => t2.id === activeTab)?.labelKey || "general")}
>
{activeTab === "general" && (
<>
<div className="flex flex-col gap-6">
<SystemStorageTab />
<AppearanceTab />
</div>
</>
)}
{activeTab === "general" && <SystemStorageTab />}
{activeTab === "appearance" && <AppearanceTab />}
{activeTab === "ai" && (
<div className="flex flex-col gap-6">

View File

@@ -0,0 +1,141 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/db/settings";
export const dynamic = "force-dynamic";
const ALLOWED_IMAGE_TYPES = [
"image/png",
"image/x-icon",
"image/svg+xml",
"image/gif",
"image/webp",
"image/jpeg",
];
const MAX_FAVICON_SIZE = 50 * 1024; // 50KB
const FETCH_TIMEOUT = 5000; // 5 seconds
const CACHE_DURATION = 300; // 5 minutes
function isAllowedUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
// Only allow https (or http for local development)
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
return false;
}
// Block private/internal IPs
const hostname = parsedUrl.hostname;
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
hostname.startsWith("172.") ||
hostname.endsWith(".local") ||
hostname === "localhost"
) {
return false;
}
return true;
} catch {
return false;
}
}
function validateImageData(base64Data: string, contentType: string): boolean {
if (!ALLOWED_IMAGE_TYPES.includes(contentType)) {
console.error("Invalid content type:", contentType);
return false;
}
// Check for obvious image magic bytes
const matches = base64Data.match(/^data:[^;]+;base64,(.+)$/);
if (!matches) return false;
const binaryData = Buffer.from(matches[1], "base64");
if (binaryData.length > MAX_FAVICON_SIZE) {
console.error("Favicon too large:", binaryData.length);
return false;
}
return true;
}
export async function GET() {
try {
const settings = await getSettings();
const customFaviconBase64 = settings?.customFaviconBase64 as string | undefined;
const customFaviconUrl = settings?.customFaviconUrl as string | undefined;
let faviconData: string | null = null;
if (customFaviconBase64) {
// Validate stored Base64 data
const match = customFaviconBase64.match(/^data:([^;]+);base64,(.+)$/);
if (match && validateImageData(customFaviconBase64, match[1])) {
faviconData = customFaviconBase64;
}
} else if (customFaviconUrl) {
// Validate URL before fetching (SSRF protection)
if (!isAllowedUrl(customFaviconUrl)) {
console.error("Blocked invalid favicon URL:", customFaviconUrl);
} else {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
const response = await fetch(customFaviconUrl, {
signal: controller.signal,
headers: {
"User-Agent": "OmniRoute/1.0",
},
});
clearTimeout(timeoutId);
if (response.ok) {
const contentType = response.headers.get("content-type") || "";
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Validate size before processing
if (uint8Array.length > MAX_FAVICON_SIZE) {
console.error("Favicon exceeds max size:", uint8Array.length);
} else {
const base64 = Buffer.from(uint8Array).toString("base64");
const fullData = `data:${contentType};base64,${base64}`;
if (validateImageData(fullData, contentType)) {
faviconData = fullData;
}
}
}
} catch (error) {
console.error("Failed to fetch custom favicon:", error);
}
}
}
if (!faviconData) {
return NextResponse.redirect("/favicon.svg");
}
const match = faviconData.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
return NextResponse.redirect("/favicon.svg");
}
const contentType = match[1];
const base64Data = match[2];
const buffer = Buffer.from(base64Data, "base64");
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": `public, max-age=${CACHE_DURATION}`,
},
});
} catch (error) {
console.error("Favicon API error:", error);
return NextResponse.redirect("/favicon.svg");
}
}

View File

@@ -5,21 +5,28 @@ import "@/lib/initCloudSync"; // Auto-initialize cloud sync
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
import { RTL_LOCALES } from "@/i18n/config";
import { getSettings } from "@/lib/db/settings";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata = {
title: "OmniRoute — AI Gateway for Multi-Provider LLMs",
description:
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.svg",
},
};
export async function generateMetadata() {
const settings = await getSettings();
const instanceName = settings?.instanceName || "OmniRoute";
const customFaviconUrl = settings?.customFaviconUrl || settings?.customFaviconBase64;
return {
title: `${instanceName} — AI Gateway for Multi-Provider LLMs`,
description:
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
icons: {
icon: customFaviconUrl ? "/api/settings/favicon" : "/favicon.svg",
apple: "/apple-touch-icon.svg",
},
};
}
export default async function RootLayout({ children }) {
const locale = await getLocale();

View File

@@ -1829,6 +1829,20 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"whitelabeling": "Branding",
"whitelabelingDesc": "Customize the application name and logo",
"appName": "Application Name",
"appNameDesc": "Display name shown in sidebar and browser tab",
"customLogo": "Custom Logo URL",
"customLogoDesc": "URL to your custom logo image",
"uploadLogo": "Upload Logo",
"resetLogo": "Reset to Default",
"logoPreview": "Preview",
"customFavicon": "Browser Favicon",
"customFaviconDesc": "URL to your custom favicon (shown in browser tab)",
"uploadFavicon": "Upload Favicon",
"resetFavicon": "Reset Favicon",
"faviconPreview": "Favicon Preview",
"promptCache": "Prompt Cache",
"flushCache": "Flush Cache",
"flushing": "Flushing…",

View File

@@ -37,11 +37,15 @@ export default function Sidebar({
const [isDisconnected, setIsDisconnected] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<string[]>([]);
const [customAppName, setCustomAppName] = useState<string | null>(null);
const [customLogo, setCustomLogo] = useState<string | null>(null);
useEffect(() => {
const applySettings = (data) => {
setShowDebug(data?.debugMode === true);
setHiddenSidebarItems(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]));
setCustomAppName(data?.instanceName || null);
setCustomLogo(data?.customLogoBase64 || data?.customLogoUrl || null);
};
fetch("/api/settings")
@@ -61,6 +65,16 @@ export default function Sidebar({
normalizeHiddenSidebarItems(detail[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
);
}
if ("instanceName" in detail) {
setCustomAppName(detail.instanceName as string || null);
}
if ("customLogoBase64" in detail) {
setCustomLogo(detail.customLogoBase64 as string || null);
} else if ("customLogoUrl" in detail) {
setCustomLogo(detail.customLogoUrl as string || null);
}
};
window.addEventListener(SIDEBAR_SETTINGS_UPDATED_EVENT, handleSettingsUpdated as EventListener);
@@ -221,12 +235,20 @@ export default function Sidebar({
className={cn("flex items-center", collapsed ? "justify-center" : "gap-3")}
>
<div className="flex items-center justify-center size-9 rounded bg-linear-to-br from-[#E54D5E] to-[#C93D4E] shrink-0">
<OmniRouteLogo size={20} className="text-white" />
{customLogo ? (
<img
src={customLogo}
alt={customAppName || APP_CONFIG.name}
className="size-5 object-contain"
/>
) : (
<OmniRouteLogo size={20} className="text-white" />
)}
</div>
{!collapsed && (
<div className="flex flex-col">
<h1 className="text-lg font-semibold tracking-tight text-text-main">
{APP_CONFIG.name}
{customAppName || APP_CONFIG.name}
</h1>
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span>
</div>

View File

@@ -16,6 +16,10 @@ export const updateSettingsSchema = z.object({
requireLogin: z.boolean().optional(),
enableSocks5Proxy: z.boolean().optional(),
instanceName: z.string().max(100).optional(),
customLogoUrl: z.string().max(2000).optional(),
customLogoBase64: z.string().max(100000).optional(),
customFaviconUrl: z.string().max(2000).optional(),
customFaviconBase64: z.string().max(50000).optional(),
corsOrigins: z.string().max(500).optional(),
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),