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).
190 lines
5.8 KiB
Go
190 lines
5.8 KiB
Go
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)
|
|
}
|