From ac10d25f5f30e89793371bfbf88ce9f871db393c Mon Sep 17 00:00:00 2001 From: oyi77 Date: Wed, 1 Apr 2026 03:41:47 +0700 Subject: [PATCH 1/4] feat(settings): add appearance tab and whitelabeling features - Add dedicated 'appearance' tab to settings navigation - Move AppearanceTab from general to dedicated tab - Add whitelabeling fields to Zod schema (customLogoUrl, customLogoBase64) - Add whitelabeling UI section with: - App name customization - Custom logo URL input - Logo file upload with preview - Reset to default functionality - Add i18n keys for whitelabeling features This allows users to customize the application name and logo for white-labeling purposes. --- .../settings/components/AppearanceTab.tsx | 103 ++++++++++++++++++ .../(dashboard)/dashboard/settings/page.tsx | 12 +- src/i18n/messages/en.json | 9 ++ src/shared/validation/settingsSchemas.ts | 2 + 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index bf173b8b4b..e9ddfcdb06 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -282,6 +282,109 @@ export default function AppearanceTab() { /> + +
+
+
+ +
+
+

{t("whitelabeling")}

+

{t("whitelabelingDesc")}

+
+
+ +
+
+
+

{t("appName")}

+

{t("appNameDesc")}

+
+ 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" + /> +
+ +
+
+

{t("customLogo")}

+

{t("customLogoDesc")}

+
+
+ 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) && ( + Logo preview { + e.currentTarget.style.display = "none"; + }} + /> + )} +
+
+ +
+

{t("uploadLogo")}

+
+ { + const file = e.target.files?.[0]; + if (file) { + if (file.size > 500 * 1024) { + alert("Logo file must be less than 500KB"); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + updateSetting("customLogoBase64", base64); + }; + reader.readAsDataURL(file); + } + }} + className="text-sm text-text-muted" + /> + +
+ {(settings.customLogoBase64 || settings.customLogoUrl) && ( +
+

{t("logoPreview")}

+ Logo preview +
+ )} +
+
+
); diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 9d0b261022..4da90585ef 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -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" && ( - <> -
- - -
- - )} + {activeTab === "general" && } + + {activeTab === "appearance" && } {activeTab === "ai" && (
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 522fa600b6..5afdda66ed 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1789,6 +1789,15 @@ "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", "promptCache": "Prompt Cache", "flushCache": "Flush Cache", "flushing": "Flushing…", diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index ac174837a6..76c86b332d 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -16,6 +16,8 @@ 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(), corsOrigins: z.string().max(500).optional(), cloudUrl: z.string().max(500).optional(), baseUrl: z.string().max(500).optional(), From 47cb9e8e44f10c4de63a8e906e5acdaf5190e4ba Mon Sep 17 00:00:00 2001 From: oyi77 Date: Wed, 1 Apr 2026 03:44:54 +0700 Subject: [PATCH 2/4] feat(sidebar): wire whitelabeling settings to sidebar - Add state for custom app name and logo - Fetch whitelabeling settings from /api/settings - Listen for whitelabeling changes via settings event - Display custom app name when set - Display custom logo (Base64 or URL) when set - Fall back to default OmniRoute logo and name --- src/shared/components/Sidebar.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 2eeb5a3b65..019da21e6b 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -37,11 +37,15 @@ export default function Sidebar({ const [isDisconnected, setIsDisconnected] = useState(false); const [showDebug, setShowDebug] = useState(false); const [hiddenSidebarItems, setHiddenSidebarItems] = useState([]); + const [customAppName, setCustomAppName] = useState(null); + const [customLogo, setCustomLogo] = useState(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")} >
- + {customLogo ? ( + {customAppName + ) : ( + + )}
{!collapsed && (

- {APP_CONFIG.name} + {customAppName || APP_CONFIG.name}

v{APP_CONFIG.version}
From 1d47cadae881b7ad18259c66c60c0f49a6ef83f9 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Wed, 1 Apr 2026 03:54:02 +0700 Subject: [PATCH 3/4] feat(favicon): add custom favicon support - Add customFaviconUrl and customFaviconBase64 to Zod schema - Add favicon customization UI (URL input + file upload) - Create /api/settings/favicon endpoint for dynamic favicon - Update layout.tsx to use generateMetadata for dynamic favicon - Add i18n keys for favicon customization - Favicon updates browser tab icon in real-time --- .../settings/components/AppearanceTab.tsx | 72 +++++++++++++++++++ src/app/api/settings/favicon/route.ts | 55 ++++++++++++++ src/app/layout.tsx | 25 ++++--- src/i18n/messages/en.json | 5 ++ src/shared/validation/settingsSchemas.ts | 2 + 5 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 src/app/api/settings/favicon/route.ts diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index e9ddfcdb06..687b3f511c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -383,6 +383,78 @@ export default function AppearanceTab() {
)} + +
+
+

{t("customFavicon")}

+

{t("customFaviconDesc")}

+
+
+ 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) && ( + Favicon preview { + e.currentTarget.style.display = "none"; + }} + /> + )} +
+
+ +
+

{t("uploadFavicon")}

+
+ { + const file = e.target.files?.[0]; + if (file) { + if (file.size > 50 * 1024) { + alert("Favicon file must be less than 50KB"); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + updateSetting("customFaviconBase64", base64); + }; + reader.readAsDataURL(file); + } + }} + className="text-sm text-text-muted" + /> + +
+ {(settings.customFaviconBase64 || settings.customFaviconUrl) && ( +
+

{t("faviconPreview")}

+ Favicon preview +
+ )} +
diff --git a/src/app/api/settings/favicon/route.ts b/src/app/api/settings/favicon/route.ts new file mode 100644 index 0000000000..de9390cba0 --- /dev/null +++ b/src/app/api/settings/favicon/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/db/settings"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const settings = await getSettings(); + + const customFaviconBase64 = settings?.customFaviconBase64; + const customFaviconUrl = settings?.customFaviconUrl; + + let faviconData: string | null = null; + + if (customFaviconBase64) { + faviconData = customFaviconBase64; + } else if (customFaviconUrl) { + try { + const response = await fetch(customFaviconUrl); + if (response.ok) { + const arrayBuffer = await response.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + const base64 = Buffer.from(uint8Array).toString("base64"); + const contentType = response.headers.get("content-type") || "image/png"; + faviconData = `data:${contentType};base64,${base64}`; + } + } 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=3600", + }, + }); + } catch (error) { + console.error("Favicon API error:", error); + return NextResponse.redirect("/favicon.svg"); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 809cef587a..c184aa720b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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(); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5afdda66ed..693ffdcdc4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1798,6 +1798,11 @@ "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…", diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 76c86b332d..de5c1fc53c 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -18,6 +18,8 @@ export const updateSettingsSchema = z.object({ 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(), From fe2aaa81cae9e91b67d3eaa99012732381478455 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Wed, 1 Apr 2026 05:09:09 +0700 Subject: [PATCH 4/4] fix: address code review issues - Add file type validation for logo/favicon uploads - Add error state handling instead of alert() - Style file inputs with proper button appearance - Add SSRF protection to favicon API (URL validation) - Add fetch timeout (5 seconds) - Add content-type validation - Reduce cache duration to 5 minutes - Validate image data size before serving --- package-lock.json | 1 + .../settings/components/AppearanceTab.tsx | 123 ++++++++++++------ src/app/api/settings/favicon/route.ts | 114 ++++++++++++++-- 3 files changed, 186 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index c884d26fcb..d5cc101566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10707,6 +10707,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, diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index 687b3f511c..89ef8242fd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -21,6 +21,7 @@ export default function AppearanceTab() { const tSidebar = useTranslations("sidebar"); const [settings, setSettings] = useState>({}); const [loading, setLoading] = useState(true); + const [uploadError, setUploadError] = useState(null); const [customThemeColor, setCustomThemeColor] = useState(customColor || "#3b82f6"); const isValidHex = /^#([0-9a-fA-F]{6})$/.test( customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}` @@ -342,26 +343,47 @@ export default function AppearanceTab() {

{t("uploadLogo")}

- { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 500 * 1024) { - alert("Logo file must be less than 500KB"); - return; +
+ {uploadError &&

{uploadError}

} {(settings.customLogoBase64 || settings.customLogoUrl) && (

{t("logoPreview")}

@@ -414,26 +437,47 @@ export default function AppearanceTab() {

{t("uploadFavicon")}

- { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 50 * 1024) { - alert("Favicon file must be less than 50KB"); - return; +
+ {uploadError && !uploadError.includes("Logo") && ( +

{uploadError}

+ )} {(settings.customFaviconBase64 || settings.customFaviconUrl) && (

{t("faviconPreview")}

diff --git a/src/app/api/settings/favicon/route.ts b/src/app/api/settings/favicon/route.ts index de9390cba0..164f1410dc 100644 --- a/src/app/api/settings/favicon/route.ts +++ b/src/app/api/settings/favicon/route.ts @@ -3,29 +3,115 @@ 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; - const customFaviconUrl = settings?.customFaviconUrl; + const customFaviconBase64 = settings?.customFaviconBase64 as string | undefined; + const customFaviconUrl = settings?.customFaviconUrl as string | undefined; let faviconData: string | null = null; if (customFaviconBase64) { - faviconData = customFaviconBase64; + // Validate stored Base64 data + const match = customFaviconBase64.match(/^data:([^;]+);base64,(.+)$/); + if (match && validateImageData(customFaviconBase64, match[1])) { + faviconData = customFaviconBase64; + } } else if (customFaviconUrl) { - try { - const response = await fetch(customFaviconUrl); - if (response.ok) { - const arrayBuffer = await response.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - const base64 = Buffer.from(uint8Array).toString("base64"); - const contentType = response.headers.get("content-type") || "image/png"; - faviconData = `data:${contentType};base64,${base64}`; + // 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); } - } catch (error) { - console.error("Failed to fetch custom favicon:", error); } } @@ -45,7 +131,7 @@ export async function GET() { return new NextResponse(buffer, { headers: { "Content-Type": contentType, - "Cache-Control": "public, max-age=3600", + "Cache-Control": `public, max-age=${CACHE_DURATION}`, }, }); } catch (error) {