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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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
|
||||
|
||||
@@ -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/<user id>/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), "..", ""))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user