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 @@ + + + diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index a6b4030f2..ffc7c5e2d 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -254,6 +254,13 @@ const displayName = computed(() => { }) const pageTitle = computed(() => { + // For custom pages, use the menu item's label instead of generic "自定义页面" + if (route.name === 'CustomPage') { + const id = route.params.id as string + const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + const menuItem = items.find((item) => item.id === id) + if (menuItem?.label) return menuItem.label + } const titleKey = route.meta.titleKey as string if (titleKey) { return t(titleKey) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index b356e3e5c..dcfc60bbb 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -47,7 +47,8 @@ " @click="handleMenuItemClick(item.path)" > - + + {{ item.label }} @@ -71,7 +72,8 @@ :data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined" @click="handleMenuItemClick(item.path)" > - + + {{ item.label }} @@ -92,7 +94,8 @@ :data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined" @click="handleMenuItemClick(item.path)" > - + + {{ item.label }} @@ -149,6 +152,15 @@ 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 + label: string + icon: unknown + iconSvg?: string + hideInSimpleMode?: boolean +} const { t } = useI18n() @@ -496,8 +508,8 @@ const ChevronDoubleRightIcon = { } // User navigation items (for regular users) -const userNavItems = computed(() => { - const items = [ +const userNavItems = computed((): NavItem[] => { + const items: NavItem[] = [ { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, @@ -516,14 +528,20 @@ const userNavItems = computed(() => { ] : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, - { path: '/profile', label: t('nav.profile'), icon: UserIcon } + { path: '/profile', label: t('nav.profile'), icon: UserIcon }, + ...customMenuItemsForUser.value.map((item): NavItem => ({ + path: `/custom/${item.id}`, + label: item.label, + icon: null, + iconSvg: item.icon_svg, + })), ] return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items }) // Personal navigation items (for admin's "My Account" section, without Dashboard) -const personalNavItems = computed(() => { - const items = [ +const personalNavItems = computed((): NavItem[] => { + const items: NavItem[] = [ { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, @@ -541,14 +559,35 @@ const personalNavItems = computed(() => { ] : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, - { path: '/profile', label: t('nav.profile'), icon: UserIcon } + { path: '/profile', label: t('nav.profile'), icon: UserIcon }, + ...customMenuItemsForUser.value.map((item): NavItem => ({ + path: `/custom/${item.id}`, + label: item.label, + icon: null, + iconSvg: item.icon_svg, + })), ] return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items }) +// Custom menu items filtered by visibility +const customMenuItemsForUser = computed(() => { + const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + return items + .filter((item) => item.visibility === 'user') + .sort((a, b) => a.sort_order - b.sort_order) +}) + +const customMenuItemsForAdmin = computed(() => { + const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + return items + .filter((item) => item.visibility === 'admin') + .sort((a, b) => a.sort_order - b.sort_order) +}) + // Admin navigation items -const adminNavItems = computed(() => { - const baseItems = [ +const adminNavItems = computed((): NavItem[] => { + const baseItems: NavItem[] = [ { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, ...(adminSettingsStore.opsMonitoringEnabled ? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }] @@ -570,11 +609,19 @@ const adminNavItems = computed(() => { filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) + // Add admin custom menu items after settings + for (const cm of customMenuItemsForAdmin.value) { + filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + } return filtered } baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon }) baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) + // Add admin custom menu items after settings + for (const cm of customMenuItemsForAdmin.value) { + baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + } return baseItems }) @@ -654,4 +701,12 @@ onMounted(() => { .fade-leave-to { opacity: 0; } + +/* Custom SVG icon in sidebar: inherit color, constrain size */ +.sidebar-svg-icon :deep(svg) { + width: 1.25rem; + height: 1.25rem; + stroke: currentColor; + fill: none; +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 01b7919a9..7357c3f11 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3625,6 +3625,27 @@ export default { enabled: 'Enable Sora Client', enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features' }, + customMenu: { + title: 'Custom Menu Pages', + description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.', + itemLabel: 'Menu Item #{n}', + name: 'Menu Name', + namePlaceholder: 'e.g. Help Center', + url: 'Page URL', + urlPlaceholder: 'https://example.com/page', + iconSvg: 'SVG Icon', + iconSvgPlaceholder: '...', + iconPreview: 'Icon Preview', + uploadSvg: 'Upload SVG', + removeSvg: 'Remove', + visibility: 'Visible To', + visibilityUser: 'Regular Users', + visibilityAdmin: 'Administrators', + add: 'Add Menu Item', + remove: 'Remove', + moveUp: 'Move Up', + moveDown: 'Move Down', + }, smtp: { title: 'SMTP Settings', description: 'Configure email sending for verification codes', @@ -3913,6 +3934,16 @@ export default { 'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.' }, + // Custom Page (iframe embed) + customPage: { + title: 'Custom Page', + openInNewTab: 'Open in new tab', + notFoundTitle: 'Page not found', + notFoundDesc: 'This custom page does not exist or has been removed.', + notConfiguredTitle: 'Page URL not configured', + notConfiguredDesc: 'The URL for this custom page has not been properly configured.', + }, + // Announcements Page announcements: { title: 'Announcements', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 3411d310a..9f2fb639f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3795,6 +3795,27 @@ export default { enabled: '启用 Sora 客户端', enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能' }, + customMenu: { + title: '自定义菜单页面', + description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。', + itemLabel: '菜单项 #{n}', + name: '菜单名称', + namePlaceholder: '如:帮助中心', + url: '页面 URL', + urlPlaceholder: 'https://example.com/page', + iconSvg: 'SVG 图标', + iconSvgPlaceholder: '...', + iconPreview: '图标预览', + uploadSvg: '上传 SVG', + removeSvg: '清除', + visibility: '可见角色', + visibilityUser: '普通用户', + visibilityAdmin: '管理员', + add: '添加菜单项', + remove: '删除', + moveUp: '上移', + moveDown: '下移', + }, smtp: { title: 'SMTP 设置', description: '配置用于发送验证码的邮件服务', @@ -4081,6 +4102,16 @@ export default { notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。' }, + // Custom Page (iframe embed) + customPage: { + title: '自定义页面', + openInNewTab: '新窗口打开', + notFoundTitle: '页面不存在', + notFoundDesc: '该自定义页面不存在或已被删除。', + notConfiguredTitle: '页面链接未配置', + notConfiguredDesc: '该自定义页面的 URL 未正确配置。', + }, + // Announcements Page announcements: { title: '公告', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cb81d160b..08f492d4d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -203,6 +203,17 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'sora.description' } }, + { + path: '/custom/:id', + name: 'CustomPage', + component: () => import('@/views/user/CustomPageView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: false, + title: 'Custom Page', + titleKey: 'customPage.title', + } + }, // ==================== Admin Routes ==================== { @@ -417,7 +428,20 @@ router.beforeEach((to, _from, next) => { // Set page title const appStore = useAppStore() - document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string) + // For custom pages, use menu item label as document title + if (to.name === 'CustomPage') { + const id = to.params.id as string + const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + const menuItem = items.find((item) => item.id === id) + if (menuItem?.label) { + const siteName = appStore.siteName || 'Sub2API' + document.title = `${menuItem.label} - ${siteName}` + } else { + document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string) + } + } else { + document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string) + } // Check if route requires authentication const requiresAuth = to.meta.requiresAuth !== false // Default to true diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 42a422727..37439a4c1 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -327,6 +327,7 @@ export const useAppStore = defineStore('app', () => { hide_ccs_import_button: false, purchase_subscription_enabled: false, purchase_subscription_url: '', + custom_menu_items: [], linuxdo_oauth_enabled: false, sora_client_enabled: false, version: siteVersion.value diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ccdde8ae5..7f2f5f513 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -75,6 +75,15 @@ export interface SendVerifyCodeResponse { countdown: number } +export interface CustomMenuItem { + id: string + label: string + icon_svg: string + url: string + visibility: 'user' | 'admin' + sort_order: number +} + export interface PublicSettings { registration_enabled: boolean email_verify_enabled: boolean @@ -93,6 +102,7 @@ export interface PublicSettings { hide_ccs_import_button: boolean purchase_subscription_enabled: boolean purchase_subscription_url: string + custom_menu_items: CustomMenuItem[] linuxdo_oauth_enabled: boolean sora_client_enabled: boolean version: string diff --git a/frontend/src/utils/embedded-url.ts b/frontend/src/utils/embedded-url.ts new file mode 100644 index 000000000..9319ee075 --- /dev/null +++ b/frontend/src/utils/embedded-url.ts @@ -0,0 +1,46 @@ +/** + * Shared URL builder for iframe-embedded pages. + * Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs + * with user_id, token, theme, ui_mode, src_host, and src parameters. + */ + +const EMBEDDED_USER_ID_QUERY_KEY = 'user_id' +const EMBEDDED_AUTH_TOKEN_QUERY_KEY = 'token' +const EMBEDDED_THEME_QUERY_KEY = 'theme' +const EMBEDDED_UI_MODE_QUERY_KEY = 'ui_mode' +const EMBEDDED_UI_MODE_VALUE = 'embedded' +const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host' +const EMBEDDED_SRC_QUERY_KEY = 'src_url' + +export function buildEmbeddedUrl( + baseUrl: string, + userId?: number, + authToken?: string | null, + theme: 'light' | 'dark' = 'light', +): string { + if (!baseUrl) return baseUrl + try { + const url = new URL(baseUrl) + if (userId) { + url.searchParams.set(EMBEDDED_USER_ID_QUERY_KEY, String(userId)) + } + if (authToken) { + url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken) + } + url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme) + url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE) + // Source tracking: let the embedded page know where it's being loaded from + if (typeof window !== 'undefined') { + url.searchParams.set(EMBEDDED_SRC_HOST_QUERY_KEY, window.location.origin) + url.searchParams.set(EMBEDDED_SRC_QUERY_KEY, window.location.href) + } + return url.toString() + } catch { + return baseUrl + } +} + +export function detectTheme(): 'light' | 'dark' { + if (typeof document === 'undefined') return 'light' + return document.documentElement.classList.contains('dark') ? 'dark' : 'light' +} diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/utils/sanitize.ts new file mode 100644 index 000000000..a61a52e17 --- /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/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 39e1a6b5c..3a42a5b71 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -832,64 +832,14 @@ -
- -
-
- Site Logo - - - -
-
- -
-
- - -
-

- {{ t('admin.settings.site.logoHint') }} -

-

{{ logoError }}

-
-
+ @@ -1160,6 +1110,127 @@ + +
+
+

+ {{ t('admin.settings.customMenu.title') }} +

+

+ {{ t('admin.settings.customMenu.description') }} +

+
+
+ +
+
+ + {{ t('admin.settings.customMenu.itemLabel', { n: index + 1 }) }} + +
+ + + + + + +
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + + +
+
+
@@ -1261,6 +1332,7 @@ import Select from '@/components/common/Select.vue' import GroupBadge from '@/components/common/GroupBadge.vue' import GroupOptionItem from '@/components/common/GroupOptionItem.vue' import Toggle from '@/components/common/Toggle.vue' +import ImageUpload from '@/components/common/ImageUpload.vue' import { useClipboard } from '@/composables/useClipboard' import { useAppStore } from '@/stores' @@ -1273,7 +1345,6 @@ const saving = ref(false) const testingSmtp = ref(false) const sendingTestEmail = ref(false) const testEmailAddress = ref('') -const logoError = ref('') // Admin API Key 状态 const adminApiKeyLoading = ref(true) @@ -1332,6 +1403,7 @@ const form = reactive({ purchase_subscription_enabled: false, purchase_subscription_url: '', sora_client_enabled: false, + custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>, smtp_host: '', smtp_port: 587, smtp_username: '', @@ -1396,42 +1468,37 @@ async function setAndCopyLinuxdoRedirectUrl() { await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied')) } -function handleLogoUpload(event: Event) { - const input = event.target as HTMLInputElement - const file = input.files?.[0] - logoError.value = '' - - if (!file) return - - // Check file size (300KB = 307200 bytes) - const maxSize = 300 * 1024 - if (file.size > maxSize) { - logoError.value = t('admin.settings.site.logoSizeError', { - size: (file.size / 1024).toFixed(1) - }) - input.value = '' - return - } - - // Check file type - if (!file.type.startsWith('image/')) { - logoError.value = t('admin.settings.site.logoTypeError') - input.value = '' - return - } +// Custom menu item management +function addMenuItem() { + form.custom_menu_items.push({ + id: '', + label: '', + icon_svg: '', + url: '', + visibility: 'user', + sort_order: form.custom_menu_items.length, + }) +} - // Convert to base64 - const reader = new FileReader() - reader.onload = (e) => { - form.site_logo = e.target?.result as string - } - reader.onerror = () => { - logoError.value = t('admin.settings.site.logoReadError') - } - reader.readAsDataURL(file) +function removeMenuItem(index: number) { + form.custom_menu_items.splice(index, 1) + // Re-index sort_order + form.custom_menu_items.forEach((item, i) => { + item.sort_order = i + }) +} - // Reset input - input.value = '' +function moveMenuItem(index: number, direction: -1 | 1) { + const targetIndex = index + direction + if (targetIndex < 0 || targetIndex >= form.custom_menu_items.length) return + const items = form.custom_menu_items + const temp = items[index] + items[index] = items[targetIndex] + items[targetIndex] = temp + // Re-index sort_order + items.forEach((item, i) => { + item.sort_order = i + }) } async function loadSettings() { @@ -1534,6 +1601,7 @@ async function saveSettings() { purchase_subscription_enabled: form.purchase_subscription_enabled, purchase_subscription_url: form.purchase_subscription_url, sora_client_enabled: form.sora_client_enabled, + custom_menu_items: form.custom_menu_items, smtp_host: form.smtp_host, smtp_port: form.smtp_port, smtp_username: form.smtp_username, diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue new file mode 100644 index 000000000..ed1c11d78 --- /dev/null +++ b/frontend/src/views/user/CustomPageView.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/views/user/PurchaseSubscriptionView.vue b/frontend/src/views/user/PurchaseSubscriptionView.vue index fdcd0d34e..d6d356f59 100644 --- a/frontend/src/views/user/PurchaseSubscriptionView.vue +++ b/frontend/src/views/user/PurchaseSubscriptionView.vue @@ -74,17 +74,12 @@ import { useAppStore } from '@/stores' import { useAuthStore } from '@/stores/auth' import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' +import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url' const { t } = useI18n() const appStore = useAppStore() const authStore = useAuthStore() -const PURCHASE_USER_ID_QUERY_KEY = 'user_id' -const PURCHASE_AUTH_TOKEN_QUERY_KEY = 'token' -const PURCHASE_THEME_QUERY_KEY = 'theme' -const PURCHASE_UI_MODE_QUERY_KEY = 'ui_mode' -const PURCHASE_UI_MODE_EMBEDDED = 'embedded' - const loading = ref(false) const purchaseTheme = ref<'light' | 'dark'>('light') let themeObserver: MutationObserver | null = null @@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => { return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false }) -function detectTheme(): 'light' | 'dark' { - if (typeof document === 'undefined') return 'light' - return document.documentElement.classList.contains('dark') ? 'dark' : 'light' -} - -function buildPurchaseUrl( - baseUrl: string, - userId?: number, - authToken?: string | null, - theme: 'light' | 'dark' = 'light', -): string { - if (!baseUrl) return baseUrl - try { - const url = new URL(baseUrl) - if (userId) { - url.searchParams.set(PURCHASE_USER_ID_QUERY_KEY, String(userId)) - } - if (authToken) { - url.searchParams.set(PURCHASE_AUTH_TOKEN_QUERY_KEY, authToken) - } - url.searchParams.set(PURCHASE_THEME_QUERY_KEY, theme) - url.searchParams.set(PURCHASE_UI_MODE_QUERY_KEY, PURCHASE_UI_MODE_EMBEDDED) - return url.toString() - } catch { - return baseUrl - } -} - const purchaseUrl = computed(() => { const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim() - return buildPurchaseUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value) + return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value) }) const isValidUrl = computed(() => {