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
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,628 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user