Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package backup writes and restores the deployment's data as a
|
||||
// gzip-compressed tar: the content directory under posts/, plus the
|
||||
// users, templates and tokens files. The CLI and the admin UI both go
|
||||
// through here, so an archive written by one restores in the other.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
)
|
||||
|
||||
// Archive entry names. The users, templates and tokens files are stored
|
||||
// under these names rather than under their configured paths, so a
|
||||
// deployment that renamed them still restores into its own layout.
|
||||
const (
|
||||
postsPrefix = "posts/"
|
||||
usersEntry = "users.toml"
|
||||
templatesEntry = "templates.toml"
|
||||
tokensEntry = "tokens.toml"
|
||||
)
|
||||
|
||||
// MaxDecompressed bounds the total size a restore will decompress, so a
|
||||
// small archive cannot expand until the process runs out of memory.
|
||||
const MaxDecompressed = 512 << 20
|
||||
|
||||
// Options names the files and directories an archive carries.
|
||||
type Options struct {
|
||||
ContentDir string
|
||||
UsersFile string
|
||||
TemplatesFile string
|
||||
TokensFile string
|
||||
}
|
||||
|
||||
// Write writes the archive. A file that is absent is skipped; a file
|
||||
// that exists but cannot be read aborts the backup, because an archive
|
||||
// that silently omits data looks complete and is not.
|
||||
func Write(w io.Writer, opts Options) error {
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
if err := writeTree(tw, opts.ContentDir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range []struct{ entry, source string }{
|
||||
{usersEntry, opts.UsersFile},
|
||||
{templatesEntry, opts.TemplatesFile},
|
||||
{tokensEntry, opts.TokensFile},
|
||||
} {
|
||||
if file.source == "" {
|
||||
continue
|
||||
}
|
||||
if err := writeFile(tw, file.entry, file.source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return fmt.Errorf("finish archive: %w", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return fmt.Errorf("finish compression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTree(tw *tar.Writer, contentDir string) error {
|
||||
if contentDir == "" {
|
||||
return nil
|
||||
}
|
||||
err := filepath.WalkDir(contentDir, func(filePath string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filePath, err)
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(contentDir, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate %s: %w", filePath, err)
|
||||
}
|
||||
return writeFile(tw, postsPrefix+filepath.ToSlash(rel), filePath)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFile(tw *tar.Writer, entry, source string) error {
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read %s: %w", source, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", source, err)
|
||||
}
|
||||
defer file.Close()
|
||||
header := &tar.Header{
|
||||
Name: entry,
|
||||
Mode: int64(info.Mode().Perm()),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("archive %s: %w", entry, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return fmt.Errorf("archive %s: %w", source, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore reads an archive and writes its entries into the deployment.
|
||||
// It returns the number of files written. Entries the layout does not
|
||||
// name are skipped; an entry that tries to escape its destination is
|
||||
// refused outright.
|
||||
func Restore(r io.Reader, opts Options) (int, error) {
|
||||
gz, err := gzip.NewReader(io.LimitReader(r, MaxDecompressed))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the archive is not a gzip file: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
// The limit applies to the decompressed bytes, which is what a
|
||||
// compression bomb expands into.
|
||||
tr := tar.NewReader(io.LimitReader(gz, MaxDecompressed))
|
||||
root, err := openContentRoot(opts.ContentDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
written := 0
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("read the archive: %w", err)
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
name := path.Clean(strings.TrimPrefix(header.Name, "./"))
|
||||
switch {
|
||||
case name == usersEntry:
|
||||
if err := writeTarget(opts.UsersFile, tr, header.Size); err != nil {
|
||||
return written, err
|
||||
}
|
||||
case name == templatesEntry:
|
||||
if err := writeTarget(opts.TemplatesFile, tr, header.Size); err != nil {
|
||||
return written, err
|
||||
}
|
||||
case name == tokensEntry:
|
||||
if err := writeTarget(opts.TokensFile, tr, header.Size); err != nil {
|
||||
return written, err
|
||||
}
|
||||
case strings.HasPrefix(name, postsPrefix):
|
||||
rel := strings.TrimPrefix(name, postsPrefix)
|
||||
if !restorablePath(rel) {
|
||||
continue
|
||||
}
|
||||
if err := writeInRoot(root, rel, tr, header.Size); err != nil {
|
||||
return written, err
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
written++
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// restorablePath reports whether a path inside posts/ may be written
|
||||
// back. Only posts, revision archives and media files with an allowed
|
||||
// image extension are accepted: the media directory is served from a
|
||||
// public route, so an archive must not be able to plant a document
|
||||
// there that a browser would execute.
|
||||
func restorablePath(rel string) bool {
|
||||
if rel == "" || strings.HasPrefix(rel, "..") || path.IsAbs(rel) {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(rel, store.MediaDirName+"/"):
|
||||
return imagefile.Allowed(path.Base(rel))
|
||||
case rel == store.MediaDirName:
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(rel, ".md")
|
||||
}
|
||||
|
||||
func openContentRoot(contentDir string) (*os.Root, error) {
|
||||
if contentDir == "" {
|
||||
return nil, errors.New("no content directory is configured")
|
||||
}
|
||||
if err := os.MkdirAll(contentDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create the content directory: %w", err)
|
||||
}
|
||||
root, err := os.OpenRoot(contentDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open the content directory: %w", err)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// writeInRoot writes rel inside the content directory. os.Root resolves
|
||||
// every operation inside that directory, so a name that escapes one,
|
||||
// through .. or through a symlink, is refused by construction.
|
||||
func writeInRoot(root *os.Root, rel string, r io.Reader, size int64) error {
|
||||
clean := path.Clean("/" + rel)
|
||||
rel = strings.TrimPrefix(clean, "/")
|
||||
if dir := path.Dir(rel); dir != "." {
|
||||
if err := root.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
data, err := readEntry(r, size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", rel, err)
|
||||
}
|
||||
if err := atomicWriteInRoot(root, rel, data); err != nil {
|
||||
return fmt.Errorf("write %s: %w", rel, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// atomicWriteInRoot replaces rel through a temp file and a rename, the
|
||||
// same shape the post store writes with: a crash mid-restore leaves the
|
||||
// previous file intact rather than a truncated one.
|
||||
func atomicWriteInRoot(root *os.Root, rel string, data []byte) error {
|
||||
dir, base := path.Split(rel)
|
||||
tmp := path.Join(dir, "."+base+".tmp")
|
||||
handle, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := handle.Write(data); err != nil {
|
||||
handle.Close()
|
||||
root.Remove(tmp)
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := handle.Close(); err != nil {
|
||||
root.Remove(tmp)
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := root.Rename(tmp, rel); err != nil {
|
||||
root.Remove(tmp)
|
||||
return fmt.Errorf("replace: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTarget writes one of the standalone TOML files. The destination
|
||||
// is the configured path, never a path from the archive.
|
||||
func writeTarget(target string, r io.Reader, size int64) error {
|
||||
if target == "" {
|
||||
return errors.New("no destination is configured for that file")
|
||||
}
|
||||
data, err := readEntry(r, size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filepath.Base(target), err)
|
||||
}
|
||||
dir := filepath.Dir(target)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create the directory for %s: %w", target, err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("chmod temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, target); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write %s: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readEntry reads one entry, refusing a declared size beyond the budget.
|
||||
func readEntry(r io.Reader, size int64) ([]byte, error) {
|
||||
if size > MaxDecompressed {
|
||||
return nil, fmt.Errorf("entry declares %d bytes, over the %d byte limit", size, int64(MaxDecompressed))
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, MaxDecompressed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
Reference in New Issue
Block a user