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() }