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>
This commit is contained in:
1 parent
cab5a79f6d
commit
c97765e08e
9 files changed
+632
-68
No files matched your search
@@ -0,0 +1,167 @@
|
||||
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), "..", ""))
|
||||
}
|
||||
Reference in new issue
Block a user