diff --git a/package-lock.json b/package-lock.json index 5fdb8f5a09..a211606fe5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index bf173b8b4b..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}` @@ -282,6 +283,227 @@ 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")}

+
+ + +
+ {uploadError &&

{uploadError}

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

{t("logoPreview")}

+ Logo preview +
+ )} +
+ +
+
+

{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")}

+
+ + +
+ {uploadError && !uploadError.includes("Logo") && ( +

{uploadError}

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

{t("faviconPreview")}

+ Favicon 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/app/api/settings/favicon/route.ts b/src/app/api/settings/favicon/route.ts new file mode 100644 index 0000000000..164f1410dc --- /dev/null +++ b/src/app/api/settings/favicon/route.ts @@ -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"); + } +} 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 ad6213f441..1e0ffaeaca 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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…", 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}
diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index ac174837a6..de5c1fc53c 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -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(),