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..b48ff9bdd78 --- /dev/null +++ b/backend/internal/service/theme_service.go @@ -0,0 +1,787 @@ +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)/\*.*?\*/`) + cssURLRe = regexp.MustCompile(`(?i)url\s*\(\s*(['"]?)\s*(.*?)\s*\1\s*\)`) + cssImportRe = regexp.MustCompile(`(?i)@import\s+url\s*\(\s*(['"]?)\s*(https?://.*?)\s*\1\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 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) + os.MkdirAll(filepath.Dir(destPath), 0o755) + os.WriteFile(destPath, data, 0o644) + 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) + os.MkdirAll(filepath.Dir(destPath), 0o755) + + 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 + 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) < 3 { + return "" + } + u := strings.TrimSpace(inner[2]) + 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 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..1b5bf297a53 --- /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