mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #4517 from weiness/fix/custom-branding-flash
fix: prevent custom branding flash on initial load
This commit is contained in:
@@ -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("</head>")
|
||||
result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
|
||||
|
||||
// Replace <title> 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 {
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
+1
-16
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
+44
-1
@@ -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</title>`,
|
||||
)
|
||||
}
|
||||
|
||||
const siteLogo = config.site_logo?.trim()
|
||||
if (siteLogo && isSafeImageUrl(siteLogo)) {
|
||||
brandedHtml = brandedHtml.replace(
|
||||
/<link\s+rel=["']icon["'][^>]*>/i,
|
||||
`<link rel="icon" href="${escapeHtml(siteLogo)}" />`,
|
||||
)
|
||||
}
|
||||
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 = `<script>window.__APP_CONFIG__=${JSON.stringify(data.data)};</script>`
|
||||
return html.replace('</head>', `${script}\n</head>`)
|
||||
return injectBranding(html, data.data).replace('</head>', `${script}\n</head>`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user