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// 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 这个名字上**: // 先写同目录的中间文件 .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 }