Files
Scoreboard/server/internal/export/project.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

629 lines
18 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 export
import (
"archive/zip"
"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 限制解压总体积,避免恶意包把磁盘塞满。
maxExtractBytes = 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 // 可选:字体目录
IncludeFonts bool // 是否把字体一并打包
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 != "" {
if err := copyTree(opts.FontsDir, filepath.Join(tmp, "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 := os.Stat(target); err == nil {
if !opts.Overwrite {
return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name)
}
stale := target + ".replaced-" + time.Now().Format("20060102-150405")
if err := os.Rename(target, stale); err != nil {
return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
}
defer func() { _ = os.RemoveAll(stale) }()
}
if err := os.Rename(tmp, target); err != nil {
return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
}
return ImportResult{
Name: name,
Dir: target,
EventDB: filepath.Join(target, eventDBName),
Meta: meta,
}, 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)
}
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
}
func vacuumInto(db *sql.DB, dst string) error {
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
}
return 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 uint64
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
}
total += f.UncompressedSize64
if total > 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)
}
_, copyErr := io.Copy(out, rc)
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
}
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
}