core/backup/archive.go
Alex Dunmow 62b245d24f feat(backup): shared backup archive format v3 (writer/reader/crypto) for cms + platform backups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:57:31 +08:00

225 lines
6.2 KiB
Go

package backup
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"time"
)
// Section maps a zip path prefix to an on-disk directory to be archived.
type Section struct {
Prefix string
Dir string
}
// BuildInput is the full set of inputs for BuildArchive.
type BuildInput struct {
Source Source
Dump []byte
Password string
Sections []Section
// Extra holds additional fixed zip entries (zip path -> contents), written
// after database.dump and before section files.
Extra map[string][]byte
}
// backupFile pairs a zip path with its source path on disk.
type backupFile struct {
zipPath string
diskPath string
}
// collectSectionFiles walks a section directory and returns zipPath→diskPath
// pairs. A missing directory is skipped silently (count 0, no warning), matching
// the cms collectSectionFiles semantics. Files that cannot be accessed inside an
// existing directory append warnings and are skipped.
func collectSectionFiles(ctx context.Context, section Section, warnings *[]string) ([]backupFile, error) {
if section.Dir == "" {
return nil, nil
}
if _, err := os.Stat(section.Dir); os.IsNotExist(err) {
return nil, nil
}
var files []backupFile
err := filepath.Walk(section.Dir, func(path string, info os.FileInfo, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
*warnings = append(*warnings, fmt.Sprintf("Failed to access %s: %v", path, err))
return nil
}
if info.IsDir() || !info.Mode().IsRegular() {
return nil
}
relPath, err := filepath.Rel(section.Dir, path)
if err != nil {
*warnings = append(*warnings, fmt.Sprintf("Failed to get relative path for %s: %v", path, err))
return nil
}
files = append(files, backupFile{
zipPath: section.Prefix + filepath.ToSlash(relPath),
diskPath: path,
})
return nil
})
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
*warnings = append(*warnings, fmt.Sprintf("Failed to walk %s: %v", section.Dir, err))
}
return files, nil
}
// BuildArchive streams a backup zip archive to w: a manifest.json, the
// password-encrypted database dump, any Extra entries, then the files of each
// configured section in order. It returns the manifest and any non-fatal
// warnings encountered while collecting/writing section files.
//
// Zip entry ordering is: manifest.json, database.dump, Extra (sorted by path),
// then section files in section order. Counts for the well-known section
// prefixes (media/, brand/, icons/, styles/, plugins/) are recorded in the
// manifest.
func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, []string, error) {
var warnings []string
if err := ctx.Err(); err != nil {
return nil, warnings, err
}
// Checksum of the unencrypted dump.
sum := sha256.Sum256(in.Dump)
checksum := hex.EncodeToString(sum[:])
// Encrypt the dump.
encryptedDump, encParams, err := EncryptWithPassword(in.Dump, in.Password)
if err != nil {
return nil, warnings, fmt.Errorf("encryption failed: %w", err)
}
// Collect files for every configured section, preserving section order.
sectionFiles := make([][]backupFile, len(in.Sections))
countsByPrefix := make(map[string]int)
for i, section := range in.Sections {
files, err := collectSectionFiles(ctx, section, &warnings)
if err != nil {
return nil, warnings, err
}
sectionFiles[i] = files
countsByPrefix[section.Prefix] += len(files)
}
manifest := &Manifest{
Version: FormatVersion,
Format: FormatName,
ExportedAt: time.Now().UTC(),
Source: in.Source,
DatabaseSize: int64(len(in.Dump)),
MediaCount: countsByPrefix["media/"],
BrandCount: countsByPrefix["brand/"],
IconsCount: countsByPrefix["icons/"],
StylesCount: countsByPrefix["styles/"],
PluginsCount: countsByPrefix["plugins/"],
Encryption: encParams,
Checksum: checksum,
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return nil, warnings, fmt.Errorf("manifest marshal failed: %w", err)
}
if err := ctx.Err(); err != nil {
return nil, warnings, err
}
zipWriter := zip.NewWriter(w)
manifestEntry, err := zipWriter.Create("manifest.json")
if err != nil {
return nil, warnings, fmt.Errorf("failed to create manifest.json in zip: %w", err)
}
if _, err := manifestEntry.Write(manifestJSON); err != nil {
return nil, warnings, fmt.Errorf("failed to write manifest.json: %w", err)
}
dumpEntry, err := zipWriter.Create("database.dump")
if err != nil {
return nil, warnings, fmt.Errorf("failed to create database.dump in zip: %w", err)
}
if _, err := dumpEntry.Write(encryptedDump); err != nil {
return nil, warnings, fmt.Errorf("failed to write database.dump: %w", err)
}
// Extra fixed entries, in a stable (sorted) order for reproducibility.
if len(in.Extra) > 0 {
names := make([]string, 0, len(in.Extra))
for name := range in.Extra {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
entry, err := zipWriter.Create(name)
if err != nil {
return nil, warnings, fmt.Errorf("failed to create %s in zip: %w", name, err)
}
if _, err := entry.Write(in.Extra[name]); err != nil {
return nil, warnings, fmt.Errorf("failed to write %s: %w", name, err)
}
}
}
// Section files, with periodic context checks (every 10 files).
written := 0
for _, files := range sectionFiles {
for _, bf := range files {
if written%10 == 0 {
if err := ctx.Err(); err != nil {
return nil, warnings, err
}
}
written++
src, err := os.Open(bf.diskPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to read %s for zip: %v", bf.diskPath, err))
continue
}
entry, err := zipWriter.Create(bf.zipPath)
if err != nil {
closeBestEffort(src)
warnings = append(warnings, fmt.Sprintf("Failed to create %s in zip: %v", bf.zipPath, err))
continue
}
if _, err := io.Copy(entry, src); err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to write %s to zip: %v", bf.zipPath, err))
}
closeBestEffort(src)
}
}
if err := zipWriter.Close(); err != nil {
return nil, warnings, fmt.Errorf("failed to close zip: %w", err)
}
return manifest, warnings, nil
}
func closeBestEffort(c io.Closer) {
_ = c.Close()
}