Files
yunzerwebsiteallinone/go/controllers/platform_template.go
T
2026-08-19 23:34:28 +08:00

577 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"server/models"
"server/pkg/tagengine"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformTemplateController 官网模板管理(平台端专用)
// 模板文件存磁盘 themes/{code}/,DB 仅登记元信息;租户端只读选用。
type PlatformTemplateController struct {
beego.Controller
}
func (c *PlatformTemplateController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
// cmsThemesRoot 模板根目录(相对运行目录),渲染与静态路径保持一致
func cmsThemesRoot() string {
return "themes"
}
// cmsTemplateSafePath 校验 code 与相对路径,返回模板文件的绝对安全路径(防目录穿越)
func cmsTemplateSafePath(code, rel string) (string, error) {
if !reThemeCode.MatchString(code) {
return "", fmt.Errorf("模板编码不合法")
}
rel = strings.TrimSpace(rel)
if rel == "" {
return "", fmt.Errorf("文件路径不能为空")
}
rel = strings.ReplaceAll(rel, "\\", "/")
if strings.Contains(rel, "..") {
return "", fmt.Errorf("文件路径不合法")
}
cleaned := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+rel)), "/")
if cleaned == "" || cleaned == "." {
return "", fmt.Errorf("文件路径不合法")
}
root, err := filepath.Abs(cmsThemesRoot())
if err != nil {
return "", fmt.Errorf("模板目录解析失败")
}
full := filepath.Join(root, code, filepath.FromSlash(cleaned))
abs, err := filepath.Abs(full)
if err != nil || !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", fmt.Errorf("文件路径不合法")
}
return abs, nil
}
// cmsTemplateDirExists 判断模板目录是否存在
func cmsTemplateDirExists(code string) bool {
if !reThemeCode.MatchString(code) {
return false
}
info, err := os.Stat(filepath.Join(cmsThemesRoot(), code))
return err == nil && info.IsDir()
}
func cmsTemplateToMap(row models.CmsFrontendTemplate) map[string]interface{} {
return map[string]interface{}{
"id": row.ID,
"code": row.Code,
"name": row.Name,
"description": row.Description,
"preview": row.Preview,
"status": row.Status,
"sort": row.Sort,
"dir_exists": cmsTemplateDirExists(row.Code),
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
}
}
// Index GET /platform/template/index 模板列表(DB 登记)
func (c *PlatformTemplateController) Index() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.CmsFrontendTemplate
_, err := models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "获取模板列表失败")
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, cmsTemplateToMap(r))
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"list": list, "total": len(list)},
}
_ = c.ServeJSON()
}
// cmsScanThemeDirs 扫描 themes/ 目录,返回磁盘上的模板编码列表
func cmsScanThemeDirs() []string {
entries, err := os.ReadDir(cmsThemesRoot())
if err != nil {
return nil
}
out := []string{}
for _, e := range entries {
if e.IsDir() && reThemeCode.MatchString(e.Name()) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
// Scan POST /platform/template/scan 扫描 themes/ 目录,新目录自动登记入库
func (c *PlatformTemplateController) Scan() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
codes := cmsScanThemeDirs()
added := 0
now := time.Now()
for _, code := range codes {
cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("code", code).
Filter("delete_time__isnull", true).
Count()
if cnt > 0 {
continue
}
name := code
if code == "default" {
name = "默认模板"
}
row := models.CmsFrontendTemplate{
Code: code,
Name: name,
Description: "目录扫描自动登记",
Preview: "/themes/" + code + "/static/preview.png",
Status: 1,
CreateTime: now,
UpdateTime: &now,
}
if _, err := models.Orm.Insert(&row); err == nil {
added++
}
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "扫描完成",
"data": map[string]interface{}{"scanned": len(codes), "added": added},
}
_ = c.ServeJSON()
}
// Upload POST /platform/template/upload 上传模板 zip(解压到 themes/{code}/)
func (c *PlatformTemplateController) Upload() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
f, header, err := c.GetFile("file")
if err != nil || header == nil {
c.jsonErr(400, 400, "请上传模板 zip 文件")
return
}
defer f.Close()
code := strings.TrimSpace(c.GetString("code"))
if code == "" {
// 未显式指定编码时取上传文件名(去扩展名)
code = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
code = strings.ToLower(strings.TrimSpace(code))
if !reThemeCode.MatchString(code) {
c.jsonErr(400, 400, "模板编码不合法(仅允许字母数字下划线中划线)")
return
}
if header.Size > 50<<20 {
c.jsonErr(400, 400, "模板包过大(限制 50MB)")
return
}
tmp, err := os.CreateTemp("", "yz_template_*.zip")
if err != nil {
c.jsonErr(500, 500, "临时文件创建失败")
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmp, f); err != nil {
tmp.Close()
c.jsonErr(500, 500, "上传失败")
return
}
tmp.Close()
if err := cmsUnzipTemplate(tmpPath, filepath.Join(cmsThemesRoot(), code)); err != nil {
c.jsonErr(400, 400, "解压失败: "+err.Error())
return
}
// 解压成功后自动登记/更新 DB
now := time.Now()
cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("code", code).
Filter("delete_time__isnull", true).
Count()
if cnt == 0 {
row := models.CmsFrontendTemplate{
Code: code,
Name: code,
Description: "zip 上传自动登记",
Preview: "/themes/" + code + "/static/preview.png",
Status: 1,
CreateTime: now,
UpdateTime: &now,
}
_, _ = models.Orm.Insert(&row)
} else {
_, _ = models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("code", code).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{"update_time": now})
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "上传成功",
"data": map[string]interface{}{"code": code},
}
_ = c.ServeJSON()
}
// cmsUnzipTemplate 解压模板 zip 到目标目录;
// 支持 zip 内含单一顶层目录的情况(自动剥离),并拦截 zip-slip。
func cmsUnzipTemplate(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("无效的 zip 文件")
}
defer r.Close()
// 探测是否所有文件都在同一顶层目录下
topDir := ""
singleTop := true
for _, f := range r.File {
name := strings.ReplaceAll(f.Name, "\\", "/")
if name == "" || strings.Contains(name, "..") {
continue
}
parts := strings.Split(strings.Trim(name, "/"), "/")
if topDir == "" {
topDir = parts[0]
} else if parts[0] != topDir {
singleTop = false
break
}
}
if len(r.File) == 0 {
return fmt.Errorf("zip 内容为空")
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("模板目录创建失败")
}
destAbs, _ := filepath.Abs(destDir)
totalSize := int64(0)
for _, f := range r.File {
name := strings.ReplaceAll(f.Name, "\\", "/")
if name == "" || strings.Contains(name, "..") {
continue
}
rel := name
if singleTop && topDir != "" {
parts := strings.Split(strings.Trim(name, "/"), "/")
if len(parts) == 1 && f.FileInfo().IsDir() {
continue // 顶层目录本身
}
rel = strings.Join(parts[1:], "/")
if rel == "" {
continue
}
}
target := filepath.Join(destAbs, filepath.FromSlash(rel))
tabs, _ := filepath.Abs(target)
if !strings.HasPrefix(tabs, destAbs+string(filepath.Separator)) {
continue // 越界条目直接跳过
}
if f.FileInfo().IsDir() {
_ = os.MkdirAll(tabs, 0o755)
continue
}
_ = os.MkdirAll(filepath.Dir(tabs), 0o755)
rc, err := f.Open()
if err != nil {
return fmt.Errorf("读取 zip 条目失败")
}
out, err := os.Create(tabs)
if err != nil {
rc.Close()
return fmt.Errorf("写入模板文件失败")
}
n, err := io.Copy(out, rc)
out.Close()
rc.Close()
if err != nil {
return fmt.Errorf("写入模板文件失败")
}
totalSize += n
if totalSize > 200<<20 {
return fmt.Errorf("解压内容过大(限制 200MB)")
}
}
return nil
}
// Files GET /platform/template/files/:code 列出模板目录下可编辑文件
func (c *PlatformTemplateController) Files() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
code := c.GetString(":code")
if !cmsTemplateDirExists(code) {
c.jsonErr(404, 404, "模板目录不存在")
return
}
base := filepath.Join(cmsThemesRoot(), code)
files := []map[string]interface{}{}
_ = filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".html" && ext != ".css" && ext != ".js" {
return nil
}
rel, _ := filepath.Rel(base, path)
files = append(files, map[string]interface{}{
"path": filepath.ToSlash(rel),
"size": info.Size(),
})
return nil
})
sort.Slice(files, func(i, j int) bool {
return files[i]["path"].(string) < files[j]["path"].(string)
})
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"code": code, "files": files},
}
_ = c.ServeJSON()
}
// ReadFile GET /platform/template/file?code=&path= 读取模板文件内容
func (c *PlatformTemplateController) ReadFile() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
code := strings.TrimSpace(c.GetString("code"))
path := strings.TrimSpace(c.GetString("path"))
full, err := cmsTemplateSafePath(code, path)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
ext := strings.ToLower(filepath.Ext(full))
if ext != ".html" && ext != ".css" && ext != ".js" {
c.jsonErr(400, 400, "仅支持读取 html/css/js 文件")
return
}
data, err := os.ReadFile(full)
if err != nil {
c.jsonErr(404, 404, "文件不存在")
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"code": code, "path": path, "content": string(data)},
}
_ = c.ServeJSON()
}
// SaveFile POST /platform/template/file/save 保存模板文件(路径校验防穿越)
func (c *PlatformTemplateController) SaveFile() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var body struct {
Code string `json:"code"`
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(raw, &body); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
full, err := cmsTemplateSafePath(strings.TrimSpace(body.Code), body.Path)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
ext := strings.ToLower(filepath.Ext(full))
if ext != ".html" && ext != ".css" && ext != ".js" {
c.jsonErr(400, 400, "仅支持保存 html/css/js 文件")
return
}
if len(body.Content) > 2<<20 {
c.jsonErr(400, 400, "文件内容过大(限制 2MB)")
return
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
c.jsonErr(500, 500, "保存失败")
return
}
if err := os.WriteFile(full, []byte(body.Content), 0o644); err != nil {
c.jsonErr(500, 500, "保存失败")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"}
_ = c.ServeJSON()
}
// Status POST /platform/template/status 启用/停用模板
func (c *PlatformTemplateController) Status() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var body struct {
ID uint64 `json:"id"`
Status int8 `json:"status"`
}
if err := json.Unmarshal(raw, &body); err != nil || body.ID == 0 {
c.jsonErr(400, 400, "参数错误")
return
}
if body.Status != 0 && body.Status != 1 {
c.jsonErr(400, 400, "状态值不合法")
return
}
now := time.Now()
n, err := models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("id", body.ID).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{"status": body.Status, "update_time": now})
if err != nil {
c.jsonErr(500, 500, "操作失败")
return
}
if n == 0 {
c.jsonErr(404, 404, "模板不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "操作成功"}
_ = c.ServeJSON()
}
// Delete POST /platform/template/delete/:id 删除登记(可选同时删除磁盘目录)
func (c *PlatformTemplateController) Delete() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, _ := c.GetUint64(":id")
if id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var body struct {
RemoveFiles bool `json:"remove_files"`
}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &body)
}
var row models.CmsFrontendTemplate
err := models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("id", id).
Filter("delete_time__isnull", true).
One(&row)
if err != nil {
c.jsonErr(404, 404, "模板不存在")
return
}
if row.Code == "default" {
c.jsonErr(400, 400, "内置默认模板不可删除")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
Filter("id", id).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
c.jsonErr(500, 500, "删除失败")
return
}
if body.RemoveFiles && cmsTemplateDirExists(row.Code) {
_ = os.RemoveAll(filepath.Join(cmsThemesRoot(), row.Code))
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"}
_ = c.ServeJSON()
}
// Tags GET /platform/template/tags 标签调用说明数据(由引擎注册表元信息生成)
func (c *PlatformTemplateController) Tags() {
if _, err := requirePlatform(&c.Controller); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": tagengine.TagDocs(),
}
_ = c.ServeJSON()
}