Files
MindSift/internal/storage/backup.go
T
sandrodachandClaude Opus 5.5 c97765e08e feat(backup): daily server and user backups, restore by section (backend)
Admins switch automatic backups on, pick the time and the retention
(default 03:00, 3 days). Each run writes a gzip snapshot of the whole
database (VACUUM INTO, consistent while others write) and one MindSift
backup ZIP per active user below $DATA_DIR/backups, then deletes files
past the retention. A restart after the time catches up on that day.
Admins list, download and trigger runs; users list, download and restore
their own backups.

Import and restore can be limited to sections (notes, tasks, lists,
inbox, themes): restore only empties the chosen sections. Export and
import now carry custom themes. File names are checked against a fixed
pattern, so no other file can be fetched through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:10:06 +01:00

168 lines
4.8 KiB
Go

package storage
import (
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// Automatic backups below $DATA_DIR/backups/:
// system/mindsift-db-YYYY-MM-DD-HHMM.db.gz the whole database (VACUUM INTO, consistent while
// others write), for the admin
// users/<user id>/mindsift-backup-YYYY-MM-DD-HHMM.zip the same ZIP as "Backup herunterladen"
// Files older than the retention are deleted after each run. Backups next to the database protect
// against mistakes, not against a broken disk; copy the folder elsewhere as well.
const (
DefaultBackupTime = "03:00"
DefaultBackupRetentionDays = 3
)
var (
backupTimeRe = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
backupNameRe = regexp.MustCompile(`^mindsift-(db|backup)-\d{4}-\d{2}-\d{2}-\d{4}\.(db\.gz|zip)$`)
ErrBackupName = errors.New("Unbekannte Sicherung")
backupStampFmt = "2006-01-02-1504"
)
func validBackupTime(v string) bool { return backupTimeRe.MatchString(v) }
// BackupFile is one entry of a backup listing.
type BackupFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt string `json:"createdAt"`
}
// BackupPath returns the file of a backup in dir, refusing anything that is not one of our names
// (no path tricks through the API).
func BackupPath(dir, name string) (string, error) {
if !backupNameRe.MatchString(name) {
return "", ErrBackupName
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err != nil {
return "", ErrBackupName
}
return p, nil
}
// ListBackups lists the backups in dir, newest first.
func ListBackups(dir string) ([]BackupFile, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return []BackupFile{}, nil
}
if err != nil {
return nil, err
}
out := []BackupFile{}
for _, e := range entries {
if e.IsDir() || !backupNameRe.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, BackupFile{Name: e.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339)})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name })
return out, nil
}
// PruneBackups deletes backups in dir older than keepDays.
func PruneBackups(dir string, keepDays int, now time.Time) error {
files, err := ListBackups(dir)
if err != nil {
return err
}
cutoff := now.Add(-time.Duration(keepDays) * 24 * time.Hour)
for _, f := range files {
created, err := time.Parse(time.RFC3339, f.CreatedAt)
if err == nil && created.Before(cutoff) {
if err := os.Remove(filepath.Join(dir, f.Name)); err != nil {
return err
}
}
}
return nil
}
// WriteSystemSnapshot writes a gzip-compressed copy of the whole database into dir.
func (s *Store) WriteSystemSnapshot(dir string, now time.Time) (string, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
name := "mindsift-db-" + now.Format(backupStampFmt) + ".db.gz"
tmp := filepath.Join(dir, ".tmp-"+now.Format("20060102150405.000000")+".db")
defer os.Remove(tmp)
if _, err := s.db.Exec(`VACUUM INTO ?`, tmp); err != nil {
return "", fmt.Errorf("Datenbank-Abzug: %w", err)
}
if err := gzipFile(tmp, filepath.Join(dir, name)); err != nil {
return "", err
}
return name, nil
}
// WriteUserBackup writes the user's MindSift backup (ZIP with the JSON export) into dir.
func (s *Store) WriteUserBackup(userID, dir string, now time.Time) (string, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
exp, err := s.ExportUserData(userID)
if err != nil {
return "", err
}
name := "mindsift-backup-" + now.Format(backupStampFmt) + ".zip"
return name, writeFileAtomically(filepath.Join(dir, name), func(w io.Writer) error { return WriteBackupZip(exp, w) })
}
func gzipFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
return writeFileAtomically(dst, func(w io.Writer) error {
gz := gzip.NewWriter(w)
if _, err := io.Copy(gz, in); err != nil {
return err
}
return gz.Close()
})
}
// writeFileAtomically writes to a temporary file and renames it, so a half-written backup never
// shows up in the list.
func writeFileAtomically(dst string, write func(io.Writer) error) error {
tmp := dst + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if err := write(f); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
// UserBackupDir is the backup folder of one user below root; user IDs are generated by MindSift.
func UserBackupDir(root, userID string) string {
return filepath.Join(root, "users", strings.ReplaceAll(filepath.Base(userID), "..", ""))
}