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 with custom site name so the browser tab shows it immediately result = injectSiteTitle(result, settingsJSON) @@ -307,7 +338,8 @@ func shouldBypassEmbeddedFrontend(path string) bool { trimmed == "/health" || trimmed == "/responses" || strings.HasPrefix(trimmed, "/responses/") || - strings.HasPrefix(trimmed, "/images/") + strings.HasPrefix(trimmed, "/images/") || + strings.HasPrefix(trimmed, "/api/v1/themes/") } func serveIndexHTML(c *gin.Context, fsys fs.FS) { diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go index 583d98a0b9b..104151fdc51 100644 --- a/backend/internal/web/embed_test.go +++ b/backend/internal/web/embed_test.go @@ -165,11 +165,11 @@ func TestFrontendServer_InjectSettings(t *testing.T) { settings: map[string]string{"key": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) settingsJSON := []byte(`{"test":"data"}`) - result := server.injectSettings(settingsJSON) + result := server.injectSettings(settingsJSON, "", "") // Should contain the script with nonce placeholder assert.Contains(t, string(result), `<script nonce="__CSP_NONCE_VALUE__">`) @@ -182,11 +182,11 @@ func TestFrontendServer_InjectSettings(t *testing.T) { settings: map[string]string{"key": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) settingsJSON := []byte(`{}`) - result := server.injectSettings(settingsJSON) + result := server.injectSettings(settingsJSON, "", "") // Script should be injected before </head> headCloseIndex := bytes.Index(result, []byte("</head>")) @@ -204,11 +204,11 @@ func TestFrontendServer_InjectSettings(t *testing.T) { }, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) settingsJSON := []byte(`{"nested":{"array":[1,2,3]},"special":"<>&"}`) - result := server.injectSettings(settingsJSON) + result := server.injectSettings(settingsJSON, "", "") assert.Contains(t, string(result), `window.__APP_CONFIG__={"nested":{"array":[1,2,3]},"special":"<>&"};`) }) @@ -220,7 +220,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // Create a gin context with nonce @@ -248,7 +248,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // First request @@ -279,7 +279,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) w := httptest.NewRecorder() @@ -300,7 +300,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // Use a real router for proper 304 handling @@ -333,7 +333,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) w := httptest.NewRecorder() @@ -351,7 +351,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { err: context.DeadlineExceeded, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // Invalidate cache to force settings fetch @@ -376,7 +376,7 @@ func TestFrontendServer_InvalidateCache(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) // First request to populate cache @@ -427,7 +427,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) apiPaths := []string{ @@ -467,7 +467,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) router := gin.New() @@ -493,7 +493,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) router := gin.New() @@ -527,7 +527,7 @@ func TestFrontendServer_Middleware(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) router := gin.New() @@ -549,7 +549,7 @@ func TestNewFrontendServer(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) assert.NotNil(t, server) @@ -565,7 +565,7 @@ func TestNewFrontendServer(t *testing.T) { settings: map[string]string{"test": "value"}, } - server, err := NewFrontendServer(provider) + server, err := NewFrontendServer(provider, nil) require.NoError(t, err) assert.NotEmpty(t, server.baseHTML) @@ -750,7 +750,7 @@ func BenchmarkFrontendServerServeIndexHTML(b *testing.B) { settings: map[string]string{"test": "value"}, } - server, _ := NewFrontendServer(provider) + server, _ := NewFrontendServer(provider, nil) b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 384e3796da4..f847465c6e3 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -31,6 +31,7 @@ import channelMonitorTemplateAPI from './channelMonitorTemplate' import adminPaymentAPI from './payment' import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' +import themesAPI from './themes' /** * Unified admin API object for convenient access @@ -63,7 +64,8 @@ export const adminAPI = { channelMonitorTemplate: channelMonitorTemplateAPI, payment: adminPaymentAPI, affiliates: affiliatesAPI, - riskControl: riskControlAPI + riskControl: riskControlAPI, + themes: themesAPI } export { @@ -94,7 +96,8 @@ export { channelMonitorTemplateAPI, adminPaymentAPI, affiliatesAPI, - riskControlAPI + riskControlAPI, + themesAPI } export default adminAPI diff --git a/frontend/src/api/admin/themes.ts b/frontend/src/api/admin/themes.ts new file mode 100644 index 00000000000..98462619f50 --- /dev/null +++ b/frontend/src/api/admin/themes.ts @@ -0,0 +1,75 @@ +import { apiClient } from '../client' + +export interface ThemeConfigOption { + label: string + value: string +} + +export interface ThemeConfigItem { + key: string + label: string + type: 'color' | 'text' | 'select' | 'number' | 'boolean' + default: string + options?: ThemeConfigOption[] + description?: string +} + +export interface ThemeMetadata { + name: string + short: string + version: string + author: string + description: string + preview: string + main_css: string + config?: ThemeConfigItem[] +} + +export interface Theme { + metadata: ThemeMetadata + installed_at: string + is_active: boolean + config?: Record<string, string> +} + +export interface ThemeListResponse { + themes: Theme[] +} + +export interface ThemeActiveResponse { + active: boolean + theme?: Theme +} + +export const themesAPI = { + list: () => apiClient.get<ThemeListResponse>('/admin/themes'), + + get: (short: string) => apiClient.get<Theme>(`/admin/themes/${short}`), + + getActive: () => apiClient.get<ThemeActiveResponse>('/admin/themes/active'), + + install: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return apiClient.post<Theme>('/admin/themes/install', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, + }) + }, + + installFromGitHub: (url: string) => + apiClient.post<Theme>('/admin/themes/install-github', { url }), + + activate: (short: string) => + apiClient.post(`/admin/themes/${short}/activate`), + + deactivate: () => apiClient.post('/admin/themes/deactivate'), + + delete: (short: string) => + apiClient.delete(`/admin/themes/${short}`), + + updateConfig: (short: string, config: Record<string, string>) => + apiClient.put(`/admin/themes/${short}/config`, config), +} + +export default themesAPI diff --git a/frontend/src/components/admin/ThemeManager.vue b/frontend/src/components/admin/ThemeManager.vue new file mode 100644 index 00000000000..7188356085f --- /dev/null +++ b/frontend/src/components/admin/ThemeManager.vue @@ -0,0 +1,448 @@ +<template> + <div class="space-y-6"> + <!-- Active Theme Banner --> + <div v-if="activeTheme" class="card"> + <div class="card-body flex items-center justify-between"> + <div class="flex items-center gap-4"> + <div + v-if="activeTheme.metadata.preview" + class="h-16 w-16 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700" + > + <img + :src="`/api/v1/themes/assets/${activeTheme.metadata.short}/${activeTheme.metadata.preview}`" + :alt="activeTheme.metadata.name" + class="h-full w-full object-cover" + /> + </div> + <div + v-else + class="flex h-16 w-16 items-center justify-center rounded-lg bg-primary-100 dark:bg-primary-900/30" + > + <Icon name="paintBrush" size="lg" class="text-primary-600 dark:text-primary-400" /> + </div> + <div> + <div class="flex items-center gap-2"> + <span class="badge badge-success">{{ t('admin.settings.theme.activeTheme') }}</span> + <h3 class="text-lg font-semibold text-gray-900 dark:text-white"> + {{ activeTheme.metadata.name }} + </h3> + </div> + <p class="mt-0.5 text-sm text-gray-500 dark:text-dark-400"> + {{ t('admin.settings.theme.version') }} {{ activeTheme.metadata.version }} + <span v-if="activeTheme.metadata.author"> + · {{ activeTheme.metadata.author }} + </span> + </p> + <p + v-if="activeTheme.metadata.description" + class="mt-1 text-xs text-gray-400 dark:text-dark-500" + > + {{ activeTheme.metadata.description }} + </p> + </div> + </div> + <button @click="handleDeactivate" class="btn btn-secondary btn-sm"> + {{ t('admin.settings.theme.deactivate') }} + </button> + </div> + </div> + + <!-- No Active Theme --> + <div v-else class="card"> + <div class="card-body text-center py-8"> + <Icon name="paintBrush" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" /> + <p class="text-sm text-gray-500 dark:text-dark-400"> + {{ t('admin.settings.theme.noActiveTheme') }} + </p> + </div> + </div> + + <!-- Install Section --> + <div class="card"> + <div class="card-header"> + <h3 class="text-base font-semibold text-gray-900 dark:text-white"> + {{ t('admin.settings.theme.install') }} + </h3> + </div> + <div class="card-body space-y-4"> + <!-- Zip Upload --> + <div> + <label class="input-label">{{ t('admin.settings.theme.installFromZip') }}</label> + <div + @dragover.prevent="dragOver = true" + @dragleave="dragOver = false" + @drop.prevent="handleDrop" + @click="fileInputRef?.click()" + :class="[ + 'mt-1 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors', + dragOver + ? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20' + : 'border-gray-300 hover:border-gray-400 dark:border-dark-600 dark:hover:border-dark-500', + ]" + > + <Icon + name="cloud" + size="lg" + class="mb-2 text-gray-400 dark:text-dark-500" + /> + <p class="text-sm text-gray-600 dark:text-dark-300"> + {{ installing ? t('admin.settings.theme.installing') : t('admin.settings.theme.dragDropHint') }} + </p> + <input + ref="fileInputRef" + type="file" + accept=".zip" + class="hidden" + @change="handleFileSelect" + /> + </div> + </div> + + <!-- GitHub URL --> + <div> + <label class="input-label">{{ t('admin.settings.theme.installFromGithub') }}</label> + <div class="mt-1 flex gap-2"> + <input + v-model="githubUrl" + type="url" + class="input flex-1" + :placeholder="t('admin.settings.theme.githubUrlPlaceholder')" + :disabled="installing" + /> + <button + @click="handleInstallFromGitHub" + class="btn btn-primary btn-md" + :disabled="!githubUrl.trim() || installing" + > + <Icon v-if="installing" name="refresh" size="sm" class="animate-spin" /> + {{ t('admin.settings.theme.install') }} + </button> + </div> + </div> + </div> + </div> + + <!-- Installed Themes --> + <div v-if="themes.length > 0" class="card"> + <div class="card-header"> + <h3 class="text-base font-semibold text-gray-900 dark:text-white"> + {{ t('admin.settings.theme.tabs.theme') }} + </h3> + </div> + <div class="card-body"> + <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> + <div + v-for="theme in themes" + :key="theme.metadata.short" + class="group relative overflow-hidden rounded-xl border border-gray-200 transition-all hover:shadow-md dark:border-dark-700" + > + <!-- Preview Image --> + <div + v-if="theme.metadata.preview" + class="aspect-video overflow-hidden bg-gray-100 dark:bg-dark-800" + > + <img + :src="`/api/v1/themes/assets/${theme.metadata.short}/${theme.metadata.preview}`" + :alt="theme.metadata.name" + class="h-full w-full object-cover" + /> + </div> + <div + v-else + class="flex aspect-video items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-800 dark:to-dark-700" + > + <Icon name="paintBrush" size="xl" class="text-gray-300 dark:text-dark-600" /> + </div> + + <!-- Theme Info --> + <div class="p-4"> + <div class="flex items-start justify-between"> + <div> + <h4 class="font-semibold text-gray-900 dark:text-white"> + {{ theme.metadata.name }} + </h4> + <p class="text-xs text-gray-500 dark:text-dark-400"> + {{ theme.metadata.version }} + <span v-if="theme.metadata.author"> · {{ theme.metadata.author }}</span> + </p> + </div> + <span v-if="theme.is_active" class="badge badge-success"> + {{ t('admin.settings.theme.activeTheme') }} + </span> + </div> + <p + v-if="theme.metadata.description" + class="mt-2 line-clamp-2 text-xs text-gray-400 dark:text-dark-500" + > + {{ theme.metadata.description }} + </p> + + <!-- Actions --> + <div class="mt-3 flex gap-2"> + <button + v-if="!theme.is_active" + @click="handleActivate(theme.metadata.short)" + class="btn btn-primary btn-sm flex-1" + > + {{ t('admin.settings.theme.activate') }} + </button> + <button + @click="handleDelete(theme.metadata.short)" + class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" + > + {{ t('admin.settings.theme.delete') }} + </button> + </div> + </div> + </div> + </div> + </div> + </div> + + <!-- No Themes --> + <div v-else-if="!loading" class="card"> + <div class="card-body text-center py-8"> + <p class="text-sm text-gray-500 dark:text-dark-400"> + {{ t('admin.settings.theme.noThemes') }} + </p> + </div> + </div> + + <!-- Theme Config --> + <div v-if="activeTheme?.metadata.config?.length" class="card"> + <div class="card-header"> + <h3 class="text-base font-semibold text-gray-900 dark:text-white"> + {{ t('admin.settings.theme.config') }} + </h3> + </div> + <div class="card-body space-y-4"> + <div v-for="item in activeTheme.metadata.config" :key="item.key"> + <!-- Section title --> + <template v-if="item.type === 'title'"> + <h4 class="mt-4 mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300"> + {{ item.label }} + </h4> + </template> + <!-- Config fields --> + <template v-else> + <!-- Boolean: checkbox with inline label, no separate label above --> + <template v-if="item.type === 'boolean'"> + <label class="flex cursor-pointer items-center gap-2"> + <input + v-model="themeConfig[item.key]" + type="checkbox" + class="h-4 w-4 rounded text-primary-600" + true-value="true" + false-value="false" + /> + <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ item.label }}</span> + </label> + <p v-if="item.description" class="input-hint ml-6">{{ item.description }}</p> + </template> + + <!-- All other types: label + input --> + <template v-else> + <label class="input-label">{{ item.label }}</label> + <p v-if="item.description" class="input-hint mb-1.5">{{ item.description }}</p> + + <!-- Color --> + <input + v-if="item.type === 'color'" + v-model="themeConfig[item.key]" + type="color" + class="h-10 w-20 rounded-lg border border-gray-200 dark:border-dark-600" + /> + + <!-- Text --> + <input + v-else-if="item.type === 'text'" + v-model="themeConfig[item.key]" + type="text" + class="input" + :placeholder="item.default" + /> + + <!-- Number --> + <input + v-else-if="item.type === 'number'" + v-model="themeConfig[item.key]" + type="number" + class="input" + :placeholder="item.default" + /> + + <!-- Select --> + <Select + v-else-if="item.type === 'select'" + :modelValue="themeConfig[item.key]" + @update:modelValue="themeConfig[item.key] = $event as string" + :options="(item.options || []).map(o => ({ value: o.value, label: o.label }))" + /> + </template> + </template> + </div> + + <div class="flex justify-end pt-2"> + <button @click="handleSaveConfig" class="btn btn-primary btn-md"> + {{ t('admin.settings.theme.saveConfig') }} + </button> + </div> + </div> + </div> + + <!-- Delete Confirmation Dialog --> + <ConfirmDialog + :show="showDeleteConfirm" + :title="t('admin.settings.theme.delete')" + :message="t('admin.settings.theme.deleteConfirm')" + :danger="true" + @confirm="confirmDelete" + @cancel="showDeleteConfirm = false" + /> + </div> +</template> + +<script setup lang="ts"> +import { ref, reactive, onMounted } from 'vue' +import { useI18n } from 'vue-i18n' +import { adminAPI } from '@/api' +import type { Theme } from '@/api/admin/themes' +import Icon from '@/components/icons/Icon.vue' +import Select from '@/components/common/Select.vue' +import ConfirmDialog from '@/components/common/ConfirmDialog.vue' + +const { t } = useI18n() + +const loading = ref(false) +const installing = ref(false) +const dragOver = ref(false) +const themes = ref<Theme[]>([]) +const activeTheme = ref<Theme | null>(null) +const githubUrl = ref('') +const themeConfig = reactive<Record<string, string>>({}) +const showDeleteConfirm = ref(false) +const deleteTarget = ref('') +const fileInputRef = ref<HTMLInputElement | null>(null) + +async function loadThemes() { + loading.value = true + try { + const [listRes, activeRes] = await Promise.all([ + adminAPI.themes.list(), + adminAPI.themes.getActive(), + ]) + themes.value = listRes.data.themes || [] + if (activeRes.data.active && activeRes.data.theme) { + activeTheme.value = activeRes.data.theme + // Initialize config with defaults + if (activeRes.data.theme.metadata.config) { + for (const item of activeRes.data.theme.metadata.config) { + const existing = activeRes.data.theme.config?.[item.key] + themeConfig[item.key] = existing ?? item.default ?? '' + } + } + } else { + activeTheme.value = null + } + } catch (e: any) { + console.error('Failed to load themes:', e) + } finally { + loading.value = false + } +} + +function handleDrop(e: DragEvent) { + dragOver.value = false + const file = e.dataTransfer?.files?.[0] + if (file && file.name.endsWith('.zip')) { + installFile(file) + } +} + +function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) { + installFile(file) + } + input.value = '' +} + +async function installFile(file: File) { + installing.value = true + try { + await adminAPI.themes.install(file) + await loadThemes() + } catch (e: any) { + console.error('Failed to install theme:', e) + alert(e.response?.data?.error || e.message) + } finally { + installing.value = false + } +} + +async function handleInstallFromGitHub() { + if (!githubUrl.value.trim()) return + installing.value = true + try { + await adminAPI.themes.installFromGitHub(githubUrl.value.trim()) + githubUrl.value = '' + await loadThemes() + } catch (e: any) { + console.error('Failed to install theme from GitHub:', e) + alert(e.response?.data?.error || e.message) + } finally { + installing.value = false + } +} + +async function handleActivate(short: string) { + try { + await adminAPI.themes.activate(short) + await loadThemes() + } catch (e: any) { + console.error('Failed to activate theme:', e) + alert(e.response?.data?.error || e.message) + } +} + +async function handleDeactivate() { + try { + await adminAPI.themes.deactivate() + await loadThemes() + } catch (e: any) { + console.error('Failed to deactivate theme:', e) + alert(e.response?.data?.error || e.message) + } +} + +function handleDelete(short: string) { + deleteTarget.value = short + showDeleteConfirm.value = true +} + +async function confirmDelete() { + showDeleteConfirm.value = false + try { + await adminAPI.themes.delete(deleteTarget.value) + await loadThemes() + } catch (e: any) { + console.error('Failed to delete theme:', e) + alert(e.response?.data?.error || e.message) + } +} + +async function handleSaveConfig() { + if (!activeTheme.value) return + try { + await adminAPI.themes.updateConfig(activeTheme.value.metadata.short, { ...themeConfig }) + // Force reload to pick up new CSS + window.location.reload() + } catch (e: any) { + console.error('Failed to save theme config:', e) + alert(e.response?.data?.error || e.message) + } +} + +onMounted(loadThemes) +</script> diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 6d0ba8a3f2b..6e7f5749556 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -130,7 +130,8 @@ const icons = { calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z', fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z', badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z', - brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z' + brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z', + paintBrush: 'M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42' } as const const iconPath = computed(() => icons[props.name]) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index b4e231e73f1..390c8ad1d06 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5314,6 +5314,7 @@ export default { general: 'General', agreement: 'Agreement', features: 'Feature Switches', + theme: 'Theme', security: 'Security', users: 'Users', gateway: 'Gateway', @@ -5321,6 +5322,26 @@ export default { backup: 'Backup', payment: 'Payment', }, + theme: { + title: 'Theme Management', + activeTheme: 'Active', + noActiveTheme: 'No active theme', + deactivate: 'Deactivate', + activate: 'Activate', + delete: 'Delete', + install: 'Install Theme', + installFromZip: 'Install from ZIP file', + installFromGithub: 'Install from GitHub', + githubUrlPlaceholder: 'Enter GitHub repo URL (e.g. https://github.com/user/theme)', + dragDropHint: 'Drag and drop a ZIP file here, or click to select', + installing: 'Installing...', + deleteConfirm: 'Are you sure you want to delete this theme?', + config: 'Theme Configuration', + saveConfig: 'Save Configuration', + version: 'Version', + author: 'Author', + noThemes: 'No themes installed', + }, features: { channelMonitor: { title: 'Channel Monitor', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 70a7f6dfcbb..413abf5ae24 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5474,6 +5474,7 @@ export default { general: '通用设置', agreement: '登录条款', features: '功能开关', + theme: '主题', security: '安全与认证', users: '用户默认值', gateway: '网关服务', @@ -5481,6 +5482,26 @@ export default { backup: '数据备份', payment: '支付设置', }, + theme: { + title: '主题管理', + activeTheme: '当前主题', + noActiveTheme: '未激活主题', + deactivate: '取消激活', + activate: '激活', + delete: '删除', + install: '安装主题', + installFromZip: '从 ZIP 文件安装', + installFromGithub: '从 GitHub 安装', + githubUrlPlaceholder: '输入 GitHub 仓库 URL(如 https://github.com/user/theme)', + dragDropHint: '拖拽 ZIP 文件到此处,或点击选择文件', + installing: '正在安装...', + deleteConfirm: '确定要删除此主题吗?', + config: '主题配置', + saveConfig: '保存配置', + version: '版本', + author: '作者', + noThemes: '暂无已安装主题', + }, features: { channelMonitor: { title: '渠道监控', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 239ce2d7b72..e21eac08fc6 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5673,6 +5673,11 @@ </div><!-- /Tab: Features --> + <!-- Tab: Theme --> + <div v-show="activeTab === 'theme'" class="space-y-6"> + <ThemeManager /> + </div><!-- /Tab: Theme --> + <!-- Tab: Email --> <!-- Tab: Payment --> <div v-show="activeTab === 'payment'" class="space-y-6"> @@ -6702,6 +6707,7 @@ import Icon from "@/components/icons/Icon.vue"; import Select from "@/components/common/Select.vue"; import ConfirmDialog from "@/components/common/ConfirmDialog.vue"; import PaymentProviderList from "@/components/payment/PaymentProviderList.vue"; +import ThemeManager from "@/components/admin/ThemeManager.vue"; import PaymentProviderDialog from "@/components/payment/PaymentProviderDialog.vue"; import GroupBadge from "@/components/common/GroupBadge.vue"; import GroupOptionItem from "@/components/common/GroupOptionItem.vue"; @@ -6748,6 +6754,7 @@ type SettingsTab = | "general" | "agreement" | "features" + | "theme" | "security" | "users" | "gateway" @@ -6759,6 +6766,7 @@ const settingsTabs = [ { key: "general" as SettingsTab, icon: "home" as const }, { key: "agreement" as SettingsTab, icon: "document" as const }, { key: "features" as SettingsTab, icon: "bolt" as const }, + { key: "theme" as SettingsTab, icon: "paintBrush" as const }, { key: "security" as SettingsTab, icon: "shield" as const }, { key: "users" as SettingsTab, icon: "user" as const }, { key: "gateway" as SettingsTab, icon: "server" as const },