From 2558efd9fbd8e1f4329a3f545c9f23c9e902bd8d Mon Sep 17 00:00:00 2001 From: Muse Date: Thu, 22 Oct 2026 23:19:34 +0000 Subject: [PATCH] B5: theme admin API, hosting chain hookup, session cookie root path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin endpoints under /panel/api/probe/* (list, preview, chunk upload, repo install, update, select, delete, public settings). saveThemeConfig gate aligned to hub: selectable, not disk-installed. Session cookie Path=/ so the root public surface (theme config save, /api/me authed) can see the operator session under a non-root panel base path — with basePath-scoped cookies it was unreachable (401). --- internal/web/controller/api.go | 4 + internal/web/controller/probe_public.go | 4 +- internal/web/controller/probe_themes.go | 189 ++++++++++++++++++++++++ internal/web/web.go | 10 +- 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 internal/web/controller/probe_themes.go diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 917e4e2..3495518 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -202,6 +202,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { // (/panel/api/agent/probe-tasks*). NewProbeTasksController(agentAdmin) + // Item 7 B5: theme store + public-surface settings, on the API + // group itself (/panel/api/probe/*). + NewProbeThemesController(api) + // Hosts API — per-inbound override endpoints for subscription links hosts := api.Group("/hosts") a.hostController = NewHostController(hosts) diff --git a/internal/web/controller/probe_public.go b/internal/web/controller/probe_public.go index a1e6a42..b4d9f99 100644 --- a/internal/web/controller/probe_public.go +++ b/internal/web/controller/probe_public.go @@ -328,7 +328,9 @@ func (pc *PublicProbeController) saveThemeConfig(c *gin.Context) { return } short := c.Param("short") - if !service.ValidThemeShort(short) || !service.ProbeThemeInstalled(short) { + // Hub save_theme_config gates on selectable, not on a disk copy: + // the built-in theme's config is savable before any install. + if !service.ValidThemeShort(short) || !service.ProbeThemeSelectable(short) { answerText(c, http.StatusBadRequest, "主题没有安装") return } diff --git a/internal/web/controller/probe_themes.go b/internal/web/controller/probe_themes.go new file mode 100644 index 0000000..3acef3e --- /dev/null +++ b/internal/web/controller/probe_themes.go @@ -0,0 +1,189 @@ +package controller + +import ( + "errors" + "io" + "net/http" + "strconv" + + "github.com/mhsanaei/3x-ui/v3/internal/web/entity" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + + "github.com/gin-gonic/gin" +) + +// Item 7 B5: theme store administration — list/install (chunked +// upload or GitHub address)/update/delete/switch, the public-surface +// settings block, and site icons. Endpoints ride the panel- +// authenticated API group and answer in the panel's Msg envelope; +// operator-facing refusals from the service layer (a bad package, a +// bad repository address) are shown verbatim, machine faults are +// logged and answered generically. The public contract surface in +// probe_public.go is separate and stays envelope-free. + +type ProbeThemesController struct{} + +func NewProbeThemesController(g *gin.RouterGroup) *ProbeThemesController { + pc := &ProbeThemesController{} + g.GET("/probe/themes", pc.list) + g.GET("/probe/themes/:short/preview", pc.preview) + g.POST("/probe/theme-upload", pc.upload) + g.POST("/probe/theme-install", pc.install) + g.POST("/probe/themes/:short/update", pc.update) + g.POST("/probe/themes/:short/select", pc.selectTheme) + g.DELETE("/probe/themes/:short", pc.remove) + g.GET("/probe/settings", pc.getSettings) + g.PUT("/probe/settings", pc.putSettings) + return pc +} + +// themeFail answers a store operation: a refusal is the operator's +// answer, anything else is a machine fault (jsonMsgObj logs it). +func themeFail(c *gin.Context, what string, err error) { + var refusal *service.ProbeThemeRefusal + if errors.As(err, &refusal) { + c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: refusal.Msg}) + return + } + jsonMsg(c, what, err) +} + +type probeThemesPayload struct { + Themes []service.ProbeTheme `json:"themes"` + Settings service.ProbePublicSettings `json:"settings"` + Favicon string `json:"favicon"` + TouchIcon string `json:"touchIcon"` +} + +func (pc *ProbeThemesController) list(c *gin.Context) { + themes, err := service.ProbeThemes() + if err != nil { + jsonMsg(c, "load themes failed", err) + return + } + jsonObj(c, probeThemesPayload{ + Themes: themes, + Settings: service.GetProbePublicSettings(), + Favicon: service.ProbeIconSetting("favicon"), + TouchIcon: service.ProbeIconSetting("touch_icon"), + }, nil) +} + +func (pc *ProbeThemesController) preview(c *gin.Context) { + data := service.ProbeThemePreview(c.Param("short")) + if data == nil { + c.Status(http.StatusNotFound) + return + } + // Not cached: reinstalling a theme under the same name also + // replaces the image (hub theme_preview). + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "image/png", data) +} + +// upload receives one chunk of a theme.tar.gz (hub Chunk protocol: +// ?offset=&total=, the raw piece as the body). A non-final piece +// answers the length received so far; the final piece installs and +// answers the installed theme. +func (pc *ProbeThemesController) upload(c *gin.Context) { + offset, oerr := strconv.ParseInt(c.Query("offset"), 10, 64) + total, terr := strconv.ParseInt(c.Query("total"), 10, 64) + if oerr != nil || terr != nil || offset < 0 || total < 0 { + c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "分片参数不对"}) + return + } + body := io.LimitReader(c.Request.Body, service.MaxThemeArchive+1) + received, err := service.ReceiveProbeThemeChunk(offset, total, body) + if err != nil { + themeFail(c, "theme upload failed", err) + return + } + if received < total { + jsonObj(c, gin.H{"received": received}, nil) + return + } + theme, err := service.FinishProbeThemeUpload() + if err != nil { + themeFail(c, "theme install failed", err) + return + } + jsonObj(c, gin.H{"theme": theme}, nil) +} + +type probeInstallForm struct { + URL string `json:"url"` +} + +func (pc *ProbeThemesController) install(c *gin.Context) { + var form probeInstallForm + if err := c.ShouldBindJSON(&form); err != nil { + c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "请求格式不对"}) + return + } + theme, err := service.InstallProbeThemeFromRepo(form.URL) + if err != nil { + themeFail(c, "theme install failed", err) + return + } + jsonObj(c, gin.H{"theme": theme}, nil) +} + +func (pc *ProbeThemesController) update(c *gin.Context) { + updated, version, err := service.ProbeThemeUpdate(c.Param("short")) + if err != nil { + themeFail(c, "theme update failed", err) + return + } + jsonObj(c, gin.H{"updated": updated, "version": version}, nil) +} + +func (pc *ProbeThemesController) selectTheme(c *gin.Context) { + if err := service.SetProbeCurrentTheme(c.Param("short")); err != nil { + themeFail(c, "theme switch failed", err) + return + } + jsonMsg(c, "", nil) +} + +func (pc *ProbeThemesController) remove(c *gin.Context) { + if err := service.RemoveProbeTheme(c.Param("short")); err != nil { + themeFail(c, "theme delete failed", err) + return + } + jsonMsg(c, "", nil) +} + +type probeSettingsForm struct { + service.ProbePublicSettings + Favicon string `json:"favicon"` + TouchIcon string `json:"touchIcon"` +} + +func (pc *ProbeThemesController) getSettings(c *gin.Context) { + jsonObj(c, probeSettingsForm{ + ProbePublicSettings: service.GetProbePublicSettings(), + Favicon: service.ProbeIconSetting("favicon"), + TouchIcon: service.ProbeIconSetting("touch_icon"), + }, nil) +} + +func (pc *ProbeThemesController) putSettings(c *gin.Context) { + var form probeSettingsForm + if err := c.ShouldBindJSON(&form); err != nil { + c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "请求格式不对"}) + return + } + if err := service.SetProbePublicSettings(form.ProbePublicSettings); err != nil { + themeFail(c, "save probe settings failed", err) + return + } + if err := service.SetProbeIconSetting("favicon", form.Favicon); err != nil { + themeFail(c, "save probe settings failed", err) + return + } + if err := service.SetProbeIconSetting("touch_icon", form.TouchIcon); err != nil { + themeFail(c, "save probe settings failed", err) + return + } + jsonMsg(c, "", nil) +} diff --git a/internal/web/web.go b/internal/web/web.go index e915a69..fadbae1 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -196,8 +196,16 @@ func (s *Server) initRouter() (*gin.Engine, error) { store := cookie.NewStore(secret) // Configure default session cookie options, including expiration (MaxAge) + // + // The cookie path is "/" rather than the panel base path (item 7 + // B5): the public surface lives at the engine root, outside the + // base path, and its session-gated endpoints — theme config save, + // /api/me's authed flag — can only see the operator's session if + // the browser sends the cookie there. The cookie stays HttpOnly + // and encrypted; theme scripts cannot read it, and the config + // endpoint keeps its Sec-Fetch-Site cross-origin refusal. sessionOptions := sessions.Options{ - Path: basePath, + Path: "/", HttpOnly: true, Secure: directHTTPS, SameSite: http.SameSiteLaxMode,