From 9d64eb6aa20d487ccc45d181ca5745c01b0efb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:51:40 +0200 Subject: [PATCH] Show what a backup contains before the operator types RESTORE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore dialog asked for its typed confirmation on nothing but a filename and a date; the counts only surfaced in the audit row after the rows were already replaced. A read-only summary endpoint now decrypts and schema-validates the stored file through the same helpers the restore itself runs and answers the summarizeBackup counts, and the dialog shows them — per section, with an other-records line so a future summary key can never silently vanish from the preview. A failed preview states its own absence and never blocks the restore. --- messages/de.json | 17 +++ messages/en.json | 17 +++ messages/es.json | 17 +++ messages/fr.json | 17 +++ messages/it.json | 17 +++ messages/pl.json | 17 +++ .../[id]/summary/__tests__/route.test.ts | 95 ++++++++++++++ .../api/admin/backups/[id]/summary/route.ts | 79 +++++++++++ src/components/admin/backups-section.tsx | 124 ++++++++++++++++++ src/lib/query-keys/admin.ts | 3 + 10 files changed, 403 insertions(+) create mode 100644 src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts create mode 100644 src/app/api/admin/backups/[id]/summary/route.ts diff --git a/messages/de.json b/messages/de.json index 49ce66bee..779554b1b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Aus Backup wiederherstellen", "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", + "previewTitle": "Inhalt der Sicherung", + "previewLoading": "Inhalt wird gelesen…", + "previewUnavailable": "Inhalt konnte nicht gelesen werden — die Wiederherstellung bleibt möglich.", + "previewEmpty": "Die Datei enthält keine Datensätze.", + "previewOther": "Weitere Datensätze", + "previewMeasurements": "Messwerte", + "previewMedications": "Medikamente und Einnahmen", + "previewMood": "Stimmungseinträge", + "previewLabs": "Laborwerte", + "previewDocuments": "Dokumente", + "previewWorkouts": "Workouts", + "previewCycles": "Zyklusdaten", + "previewVisits": "Arztbesuche", + "previewVaccinations": "Impfungen", + "previewReminders": "Vorsorge-Erinnerungen", + "previewCustomMetrics": "Eigene Messgrößen", + "previewHealthScores": "Score-Verlauf", "restoreConfirm": "Jetzt wiederherstellen", "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", diff --git a/messages/en.json b/messages/en.json index cb3ea8113..79fb592b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Restore from backup", "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.", "restorePromptLabel": "Type RESTORE to continue", + "previewTitle": "What this backup contains", + "previewLoading": "Reading contents…", + "previewUnavailable": "Contents could not be read — restoring is still possible.", + "previewEmpty": "The file contains no records.", + "previewOther": "Other records", + "previewMeasurements": "Measurements", + "previewMedications": "Medications and intakes", + "previewMood": "Mood entries", + "previewLabs": "Lab results", + "previewDocuments": "Documents", + "previewWorkouts": "Workouts", + "previewCycles": "Cycle data", + "previewVisits": "Doctor visits", + "previewVaccinations": "Vaccinations", + "previewReminders": "Preventive-care reminders", + "previewCustomMetrics": "Custom metrics", + "previewHealthScores": "Score history", "restoreConfirm": "Restore now", "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", diff --git a/messages/es.json b/messages/es.json index 851bac220..4a9d2fc69 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Restaurar desde copia de seguridad", "restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.", "restorePromptLabel": "Escribe RESTORE para continuar", + "previewTitle": "Contenido de la copia", + "previewLoading": "Leyendo el contenido…", + "previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.", + "previewEmpty": "El archivo no contiene registros.", + "previewOther": "Otros registros", + "previewMeasurements": "Mediciones", + "previewMedications": "Medicamentos y tomas", + "previewMood": "Registros de ánimo", + "previewLabs": "Resultados de laboratorio", + "previewDocuments": "Documentos", + "previewWorkouts": "Entrenamientos", + "previewCycles": "Datos del ciclo", + "previewVisits": "Visitas médicas", + "previewVaccinations": "Vacunas", + "previewReminders": "Recordatorios preventivos", + "previewCustomMetrics": "Métricas propias", + "previewHealthScores": "Historial de puntuación", "restoreConfirm": "Restaurar ahora", "restoreInProgress": "Restaurando...", "restoreSuccess": "Restauración completada", diff --git a/messages/fr.json b/messages/fr.json index 0cfb46887..04f0b98e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Restaurer à partir d'une sauvegarde", "restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.", "restorePromptLabel": "Tapez RESTORE pour continuer", + "previewTitle": "Contenu de la sauvegarde", + "previewLoading": "Lecture du contenu…", + "previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.", + "previewEmpty": "Le fichier ne contient aucun enregistrement.", + "previewOther": "Autres enregistrements", + "previewMeasurements": "Mesures", + "previewMedications": "Médicaments et prises", + "previewMood": "Entrées d'humeur", + "previewLabs": "Résultats de laboratoire", + "previewDocuments": "Documents", + "previewWorkouts": "Entraînements", + "previewCycles": "Données de cycle", + "previewVisits": "Visites médicales", + "previewVaccinations": "Vaccinations", + "previewReminders": "Rappels de prévention", + "previewCustomMetrics": "Mesures personnalisées", + "previewHealthScores": "Historique du score", "restoreConfirm": "Restaurer maintenant", "restoreInProgress": "Restauration en cours...", "restoreSuccess": "Restauration terminée", diff --git a/messages/it.json b/messages/it.json index 11e37762e..8a506f1bb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Ripristina dal backup", "restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.", "restorePromptLabel": "Digita RESTORE per continuare", + "previewTitle": "Contenuto del backup", + "previewLoading": "Lettura del contenuto…", + "previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.", + "previewEmpty": "Il file non contiene record.", + "previewOther": "Altri record", + "previewMeasurements": "Misurazioni", + "previewMedications": "Farmaci e assunzioni", + "previewMood": "Voci dell'umore", + "previewLabs": "Risultati di laboratorio", + "previewDocuments": "Documenti", + "previewWorkouts": "Allenamenti", + "previewCycles": "Dati del ciclo", + "previewVisits": "Visite mediche", + "previewVaccinations": "Vaccinazioni", + "previewReminders": "Promemoria di prevenzione", + "previewCustomMetrics": "Metriche personalizzate", + "previewHealthScores": "Storico del punteggio", "restoreConfirm": "Ripristina ora", "restoreInProgress": "Ripristino in corso...", "restoreSuccess": "Ripristino completato", diff --git a/messages/pl.json b/messages/pl.json index d1a425342..00fac59c1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Przywróć z kopii zapasowej", "restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.", "restorePromptLabel": "Wpisz RESTORE, aby kontynuować", + "previewTitle": "Zawartość kopii", + "previewLoading": "Odczytywanie zawartości…", + "previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.", + "previewEmpty": "Plik nie zawiera rekordów.", + "previewOther": "Inne rekordy", + "previewMeasurements": "Pomiary", + "previewMedications": "Leki i dawki", + "previewMood": "Wpisy nastroju", + "previewLabs": "Wyniki badań", + "previewDocuments": "Dokumenty", + "previewWorkouts": "Treningi", + "previewCycles": "Dane cyklu", + "previewVisits": "Wizyty lekarskie", + "previewVaccinations": "Szczepienia", + "previewReminders": "Przypomnienia profilaktyczne", + "previewCustomMetrics": "Własne metryki", + "previewHealthScores": "Historia wyniku", "restoreConfirm": "Przywróć teraz", "restoreInProgress": "Przywracanie...", "restoreSuccess": "Przywracanie zakończone", diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts new file mode 100644 index 000000000..d06486115 --- /dev/null +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * v1.37.20 — restore preview: the summary read decrypts and validates the + * stored file through the SAME helpers the restore uses, and only ever + * reads. Watched red: written against the route before its annotate landed; + * the not-found and decrypt-failure arms were asserted first. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), + HttpError: class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const findUniqueMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) }, + }, +})); + +const decryptMock = vi.fn(); +vi.mock("@/lib/crypto", () => ({ + decrypt: (...a: unknown[]) => decryptMock(...a), +})); + +const parseMock = vi.fn(); +const summarizeMock = vi.fn(); +vi.mock("@/lib/validations/backup", () => ({ + parseBackupPayload: (...a: unknown[]) => parseMock(...a), + summarizeBackup: (...a: unknown[]) => summarizeMock(...a), + isCompatibleSchemaVersion: vi.fn(() => true), +})); + +import { GET } from "../route"; + +const request = new NextRequest( + "http://localhost/api/admin/backups/b1/summary", +); +const params = { params: Promise.resolve({ id: "b1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + findUniqueMock.mockResolvedValue({ + id: "b1", + userId: "u1", + data: "ciphertext", + createdAt: new Date("2026-08-01T00:00:00Z"), + user: { id: "u1", username: "self-hoster" }, + }); + decryptMock.mockReturnValue("{}"); + parseMock.mockReturnValue({ schemaVersion: 3 }); + summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 }); +}); + +describe("GET /api/admin/backups/[id]/summary", () => { + it("answers the file's counts without writing anything", async () => { + const res = await GET(request, params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 }); + expect(body.data.owner).toBe("self-hoster"); + }); + + it("404s an unknown backup id", async () => { + findUniqueMock.mockResolvedValue(null); + await expect(GET(request, params)).rejects.toMatchObject({ status: 404 }); + }); + + it("degrades to an error response when the file cannot be decrypted", async () => { + decryptMock.mockImplementation(() => { + throw new Error("bad key"); + }); + const res = await GET(request, params); + expect(res.status).toBe(500); + }); + + it("422s a file that fails schema validation", async () => { + parseMock.mockImplementation(() => { + throw new Error("nope"); + }); + const res = await GET(request, params); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts new file mode 100644 index 000000000..fe32c81e7 --- /dev/null +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -0,0 +1,79 @@ +/** + * v1.37.20 — restore preview: what a backup file contains, BEFORE the + * operator confirms a restore. + * + * The restore dialog used to ask for the typed confirmation on nothing but + * a filename and a date; the counts only appeared in the audit row after + * the rows were already replaced. This read decrypts and schema-validates + * the stored file exactly the way the restore itself does (same decrypt, + * same `parseBackupPayload`, same compatibility gate) and answers with the + * `summarizeBackup` counts — so what the operator confirms is what the + * restore will write, derived from the same code path. Read-only; nothing + * is touched. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { annotate } from "@/lib/logging/context"; +import { + isCompatibleSchemaVersion, + parseBackupPayload, + summarizeBackup, +} from "@/lib/validations/backup"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user: admin } = await requireAdmin(); + void admin; + const { id } = await params; + + const backup = await prisma.dataBackup.findUnique({ + where: { id }, + include: { user: { select: { id: true, username: true } } }, + }); + if (!backup) { + throw new HttpError(404, "Backup not found"); + } + + let plaintext: string; + try { + plaintext = decrypt(backup.data); + } catch { + return apiError("Failed to decrypt backup payload", 500); + } + + let payload; + try { + payload = parseBackupPayload(plaintext); + } catch { + return apiError("Backup payload failed schema validation", 422); + } + + if (!isCompatibleSchemaVersion(payload.schemaVersion)) { + return apiError( + `Backup schema version ${payload.schemaVersion} is not restorable by this release`, + 422, + ); + } + + const summary = summarizeBackup(payload); + + annotate({ + action: { name: "admin.backups.summary" }, + meta: { backupId: id, ownerId: backup.userId }, + }); + + return apiSuccess({ + summary, + owner: backup.user?.username ?? null, + createdAt: backup.createdAt.toISOString(), + }); + }, +); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 7950bc323..abcf78d0e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -68,6 +68,46 @@ import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; * Mirrors the wipe dialog's pattern but adds the typed gate because the * blast radius is bigger (re-creates rows, not just deletes). */ +/** + * v1.37.20 — the sections a restore preview names, in render order. Each + * entry sums one or more `BackupSummary` counters under one human label; + * every counter NOT named here still reaches the operator through the + * trailing "other records" line, so a future summary key can be forgotten + * here without silently vanishing from the preview. + */ +const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ + labelKey: string; + keys: readonly string[]; +}> = [ + { labelKey: "previewMeasurements", keys: ["measurements"] }, + { + labelKey: "previewMedications", + keys: ["medications", "intakeEvents", "medicationSideEffects"], + }, + { labelKey: "previewMood", keys: ["moodEntries"] }, + { labelKey: "previewLabs", keys: ["labResults", "biomarkers"] }, + { labelKey: "previewDocuments", keys: ["documents"] }, + { labelKey: "previewWorkouts", keys: ["workouts"] }, + { labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] }, + { + labelKey: "previewVisits", + keys: ["encounters", "practitioners", "encounterLinks"], + }, + { + labelKey: "previewVaccinations", + keys: ["vaccinations", "vaccinationLinks"], + }, + { + labelKey: "previewReminders", + keys: ["measurementReminders", "measurementReminderEvents"], + }, + { + labelKey: "previewCustomMetrics", + keys: ["customMetrics", "customMetricEntries"], + }, + { labelKey: "previewHealthScores", keys: ["healthScoreRecords"] }, +]; + function RestoreRowDialog({ row, pending, @@ -83,6 +123,39 @@ function RestoreRowDialog({ const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; + // v1.37.20 — restore preview: fetch what the file contains the moment the + // dialog opens, so the typed confirmation is an informed one. Derived from + // the same decrypt + schema path the restore itself runs. A failed preview + // never blocks the restore — it states its own absence instead. + const preview = useQuery({ + queryKey: queryKeys.adminBackupSummary(row.id), + queryFn: () => + apiGet<{ summary: Record }>( + `/api/admin/backups/${row.id}/summary`, + ), + enabled: open, + staleTime: 60_000, + }); + const numericSummary: Record = {}; + if (preview.data?.summary) { + for (const [key, value] of Object.entries(preview.data.summary)) { + if (typeof value === "number") numericSummary[key] = value; + } + } + const namedKeys = new Set( + RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys), + ); + const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({ + labelKey: section.labelKey, + count: section.keys.reduce( + (sum, key) => sum + (numericSummary[key] ?? 0), + 0, + ), + })).filter((entry) => entry.count > 0); + const otherCount = Object.entries(numericSummary) + .filter(([key]) => !namedKeys.has(key)) + .reduce((sum, [, value]) => sum + value, 0); + return ( + {/* v1.37.20 — what the file contains, before the typed gate. */} +
+

+ {t("admin.section.backups.previewTitle")} +

+ {preview.isLoading && ( +

+ {t("admin.section.backups.previewLoading")} +

+ )} + {preview.isError && ( +

+ {t("admin.section.backups.previewUnavailable")} +

+ )} + {preview.isSuccess && ( +
    + {previewRows.map((entry) => ( +
  • + + {t(`admin.section.backups.${entry.labelKey}`)} + + + {fmt.integer(entry.count)} + +
  • + ))} + {otherCount > 0 && ( +
  • + + {t("admin.section.backups.previewOther")} + + + {fmt.integer(otherCount)} + +
  • + )} + {previewRows.length === 0 && otherCount === 0 && ( +
  • + {t("admin.section.backups.previewEmpty")} +
  • + )} +
+ )} +