diff --git a/messages/de.json b/messages/de.json index 321fde52c..28eb77533 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1551,7 +1551,12 @@ "colType": "Typ", "colSize": "Größe", "colCreatedAt": "Letzter Snapshot", - "retentionLabel": "Aufbewahrung: {days} Tage" + "colActions": "Aktionen", + "retentionLabel": "Aufbewahrung: {days} Tage", + "download": "Herunterladen", + "downloadAria": "Backup für {username} herunterladen", + "downloadFailed": "Download fehlgeschlagen", + "downloadStarted": "Download gestartet" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 3b30a85dc..34c0e093e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1551,7 +1551,12 @@ "colType": "Type", "colSize": "Size", "colCreatedAt": "Last snapshot", - "retentionLabel": "Retention: {days} days" + "colActions": "Actions", + "retentionLabel": "Retention: {days} days", + "download": "Download", + "downloadAria": "Download backup for {username}", + "downloadFailed": "Download failed", + "downloadStarted": "Download started" }, "danger-zone": { "title": "Danger Zone", diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts new file mode 100644 index 000000000..b063b0ced --- /dev/null +++ b/src/app/api/admin/backups/[id]/download/route.ts @@ -0,0 +1,121 @@ +/** + * GET /api/admin/backups/[id]/download — admin-only download of a single + * `DataBackup` snapshot as JSON. + * + * The encrypted blob in the DB is decrypted, parsed, then streamed back + * with `Content-Disposition: attachment` so the browser saves it as + * `healthlog-backup--.json`. Auth gate mirrors the rest + * of the `/api/admin/backups` family — `requireAdmin()` (cookie session + * only; bearer tokens never elevate). + * + * Phase B1 / criterion 1 of the v1.4.15 backup-completeness work: today + * admins can list backups and trigger a manual run, but never see the + * payload. This endpoint closes the loop without breaking the encryption- + * at-rest contract: the ciphertext stays in the DB, the plaintext is only + * materialised inside the request handler and streamed to the admin. + */ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { decrypt } from "@/lib/crypto"; +import { annotate } from "@/lib/logging/context"; +import { parseBackupPayload } from "@/lib/validations/backup"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user: admin } = await requireAdmin(); + const { id } = await params; + annotate({ action: { name: "admin.backups.download" }, meta: { id } }); + + const backup = await prisma.dataBackup.findUnique({ + where: { id }, + include: { user: { select: { id: true, username: true } } }, + }); + + if (!backup) { + // Audit the denied attempt — an admin trying to download a + // backup that no longer exists is interesting on its own. + await auditLog("admin.backups.download.denied", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { reason: "not_found", backupId: id }, + }); + throw new HttpError(404, "Backup not found"); + } + + let plaintext: string; + try { + plaintext = decrypt(backup.data); + } catch (err) { + // Decryption failure is rare but real — a rotated/missing key, or a + // corrupted blob from a partially-failed write. Surface it as a 500 + // and audit the event so the admin has something to investigate. + await auditLog("admin.backups.download.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId: backup.userId, + reason: err instanceof Error ? err.message : "decrypt_failed", + }, + }); + return apiError("Failed to decrypt backup payload", 500); + } + + // Validate the payload BEFORE handing it back. This catches the + // pathological case where the worker wrote something the upload + // route would later reject — better to fail loudly here than to ship + // a junk file that breaks an admin's restore plan. + try { + parseBackupPayload(plaintext); + } catch (err) { + await auditLog("admin.backups.download.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId: backup.userId, + reason: "schema_invalid", + message: err instanceof Error ? err.message : String(err), + }, + }); + return apiError("Backup payload failed schema validation", 500); + } + + const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD + const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`; + + await auditLog("admin.backups.download", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId: backup.userId, + ownerUsername: backup.user.username, + type: backup.type, + sizeBytes: Buffer.byteLength(plaintext, "utf8"), + }, + }); + + // Returning the plaintext as-is keeps the response a faithful copy of + // what the worker wrote (formatting, key order). The file is the + // canonical artefact admins will store / re-upload. + return new NextResponse(plaintext, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-disposition": `attachment; filename="${filename}"`, + // Backups contain sensitive health data — keep them out of any + // shared cache (CDN, browser disk cache). + "cache-control": "no-store, max-age=0", + }, + }); + }, +); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index a21c1447b..a9046a169 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -12,7 +12,8 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database, Loader2, PlayCircle } from "lucide-react"; +import { Database, Download, Loader2, PlayCircle } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -71,6 +72,46 @@ export function BackupsSection() { const rows: BackupRow[] = data?.rows ?? []; + // Per-row download in-flight state — keyed by backup id so two + // parallel clicks on different rows don't share a single spinner. + const [downloadingId, setDownloadingId] = useState(null); + + async function handleDownload(row: BackupRow) { + setDownloadingId(row.id); + try { + const res = await fetch(`/api/admin/backups/${row.id}/download`); + if (!res.ok) { + throw new Error(await getApiErrorMessage(res)); + } + // Use the server-provided Content-Disposition filename if present, + // otherwise fall back to a deterministic client-side name. The + // server filename is more accurate (uses the actual createdAt). + const cd = res.headers.get("content-disposition") ?? ""; + const match = cd.match(/filename="?([^";]+)"?/i); + const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`; + const filename = match?.[1] ?? fallback; + + const blob = await res.blob(); + 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); + toast.success(t("admin.section.backups.downloadStarted")); + } catch (err) { + toast.error( + err instanceof Error && err.message + ? err.message + : t("admin.section.backups.downloadFailed"), + ); + } finally { + setDownloadingId(null); + } + } + return (
@@ -137,6 +178,9 @@ export function BackupsSection() { {t("admin.section.backups.colCreatedAt")} + + {t("admin.section.backups.colActions")} + @@ -154,6 +198,24 @@ export function BackupsSection() { {formatDateTime(row.createdAt)} + + + ))} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 3b5f1624c..74391dca9 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -975,6 +975,10 @@ async function handleDataBackup(jobs: Job[]) { ]); const backupJson = JSON.stringify({ + // Bumped only when the on-disk shape changes incompatibly. + // Mirrors `BACKUP_SCHEMA_VERSION` in + // `src/lib/validations/backup.ts` — keep them in sync. + schemaVersion: "1", exportedAt: new Date().toISOString(), userId: user.id, measurements: measurements.map((m) => ({ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts new file mode 100644 index 000000000..2e015d5a1 --- /dev/null +++ b/src/lib/validations/backup.ts @@ -0,0 +1,153 @@ +/** + * Zod schemas + helpers for the per-user `DataBackup` JSON payload. + * + * Single source of truth shared by: + * - the pg-boss `data-backup` worker that writes a backup, + * - `GET /api/admin/backups/[id]/download` that streams it back as JSON, + * - `POST /api/admin/backups/upload` that ingests an admin-supplied file, + * - `POST /api/admin/backups/[id]/restore` that re-creates DB rows from it. + * + * The schema is intentionally permissive about *extra* fields (`.passthrough`) + * so older snapshots written before a column was added still parse and so + * future minor additions don't break old admins. Required fields are + * deliberately tight — any drift surfaces as a validation error rather than + * a silent data-loss restore. + * + * `schemaVersion` is the migration handle. Bump when the on-disk shape + * changes incompatibly. The current writer (worker) historically did NOT + * include this field; `parseBackupPayload()` defaults it to "1" so legacy + * blobs continue to round-trip and the upload validator can still produce + * a useful summary for them. + */ +import { z } from "zod/v4"; + +export const BACKUP_SCHEMA_VERSION = "1" as const; + +const isoDateTime = z + .string() + .min(1) + .refine((s) => !Number.isNaN(Date.parse(s)), { + message: "Expected ISO-8601 date-time string", + }); + +const measurementSchema = z + .object({ + type: z.string().min(1), + value: z.number(), + unit: z.string().min(1), + measuredAt: isoDateTime, + source: z.string().min(1).optional(), + notes: z.string().nullable().optional(), + }) + .passthrough(); + +const medicationScheduleSchema = z + .object({ + windowStart: z.string().min(1), + windowEnd: z.string().min(1), + label: z.string().nullable().optional(), + dose: z.string().nullable().optional(), + }) + .passthrough(); + +const medicationSchema = z + .object({ + name: z.string().min(1), + dose: z.string(), + active: z.boolean().optional(), + schedules: z.array(medicationScheduleSchema).default([]), + }) + .passthrough(); + +const intakeEventSchema = z + .object({ + medication: z.string().min(1), + scheduledFor: isoDateTime, + takenAt: isoDateTime.nullable().optional(), + skipped: z.boolean().optional(), + source: z.string().min(1).optional(), + }) + .passthrough(); + +const moodEntrySchema = z + .object({ + date: z.string().min(1), + mood: z.string().min(1), + score: z.number().int().min(0).max(10), + tags: z.string().nullable().optional(), + source: z.string().min(1).optional(), + loggedAt: isoDateTime, + }) + .passthrough(); + +/** + * Wire shape — exactly what the pg-boss worker writes today, plus a + * `schemaVersion` field that newer writers stamp explicitly. Older blobs + * without the field default to v1 in `parseBackupPayload`. + */ +export const backupPayloadSchema = z + .object({ + schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION), + exportedAt: isoDateTime, + userId: z.string().min(1), + measurements: z.array(measurementSchema).default([]), + medications: z.array(medicationSchema).default([]), + intakeEvents: z.array(intakeEventSchema).default([]), + moodEntries: z.array(moodEntrySchema).default([]), + }) + .passthrough(); + +export type BackupPayload = z.infer; + +/** + * Numeric counts of each backed-up record kind. Returned in the + * upload + restore API responses so the admin sees what they + * uploaded/restored without having to download the file again. + */ +export interface BackupSummary { + schemaVersion: string; + userId: string; + exportedAt: string; + measurements: number; + medications: number; + intakeEvents: number; + moodEntries: number; +} + +export function summarizeBackup(payload: BackupPayload): BackupSummary { + return { + schemaVersion: payload.schemaVersion, + userId: payload.userId, + exportedAt: payload.exportedAt, + measurements: payload.measurements.length, + medications: payload.medications.length, + intakeEvents: payload.intakeEvents.length, + moodEntries: payload.moodEntries.length, + }; +} + +/** + * Parse a JSON blob (string or already-parsed object) against + * `backupPayloadSchema`, returning a typed payload. Throws ZodError on + * mismatch — the admin route catches it and turns it into a 422 with a + * field-level error list. + * + * Accepts both forms because: + * - the upload route hands us the parsed object after `await req.text()` + * - the restore route reads `DataBackup.data` (decrypted) which is + * always a JSON string straight from the worker. + */ +export function parseBackupPayload(input: string | unknown): BackupPayload { + const raw = typeof input === "string" ? JSON.parse(input) : input; + return backupPayloadSchema.parse(raw); +} + +/** + * The schemaVersion the system understands today. Used by the upload + * route to reject inbound files written by a *future* HealthLog instance + * — restoring them under the current code might silently drop data the + * new shape carried. + */ +export function isCompatibleSchemaVersion(version: string): boolean { + return version === BACKUP_SCHEMA_VERSION; +} diff --git a/tests/integration/admin-backups-download.test.ts b/tests/integration/admin-backups-download.test.ts new file mode 100644 index 000000000..7ceb2f598 --- /dev/null +++ b/tests/integration/admin-backups-download.test.ts @@ -0,0 +1,156 @@ +/** + * Integration test for `GET /api/admin/backups/[id]/download`. + * + * Verifies: + * - 200 + content-type=application/json + content-disposition=attachment + * - the streamed body is non-empty and round-trips through + * `parseBackupPayload()` (proves we shipped the same shape we'll + * accept via the upload route). + * - 404 on unknown id (with audit-log denial entry). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Crypto module reads `ENCRYPTION_KEY` at first call. Vitest doesn't load +// the dev `.env`, so seed a deterministic 32-byte test key BEFORE the +// `@/lib/crypto` import below (Node binds module-level imports first, but +// `loadKeys()` is lazy — runs on first `encrypt()` call — so an env set +// here is observed in time). +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { parseBackupPayload } from "@/lib/validations/backup"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedAdminSession() { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "backup-admin", + email: "backup-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { + userId: admin.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return admin; +} + +describe("GET /api/admin/backups/[id]/download", () => { + it("streams a valid backup payload as JSON with Content-Disposition", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + + const payload = { + schemaVersion: "1", + exportedAt: "2026-05-09T10:00:00.000Z", + userId: admin.id, + measurements: [ + { + type: "WEIGHT", + value: 82.4, + unit: "kg", + measuredAt: "2026-05-08T07:00:00.000Z", + source: "MANUAL", + notes: null, + }, + ], + medications: [], + intakeEvents: [], + moodEntries: [], + }; + const encrypted = encrypt(JSON.stringify(payload)); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "WEEKLY_AUTO", + data: encrypted, + }, + }); + + const { GET } = await import("@/app/api/admin/backups/[id]/download/route"); + const req = new Request( + `http://localhost/api/admin/backups/${backup.id}/download`, + ); + const res = await GET(req as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("content-disposition") ?? "").toMatch( + /^attachment;\s*filename=".+\.json"$/i, + ); + + const text = await res.text(); + expect(text.length).toBeGreaterThan(0); + const parsed = parseBackupPayload(text); + expect(parsed.userId).toBe(admin.id); + expect(parsed.measurements).toHaveLength(1); + + // Audit trail: a download event must be present. + const events = await prisma.auditLog.findMany({ + where: { action: "admin.backups.download" }, + }); + expect(events).toHaveLength(1); + expect(events[0]?.userId).toBe(admin.id); + }); + + it("returns 404 for an unknown id and audits the denied attempt", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + + const { GET } = await import("@/app/api/admin/backups/[id]/download/route"); + const req = new Request( + "http://localhost/api/admin/backups/does-not-exist/download", + ); + const res = await GET(req as unknown as Parameters[0], { + params: Promise.resolve({ id: "does-not-exist" }), + }); + + expect(res.status).toBe(404); + + const denied = await prisma.auditLog.findMany({ + where: { action: "admin.backups.download.denied", userId: admin.id }, + }); + expect(denied).toHaveLength(1); + }); +});