From 500f24169201c2dc1329c5222855e431fd4e7862 Mon Sep 17 00:00:00 2001
From: weiness <840833211@qq.com>
Date: Sat, 18 Jul 2026 00:24:06 +0800
Subject: [PATCH] fix: prevent custom branding flash on initial load
---
backend/internal/web/embed_on.go | 55 ++++++++++++++++++-
backend/internal/web/embed_test.go | 35 ++++++++++++
frontend/src/App.vue | 17 +-----
frontend/src/main.ts | 2 +
frontend/src/utils/__tests__/branding.spec.ts | 22 ++++++++
frontend/src/utils/branding.ts | 21 +++++++
.../src/views/public/LegalDocumentView.vue | 36 ++++++------
frontend/vite.config.ts | 45 ++++++++++++++-
8 files changed, 199 insertions(+), 34 deletions(-)
create mode 100644 frontend/src/utils/__tests__/branding.spec.ts
create mode 100644 frontend/src/utils/branding.ts
diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go
index 4bd984301b..516ac88dc0 100644
--- a/backend/internal/web/embed_on.go
+++ b/backend/internal/web/embed_on.go
@@ -11,6 +11,7 @@ import (
"io"
"io/fs"
"net/http"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -209,12 +210,64 @@ func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
headClose := []byte("")
result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
- // Replace
with custom site name so the browser tab shows it immediately
+ // Apply custom branding before the browser paints the static defaults.
result = injectSiteTitle(result, settingsJSON)
+ result = injectSiteFavicon(result, settingsJSON)
return result
}
+// injectSiteFavicon replaces the static favicon with a configured, browser-safe image URL.
+func injectSiteFavicon(html, settingsJSON []byte) []byte {
+ var cfg struct {
+ SiteLogo string `json:"site_logo"`
+ }
+ if err := json.Unmarshal(settingsJSON, &cfg); err != nil {
+ return html
+ }
+
+ logoURL := safeImageURL(cfg.SiteLogo)
+ if logoURL == "" {
+ return html
+ }
+
+ linkStart := bytes.Index(html, []byte(`')
+ if linkEndOffset == -1 {
+ return html
+ }
+ linkEnd := linkStart + linkEndOffset + 1
+ replacement := []byte(``)
+
+ var buf bytes.Buffer
+ buf.Write(html[:linkStart])
+ buf.Write(replacement)
+ buf.Write(html[linkEnd:])
+ return buf.Bytes()
+}
+
+func safeImageURL(value string) string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return ""
+ }
+ if strings.HasPrefix(trimmed, "/") && !strings.HasPrefix(trimmed, "//") {
+ return trimmed
+ }
+ if strings.HasPrefix(strings.ToLower(trimmed), "data:image/") {
+ return trimmed
+ }
+
+ parsed, err := url.Parse(trimmed)
+ if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
+ return ""
+ }
+ return trimmed
+}
+
// injectSiteTitle replaces the static in HTML with the configured site name.
// This ensures the browser tab shows the correct title before JS executes.
func injectSiteTitle(html, settingsJSON []byte) []byte {
diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go
index 769b856ac1..3a9bc1151a 100644
--- a/backend/internal/web/embed_test.go
+++ b/backend/internal/web/embed_test.go
@@ -114,6 +114,41 @@ func TestInjectSiteTitle(t *testing.T) {
})
}
+func TestInjectSiteFavicon(t *testing.T) {
+ t.Run("replaces_favicon_with_site_logo", func(t *testing.T) {
+ html := []byte(``)
+ settingsJSON := []byte(`{"site_logo":"https://example.com/custom-logo.png"}`)
+
+ result := injectSiteFavicon(html, settingsJSON)
+
+ assert.Contains(t, string(result), ``)
+ assert.NotContains(t, string(result), `/logo.png`)
+ })
+
+ t.Run("supports_relative_and_data_image_urls", func(t *testing.T) {
+ html := []byte(``)
+
+ assert.Contains(t, string(injectSiteFavicon(html, []byte(`{"site_logo":"/uploads/logo.svg"}`))), `/uploads/logo.svg`)
+ assert.Contains(t, string(injectSiteFavicon(html, []byte(`{"site_logo":"data:image/png;base64,abc"}`))), `data:image/png;base64,abc`)
+ })
+
+ t.Run("rejects_unsafe_logo_urls", func(t *testing.T) {
+ html := []byte(``)
+
+ result := injectSiteFavicon(html, []byte(`{"site_logo":"javascript:alert(1)"}`))
+
+ assert.Equal(t, string(html), string(result))
+ })
+
+ t.Run("escapes_logo_url_for_html", func(t *testing.T) {
+ html := []byte(``)
+
+ result := injectSiteFavicon(html, []byte(`{"site_logo":"https://example.com/logo.png?a=1&b=2"}`))
+
+ assert.Contains(t, string(result), `a=1&b=2`)
+ })
+}
+
func TestReplaceNoncePlaceholder(t *testing.T) {
t.Run("replaces_single_placeholder", func(t *testing.T) {
html := []byte(``)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 14c364c3b2..1a4f7437d5 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -8,6 +8,7 @@ import { resolveRouteDocumentTitle } from '@/router/title'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore, useAdminSettingsStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
+import { updateFavicon } from '@/utils/branding'
const router = useRouter()
const route = useRoute()
@@ -26,22 +27,6 @@ function updateDocumentTitle() {
document.title = resolveRouteDocumentTitle(route, appStore.siteName, customMenuItems)
}
-/**
- * Update favicon dynamically
- * @param logoUrl - URL of the logo to use as favicon
- */
-function updateFavicon(logoUrl: string) {
- // Find existing favicon link or create new one
- let link = document.querySelector('link[rel="icon"]')
- if (!link) {
- link = document.createElement('link')
- link.rel = 'icon'
- document.head.appendChild(link)
- }
- link.type = logoUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/x-icon'
- link.href = logoUrl
-}
-
// Watch for site settings changes and update favicon/title
watch(
() => appStore.siteLogo,
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 68ace88573..d7c17b4663 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -4,6 +4,7 @@ import App from './App.vue'
import router from './router'
import i18n, { initI18n } from './i18n'
import { useAppStore } from '@/stores/app'
+import { updateFavicon } from '@/utils/branding'
import './style.css'
function initThemeClass() {
@@ -31,6 +32,7 @@ async function bootstrap() {
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
document.title = `${appStore.siteName} - AI API Gateway`
}
+ updateFavicon(appStore.siteLogo)
await initI18n()
diff --git a/frontend/src/utils/__tests__/branding.spec.ts b/frontend/src/utils/__tests__/branding.spec.ts
new file mode 100644
index 0000000000..192c180c16
--- /dev/null
+++ b/frontend/src/utils/__tests__/branding.spec.ts
@@ -0,0 +1,22 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { updateFavicon } from '@/utils/branding'
+
+describe('updateFavicon', () => {
+ beforeEach(() => {
+ document.head.innerHTML = ''
+ })
+
+ it('replaces the default favicon with the configured logo', () => {
+ updateFavicon('https://example.com/custom-logo.png')
+
+ const link = document.querySelector('link[rel="icon"]')
+ expect(link?.href).toBe('https://example.com/custom-logo.png')
+ })
+
+ it('ignores unsafe logo URLs', () => {
+ updateFavicon('javascript:alert(1)')
+
+ const link = document.querySelector('link[rel="icon"]')
+ expect(link?.getAttribute('href')).toBe('/logo.png')
+ })
+})
diff --git a/frontend/src/utils/branding.ts b/frontend/src/utils/branding.ts
new file mode 100644
index 0000000000..be8c68c7ca
--- /dev/null
+++ b/frontend/src/utils/branding.ts
@@ -0,0 +1,21 @@
+import { sanitizeUrl } from '@/utils/url'
+
+export function updateFavicon(logoUrl: string): void {
+ const sanitizedLogoUrl = sanitizeUrl(logoUrl, {
+ allowRelative: true,
+ allowDataUrl: true,
+ })
+ if (!sanitizedLogoUrl) {
+ return
+ }
+
+ let link = document.querySelector('link[rel="icon"]')
+ if (!link) {
+ link = document.createElement('link')
+ link.rel = 'icon'
+ document.head.appendChild(link)
+ }
+
+ link.type = sanitizedLogoUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/x-icon'
+ link.href = sanitizedLogoUrl
+}
diff --git a/frontend/src/views/public/LegalDocumentView.vue b/frontend/src/views/public/LegalDocumentView.vue
index 601bb12d29..298f23a0b8 100644
--- a/frontend/src/views/public/LegalDocumentView.vue
+++ b/frontend/src/views/public/LegalDocumentView.vue
@@ -3,12 +3,18 @@
-
-
-
-
- {{ siteName }}
-
+
+
+
+
+
+ {{ siteName }}
+
+
+
+
+
+
(null)
-const loading = ref(true)
+const appStore = useAppStore()
+const settings = computed(() => appStore.cachedPublicSettings)
+const loading = ref(!settings.value)
const loadError = ref(false)
marked.setOptions({
@@ -166,15 +173,12 @@ const documentIcon = computed(() => {
})
onMounted(async () => {
- loading.value = true
loadError.value = false
- try {
- settings.value = await getPublicSettings()
- } catch {
+ const loadedSettings = await appStore.fetchPublicSettings()
+ if (!loadedSettings) {
loadError.value = true
- } finally {
- loading.value = false
}
+ loading.value = false
})
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 3877070453..ee2009e297 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,6 +3,49 @@ import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker'
import { resolve } from 'path'
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (character) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ })[character] || character)
+}
+
+function isSafeImageUrl(value: string): boolean {
+ const trimmed = value.trim()
+ if ((trimmed.startsWith('/') && !trimmed.startsWith('//')) || /^data:image\//i.test(trimmed)) {
+ return true
+ }
+ try {
+ const parsed = new URL(trimmed)
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ } catch {
+ return false
+ }
+}
+
+function injectBranding(html: string, config: { site_name?: string; site_logo?: string }): string {
+ let brandedHtml = html
+ const siteName = config.site_name?.trim()
+ if (siteName) {
+ brandedHtml = brandedHtml.replace(
+ /[^<]*<\/title>/i,
+ `${escapeHtml(siteName)} - AI API Gateway`,
+ )
+ }
+
+ const siteLogo = config.site_logo?.trim()
+ if (siteLogo && isSafeImageUrl(siteLogo)) {
+ brandedHtml = brandedHtml.replace(
+ /]*>/i,
+ ``,
+ )
+ }
+ return brandedHtml
+}
+
/**
* Vite 插件:开发模式下注入公开配置到 index.html
* 与生产模式的后端注入行为保持一致,消除闪烁
@@ -22,7 +65,7 @@ function injectPublicSettings(backendUrl: string): Plugin {
const data = await response.json()
if (data.code === 0 && data.data) {
const script = ``
- return html.replace('', `${script}\n`)
+ return injectBranding(html, data.data).replace('', `${script}\n`)
}
}
} catch (e) {