1. 增加了几种模板

This commit is contained in:
李琦
2026-08-24 13:06:04 +08:00
parent 1617baec19
commit 287e428e87
124 changed files with 11330 additions and 142 deletions

View File

@@ -23,7 +23,7 @@ func GetSettings(c *gin.Context) {
publicKeys := []string{
"site_title", "site_description", "site_author", "site_keywords",
"visible_menus", "posts_per_page", "works_per_page", "snippets_per_page",
"footer_icp", "contact_email", "homepage_config",
"footer_icp", "contact_email", "homepage_config", "site_template",
}
for _, key := range publicKeys {

556
server/handlers/template.go Normal file
View File

@@ -0,0 +1,556 @@
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
}