package handlers import ( "archive/zip" "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path" "path/filepath" "regexp" "strings" "github.com/gin-gonic/gin" "github.com/niangaodev/art-code/models" "github.com/niangaodev/art-code/repositories" "github.com/niangaodev/art-code/utils" ) const ( maxZipSize = 10 << 20 // 上传 zip 上限 10MB maxZipEntries = 200 // 条目数上限 maxExtractSize = 50 << 20 // 解压后总量上限 50MB templatesURLBase = "/uploads/templates" ) var templateSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) // 扩展名白名单(禁 JS/HTML/SVG,防 XSS 与任意执行) var templateExtWhitelist = map[string]bool{ ".json": true, ".css": true, ".woff2": true, ".woff": true, ".ttf": true, ".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true, ".ico": true, } func templatesUploadDir() string { base := os.Getenv("UPLOADS_BASE_PATH") if base == "" { base = "./uploads" } return filepath.Join(base, "templates") } // ---------- 公开接口 ---------- // GetTemplates 公开:返回所有启用模板的元数据列表 func GetTemplates(c *gin.Context) { list, err := repositories.GetActiveTemplates() if err != nil { utils.ServerError(c, err) return } out := make([]map[string]interface{}, 0, len(list)) for i := range list { out = append(out, templateMeta(&list[i])) } utils.Success(c, out) } // GetTemplateDescriptor 公开:返回模板完整布局描述(自定义渲染引擎拉取) func GetTemplateDescriptor(c *gin.Context) { slug := c.Param("slug") t, err := repositories.GetActiveTemplateBySlug(slug) if err != nil { utils.Error(c, 404, "Template not found or inactive") return } var manifest, layout interface{} if t.Manifest != "" { _ = json.Unmarshal([]byte(t.Manifest), &manifest) } if t.LayoutJSON != "" { _ = json.Unmarshal([]byte(t.LayoutJSON), &layout) } utils.Success(c, models.TemplateDescriptor{ Slug: t.Slug, Name: t.Name, Type: t.Type, Description: t.Description, Manifest: manifest, Layout: layout, SkinCssPath: t.SkinCSSPath, AssetsBase: fmt.Sprintf("%s/%s", templatesURLBase, t.Slug), }) } // ---------- 后台接口 ---------- // AdminGetTemplates 后台:全部模板列表 func AdminGetTemplates(c *gin.Context) { list, err := repositories.GetAllTemplates() if err != nil { utils.ServerError(c, err) return } current := repositories.GetCurrentSiteTemplateSlug() out := make([]map[string]interface{}, 0, len(list)) for i := range list { m := templateMeta(&list[i]) m["isCurrent"] = list[i].Slug == current out = append(out, m) } utils.Success(c, out) } // AdminUploadTemplate 后台:上传自定义布局包(zip) func AdminUploadTemplate(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxZipSize) fileHeader, err := c.FormFile("file") if err != nil { utils.Error(c, 400, "请上传 zip 文件(字段名 file)") return } // 校验扩展名 if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") { utils.Error(c, 400, "仅支持 .zip 格式") return } src, err := fileHeader.Open() if err != nil { utils.ServerError(c, err) return } defer src.Close() buf, err := io.ReadAll(io.LimitReader(src, maxZipSize+1)) if err != nil { utils.ServerError(c, err) return } if len(buf) > maxZipSize { utils.Error(c, 400, "zip 超过 10MB 限制") return } // zip 魔数校验 if len(buf) < 4 || !bytes.Equal(buf[:4], []byte("PK\x03\x04")) { utils.Error(c, 400, "文件不是有效的 zip 包") return } zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf))) if err != nil { utils.Error(c, 400, "zip 解析失败: "+err.Error()) return } if len(zr.File) > maxZipEntries { utils.Error(c, 400, fmt.Sprintf("zip 条目数超过 %d 限制", maxZipEntries)) return } // 遍历校验 + 提取 manifest/layout/skin manifestRaw := "" layoutRaw := "" skinName := "" totalSize := int64(0) for _, f := range zr.File { name := filepath.ToSlash(f.Name) if name == "" || strings.HasSuffix(name, "/") { continue // 目录条目 } if !safeZipPath(name) { utils.Error(c, 400, "zip 包含非法路径: "+name) return } ext := strings.ToLower(filepath.Ext(name)) if !templateExtWhitelist[ext] { utils.Error(c, 400, "不允许的文件类型 "+ext+"(仅支持 json/css/字体/图片): "+name) return } if f.UncompressedSize64 > maxExtractSize { utils.Error(c, 400, "单个文件过大: "+name) return } totalSize += int64(f.UncompressedSize64) if totalSize > maxExtractSize { utils.Error(c, 400, "解压后总量超过 50MB 限制") return } base := path.Base(name) switch { case base == "manifest.json" && !strings.Contains(name, "/"): manifestRaw, err = readZipEntry(f) if err != nil { utils.ServerError(c, err) return } case base == "layout.json" && !strings.Contains(name, "/"): layoutRaw, err = readZipEntry(f) if err != nil { utils.ServerError(c, err) return } case strings.HasSuffix(name, ".css") && !strings.Contains(name, "/"): if skinName == "" { skinName = name } } } // manifest 校验 if manifestRaw == "" { utils.Error(c, 400, "zip 根目录缺少 manifest.json") return } var manifest struct { Name string `json:"name"` Slug string `json:"slug"` Version string `json:"version"` Type string `json:"type"` Description string `json:"description"` Skin string `json:"skin"` Layout string `json:"layout"` } if err := json.Unmarshal([]byte(manifestRaw), &manifest); err != nil { utils.Error(c, 400, "manifest.json 不是合法 JSON: "+err.Error()) return } manifest.Name = strings.TrimSpace(manifest.Name) manifest.Slug = strings.TrimSpace(manifest.Slug) manifest.Version = strings.TrimSpace(manifest.Version) if manifest.Name == "" || manifest.Slug == "" || manifest.Version == "" { utils.Error(c, 400, "manifest.json 缺少必填字段 name/slug/version") return } if !templateSlugRe.MatchString(manifest.Slug) { utils.Error(c, 400, "slug 需匹配 ^[a-z0-9][a-z0-9-]{0,31}$") return } // 内置模板 slug 为保留字 builtinSlugs := map[string]bool{ repositories.BuiltinTemplateClassic: true, repositories.BuiltinTemplateTerminal: true, repositories.BuiltinTemplatePixel: true, repositories.BuiltinTemplateMagazine: true, repositories.BuiltinTemplateNewspaper: true, repositories.BuiltinTemplateBento: true, repositories.BuiltinTemplateGlass: true, repositories.BuiltinTemplateRetro: true, repositories.BuiltinTemplateGuofeng: true, } if builtinSlugs[manifest.Slug] { utils.Error(c, 400, "slug 与内置模板冲突(内置模板 slug 为保留字)") return } if manifest.Type != "" && manifest.Type != repositories.TemplateTypeCustom { utils.Error(c, 400, "manifest.type 必须为 custom") return } // 至少提供 skin 或 layout 之一,且必须在 zip 根目录 if manifest.Skin == "" && manifest.Layout == "" { utils.Error(c, 400, "manifest 至少需要 skin 或 layout 字段之一") return } if manifest.Skin != "" && path.Base(filepath.ToSlash(manifest.Skin)) != manifest.Skin { utils.Error(c, 400, "skin 必须是 zip 根目录下的 .css 文件") return } if manifest.Skin != "" && !strings.HasSuffix(manifest.Skin, ".css") { utils.Error(c, 400, "skin 必须是 .css 文件") return } if manifest.Layout != "" && !strings.HasSuffix(manifest.Layout, ".json") { utils.Error(c, 400, "layout 必须是 .json 文件") return } // 校验 zip 中确实包含所声明的文件 if manifest.Skin != "" && skinName == "" { utils.Error(c, 400, "zip 中缺少 manifest 声明的 skin 文件: "+manifest.Skin) return } if manifest.Layout != "" && layoutRaw == "" { utils.Error(c, 400, "zip 中缺少 manifest 声明的 layout 文件: "+manifest.Layout) return } // layout.json 若存在必须为合法 JSON if layoutRaw != "" { var probe interface{} if err := json.Unmarshal([]byte(layoutRaw), &probe); err != nil { utils.Error(c, 400, "layout.json 不是合法 JSON: "+err.Error()) return } } // slug 冲突检查 if existing, _ := repositories.GetTemplateBySlug(manifest.Slug); existing != nil { utils.Error(c, 409, "模板 slug 已存在: "+manifest.Slug) return } // 落盘 dir := filepath.Join(templatesUploadDir(), manifest.Slug) if err := os.MkdirAll(dir, 0o755); err != nil { utils.ServerError(c, err) return } rollback := true defer func() { if rollback { _ = os.RemoveAll(dir) } }() for _, f := range zr.File { name := filepath.ToSlash(f.Name) if name == "" || strings.HasSuffix(name, "/") { continue } target := filepath.Join(dir, filepath.FromSlash(name)) // 双重路径校验(Join 后必须仍在 dir 内) rel, err := filepath.Rel(dir, target) if err != nil || strings.HasPrefix(rel, "..") { utils.Error(c, 400, "zip 包含非法路径: "+name) return } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { utils.ServerError(c, err) return } if err := writeZipEntry(f, target); err != nil { utils.ServerError(c, err) return } } // 落库 skinCssPath := "" if manifest.Skin != "" { skinCssPath = fmt.Sprintf("%s/%s/%s", templatesURLBase, manifest.Slug, manifest.Skin) } t := models.Template{ Slug: manifest.Slug, Name: manifest.Name, Type: repositories.TemplateTypeCustom, Description: manifest.Description, Manifest: manifestRaw, LayoutJSON: layoutRaw, SkinCSSPath: skinCssPath, IsSystem: false, IsActive: false, // 上传后默认停用,需手动启用 SortOrder: 100, } if err := repositories.CreateTemplate(&t); err != nil { utils.ServerError(c, err) return } rollback = false utils.SuccessWithMsg(c, "模板上传成功", templateMeta(&t)) } // AdminUpdateTemplate 后台:更新模板 name/description/isActive func AdminUpdateTemplate(c *gin.Context) { t, ok := getTemplateByIDParam(c) if !ok { return } var req struct { Name *string `json:"name"` Description *string `json:"description"` IsActive *bool `json:"isActive"` } if err := c.ShouldBindJSON(&req); err != nil { utils.Error(c, 400, "Invalid request") return } if req.Name != nil { if t.IsSystem { utils.Error(c, 400, "系统内置模板不可修改名称") return } name := strings.TrimSpace(*req.Name) if name == "" { utils.Error(c, 400, "Name is required") return } t.Name = name } if req.Description != nil { t.Description = *req.Description } if req.IsActive != nil { if t.IsSystem { utils.Error(c, 400, "系统内置模板不可停用") return } // 不允许停用当前正在使用的模板 if !*req.IsActive && t.Slug == repositories.GetCurrentSiteTemplateSlug() { utils.Error(c, 400, "当前正在使用的模板不能停用") return } t.IsActive = *req.IsActive } if err := repositories.UpdateTemplate(t); err != nil { utils.ServerError(c, err) return } utils.SuccessWithMsg(c, "模板已更新", templateMeta(t)) } // AdminGetTemplateDetail 后台:模板详情(含 manifest/layout 全文,供详情面板展示) func AdminGetTemplateDetail(c *gin.Context) { t, ok := getTemplateByIDParam(c) if !ok { return } var manifest, layout interface{} if t.Manifest != "" { _ = json.Unmarshal([]byte(t.Manifest), &manifest) } if t.LayoutJSON != "" { _ = json.Unmarshal([]byte(t.LayoutJSON), &layout) } m := templateMeta(t) m["isCurrent"] = t.Slug == repositories.GetCurrentSiteTemplateSlug() m["manifest"] = manifest m["layout"] = layout m["assetsBase"] = fmt.Sprintf("%s/%s", templatesURLBase, t.Slug) utils.Success(c, m) } // AdminActivateTemplate 后台:将模板设为站点当前模板 func AdminActivateTemplate(c *gin.Context) { t, ok := getTemplateByIDParam(c) if !ok { return } if !t.IsActive { utils.Error(c, 400, "模板未启用,无法设为当前模板") return } if err := repositories.ActivateTemplate(t.Slug); err != nil { utils.ServerError(c, err) return } utils.SuccessWithMsg(c, "已切换模板", gin.H{"slug": t.Slug}) } // AdminDeleteTemplate 后台:删除模板(仅自定义且非当前使用) func AdminDeleteTemplate(c *gin.Context) { idStr := c.Param("id") var id uint if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil { utils.Error(c, 400, "Invalid ID") return } t, err := repositories.GetTemplateByID(id) if err != nil { utils.Error(c, 404, "Template not found") return } if err := repositories.DeleteTemplate(id); err != nil { if errors.Is(err, repositories.ErrTemplateSystemDelete) { utils.Error(c, 400, "系统内置模板不可删除") return } if errors.Is(err, repositories.ErrTemplateInUse) { utils.Error(c, 400, "当前正在使用的模板不可删除,请先切换到其他模板") return } utils.ServerError(c, err) return } // 物理删除文件目录 _ = os.RemoveAll(filepath.Join(templatesUploadDir(), t.Slug)) utils.SuccessWithMsg(c, "模板已删除", nil) } // ---------- helpers ---------- func templateMeta(t *models.Template) map[string]interface{} { return map[string]interface{}{ "id": t.ID, "slug": t.Slug, "name": t.Name, "type": t.Type, "description": t.Description, "skinCssPath": t.SkinCSSPath, "isSystem": t.IsSystem, "isActive": t.IsActive, "sortOrder": t.SortOrder, "createdAt": t.CreatedAt, "updatedAt": t.UpdatedAt, } } func getTemplateByIDParam(c *gin.Context) (*models.Template, bool) { idStr := c.Param("id") var id uint if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil { utils.Error(c, 400, "Invalid ID") return nil, false } t, err := repositories.GetTemplateByID(id) if err != nil { utils.Error(c, 404, "Template not found") return nil, false } return t, true } // safeZipPath 路径穿越防护:拒绝绝对路径、反斜杠、.. 段 func safeZipPath(name string) bool { if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "\\") { return false } clean := path.Clean(name) if clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { return false } return true } func readZipEntry(f *zip.File) (string, error) { rc, err := f.Open() if err != nil { return "", err } defer rc.Close() b, err := io.ReadAll(rc) if err != nil { return "", err } return string(b), nil } func writeZipEntry(f *zip.File, target string) error { rc, err := f.Open() if err != nil { return err } defer rc.Close() out, err := os.Create(target) if err != nil { return err } defer out.Close() _, err = io.Copy(out, rc) return err }