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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 15:26:30 +02:00
parent 79996f946a
commit ceb5f72f12
3 changed files with 115 additions and 0 deletions

View file

@ -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<void> {
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);
},
};

View file

@ -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<string | null>(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 @@
</div>
</Card>
<!-- Backup & Wiederherstellung (M1 thin slice) -->
<Card>
<div class="p-6">
<h3 class="text-lg font-semibold mb-2 flex items-center gap-2">
<DownloadSimple size={20} class="text-indigo-500" />
Backup & Wiederherstellung
</h3>
<p class="text-sm text-muted-foreground mb-4">
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.
</p>
<div class="flex items-center gap-3">
<button
onclick={handleBackupDownload}
disabled={backupLoading}
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
<DownloadSimple size={16} />
<span>{backupLoading ? 'Lade Backup…' : 'Backup herunterladen (.jsonl)'}</span>
</button>
<span class="text-xs text-muted-foreground">
Experimentell — Import folgt in Kurze.
</span>
</div>
{#if backupError}
<p class="text-sm text-red-600 mt-3">{backupError}</p>
{/if}
</div>
</Card>
<!-- Danger Zone -->
<Card>
<div class="p-6 border-t-4 border-red-500">

View file

@ -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) {