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*).
|
// (/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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user