diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 630952096..46edcb692 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -100,7 +100,7 @@ func runSetupServer() {
r := gin.New()
r.Use(middleware.Recovery())
r.Use(middleware.CORS(config.CORSConfig{}))
- r.Use(middleware.SecurityHeaders(config.CSPConfig{Enabled: true, Policy: config.DefaultCSPPolicy}))
+ r.Use(middleware.SecurityHeaders(config.CSPConfig{Enabled: true, Policy: config.DefaultCSPPolicy}, nil))
// Register setup routes
setup.RegisterRoutes(r)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index e7da042ce..e32c142f4 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -1,6 +1,9 @@
package admin
import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
"fmt"
"log"
"net/http"
@@ -20,6 +23,18 @@ import (
// semverPattern 预编译 semver 格式校验正则
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
+// menuItemIDPattern validates custom menu item IDs: alphanumeric, hyphens, underscores only.
+var menuItemIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+
+// generateMenuItemID generates a short random hex ID for a custom menu item.
+func generateMenuItemID() (string, error) {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("generate menu item ID: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
+
// SettingHandler 系统设置处理器
type SettingHandler struct {
settingService *service.SettingService
@@ -92,6 +107,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled,
+ CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions,
@@ -141,17 +157,18 @@ 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"`
// 默认配置
DefaultConcurrency int `json:"default_concurrency"`
@@ -299,6 +316,84 @@ 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
+ }
+ if item.Visibility != "user" && item.Visibility != "admin" {
+ 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) == "" {
+ id, err := generateMenuItemID()
+ if err != nil {
+ response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID")
+ return
+ }
+ items[i].ID = id
+ } else if len(item.ID) > maxMenuItemIDLen {
+ response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)")
+ return
+ } else if !menuItemIDPattern.MatchString(item.ID) {
+ response.BadRequest(c, "Custom menu item ID contains invalid characters (only a-z, A-Z, 0-9, - and _ are allowed)")
+ 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")
+ return
+ }
+ customMenuJSON = string(menuBytes)
+ }
+
// Ops metrics collector interval validation (seconds).
if req.OpsMetricsIntervalSeconds != nil {
v := *req.OpsMetricsIntervalSeconds
@@ -358,6 +453,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL,
SoraClientEnabled: req.SoraClientEnabled,
+ CustomMenuItems: customMenuJSON,
DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions,
@@ -449,6 +545,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
SoraClientEnabled: updatedSettings.SoraClientEnabled,
+ CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
DefaultSubscriptions: updatedDefaultSubscriptions,
@@ -612,6 +709,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 e90860101..beb03e679 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -1,5 +1,20 @@
package dto
+import (
+ "encoding/json"
+ "strings"
+)
+
+// CustomMenuItem represents a user-configured custom menu entry.
+type CustomMenuItem struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ IconSVG string `json:"icon_svg"`
+ URL string `json:"url"`
+ Visibility string `json:"visibility"` // "user" or "admin"
+ SortOrder int `json:"sort_order"`
+}
+
// SystemSettings represents the admin settings API response payload.
type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
@@ -27,17 +42,18 @@ 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"`
DefaultBalance float64 `json:"default_balance"`
@@ -69,27 +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"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
- Version string `json:"version"`
+ 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"`
}
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
@@ -138,3 +155,29 @@ 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
+}
+
+// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries.
+func ParseUserVisibleMenuItems(raw string) []CustomMenuItem {
+ items := ParseCustomMenuItems(raw)
+ filtered := make([]CustomMenuItem, 0, len(items))
+ for _, item := range items {
+ if item.Visibility != "admin" {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered
+}
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 2141a9ee5..a48eaf318 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -50,6 +50,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
+ CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version,
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index a8845d9b2..f15a20741 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/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go
index f061db90a..d9ec951e7 100644
--- a/backend/internal/server/middleware/security_headers.go
+++ b/backend/internal/server/middleware/security_headers.go
@@ -41,7 +41,9 @@ func GetNonceFromContext(c *gin.Context) string {
}
// SecurityHeaders sets baseline security headers for all responses.
-func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
+// getFrameSrcOrigins is an optional function that returns extra origins to inject into frame-src;
+// pass nil to disable dynamic frame-src injection.
+func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) gin.HandlerFunc {
policy := strings.TrimSpace(cfg.Policy)
if policy == "" {
policy = config.DefaultCSPPolicy
@@ -51,6 +53,15 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
policy = enhanceCSPPolicy(policy)
return func(c *gin.Context) {
+ finalPolicy := policy
+ if getFrameSrcOrigins != nil {
+ for _, origin := range getFrameSrcOrigins() {
+ if origin != "" {
+ finalPolicy = addToDirective(finalPolicy, "frame-src", origin)
+ }
+ }
+ }
+
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
@@ -65,12 +76,10 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
if err != nil {
// crypto/rand 失败时降级为无 nonce 的 CSP 策略
log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err)
- finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'unsafe-inline'")
- c.Header("Content-Security-Policy", finalPolicy)
+ c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'unsafe-inline'"))
} else {
c.Set(CSPNonceKey, nonce)
- finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'nonce-"+nonce+"'")
- c.Header("Content-Security-Policy", finalPolicy)
+ c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'nonce-"+nonce+"'"))
}
}
c.Next()
diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go
index 5a7798255..031385d06 100644
--- a/backend/internal/server/middleware/security_headers_test.go
+++ b/backend/internal/server/middleware/security_headers_test.go
@@ -84,7 +84,7 @@ func TestGetNonceFromContext(t *testing.T) {
func TestSecurityHeaders(t *testing.T) {
t.Run("sets_basic_security_headers", func(t *testing.T) {
cfg := config.CSPConfig{Enabled: false}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -99,7 +99,7 @@ func TestSecurityHeaders(t *testing.T) {
t.Run("csp_disabled_no_csp_header", func(t *testing.T) {
cfg := config.CSPConfig{Enabled: false}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -115,7 +115,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: "default-src 'self'",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -136,7 +136,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: "default-src 'self'; script-src 'self' __CSP_NONCE__",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -156,7 +156,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: "script-src 'self' __CSP_NONCE__",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -180,7 +180,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: "",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -199,7 +199,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: " \t\n ",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -217,7 +217,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: "script-src __CSP_NONCE__; style-src __CSP_NONCE__",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -235,7 +235,7 @@ func TestSecurityHeaders(t *testing.T) {
t.Run("calls_next_handler", func(t *testing.T) {
cfg := config.CSPConfig{Enabled: true, Policy: "default-src 'self'"}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
nextCalled := false
router := gin.New()
@@ -258,7 +258,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true,
Policy: "script-src __CSP_NONCE__",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
nonces := make(map[string]bool)
for i := 0; i < 10; i++ {
@@ -376,7 +376,7 @@ func BenchmarkSecurityHeadersMiddleware(b *testing.B) {
Enabled: true,
Policy: "script-src 'self' __CSP_NONCE__",
}
- middleware := SecurityHeaders(cfg)
+ middleware := SecurityHeaders(cfg, nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index 07b51f238..430edcf8b 100644
--- a/backend/internal/server/router.go
+++ b/backend/internal/server/router.go
@@ -1,7 +1,10 @@
package server
import (
+ "context"
"log"
+ "sync/atomic"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
@@ -14,6 +17,8 @@ import (
"github.com/redis/go-redis/v9"
)
+const frameSrcRefreshTimeout = 5 * time.Second
+
// SetupRouter 配置路由器中间件和路由
func SetupRouter(
r *gin.Engine,
@@ -28,11 +33,33 @@ func SetupRouter(
cfg *config.Config,
redisClient *redis.Client,
) *gin.Engine {
+ // 缓存 iframe 页面的 origin 列表,用于动态注入 CSP frame-src
+ var cachedFrameOrigins atomic.Pointer[[]string]
+ emptyOrigins := []string{}
+ cachedFrameOrigins.Store(&emptyOrigins)
+
+ refreshFrameOrigins := func() {
+ ctx, cancel := context.WithTimeout(context.Background(), frameSrcRefreshTimeout)
+ defer cancel()
+ origins, err := settingService.GetFrameSrcOrigins(ctx)
+ if err != nil {
+ // 获取失败时保留已有缓存,避免 frame-src 被意外清空
+ return
+ }
+ cachedFrameOrigins.Store(&origins)
+ }
+ refreshFrameOrigins() // 启动时初始化
+
// 应用中间件
r.Use(middleware2.RequestLogger())
r.Use(middleware2.Logger())
r.Use(middleware2.CORS(cfg.CORS))
- r.Use(middleware2.SecurityHeaders(cfg.Security.CSP))
+ r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string {
+ if p := cachedFrameOrigins.Load(); p != nil {
+ return *p
+ }
+ return nil
+ }))
// Serve embedded frontend with settings injection if available
if web.HasEmbeddedFrontend() {
@@ -40,11 +67,17 @@ func SetupRouter(
if err != nil {
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
r.Use(web.ServeEmbeddedFrontend())
+ settingService.SetOnUpdateCallback(refreshFrameOrigins)
} else {
- // Register cache invalidation callback
- settingService.SetOnUpdateCallback(frontendServer.InvalidateCache)
+ // Register combined callback: invalidate HTML cache + refresh frame origins
+ settingService.SetOnUpdateCallback(func() {
+ frontendServer.InvalidateCache()
+ refreshFrameOrigins()
+ })
r.Use(frontendServer.Middleware())
}
+ } else {
+ settingService.SetOnUpdateCallback(refreshFrameOrigins)
}
// 注册路由
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index b304bc9fb..df2130027 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -113,8 +113,9 @@ const (
SettingKeyDocURL = "doc_url" // 文档链接
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
- SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口
- SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL(作为 iframe src)
+ SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
+ SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
+ SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组)
// 默认配置
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 64871b9a6..3809c9d08 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "net/url"
"strconv"
"strings"
"sync/atomic"
@@ -124,6 +125,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyPurchaseSubscriptionEnabled,
SettingKeyPurchaseSubscriptionURL,
SettingKeySoraClientEnabled,
+ SettingKeyCustomMenuItems,
SettingKeyLinuxDoConnectEnabled,
}
@@ -163,6 +165,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
+ CustomMenuItems: settings[SettingKeyCustomMenuItems],
LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil
}
@@ -193,27 +196,28 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
// Return a struct that matches the frontend's expected format
return &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"`
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo,omitempty"`
- SiteSubtitle string `json:"site_subtitle,omitempty"`
- APIBaseURL string `json:"api_base_url,omitempty"`
- ContactInfo string `json:"contact_info,omitempty"`
- DocURL string `json:"doc_url,omitempty"`
- HomeContent string `json:"home_content,omitempty"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- Version string `json:"version,omitempty"`
+ 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"`
+ TurnstileEnabled bool `json:"turnstile_enabled"`
+ TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
+ SiteName string `json:"site_name"`
+ SiteLogo string `json:"site_logo,omitempty"`
+ SiteSubtitle string `json:"site_subtitle,omitempty"`
+ APIBaseURL string `json:"api_base_url,omitempty"`
+ ContactInfo string `json:"contact_info,omitempty"`
+ DocURL string `json:"doc_url,omitempty"`
+ HomeContent string `json:"home_content,omitempty"`
+ HideCcsImportButton bool `json:"hide_ccs_import_button"`
+ PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
+ PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
+ SoraClientEnabled bool `json:"sora_client_enabled"`
+ CustomMenuItems json.RawMessage `json:"custom_menu_items"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
+ Version string `json:"version,omitempty"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
@@ -234,11 +238,119 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled,
+ CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: s.version,
}, nil
}
+// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
+// array string, returning only items with visibility != "admin".
+func filterUserVisibleMenuItems(raw string) json.RawMessage {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "[]" {
+ return json.RawMessage("[]")
+ }
+ var items []struct {
+ Visibility string `json:"visibility"`
+ }
+ if err := json.Unmarshal([]byte(raw), &items); err != nil {
+ return json.RawMessage("[]")
+ }
+
+ // Parse full items to preserve all fields
+ var fullItems []json.RawMessage
+ if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
+ return json.RawMessage("[]")
+ }
+
+ var filtered []json.RawMessage
+ for i, item := range items {
+ if item.Visibility != "admin" {
+ filtered = append(filtered, fullItems[i])
+ }
+ }
+ if len(filtered) == 0 {
+ return json.RawMessage("[]")
+ }
+ result, err := json.Marshal(filtered)
+ if err != nil {
+ return json.RawMessage("[]")
+ }
+ return result
+}
+
+// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url
+// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
+func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
+ settings, err := s.GetPublicSettings(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ seen := make(map[string]struct{})
+ var origins []string
+
+ addOrigin := func(rawURL string) {
+ if origin := extractOriginFromURL(rawURL); origin != "" {
+ if _, ok := seen[origin]; !ok {
+ seen[origin] = struct{}{}
+ origins = append(origins, origin)
+ }
+ }
+ }
+
+ // purchase subscription URL
+ if settings.PurchaseSubscriptionEnabled {
+ addOrigin(settings.PurchaseSubscriptionURL)
+ }
+
+ // all custom menu items (including admin-only, since CSP must allow all iframes)
+ for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
+ addOrigin(item)
+ }
+
+ return origins, nil
+}
+
+// extractOriginFromURL returns the scheme+host origin from rawURL.
+// Only http and https schemes are accepted.
+func extractOriginFromURL(rawURL string) string {
+ rawURL = strings.TrimSpace(rawURL)
+ if rawURL == "" {
+ return ""
+ }
+ u, err := url.Parse(rawURL)
+ if err != nil || u.Host == "" {
+ return ""
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return ""
+ }
+ return u.Scheme + "://" + u.Host
+}
+
+// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
+func parseCustomMenuItemURLs(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "[]" {
+ return nil
+ }
+ var items []struct {
+ URL string `json:"url"`
+ }
+ if err := json.Unmarshal([]byte(raw), &items); err != nil {
+ return nil
+ }
+ urls := make([]string, 0, len(items))
+ for _, item := range items {
+ if item.URL != "" {
+ urls = append(urls, item.URL)
+ }
+ }
+ return urls
+}
+
// UpdateSettings 更新系统设置
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
@@ -293,6 +405,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled)
+ updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
// 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
@@ -509,6 +622,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
SettingKeySoraClientEnabled: "false",
+ SettingKeyCustomMenuItems: "[]",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyDefaultSubscriptions: "[]",
@@ -567,6 +681,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
+ CustomMenuItems: settings[SettingKeyCustomMenuItems],
}
// 解析整数类型
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 5a441ea12..9f0de6000 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -40,6 +40,7 @@ type SystemSettings struct {
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string
SoraClientEnabled bool
+ CustomMenuItems string // JSON array of custom menu items
DefaultConcurrency int
DefaultBalance float64
@@ -92,6 +93,7 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string
SoraClientEnabled bool
+ CustomMenuItems string // JSON array of custom menu items
LinuxDoOAuthEnabled bool
Version string
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index c1b767ba7..52855a040 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -4,6 +4,7 @@
*/
import { apiClient } from '../client'
+import type { CustomMenuItem } from '@/types'
export interface DefaultSubscriptionSetting {
group_id: number
@@ -38,6 +39,7 @@ export interface SystemSettings {
purchase_subscription_enabled: boolean
purchase_subscription_url: string
sora_client_enabled: boolean
+ custom_menu_items: CustomMenuItem[]
// SMTP settings
smtp_host: string
smtp_port: number
@@ -99,6 +101,7 @@ export interface UpdateSettingsRequest {
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
sora_client_enabled?: boolean
+ custom_menu_items?: CustomMenuItem[]
smtp_host?: string
smtp_port?: number
smtp_username?: string
diff --git a/frontend/src/components/common/ImageUpload.vue b/frontend/src/components/common/ImageUpload.vue
new file mode 100644
index 000000000..6ef84079b
--- /dev/null
+++ b/frontend/src/components/common/ImageUpload.vue
@@ -0,0 +1,146 @@
+
+ {{ hint }} {{ error }}
+
+
+
- {{ t('admin.settings.site.logoHint') }} -
-{{ logoError }}
-+ {{ t('admin.settings.customMenu.description') }} +
++ {{ t('customPage.notFoundDesc') }} +
++ {{ t('customPage.notConfiguredDesc') }} +
+