feat(backup): download the full backup as ZIP

GET /export?format=zip streams the JSON backup compressed (plain JSON stays
the API default). The MindSift import recognises the backup ZIP next to the
plain JSON and the Markdown archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 06:43:50 +01:00
1 parent 1601a6a2d5
commit 1df32ece92
7 files changed
+122 -25

No files matched your search

+10 -6
View File
@@ -2159,12 +2159,7 @@ func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.HasSuffix(strings.ToLower(name), ".json") || (len(data) > 0 && data[0] == '{') {
plan, err := storage.PlanMindSiftJSONImport(data)
s.runImport(w, r, plan, err)
return
}
plan, err := storage.PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
plan, err := storage.PlanMindSiftImport(data, name)
s.runImport(w, r, plan, err)
}
@@ -2197,6 +2192,15 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default
if r.URL.Query().Get("format") == "zip" {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
if err := storage.WriteBackupZip(exportData, w); err != nil {
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
}
return
}
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData)
}
+59
View File
@@ -2,6 +2,7 @@ package storage
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -10,6 +11,7 @@ import (
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/sandrodach/mindsift/internal/domain"
@@ -300,6 +302,63 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
return plan, nil
}
// BackupFileName is the JSON inside the backup ZIP.
const BackupFileName = "mindsift-backup.json"
// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file.
func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
zw := zip.NewWriter(w)
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: parseISO(exp.ExportedAt, time.Now())})
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(exp); err != nil {
return err
}
return zw.Close()
}
// PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP
// (one JSON inside) or the Markdown archive.
func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if strings.HasSuffix(strings.ToLower(fileName), ".json") || (len(data) > 0 && data[0] == '{') {
return PlanMindSiftJSONImport(data)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, errors.New("Die Datei ist weder ein MindSift-Backup (.json/.zip) noch ein MindSift-Markdown-Archiv")
}
var backup *zip.File
for _, f := range zr.File {
name := cleanArchivePath(f.Name)
if strings.EqualFold(path.Ext(name), ".md") {
backup = nil
break
}
if strings.EqualFold(path.Ext(name), ".json") && !hiddenPath(name) {
backup = f
}
}
if backup == nil {
return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
}
if backup.UncompressedSize64 > maxImportUnpackedBytes {
return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)")
}
rc, err := backup.Open()
if err != nil {
return nil, errors.New("Backup im Archiv ist nicht lesbar")
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, maxImportUnpackedBytes+1))
if err != nil || len(raw) > maxImportUnpackedBytes {
return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß")
}
return PlanMindSiftJSONImport(raw)
}
// PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the
// exporting user belong to someone else and are skipped; discarded thoughts are not restored.
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
+22
View File
@@ -197,3 +197,25 @@ func TestImportObsidianVault(t *testing.T) {
t.Errorf("invalid archive must be rejected")
}
}
func TestImportBackupZip(t *testing.T) {
store, anna, ben := newImportStore(t)
seedAnna(t, store, anna)
exp, _ := store.ExportUserData(anna.ID)
var buf bytes.Buffer
if err := WriteBackupZip(exp, &buf); err != nil {
t.Fatalf("WriteBackupZip: %v", err)
}
if buf.Len() == 0 {
t.Fatalf("empty archive")
}
plan, err := PlanMindSiftImport(buf.Bytes(), "mindsift-backup-2026-10-29.zip")
if err != nil {
t.Fatalf("plan: %v", err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatalf("apply: %v", err)
}
checkRestored(t, store, ben)
}