Files
nl-blogs/server/handlers/template.go
2026-08-24 13:06:04 +08:00

557 lines
14 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 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
}