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