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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user