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