Unverified Commit 405829dc authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #727 from touwaeriol/pr/custom-menu-pages

feat: custom menu pages with iframe embedding and CSP injection
parents 7abec188 451a8511
...@@ -100,7 +100,7 @@ func runSetupServer() { ...@@ -100,7 +100,7 @@ func runSetupServer() {
r := gin.New() r := gin.New()
r.Use(middleware.Recovery()) r.Use(middleware.Recovery())
r.Use(middleware.CORS(config.CORSConfig{})) 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 // Register setup routes
setup.RegisterRoutes(r) setup.RegisterRoutes(r)
......
package admin package admin
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
...@@ -20,6 +23,18 @@ import ( ...@@ -20,6 +23,18 @@ import (
// semverPattern 预编译 semver 格式校验正则 // semverPattern 预编译 semver 格式校验正则
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) 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 系统设置处理器 // SettingHandler 系统设置处理器
type SettingHandler struct { type SettingHandler struct {
settingService *service.SettingService settingService *service.SettingService
...@@ -92,6 +107,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -92,6 +107,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
DefaultConcurrency: settings.DefaultConcurrency, DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance, DefaultBalance: settings.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
...@@ -141,17 +157,18 @@ type UpdateSettingsRequest struct { ...@@ -141,17 +157,18 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
// OEM设置 // OEM设置
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"` SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"` SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"` HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
// 默认配置 // 默认配置
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
...@@ -299,6 +316,84 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -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). // Ops metrics collector interval validation (seconds).
if req.OpsMetricsIntervalSeconds != nil { if req.OpsMetricsIntervalSeconds != nil {
v := *req.OpsMetricsIntervalSeconds v := *req.OpsMetricsIntervalSeconds
...@@ -358,6 +453,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -358,6 +453,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: purchaseEnabled, PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL, PurchaseSubscriptionURL: purchaseURL,
SoraClientEnabled: req.SoraClientEnabled, SoraClientEnabled: req.SoraClientEnabled,
CustomMenuItems: customMenuJSON,
DefaultConcurrency: req.DefaultConcurrency, DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance, DefaultBalance: req.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
...@@ -449,6 +545,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -449,6 +545,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
SoraClientEnabled: updatedSettings.SoraClientEnabled, SoraClientEnabled: updatedSettings.SoraClientEnabled,
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance, DefaultBalance: updatedSettings.DefaultBalance,
DefaultSubscriptions: updatedDefaultSubscriptions, DefaultSubscriptions: updatedDefaultSubscriptions,
...@@ -612,6 +709,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -612,6 +709,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion { if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
changed = append(changed, "min_claude_code_version") 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 return changed
} }
......
package dto 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. // SystemSettings represents the admin settings API response payload.
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
...@@ -27,17 +42,18 @@ type SystemSettings struct { ...@@ -27,17 +42,18 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"` SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"` SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"` HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"` DefaultBalance float64 `json:"default_balance"`
...@@ -69,27 +85,28 @@ type DefaultSubscriptionSetting struct { ...@@ -69,27 +85,28 @@ type DefaultSubscriptionSetting struct {
} }
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"` SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"` SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"` HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
SoraClientEnabled bool `json:"sora_client_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version"` SoraClientEnabled bool `json:"sora_client_enabled"`
Version string `json:"version"`
} }
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段) // SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
...@@ -138,3 +155,29 @@ type StreamTimeoutSettings struct { ...@@ -138,3 +155,29 @@ type StreamTimeoutSettings struct {
ThresholdCount int `json:"threshold_count"` ThresholdCount int `json:"threshold_count"`
ThresholdWindowMinutes int `json:"threshold_window_minutes"` 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
}
...@@ -50,6 +50,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ...@@ -50,6 +50,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton: settings.HideCcsImportButton, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version, Version: h.version,
......
...@@ -513,7 +513,8 @@ func TestAPIContracts(t *testing.T) { ...@@ -513,7 +513,8 @@ func TestAPIContracts(t *testing.T) {
"hide_ccs_import_button": false, "hide_ccs_import_button": false,
"purchase_subscription_enabled": false, "purchase_subscription_enabled": false,
"purchase_subscription_url": "", "purchase_subscription_url": "",
"min_claude_code_version": "" "min_claude_code_version": "",
"custom_menu_items": []
} }
}`, }`,
}, },
......
...@@ -41,7 +41,9 @@ func GetNonceFromContext(c *gin.Context) string { ...@@ -41,7 +41,9 @@ func GetNonceFromContext(c *gin.Context) string {
} }
// SecurityHeaders sets baseline security headers for all responses. // 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) policy := strings.TrimSpace(cfg.Policy)
if policy == "" { if policy == "" {
policy = config.DefaultCSPPolicy policy = config.DefaultCSPPolicy
...@@ -51,6 +53,15 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { ...@@ -51,6 +53,15 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
policy = enhanceCSPPolicy(policy) policy = enhanceCSPPolicy(policy)
return func(c *gin.Context) { 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-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY") c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin") c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
...@@ -65,12 +76,10 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { ...@@ -65,12 +76,10 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
if err != nil { if err != nil {
// crypto/rand 失败时降级为无 nonce 的 CSP 策略 // crypto/rand 失败时降级为无 nonce 的 CSP 策略
log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err) log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err)
finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'unsafe-inline'") c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'unsafe-inline'"))
c.Header("Content-Security-Policy", finalPolicy)
} else { } else {
c.Set(CSPNonceKey, nonce) c.Set(CSPNonceKey, nonce)
finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'nonce-"+nonce+"'") c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'nonce-"+nonce+"'"))
c.Header("Content-Security-Policy", finalPolicy)
} }
} }
c.Next() c.Next()
......
...@@ -84,7 +84,7 @@ func TestGetNonceFromContext(t *testing.T) { ...@@ -84,7 +84,7 @@ func TestGetNonceFromContext(t *testing.T) {
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {
t.Run("sets_basic_security_headers", func(t *testing.T) { t.Run("sets_basic_security_headers", func(t *testing.T) {
cfg := config.CSPConfig{Enabled: false} cfg := config.CSPConfig{Enabled: false}
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -99,7 +99,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -99,7 +99,7 @@ func TestSecurityHeaders(t *testing.T) {
t.Run("csp_disabled_no_csp_header", func(t *testing.T) { t.Run("csp_disabled_no_csp_header", func(t *testing.T) {
cfg := config.CSPConfig{Enabled: false} cfg := config.CSPConfig{Enabled: false}
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -115,7 +115,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -115,7 +115,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: "default-src 'self'", Policy: "default-src 'self'",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -136,7 +136,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -136,7 +136,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: "default-src 'self'; script-src 'self' __CSP_NONCE__", Policy: "default-src 'self'; script-src 'self' __CSP_NONCE__",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -156,7 +156,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -156,7 +156,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: "script-src 'self' __CSP_NONCE__", Policy: "script-src 'self' __CSP_NONCE__",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -180,7 +180,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -180,7 +180,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: "", Policy: "",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -199,7 +199,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -199,7 +199,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: " \t\n ", Policy: " \t\n ",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -217,7 +217,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -217,7 +217,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: "script-src __CSP_NONCE__; style-src __CSP_NONCE__", Policy: "script-src __CSP_NONCE__; style-src __CSP_NONCE__",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
...@@ -235,7 +235,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -235,7 +235,7 @@ func TestSecurityHeaders(t *testing.T) {
t.Run("calls_next_handler", func(t *testing.T) { t.Run("calls_next_handler", func(t *testing.T) {
cfg := config.CSPConfig{Enabled: true, Policy: "default-src 'self'"} cfg := config.CSPConfig{Enabled: true, Policy: "default-src 'self'"}
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
nextCalled := false nextCalled := false
router := gin.New() router := gin.New()
...@@ -258,7 +258,7 @@ func TestSecurityHeaders(t *testing.T) { ...@@ -258,7 +258,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled: true, Enabled: true,
Policy: "script-src __CSP_NONCE__", Policy: "script-src __CSP_NONCE__",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
nonces := make(map[string]bool) nonces := make(map[string]bool)
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
...@@ -376,7 +376,7 @@ func BenchmarkSecurityHeadersMiddleware(b *testing.B) { ...@@ -376,7 +376,7 @@ func BenchmarkSecurityHeadersMiddleware(b *testing.B) {
Enabled: true, Enabled: true,
Policy: "script-src 'self' __CSP_NONCE__", Policy: "script-src 'self' __CSP_NONCE__",
} }
middleware := SecurityHeaders(cfg) middleware := SecurityHeaders(cfg, nil)
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
......
package server package server
import ( import (
"context"
"log" "log"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/handler"
...@@ -14,6 +17,8 @@ import ( ...@@ -14,6 +17,8 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
const frameSrcRefreshTimeout = 5 * time.Second
// SetupRouter 配置路由器中间件和路由 // SetupRouter 配置路由器中间件和路由
func SetupRouter( func SetupRouter(
r *gin.Engine, r *gin.Engine,
...@@ -28,11 +33,33 @@ func SetupRouter( ...@@ -28,11 +33,33 @@ func SetupRouter(
cfg *config.Config, cfg *config.Config,
redisClient *redis.Client, redisClient *redis.Client,
) *gin.Engine { ) *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.RequestLogger())
r.Use(middleware2.Logger()) r.Use(middleware2.Logger())
r.Use(middleware2.CORS(cfg.CORS)) 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 // Serve embedded frontend with settings injection if available
if web.HasEmbeddedFrontend() { if web.HasEmbeddedFrontend() {
...@@ -40,11 +67,17 @@ func SetupRouter( ...@@ -40,11 +67,17 @@ func SetupRouter(
if err != nil { if err != nil {
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err) log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
r.Use(web.ServeEmbeddedFrontend()) r.Use(web.ServeEmbeddedFrontend())
settingService.SetOnUpdateCallback(refreshFrameOrigins)
} else { } else {
// Register cache invalidation callback // Register combined callback: invalidate HTML cache + refresh frame origins
settingService.SetOnUpdateCallback(frontendServer.InvalidateCache) settingService.SetOnUpdateCallback(func() {
frontendServer.InvalidateCache()
refreshFrameOrigins()
})
r.Use(frontendServer.Middleware()) r.Use(frontendServer.Middleware())
} }
} else {
settingService.SetOnUpdateCallback(refreshFrameOrigins)
} }
// 注册路由 // 注册路由
......
...@@ -113,8 +113,9 @@ const ( ...@@ -113,8 +113,9 @@ const (
SettingKeyDocURL = "doc_url" // 文档链接 SettingKeyDocURL = "doc_url" // 文档链接
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src) SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口 SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL(作为 iframe src) SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组)
// 默认配置 // 默认配置
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
......
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
...@@ -124,6 +125,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -124,6 +125,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyPurchaseSubscriptionEnabled, SettingKeyPurchaseSubscriptionEnabled,
SettingKeyPurchaseSubscriptionURL, SettingKeyPurchaseSubscriptionURL,
SettingKeySoraClientEnabled, SettingKeySoraClientEnabled,
SettingKeyCustomMenuItems,
SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectEnabled,
} }
...@@ -163,6 +165,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -163,6 +165,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
CustomMenuItems: settings[SettingKeyCustomMenuItems],
LinuxDoOAuthEnabled: linuxDoEnabled, LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil }, nil
} }
...@@ -193,27 +196,28 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -193,27 +196,28 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
// Return a struct that matches the frontend's expected format // Return a struct that matches the frontend's expected format
return &struct { return &struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` TotpEnabled bool `json:"totp_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo,omitempty"` SiteLogo string `json:"site_logo,omitempty"`
SiteSubtitle string `json:"site_subtitle,omitempty"` SiteSubtitle string `json:"site_subtitle,omitempty"`
APIBaseURL string `json:"api_base_url,omitempty"` APIBaseURL string `json:"api_base_url,omitempty"`
ContactInfo string `json:"contact_info,omitempty"` ContactInfo string `json:"contact_info,omitempty"`
DocURL string `json:"doc_url,omitempty"` DocURL string `json:"doc_url,omitempty"`
HomeContent string `json:"home_content,omitempty"` HomeContent string `json:"home_content,omitempty"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` CustomMenuItems json.RawMessage `json:"custom_menu_items"`
Version string `json:"version,omitempty"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version,omitempty"`
}{ }{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
...@@ -234,11 +238,119 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -234,11 +238,119 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: s.version, Version: s.version,
}, nil }, 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 更新系统设置 // UpdateSettings 更新系统设置
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
...@@ -293,6 +405,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -293,6 +405,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled) updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled)
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
// 默认配置 // 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
...@@ -509,6 +622,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -509,6 +622,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyPurchaseSubscriptionEnabled: "false", SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "", SettingKeyPurchaseSubscriptionURL: "",
SettingKeySoraClientEnabled: "false", SettingKeySoraClientEnabled: "false",
SettingKeyCustomMenuItems: "[]",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultSubscriptions: "[]",
...@@ -567,6 +681,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -567,6 +681,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
CustomMenuItems: settings[SettingKeyCustomMenuItems],
} }
// 解析整数类型 // 解析整数类型
......
...@@ -40,6 +40,7 @@ type SystemSettings struct { ...@@ -40,6 +40,7 @@ type SystemSettings struct {
PurchaseSubscriptionEnabled bool PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string PurchaseSubscriptionURL string
SoraClientEnabled bool SoraClientEnabled bool
CustomMenuItems string // JSON array of custom menu items
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
...@@ -92,6 +93,7 @@ type PublicSettings struct { ...@@ -92,6 +93,7 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled bool PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string PurchaseSubscriptionURL string
SoraClientEnabled bool SoraClientEnabled bool
CustomMenuItems string // JSON array of custom menu items
LinuxDoOAuthEnabled bool LinuxDoOAuthEnabled bool
Version string Version string
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
*/ */
import { apiClient } from '../client' import { apiClient } from '../client'
import type { CustomMenuItem } from '@/types'
export interface DefaultSubscriptionSetting { export interface DefaultSubscriptionSetting {
group_id: number group_id: number
...@@ -38,6 +39,7 @@ export interface SystemSettings { ...@@ -38,6 +39,7 @@ export interface SystemSettings {
purchase_subscription_enabled: boolean purchase_subscription_enabled: boolean
purchase_subscription_url: string purchase_subscription_url: string
sora_client_enabled: boolean sora_client_enabled: boolean
custom_menu_items: CustomMenuItem[]
// SMTP settings // SMTP settings
smtp_host: string smtp_host: string
smtp_port: number smtp_port: number
...@@ -99,6 +101,7 @@ export interface UpdateSettingsRequest { ...@@ -99,6 +101,7 @@ export interface UpdateSettingsRequest {
purchase_subscription_enabled?: boolean purchase_subscription_enabled?: boolean
purchase_subscription_url?: string purchase_subscription_url?: string
sora_client_enabled?: boolean sora_client_enabled?: boolean
custom_menu_items?: CustomMenuItem[]
smtp_host?: string smtp_host?: string
smtp_port?: number smtp_port?: number
smtp_username?: string smtp_username?: string
......
<template>
<div class="flex items-start gap-4">
<!-- Preview Box -->
<div class="flex-shrink-0">
<div
class="flex items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
:class="[previewSizeClass, { 'border-solid': !!modelValue }]"
>
<!-- SVG mode: render inline -->
<span
v-if="mode === 'svg' && modelValue"
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
:class="innerSizeClass"
v-html="sanitizedValue"
></span>
<!-- Image mode: show as img -->
<img
v-else-if="mode === 'image' && modelValue"
:src="modelValue"
alt=""
class="h-full w-full object-contain"
/>
<!-- Empty placeholder -->
<svg
v-else
class="text-gray-400 dark:text-dark-500"
:class="placeholderSizeClass"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<!-- Controls -->
<div class="flex-1 space-y-2">
<div class="flex items-center gap-2">
<label class="btn btn-secondary btn-sm cursor-pointer">
<input
type="file"
:accept="acceptTypes"
class="hidden"
@change="handleUpload"
/>
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
{{ uploadLabel }}
</label>
<button
v-if="modelValue"
type="button"
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="$emit('update:modelValue', '')"
>
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
{{ removeLabel }}
</button>
</div>
<p v-if="hint" class="text-xs text-gray-500 dark:text-gray-400">{{ hint }}</p>
<p v-if="error" class="text-xs text-red-500">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
const props = withDefaults(defineProps<{
modelValue: string
mode?: 'image' | 'svg'
size?: 'sm' | 'md'
uploadLabel?: string
removeLabel?: string
hint?: string
maxSize?: number // bytes
}>(), {
mode: 'image',
size: 'md',
uploadLabel: 'Upload',
removeLabel: 'Remove',
hint: '',
maxSize: 300 * 1024,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
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')
function handleUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
error.value = ''
if (!file) return
if (props.maxSize && file.size > props.maxSize) {
error.value = `File too large (${(file.size / 1024).toFixed(1)} KB), max ${(props.maxSize / 1024).toFixed(0)} KB`
input.value = ''
return
}
const reader = new FileReader()
if (props.mode === 'svg') {
reader.onload = (e) => {
const text = e.target?.result as string
if (text) emit('update:modelValue', text.trim())
}
reader.readAsText(file)
} else {
if (!file.type.startsWith('image/')) {
error.value = 'Please select an image file'
input.value = ''
return
}
reader.onload = (e) => {
emit('update:modelValue', e.target?.result as string)
}
reader.readAsDataURL(file)
}
reader.onerror = () => {
error.value = 'Failed to read file'
}
input.value = ''
}
</script>
...@@ -254,6 +254,13 @@ const displayName = computed(() => { ...@@ -254,6 +254,13 @@ const displayName = computed(() => {
}) })
const pageTitle = 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 const titleKey = route.meta.titleKey as string
if (titleKey) { if (titleKey) {
return t(titleKey) return t(titleKey)
......
...@@ -47,7 +47,8 @@ ...@@ -47,7 +47,8 @@
" "
@click="handleMenuItemClick(item.path)" @click="handleMenuItemClick(item.path)"
> >
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" /> <span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span> <span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition> </transition>
...@@ -71,7 +72,8 @@ ...@@ -71,7 +72,8 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined" :data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)" @click="handleMenuItemClick(item.path)"
> >
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" /> <span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span> <span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition> </transition>
...@@ -92,7 +94,8 @@ ...@@ -92,7 +94,8 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined" :data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)" @click="handleMenuItemClick(item.path)"
> >
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" /> <span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span> <span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition> </transition>
...@@ -149,6 +152,15 @@ import { useRoute } from 'vue-router' ...@@ -149,6 +152,15 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue' 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() const { t } = useI18n()
...@@ -496,8 +508,8 @@ const ChevronDoubleRightIcon = { ...@@ -496,8 +508,8 @@ const ChevronDoubleRightIcon = {
} }
// User navigation items (for regular users) // User navigation items (for regular users)
const userNavItems = computed(() => { const userNavItems = computed((): NavItem[] => {
const items = [ const items: NavItem[] = [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
...@@ -516,14 +528,20 @@ const userNavItems = computed(() => { ...@@ -516,14 +528,20 @@ const userNavItems = computed(() => {
] ]
: []), : []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { 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 return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
}) })
// Personal navigation items (for admin's "My Account" section, without Dashboard) // Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => { const personalNavItems = computed((): NavItem[] => {
const items = [ const items: NavItem[] = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...@@ -541,14 +559,35 @@ const personalNavItems = computed(() => { ...@@ -541,14 +559,35 @@ const personalNavItems = computed(() => {
] ]
: []), : []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { 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 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 // Admin navigation items
const adminNavItems = computed(() => { const adminNavItems = computed((): NavItem[] => {
const baseItems = [ const baseItems: NavItem[] = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
...(adminSettingsStore.opsMonitoringEnabled ...(adminSettingsStore.opsMonitoringEnabled
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }] ? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
...@@ -570,11 +609,19 @@ const adminNavItems = computed(() => { ...@@ -570,11 +609,19 @@ const adminNavItems = computed(() => {
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) 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/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) 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 return filtered
} }
baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon }) baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) 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 return baseItems
}) })
...@@ -654,4 +701,12 @@ onMounted(() => { ...@@ -654,4 +701,12 @@ onMounted(() => {
.fade-leave-to { .fade-leave-to {
opacity: 0; 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;
}
</style> </style>
...@@ -3625,6 +3625,27 @@ export default { ...@@ -3625,6 +3625,27 @@ export default {
enabled: 'Enable Sora Client', enabled: 'Enable Sora Client',
enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features' 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: '<svg>...</svg>',
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: { smtp: {
title: 'SMTP Settings', title: 'SMTP Settings',
description: 'Configure email sending for verification codes', description: 'Configure email sending for verification codes',
...@@ -3913,6 +3934,16 @@ export default { ...@@ -3913,6 +3934,16 @@ export default {
'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.' '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 Page
announcements: { announcements: {
title: 'Announcements', title: 'Announcements',
......
...@@ -3795,6 +3795,27 @@ export default { ...@@ -3795,6 +3795,27 @@ export default {
enabled: '启用 Sora 客户端', enabled: '启用 Sora 客户端',
enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能' enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能'
}, },
customMenu: {
title: '自定义菜单页面',
description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。',
itemLabel: '菜单项 #{n}',
name: '菜单名称',
namePlaceholder: '如:帮助中心',
url: '页面 URL',
urlPlaceholder: 'https://example.com/page',
iconSvg: 'SVG 图标',
iconSvgPlaceholder: '<svg>...</svg>',
iconPreview: '图标预览',
uploadSvg: '上传 SVG',
removeSvg: '清除',
visibility: '可见角色',
visibilityUser: '普通用户',
visibilityAdmin: '管理员',
add: '添加菜单项',
remove: '删除',
moveUp: '上移',
moveDown: '下移',
},
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务', description: '配置用于发送验证码的邮件服务',
...@@ -4081,6 +4102,16 @@ export default { ...@@ -4081,6 +4102,16 @@ export default {
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。' notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
}, },
// Custom Page (iframe embed)
customPage: {
title: '自定义页面',
openInNewTab: '新窗口打开',
notFoundTitle: '页面不存在',
notFoundDesc: '该自定义页面不存在或已被删除。',
notConfiguredTitle: '页面链接未配置',
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
},
// Announcements Page // Announcements Page
announcements: { announcements: {
title: '公告', title: '公告',
......
...@@ -203,6 +203,17 @@ const routes: RouteRecordRaw[] = [ ...@@ -203,6 +203,17 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'sora.description' 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 ==================== // ==================== Admin Routes ====================
{ {
...@@ -417,7 +428,20 @@ router.beforeEach((to, _from, next) => { ...@@ -417,7 +428,20 @@ router.beforeEach((to, _from, next) => {
// Set page title // Set page title
const appStore = useAppStore() 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 // Check if route requires authentication
const requiresAuth = to.meta.requiresAuth !== false // Default to true const requiresAuth = to.meta.requiresAuth !== false // Default to true
......
...@@ -327,6 +327,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -327,6 +327,7 @@ export const useAppStore = defineStore('app', () => {
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false, purchase_subscription_enabled: false,
purchase_subscription_url: '', purchase_subscription_url: '',
custom_menu_items: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
sora_client_enabled: false, sora_client_enabled: false,
version: siteVersion.value version: siteVersion.value
......
...@@ -75,6 +75,15 @@ export interface SendVerifyCodeResponse { ...@@ -75,6 +75,15 @@ export interface SendVerifyCodeResponse {
countdown: number countdown: number
} }
export interface CustomMenuItem {
id: string
label: string
icon_svg: string
url: string
visibility: 'user' | 'admin'
sort_order: number
}
export interface PublicSettings { export interface PublicSettings {
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
...@@ -93,6 +102,7 @@ export interface PublicSettings { ...@@ -93,6 +102,7 @@ export interface PublicSettings {
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean purchase_subscription_enabled: boolean
purchase_subscription_url: string purchase_subscription_url: string
custom_menu_items: CustomMenuItem[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
sora_client_enabled: boolean sora_client_enabled: boolean
version: string version: string
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment