B5: theme admin API, hosting chain hookup, session cookie root path

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).
This commit is contained in:
Muse
2026-10-22 23:19:34 +00:00
parent 7ad65652ec
commit 2558efd9fb
4 changed files with 205 additions and 2 deletions
+4
View File
@@ -202,6 +202,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
// (/panel/api/agent/probe-tasks*). // (/panel/api/agent/probe-tasks*).
NewProbeTasksController(agentAdmin) 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 — per-inbound override endpoints for subscription links
hosts := api.Group("/hosts") hosts := api.Group("/hosts")
a.hostController = NewHostController(hosts) a.hostController = NewHostController(hosts)
+3 -1
View File
@@ -328,7 +328,9 @@ func (pc *PublicProbeController) saveThemeConfig(c *gin.Context) {
return return
} }
short := c.Param("short") 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, "主题没有安装") answerText(c, http.StatusBadRequest, "主题没有安装")
return return
} }
+189
View File
@@ -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)
}
+9 -1
View File
@@ -196,8 +196,16 @@ func (s *Server) initRouter() (*gin.Engine, error) {
store := cookie.NewStore(secret) store := cookie.NewStore(secret)
// Configure default session cookie options, including expiration (MaxAge) // 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{ sessionOptions := sessions.Options{
Path: basePath, Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: directHTTPS, Secure: directHTTPS,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,