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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 15:10:06 +01:00
1 parent cab5a79f6d
commit c97765e08e
9 files changed
+632 -68

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
package api
import (
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// Automatic backups (see storage/backup.go): daily at the configured time a snapshot of the whole
// database for the admin and one MindSift backup per active user, both kept for the configured number
// of days. Admins can also start a run by hand; users list, download and restore their own backups.
var backupRunMu sync.Mutex
func (s *Server) backupRoot() string {
return filepath.Join(filepath.Dir(s.cfg.DatabasePath), "backups")
}
// runBackups makes one full run; returns the name of the system snapshot.
func (s *Server) runBackups() (string, error) {
backupRunMu.Lock()
defer backupRunMu.Unlock()
now := time.Now()
root := s.backupRoot()
sys, _ := s.store.GetSystemSettings()
keep := storage.DefaultBackupRetentionDays
if sys != nil && sys.BackupRetentionDays > 0 {
keep = sys.BackupRetentionDays
}
name, err := s.store.WriteSystemSnapshot(filepath.Join(root, "system"), now)
if err != nil {
return "", err
}
_ = storage.PruneBackups(filepath.Join(root, "system"), keep, now)
users, err := s.store.GetAllUsers()
if err != nil {
return name, err
}
failed := 0
for _, u := range users {
if !u.IsActive {
continue
}
dir := storage.UserBackupDir(root, u.ID)
if _, err := s.store.WriteUserBackup(u.ID, dir, now); err != nil {
failed++
slog.Error("Benutzer-Backup fehlgeschlagen", "user_id", u.ID, "error", err)
continue
}
_ = storage.PruneBackups(dir, keep, now)
}
slog.Info("Backups erstellt", "system", name, "users", len(users), "failed", failed)
if failed > 0 {
return name, fmt.Errorf("%d Benutzer-Backups fehlgeschlagen (siehe Server-Log)", failed)
}
return name, nil
}
// startBackupScheduler runs the backups once a day at the configured time (server time zone). After a
// restart past that time on a day without a run, it catches up.
func (s *Server) startBackupScheduler() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
lastRun := ""
if files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system")); err == nil && len(files) > 0 {
lastRun = strings.TrimPrefix(files[0].Name, "mindsift-db-")[:10] // date of the newest snapshot
}
for range ticker.C {
sys, err := s.store.GetSystemSettings()
if err != nil || sys == nil || !sys.BackupEnabled {
continue
}
now := time.Now()
today := now.Format("2006-01-02")
if lastRun == today || now.Format("15:04") < sys.BackupTime {
continue
}
lastRun = today
if _, err := s.runBackups(); err != nil {
slog.Error("Automatisches Backup fehlgeschlagen", "error", err)
}
}
}
func (s *Server) handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, files)
}
func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
if _, err := s.runBackups(); err != nil {
writeError(w, http.StatusInternalServerError, "Backup fehlgeschlagen: "+err.Error())
return
}
s.handleAdminListBackups(w, r)
}
func (s *Server) handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
serveBackupFile(w, r, filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name"), "application/gzip")
}
func (s *Server) handleListMyBackups(w http.ResponseWriter, r *http.Request) {
files, err := storage.ListBackups(storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context())))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, files)
}
func (s *Server) handleDownloadMyBackup(w http.ResponseWriter, r *http.Request) {
dir := storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context()))
serveBackupFile(w, r, dir, chi.URLParam(r, "name"), "application/zip")
}
// handleRestoreMyBackup imports one of the user's server backups: mode=merge (add next to the data)
// or mode=restore (empty the chosen sections first), sections=notes,tasks,lists,inbox,themes.
func (s *Server) handleRestoreMyBackup(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
path, err := storage.BackupPath(storage.UserBackupDir(s.backupRoot(), userID), chi.URLParam(r, "name"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
plan, err := storage.PlanMindSiftImport(data, filepath.Base(path))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ","))
plan = plan.Only(sections)
if r.URL.Query().Get("preview") == "1" {
writeJSON(w, http.StatusOK, plan.Summary(false))
return
}
s.applyImport(w, userID, plan, sections, r.URL.Query().Get("mode") == "restore")
}
func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentType string) {
path, err := storage.BackupPath(dir, name)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
http.ServeFile(w, r, path)
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
func TestBackupsRunListDownloadAndRestoreSections(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
_, _ = store.CreateDocument(uid, "", "Notiz vom Montag", "📝", "", nil, nil, false)
list, _ := store.CreateList(uid, "Einkauf", "🛒", "shopping")
_, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, uid)
_, _ = store.SaveUserTheme(uid, "", "Mein Theme", ":root[data-theme=\"x\"] { --accent-primary: #f00; }")
call := func(method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// Admin starts a run: one database snapshot plus a backup for the user
rec := call("POST", "/api/v1/admin/backups")
if rec.Code != http.StatusOK {
t.Fatalf("run backups: %d %s", rec.Code, rec.Body.String())
}
var sysFiles []storage.BackupFile
_ = json.Unmarshal(rec.Body.Bytes(), &sysFiles)
if len(sysFiles) != 1 || !strings.HasSuffix(sysFiles[0].Name, ".db.gz") || sysFiles[0].Size == 0 {
t.Fatalf("expected one snapshot, got %+v", sysFiles)
}
if rec := call("GET", "/api/v1/admin/backups/"+sysFiles[0].Name); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
t.Fatalf("download snapshot: %d", rec.Code)
}
if rec := call("GET", "/api/v1/admin/backups/..%2F..%2Fmindsift.db"); rec.Code != http.StatusNotFound {
t.Fatalf("path tricks must be refused, got %d", rec.Code)
}
var mine []storage.BackupFile
_ = json.Unmarshal(call("GET", "/api/v1/backups").Body.Bytes(), &mine)
if len(mine) != 1 {
t.Fatalf("expected one user backup, got %+v", mine)
}
// Lose data, then restore only the lists: notes stay as they are now
listsBefore, _ := store.GetAllListsForUser(uid)
docsBefore, _ := store.GetDocuments(uid)
_ = store.PurgeUserSections(uid, map[string]bool{storage.SectionLists: true, storage.SectionThemes: true})
_, _ = store.CreateDocument(uid, "", "Notiz vom Dienstag", "📝", "", nil, nil, false)
rec = call("POST", "/api/v1/backups/"+mine[0].Name+"/restore?mode=restore&sections=lists,themes")
if rec.Code != http.StatusOK {
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
}
lists, _ := store.GetAllListsForUser(uid)
themes, _ := store.GetUserThemes(uid)
docs, _ := store.GetDocuments(uid)
if len(lists) != len(listsBefore) || len(themes) != 1 {
t.Fatalf("lists and themes should be back: %d of %d lists, %d themes", len(lists), len(listsBefore), len(themes))
}
if len(docs) != len(docsBefore)+1 {
t.Fatalf("notes were not selected and must stay untouched (plus Dienstag), got %d of %d", len(docs), len(docsBefore)+1)
}
// Retention: old files go
dir := filepath.Join(srv.backupRoot(), "system")
old := filepath.Join(dir, "mindsift-db-2000-01-01-0300.db.gz")
_ = os.WriteFile(old, []byte("x"), 0o600)
_ = os.Chtimes(old, mustTime("2000-01-01"), mustTime("2000-01-01"))
_, _ = srv.runBackups()
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatalf("backups older than the retention must be deleted")
}
}
func mustTime(day string) (t time.Time) {
t, _ = time.Parse("2006-01-02", day)
return t
}
+11 -5
View File
@@ -82,15 +82,21 @@ func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage
writeError(w, http.StatusBadRequest, err.Error())
return
}
// sections=notes,tasks,…: only these parts of a MindSift backup (all when not given)
sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ","))
plan = plan.Only(sections)
if r.URL.Query().Get("preview") == "1" {
writeJSON(w, http.StatusOK, plan.Summary(false))
return
}
userID := auth.GetUserID(r.Context())
// mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is
// fully read and validated at this point, so a broken file never wipes anything.
if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") {
if err := s.store.PurgeUserData(userID); err != nil {
s.applyImport(w, auth.GetUserID(r.Context()), plan, sections, r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift"))
}
// applyImport adds the plan to the user's data; with restore the chosen sections are emptied first.
// The plan is fully read and validated at this point, so a broken file never wipes anything.
func (s *Server) applyImport(w http.ResponseWriter, userID string, plan *storage.ImportPlan, sections map[string]bool, restore bool) {
if restore {
if err := s.store.PurgeUserSections(userID, sections); err != nil {
writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error())
return
}
+9
View File
@@ -56,6 +56,9 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
go srv.startDemoResetTicker()
}
if !cfg.DemoMode {
go srv.startBackupScheduler()
}
return srv
}
@@ -130,6 +133,9 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport)
r.Get("/api/v1/backups", s.handleListMyBackups)
r.Get("/api/v1/backups/{name}", s.handleDownloadMyBackup)
r.Post("/api/v1/backups/{name}/restore", s.handleRestoreMyBackup)
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
@@ -256,6 +262,9 @@ func (s *Server) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(s.adminMiddleware)
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
r.Post("/api/v1/admin/backups", s.handleAdminRunBackup)
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)