fix: custom menu security hardening and code quality improvements
- Add admin menu permission check in CustomPageView (visibility + role) - Sanitize SVG content with DOMPurify before v-html rendering (XSS prevention) - Decouple router.go from dto package using anonymous struct - Consolidate duplicate parseCustomMenuItems into dto.ParseCustomMenuItems - Enhance menu item validation (count, length, ID uniqueness limits) - Add audit logging for purchase_subscription and custom_menu_items changes - Update API contract test to include custom_menu_items field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,20 +30,6 @@ func generateMenuItemID() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
|
|
||||||
// Returns empty slice on empty/invalid input.
|
|
||||||
func parseCustomMenuItems(raw string) []dto.CustomMenuItem {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" || raw == "[]" {
|
|
||||||
return []dto.CustomMenuItem{}
|
|
||||||
}
|
|
||||||
var items []dto.CustomMenuItem
|
|
||||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
||||||
return []dto.CustomMenuItem{}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingHandler 系统设置处理器
|
// SettingHandler 系统设置处理器
|
||||||
type SettingHandler struct {
|
type SettingHandler struct {
|
||||||
settingService *service.SettingService
|
settingService *service.SettingService
|
||||||
@@ -116,7 +102,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: parseCustomMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
|
||||||
DefaultConcurrency: settings.DefaultConcurrency,
|
DefaultConcurrency: settings.DefaultConcurrency,
|
||||||
DefaultBalance: settings.DefaultBalance,
|
DefaultBalance: settings.DefaultBalance,
|
||||||
DefaultSubscriptions: defaultSubscriptions,
|
DefaultSubscriptions: defaultSubscriptions,
|
||||||
@@ -166,17 +152,17 @@ 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"`
|
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
@@ -326,18 +312,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自定义菜单项验证
|
// 自定义菜单项验证
|
||||||
|
const (
|
||||||
|
maxCustomMenuItems = 20
|
||||||
|
maxMenuItemLabelLen = 50
|
||||||
|
maxMenuItemURLLen = 2048
|
||||||
|
maxMenuItemIconSVGLen = 10 * 1024 // 10KB
|
||||||
|
maxMenuItemIDLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
customMenuJSON := previousSettings.CustomMenuItems
|
customMenuJSON := previousSettings.CustomMenuItems
|
||||||
if req.CustomMenuItems != nil {
|
if req.CustomMenuItems != nil {
|
||||||
items := *req.CustomMenuItems
|
items := *req.CustomMenuItems
|
||||||
|
if len(items) > maxCustomMenuItems {
|
||||||
|
response.BadRequest(c, "Too many custom menu items (max 20)")
|
||||||
|
return
|
||||||
|
}
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if strings.TrimSpace(item.Label) == "" {
|
if strings.TrimSpace(item.Label) == "" {
|
||||||
response.BadRequest(c, "Custom menu item label is required")
|
response.BadRequest(c, "Custom menu item label is required")
|
||||||
return
|
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) == "" {
|
if strings.TrimSpace(item.URL) == "" {
|
||||||
response.BadRequest(c, "Custom menu item URL is required")
|
response.BadRequest(c, "Custom menu item URL is required")
|
||||||
return
|
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 {
|
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
|
||||||
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
|
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
|
||||||
return
|
return
|
||||||
@@ -346,11 +352,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
||||||
return
|
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
|
// Auto-generate ID if missing
|
||||||
if strings.TrimSpace(item.ID) == "" {
|
if strings.TrimSpace(item.ID) == "" {
|
||||||
items[i].ID = generateMenuItemID()
|
items[i].ID = generateMenuItemID()
|
||||||
|
} else if len(item.ID) > maxMenuItemIDLen {
|
||||||
|
response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)")
|
||||||
|
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)
|
menuBytes, err := json.Marshal(items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Failed to serialize custom menu items")
|
response.BadRequest(c, "Failed to serialize custom menu items")
|
||||||
@@ -510,7 +532,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: parseCustomMenuItems(updatedSettings.CustomMenuItems),
|
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
|
||||||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||||||
DefaultBalance: updatedSettings.DefaultBalance,
|
DefaultBalance: updatedSettings.DefaultBalance,
|
||||||
DefaultSubscriptions: updatedDefaultSubscriptions,
|
DefaultSubscriptions: updatedDefaultSubscriptions,
|
||||||
@@ -674,6 +696,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
|
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
|
||||||
changed = append(changed, "min_claude_code_version")
|
changed = append(changed, "min_claude_code_version")
|
||||||
}
|
}
|
||||||
|
if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled {
|
||||||
|
changed = append(changed, "purchase_subscription_enabled")
|
||||||
|
}
|
||||||
|
if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
|
||||||
|
changed = append(changed, "purchase_subscription_url")
|
||||||
|
}
|
||||||
|
if before.CustomMenuItems != after.CustomMenuItems {
|
||||||
|
changed = append(changed, "custom_menu_items")
|
||||||
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// CustomMenuItem represents a user-configured custom menu entry.
|
// CustomMenuItem represents a user-configured custom menu entry.
|
||||||
type CustomMenuItem struct {
|
type CustomMenuItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -37,17 +42,17 @@ 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"`
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
|
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
@@ -80,28 +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"`
|
||||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
|
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
|
||||||
@@ -150,3 +155,17 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -53,22 +50,9 @@ 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: parsePublicCustomMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
SoraClientEnabled: settings.SoraClientEnabled,
|
SoraClientEnabled: settings.SoraClientEnabled,
|
||||||
Version: h.version,
|
Version: h.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePublicCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
|
|
||||||
func parsePublicCustomMenuItems(raw string) []dto.CustomMenuItem {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" || raw == "[]" {
|
|
||||||
return []dto.CustomMenuItem{}
|
|
||||||
}
|
|
||||||
var items []dto.CustomMenuItem
|
|
||||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
|
||||||
return []dto.CustomMenuItem{}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
v-if="mode === 'svg' && modelValue"
|
v-if="mode === 'svg' && modelValue"
|
||||||
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
|
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
|
||||||
:class="innerSizeClass"
|
:class="innerSizeClass"
|
||||||
v-html="modelValue"
|
v-html="sanitizedValue"
|
||||||
></span>
|
></span>
|
||||||
<!-- Image mode: show as img -->
|
<!-- Image mode: show as img -->
|
||||||
<img
|
<img
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { sanitizeSvg } from '@/utils/sanitize'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
@@ -97,6 +98,10 @@ const error = ref('')
|
|||||||
|
|
||||||
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
|
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 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 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')
|
const placeholderSizeClass = computed(() => props.size === 'sm' ? 'h-5 w-5' : 'h-8 w-8')
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"
|
"
|
||||||
@click="handleMenuItemClick(item.path)"
|
@click="handleMenuItemClick(item.path)"
|
||||||
>
|
>
|
||||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
|
<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" />
|
<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>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
: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)"
|
||||||
>
|
>
|
||||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
|
<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" />
|
<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>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
: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)"
|
||||||
>
|
>
|
||||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
|
<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" />
|
<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>
|
||||||
@@ -152,6 +152,7 @@ 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 {
|
interface NavItem {
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
6
frontend/src/utils/sanitize.ts
Normal file
6
frontend/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
export function sanitizeSvg(svg: string): string {
|
||||||
|
if (!svg) return ''
|
||||||
|
return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
|
||||||
|
}
|
||||||
@@ -87,7 +87,11 @@ const menuItemId = computed(() => route.params.id as string)
|
|||||||
|
|
||||||
const menuItem = computed(() => {
|
const menuItem = computed(() => {
|
||||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||||
return items.find((item) => item.id === menuItemId.value) ?? null
|
const found = items.find((item) => item.id === menuItemId.value) ?? null
|
||||||
|
if (found && found.visibility === 'admin' && !authStore.isAdmin) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return found
|
||||||
})
|
})
|
||||||
|
|
||||||
const embeddedUrl = computed(() => {
|
const embeddedUrl = computed(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user