From 5dc62785f43d53883b81eaf32c056aa78085d64e Mon Sep 17 00:00:00 2001 From: thedavidweng <95214375+thedavidweng@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:50:56 +0000 Subject: [PATCH] feat: add theme system for CSS-based UI customization Allow admins to install and activate themes to customize the sub2api frontend appearance via CSS overrides. Themes are distributed as zip packages or imported from GitHub repositories, similar to Komari Monitor. Backend: - Add ThemeService for theme lifecycle management (install, activate, delete, config) with CSS sanitization and path traversal protection - Add admin API endpoints under /api/v1/admin/themes for CRUD operations - Add public /api/v1/themes/assets route for serving theme static files - Inject theme CSS and config CSS variables into index.html before alongside existing window.__APP_CONFIG__ injection - Wire ThemeService into FrontendServer with cache invalidation callback Frontend: - Add ThemeManager component with drag-drop zip upload, GitHub URL install, theme grid, config editor, and delete confirmation - Add 'Theme' tab to admin settings page between Features and Security - Add i18n translations for zh and en locales Theme package format: - sub2api-theme.json: metadata (name, short, version, author) + config schema - style.css: main CSS file that overrides design tokens and component styles - Optional: preview.png, fonts/, images/, additional CSS files Security measures: - CSS sanitization (strip expression(), -moz-binding, external url()) - File type whitelist (.css, .woff2, .png, .svg, etc.) - Size limits (10MB zip, 512KB CSS, 20MB total extracted) - Path traversal prevention via short ID validation + filepath.Clean --- backend/cmd/server/wire_gen.go | 9 +- .../internal/handler/admin/theme_handler.go | 152 +++ backend/internal/handler/handler.go | 2 + .../internal/handler/theme_asset_handler.go | 42 + backend/internal/handler/wire.go | 6 + backend/internal/server/http.go | 3 +- backend/internal/server/router.go | 12 +- backend/internal/server/routes/admin.go | 18 + backend/internal/server/routes/theme.go | 15 + backend/internal/service/theme_service.go | 796 +++++++++++ .../internal/service/theme_service_test.go | 1173 +++++++++++++++++ backend/internal/service/wire.go | 1 + backend/internal/web/embed_off.go | 9 +- backend/internal/web/embed_on.go | 42 +- backend/internal/web/embed_test.go | 40 +- frontend/src/api/admin/index.ts | 7 +- frontend/src/api/admin/themes.ts | 75 ++ .../src/components/admin/ThemeManager.vue | 448 +++++++ frontend/src/components/icons/Icon.vue | 3 +- frontend/src/i18n/locales/en.ts | 21 + frontend/src/i18n/locales/zh.ts | 21 + frontend/src/views/admin/SettingsView.vue | 8 + 22 files changed, 2869 insertions(+), 34 deletions(-) create mode 100644 backend/internal/handler/admin/theme_handler.go create mode 100644 backend/internal/handler/theme_asset_handler.go create mode 100644 backend/internal/server/routes/theme.go create mode 100644 backend/internal/service/theme_service.go create mode 100644 backend/internal/service/theme_service_test.go create mode 100644 frontend/src/api/admin/themes.ts create mode 100644 frontend/src/components/admin/ThemeManager.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 106432158a7..8e2e262c72d 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -239,7 +239,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { contentModerationHandler := admin.NewContentModerationHandler(contentModerationService) paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler) + themeService := service.NewThemeService(settingRepository) + themeHandler := admin.NewThemeHandler(themeService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, themeHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -250,13 +252,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) + themeAssetHandler := handler.NewThemeAssetHandler(themeService) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, themeAssetHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) - engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient) + engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, themeService, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) diff --git a/backend/internal/handler/admin/theme_handler.go b/backend/internal/handler/admin/theme_handler.go new file mode 100644 index 00000000000..10f1d1bec4f --- /dev/null +++ b/backend/internal/handler/admin/theme_handler.go @@ -0,0 +1,152 @@ +package admin + +import ( + "io" + "net/http" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// ThemeHandler handles admin theme management endpoints +type ThemeHandler struct { + themeService *service.ThemeService +} + +// NewThemeHandler creates a new ThemeHandler +func NewThemeHandler(themeService *service.ThemeService) *ThemeHandler { + return &ThemeHandler{themeService: themeService} +} + +// List returns all installed themes +// GET /api/v1/admin/themes +func (h *ThemeHandler) List(c *gin.Context) { + themes, err := h.themeService.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"themes": themes}) +} + +// Get returns a specific theme +// GET /api/v1/admin/themes/:short +func (h *ThemeHandler) Get(c *gin.Context) { + short := c.Param("short") + theme, err := h.themeService.Get(c.Request.Context(), short) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, theme) +} + +// GetActive returns the currently active theme +// GET /api/v1/admin/themes/active +func (h *ThemeHandler) GetActive(c *gin.Context) { + theme, err := h.themeService.GetActiveTheme(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if theme == nil { + c.JSON(http.StatusOK, gin.H{"active": false}) + return + } + c.JSON(http.StatusOK, gin.H{"active": true, "theme": theme}) +} + +// Install handles theme installation from a zip upload +// POST /api/v1/admin/themes/install +func (h *ThemeHandler) Install(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.ThemeMaxZipSize) + + file, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing or invalid file field"}) + return + } + defer file.Close() + + zipData, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file: " + err.Error()}) + return + } + + theme, err := h.themeService.InstallFromZip(c.Request.Context(), zipData) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, theme) +} + +// InstallFromGitHub handles theme installation from a GitHub URL +// POST /api/v1/admin/themes/install-github +func (h *ThemeHandler) InstallFromGitHub(c *gin.Context) { + var req struct { + URL string `json:"url" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing url field"}) + return + } + + theme, err := h.themeService.InstallFromGitHub(c.Request.Context(), req.URL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, theme) +} + +// Activate activates a theme +// POST /api/v1/admin/themes/:short/activate +func (h *ThemeHandler) Activate(c *gin.Context) { + short := c.Param("short") + if err := h.themeService.Activate(c.Request.Context(), short); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "theme activated"}) +} + +// Deactivate deactivates the current theme +// POST /api/v1/admin/themes/deactivate +func (h *ThemeHandler) Deactivate(c *gin.Context) { + if err := h.themeService.Deactivate(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "theme deactivated"}) +} + +// Delete removes a theme +// DELETE /api/v1/admin/themes/:short +func (h *ThemeHandler) Delete(c *gin.Context) { + short := c.Param("short") + if err := h.themeService.Delete(c.Request.Context(), short); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "theme deleted"}) +} + +// UpdateConfig updates theme configuration +// PUT /api/v1/admin/themes/:short/config +func (h *ThemeHandler) UpdateConfig(c *gin.Context) { + short := c.Param("short") + var config map[string]string + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config format"}) + return + } + if err := h.themeService.UpdateConfig(c.Request.Context(), short, config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "config updated"}) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 308b219921e..b69c99efcd7 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -36,6 +36,7 @@ type AdminHandlers struct { ContentModeration *admin.ContentModerationHandler Payment *admin.PaymentHandler Affiliate *admin.AffiliateHandler + Theme *admin.ThemeHandler } // Handlers contains all HTTP handlers @@ -56,6 +57,7 @@ type Handlers struct { Payment *PaymentHandler PaymentWebhook *PaymentWebhookHandler AvailableChannel *AvailableChannelHandler + ThemeAsset *ThemeAssetHandler } // BuildInfo contains build-time information diff --git a/backend/internal/handler/theme_asset_handler.go b/backend/internal/handler/theme_asset_handler.go new file mode 100644 index 00000000000..4c6bcd27260 --- /dev/null +++ b/backend/internal/handler/theme_asset_handler.go @@ -0,0 +1,42 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// ThemeAssetHandler serves theme static files (CSS, fonts, images) +type ThemeAssetHandler struct { + themeService *service.ThemeService +} + +// NewThemeAssetHandler creates a new ThemeAssetHandler +func NewThemeAssetHandler(themeService *service.ThemeService) *ThemeAssetHandler { + return &ThemeAssetHandler{themeService: themeService} +} + +// ServeAsset serves a file from a theme directory +// GET /api/v1/themes/assets/:short/*filepath +func (h *ThemeAssetHandler) ServeAsset(c *gin.Context) { + short := c.Param("short") + filepath := c.Param("filepath") + + // Remove leading slash from filepath + if len(filepath) > 0 && filepath[0] == '/' { + filepath = filepath[1:] + } + + content, contentType, err := h.themeService.ServeThemeAsset(c.Request.Context(), short, filepath) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "public, max-age=86400") + c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) + c.Data(http.StatusOK, contentType, content) +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index c8c4615702e..11efbc95d68 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -39,6 +39,7 @@ func ProvideAdminHandlers( contentModerationHandler *admin.ContentModerationHandler, paymentHandler *admin.PaymentHandler, affiliateHandler *admin.AffiliateHandler, + themeHandler *admin.ThemeHandler, ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, @@ -71,6 +72,7 @@ func ProvideAdminHandlers( ContentModeration: contentModerationHandler, Payment: paymentHandler, Affiliate: affiliateHandler, + Theme: themeHandler, } } @@ -111,6 +113,7 @@ func ProvideHandlers( paymentHandler *PaymentHandler, paymentWebhookHandler *PaymentWebhookHandler, availableChannelHandler *AvailableChannelHandler, + themeAssetHandler *ThemeAssetHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, ) *Handlers { @@ -131,6 +134,7 @@ func ProvideHandlers( Payment: paymentHandler, PaymentWebhook: paymentWebhookHandler, AvailableChannel: availableChannelHandler, + ThemeAsset: themeAssetHandler, } } @@ -184,6 +188,8 @@ var ProviderSet = wire.NewSet( admin.NewContentModerationHandler, admin.NewPaymentHandler, admin.NewAffiliateHandler, + admin.NewThemeHandler, + NewThemeAssetHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index aa7888b7397..9d833298fdb 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -37,6 +37,7 @@ func ProvideRouter( subscriptionService *service.SubscriptionService, opsService *service.OpsService, settingService *service.SettingService, + themeService *service.ThemeService, redisClient *redis.Client, ) *gin.Engine { if cfg.Server.Mode == "release" { @@ -94,7 +95,7 @@ func ProvideRouter( service.SetWebSearchManager(websearch.NewManager(configs, redisClient)) }) - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, themeService, cfg, redisClient) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index f477f3a754c..9d71124f247 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -30,6 +30,7 @@ func SetupRouter( subscriptionService *service.SubscriptionService, opsService *service.OpsService, settingService *service.SettingService, + themeService *service.ThemeService, cfg *config.Config, redisClient *redis.Client, ) *gin.Engine { @@ -63,7 +64,7 @@ func SetupRouter( // Serve embedded frontend with settings injection if available if web.HasEmbeddedFrontend() { - frontendServer, err := web.NewFrontendServer(settingService) + frontendServer, err := web.NewFrontendServer(settingService, themeService) if err != nil { log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err) r.Use(web.ServeEmbeddedFrontend()) @@ -74,12 +75,21 @@ func SetupRouter( frontendServer.InvalidateCache() refreshFrameOrigins() }) + // Register theme service callback to invalidate HTML cache when active theme changes + if themeService != nil { + themeService.SetOnUpdateCallback(func() { + frontendServer.InvalidateCache() + }) + } r.Use(frontendServer.Middleware()) } } else { settingService.SetOnUpdateCallback(refreshFrameOrigins) } + // 注册主题资源路由(公开,无需认证) + routes.RegisterThemeAssetRoute(r, handlers.ThemeAsset) + // 注册路由 registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 9a3253b55dd..f45e5f8accf 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -86,6 +86,9 @@ func RegisterAdminRoutes( // 定时测试计划 registerScheduledTestRoutes(admin, h) + // 主题管理 + registerThemeRoutes(admin, h) + // 渠道管理 registerChannelRoutes(admin, h) @@ -648,3 +651,18 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } } + +func registerThemeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + themes := admin.Group("/themes") + { + themes.GET("", h.Admin.Theme.List) + themes.GET("/active", h.Admin.Theme.GetActive) + themes.POST("/install", h.Admin.Theme.Install) + themes.POST("/install-github", h.Admin.Theme.InstallFromGitHub) + themes.POST("/deactivate", h.Admin.Theme.Deactivate) + themes.GET("/:short", h.Admin.Theme.Get) + themes.POST("/:short/activate", h.Admin.Theme.Activate) + themes.DELETE("/:short", h.Admin.Theme.Delete) + themes.PUT("/:short/config", h.Admin.Theme.UpdateConfig) + } +} diff --git a/backend/internal/server/routes/theme.go b/backend/internal/server/routes/theme.go new file mode 100644 index 00000000000..6df81b2ea67 --- /dev/null +++ b/backend/internal/server/routes/theme.go @@ -0,0 +1,15 @@ +package routes + +import ( + "github.com/Wei-Shaw/sub2api/internal/handler" + + "github.com/gin-gonic/gin" +) + +// RegisterThemeAssetRoute registers the public theme asset serving route +func RegisterThemeAssetRoute(r *gin.Engine, h *handler.ThemeAssetHandler) { + if h == nil { + return + } + r.GET("/api/v1/themes/assets/:short/*filepath", h.ServeAsset) +} diff --git a/backend/internal/service/theme_service.go b/backend/internal/service/theme_service.go new file mode 100644 index 00000000000..cebf75da0c3 --- /dev/null +++ b/backend/internal/service/theme_service.go @@ -0,0 +1,796 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +// ThemeMetadata represents the parsed sub2api-theme.json +type ThemeMetadata struct { + Name string `json:"name"` + Short string `json:"short"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Preview string `json:"preview"` + MainCSS string `json:"main_css"` + Config []ThemeConfigItem `json:"config,omitempty"` +} + +// ThemeConfigItem declares a configurable option exposed to the admin UI +type ThemeConfigItem struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Default string `json:"default"` + Options []ThemeConfigOption `json:"options,omitempty"` + Description string `json:"description,omitempty"` +} + +// ThemeConfigOption is a select option +type ThemeConfigOption struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// Theme represents a fully installed theme with metadata +type Theme struct { + Metadata ThemeMetadata `json:"metadata"` + InstalledAt time.Time `json:"installed_at"` + IsActive bool `json:"is_active"` + Config map[string]string `json:"config,omitempty"` +} + +const ( + themeActiveKey = "active_theme" + themeConfigPrefix = "theme_config_" + + ThemeMaxZipSize = 10 * 1024 * 1024 // 10MB + themeMaxCSSSize = 512 * 1024 // 512KB + themeMaxAssetSize = 2 * 1024 * 1024 // 2MB per file + themeMaxTotalSize = 20 * 1024 * 1024 // 20MB total extracted +) + +var themeShortRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) + +// Pre-compiled regexes for CSS sanitization +var ( + cssCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`) + // Match url() with single-quoted, double-quoted, or unquoted argument + cssURLRe = regexp.MustCompile(`(?i)url\s*\(\s*(?:'([^']*)'|"([^"]*)"|([^)]*))\s*\)`) + cssImportRe = regexp.MustCompile(`(?i)@import\s+url\s*\(\s*(?:'([^']*)'|"([^"]*)"|([^)]*))\s*\)`) +) + +var allowedThemeExtensions = map[string]bool{ + ".css": true, + ".woff": true, + ".woff2": true, + ".ttf": true, + ".otf": true, + ".png": true, + ".jpg": true, + ".jpeg": true, + ".gif": true, + ".svg": true, + ".ico": true, + ".webp": true, + ".json": true, +} + +// ThemeService manages themes +type ThemeService struct { + settingRepo SettingRepository + dataDir string + onUpdate func() +} + +// NewThemeService creates a new ThemeService +func NewThemeService(settingRepo SettingRepository) *ThemeService { + return &ThemeService{ + settingRepo: settingRepo, + dataDir: filepath.Join("data", "themes"), + } +} + +// SetOnUpdateCallback sets the callback for when active theme changes +func (s *ThemeService) SetOnUpdateCallback(callback func()) { + s.onUpdate = callback +} + +// InstallFromZip extracts a zip archive, validates it, and installs the theme +func (s *ThemeService) InstallFromZip(ctx context.Context, zipData []byte) (*Theme, error) { + if len(zipData) > ThemeMaxZipSize { + return nil, fmt.Errorf("zip file too large (max %dMB)", ThemeMaxZipSize/1024/1024) + } + + // Read zip + reader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return nil, fmt.Errorf("invalid zip file: %w", err) + } + + // Find and parse sub2api-theme.json, extract all files to temp dir + tmpDir, err := os.MkdirTemp("", "sub2api-theme-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + var metadata *ThemeMetadata + var totalSize int64 + + for _, f := range reader.File { + // Check total size + totalSize += int64(f.UncompressedSize64) + if totalSize > themeMaxTotalSize { + return nil, fmt.Errorf("extracted content too large (max %dMB)", themeMaxTotalSize/1024/1024) + } + + // Sanitize path + cleanName := filepath.Clean(f.Name) + if strings.Contains(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.HasPrefix(cleanName, "\\") { + return nil, fmt.Errorf("invalid file path in zip: %s", f.Name) + } + + // Determine the output path - strip leading directory if present + outName := cleanName + // Some zips have a root directory (e.g., "theme-main/style.css"), strip it + parts := strings.SplitN(outName, "/", 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + outName = parts[1] + } + if outName == "" { + continue + } + + // Parse metadata + if filepath.Base(outName) == "sub2api-theme.json" { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to read theme metadata: %w", err) + } + data, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read theme metadata: %w", err) + } + var m ThemeMetadata + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("invalid theme metadata: %w", err) + } + metadata = &m + // Write metadata to temp dir + destPath := filepath.Join(tmpDir, outName) + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return nil, fmt.Errorf("failed to create dir: %w", err) + } + if err := os.WriteFile(destPath, data, 0o644); err != nil { + return nil, fmt.Errorf("failed to write metadata: %w", err) + } + continue + } + + // Validate file extension + ext := strings.ToLower(filepath.Ext(outName)) + if !allowedThemeExtensions[ext] { + return nil, fmt.Errorf("file type not allowed: %s (%s)", outName, ext) + } + + // Check individual file size + if int64(f.UncompressedSize64) > themeMaxAssetSize { + return nil, fmt.Errorf("file too large: %s (max %dMB)", outName, themeMaxAssetSize/1024/1024) + } + + // Extract file + destPath := filepath.Join(tmpDir, outName) + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return nil, fmt.Errorf("failed to create dir: %w", err) + } + + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", f.Name, err) + } + + data, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", f.Name, err) + } + + // Sanitize CSS files + if ext == ".css" { + data, err = sanitizeCSS(data) + if err != nil { + return nil, fmt.Errorf("CSS sanitization failed for %s: %w", outName, err) + } + if len(data) > themeMaxCSSSize { + return nil, fmt.Errorf("CSS file too large: %s (max %dKB)", outName, themeMaxCSSSize/1024) + } + } + + if err := os.WriteFile(destPath, data, 0o644); err != nil { + return nil, fmt.Errorf("failed to write %s: %w", outName, err) + } + } + + if metadata == nil { + return nil, fmt.Errorf("sub2api-theme.json not found in zip") + } + + // Validate metadata + if err := validateThemeMetadata(metadata); err != nil { + return nil, err + } + + // Set defaults + if metadata.MainCSS == "" { + metadata.MainCSS = "style.css" + } + + // Move from temp to final location + themeDir := filepath.Join(s.dataDir, metadata.Short) + if err := os.RemoveAll(themeDir); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to clean existing theme: %w", err) + } + if err := os.MkdirAll(s.dataDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create themes dir: %w", err) + } + if err := os.Rename(tmpDir, themeDir); err != nil { + // Fallback: copy + if err := copyDir(tmpDir, themeDir); err != nil { + return nil, fmt.Errorf("failed to install theme: %w", err) + } + } + + // Check if this theme was previously active + activeShort := s.getActiveThemeShort(ctx) + isActive := activeShort == metadata.Short + + theme := &Theme{ + Metadata: *metadata, + InstalledAt: time.Now(), + IsActive: isActive, + } + + // Load config if theme has config items and is active + if isActive && len(metadata.Config) > 0 { + theme.Config = s.getThemeConfig(ctx, metadata.Short) + } + + logger.LegacyPrintf("service.theme", "Theme installed: %s (%s)", metadata.Name, metadata.Short) + return theme, nil +} + +// InstallFromGitHub downloads a theme from a GitHub repo URL +func (s *ThemeService) InstallFromGitHub(ctx context.Context, repoURL string) (*Theme, error) { + owner, repo, err := parseGitHubURL(repoURL) + if err != nil { + return nil, err + } + + // Try main branch first, then master + for _, branch := range []string{"main", "master"} { + zipURL := fmt.Sprintf("https://github.com/%s/%s/archive/refs/heads/%s.zip", owner, repo, branch) + zipData, err := downloadFile(ctx, zipURL) + if err != nil { + continue + } + return s.InstallFromZip(ctx, zipData) + } + + return nil, fmt.Errorf("failed to download theme from GitHub (tried main and master branches)") +} + +// List returns all installed themes +func (s *ThemeService) List(ctx context.Context) ([]Theme, error) { + activeShort := s.getActiveThemeShort(ctx) + + entries, err := os.ReadDir(s.dataDir) + if err != nil { + if os.IsNotExist(err) { + return []Theme{}, nil + } + return nil, fmt.Errorf("failed to read themes directory: %w", err) + } + + var themes []Theme + for _, entry := range entries { + if !entry.IsDir() { + continue + } + theme, err := s.loadThemeFromDir(ctx, entry.Name(), activeShort) + if err != nil { + logger.LegacyPrintf("service.theme", "Warning: skipping invalid theme %s: %v", entry.Name(), err) + continue + } + themes = append(themes, *theme) + } + + return themes, nil +} + +// Get returns a specific theme by its short identifier +func (s *ThemeService) Get(ctx context.Context, short string) (*Theme, error) { + if !themeShortRegex.MatchString(short) { + return nil, fmt.Errorf("invalid theme identifier") + } + activeShort := s.getActiveThemeShort(ctx) + return s.loadThemeFromDir(ctx, short, activeShort) +} + +// GetActiveTheme returns the currently active theme, or nil if none +func (s *ThemeService) GetActiveTheme(ctx context.Context) (*Theme, error) { + short := s.getActiveThemeShort(ctx) + if short == "" { + return nil, nil + } + return s.Get(ctx, short) +} + +// Activate sets a theme as the active theme +func (s *ThemeService) Activate(ctx context.Context, short string) error { + if !themeShortRegex.MatchString(short) { + return fmt.Errorf("invalid theme identifier") + } + + // Verify theme exists + metaPath := filepath.Join(s.dataDir, short, "sub2api-theme.json") + if _, err := os.Stat(metaPath); err != nil { + return fmt.Errorf("theme not found: %s", short) + } + + if err := s.settingRepo.Set(ctx, themeActiveKey, short); err != nil { + return fmt.Errorf("failed to activate theme: %w", err) + } + + logger.LegacyPrintf("service.theme", "Theme activated: %s", short) + + if s.onUpdate != nil { + s.onUpdate() + } + return nil +} + +// Deactivate removes the active theme +func (s *ThemeService) Deactivate(ctx context.Context) error { + if err := s.settingRepo.Delete(ctx, themeActiveKey); err != nil { + return fmt.Errorf("failed to deactivate theme: %w", err) + } + + logger.LegacyPrintf("service.theme", "Theme deactivated") + + if s.onUpdate != nil { + s.onUpdate() + } + return nil +} + +// Delete removes a theme from disk +func (s *ThemeService) Delete(ctx context.Context, short string) error { + if !themeShortRegex.MatchString(short) { + return fmt.Errorf("invalid theme identifier") + } + + // Deactivate if active + activeShort := s.getActiveThemeShort(ctx) + if activeShort == short { + if err := s.Deactivate(ctx); err != nil { + return err + } + } + + themeDir := filepath.Join(s.dataDir, short) + if err := os.RemoveAll(themeDir); err != nil { + return fmt.Errorf("failed to delete theme: %w", err) + } + + // Clean up config (best effort) + _ = s.settingRepo.Delete(ctx, themeConfigPrefix+short) + + logger.LegacyPrintf("service.theme", "Theme deleted: %s", short) + return nil +} + +// UpdateConfig updates theme-specific configuration values +func (s *ThemeService) UpdateConfig(ctx context.Context, short string, config map[string]string) error { + if !themeShortRegex.MatchString(short) { + return fmt.Errorf("invalid theme identifier") + } + + data, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := s.settingRepo.Set(ctx, themeConfigPrefix+short, string(data)); err != nil { + return fmt.Errorf("failed to save theme config: %w", err) + } + + // Invalidate cache since config affects CSS variables + if s.onUpdate != nil { + s.onUpdate() + } + return nil +} + +// GetActiveThemeShort returns the short ID of the active theme +func (s *ThemeService) GetActiveThemeShort(ctx context.Context) string { + return s.getActiveThemeShort(ctx) +} + +// GetThemeConfigCSS generates CSS custom properties from the active theme's config +func (s *ThemeService) GetThemeConfigCSS(ctx context.Context) string { + short := s.getActiveThemeShort(ctx) + if short == "" { + return "" + } + + config := s.getThemeConfig(ctx, short) + if len(config) == 0 { + return "" + } + + // Load theme metadata to get config item definitions + metadata, err := s.loadMetadata(short) + if err != nil || len(metadata.Config) == 0 { + return "" + } + + var css strings.Builder + _, _ = css.WriteString(":root {\n") + for _, item := range metadata.Config { + val, ok := config[item.Key] + if !ok || val == "" { + val = item.Default + } + if val != "" { + _, _ = css.WriteString(fmt.Sprintf(" --theme-%s: %s;\n", item.Key, val)) + } + } + _, _ = css.WriteString("}\n") + return css.String() +} + +// ServeThemeAsset serves a file from a theme's directory +func (s *ThemeService) ServeThemeAsset(ctx context.Context, short, assetPath string) ([]byte, string, error) { + if !themeShortRegex.MatchString(short) { + return nil, "", fmt.Errorf("invalid theme identifier") + } + + // Clean and validate asset path + cleaned := filepath.Clean("/" + assetPath) + cleaned = strings.TrimPrefix(cleaned, "/") + if cleaned == "" || cleaned == "." { + // Default to main CSS + metadata, err := s.loadMetadata(short) + if err != nil { + return nil, "", fmt.Errorf("theme not found") + } + cleaned = metadata.MainCSS + if cleaned == "" { + cleaned = "style.css" + } + } + + // Prevent directory traversal + if strings.Contains(cleaned, "..") || strings.HasPrefix(cleaned, "/") { + return nil, "", fmt.Errorf("invalid asset path") + } + + // Resolve full path and verify it's within the theme directory + themeDir := filepath.Join(s.dataDir, short) + fullPath := filepath.Join(themeDir, cleaned) + + // Ensure resolved path is within theme directory + absThemeDir, _ := filepath.Abs(themeDir) + absFullPath, _ := filepath.Abs(fullPath) + if !strings.HasPrefix(absFullPath, absThemeDir) { + return nil, "", fmt.Errorf("path traversal detected") + } + + // Verify file exists and is not a directory + info, err := os.Stat(fullPath) + if err != nil { + return nil, "", fmt.Errorf("file not found") + } + if info.IsDir() { + return nil, "", fmt.Errorf("is a directory") + } + + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, "", fmt.Errorf("failed to read file: %w", err) + } + + contentType := mimeByExt(filepath.Ext(fullPath)) + return content, contentType, nil +} + +// --- Internal helpers --- + +func (s *ThemeService) getActiveThemeShort(ctx context.Context) string { + val, err := s.settingRepo.GetValue(ctx, themeActiveKey) + if err != nil { + return "" + } + return val +} + +func (s *ThemeService) getThemeConfig(ctx context.Context, short string) map[string]string { + val, err := s.settingRepo.GetValue(ctx, themeConfigPrefix+short) + if err != nil { + return nil + } + var config map[string]string + if err := json.Unmarshal([]byte(val), &config); err != nil { + return nil + } + return config +} + +func (s *ThemeService) loadMetadata(short string) (*ThemeMetadata, error) { + metaPath := filepath.Join(s.dataDir, short, "sub2api-theme.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return nil, err + } + var m ThemeMetadata + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} + +func (s *ThemeService) loadThemeFromDir(ctx context.Context, short, activeShort string) (*Theme, error) { + metadata, err := s.loadMetadata(short) + if err != nil { + return nil, err + } + + info, err := os.Stat(filepath.Join(s.dataDir, short)) + if err != nil { + return nil, err + } + + isActive := short == activeShort + + theme := &Theme{ + Metadata: *metadata, + InstalledAt: info.ModTime(), + IsActive: isActive, + } + + if isActive && len(metadata.Config) > 0 { + theme.Config = s.getThemeConfig(ctx, short) + } + + return theme, nil +} + +func validateThemeMetadata(m *ThemeMetadata) error { + if m.Name == "" { + return fmt.Errorf("theme name is required") + } + if len(m.Name) > 64 { + return fmt.Errorf("theme name too long (max 64 characters)") + } + if m.Short == "" { + return fmt.Errorf("theme short identifier is required") + } + if !themeShortRegex.MatchString(m.Short) { + return fmt.Errorf("invalid theme short identifier: must match [a-z0-9][a-z0-9_-]{0,31}") + } + if m.Version == "" { + return fmt.Errorf("theme version is required") + } + if len(m.Author) > 128 { + return fmt.Errorf("theme author too long (max 128 characters)") + } + if len(m.Description) > 512 { + return fmt.Errorf("theme description too long (max 512 characters)") + } + + // Validate config items + for _, item := range m.Config { + if item.Key == "" { + return fmt.Errorf("theme config item key is required") + } + if item.Label == "" { + return fmt.Errorf("theme config item label is required") + } + switch item.Type { + case "title", "color", "text", "select", "number", "boolean": + // valid + default: + return fmt.Errorf("invalid theme config type: %s", item.Type) + } + if item.Type == "select" && len(item.Options) == 0 { + return fmt.Errorf("select config item must have options") + } + } + + return nil +} + +// sanitizeCSS removes dangerous CSS constructs +func sanitizeCSS(data []byte) ([]byte, error) { + content := string(data) + + // Remove CSS comments (to prevent hiding malicious content in comments) + content = removeComments(content) + + // Check for dangerous constructs + dangerous := []string{ + "expression(", + "-moz-binding", + "behavior:", + "behavior ", + "javascript:", + "vbscript:", + "data:text/html", + } + lower := strings.ToLower(content) + for _, d := range dangerous { + if strings.Contains(lower, d) { + return nil, fmt.Errorf("dangerous CSS construct detected: %s", d) + } + } + + // Remove url() references with absolute URLs or data: URIs (except data: for fonts) + // Allow relative url() references and data:font/* + content = cssURLRe.ReplaceAllStringFunc(content, func(match string) string { + inner := cssURLRe.FindStringSubmatch(match) + if len(inner) < 4 { + return "" + } + // Pick the capture group that matched (single-quoted, double-quoted, or unquoted) + u := strings.TrimSpace(inner[1] + inner[2] + inner[3]) + lowerU := strings.ToLower(u) + + // Allow relative paths + if !strings.HasPrefix(lowerU, "http://") && + !strings.HasPrefix(lowerU, "https://") && + !strings.HasPrefix(lowerU, "data:") && + !strings.HasPrefix(lowerU, "javascript:") { + return match + } + + // Allow data: for fonts + if strings.HasPrefix(lowerU, "data:font/") || strings.HasPrefix(lowerU, "data:application/font") { + return match + } + + // Block everything else + return "" + }) + + // Remove @import with external URLs + content = cssImportRe.ReplaceAllString(content, "") + + return []byte(content), nil +} + +func removeComments(content string) string { + return cssCommentRe.ReplaceAllString(content, "") +} + +func parseGitHubURL(url string) (owner, repo string, err error) { + // Accept formats: + // https://github.com/owner/repo + // https://github.com/owner/repo/ + // github.com/owner/repo + url = strings.TrimSpace(url) + url = strings.TrimRight(url, "/") + + // Remove protocol + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + + // Remove domain + if !strings.HasPrefix(url, "github.com/") { + return "", "", fmt.Errorf("not a GitHub URL") + } + url = strings.TrimPrefix(url, "github.com/") + + parts := strings.SplitN(url, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid GitHub URL format") + } + + // Remove .git suffix + repoName := strings.TrimSuffix(parts[1], ".git") + + return parts[0], repoName, nil +} + +func downloadFile(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Limit download size + limitedReader := io.LimitReader(resp.Body, int64(ThemeMaxZipSize)) + data, err := io.ReadAll(limitedReader) + if err != nil { + return nil, err + } + + return data, nil +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + destPath := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(destPath, data, info.Mode()) + }) +} + +func mimeByExt(ext string) string { + switch strings.ToLower(ext) { + case ".css": + return "text/css; charset=utf-8" + case ".woff": + return "font/woff" + case ".woff2": + return "font/woff2" + case ".ttf": + return "font/ttf" + case ".otf": + return "font/otf" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".svg": + return "image/svg+xml" + case ".ico": + return "image/x-icon" + case ".webp": + return "image/webp" + case ".json": + return "application/json" + default: + return "application/octet-stream" + } +} diff --git a/backend/internal/service/theme_service_test.go b/backend/internal/service/theme_service_test.go new file mode 100644 index 00000000000..0336fbadd62 --- /dev/null +++ b/backend/internal/service/theme_service_test.go @@ -0,0 +1,1173 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Stub repository --- + +type themeRepoStub struct { + values map[string]string +} + +func (s *themeRepoStub) GetValue(_ context.Context, key string) (string, error) { + v, ok := s.values[key] + if !ok { + return "", nil + } + return v, nil +} + +func (s *themeRepoStub) Set(_ context.Context, key, value string) error { + if s.values == nil { + s.values = make(map[string]string) + } + s.values[key] = value + return nil +} + +func (s *themeRepoStub) Delete(_ context.Context, key string) error { + delete(s.values, key) + return nil +} + +func (s *themeRepoStub) Get(_ context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *themeRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + panic("unexpected GetMultiple call") +} + +func (s *themeRepoStub) SetMultiple(_ context.Context, settings map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *themeRepoStub) GetAll(_ context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +// --- Test helpers --- + +func newTestThemeService(t *testing.T, repo SettingRepository) *ThemeService { + t.Helper() + svc := NewThemeService(repo) + svc.dataDir = t.TempDir() + return svc +} + +func createThemeZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + for name, content := range files { + f, err := w.Create(name) + require.NoError(t, err) + _, err = f.Write([]byte(content)) + require.NoError(t, err) + } + + err := w.Close() + require.NoError(t, err) + return buf.Bytes() +} + +func validMetadata(name, short string) string { + return `{ + "name": "` + name + `", + "short": "` + short + `", + "version": "1.0.0", + "author": "Test", + "description": "A test theme", + "main_css": "style.css" + }` +} + +// --- InstallFromZip tests --- + +func TestThemeService_InstallFromZip_Success(t *testing.T) { + repo := &themeRepoStub{} + svc := newTestThemeService(t, repo) + + zipData := createThemeZip(t, map[string]string{ + "theme/sub2api-theme.json": validMetadata("Test Theme", "test"), + "theme/style.css": "body { color: red; }\n", + }) + + theme, err := svc.InstallFromZip(context.Background(), zipData) + require.NoError(t, err) + require.NotNil(t, theme) + assert.Equal(t, "Test Theme", theme.Metadata.Name) + assert.Equal(t, "test", theme.Metadata.Short) + assert.Equal(t, "1.0.0", theme.Metadata.Version) + assert.Equal(t, "Test", theme.Metadata.Author) + assert.False(t, theme.IsActive) +} + +func TestThemeService_InstallFromZip_DefaultMainCSS(t *testing.T) { + repo := &themeRepoStub{} + svc := newTestThemeService(t, repo) + + zipData := createThemeZip(t, map[string]string{ + "theme/sub2api-theme.json": `{"name":"T","short":"t","version":"1"}`, + }) + + theme, err := svc.InstallFromZip(context.Background(), zipData) + require.NoError(t, err) + assert.Equal(t, "style.css", theme.Metadata.MainCSS) +} + +func TestThemeService_InstallFromZip_ActiveOnReinstall(t *testing.T) { + repo := &themeRepoStub{} + _ = repo.Set(context.Background(), "active_theme", "mytheme") + svc := newTestThemeService(t, repo) + + zipData := createThemeZip(t, map[string]string{ + "theme/sub2api-theme.json": `{"name":"M","short":"mytheme","version":"1"}`, + }) + + theme, err := svc.InstallFromZip(context.Background(), zipData) + require.NoError(t, err) + assert.True(t, theme.IsActive) +} + +func TestThemeService_InstallFromZip_InvalidZip(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + _, err := svc.InstallFromZip(context.Background(), []byte("not a zip")) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid zip file") +} + +func TestThemeService_InstallFromZip_MissingMetadata(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + zipData := createThemeZip(t, map[string]string{ + "style.css": "body { color: red; }", + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestThemeService_InstallFromZip_InvalidJSONMetadata(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": "{invalid}", + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid theme metadata") +} + +func TestThemeService_InstallFromZip_MissingName(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": `{"short":"t","version":"1"}`, + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestThemeService_InstallFromZip_NameTooLong(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + name := strings.Repeat("a", 65) + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": `{"name":"` + name + `","short":"t","version":"1"}`, + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "name too long") +} + +func TestThemeService_InstallFromZip_InvalidShort(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + tests := []struct { + name string + short string + }{ + {"uppercase", "MY-THEME"}, + {"too_long", strings.Repeat("a", 33)}, + {"empty", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": `{"name":"T","short":"` + tt.short + `","version":"1"}`, + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + }) + } +} + +func TestThemeService_InstallFromZip_MissingVersion(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": `{"name":"T","short":"t"}`, + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "version is required") +} + +func TestThemeService_InstallFromZip_PathTraversal(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + tests := []string{ + "../outside.txt", + "../../etc/passwd", + "foo/../../../etc/passwd", + } + for _, path := range tests { + t.Run(path, func(t *testing.T) { + zipData := createThemeZip(t, map[string]string{ + path: "evil", + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid file path") + }) + } +} + +func TestThemeService_InstallFromZip_DisallowedFileType(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + tests := []string{"script.js", "page.html", "file.exe", "file.sh", "file.php"} + for _, name := range tests { + t.Run(name, func(t *testing.T) { + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": validMetadata("T", "t"), + name: "content", + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "file type not allowed") + }) + } +} + +func TestThemeService_InstallFromZip_TooLargeTotal(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + + // Create 11 x 2MB files of highly compressible data (repeated 'a'). + // After Deflate, each compresses to ~1KB, so total zip stays well under 10MB. + // But UncompressedSize64 is 22MB, which exceeds the 20MB total limit. + files := map[string]string{ + "sub2api-theme.json": validMetadata("T", "t"), + } + for i := 0; i < 11; i++ { + files[fmt.Sprintf("data%d.json", i)] = strings.Repeat("a", 2*1024*1024) + } + zipData := createThemeZip(t, files) + + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "extracted content too large") +} + +func TestThemeService_InstallFromZip_FileTooLarge(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + // Create a zip with a file over the 2MB limit by padding + largeContent := strings.Repeat("x", 3*1024*1024) + zipData := createThemeZip(t, map[string]string{ + "sub2api-theme.json": validMetadata("T", "t"), + "large.png": largeContent, + }) + _, err := svc.InstallFromZip(context.Background(), zipData) + require.Error(t, err) + assert.Contains(t, err.Error(), "file too large") +} + +func TestThemeService_InstallFromZip_DangerousCSS(t *testing.T) { + svc := newTestThemeService(t, &themeRepoStub{}) + tests := []struct { + name string + css string + keyword string + }{ + {"javascript", "a { background: url(javascript:alert(1)); }", "javascript:"}, + {"expression", "a { width: expression(1); }", "expression("}, + {"behavior", "a { behavior: url(h.htc); }", "behavior:"}, + {"moz_binding", "a { -moz-binding: url(x); }", "-moz-binding"}, + {"vbscript", "a { background: url(vbscript:msg); }", "vbscript:"}, + {"data_html", "a { background: url(data:text/html,`) // Inject before headClose := []byte("") - result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) + inject := script + + // Inject theme CSS if a theme is active + if themeShort != "" { + // Inject theme config CSS variables + if themeConfigCSS != "" { + styleTag := []byte(``) + inject = append(inject, styleTag...) + } + // Inject theme stylesheet link + linkTag := []byte(``) + inject = append(inject, linkTag...) + } + + result := bytes.Replace(s.baseHTML, headClose, append(inject, headClose...), 1) // Replace with custom site name so the browser tab shows it immediately result = injectSiteTitle(result, settingsJSON) @@ -307,7 +338,8 @@ func shouldBypassEmbeddedFrontend(path string) bool { trimmed == "/health" || trimmed == "/responses" || strings.HasPrefix(trimmed, "/responses/") || - strings.HasPrefix(trimmed, "/images/") + strings.HasPrefix(trimmed, "/images/") || + strings.HasPrefix(trimmed, "/api/v1/themes/") } func serveIndexHTML(c *gin.Context, fsys fs.FS) { diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go index 583d98a0b9b..104151fdc51 100644 --- a/backend/internal/web/embed_test.go +++ b/backend/internal/web/embed_test.go @@ -165,11 +165,11 @@ func TestFrontendServer_InjectSettings(t *testing.T) { settings: map[string]string{"key": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) settingsJSON := []byte(`{"test":"data"}`) - result := server.injectSettings(settingsJSON) + result := server.injectSettings(settingsJSON, "", "") // Should contain the script with nonce placeholder assert.Contains(t, string(result), `<script nonce="__CSP_NONCE_VALUE__">`) @@ -182,11 +182,11 @@ func TestFrontendServer_InjectSettings(t *testing.T) { settings: map[string]string{"key": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) settingsJSON := []byte(`{}`) - result := server.injectSettings(settingsJSON) + result := server.injectSettings(settingsJSON, "", "") // Script should be injected before </head> headCloseIndex := bytes.Index(result, []byte("</head>")) @@ -204,11 +204,11 @@ func TestFrontendServer_InjectSettings(t *testing.T) { }, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) settingsJSON := []byte(`{"nested":{"array":[1,2,3]},"special":"<>&"}`) - result := server.injectSettings(settingsJSON) + result := server.injectSettings(settingsJSON, "", "") assert.Contains(t, string(result), `window.__APP_CONFIG__={"nested":{"array":[1,2,3]},"special":"<>&"};`) }) @@ -220,7 +220,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // Create a gin context with nonce @@ -248,7 +248,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // First request @@ -279,7 +279,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) w := httptest.NewRecorder() @@ -300,7 +300,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // Use a real router for proper 304 handling @@ -333,7 +333,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) w := httptest.NewRecorder() @@ -351,7 +351,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { err: context.DeadlineExceeded, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // Invalidate cache to force settings fetch @@ -376,7 +376,7 @@ func TestFrontendServer_InvalidateCache(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // First request to populate cache @@ -427,7 +427,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) apiPaths := []string{ @@ -467,7 +467,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) router := gin.New() @@ -493,7 +493,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) router := gin.New() @@ -527,7 +527,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) router := gin.New() @@ -549,7 +549,7 @@ func TestNewFrontendServer(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) assert.NotNil(t, server) @@ -565,7 +565,7 @@ func TestNewFrontendServer(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) assert.NotEmpty(t, server.baseHTML) @@ -750,7 +750,7 @@ func BenchmarkFrontendServerServeIndexHTML(b *testing.B) { settings: map[string]string{"test": "value"}, } - server, _ := NewFrontendServer(provider) + server, _ := NewFrontendServer(provider, nil) b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 384e3796da4..f847465c6e3 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -31,6 +31,7 @@ import channelMonitorTemplateAPI from './channelMonitorTemplate' import adminPaymentAPI from './payment' import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' +import themesAPI from './themes' /** * Unified admin API object for convenient access @@ -63,7 +64,8 @@ export const adminAPI = { channelMonitorTemplate: channelMonitorTemplateAPI, payment: adminPaymentAPI, affiliates: affiliatesAPI, - riskControl: riskControlAPI + riskControl: riskControlAPI, + themes: themesAPI } export { @@ -94,7 +96,8 @@ export { channelMonitorTemplateAPI, adminPaymentAPI, affiliatesAPI, - riskControlAPI + riskControlAPI, + themesAPI } export default adminAPI diff --git a/frontend/src/api/admin/themes.ts b/frontend/src/api/admin/themes.ts new file mode 100644 index 00000000000..14683ba4f92 --- /dev/null +++ b/frontend/src/api/admin/themes.ts @@ -0,0 +1,75 @@ +import { apiClient } from '../client' + +export interface ThemeConfigOption { + label: string + value: string +} + +export interface ThemeConfigItem { + key: string + label: string + type: 'title' | 'color' | 'text' | 'select' | 'number' | 'boolean' + default: string + options?: ThemeConfigOption[] + description?: string +} + +export interface ThemeMetadata { + name: string + short: string + version: string + author: string + description: string + preview: string + main_css: string + config?: ThemeConfigItem[] +} + +export interface Theme { + metadata: ThemeMetadata + installed_at: string + is_active: boolean + config?: Record<string, string> +} + +export interface ThemeListResponse { + themes: Theme[] +} + +export interface ThemeActiveResponse { + active: boolean + theme?: Theme +} + +export const themesAPI = { + list: () => apiClient.get<ThemeListResponse>('/admin/themes'), + + get: (short: string) => apiClient.get<Theme>(`/admin/themes/${short}`), + + getActive: () => apiClient.get<ThemeActiveResponse>('/admin/themes/active'), + + install: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return apiClient.post<Theme>('/admin/themes/install', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, + }) + }, + + installFromGitHub: (url: string) => + apiClient.post<Theme>('/admin/themes/install-github', { url }), + + activate: (short: string) => + apiClient.post(`/admin/themes/${short}/activate`), + + deactivate: () => apiClient.post('/admin/themes/deactivate'), + + delete: (short: string) => + apiClient.delete(`/admin/themes/${short}`), + + updateConfig: (short: string, config: Record<string, string>) => + apiClient.put(`/admin/themes/${short}/config`, config), +} + +export default themesAPI diff --git a/frontend/src/components/admin/ThemeManager.vue b/frontend/src/components/admin/ThemeManager.vue new file mode 100644 index 00000000000..7188356085f --- /dev/null +++ b/frontend/src/components/admin/ThemeManager.vue @@ -0,0 +1,448 @@ +<template> + <div class="space-y-6"> + <!-- Active Theme Banner --> + <div v-if="activeTheme" class="card"> + <div class="card-body flex items-center justify-between"> + <div class="flex items-center gap-4"> + <div + v-if="activeTheme.metadata.preview" + class="h-16 w-16 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700" + > + <img + :src="`/api/v1/themes/assets/${activeTheme.metadata.short}/${activeTheme.metadata.preview}`" + :alt="activeTheme.metadata.name" + class="h-full w-full object-cover" + /> + </div> + <div + v-else + class="flex h-16 w-16 items-center justify-center rounded-lg bg-primary-100 dark:bg-primary-900/30" + > + <Icon name="paintBrush" size="lg" class="text-primary-600 dark:text-primary-400" /> + </div> + <div> + <div class="flex items-center gap-2"> + <span class="badge badge-success">{{ t('admin.settings.theme.activeTheme') }}</span> + <h3 class="text-lg font-semibold text-gray-900 dark:text-white"> + {{ activeTheme.metadata.name }} + </h3> + </div> + <p class="mt-0.5 text-sm text-gray-500 dark:text-dark-400"> + {{ t('admin.settings.theme.version') }} {{ activeTheme.metadata.version }} + <span v-if="activeTheme.metadata.author"> + · {{ activeTheme.metadata.author }} + </span> + </p> + <p + v-if="activeTheme.metadata.description" + class="mt-1 text-xs text-gray-400 dark:text-dark-500" + > + {{ activeTheme.metadata.description }} + </p> + </div> + </div> + <button @click="handleDeactivate" class="btn btn-secondary btn-sm"> + {{ t('admin.settings.theme.deactivate') }} + </button> + </div> + </div> + + <!-- No Active Theme --> + <div v-else class="card"> + <div class="card-body text-center py-8"> + <Icon name="paintBrush" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" /> + <p class="text-sm text-gray-500 dark:text-dark-400"> + {{ t('admin.settings.theme.noActiveTheme') }} + </p> + </div> + </div> + + <!-- Install Section --> + <div class="card"> + <div class="card-header"> + <h3 class="text-base font-semibold text-gray-900 dark:text-white"> + {{ t('admin.settings.theme.install') }} + </h3> + </div> + <div class="card-body space-y-4"> + <!-- Zip Upload --> + <div> + <label class="input-label">{{ t('admin.settings.theme.installFromZip') }}</label> + <div + @dragover.prevent="dragOver = true" + @dragleave="dragOver = false" + @drop.prevent="handleDrop" + @click="fileInputRef?.click()" + :class="[ + 'mt-1 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors', + dragOver + ? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20' + : 'border-gray-300 hover:border-gray-400 dark:border-dark-600 dark:hover:border-dark-500', + ]" + > + <Icon + name="cloud" + size="lg" + class="mb-2 text-gray-400 dark:text-dark-500" + /> + <p class="text-sm text-gray-600 dark:text-dark-300"> + {{ installing ? t('admin.settings.theme.installing') : t('admin.settings.theme.dragDropHint') }} + </p> + <input + ref="fileInputRef" + type="file" + accept=".zip" + class="hidden" + @change="handleFileSelect" + /> + </div> + </div> + + <!-- GitHub URL --> + <div> + <label class="input-label">{{ t('admin.settings.theme.installFromGithub') }}</label> + <div class="mt-1 flex gap-2"> + <input + v-model="githubUrl" + type="url" + class="input flex-1" + :placeholder="t('admin.settings.theme.githubUrlPlaceholder')" + :disabled="installing" + /> + <button + @click="handleInstallFromGitHub" + class="btn btn-primary btn-md" + :disabled="!githubUrl.trim() || installing" + > + <Icon v-if="installing" name="refresh" size="sm" class="animate-spin" /> + {{ t('admin.settings.theme.install') }} + </button> + </div> + </div> + </div> + </div> + + <!-- Installed Themes --> + <div v-if="themes.length > 0" class="card"> + <div class="card-header"> + <h3 class="text-base font-semibold text-gray-900 dark:text-white"> + {{ t('admin.settings.theme.tabs.theme') }} + </h3> + </div> + <div class="card-body"> + <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> + <div + v-for="theme in themes" + :key="theme.metadata.short" + class="group relative overflow-hidden rounded-xl border border-gray-200 transition-all hover:shadow-md dark:border-dark-700" + > + <!-- Preview Image --> + <div + v-if="theme.metadata.preview" + class="aspect-video overflow-hidden bg-gray-100 dark:bg-dark-800" + > + <img + :src="`/api/v1/themes/assets/${theme.metadata.short}/${theme.metadata.preview}`" + :alt="theme.metadata.name" + class="h-full w-full object-cover" + /> + </div> + <div + v-else + class="flex aspect-video items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-800 dark:to-dark-700" + > + <Icon name="paintBrush" size="xl" class="text-gray-300 dark:text-dark-600" /> + </div> + + <!-- Theme Info --> + <div class="p-4"> + <div class="flex items-start justify-between"> + <div> + <h4 class="font-semibold text-gray-900 dark:text-white"> + {{ theme.metadata.name }} + </h4> + <p class="text-xs text-gray-500 dark:text-dark-400"> + {{ theme.metadata.version }} + <span v-if="theme.metadata.author"> · {{ theme.metadata.author }}</span> + </p> + </div> + <span v-if="theme.is_active" class="badge badge-success"> + {{ t('admin.settings.theme.activeTheme') }} + </span> + </div> + <p + v-if="theme.metadata.description" + class="mt-2 line-clamp-2 text-xs text-gray-400 dark:text-dark-500" + > + {{ theme.metadata.description }} + </p> + + <!-- Actions --> + <div class="mt-3 flex gap-2"> + <button + v-if="!theme.is_active" + @click="handleActivate(theme.metadata.short)" + class="btn btn-primary btn-sm flex-1" + > + {{ t('admin.settings.theme.activate') }} + </button> + <button + @click="handleDelete(theme.metadata.short)" + class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" + > + {{ t('admin.settings.theme.delete') }} + </button> + </div> + </div> + </div> + </div> + </div> + </div> + + <!-- No Themes --> + <div v-else-if="!loading" class="card"> + <div class="card-body text-center py-8"> + <p class="text-sm text-gray-500 dark:text-dark-400"> + {{ t('admin.settings.theme.noThemes') }} + </p> + </div> + </div> + + <!-- Theme Config --> + <div v-if="activeTheme?.metadata.config?.length" class="card"> + <div class="card-header"> + <h3 class="text-base font-semibold text-gray-900 dark:text-white"> + {{ t('admin.settings.theme.config') }} + </h3> + </div> + <div class="card-body space-y-4"> + <div v-for="item in activeTheme.metadata.config" :key="item.key"> + <!-- Section title --> + <template v-if="item.type === 'title'"> + <h4 class="mt-4 mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300"> + {{ item.label }} + </h4> + </template> + <!-- Config fields --> + <template v-else> + <!-- Boolean: checkbox with inline label, no separate label above --> + <template v-if="item.type === 'boolean'"> + <label class="flex cursor-pointer items-center gap-2"> + <input + v-model="themeConfig[item.key]" + type="checkbox" + class="h-4 w-4 rounded text-primary-600" + true-value="true" + false-value="false" + /> + <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ item.label }}</span> + </label> + <p v-if="item.description" class="input-hint ml-6">{{ item.description }}</p> + </template> + + <!-- All other types: label + input --> + <template v-else> + <label class="input-label">{{ item.label }}</label> + <p v-if="item.description" class="input-hint mb-1.5">{{ item.description }}</p> + + <!-- Color --> + <input + v-if="item.type === 'color'" + v-model="themeConfig[item.key]" + type="color" + class="h-10 w-20 rounded-lg border border-gray-200 dark:border-dark-600" + /> + + <!-- Text --> + <input + v-else-if="item.type === 'text'" + v-model="themeConfig[item.key]" + type="text" + class="input" + :placeholder="item.default" + /> + + <!-- Number --> + <input + v-else-if="item.type === 'number'" + v-model="themeConfig[item.key]" + type="number" + class="input" + :placeholder="item.default" + /> + + <!-- Select --> + <Select + v-else-if="item.type === 'select'" + :modelValue="themeConfig[item.key]" + @update:modelValue="themeConfig[item.key] = $event as string" + :options="(item.options || []).map(o => ({ value: o.value, label: o.label }))" + /> + </template> + </template> + </div> + + <div class="flex justify-end pt-2"> + <button @click="handleSaveConfig" class="btn btn-primary btn-md"> + {{ t('admin.settings.theme.saveConfig') }} + </button> + </div> + </div> + </div> + + <!-- Delete Confirmation Dialog --> + <ConfirmDialog + :show="showDeleteConfirm" + :title="t('admin.settings.theme.delete')" + :message="t('admin.settings.theme.deleteConfirm')" + :danger="true" + @confirm="confirmDelete" + @cancel="showDeleteConfirm = false" + /> + </div> +</template> + +<script setup lang="ts"> +import { ref, reactive, onMounted } from 'vue' +import { useI18n } from 'vue-i18n' +import { adminAPI } from '@/api' +import type { Theme } from '@/api/admin/themes' +import Icon from '@/components/icons/Icon.vue' +import Select from '@/components/common/Select.vue' +import ConfirmDialog from '@/components/common/ConfirmDialog.vue' + +const { t } = useI18n() + +const loading = ref(false) +const installing = ref(false) +const dragOver = ref(false) +const themes = ref<Theme[]>([]) +const activeTheme = ref<Theme | null>(null) +const githubUrl = ref('') +const themeConfig = reactive<Record<string, string>>({}) +const showDeleteConfirm = ref(false) +const deleteTarget = ref('') +const fileInputRef = ref<HTMLInputElement | null>(null) + +async function loadThemes() { + loading.value = true + try { + const [listRes, activeRes] = await Promise.all([ + adminAPI.themes.list(), + adminAPI.themes.getActive(), + ]) + themes.value = listRes.data.themes || [] + if (activeRes.data.active && activeRes.data.theme) { + activeTheme.value = activeRes.data.theme + // Initialize config with defaults + if (activeRes.data.theme.metadata.config) { + for (const item of activeRes.data.theme.metadata.config) { + const existing = activeRes.data.theme.config?.[item.key] + themeConfig[item.key] = existing ?? item.default ?? '' + } + } + } else { + activeTheme.value = null + } + } catch (e: any) { + console.error('Failed to load themes:', e) + } finally { + loading.value = false + } +} + +function handleDrop(e: DragEvent) { + dragOver.value = false + const file = e.dataTransfer?.files?.[0] + if (file && file.name.endsWith('.zip')) { + installFile(file) + } +} + +function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) { + installFile(file) + } + input.value = '' +} + +async function installFile(file: File) { + installing.value = true + try { + await adminAPI.themes.install(file) + await loadThemes() + } catch (e: any) { + console.error('Failed to install theme:', e) + alert(e.response?.data?.error || e.message) + } finally { + installing.value = false + } +} + +async function handleInstallFromGitHub() { + if (!githubUrl.value.trim()) return + installing.value = true + try { + await adminAPI.themes.installFromGitHub(githubUrl.value.trim()) + githubUrl.value = '' + await loadThemes() + } catch (e: any) { + console.error('Failed to install theme from GitHub:', e) + alert(e.response?.data?.error || e.message) + } finally { + installing.value = false + } +} + +async function handleActivate(short: string) { + try { + await adminAPI.themes.activate(short) + await loadThemes() + } catch (e: any) { + console.error('Failed to activate theme:', e) + alert(e.response?.data?.error || e.message) + } +} + +async function handleDeactivate() { + try { + await adminAPI.themes.deactivate() + await loadThemes() + } catch (e: any) { + console.error('Failed to deactivate theme:', e) + alert(e.response?.data?.error || e.message) + } +} + +function handleDelete(short: string) { + deleteTarget.value = short + showDeleteConfirm.value = true +} + +async function confirmDelete() { + showDeleteConfirm.value = false + try { + await adminAPI.themes.delete(deleteTarget.value) + await loadThemes() + } catch (e: any) { + console.error('Failed to delete theme:', e) + alert(e.response?.data?.error || e.message) + } +} + +async function handleSaveConfig() { + if (!activeTheme.value) return + try { + await adminAPI.themes.updateConfig(activeTheme.value.metadata.short, { ...themeConfig }) + // Force reload to pick up new CSS + window.location.reload() + } catch (e: any) { + console.error('Failed to save theme config:', e) + alert(e.response?.data?.error || e.message) + } +} + +onMounted(loadThemes) +</script> diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 6d0ba8a3f2b..6e7f5749556 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -130,7 +130,8 @@ const icons = { calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z', fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z', badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z', - brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z' + brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z', + paintBrush: 'M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42' } as const const iconPath = computed(() => icons[props.name]) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index b4e231e73f1..390c8ad1d06 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5314,6 +5314,7 @@ export default { general: 'General', agreement: 'Agreement', features: 'Feature Switches', + theme: 'Theme', security: 'Security', users: 'Users', gateway: 'Gateway', @@ -5321,6 +5322,26 @@ export default { backup: 'Backup', payment: 'Payment', }, + theme: { + title: 'Theme Management', + activeTheme: 'Active', + noActiveTheme: 'No active theme', + deactivate: 'Deactivate', + activate: 'Activate', + delete: 'Delete', + install: 'Install Theme', + installFromZip: 'Install from ZIP file', + installFromGithub: 'Install from GitHub', + githubUrlPlaceholder: 'Enter GitHub repo URL (e.g. https://github.com/user/theme)', + dragDropHint: 'Drag and drop a ZIP file here, or click to select', + installing: 'Installing...', + deleteConfirm: 'Are you sure you want to delete this theme?', + config: 'Theme Configuration', + saveConfig: 'Save Configuration', + version: 'Version', + author: 'Author', + noThemes: 'No themes installed', + }, features: { channelMonitor: { title: 'Channel Monitor', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 70a7f6dfcbb..413abf5ae24 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5474,6 +5474,7 @@ export default { general: '通用设置', agreement: '登录条款', features: '功能开关', + theme: '主题', security: '安全与认证', users: '用户默认值', gateway: '网关服务', @@ -5481,6 +5482,26 @@ export default { backup: '数据备份', payment: '支付设置', }, + theme: { + title: '主题管理', + activeTheme: '当前主题', + noActiveTheme: '未激活主题', + deactivate: '取消激活', + activate: '激活', + delete: '删除', + install: '安装主题', + installFromZip: '从 ZIP 文件安装', + installFromGithub: '从 GitHub 安装', + githubUrlPlaceholder: '输入 GitHub 仓库 URL(如 https://github.com/user/theme)', + dragDropHint: '拖拽 ZIP 文件到此处,或点击选择文件', + installing: '正在安装...', + deleteConfirm: '确定要删除此主题吗?', + config: '主题配置', + saveConfig: '保存配置', + version: '版本', + author: '作者', + noThemes: '暂无已安装主题', + }, features: { channelMonitor: { title: '渠道监控', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 239ce2d7b72..e21eac08fc6 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5673,6 +5673,11 @@ </div><!-- /Tab: Features --> + <!-- Tab: Theme --> + <div v-show="activeTab === 'theme'" class="space-y-6"> + <ThemeManager /> + </div><!-- /Tab: Theme --> + <!-- Tab: Email --> <!-- Tab: Payment --> <div v-show="activeTab === 'payment'" class="space-y-6"> @@ -6702,6 +6707,7 @@ import Icon from "@/components/icons/Icon.vue"; import Select from "@/components/common/Select.vue"; import ConfirmDialog from "@/components/common/ConfirmDialog.vue"; import PaymentProviderList from "@/components/payment/PaymentProviderList.vue"; +import ThemeManager from "@/components/admin/ThemeManager.vue"; import PaymentProviderDialog from "@/components/payment/PaymentProviderDialog.vue"; import GroupBadge from "@/components/common/GroupBadge.vue"; import GroupOptionItem from "@/components/common/GroupOptionItem.vue"; @@ -6748,6 +6754,7 @@ type SettingsTab = | "general" | "agreement" | "features" + | "theme" | "security" | "users" | "gateway" @@ -6759,6 +6766,7 @@ const settingsTabs = [ { key: "general" as SettingsTab, icon: "home" as const }, { key: "agreement" as SettingsTab, icon: "document" as const }, { key: "features" as SettingsTab, icon: "bolt" as const }, + { key: "theme" as SettingsTab, icon: "paintBrush" as const }, { key: "security" as SettingsTab, icon: "shield" as const }, { key: "users" as SettingsTab, icon: "user" as const }, { key: "gateway" as SettingsTab, icon: "server" as const },