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
+112
-50
@@ -3,75 +3,137 @@ package storage
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrLastAdmin protects the server from losing its last administrator.
|
||||
var ErrLastAdmin = errors.New("Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.")
|
||||
|
||||
// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and
|
||||
// shares), folders, shared folders, task lists and lists – in one transaction. The account, its
|
||||
// settings, themes, API keys and group memberships stay. Other people's tasks in the user's task lists
|
||||
// become their own personal tasks, as when a single list is deleted.
|
||||
func (s *Store) PurgeUserData(userID string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Sections of a user's data, for restoring only part of a backup.
|
||||
const (
|
||||
SectionNotes = "notes" // notes, folders, shared folders, pins, tag pages
|
||||
SectionTasks = "tasks" // task lists and tasks
|
||||
SectionLists = "lists" // shopping and other lists
|
||||
SectionInbox = "inbox" // thoughts in the inbox (and the discarded ones)
|
||||
SectionThemes = "themes" // own CSS themes
|
||||
)
|
||||
|
||||
// AllSections in a fixed order.
|
||||
var AllSections = []string{SectionNotes, SectionTasks, SectionLists, SectionInbox, SectionThemes}
|
||||
|
||||
// NormalizeSections keeps known sections; an empty selection means all of them.
|
||||
func NormalizeSections(sections []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, sec := range sections {
|
||||
sec = strings.ToLower(strings.TrimSpace(sec))
|
||||
for _, known := range AllSections {
|
||||
if sec == known {
|
||||
out[sec] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
for _, sec := range AllSections {
|
||||
out[sec] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// purgeStmts lists what deleting one section of a user's data takes. Each statement gets the user ID
|
||||
// as often as it has placeholders.
|
||||
func purgeStmts(section string) []struct {
|
||||
q string
|
||||
args int
|
||||
} {
|
||||
ownDocs := `(SELECT id FROM documents WHERE user_id = ?)`
|
||||
ownTaskLists := `(SELECT id FROM task_lists WHERE user_id = ?)`
|
||||
ownFolders := `(SELECT id FROM workspace_shared_folders WHERE owner_id = ?)`
|
||||
stmts := []struct {
|
||||
type st = struct {
|
||||
q string
|
||||
args int // how many times userID is passed
|
||||
}{
|
||||
// Task lists: keep the tasks of others, drop shares and the lists
|
||||
{`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2},
|
||||
{`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1},
|
||||
{`DELETE FROM task_lists WHERE user_id = ?`, 1},
|
||||
// Tasks of others linked to blocks of the user's notes lose the link
|
||||
{`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
|
||||
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1},
|
||||
{`DELETE FROM thoughts WHERE user_id = ?`, 1},
|
||||
// Shared folders owned by the user
|
||||
{`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1},
|
||||
// Notes (history tables cascade with the document)
|
||||
{`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM documents WHERE user_id = ?`, 1},
|
||||
// The user's own placement of notes, folders, pins and tag pages
|
||||
{`DELETE FROM doc_user_folders WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM doc_pins WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM workspace_folders WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM tag_pages WHERE user_id = ?`, 1},
|
||||
// Lists (items cascade)
|
||||
{`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1},
|
||||
{`DELETE FROM lists WHERE user_id = ?`, 1},
|
||||
args int
|
||||
}
|
||||
for _, st := range stmts {
|
||||
args := make([]interface{}, st.args)
|
||||
for i := range args {
|
||||
args[i] = userID
|
||||
switch section {
|
||||
case SectionTasks:
|
||||
return []st{
|
||||
// Task lists: keep the tasks of others, drop shares and the lists
|
||||
{`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2},
|
||||
{`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1},
|
||||
{`DELETE FROM task_lists WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type IN ('task', 'document_block')))`, 1},
|
||||
}
|
||||
if _, err := tx.Exec(st.q, args...); err != nil {
|
||||
return fmt.Errorf("Daten löschen: %w", err)
|
||||
case SectionInbox:
|
||||
return []st{{`DELETE FROM thoughts WHERE user_id = ? AND status IN ('inbox', 'discarded')`, 1}}
|
||||
case SectionNotes:
|
||||
return []st{
|
||||
// Tasks of others linked to blocks of the user's notes lose the link
|
||||
{`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
|
||||
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1},
|
||||
// Shared folders owned by the user
|
||||
{`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1},
|
||||
{`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1},
|
||||
// Notes (history tables cascade with the document)
|
||||
{`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1},
|
||||
{`DELETE FROM documents WHERE user_id = ?`, 1},
|
||||
// The user's own placement of notes, folders, pins and tag pages
|
||||
{`DELETE FROM doc_user_folders WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM doc_pins WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM workspace_folders WHERE user_id = ?`, 1},
|
||||
{`DELETE FROM tag_pages WHERE user_id = ?`, 1},
|
||||
}
|
||||
case SectionLists:
|
||||
return []st{
|
||||
{`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1},
|
||||
{`DELETE FROM lists WHERE user_id = ?`, 1}, // items cascade
|
||||
}
|
||||
case SectionThemes:
|
||||
return []st{{`DELETE FROM user_themes WHERE user_id = ?`, 1}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeUserSections deletes the chosen sections of the user's own data in one transaction. Content of
|
||||
// others shared with the user stays; other people's tasks in the user's task lists become their own
|
||||
// personal tasks, as when a single list is deleted.
|
||||
func (s *Store) PurgeUserSections(userID string, sections map[string]bool) error {
|
||||
var stmts []sqlStmt
|
||||
for _, sec := range AllSections {
|
||||
if !sections[sec] {
|
||||
continue
|
||||
}
|
||||
for _, st := range purgeStmts(sec) {
|
||||
args := make([]interface{}, st.args)
|
||||
for i := range args {
|
||||
args[i] = userID
|
||||
}
|
||||
stmts = append(stmts, stmt(st.q, args...))
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
if sections[SectionTasks] && sections[SectionInbox] {
|
||||
// Everything else of the thought history (turned into notes or list items) goes as well
|
||||
stmts = append(stmts, stmt(`DELETE FROM thoughts WHERE user_id = ?`, userID))
|
||||
}
|
||||
if err := s.execInTx(stmts...); err != nil {
|
||||
return fmt.Errorf("Daten löschen: %w", err)
|
||||
}
|
||||
s.PruneTaskAssignees()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and
|
||||
// shares), folders, shared folders, task lists and lists. The account, its settings, themes, API keys
|
||||
// and group memberships stay.
|
||||
func (s *Store) PurgeUserData(userID string) error {
|
||||
return s.PurgeUserSections(userID, map[string]bool{SectionNotes: true, SectionTasks: true, SectionLists: true, SectionInbox: true})
|
||||
}
|
||||
|
||||
// IsLastAdmin reports whether userID is an administrator and no other one exists.
|
||||
func (s *Store) IsLastAdmin(userID string) bool {
|
||||
var role string
|
||||
|
||||
Reference in new issue
Block a user