Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 152 additions & 0 deletions backend/internal/handler/admin/theme_handler.go
Original file line number Diff line number Diff line change
@@ -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"})
}
2 changes: 2 additions & 0 deletions backend/internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type AdminHandlers struct {
ContentModeration *admin.ContentModerationHandler
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
Theme *admin.ThemeHandler
}

// Handlers contains all HTTP handlers
Expand All @@ -56,6 +57,7 @@ type Handlers struct {
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
ThemeAsset *ThemeAssetHandler
}

// BuildInfo contains build-time information
Expand Down
42 changes: 42 additions & 0 deletions backend/internal/handler/theme_asset_handler.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 6 additions & 0 deletions backend/internal/handler/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func ProvideAdminHandlers(
contentModerationHandler *admin.ContentModerationHandler,
paymentHandler *admin.PaymentHandler,
affiliateHandler *admin.AffiliateHandler,
themeHandler *admin.ThemeHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
Expand Down Expand Up @@ -71,6 +72,7 @@ func ProvideAdminHandlers(
ContentModeration: contentModerationHandler,
Payment: paymentHandler,
Affiliate: affiliateHandler,
Theme: themeHandler,
}
}

Expand Down Expand Up @@ -111,6 +113,7 @@ func ProvideHandlers(
paymentHandler *PaymentHandler,
paymentWebhookHandler *PaymentWebhookHandler,
availableChannelHandler *AvailableChannelHandler,
themeAssetHandler *ThemeAssetHandler,
_ *service.IdempotencyCoordinator,
_ *service.IdempotencyCleanupService,
) *Handlers {
Expand All @@ -131,6 +134,7 @@ func ProvideHandlers(
Payment: paymentHandler,
PaymentWebhook: paymentWebhookHandler,
AvailableChannel: availableChannelHandler,
ThemeAsset: themeAssetHandler,
}
}

Expand Down Expand Up @@ -184,6 +188,8 @@ var ProviderSet = wire.NewSet(
admin.NewContentModerationHandler,
admin.NewPaymentHandler,
admin.NewAffiliateHandler,
admin.NewThemeHandler,
NewThemeAssetHandler,

// AdminHandlers and Handlers constructors
ProvideAdminHandlers,
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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 服务器
Expand Down
12 changes: 11 additions & 1 deletion backend/internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand All @@ -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)

Expand Down
18 changes: 18 additions & 0 deletions backend/internal/server/routes/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ func RegisterAdminRoutes(
// 定时测试计划
registerScheduledTestRoutes(admin, h)

// 主题管理
registerThemeRoutes(admin, h)

// 渠道管理
registerChannelRoutes(admin, h)

Expand Down Expand Up @@ -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)
}
}
15 changes: 15 additions & 0 deletions backend/internal/server/routes/theme.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading