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(`<link rel="icon"`)) + if linkStart == -1 { + return html + } + linkEndOffset := bytes.IndexByte(html[linkStart:], '>') + if linkEndOffset == -1 { + return html + } + linkEnd := linkStart + linkEndOffset + 1 + replacement := []byte(`<link rel="icon" href="` + htmlpkg.EscapeString(logoURL) + `" />`) + + 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 <title> 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(`<html><head><link rel="icon" type="image/png" href="/logo.png" /></head></html>`) + settingsJSON := []byte(`{"site_logo":"https://example.com/custom-logo.png"}`) + + result := injectSiteFavicon(html, settingsJSON) + + assert.Contains(t, string(result), `<link rel="icon" href="https://example.com/custom-logo.png" />`) + assert.NotContains(t, string(result), `/logo.png`) + }) + + t.Run("supports_relative_and_data_image_urls", func(t *testing.T) { + html := []byte(`<link rel="icon" href="/logo.png" />`) + + 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(`<link rel="icon" href="/logo.png" />`) + + 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(`<link rel="icon" href="/logo.png" />`) + + 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(`<script nonce="__CSP_NONCE_VALUE__">console.log('test');</script>`) 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<HTMLLinkElement>('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 = '<link rel="icon" href="/logo.png">' + }) + + it('replaces the default favicon with the configured logo', () => { + updateFavicon('https://example.com/custom-logo.png') + + const link = document.querySelector<HTMLLinkElement>('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<HTMLLinkElement>('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<HTMLLinkElement>('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 @@ <header class="border-b border-gray-200 bg-white/95 dark:border-dark-800 dark:bg-dark-900/95"> <div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6"> <RouterLink to="/home" class="flex min-w-0 items-center gap-3"> - <span class="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700"> - <img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" /> - </span> - <span class="truncate text-base font-semibold text-gray-950 dark:text-white"> - {{ siteName }} - </span> + <template v-if="settings"> + <span class="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700"> + <img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" /> + </span> + <span class="truncate text-base font-semibold text-gray-950 dark:text-white"> + {{ siteName }} + </span> + </template> + <template v-else> + <span class="h-10 w-10 flex-shrink-0 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span> + <span class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span> + </template> </RouterLink> <RouterLink to="/login" @@ -90,10 +96,10 @@ import { marked } from 'marked' import DOMPurify from 'dompurify' import { useI18n } from 'vue-i18n' import Icon from '@/components/icons/Icon.vue' -import { getPublicSettings } from '@/api/auth' import { getLocale } from '@/i18n' import { sanitizeUrl } from '@/utils/url' -import type { LoginAgreementDocument, PublicSettings } from '@/types' +import { useAppStore } from '@/stores/app' +import type { LoginAgreementDocument } from '@/types' import zhAdminCompliance from '../../../../docs/legal/admin-compliance.zh.md?raw' import enAdminCompliance from '../../../../docs/legal/admin-compliance.en.md?raw' @@ -101,8 +107,9 @@ type LegalDocumentIcon = 'document' | 'shield' | 'globe' | 'cog' const route = useRoute() const { t } = useI18n() -const settings = ref<PublicSettings | null>(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<LegalDocumentIcon>(() => { }) 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 }) </script> 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>[^<]*<\/title>/i, + `<title>${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) {