Files
MindSift/internal/storage/account.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

155 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.")
// 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 = ?)`
type st = struct {
q string
args int
}
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},
}
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 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
if err := s.db.QueryRow(`SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil || role != "admin" {
return false
}
var others int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND id != ?`, userID).Scan(&others)
return others == 0
}
// DeleteOwnAccount removes the user with all data; refused for the last administrator.
func (s *Store) DeleteOwnAccount(userID string) error {
if s.IsLastAdmin(userID) {
return ErrLastAdmin
}
return s.DeleteUser(userID)
}