// Copyright (c) 2026 Petr BalvĂ­n (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 }