diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index eec403dc..26cd3128 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -30,20 +30,6 @@ func generateMenuItemID() string {
return hex.EncodeToString(b)
}
-// parseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
-// Returns empty slice on empty/invalid input.
-func parseCustomMenuItems(raw string) []dto.CustomMenuItem {
- raw = strings.TrimSpace(raw)
- if raw == "" || raw == "[]" {
- return []dto.CustomMenuItem{}
- }
- var items []dto.CustomMenuItem
- if err := json.Unmarshal([]byte(raw), &items); err != nil {
- return []dto.CustomMenuItem{}
- }
- return items
-}
-
// SettingHandler 系统设置处理器
type SettingHandler struct {
settingService *service.SettingService
@@ -116,7 +102,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled,
- CustomMenuItems: parseCustomMenuItems(settings.CustomMenuItems),
+ CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions,
@@ -166,17 +152,17 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
// OEM设置
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo"`
- SiteSubtitle string `json:"site_subtitle"`
- APIBaseURL string `json:"api_base_url"`
- ContactInfo string `json:"contact_info"`
- DocURL string `json:"doc_url"`
- HomeContent string `json:"home_content"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
+ SiteName string `json:"site_name"`
+ SiteLogo string `json:"site_logo"`
+ SiteSubtitle string `json:"site_subtitle"`
+ APIBaseURL string `json:"api_base_url"`
+ ContactInfo string `json:"contact_info"`
+ DocURL string `json:"doc_url"`
+ HomeContent string `json:"home_content"`
+ HideCcsImportButton bool `json:"hide_ccs_import_button"`
+ PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
+ PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
+ SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
// 默认配置
@@ -326,18 +312,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
// 自定义菜单项验证
+ const (
+ maxCustomMenuItems = 20
+ maxMenuItemLabelLen = 50
+ maxMenuItemURLLen = 2048
+ maxMenuItemIconSVGLen = 10 * 1024 // 10KB
+ maxMenuItemIDLen = 32
+ )
+
customMenuJSON := previousSettings.CustomMenuItems
if req.CustomMenuItems != nil {
items := *req.CustomMenuItems
+ if len(items) > maxCustomMenuItems {
+ response.BadRequest(c, "Too many custom menu items (max 20)")
+ return
+ }
for i, item := range items {
if strings.TrimSpace(item.Label) == "" {
response.BadRequest(c, "Custom menu item label is required")
return
}
+ if len(item.Label) > maxMenuItemLabelLen {
+ response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
+ return
+ }
if strings.TrimSpace(item.URL) == "" {
response.BadRequest(c, "Custom menu item URL is required")
return
}
+ if len(item.URL) > maxMenuItemURLLen {
+ response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
+ return
+ }
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
return
@@ -346,11 +352,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
return
}
+ if len(item.IconSVG) > maxMenuItemIconSVGLen {
+ response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
+ return
+ }
// Auto-generate ID if missing
if strings.TrimSpace(item.ID) == "" {
items[i].ID = generateMenuItemID()
+ } else if len(item.ID) > maxMenuItemIDLen {
+ response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)")
+ return
}
}
+ // ID uniqueness check
+ seen := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ if _, exists := seen[item.ID]; exists {
+ response.BadRequest(c, "Duplicate custom menu item ID: "+item.ID)
+ return
+ }
+ seen[item.ID] = struct{}{}
+ }
menuBytes, err := json.Marshal(items)
if err != nil {
response.BadRequest(c, "Failed to serialize custom menu items")
@@ -510,7 +532,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
SoraClientEnabled: updatedSettings.SoraClientEnabled,
- CustomMenuItems: parseCustomMenuItems(updatedSettings.CustomMenuItems),
+ CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
DefaultSubscriptions: updatedDefaultSubscriptions,
@@ -674,6 +696,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
changed = append(changed, "min_claude_code_version")
}
+ if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled {
+ changed = append(changed, "purchase_subscription_enabled")
+ }
+ if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
+ changed = append(changed, "purchase_subscription_url")
+ }
+ if before.CustomMenuItems != after.CustomMenuItems {
+ changed = append(changed, "custom_menu_items")
+ }
return changed
}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index a7d5da22..f3c21be5 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -1,5 +1,10 @@
package dto
+import (
+ "encoding/json"
+ "strings"
+)
+
// CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct {
ID string `json:"id"`
@@ -37,17 +42,17 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo"`
- SiteSubtitle string `json:"site_subtitle"`
- APIBaseURL string `json:"api_base_url"`
- ContactInfo string `json:"contact_info"`
- DocURL string `json:"doc_url"`
- HomeContent string `json:"home_content"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
+ SiteName string `json:"site_name"`
+ SiteLogo string `json:"site_logo"`
+ SiteSubtitle string `json:"site_subtitle"`
+ APIBaseURL string `json:"api_base_url"`
+ ContactInfo string `json:"contact_info"`
+ DocURL string `json:"doc_url"`
+ HomeContent string `json:"home_content"`
+ HideCcsImportButton bool `json:"hide_ccs_import_button"`
+ PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
+ PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
+ SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
DefaultConcurrency int `json:"default_concurrency"`
@@ -80,28 +85,28 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key"`
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo"`
- SiteSubtitle string `json:"site_subtitle"`
- APIBaseURL string `json:"api_base_url"`
- ContactInfo string `json:"contact_info"`
- DocURL string `json:"doc_url"`
- HomeContent string `json:"home_content"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
+ RegistrationEnabled bool `json:"registration_enabled"`
+ EmailVerifyEnabled bool `json:"email_verify_enabled"`
+ PromoCodeEnabled bool `json:"promo_code_enabled"`
+ PasswordResetEnabled bool `json:"password_reset_enabled"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
+ TurnstileEnabled bool `json:"turnstile_enabled"`
+ TurnstileSiteKey string `json:"turnstile_site_key"`
+ SiteName string `json:"site_name"`
+ SiteLogo string `json:"site_logo"`
+ SiteSubtitle string `json:"site_subtitle"`
+ APIBaseURL string `json:"api_base_url"`
+ ContactInfo string `json:"contact_info"`
+ DocURL string `json:"doc_url"`
+ HomeContent string `json:"home_content"`
+ HideCcsImportButton bool `json:"hide_ccs_import_button"`
+ PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
+ PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
- Version string `json:"version"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
+ SoraClientEnabled bool `json:"sora_client_enabled"`
+ Version string `json:"version"`
}
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
@@ -150,3 +155,17 @@ type StreamTimeoutSettings struct {
ThresholdCount int `json:"threshold_count"`
ThresholdWindowMinutes int `json:"threshold_window_minutes"`
}
+
+// ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
+// Returns empty slice on empty/invalid input.
+func ParseCustomMenuItems(raw string) []CustomMenuItem {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "[]" {
+ return []CustomMenuItem{}
+ }
+ var items []CustomMenuItem
+ if err := json.Unmarshal([]byte(raw), &items); err != nil {
+ return []CustomMenuItem{}
+ }
+ return items
+}
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 1b8e33a8..40277a48 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -1,9 +1,6 @@
package handler
import (
- "encoding/json"
- "strings"
-
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -53,22 +50,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
- CustomMenuItems: parsePublicCustomMenuItems(settings.CustomMenuItems),
+ CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version,
})
}
-
-// parsePublicCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
-func parsePublicCustomMenuItems(raw string) []dto.CustomMenuItem {
- raw = strings.TrimSpace(raw)
- if raw == "" || raw == "[]" {
- return []dto.CustomMenuItem{}
- }
- var items []dto.CustomMenuItem
- if err := json.Unmarshal([]byte(raw), &items); err != nil {
- return []dto.CustomMenuItem{}
- }
- return items
-}
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index a8845d9b..f15a2074 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -513,7 +513,8 @@ func TestAPIContracts(t *testing.T) {
"hide_ccs_import_button": false,
"purchase_subscription_enabled": false,
"purchase_subscription_url": "",
- "min_claude_code_version": ""
+ "min_claude_code_version": "",
+ "custom_menu_items": []
}
}`,
},
diff --git a/frontend/src/components/common/ImageUpload.vue b/frontend/src/components/common/ImageUpload.vue
index b77ab64e..6ef84079 100644
--- a/frontend/src/components/common/ImageUpload.vue
+++ b/frontend/src/components/common/ImageUpload.vue
@@ -11,7 +11,7 @@
v-if="mode === 'svg' && modelValue"
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
:class="innerSizeClass"
- v-html="modelValue"
+ v-html="sanitizedValue"
>
import { ref, computed } from 'vue'
import Icon from '@/components/icons/Icon.vue'
+import { sanitizeSvg } from '@/utils/sanitize'
const props = withDefaults(defineProps<{
modelValue: string
@@ -97,6 +98,10 @@ const error = ref('')
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
+const sanitizedValue = computed(() =>
+ props.mode === 'svg' ? sanitizeSvg(props.modelValue ?? '') : ''
+)
+
const previewSizeClass = computed(() => props.size === 'sm' ? 'h-14 w-14' : 'h-20 w-20')
const innerSizeClass = computed(() => props.size === 'sm' ? 'h-7 w-7' : 'h-12 w-12')
const placeholderSizeClass = computed(() => props.size === 'sm' ? 'h-5 w-5' : 'h-8 w-8')
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 40b8c8de..dcfc60bb 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -47,7 +47,7 @@
"
@click="handleMenuItemClick(item.path)"
>
-
+
{{ item.label }}
@@ -72,7 +72,7 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
-
+
{{ item.label }}
@@ -94,7 +94,7 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
-
+
{{ item.label }}
@@ -152,6 +152,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
+import { sanitizeSvg } from '@/utils/sanitize'
interface NavItem {
path: string
diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/utils/sanitize.ts
new file mode 100644
index 00000000..a61a52e1
--- /dev/null
+++ b/frontend/src/utils/sanitize.ts
@@ -0,0 +1,6 @@
+import DOMPurify from 'dompurify'
+
+export function sanitizeSvg(svg: string): string {
+ if (!svg) return ''
+ return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
+}
diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue
index 45e61e17..ed1c11d7 100644
--- a/frontend/src/views/user/CustomPageView.vue
+++ b/frontend/src/views/user/CustomPageView.vue
@@ -87,7 +87,11 @@ const menuItemId = computed(() => route.params.id as string)
const menuItem = computed(() => {
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
- return items.find((item) => item.id === menuItemId.value) ?? null
+ const found = items.find((item) => item.id === menuItemId.value) ?? null
+ if (found && found.visibility === 'admin' && !authStore.isAdmin) {
+ return null
+ }
+ return found
})
const embeddedUrl = computed(() => {