三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开): 一、分工作表报名模板(新增能力) - 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板, 一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。 - 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。 - 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。 - 单表 CSV/XLSX 导入路径不回归。 二、号码布判重(修误判) - 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。 - 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行; 号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。 - 团体行的号码布一并纳入查重。 三、自动备份的关停与落盘(修数据安全隐患) - export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限, 超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。 - vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件; Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。 - 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件, 避免半截 .db 被列进备份列表、进而被「一键恢复」选中。 文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
709 lines
23 KiB
Go
709 lines
23 KiB
Go
package export
|
||
|
||
import (
|
||
"archive/zip"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
const (
|
||
// projectFormat 是赛事项目包的标识,导入时用来挡住「拿错文件」。
|
||
projectFormat = "scoreboard-event-project"
|
||
// projectFormatVersion 是当前程序能读写的包格式版本。
|
||
projectFormatVersion = 1
|
||
// eventDBName 是包内赛事库的固定文件名。
|
||
eventDBName = "event.db"
|
||
)
|
||
|
||
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
|
||
// 定义为变量便于测试用较小的上限验证限流逻辑。
|
||
var maxExtractBytes int64 = 4 << 30
|
||
|
||
// ProjectFile 描述包内一个文件的校验信息。
|
||
type ProjectFile struct {
|
||
Path string `yaml:"path"`
|
||
Size int64 `yaml:"size"`
|
||
SHA256 string `yaml:"sha256"`
|
||
}
|
||
|
||
// ProjectMeta 是包内 meta.yaml 的映射;导入时先校验 format/formatVersion 与校验和。
|
||
type ProjectMeta struct {
|
||
Format string `yaml:"format"`
|
||
FormatVersion int `yaml:"formatVersion"`
|
||
Name string `yaml:"name"`
|
||
EventName string `yaml:"eventName,omitempty"`
|
||
Subtitle string `yaml:"subtitle,omitempty"`
|
||
Organizer string `yaml:"organizer,omitempty"`
|
||
CreatedAt time.Time `yaml:"createdAt"`
|
||
CreatedBy string `yaml:"createdBy,omitempty"`
|
||
AppVersion string `yaml:"appVersion,omitempty"`
|
||
Database ProjectFile `yaml:"database"`
|
||
Files []ProjectFile `yaml:"files"`
|
||
}
|
||
|
||
// ProjectExportOptions 描述一次赛事包导出。
|
||
type ProjectExportOptions struct {
|
||
Name string // 项目名,导入后落成 data/event_projs/<Name>/
|
||
EventName string // 留空则读赛事库 settings
|
||
Subtitle string
|
||
Organizer string
|
||
CreatedBy string
|
||
AppVersion string
|
||
UploadsDir string // 可选:背景图 / 会标目录
|
||
FontsDir string // 可选:上传字体目录(运行时的 <项目目录>/uploads/fonts)
|
||
IncludeFonts bool // 是否把字体一并打包(打进包内 uploads/fonts/)
|
||
RulesPath string // 可选:规则包 YAML
|
||
Now time.Time
|
||
}
|
||
|
||
// ImportOptions 描述一次赛事包导入。
|
||
type ImportOptions struct {
|
||
// Overwrite 为 true 时覆盖同名项目;覆盖前旧目录会先改名,避免删到一半失败。
|
||
Overwrite bool
|
||
// Rename 非空时用该名字作为目录名(解决「两台机器都有同名赛事」)。
|
||
Rename string
|
||
}
|
||
|
||
// ImportResult 是导入结果。
|
||
type ImportResult struct {
|
||
Name string `json:"name"`
|
||
Dir string `json:"dir"`
|
||
EventDB string `json:"eventDB"`
|
||
Meta ProjectMeta `json:"meta"`
|
||
}
|
||
|
||
// ExportProjectZip 把当前赛事库、uploads、可选字体与规则包打成一个 zip。
|
||
// 包内 event.db 用 VACUUM INTO 生成,即使服务正在写入也是一致快照。
|
||
func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (ProjectMeta, error) {
|
||
if db == nil {
|
||
return ProjectMeta{}, ErrNoDatabase
|
||
}
|
||
name, err := SafeProjectName(opts.Name)
|
||
if err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
|
||
meta, _, err := resolveMeta(db, Meta{EventName: opts.EventName, Subtitle: opts.Subtitle, Organizer: opts.Organizer})
|
||
if err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
|
||
tmp, err := os.MkdirTemp("", "scoreboard-project-*")
|
||
if err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("export: 创建临时目录: %w", err)
|
||
}
|
||
defer func() { _ = os.RemoveAll(tmp) }()
|
||
|
||
if err := vacuumInto(db, filepath.Join(tmp, eventDBName)); err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
// uploads 即使为空也要保留目录,导入方才知道该往哪放资源。
|
||
uploads := filepath.Join(tmp, "uploads")
|
||
if err := os.MkdirAll(uploads, 0o755); err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("export: 创建 uploads 目录: %w", err)
|
||
}
|
||
if opts.UploadsDir != "" {
|
||
if err := copyTree(opts.UploadsDir, uploads); err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
}
|
||
if opts.IncludeFonts && opts.FontsDir != "" {
|
||
// 字体在包内的位置就是运行时的位置(<项目目录>/uploads/fonts/),
|
||
// 否则导入后字体落在没人读的 fonts/ 下,兜底就成了假兜底。
|
||
if err := copyTree(opts.FontsDir, filepath.Join(uploads, "fonts")); err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
}
|
||
if opts.RulesPath != "" {
|
||
if err := copyFile(opts.RulesPath, filepath.Join(tmp, "rules.yaml")); err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
}
|
||
|
||
files, err := buildManifest(tmp)
|
||
if err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
dbFile, ok := findFile(files, eventDBName)
|
||
if !ok {
|
||
return ProjectMeta{}, fmt.Errorf("%w: 缺少 %s", ErrInvalidPackage, eventDBName)
|
||
}
|
||
|
||
pkg := ProjectMeta{
|
||
Format: projectFormat,
|
||
FormatVersion: projectFormatVersion,
|
||
Name: name,
|
||
EventName: meta.EventName,
|
||
Subtitle: meta.Subtitle,
|
||
Organizer: meta.Organizer,
|
||
CreatedAt: nowOr(opts.Now),
|
||
CreatedBy: opts.CreatedBy,
|
||
AppVersion: opts.AppVersion,
|
||
Database: dbFile,
|
||
Files: files,
|
||
}
|
||
raw, err := yaml.Marshal(&pkg)
|
||
if err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("export: 生成 meta.yaml: %w", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(tmp, "meta.yaml"), raw, 0o644); err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("export: 写入 meta.yaml: %w", err)
|
||
}
|
||
if err := zipDir(tmp, dst); err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
return pkg, nil
|
||
}
|
||
|
||
// InspectProjectZip 只读取包内 meta.yaml 并校验格式,不解压——导入前预览用。
|
||
func InspectProjectZip(src string) (ProjectMeta, error) {
|
||
r, err := zip.OpenReader(src)
|
||
if err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
|
||
}
|
||
defer func() { _ = r.Close() }()
|
||
|
||
for _, f := range r.File {
|
||
if path.Clean(strings.ReplaceAll(f.Name, "\\", "/")) != "meta.yaml" {
|
||
continue
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
|
||
}
|
||
defer func() { _ = rc.Close() }()
|
||
|
||
raw, err := io.ReadAll(io.LimitReader(rc, 1<<20))
|
||
if err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
|
||
}
|
||
var meta ProjectMeta
|
||
if err := yaml.Unmarshal(raw, &meta); err != nil {
|
||
return ProjectMeta{}, fmt.Errorf("%w: 解析 meta.yaml: %v", ErrInvalidPackage, err)
|
||
}
|
||
if err := validateMeta(meta); err != nil {
|
||
return ProjectMeta{}, err
|
||
}
|
||
return meta, nil
|
||
}
|
||
return ProjectMeta{}, fmt.Errorf("%w: 缺少 meta.yaml", ErrInvalidPackage)
|
||
}
|
||
|
||
func validateMeta(meta ProjectMeta) error {
|
||
if meta.Format != projectFormat {
|
||
return fmt.Errorf("%w: format=%q", ErrInvalidPackage, meta.Format)
|
||
}
|
||
if meta.FormatVersion < 1 || meta.FormatVersion > projectFormatVersion {
|
||
return fmt.Errorf("%w: formatVersion=%d(本程序支持 1..%d)", ErrUnsupportedVersion, meta.FormatVersion, projectFormatVersion)
|
||
}
|
||
if _, err := SafeProjectName(meta.Name); err != nil {
|
||
return err
|
||
}
|
||
if path.Clean(meta.Database.Path) != eventDBName {
|
||
return fmt.Errorf("%w: database.path=%q", ErrInvalidPackage, meta.Database.Path)
|
||
}
|
||
if meta.Database.SHA256 == "" {
|
||
return fmt.Errorf("%w: meta.yaml 缺少 event.db 校验和", ErrInvalidPackage)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ImportProjectZip 校验并解压赛事包到 projRoot/<项目名>/,返回落盘位置。
|
||
func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, error) {
|
||
meta, err := InspectProjectZip(src)
|
||
if err != nil {
|
||
return ImportResult{}, err
|
||
}
|
||
name := meta.Name
|
||
if strings.TrimSpace(opts.Rename) != "" {
|
||
name = opts.Rename
|
||
}
|
||
name, err = SafeProjectName(name)
|
||
if err != nil {
|
||
return ImportResult{}, err
|
||
}
|
||
|
||
if err := os.MkdirAll(projRoot, 0o755); err != nil {
|
||
return ImportResult{}, fmt.Errorf("export: 创建赛事项目根目录: %w", err)
|
||
}
|
||
tmp, err := os.MkdirTemp(projRoot, ".import-*")
|
||
if err != nil {
|
||
return ImportResult{}, fmt.Errorf("export: 创建导入临时目录: %w", err)
|
||
}
|
||
defer func() { _ = os.RemoveAll(tmp) }()
|
||
|
||
if err := extractZip(src, tmp); err != nil {
|
||
return ImportResult{}, err
|
||
}
|
||
if err := verifyManifest(tmp, meta); err != nil {
|
||
return ImportResult{}, err
|
||
}
|
||
eventDB := filepath.Join(tmp, eventDBName)
|
||
if err := verifySQLite(eventDB); err != nil {
|
||
return ImportResult{}, err
|
||
}
|
||
|
||
target := filepath.Join(projRoot, name)
|
||
if err := swapProjectDir(tmp, target, opts.Overwrite, os.Rename); err != nil {
|
||
return ImportResult{}, err
|
||
}
|
||
return ImportResult{
|
||
Name: name,
|
||
Dir: target,
|
||
EventDB: filepath.Join(target, eventDBName),
|
||
Meta: meta,
|
||
}, nil
|
||
}
|
||
|
||
// swapProjectDir 用 src 目录替换 target 目录。
|
||
// 覆盖时先把旧目录改名暂存,src 落盘成功后才删除暂存目录;src 落盘失败则把旧目录改回来,
|
||
// 绝不出现「旧项目已删、新项目没成」的空档。rename 注入仅为测试模拟第二次改名失败。
|
||
func swapProjectDir(src, target string, overwrite bool, rename func(old, new string) error) error {
|
||
name := filepath.Base(target)
|
||
stale := ""
|
||
if _, err := os.Stat(target); err == nil {
|
||
if !overwrite {
|
||
return fmt.Errorf("%w: %s", ErrProjectExists, name)
|
||
}
|
||
stale = target + ".replaced-" + time.Now().Format("20060102-150405")
|
||
if err := rename(target, stale); err != nil {
|
||
return fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
|
||
}
|
||
} else if !os.IsNotExist(err) {
|
||
return fmt.Errorf("export: 检查项目目录 %s: %w", target, err)
|
||
}
|
||
if err := rename(src, target); err != nil {
|
||
if stale != "" {
|
||
if rbErr := rename(stale, target); rbErr != nil {
|
||
return fmt.Errorf("export: 落盘赛事项目 %s: %w(旧项目回滚失败,暂存在 %s: %v)", name, err, stale, rbErr)
|
||
}
|
||
}
|
||
return fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
|
||
}
|
||
if stale != "" {
|
||
// 新项目已就位,清理旧目录失败不影响本次导入,留待下次人工处理。
|
||
_ = os.RemoveAll(stale)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
|
||
func SafeProjectName(name string) (string, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return "", fmt.Errorf("%w: 名称为空", ErrUnsafeName)
|
||
}
|
||
if name == "." || name == ".." {
|
||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||
}
|
||
if len([]rune(name)) > 64 {
|
||
return "", fmt.Errorf("%w: 名称超过 64 个字符", ErrUnsafeName)
|
||
}
|
||
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
|
||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||
}
|
||
// Windows 会静默剥掉目录名末尾的点与空格,"赛事." 与 "赛事" 会撞成同一个目录。
|
||
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
|
||
return "", fmt.Errorf("%w: %q 不能以点或空格结尾", ErrUnsafeName, name)
|
||
}
|
||
for _, r := range name {
|
||
if r < 0x20 {
|
||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||
}
|
||
}
|
||
if filepath.IsAbs(name) {
|
||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||
}
|
||
base := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
|
||
switch base {
|
||
case "CON", "PRN", "AUX", "NUL",
|
||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
|
||
return "", fmt.Errorf("%w: %q 是系统保留名", ErrUnsafeName, name)
|
||
}
|
||
return name, nil
|
||
}
|
||
|
||
// tmpMark 是「中间文件」在文件名里的标记:备份先写成 <最终名>.tmp-<随机>,成功后再改名。
|
||
// ListBackups / safetyCopies 靠它把这些文件挡在备份列表之外(见 isFinalBackupName)。
|
||
const tmpMark = ".tmp"
|
||
|
||
// vacuumInto 把 db 的快照写进 dst,并且**只以完整文件的形态出现在 dst 这个名字上**:
|
||
// 先写同目录的中间文件 <dst>.tmp-<随机>,VACUUM 成功后再改名过去。
|
||
//
|
||
// 不能直接写 dst。VACUUM INTO 是「先按最终名建出空文件,再往里灌页」:源库一旦被别的
|
||
// 进程/连接锁住(或有别的写入卡着),它会带着一个 0 字节的目标文件在那里等(实测),
|
||
// 进程此时退出(正常关闭 / 被 Console 杀掉 / 掉电)就在原地留下半截 .db。
|
||
// 备份目录里所有 .db 都会被 ListBackups 列出来、被「一键恢复」当作可恢复的文件选中,
|
||
// 而备份是最后一道防线——它自己不能是个坏文件。
|
||
//
|
||
// 中间文件与最终文件同目录,改名不跨文件系统;失败(含 VACUUM 中途失败)时中间文件会被删掉。
|
||
func vacuumInto(db *sql.DB, dst string) error {
|
||
tmp, err := vacuumTempPath(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
|
||
stmt := "VACUUM INTO '" + strings.ReplaceAll(tmp, "'", "''") + "'"
|
||
if _, err := db.Exec(stmt); err != nil {
|
||
// 半截的中间文件既不是备份,也不该在备份目录里攒着——删不掉不算新错误,
|
||
// 照原样回报 VACUUM 的失败(它才是操作员要看的原因)。
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
|
||
}
|
||
if err := os.Rename(tmp, dst); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("export: 提交备份文件 %s: %w", dst, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// vacuumTempPath 给一次 VACUUM INTO 挑一个中间文件名。
|
||
//
|
||
// 只挑名字、不建文件:VACUUM INTO 拒绝写已经存在的目标文件。随机串用来避开
|
||
// 「上一次异常退出留下的同名中间文件」与「并发的 CLI 备份(scoreboard backup)」。
|
||
func vacuumTempPath(dst string) (string, error) {
|
||
dir, base := filepath.Split(dst)
|
||
var buf [8]byte
|
||
if _, err := rand.Read(buf[:]); err != nil {
|
||
return "", fmt.Errorf("export: 生成备份中间文件名: %w", err)
|
||
}
|
||
p := filepath.Join(dir, base+tmpMark+"-"+hex.EncodeToString(buf[:]))
|
||
if _, err := os.Stat(p); err == nil {
|
||
return "", fmt.Errorf("export: 备份中间文件名 %s 已存在,请清理后重试", p)
|
||
} else if !os.IsNotExist(err) {
|
||
return "", fmt.Errorf("export: 检查备份中间文件 %s: %w", p, err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func buildManifest(root string) ([]ProjectFile, error) {
|
||
var out []ProjectFile
|
||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(root, p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel = filepath.ToSlash(rel)
|
||
if rel == "meta.yaml" {
|
||
return nil
|
||
}
|
||
sum, size, err := hashFile(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out = append(out, ProjectFile{Path: rel, Size: size, SHA256: sum})
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("export: 生成文件清单: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func findFile(files []ProjectFile, name string) (ProjectFile, bool) {
|
||
for _, f := range files {
|
||
if f.Path == name {
|
||
return f, true
|
||
}
|
||
}
|
||
return ProjectFile{}, false
|
||
}
|
||
|
||
func hashFile(p string) (string, int64, error) {
|
||
f, err := os.Open(p)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("export: 读取 %s: %w", p, err)
|
||
}
|
||
defer func() { _ = f.Close() }()
|
||
|
||
h := sha256.New()
|
||
n, err := io.Copy(h, f)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("export: 计算 %s 校验和: %w", p, err)
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||
}
|
||
|
||
func copyTree(src, dst string) error {
|
||
info, err := os.Stat(src)
|
||
if err != nil {
|
||
return fmt.Errorf("export: 读取目录 %s: %w", src, err)
|
||
}
|
||
if !info.IsDir() {
|
||
return fmt.Errorf("export: %s 不是目录", src)
|
||
}
|
||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||
return fmt.Errorf("export: 创建目录 %s: %w", dst, err)
|
||
}
|
||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, err := filepath.Rel(src, p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
target := filepath.Join(dst, rel)
|
||
if d.IsDir() {
|
||
return os.MkdirAll(target, 0o755)
|
||
}
|
||
return copyFile(p, target)
|
||
})
|
||
}
|
||
|
||
func copyFile(src, dst string) error {
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return fmt.Errorf("export: 读取 %s: %w", src, err)
|
||
}
|
||
defer func() { _ = in.Close() }()
|
||
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(dst), err)
|
||
}
|
||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
return fmt.Errorf("export: 创建 %s: %w", dst, err)
|
||
}
|
||
if _, err := io.Copy(out, in); err != nil {
|
||
_ = out.Close()
|
||
return fmt.Errorf("export: 复制 %s → %s: %w", src, dst, err)
|
||
}
|
||
if err := out.Close(); err != nil {
|
||
return fmt.Errorf("export: 关闭 %s: %w", dst, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// zipDir 先写临时文件再改名,避免导出中途失败留下半截 zip。
|
||
func zipDir(root, dst string) error {
|
||
if dir := filepath.Dir(dst); dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
|
||
}
|
||
}
|
||
tmp := dst + ".tmp"
|
||
out, err := os.Create(tmp)
|
||
if err != nil {
|
||
return fmt.Errorf("export: 创建 %s: %w", tmp, err)
|
||
}
|
||
zw := zip.NewWriter(out)
|
||
if err := writeZipTree(zw, root); err != nil {
|
||
_ = zw.Close()
|
||
_ = out.Close()
|
||
_ = os.Remove(tmp)
|
||
return err
|
||
}
|
||
if err := zw.Close(); err != nil {
|
||
_ = out.Close()
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("export: 关闭 zip: %w", err)
|
||
}
|
||
if err := out.Close(); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("export: 关闭 %s: %w", tmp, err)
|
||
}
|
||
if err := os.Rename(tmp, dst); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("export: 重命名 %s: %w", dst, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func writeZipTree(zw *zip.Writer, root string) error {
|
||
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, err := filepath.Rel(root, p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
name := filepath.ToSlash(rel)
|
||
if d.IsDir() {
|
||
// 保留空目录(如空的 uploads/),导入方才能得到完整结构。
|
||
_, err := zw.Create(name + "/")
|
||
return err
|
||
}
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
hdr, err := zip.FileInfoHeader(info)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
hdr.Name = name
|
||
hdr.Method = zip.Deflate
|
||
w, err := zw.CreateHeader(hdr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
src, err := os.Open(p)
|
||
if err != nil {
|
||
return fmt.Errorf("export: 读取 %s: %w", p, err)
|
||
}
|
||
_, copyErr := io.Copy(w, src)
|
||
closeErr := src.Close()
|
||
if copyErr != nil {
|
||
return fmt.Errorf("export: 写入 zip 条目 %s: %w", name, copyErr)
|
||
}
|
||
return closeErr
|
||
})
|
||
}
|
||
|
||
func extractZip(src, dst string) error {
|
||
r, err := zip.OpenReader(src)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
|
||
}
|
||
defer func() { _ = r.Close() }()
|
||
|
||
var total int64
|
||
for _, f := range r.File {
|
||
target, err := secureJoin(dst, f.Name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if f.FileInfo().IsDir() {
|
||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||
return fmt.Errorf("export: 创建目录 %s: %w", target, err)
|
||
}
|
||
continue
|
||
}
|
||
// 声明值先卡一道,避免一上来就落盘;注意先判上界再转 int64,防止溢出。
|
||
if f.UncompressedSize64 > uint64(maxExtractBytes) || total+int64(f.UncompressedSize64) > maxExtractBytes {
|
||
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(target), err)
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return fmt.Errorf("%w: 读取条目 %s: %v", ErrInvalidPackage, f.Name, err)
|
||
}
|
||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
_ = rc.Close()
|
||
return fmt.Errorf("export: 写入 %s: %w", target, err)
|
||
}
|
||
// header 里的声明值可以伪造,按实际写出的字节数再卡一次上限。
|
||
n, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractBytes-total+1))
|
||
closeOutErr := out.Close()
|
||
closeInErr := rc.Close()
|
||
if copyErr != nil {
|
||
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
|
||
}
|
||
total += n
|
||
if total > maxExtractBytes {
|
||
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
|
||
}
|
||
if closeOutErr != nil {
|
||
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
|
||
}
|
||
if closeInErr != nil {
|
||
return fmt.Errorf("export: 关闭条目 %s: %w", f.Name, closeInErr)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// secureJoin 把 zip 条目名限制在 root 之内,挡住 ../ 与绝对路径(zip slip)。
|
||
func secureJoin(root, name string) (string, error) {
|
||
clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
|
||
if clean == "." {
|
||
return root, nil
|
||
}
|
||
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
|
||
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
|
||
}
|
||
target := filepath.Join(root, filepath.FromSlash(clean))
|
||
rel, err := filepath.Rel(root, target)
|
||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
func verifyManifest(root string, meta ProjectMeta) error {
|
||
if len(meta.Files) == 0 {
|
||
return fmt.Errorf("%w: meta.yaml 缺少文件清单", ErrInvalidPackage)
|
||
}
|
||
for _, f := range meta.Files {
|
||
target, err := secureJoin(root, f.Path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sum, size, err := hashFile(target)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: %v", ErrInvalidPackage, err)
|
||
}
|
||
if f.Size != 0 && f.Size != size {
|
||
return fmt.Errorf("%w: %s 大小 %d ≠ %d", ErrChecksumMismatch, f.Path, size, f.Size)
|
||
}
|
||
if !strings.EqualFold(sum, f.SHA256) {
|
||
return fmt.Errorf("%w: %s", ErrChecksumMismatch, f.Path)
|
||
}
|
||
}
|
||
dbFile, ok := findFile(meta.Files, eventDBName)
|
||
if !ok {
|
||
return fmt.Errorf("%w: 文件清单缺少 %s", ErrInvalidPackage, eventDBName)
|
||
}
|
||
if !strings.EqualFold(dbFile.SHA256, meta.Database.SHA256) {
|
||
return fmt.Errorf("%w: database.sha256 与清单不一致", ErrChecksumMismatch)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// verifySQLite 确认包内 event.db 确实是完整可读的赛事库,而不是改了后缀的其它文件。
|
||
func verifySQLite(p string) error {
|
||
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
|
||
if err != nil {
|
||
return fmt.Errorf("%w: 打开 %s: %v", ErrInvalidPackage, p, err)
|
||
}
|
||
defer func() { _ = db.Close() }()
|
||
|
||
var integrity string
|
||
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil {
|
||
return fmt.Errorf("%w: 完整性检查: %v", ErrInvalidPackage, err)
|
||
}
|
||
if !strings.EqualFold(integrity, "ok") {
|
||
return fmt.Errorf("%w: 完整性检查结果 %q", ErrInvalidPackage, integrity)
|
||
}
|
||
var n int
|
||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n); err != nil {
|
||
return fmt.Errorf("%w: 读取表结构: %v", ErrInvalidPackage, err)
|
||
}
|
||
if n == 0 {
|
||
return fmt.Errorf("%w: %s 不是赛事库(缺少 schema_migrations)", ErrInvalidPackage, eventDBName)
|
||
}
|
||
return nil
|
||
}
|