From ceb5f72f123157c21b115b1910e1fa120006f30f Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 15:26:30 +0200 Subject: [PATCH] feat(sync): wire /backup/export route + client + settings UI (M1 tail) Recovering three files dropped when a parallel terminal reset past the original M1 commit: - cmd/server/main.go: register GET /backup/export outside billingMiddleware - lib/api/services/backup.ts: browser-side downloadBackup() helper - settings/my-data/+page.svelte: "Backup & Wiederherstellung" section Pairs with the earlier backup handler + schema_version work already on main (79996f946). With this commit the endpoint is actually reachable end-to-end and the download button works. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/api/services/backup.ts | 59 +++++++++++++++++++ .../(app)/settings/my-data/+page.svelte | 49 +++++++++++++++ services/mana-sync/cmd/server/main.go | 7 +++ 3 files changed, 115 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/api/services/backup.ts diff --git a/apps/mana/apps/web/src/lib/api/services/backup.ts b/apps/mana/apps/web/src/lib/api/services/backup.ts new file mode 100644 index 000000000..1fcb2a16e --- /dev/null +++ b/apps/mana/apps/web/src/lib/api/services/backup.ts @@ -0,0 +1,59 @@ +/** + * Backup / Restore API — M1 thin slice. + * + * Talks directly to mana-sync's /backup/export endpoint, which streams every + * sync_changes row owned by the current user as JSONL (one event per line). + * The file is immediately usable as input for a future import flow: replaying + * the events through applyServerChanges() reconstructs the user's entire + * dataset in a fresh IndexedDB. + * + * Field-level encrypted fields stay ciphertext throughout — the file is safe + * at rest for those fields. Plaintext fields (IDs, timestamps, sort keys) are + * visible as-is, which matches the GDPR data-portability expectation. + */ + +import { authStore } from '$lib/stores/auth.svelte'; + +function getSyncServerUrl(): string { + if (typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_SYNC_SERVER_URL__?: string }) + .__PUBLIC_SYNC_SERVER_URL__; + if (injected) return injected; + } + return (import.meta.env.PUBLIC_SYNC_SERVER_URL as string | undefined) ?? 'http://localhost:3050'; +} + +export const backupService = { + /** + * Trigger a browser download of the user's full sync-event backup as + * a .jsonl file. Streams directly from mana-sync; no intermediate buffer + * in the app server. + */ + async downloadBackup(): Promise { + const token = await authStore.getValidToken(); + if (!token) throw new Error('not authenticated'); + + const response = await fetch(`${getSyncServerUrl()}/backup/export`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`backup export failed: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const filename = + response.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] || + `mana-backup-${new Date().toISOString().slice(0, 10)}.jsonl`; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/settings/my-data/+page.svelte b/apps/mana/apps/web/src/routes/(app)/settings/my-data/+page.svelte index 041e61989..3e224fa30 100644 --- a/apps/mana/apps/web/src/routes/(app)/settings/my-data/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/settings/my-data/+page.svelte @@ -19,6 +19,7 @@ import DeleteConfirmationModal from '$lib/components/my-data/DeleteConfirmationModal.svelte'; import QRExportModal from '$lib/components/my-data/QRExportModal.svelte'; import { myDataService, type UserDataSummary } from '$lib/api/services/my-data'; + import { backupService } from '$lib/api/services/backup'; import type { DeleteUserDataResponse } from '$lib/api/services/admin'; import { authStore } from '$lib/stores/auth.svelte'; @@ -36,6 +37,22 @@ // QR Export dialog state let showQRDialog = $state(false); + // Backup (M1 thin slice) state + let backupLoading = $state(false); + let backupError = $state(null); + + async function handleBackupDownload() { + backupLoading = true; + backupError = null; + try { + await backupService.downloadBackup(); + } catch (e) { + backupError = e instanceof Error ? e.message : 'Backup fehlgeschlagen'; + } finally { + backupLoading = false; + } + } + async function loadMyData() { loading = true; error = null; @@ -366,6 +383,38 @@ + + +
+

+ + Backup & Wiederherstellung +

+

+ Lade eine vollstandige Kopie deiner synchronisierten Daten als JSONL-Datei herunter. Die + Datei enthalt den kompletten Sync-Event-Stream deines Accounts — geeignet fur + Account-Migration, Backups oder DSGVO-Datenportabilitat. Sensible Felder bleiben dabei + verschlusselt. +

+
+ + + Experimentell — Import folgt in Kurze. + +
+ {#if backupError} +

{backupError}

+ {/if} +
+
+
diff --git a/services/mana-sync/cmd/server/main.go b/services/mana-sync/cmd/server/main.go index 73a151a1e..99da65a67 100644 --- a/services/mana-sync/cmd/server/main.go +++ b/services/mana-sync/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/mana/mana-sync/internal/auth" + "github.com/mana/mana-sync/internal/backup" "github.com/mana/mana-sync/internal/billing" "github.com/mana/mana-sync/internal/config" "github.com/mana/mana-sync/internal/store" @@ -65,6 +66,12 @@ func main() { mux.Handle("GET /sync/{appId}/pull", billingMiddleware(http.HandlerFunc(handler.HandlePull))) mux.Handle("GET /sync/{appId}/stream", billingMiddleware(http.HandlerFunc(handler.HandleStream))) + // Backup/export — GDPR-grade, auth-only (no billing gate so users can + // always retrieve their data). M1 thin slice: streams raw sync_changes + // as JSONL. Manifest + zip container land in M3. + backupHandler := backup.NewHandler(db, validator) + mux.Handle("GET /backup/export", http.HandlerFunc(backupHandler.HandleExport)) + // WebSocket endpoints // Unified: one connection per user, receives all app notifications with appId in payload mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {