mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
79996f946a
commit
ceb5f72f12
3 changed files with 115 additions and 0 deletions
59
apps/mana/apps/web/src/lib/api/services/backup.ts
Normal file
59
apps/mana/apps/web/src/lib/api/services/backup.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue