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

+36 -7
View File
@@ -28,12 +28,34 @@ const (
// ImportPlan is everything an import would create. It is built completely (and validated) before
// anything is written, so the preview shows exactly what the import does.
type ImportPlan struct {
Folders []string `json:"-"`
Docs []importDoc `json:"-"`
TaskLists []importTaskList `json:"-"`
Lists []importList `json:"-"`
Inbox []importTask `json:"-"`
Skipped []ImportSkip `json:"skipped"`
Folders []string `json:"-"`
Docs []importDoc `json:"-"`
TaskLists []importTaskList `json:"-"`
Lists []importList `json:"-"`
Inbox []importTask `json:"-"`
Themes []domain.UserTheme `json:"-"`
Skipped []ImportSkip `json:"skipped"`
}
// Only keeps the chosen sections of the plan (see NormalizeSections).
func (p *ImportPlan) Only(sections map[string]bool) *ImportPlan {
out := &ImportPlan{Skipped: p.Skipped}
if sections[SectionNotes] {
out.Folders, out.Docs = p.Folders, p.Docs
}
if sections[SectionTasks] {
out.TaskLists = p.TaskLists
}
if sections[SectionLists] {
out.Lists = p.Lists
}
if sections[SectionInbox] {
out.Inbox = p.Inbox
}
if sections[SectionThemes] {
out.Themes = p.Themes
}
return out
}
type ImportSkip struct {
@@ -50,6 +72,7 @@ type ImportSummary struct {
Lists int `json:"lists"`
ListItems int `json:"listItems"`
Inbox int `json:"inbox"`
Themes int `json:"themes"`
Skipped []ImportSkip `json:"skipped"`
Applied bool `json:"applied"`
}
@@ -85,7 +108,7 @@ func (p *ImportPlan) skip(path, reason string) {
}
func (p *ImportPlan) Summary(applied bool) ImportSummary {
sum := ImportSummary{Folders: len(p.Folders), Notes: len(p.Docs), Lists: len(p.Lists), Inbox: len(p.Inbox), Skipped: p.Skipped, Applied: applied}
sum := ImportSummary{Folders: len(p.Folders), Notes: len(p.Docs), Lists: len(p.Lists), Inbox: len(p.Inbox), Themes: len(p.Themes), Skipped: p.Skipped, Applied: applied}
for _, tl := range p.TaskLists {
if tl.title != "" {
sum.TaskLists++
@@ -416,6 +439,7 @@ func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
}
plan.Lists = append(plan.Lists, il)
}
plan.Themes = exp.Themes
plan.Folders = sortedKeys(folders)
return plan, nil
}
@@ -484,6 +508,11 @@ func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, err
}
}
}
for _, th := range plan.Themes {
if _, err := s.SaveUserTheme(userID, "", th.Name, th.CSS); err != nil {
plan.skip(th.Name, "Theme: "+err.Error())
}
}
return plan.Summary(true), nil
}