Merge pull request #727 from touwaeriol/pr/custom-menu-pages
feat: custom menu pages with iframe embedding and CSP injection
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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 (
|
|||||||
// 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) {
|
|||||||
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,
|
||||||
@@ -152,6 +168,7 @@ type UpdateSettingsRequest struct {
|
|||||||
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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义菜单项验证
|
||||||
|
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) {
|
|||||||
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) {
|
|||||||
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,
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
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"`
|
||||||
@@ -38,6 +53,7 @@ type SystemSettings struct {
|
|||||||
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"`
|
||||||
@@ -87,6 +103,7 @@ type PublicSettings struct {
|
|||||||
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"`
|
||||||
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@@ -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) {
|
|||||||
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) {
|
|||||||
"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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
|||||||
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 {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
|
|
||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
|
|
||||||
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) {
|
|||||||
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) {
|
|||||||
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++ {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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 (
|
|||||||
"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(
|
|||||||
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(
|
|||||||
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 (
|
|||||||
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 (
|
|||||||
"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
|
|||||||
SettingKeyPurchaseSubscriptionEnabled,
|
SettingKeyPurchaseSubscriptionEnabled,
|
||||||
SettingKeyPurchaseSubscriptionURL,
|
SettingKeyPurchaseSubscriptionURL,
|
||||||
SettingKeySoraClientEnabled,
|
SettingKeySoraClientEnabled,
|
||||||
|
SettingKeyCustomMenuItems,
|
||||||
SettingKeyLinuxDoConnectEnabled,
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
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"`
|
||||||
|
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
}{
|
}{
|
||||||
@@ -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
|
|||||||
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 {
|
|||||||
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
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 {
|
|||||||
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 {
|
|||||||
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
|
||||||
|
|||||||
146
frontend/src/components/common/ImageUpload.vue
Normal file
146
frontend/src/components/common/ImageUpload.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<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(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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 @@
|
|||||||
"
|
"
|
||||||
@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 @@
|
|||||||
: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 @@
|
|||||||
: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'
|
|||||||
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 = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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(() => {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{ 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(() => {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{ 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(() => {
|
|||||||
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(() => {
|
|||||||
.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 {
|
|||||||
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 {
|
|||||||
'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 {
|
|||||||
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 {
|
|||||||
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[] = [
|
|||||||
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) => {
|
|||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
// 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)
|
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', () => {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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
|
||||||
|
|||||||
46
frontend/src/utils/embedded-url.ts
Normal file
46
frontend/src/utils/embedded-url.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
6
frontend/src/utils/sanitize.ts
Normal file
6
frontend/src/utils/sanitize.ts
Normal file
@@ -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 } })
|
||||||
|
}
|
||||||
@@ -832,64 +832,14 @@
|
|||||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{{ t('admin.settings.site.siteLogo') }}
|
{{ t('admin.settings.site.siteLogo') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-start gap-6">
|
<ImageUpload
|
||||||
<!-- Logo Preview -->
|
v-model="form.site_logo"
|
||||||
<div class="flex-shrink-0">
|
mode="image"
|
||||||
<div
|
:upload-label="t('admin.settings.site.uploadImage')"
|
||||||
class="flex h-20 w-20 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"
|
:remove-label="t('admin.settings.site.remove')"
|
||||||
:class="{ 'border-solid': form.site_logo }"
|
:hint="t('admin.settings.site.logoHint')"
|
||||||
>
|
:max-size="300 * 1024"
|
||||||
<img
|
|
||||||
v-if="form.site_logo"
|
|
||||||
:src="form.site_logo"
|
|
||||||
alt="Site Logo"
|
|
||||||
class="h-full w-full object-contain"
|
|
||||||
/>
|
/>
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
class="h-8 w-8 text-gray-400 dark:text-dark-500"
|
|
||||||
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>
|
|
||||||
<!-- Upload Controls -->
|
|
||||||
<div class="flex-1 space-y-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label class="btn btn-secondary btn-sm cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleLogoUpload"
|
|
||||||
/>
|
|
||||||
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
|
|
||||||
{{ t('admin.settings.site.uploadImage') }}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
v-if="form.site_logo"
|
|
||||||
type="button"
|
|
||||||
@click="form.site_logo = ''"
|
|
||||||
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
|
||||||
>
|
|
||||||
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
|
|
||||||
{{ t('admin.settings.site.remove') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ t('admin.settings.site.logoHint') }}
|
|
||||||
</p>
|
|
||||||
<p v-if="logoError" class="text-xs text-red-500">{{ logoError }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Home Content -->
|
<!-- Home Content -->
|
||||||
@@ -1160,6 +1110,127 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Menu Items -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.customMenu.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.customMenu.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 p-6">
|
||||||
|
<!-- Existing menu items -->
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in form.custom_menu_items"
|
||||||
|
:key="item.id || index"
|
||||||
|
class="rounded-lg border border-gray-200 p-4 dark:border-dark-600"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.customMenu.itemLabel', { n: index + 1 }) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Move up -->
|
||||||
|
<button
|
||||||
|
v-if="index > 0"
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
|
||||||
|
:title="t('admin.settings.customMenu.moveUp')"
|
||||||
|
@click="moveMenuItem(index, -1)"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Move down -->
|
||||||
|
<button
|
||||||
|
v-if="index < form.custom_menu_items.length - 1"
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
|
||||||
|
:title="t('admin.settings.customMenu.moveDown')"
|
||||||
|
@click="moveMenuItem(index, 1)"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||||
|
:title="t('admin.settings.customMenu.remove')"
|
||||||
|
@click="removeMenuItem(index)"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<!-- Label -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.customMenu.name') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="item.label"
|
||||||
|
type="text"
|
||||||
|
class="input text-sm"
|
||||||
|
:placeholder="t('admin.settings.customMenu.namePlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.customMenu.visibility') }}
|
||||||
|
</label>
|
||||||
|
<select v-model="item.visibility" class="input text-sm">
|
||||||
|
<option value="user">{{ t('admin.settings.customMenu.visibilityUser') }}</option>
|
||||||
|
<option value="admin">{{ t('admin.settings.customMenu.visibilityAdmin') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL (full width) -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.customMenu.url') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="item.url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.customMenu.urlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG Icon (full width) -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.customMenu.iconSvg') }}
|
||||||
|
</label>
|
||||||
|
<ImageUpload
|
||||||
|
:model-value="item.icon_svg"
|
||||||
|
mode="svg"
|
||||||
|
size="sm"
|
||||||
|
:upload-label="t('admin.settings.customMenu.uploadSvg')"
|
||||||
|
:remove-label="t('admin.settings.customMenu.removeSvg')"
|
||||||
|
@update:model-value="(v: string) => item.icon_svg = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 py-3 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
|
||||||
|
@click="addMenuItem"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||||
|
{{ t('admin.settings.customMenu.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||||
<div v-if="form.email_verify_enabled" class="card">
|
<div v-if="form.email_verify_enabled" class="card">
|
||||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
@@ -1261,6 +1332,7 @@ import Select from '@/components/common/Select.vue'
|
|||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||||
import Toggle from '@/components/common/Toggle.vue'
|
import Toggle from '@/components/common/Toggle.vue'
|
||||||
|
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useAppStore } from '@/stores'
|
import { useAppStore } from '@/stores'
|
||||||
|
|
||||||
@@ -1273,7 +1345,6 @@ const saving = ref(false)
|
|||||||
const testingSmtp = ref(false)
|
const testingSmtp = ref(false)
|
||||||
const sendingTestEmail = ref(false)
|
const sendingTestEmail = ref(false)
|
||||||
const testEmailAddress = ref('')
|
const testEmailAddress = ref('')
|
||||||
const logoError = ref('')
|
|
||||||
|
|
||||||
// Admin API Key 状态
|
// Admin API Key 状态
|
||||||
const adminApiKeyLoading = ref(true)
|
const adminApiKeyLoading = ref(true)
|
||||||
@@ -1332,6 +1403,7 @@ const form = reactive<SettingsForm>({
|
|||||||
purchase_subscription_enabled: false,
|
purchase_subscription_enabled: false,
|
||||||
purchase_subscription_url: '',
|
purchase_subscription_url: '',
|
||||||
sora_client_enabled: false,
|
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_host: '',
|
||||||
smtp_port: 587,
|
smtp_port: 587,
|
||||||
smtp_username: '',
|
smtp_username: '',
|
||||||
@@ -1396,42 +1468,37 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
|||||||
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
|
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogoUpload(event: Event) {
|
// Custom menu item management
|
||||||
const input = event.target as HTMLInputElement
|
function addMenuItem() {
|
||||||
const file = input.files?.[0]
|
form.custom_menu_items.push({
|
||||||
logoError.value = ''
|
id: '',
|
||||||
|
label: '',
|
||||||
if (!file) return
|
icon_svg: '',
|
||||||
|
url: '',
|
||||||
// Check file size (300KB = 307200 bytes)
|
visibility: 'user',
|
||||||
const maxSize = 300 * 1024
|
sort_order: form.custom_menu_items.length,
|
||||||
if (file.size > maxSize) {
|
|
||||||
logoError.value = t('admin.settings.site.logoSizeError', {
|
|
||||||
size: (file.size / 1024).toFixed(1)
|
|
||||||
})
|
})
|
||||||
input.value = ''
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file type
|
function removeMenuItem(index: number) {
|
||||||
if (!file.type.startsWith('image/')) {
|
form.custom_menu_items.splice(index, 1)
|
||||||
logoError.value = t('admin.settings.site.logoTypeError')
|
// Re-index sort_order
|
||||||
input.value = ''
|
form.custom_menu_items.forEach((item, i) => {
|
||||||
return
|
item.sort_order = i
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to base64
|
function moveMenuItem(index: number, direction: -1 | 1) {
|
||||||
const reader = new FileReader()
|
const targetIndex = index + direction
|
||||||
reader.onload = (e) => {
|
if (targetIndex < 0 || targetIndex >= form.custom_menu_items.length) return
|
||||||
form.site_logo = e.target?.result as string
|
const items = form.custom_menu_items
|
||||||
}
|
const temp = items[index]
|
||||||
reader.onerror = () => {
|
items[index] = items[targetIndex]
|
||||||
logoError.value = t('admin.settings.site.logoReadError')
|
items[targetIndex] = temp
|
||||||
}
|
// Re-index sort_order
|
||||||
reader.readAsDataURL(file)
|
items.forEach((item, i) => {
|
||||||
|
item.sort_order = i
|
||||||
// Reset input
|
})
|
||||||
input.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
@@ -1534,6 +1601,7 @@ async function saveSettings() {
|
|||||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||||
purchase_subscription_url: form.purchase_subscription_url,
|
purchase_subscription_url: form.purchase_subscription_url,
|
||||||
sora_client_enabled: form.sora_client_enabled,
|
sora_client_enabled: form.sora_client_enabled,
|
||||||
|
custom_menu_items: form.custom_menu_items,
|
||||||
smtp_host: form.smtp_host,
|
smtp_host: form.smtp_host,
|
||||||
smtp_port: form.smtp_port,
|
smtp_port: form.smtp_port,
|
||||||
smtp_username: form.smtp_username,
|
smtp_username: form.smtp_username,
|
||||||
|
|||||||
170
frontend/src/views/user/CustomPageView.vue
Normal file
170
frontend/src/views/user/CustomPageView.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="custom-page-layout">
|
||||||
|
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!menuItem"
|
||||||
|
class="flex h-full items-center justify-center p-10 text-center"
|
||||||
|
>
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||||
|
>
|
||||||
|
<Icon name="link" size="lg" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('customPage.notFoundTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('customPage.notFoundDesc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||||
|
>
|
||||||
|
<Icon name="link" size="lg" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('customPage.notConfiguredTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('customPage.notConfiguredDesc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="custom-embed-shell">
|
||||||
|
<a
|
||||||
|
:href="embeddedUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-secondary btn-sm custom-open-fab"
|
||||||
|
>
|
||||||
|
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||||
|
{{ t('customPage.openInNewTab') }}
|
||||||
|
</a>
|
||||||
|
<iframe
|
||||||
|
:src="embeddedUrl"
|
||||||
|
class="custom-embed-frame"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
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 route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const pageTheme = ref<'light' | 'dark'>('light')
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
const menuItemId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
|
const menuItem = computed(() => {
|
||||||
|
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||||
|
const found = items.find((item) => item.id === menuItemId.value) ?? null
|
||||||
|
if (found && found.visibility === 'admin' && !authStore.isAdmin) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
})
|
||||||
|
|
||||||
|
const embeddedUrl = computed(() => {
|
||||||
|
if (!menuItem.value) return ''
|
||||||
|
return buildEmbeddedUrl(
|
||||||
|
menuItem.value.url,
|
||||||
|
authStore.user?.id,
|
||||||
|
authStore.token,
|
||||||
|
pageTheme.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidUrl = computed(() => {
|
||||||
|
const url = embeddedUrl.value
|
||||||
|
return url.startsWith('http://') || url.startsWith('https://')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
pageTheme.value = detectTheme()
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
pageTheme.value = detectTheme()
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appStore.publicSettingsLoaded) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await appStore.fetchPublicSettings()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect()
|
||||||
|
themeObserver = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-page-layout {
|
||||||
|
@apply flex flex-col;
|
||||||
|
height: calc(100vh - 64px - 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-embed-shell {
|
||||||
|
@apply relative;
|
||||||
|
@apply h-full w-full overflow-hidden rounded-2xl;
|
||||||
|
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-open-fab {
|
||||||
|
@apply absolute right-3 top-3 z-10;
|
||||||
|
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-embed-frame {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,17 +74,12 @@ import { useAppStore } from '@/stores'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const authStore = useAuthStore()
|
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 loading = ref(false)
|
||||||
const purchaseTheme = ref<'light' | 'dark'>('light')
|
const purchaseTheme = ref<'light' | 'dark'>('light')
|
||||||
let themeObserver: MutationObserver | null = null
|
let themeObserver: MutationObserver | null = null
|
||||||
@@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => {
|
|||||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
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 purchaseUrl = computed(() => {
|
||||||
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
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(() => {
|
const isValidUrl = computed(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user