diff --git a/internal/api/backups.go b/internal/api/backups.go new file mode 100644 index 0000000..1780062 --- /dev/null +++ b/internal/api/backups.go @@ -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) +} diff --git a/internal/api/backups_test.go b/internal/api/backups_test.go new file mode 100644 index 0000000..62161da --- /dev/null +++ b/internal/api/backups_test.go @@ -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§ions=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 +} diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go index 7e25633..56548a3 100644 --- a/internal/api/handlers_account.go +++ b/internal/api/handlers_account.go @@ -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 } diff --git a/internal/api/server.go b/internal/api/server.go index 8a1d484..cdffec0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/domain/models.go b/internal/domain/models.go index aeb3653..98ae2b2 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -292,6 +292,10 @@ type SystemSettings struct { // metadata stay blocked AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"` SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"` + // Automatic backups: on/off, daily time (HH:MM, server time zone) and how many days to keep them + BackupEnabled bool `json:"backupEnabled"` + BackupTime string `json:"backupTime"` + BackupRetentionDays int `json:"backupRetentionDays"` } // UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole @@ -532,6 +536,7 @@ type FullExport struct { Documents []Document `json:"documents"` Lists []SharedList `json:"lists"` TaskLists []TaskList `json:"taskLists,omitempty"` + Themes []UserTheme `json:"themes,omitempty"` } type SharedFolder struct { diff --git a/internal/storage/account.go b/internal/storage/account.go index 9a0a9e2..e744aad 100644 --- a/internal/storage/account.go +++ b/internal/storage/account.go @@ -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 diff --git a/internal/storage/backup.go b/internal/storage/backup.go new file mode 100644 index 0000000..b6721a2 --- /dev/null +++ b/internal/storage/backup.go @@ -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//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), "..", "")) +} diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index aea9b6e..11db098 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -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 } diff --git a/internal/storage/system.go b/internal/storage/system.go index 9554149..ff29e2a 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -2,6 +2,7 @@ package storage import ( "encoding/json" + "strconv" "strings" "github.com/sandrodach/mindsift/internal/domain" @@ -75,6 +76,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { docs, _ := s.GetDocuments(userID) lists, _ := s.GetAllListsForUser(userID) taskLists, _ := s.GetTaskLists(userID) + themes, _ := s.GetUserThemes(userID) return &domain.FullExport{ ExportedAt: domain.NowISO(), @@ -84,6 +86,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { Documents: docs, Lists: lists, TaskLists: taskLists, + Themes: themes, }, nil } @@ -111,11 +114,13 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings := &domain.SystemSettings{ - SharingMode: SharingOrganizations, - AllowRegistration: false, - InstanceName: "MindSift", - SMTPPort: "587", - SMTPTLS: true, + SharingMode: SharingOrganizations, + AllowRegistration: false, + InstanceName: "MindSift", + SMTPPort: "587", + SMTPTLS: true, + BackupTime: DefaultBackupTime, + BackupRetentionDays: DefaultBackupRetentionDays, } rows, err := s.db.Query(`SELECT key, value FROM system_settings`) @@ -152,6 +157,16 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings.SMTPSkipVerify = v == "true" case "allow_private_webhooks": settings.AllowPrivateWebhooks = v == "true" + case "backup_enabled": + settings.BackupEnabled = v == "true" + case "backup_time": + if validBackupTime(v) { + settings.BackupTime = v + } + case "backup_retention_days": + if n, err := strconv.Atoi(v); err == nil && n > 0 { + settings.BackupRetentionDays = n + } } } } @@ -182,6 +197,12 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { if settings.InstanceName == "" { settings.InstanceName = "MindSift" } + if !validBackupTime(settings.BackupTime) { + settings.BackupTime = DefaultBackupTime + } + if settings.BackupRetentionDays < 1 || settings.BackupRetentionDays > 365 { + settings.BackupRetentionDays = DefaultBackupRetentionDays + } _, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('sharing_mode', ?), ('allow_registration', ?), @@ -193,7 +214,10 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { ('smtp_from', ?), ('smtp_tls', ?), ('smtp_skip_verify', ?), - ('allow_private_webhooks', ?) + ('allow_private_webhooks', ?), + ('backup_enabled', ?), + ('backup_time', ?), + ('backup_retention_days', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settings.SharingMode, regStr, @@ -206,6 +230,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { tlsStr, skipVerifyStr, privateHooksStr, + strconv.FormatBool(settings.BackupEnabled), + settings.BackupTime, + strconv.Itoa(settings.BackupRetentionDays), ) return err }