diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 2e92fa156a..ad78c2d298 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -194,6 +194,7 @@ import { useI18n } from 'vue-i18n' import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import VersionBadge from '@/components/common/VersionBadge.vue' import { sanitizeSvg } from '@/utils/sanitize' +import { sanitizeUrl } from '@/utils/url' import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags' import { useBatchImageAccess } from '@/composables/useBatchImageAccess' @@ -256,7 +257,7 @@ const expandedGroups = ref>(new Set()) // Site settings from appStore (cached, no flicker) const siteName = computed(() => appStore.siteName) -const siteLogo = computed(() => appStore.siteLogo) +const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true })) const siteVersion = computed(() => appStore.siteVersion) const settingsLoaded = computed(() => appStore.publicSettingsLoaded) diff --git a/frontend/src/components/layout/__tests__/siteLogoSanitization.spec.ts b/frontend/src/components/layout/__tests__/siteLogoSanitization.spec.ts new file mode 100644 index 0000000000..d6b35f37bc --- /dev/null +++ b/frontend/src/components/layout/__tests__/siteLogoSanitization.spec.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const dir = dirname(fileURLToPath(import.meta.url)) +const sidebarSource = readFileSync(resolve(dir, '../AppSidebar.vue'), 'utf8') +const homeViewSource = readFileSync(resolve(dir, '../../../views/HomeView.vue'), 'utf8') +const keyUsageViewSource = readFileSync(resolve(dir, '../../../views/KeyUsageView.vue'), 'utf8') + +describe('site_logo sanitization', () => { + it('AppSidebar imports sanitizeUrl and applies it to siteLogo', () => { + expect(sidebarSource).toContain("import { sanitizeUrl } from '@/utils/url'") + expect(sidebarSource).toContain('sanitizeUrl(appStore.siteLogo') + }) + + it('HomeView applies sanitizeUrl to siteLogo', () => { + expect(homeViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.site_logo || appStore.siteLogo') + }) + + it('KeyUsageView applies sanitizeUrl to siteLogo', () => { + expect(keyUsageViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.site_logo || appStore.siteLogo') + }) + + it('all three pass allowRelative and allowDataUrl options', () => { + for (const src of [sidebarSource, homeViewSource, keyUsageViewSource]) { + expect(src).toContain('allowRelative: true') + expect(src).toContain('allowDataUrl: true') + } + }) +}) diff --git a/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts b/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts new file mode 100644 index 0000000000..c396991a0c --- /dev/null +++ b/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import en from '@/i18n/locales/en' + +function flattenKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = [] + for (const [k, v] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${k}` : k + if (typeof v === 'object' && v !== null && !Array.isArray(v)) { + keys.push(...flattenKeys(v, fullKey)) + } else { + keys.push(fullKey) + } + } + return keys +} + +describe('ops locale key completeness', () => { + const requiredKeys = [ + 'admin.ops.result', + 'admin.ops.timeRange.custom', + 'admin.ops.customTimeRange.startTime', + 'admin.ops.customTimeRange.endTime', + ] + + for (const key of requiredKeys) { + it(`en locale has ${key}`, () => { + const enKeys = flattenKeys(en) + expect(enKeys).toContain(key) + }) + } +}) + +describe('groups locale key completeness', () => { + it('en locale has admin.groups.failedToSave', () => { + const enKeys = flattenKeys(en) + expect(enKeys).toContain('admin.groups.failedToSave') + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts index 1bcbc42775..88e997ed82 100644 --- a/frontend/src/i18n/locales/en/admin/ops.ts +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -23,6 +23,7 @@ export default { lastRun: 'last_run:', lastSuccess: 'last_success:', lastError: 'last_error:', + result: 'Result', noData: 'No data.', loadingText: 'loading', ready: 'ready', @@ -143,7 +144,12 @@ export default { '6h': 'Last 6 hours', '24h': 'Last 24 hours', '7d': 'Last 7 days', - '30d': 'Last 30 days' + '30d': 'Last 30 days', + custom: 'Custom Range' + }, + customTimeRange: { + startTime: 'Start Time', + endTime: 'End Time' }, openaiTokenStats: { title: 'OpenAI Token Request Stats', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index ffe9723126..9ab9eeac9c 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -770,6 +770,7 @@ export default { failedToLoad: 'Failed to load groups', failedToCreate: 'Failed to create group', failedToUpdate: 'Failed to update group', + failedToSave: 'Failed to save group', failedToDelete: 'Failed to delete group', nameRequired: 'Please enter group name', rateMultipliers: 'Rate Multipliers', diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index d8eb176998..f88742a92a 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -419,7 +419,7 @@ const appStore = useAppStore() // Site settings - directly from appStore (already initialized from injected config) const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API') -const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '') +const siteLogo = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true })) const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform') const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')) const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '') diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index dda7590dce..2cbecbcb66 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -431,7 +431,7 @@ const appStore = useAppStore() // ==================== Site Settings (same as HomeView) ==================== const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API') -const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '') +const siteLogo = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true })) const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')) const githubUrl = 'https://github.com/Wei-Shaw/sub2api'