577 lines
15 KiB
Go
577 lines
15 KiB
Go
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()
|
||
}
|