236 lines
6.7 KiB
Go
236 lines
6.7 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"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.
|
|
checksum := computeChecksum(in.Dump)
|
|
|
|
// 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.
|
|
// Failure contract (cms parity — the cssManifestsFile block in
|
|
// buildBackupArchive): a failed Create skips the entry silently, a failed
|
|
// Write appends a warning. Extra entries are regenerable extras; a problem
|
|
// with one must never abort the archive.
|
|
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 {
|
|
continue
|
|
}
|
|
if _, err := entry.Write(in.Extra[name]); err != nil {
|
|
warnings = append(warnings, fmt.Sprintf("Failed to write %s: %v", 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()
|
|
}
|
|
|
|
// computeChecksum calculates the SHA-256 hash of data, base64-encoded —
|
|
// byte-identical to the cms computeChecksum so the existing cms restore path
|
|
// (verifyChecksum) accepts archives built by this package.
|
|
func computeChecksum(data []byte) string {
|
|
hash := sha256.Sum256(data)
|
|
return base64.StdEncoding.EncodeToString(hash[:])
|
|
}
|