Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
405829dc
Unverified
Commit
405829dc
authored
Mar 03, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 03, 2026
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
Changes
25
Hide whitespace changes
Inline
Side-by-side
backend/cmd/server/main.go
View file @
405829dc
...
...
@@ -100,7 +100,7 @@ func runSetupServer() {
r
:=
gin
.
New
()
r
.
Use
(
middleware
.
Recovery
())
r
.
Use
(
middleware
.
CORS
(
config
.
CORSConfig
{}))
r
.
Use
(
middleware
.
SecurityHeaders
(
config
.
CSPConfig
{
Enabled
:
true
,
Policy
:
config
.
DefaultCSPPolicy
}))
r
.
Use
(
middleware
.
SecurityHeaders
(
config
.
CSPConfig
{
Enabled
:
true
,
Policy
:
config
.
DefaultCSPPolicy
}
,
nil
))
// Register setup routes
setup
.
RegisterRoutes
(
r
)
...
...
backend/internal/handler/admin/setting_handler.go
View file @
405829dc
package
admin
import
(
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
...
...
@@ -20,6 +23,18 @@ import (
// semverPattern 预编译 semver 格式校验正则
var
semverPattern
=
regexp
.
MustCompile
(
`^\d+\.\d+\.\d+$`
)
// menuItemIDPattern validates custom menu item IDs: alphanumeric, hyphens, underscores only.
var
menuItemIDPattern
=
regexp
.
MustCompile
(
`^[a-zA-Z0-9_-]+$`
)
// generateMenuItemID generates a short random hex ID for a custom menu item.
func
generateMenuItemID
()
(
string
,
error
)
{
b
:=
make
([]
byte
,
8
)
if
_
,
err
:=
rand
.
Read
(
b
);
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"generate menu item ID: %w"
,
err
)
}
return
hex
.
EncodeToString
(
b
),
nil
}
// SettingHandler 系统设置处理器
type
SettingHandler
struct
{
settingService
*
service
.
SettingService
...
...
@@ -92,6 +107,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
SoraClientEnabled
:
settings
.
SoraClientEnabled
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
settings
.
CustomMenuItems
),
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
DefaultSubscriptions
:
defaultSubscriptions
,
...
...
@@ -141,17 +157,18 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
// OEM设置
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
CustomMenuItems
*
[]
dto
.
CustomMenuItem
`json:"custom_menu_items"`
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
...
...
@@ -299,6 +316,84 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// 自定义菜单项验证
const
(
maxCustomMenuItems
=
20
maxMenuItemLabelLen
=
50
maxMenuItemURLLen
=
2048
maxMenuItemIconSVGLen
=
10
*
1024
// 10KB
maxMenuItemIDLen
=
32
)
customMenuJSON
:=
previousSettings
.
CustomMenuItems
if
req
.
CustomMenuItems
!=
nil
{
items
:=
*
req
.
CustomMenuItems
if
len
(
items
)
>
maxCustomMenuItems
{
response
.
BadRequest
(
c
,
"Too many custom menu items (max 20)"
)
return
}
for
i
,
item
:=
range
items
{
if
strings
.
TrimSpace
(
item
.
Label
)
==
""
{
response
.
BadRequest
(
c
,
"Custom menu item label is required"
)
return
}
if
len
(
item
.
Label
)
>
maxMenuItemLabelLen
{
response
.
BadRequest
(
c
,
"Custom menu item label is too long (max 50 characters)"
)
return
}
if
strings
.
TrimSpace
(
item
.
URL
)
==
""
{
response
.
BadRequest
(
c
,
"Custom menu item URL is required"
)
return
}
if
len
(
item
.
URL
)
>
maxMenuItemURLLen
{
response
.
BadRequest
(
c
,
"Custom menu item URL is too long (max 2048 characters)"
)
return
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
strings
.
TrimSpace
(
item
.
URL
));
err
!=
nil
{
response
.
BadRequest
(
c
,
"Custom menu item URL must be an absolute http(s) URL"
)
return
}
if
item
.
Visibility
!=
"user"
&&
item
.
Visibility
!=
"admin"
{
response
.
BadRequest
(
c
,
"Custom menu item visibility must be 'user' or 'admin'"
)
return
}
if
len
(
item
.
IconSVG
)
>
maxMenuItemIconSVGLen
{
response
.
BadRequest
(
c
,
"Custom menu item icon SVG is too large (max 10KB)"
)
return
}
// Auto-generate ID if missing
if
strings
.
TrimSpace
(
item
.
ID
)
==
""
{
id
,
err
:=
generateMenuItemID
()
if
err
!=
nil
{
response
.
Error
(
c
,
http
.
StatusInternalServerError
,
"Failed to generate menu item ID"
)
return
}
items
[
i
]
.
ID
=
id
}
else
if
len
(
item
.
ID
)
>
maxMenuItemIDLen
{
response
.
BadRequest
(
c
,
"Custom menu item ID is too long (max 32 characters)"
)
return
}
else
if
!
menuItemIDPattern
.
MatchString
(
item
.
ID
)
{
response
.
BadRequest
(
c
,
"Custom menu item ID contains invalid characters (only a-z, A-Z, 0-9, - and _ are allowed)"
)
return
}
}
// ID uniqueness check
seen
:=
make
(
map
[
string
]
struct
{},
len
(
items
))
for
_
,
item
:=
range
items
{
if
_
,
exists
:=
seen
[
item
.
ID
];
exists
{
response
.
BadRequest
(
c
,
"Duplicate custom menu item ID: "
+
item
.
ID
)
return
}
seen
[
item
.
ID
]
=
struct
{}{}
}
menuBytes
,
err
:=
json
.
Marshal
(
items
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Failed to serialize custom menu items"
)
return
}
customMenuJSON
=
string
(
menuBytes
)
}
// Ops metrics collector interval validation (seconds).
if
req
.
OpsMetricsIntervalSeconds
!=
nil
{
v
:=
*
req
.
OpsMetricsIntervalSeconds
...
...
@@ -358,6 +453,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
PurchaseSubscriptionURL
:
purchaseURL
,
SoraClientEnabled
:
req
.
SoraClientEnabled
,
CustomMenuItems
:
customMenuJSON
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
DefaultSubscriptions
:
defaultSubscriptions
,
...
...
@@ -449,6 +545,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
SoraClientEnabled
:
updatedSettings
.
SoraClientEnabled
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
updatedSettings
.
CustomMenuItems
),
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
DefaultSubscriptions
:
updatedDefaultSubscriptions
,
...
...
@@ -612,6 +709,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
MinClaudeCodeVersion
!=
after
.
MinClaudeCodeVersion
{
changed
=
append
(
changed
,
"min_claude_code_version"
)
}
if
before
.
PurchaseSubscriptionEnabled
!=
after
.
PurchaseSubscriptionEnabled
{
changed
=
append
(
changed
,
"purchase_subscription_enabled"
)
}
if
before
.
PurchaseSubscriptionURL
!=
after
.
PurchaseSubscriptionURL
{
changed
=
append
(
changed
,
"purchase_subscription_url"
)
}
if
before
.
CustomMenuItems
!=
after
.
CustomMenuItems
{
changed
=
append
(
changed
,
"custom_menu_items"
)
}
return
changed
}
...
...
backend/internal/handler/dto/settings.go
View file @
405829dc
package
dto
import
(
"encoding/json"
"strings"
)
// CustomMenuItem represents a user-configured custom menu entry.
type
CustomMenuItem
struct
{
ID
string
`json:"id"`
Label
string
`json:"label"`
IconSVG
string
`json:"icon_svg"`
URL
string
`json:"url"`
Visibility
string
`json:"visibility"`
// "user" or "admin"
SortOrder
int
`json:"sort_order"`
}
// SystemSettings represents the admin settings API response payload.
type
SystemSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
...
...
@@ -27,17 +42,18 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
`json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
...
...
@@ -69,27 +85,28 @@ type DefaultSubscriptionSetting struct {
}
type
PublicSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
InvitationCodeEnabled
bool
`json:"invitation_code_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
Version
string
`json:"version"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
InvitationCodeEnabled
bool
`json:"invitation_code_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
Version
string
`json:"version"`
}
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
...
...
@@ -138,3 +155,29 @@ type StreamTimeoutSettings struct {
ThresholdCount
int
`json:"threshold_count"`
ThresholdWindowMinutes
int
`json:"threshold_window_minutes"`
}
// ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
// Returns empty slice on empty/invalid input.
func
ParseCustomMenuItems
(
raw
string
)
[]
CustomMenuItem
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
||
raw
==
"[]"
{
return
[]
CustomMenuItem
{}
}
var
items
[]
CustomMenuItem
if
err
:=
json
.
Unmarshal
([]
byte
(
raw
),
&
items
);
err
!=
nil
{
return
[]
CustomMenuItem
{}
}
return
items
}
// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries.
func
ParseUserVisibleMenuItems
(
raw
string
)
[]
CustomMenuItem
{
items
:=
ParseCustomMenuItems
(
raw
)
filtered
:=
make
([]
CustomMenuItem
,
0
,
len
(
items
))
for
_
,
item
:=
range
items
{
if
item
.
Visibility
!=
"admin"
{
filtered
=
append
(
filtered
,
item
)
}
}
return
filtered
}
backend/internal/handler/setting_handler.go
View file @
405829dc
...
...
@@ -50,6 +50,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
CustomMenuItems
:
dto
.
ParseUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
SoraClientEnabled
:
settings
.
SoraClientEnabled
,
Version
:
h
.
version
,
...
...
backend/internal/server/api_contract_test.go
View file @
405829dc
...
...
@@ -513,7 +513,8 @@ func TestAPIContracts(t *testing.T) {
"hide_ccs_import_button": false,
"purchase_subscription_enabled": false,
"purchase_subscription_url": "",
"min_claude_code_version": ""
"min_claude_code_version": "",
"custom_menu_items": []
}
}`
,
},
...
...
backend/internal/server/middleware/security_headers.go
View file @
405829dc
...
...
@@ -41,7 +41,9 @@ func GetNonceFromContext(c *gin.Context) string {
}
// SecurityHeaders sets baseline security headers for all responses.
func
SecurityHeaders
(
cfg
config
.
CSPConfig
)
gin
.
HandlerFunc
{
// getFrameSrcOrigins is an optional function that returns extra origins to inject into frame-src;
// pass nil to disable dynamic frame-src injection.
func
SecurityHeaders
(
cfg
config
.
CSPConfig
,
getFrameSrcOrigins
func
()
[]
string
)
gin
.
HandlerFunc
{
policy
:=
strings
.
TrimSpace
(
cfg
.
Policy
)
if
policy
==
""
{
policy
=
config
.
DefaultCSPPolicy
...
...
@@ -51,6 +53,15 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
policy
=
enhanceCSPPolicy
(
policy
)
return
func
(
c
*
gin
.
Context
)
{
finalPolicy
:=
policy
if
getFrameSrcOrigins
!=
nil
{
for
_
,
origin
:=
range
getFrameSrcOrigins
()
{
if
origin
!=
""
{
finalPolicy
=
addToDirective
(
finalPolicy
,
"frame-src"
,
origin
)
}
}
}
c
.
Header
(
"X-Content-Type-Options"
,
"nosniff"
)
c
.
Header
(
"X-Frame-Options"
,
"DENY"
)
c
.
Header
(
"Referrer-Policy"
,
"strict-origin-when-cross-origin"
)
...
...
@@ -65,12 +76,10 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
if
err
!=
nil
{
// crypto/rand 失败时降级为无 nonce 的 CSP 策略
log
.
Printf
(
"[SecurityHeaders] %v — 降级为无 nonce 的 CSP"
,
err
)
finalPolicy
:=
strings
.
ReplaceAll
(
policy
,
NonceTemplate
,
"'unsafe-inline'"
)
c
.
Header
(
"Content-Security-Policy"
,
finalPolicy
)
c
.
Header
(
"Content-Security-Policy"
,
strings
.
ReplaceAll
(
finalPolicy
,
NonceTemplate
,
"'unsafe-inline'"
))
}
else
{
c
.
Set
(
CSPNonceKey
,
nonce
)
finalPolicy
:=
strings
.
ReplaceAll
(
policy
,
NonceTemplate
,
"'nonce-"
+
nonce
+
"'"
)
c
.
Header
(
"Content-Security-Policy"
,
finalPolicy
)
c
.
Header
(
"Content-Security-Policy"
,
strings
.
ReplaceAll
(
finalPolicy
,
NonceTemplate
,
"'nonce-"
+
nonce
+
"'"
))
}
}
c
.
Next
()
...
...
backend/internal/server/middleware/security_headers_test.go
View file @
405829dc
...
...
@@ -84,7 +84,7 @@ func TestGetNonceFromContext(t *testing.T) {
func
TestSecurityHeaders
(
t
*
testing
.
T
)
{
t
.
Run
(
"sets_basic_security_headers"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
config
.
CSPConfig
{
Enabled
:
false
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -99,7 +99,7 @@ func TestSecurityHeaders(t *testing.T) {
t
.
Run
(
"csp_disabled_no_csp_header"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
config
.
CSPConfig
{
Enabled
:
false
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -115,7 +115,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
"default-src 'self'"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -136,7 +136,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
"default-src 'self'; script-src 'self' __CSP_NONCE__"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -156,7 +156,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
"script-src 'self' __CSP_NONCE__"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -180,7 +180,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
""
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -199,7 +199,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
"
\t\n
"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -217,7 +217,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
"script-src __CSP_NONCE__; style-src __CSP_NONCE__"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
...
...
@@ -235,7 +235,7 @@ func TestSecurityHeaders(t *testing.T) {
t
.
Run
(
"calls_next_handler"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
config
.
CSPConfig
{
Enabled
:
true
,
Policy
:
"default-src 'self'"
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
nextCalled
:=
false
router
:=
gin
.
New
()
...
...
@@ -258,7 +258,7 @@ func TestSecurityHeaders(t *testing.T) {
Enabled
:
true
,
Policy
:
"script-src __CSP_NONCE__"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
nonces
:=
make
(
map
[
string
]
bool
)
for
i
:=
0
;
i
<
10
;
i
++
{
...
...
@@ -376,7 +376,7 @@ func BenchmarkSecurityHeadersMiddleware(b *testing.B) {
Enabled
:
true
,
Policy
:
"script-src 'self' __CSP_NONCE__"
,
}
middleware
:=
SecurityHeaders
(
cfg
)
middleware
:=
SecurityHeaders
(
cfg
,
nil
)
b
.
ResetTimer
()
for
i
:=
0
;
i
<
b
.
N
;
i
++
{
...
...
backend/internal/server/router.go
View file @
405829dc
package
server
import
(
"context"
"log"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
...
...
@@ -14,6 +17,8 @@ import (
"github.com/redis/go-redis/v9"
)
const
frameSrcRefreshTimeout
=
5
*
time
.
Second
// SetupRouter 配置路由器中间件和路由
func
SetupRouter
(
r
*
gin
.
Engine
,
...
...
@@ -28,11 +33,33 @@ func SetupRouter(
cfg
*
config
.
Config
,
redisClient
*
redis
.
Client
,
)
*
gin
.
Engine
{
// 缓存 iframe 页面的 origin 列表,用于动态注入 CSP frame-src
var
cachedFrameOrigins
atomic
.
Pointer
[[]
string
]
emptyOrigins
:=
[]
string
{}
cachedFrameOrigins
.
Store
(
&
emptyOrigins
)
refreshFrameOrigins
:=
func
()
{
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
frameSrcRefreshTimeout
)
defer
cancel
()
origins
,
err
:=
settingService
.
GetFrameSrcOrigins
(
ctx
)
if
err
!=
nil
{
// 获取失败时保留已有缓存,避免 frame-src 被意外清空
return
}
cachedFrameOrigins
.
Store
(
&
origins
)
}
refreshFrameOrigins
()
// 启动时初始化
// 应用中间件
r
.
Use
(
middleware2
.
RequestLogger
())
r
.
Use
(
middleware2
.
Logger
())
r
.
Use
(
middleware2
.
CORS
(
cfg
.
CORS
))
r
.
Use
(
middleware2
.
SecurityHeaders
(
cfg
.
Security
.
CSP
))
r
.
Use
(
middleware2
.
SecurityHeaders
(
cfg
.
Security
.
CSP
,
func
()
[]
string
{
if
p
:=
cachedFrameOrigins
.
Load
();
p
!=
nil
{
return
*
p
}
return
nil
}))
// Serve embedded frontend with settings injection if available
if
web
.
HasEmbeddedFrontend
()
{
...
...
@@ -40,11 +67,17 @@ func SetupRouter(
if
err
!=
nil
{
log
.
Printf
(
"Warning: Failed to create frontend server with settings injection: %v, using legacy mode"
,
err
)
r
.
Use
(
web
.
ServeEmbeddedFrontend
())
settingService
.
SetOnUpdateCallback
(
refreshFrameOrigins
)
}
else
{
// Register cache invalidation callback
settingService
.
SetOnUpdateCallback
(
frontendServer
.
InvalidateCache
)
// Register combined callback: invalidate HTML cache + refresh frame origins
settingService
.
SetOnUpdateCallback
(
func
()
{
frontendServer
.
InvalidateCache
()
refreshFrameOrigins
()
})
r
.
Use
(
frontendServer
.
Middleware
())
}
}
else
{
settingService
.
SetOnUpdateCallback
(
refreshFrameOrigins
)
}
// 注册路由
...
...
backend/internal/service/domain_constants.go
View file @
405829dc
...
...
@@ -113,8 +113,9 @@ const (
SettingKeyDocURL
=
"doc_url"
// 文档链接
SettingKeyHomeContent
=
"home_content"
// 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
SettingKeyHideCcsImportButton
=
"hide_ccs_import_button"
// 是否隐藏 API Keys 页面的导入 CCS 按钮
SettingKeyPurchaseSubscriptionEnabled
=
"purchase_subscription_enabled"
// 是否展示“购买订阅”页面入口
SettingKeyPurchaseSubscriptionURL
=
"purchase_subscription_url"
// “购买订阅”页面 URL(作为 iframe src)
SettingKeyPurchaseSubscriptionEnabled
=
"purchase_subscription_enabled"
// 是否展示"购买订阅"页面入口
SettingKeyPurchaseSubscriptionURL
=
"purchase_subscription_url"
// "购买订阅"页面 URL(作为 iframe src)
SettingKeyCustomMenuItems
=
"custom_menu_items"
// 自定义菜单项(JSON 数组)
// 默认配置
SettingKeyDefaultConcurrency
=
"default_concurrency"
// 新用户默认并发量
...
...
backend/internal/service/setting_service.go
View file @
405829dc
...
...
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"sync/atomic"
...
...
@@ -124,6 +125,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyPurchaseSubscriptionEnabled
,
SettingKeyPurchaseSubscriptionURL
,
SettingKeySoraClientEnabled
,
SettingKeyCustomMenuItems
,
SettingKeyLinuxDoConnectEnabled
,
}
...
...
@@ -163,6 +165,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PurchaseSubscriptionEnabled
:
settings
[
SettingKeyPurchaseSubscriptionEnabled
]
==
"true"
,
PurchaseSubscriptionURL
:
strings
.
TrimSpace
(
settings
[
SettingKeyPurchaseSubscriptionURL
]),
SoraClientEnabled
:
settings
[
SettingKeySoraClientEnabled
]
==
"true"
,
CustomMenuItems
:
settings
[
SettingKeyCustomMenuItems
],
LinuxDoOAuthEnabled
:
linuxDoEnabled
,
},
nil
}
...
...
@@ -193,27 +196,28 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
// Return a struct that matches the frontend's expected format
return
&
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
InvitationCodeEnabled
bool
`json:"invitation_code_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key,omitempty"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo,omitempty"`
SiteSubtitle
string
`json:"site_subtitle,omitempty"`
APIBaseURL
string
`json:"api_base_url,omitempty"`
ContactInfo
string
`json:"contact_info,omitempty"`
DocURL
string
`json:"doc_url,omitempty"`
HomeContent
string
`json:"home_content,omitempty"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url,omitempty"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
Version
string
`json:"version,omitempty"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
InvitationCodeEnabled
bool
`json:"invitation_code_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key,omitempty"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo,omitempty"`
SiteSubtitle
string
`json:"site_subtitle,omitempty"`
APIBaseURL
string
`json:"api_base_url,omitempty"`
ContactInfo
string
`json:"contact_info,omitempty"`
DocURL
string
`json:"doc_url,omitempty"`
HomeContent
string
`json:"home_content,omitempty"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url,omitempty"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
CustomMenuItems
json
.
RawMessage
`json:"custom_menu_items"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
Version
string
`json:"version,omitempty"`
}{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
...
...
@@ -234,11 +238,119 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
SoraClientEnabled
:
settings
.
SoraClientEnabled
,
CustomMenuItems
:
filterUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
Version
:
s
.
version
,
},
nil
}
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
// array string, returning only items with visibility != "admin".
func
filterUserVisibleMenuItems
(
raw
string
)
json
.
RawMessage
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
||
raw
==
"[]"
{
return
json
.
RawMessage
(
"[]"
)
}
var
items
[]
struct
{
Visibility
string
`json:"visibility"`
}
if
err
:=
json
.
Unmarshal
([]
byte
(
raw
),
&
items
);
err
!=
nil
{
return
json
.
RawMessage
(
"[]"
)
}
// Parse full items to preserve all fields
var
fullItems
[]
json
.
RawMessage
if
err
:=
json
.
Unmarshal
([]
byte
(
raw
),
&
fullItems
);
err
!=
nil
{
return
json
.
RawMessage
(
"[]"
)
}
var
filtered
[]
json
.
RawMessage
for
i
,
item
:=
range
items
{
if
item
.
Visibility
!=
"admin"
{
filtered
=
append
(
filtered
,
fullItems
[
i
])
}
}
if
len
(
filtered
)
==
0
{
return
json
.
RawMessage
(
"[]"
)
}
result
,
err
:=
json
.
Marshal
(
filtered
)
if
err
!=
nil
{
return
json
.
RawMessage
(
"[]"
)
}
return
result
}
// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url
// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
func
(
s
*
SettingService
)
GetFrameSrcOrigins
(
ctx
context
.
Context
)
([]
string
,
error
)
{
settings
,
err
:=
s
.
GetPublicSettings
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
seen
:=
make
(
map
[
string
]
struct
{})
var
origins
[]
string
addOrigin
:=
func
(
rawURL
string
)
{
if
origin
:=
extractOriginFromURL
(
rawURL
);
origin
!=
""
{
if
_
,
ok
:=
seen
[
origin
];
!
ok
{
seen
[
origin
]
=
struct
{}{}
origins
=
append
(
origins
,
origin
)
}
}
}
// purchase subscription URL
if
settings
.
PurchaseSubscriptionEnabled
{
addOrigin
(
settings
.
PurchaseSubscriptionURL
)
}
// all custom menu items (including admin-only, since CSP must allow all iframes)
for
_
,
item
:=
range
parseCustomMenuItemURLs
(
settings
.
CustomMenuItems
)
{
addOrigin
(
item
)
}
return
origins
,
nil
}
// extractOriginFromURL returns the scheme+host origin from rawURL.
// Only http and https schemes are accepted.
func
extractOriginFromURL
(
rawURL
string
)
string
{
rawURL
=
strings
.
TrimSpace
(
rawURL
)
if
rawURL
==
""
{
return
""
}
u
,
err
:=
url
.
Parse
(
rawURL
)
if
err
!=
nil
||
u
.
Host
==
""
{
return
""
}
if
u
.
Scheme
!=
"http"
&&
u
.
Scheme
!=
"https"
{
return
""
}
return
u
.
Scheme
+
"://"
+
u
.
Host
}
// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
func
parseCustomMenuItemURLs
(
raw
string
)
[]
string
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
||
raw
==
"[]"
{
return
nil
}
var
items
[]
struct
{
URL
string
`json:"url"`
}
if
err
:=
json
.
Unmarshal
([]
byte
(
raw
),
&
items
);
err
!=
nil
{
return
nil
}
urls
:=
make
([]
string
,
0
,
len
(
items
))
for
_
,
item
:=
range
items
{
if
item
.
URL
!=
""
{
urls
=
append
(
urls
,
item
.
URL
)
}
}
return
urls
}
// UpdateSettings 更新系统设置
func
(
s
*
SettingService
)
UpdateSettings
(
ctx
context
.
Context
,
settings
*
SystemSettings
)
error
{
if
err
:=
s
.
validateDefaultSubscriptionGroups
(
ctx
,
settings
.
DefaultSubscriptions
);
err
!=
nil
{
...
...
@@ -293,6 +405,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyPurchaseSubscriptionEnabled
]
=
strconv
.
FormatBool
(
settings
.
PurchaseSubscriptionEnabled
)
updates
[
SettingKeyPurchaseSubscriptionURL
]
=
strings
.
TrimSpace
(
settings
.
PurchaseSubscriptionURL
)
updates
[
SettingKeySoraClientEnabled
]
=
strconv
.
FormatBool
(
settings
.
SoraClientEnabled
)
updates
[
SettingKeyCustomMenuItems
]
=
settings
.
CustomMenuItems
// 默认配置
updates
[
SettingKeyDefaultConcurrency
]
=
strconv
.
Itoa
(
settings
.
DefaultConcurrency
)
...
...
@@ -509,6 +622,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyPurchaseSubscriptionEnabled
:
"false"
,
SettingKeyPurchaseSubscriptionURL
:
""
,
SettingKeySoraClientEnabled
:
"false"
,
SettingKeyCustomMenuItems
:
"[]"
,
SettingKeyDefaultConcurrency
:
strconv
.
Itoa
(
s
.
cfg
.
Default
.
UserConcurrency
),
SettingKeyDefaultBalance
:
strconv
.
FormatFloat
(
s
.
cfg
.
Default
.
UserBalance
,
'f'
,
8
,
64
),
SettingKeyDefaultSubscriptions
:
"[]"
,
...
...
@@ -567,6 +681,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
PurchaseSubscriptionEnabled
:
settings
[
SettingKeyPurchaseSubscriptionEnabled
]
==
"true"
,
PurchaseSubscriptionURL
:
strings
.
TrimSpace
(
settings
[
SettingKeyPurchaseSubscriptionURL
]),
SoraClientEnabled
:
settings
[
SettingKeySoraClientEnabled
]
==
"true"
,
CustomMenuItems
:
settings
[
SettingKeyCustomMenuItems
],
}
// 解析整数类型
...
...
backend/internal/service/settings_view.go
View file @
405829dc
...
...
@@ -40,6 +40,7 @@ type SystemSettings struct {
PurchaseSubscriptionEnabled
bool
PurchaseSubscriptionURL
string
SoraClientEnabled
bool
CustomMenuItems
string
// JSON array of custom menu items
DefaultConcurrency
int
DefaultBalance
float64
...
...
@@ -92,6 +93,7 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled
bool
PurchaseSubscriptionURL
string
SoraClientEnabled
bool
CustomMenuItems
string
// JSON array of custom menu items
LinuxDoOAuthEnabled
bool
Version
string
...
...
frontend/src/api/admin/settings.ts
View file @
405829dc
...
...
@@ -4,6 +4,7 @@
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
CustomMenuItem
}
from
'
@/types
'
export
interface
DefaultSubscriptionSetting
{
group_id
:
number
...
...
@@ -38,6 +39,7 @@ export interface SystemSettings {
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
sora_client_enabled
:
boolean
custom_menu_items
:
CustomMenuItem
[]
// SMTP settings
smtp_host
:
string
smtp_port
:
number
...
...
@@ -99,6 +101,7 @@ export interface UpdateSettingsRequest {
purchase_subscription_enabled
?:
boolean
purchase_subscription_url
?:
string
sora_client_enabled
?:
boolean
custom_menu_items
?:
CustomMenuItem
[]
smtp_host
?:
string
smtp_port
?:
number
smtp_username
?:
string
...
...
frontend/src/components/common/ImageUpload.vue
0 → 100644
View file @
405829dc
<
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
>
frontend/src/components/layout/AppHeader.vue
View file @
405829dc
...
...
@@ -254,6 +254,13 @@ const displayName = computed(() => {
})
const
pageTitle
=
computed
(()
=>
{
// For custom pages, use the menu item's label instead of generic "自定义页面"
if
(
route
.
name
===
'
CustomPage
'
)
{
const
id
=
route
.
params
.
id
as
string
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
const
menuItem
=
items
.
find
((
item
)
=>
item
.
id
===
id
)
if
(
menuItem
?.
label
)
return
menuItem
.
label
}
const
titleKey
=
route
.
meta
.
titleKey
as
string
if
(
titleKey
)
{
return
t
(
titleKey
)
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
405829dc
...
...
@@ -47,7 +47,8 @@
"
@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"
>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
...
...
@@ -71,7 +72,8 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@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"
>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
...
...
@@ -92,7 +94,8 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@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"
>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
...
...
@@ -149,6 +152,15 @@ import { useRoute } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAdminSettingsStore
,
useAppStore
,
useAuthStore
,
useOnboardingStore
}
from
'
@/stores
'
import
VersionBadge
from
'
@/components/common/VersionBadge.vue
'
import
{
sanitizeSvg
}
from
'
@/utils/sanitize
'
interface
NavItem
{
path
:
string
label
:
string
icon
:
unknown
iconSvg
?:
string
hideInSimpleMode
?:
boolean
}
const
{
t
}
=
useI18n
()
...
...
@@ -496,8 +508,8 @@ const ChevronDoubleRightIcon = {
}
// User navigation items (for regular users)
const
userNavItems
=
computed
(()
=>
{
const
items
=
[
const
userNavItems
=
computed
(()
:
NavItem
[]
=>
{
const
items
:
NavItem
[]
=
[
{
path
:
'
/dashboard
'
,
label
:
t
(
'
nav.dashboard
'
),
icon
:
DashboardIcon
},
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
...
...
@@ -516,14 +528,20 @@ const userNavItems = computed(() => {
]
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
},
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
path
:
`/custom/
${
item
.
id
}
`
,
label
:
item
.
label
,
icon
:
null
,
iconSvg
:
item
.
icon_svg
,
})),
]
return
authStore
.
isSimpleMode
?
items
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
:
items
})
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const
personalNavItems
=
computed
(()
=>
{
const
items
=
[
const
personalNavItems
=
computed
(()
:
NavItem
[]
=>
{
const
items
:
NavItem
[]
=
[
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...
...
@@ -541,14 +559,35 @@ const personalNavItems = computed(() => {
]
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
},
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
path
:
`/custom/
${
item
.
id
}
`
,
label
:
item
.
label
,
icon
:
null
,
iconSvg
:
item
.
icon_svg
,
})),
]
return
authStore
.
isSimpleMode
?
items
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
:
items
})
// Custom menu items filtered by visibility
const
customMenuItemsForUser
=
computed
(()
=>
{
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
return
items
.
filter
((
item
)
=>
item
.
visibility
===
'
user
'
)
.
sort
((
a
,
b
)
=>
a
.
sort_order
-
b
.
sort_order
)
})
const
customMenuItemsForAdmin
=
computed
(()
=>
{
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
return
items
.
filter
((
item
)
=>
item
.
visibility
===
'
admin
'
)
.
sort
((
a
,
b
)
=>
a
.
sort_order
-
b
.
sort_order
)
})
// Admin navigation items
const
adminNavItems
=
computed
(()
=>
{
const
baseItems
=
[
const
adminNavItems
=
computed
(()
:
NavItem
[]
=>
{
const
baseItems
:
NavItem
[]
=
[
{
path
:
'
/admin/dashboard
'
,
label
:
t
(
'
nav.dashboard
'
),
icon
:
DashboardIcon
},
...(
adminSettingsStore
.
opsMonitoringEnabled
?
[{
path
:
'
/admin/ops
'
,
label
:
t
(
'
nav.ops
'
),
icon
:
ChartIcon
}]
...
...
@@ -570,11 +609,19 @@ const adminNavItems = computed(() => {
filtered
.
push
({
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
})
filtered
.
push
({
path
:
'
/admin/data-management
'
,
label
:
t
(
'
nav.dataManagement
'
),
icon
:
DatabaseIcon
})
filtered
.
push
({
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
})
// Add admin custom menu items after settings
for
(
const
cm
of
customMenuItemsForAdmin
.
value
)
{
filtered
.
push
({
path
:
`/custom/
${
cm
.
id
}
`
,
label
:
cm
.
label
,
icon
:
null
,
iconSvg
:
cm
.
icon_svg
})
}
return
filtered
}
baseItems
.
push
({
path
:
'
/admin/data-management
'
,
label
:
t
(
'
nav.dataManagement
'
),
icon
:
DatabaseIcon
})
baseItems
.
push
({
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
})
// Add admin custom menu items after settings
for
(
const
cm
of
customMenuItemsForAdmin
.
value
)
{
baseItems
.
push
({
path
:
`/custom/
${
cm
.
id
}
`
,
label
:
cm
.
label
,
icon
:
null
,
iconSvg
:
cm
.
icon_svg
})
}
return
baseItems
})
...
...
@@ -654,4 +701,12 @@ onMounted(() => {
.fade-leave-to
{
opacity
:
0
;
}
/* Custom SVG icon in sidebar: inherit color, constrain size */
.sidebar-svg-icon
:deep
(
svg
)
{
width
:
1.25rem
;
height
:
1.25rem
;
stroke
:
currentColor
;
fill
:
none
;
}
</
style
>
frontend/src/i18n/locales/en.ts
View file @
405829dc
...
...
@@ -3625,6 +3625,27 @@ export default {
enabled
:
'
Enable Sora Client
'
,
enabledHint
:
'
When enabled, the Sora entry will be shown in the sidebar for users to access Sora features
'
},
customMenu
:
{
title
:
'
Custom Menu Pages
'
,
description
:
'
Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.
'
,
itemLabel
:
'
Menu Item #{n}
'
,
name
:
'
Menu Name
'
,
namePlaceholder
:
'
e.g. Help Center
'
,
url
:
'
Page URL
'
,
urlPlaceholder
:
'
https://example.com/page
'
,
iconSvg
:
'
SVG Icon
'
,
iconSvgPlaceholder
:
'
<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
:
{
title
:
'
SMTP Settings
'
,
description
:
'
Configure email sending for verification codes
'
,
...
...
@@ -3913,6 +3934,16 @@ export default {
'
The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.
'
},
// Custom Page (iframe embed)
customPage
:
{
title
:
'
Custom Page
'
,
openInNewTab
:
'
Open in new tab
'
,
notFoundTitle
:
'
Page not found
'
,
notFoundDesc
:
'
This custom page does not exist or has been removed.
'
,
notConfiguredTitle
:
'
Page URL not configured
'
,
notConfiguredDesc
:
'
The URL for this custom page has not been properly configured.
'
,
},
// Announcements Page
announcements
:
{
title
:
'
Announcements
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
405829dc
...
...
@@ -3795,6 +3795,27 @@ export default {
enabled
:
'
启用 Sora 客户端
'
,
enabledHint
:
'
开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能
'
},
customMenu
:
{
title
:
'
自定义菜单页面
'
,
description
:
'
添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。
'
,
itemLabel
:
'
菜单项 #{n}
'
,
name
:
'
菜单名称
'
,
namePlaceholder
:
'
如:帮助中心
'
,
url
:
'
页面 URL
'
,
urlPlaceholder
:
'
https://example.com/page
'
,
iconSvg
:
'
SVG 图标
'
,
iconSvgPlaceholder
:
'
<svg>...</svg>
'
,
iconPreview
:
'
图标预览
'
,
uploadSvg
:
'
上传 SVG
'
,
removeSvg
:
'
清除
'
,
visibility
:
'
可见角色
'
,
visibilityUser
:
'
普通用户
'
,
visibilityAdmin
:
'
管理员
'
,
add
:
'
添加菜单项
'
,
remove
:
'
删除
'
,
moveUp
:
'
上移
'
,
moveDown
:
'
下移
'
,
},
smtp
:
{
title
:
'
SMTP 设置
'
,
description
:
'
配置用于发送验证码的邮件服务
'
,
...
...
@@ -4081,6 +4102,16 @@ export default {
notConfiguredDesc
:
'
管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。
'
},
// Custom Page (iframe embed)
customPage
:
{
title
:
'
自定义页面
'
,
openInNewTab
:
'
新窗口打开
'
,
notFoundTitle
:
'
页面不存在
'
,
notFoundDesc
:
'
该自定义页面不存在或已被删除。
'
,
notConfiguredTitle
:
'
页面链接未配置
'
,
notConfiguredDesc
:
'
该自定义页面的 URL 未正确配置。
'
,
},
// Announcements Page
announcements
:
{
title
:
'
公告
'
,
...
...
frontend/src/router/index.ts
View file @
405829dc
...
...
@@ -203,6 +203,17 @@ const routes: RouteRecordRaw[] = [
descriptionKey
:
'
sora.description
'
}
},
{
path
:
'
/custom/:id
'
,
name
:
'
CustomPage
'
,
component
:
()
=>
import
(
'
@/views/user/CustomPageView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
Custom Page
'
,
titleKey
:
'
customPage.title
'
,
}
},
// ==================== Admin Routes ====================
{
...
...
@@ -417,7 +428,20 @@ router.beforeEach((to, _from, next) => {
// Set page title
const
appStore
=
useAppStore
()
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
,
to
.
meta
.
titleKey
as
string
)
// For custom pages, use menu item label as document title
if
(
to
.
name
===
'
CustomPage
'
)
{
const
id
=
to
.
params
.
id
as
string
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
const
menuItem
=
items
.
find
((
item
)
=>
item
.
id
===
id
)
if
(
menuItem
?.
label
)
{
const
siteName
=
appStore
.
siteName
||
'
Sub2API
'
document
.
title
=
`
${
menuItem
.
label
}
-
${
siteName
}
`
}
else
{
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
,
to
.
meta
.
titleKey
as
string
)
}
}
else
{
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
,
to
.
meta
.
titleKey
as
string
)
}
// Check if route requires authentication
const
requiresAuth
=
to
.
meta
.
requiresAuth
!==
false
// Default to true
...
...
frontend/src/stores/app.ts
View file @
405829dc
...
...
@@ -327,6 +327,7 @@ export const useAppStore = defineStore('app', () => {
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
custom_menu_items
:
[],
linuxdo_oauth_enabled
:
false
,
sora_client_enabled
:
false
,
version
:
siteVersion
.
value
...
...
frontend/src/types/index.ts
View file @
405829dc
...
...
@@ -75,6 +75,15 @@ export interface SendVerifyCodeResponse {
countdown
:
number
}
export
interface
CustomMenuItem
{
id
:
string
label
:
string
icon_svg
:
string
url
:
string
visibility
:
'
user
'
|
'
admin
'
sort_order
:
number
}
export
interface
PublicSettings
{
registration_enabled
:
boolean
email_verify_enabled
:
boolean
...
...
@@ -93,6 +102,7 @@ export interface PublicSettings {
hide_ccs_import_button
:
boolean
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
custom_menu_items
:
CustomMenuItem
[]
linuxdo_oauth_enabled
:
boolean
sora_client_enabled
:
boolean
version
:
string
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment