mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/v1463-restore' into release/v1.46.3
This commit is contained in:
commit
00d129ad42
28 files changed
+2504
-154
No files matched your search
+82
-20
@@ -25090,10 +25090,52 @@ paths:
|
||||
same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the
|
||||
backup schema, was written by a newer schema version than this server reads, needs an encryption key this server
|
||||
does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account
|
||||
that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens
|
||||
cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
|
||||
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
|
||||
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
|
||||
that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists
|
||||
here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>`
|
||||
for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy,
|
||||
with no password and no email (an admin sets its first password with the existing password reset). The file's
|
||||
owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like
|
||||
any other; the erased account's id is not stored. A target is refused before the file is read when the account
|
||||
does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does
|
||||
not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three
|
||||
uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A
|
||||
browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last
|
||||
five minutes (with a second factor, on an account that has one). Without it the answer is 401
|
||||
`auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
|
||||
parameters:
|
||||
- in: query
|
||||
name: into
|
||||
schema:
|
||||
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
|
||||
which has to exist."
|
||||
type: string
|
||||
enum:
|
||||
- existing
|
||||
- new
|
||||
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
|
||||
which has to exist."
|
||||
- in: query
|
||||
name: userId
|
||||
schema:
|
||||
description: "With `into=existing`: the account's id."
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "With `into=existing`: the account's id."
|
||||
- in: query
|
||||
name: confirm
|
||||
schema:
|
||||
description: "With `into=existing`: the account's username, typed by the operator."
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "With `into=existing`: the account's username, typed by the operator."
|
||||
- in: query
|
||||
name: username
|
||||
schema:
|
||||
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 30
|
||||
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -25139,6 +25181,22 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403": *a25
|
||||
"404":
|
||||
description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: "The chosen target cannot take the copy, and nothing was stored: a managed profile
|
||||
(`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an
|
||||
account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username
|
||||
already taken (`backup.target.username_taken`), or a file whose own account is still here
|
||||
(`backup.owner.exists`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this
|
||||
server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` =
|
||||
@@ -25150,8 +25208,9 @@ paths:
|
||||
"422":
|
||||
description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as
|
||||
gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not
|
||||
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist
|
||||
here."
|
||||
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here
|
||||
(`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose
|
||||
`confirm` is not the account's username (`backup.target.unconfirmed`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -38654,20 +38713,23 @@ components:
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
|
||||
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
|
||||
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
|
||||
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
|
||||
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired,
|
||||
consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich —
|
||||
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
|
||||
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
|
||||
backup.payload.undecryptable, backup.restore.active, backup.section.missing,
|
||||
backup.target.holds_records, backup.target.managed_profile, backup.target.not_found,
|
||||
backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken,
|
||||
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
|
||||
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
|
||||
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
|
||||
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
|
||||
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
|
||||
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle —
|
||||
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
|
||||
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
|
||||
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
|
||||
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
|
||||
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
|
||||
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
|
||||
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
HealthLog keeps copies of each account so that one account can be rolled back
|
||||
on this server. These copies are per account. They cannot rebuild a lost
|
||||
server: a restore writes into an account that already exists here under the
|
||||
same id, and refuses a copy whose account is gone. To recover a server, you
|
||||
need a database dump and the encryption keys. That procedure is in
|
||||
server: a restore writes into an account on this server, the one the copy
|
||||
belongs to, or, when that account has been erased, one you choose (see
|
||||
"Restoring an erased account"). To recover a server, you need a database dump
|
||||
and the encryption keys. That procedure is in
|
||||
[disaster-recovery.md](disaster-recovery.md).
|
||||
|
||||
## What exists
|
||||
@@ -81,13 +82,50 @@ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restor
|
||||
```
|
||||
|
||||
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
|
||||
compression) and restore it from its row. The account `<id>` must exist on this
|
||||
server.
|
||||
compression) and restore it from its row. When the account `<id>` no longer
|
||||
exists on this server, the upload offers to put the copy into another account
|
||||
(next section).
|
||||
|
||||
Every restore, preview and upload checks first that this server holds every
|
||||
application key the copy's content was written under, and refuses with the key
|
||||
id named if not. Nothing is changed by a refused restore.
|
||||
|
||||
### Restoring an erased account
|
||||
|
||||
Once an account has been erased (its deletion window ended and the hourly purge
|
||||
ran), its copies on this server and in the bucket are gone, but a file you kept
|
||||
still holds the record: a disaster-recovery download, an off-host object you
|
||||
fetched before it expired, or the person's own full backup. Uploading it in
|
||||
Admin → Backups is refused at first, because the account it names no longer
|
||||
exists, and the console then asks where the records should go:
|
||||
|
||||
- **A new account.** Type a username. The account is created without a
|
||||
password or email, as an ordinary account with the user role, and nobody
|
||||
can sign into it yet. Before handing it over, set its first password with
|
||||
**Reset password** in Admin → Users; the person can change it, add an email
|
||||
and enrol a passkey from their settings.
|
||||
- **An existing account**, for someone who has signed up again. Pick the
|
||||
account and type its username to confirm. The account must hold no records
|
||||
yet: a restore replaces an account's records and does not merge them, so an
|
||||
account with even one record of any kind a copy carries is refused, with the
|
||||
kinds named. Managed profiles and accounts pending deletion are not offered.
|
||||
|
||||
The file is then uploaded a second time, and its owner is changed to the chosen
|
||||
account as it is read. Nothing else in the file changes. The stored copy is
|
||||
then that account's copy in every respect: its preview, its download and its
|
||||
restore are the ordinary ones, and **Restore** on its row brings everything
|
||||
back under the chosen account. The erased account's id is not stored, not in
|
||||
the copy and not in the audit trail.
|
||||
|
||||
A copy whose own account still exists on this server can only go back into
|
||||
that account; choosing a target for it is refused. The copy does not carry
|
||||
what a restore never touches (below), so a returning person signs in fresh,
|
||||
and the links of a managed profile to its guardians do not come back: a
|
||||
restored managed profile is an ordinary account.
|
||||
|
||||
Once stored, the copy restores like any other: a restore run later, after the
|
||||
account has gained records, replaces those too.
|
||||
|
||||
### What a restore does not touch
|
||||
|
||||
API tokens, trusted devices, step-up elevations, known devices, clinician share
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet",
|
||||
"uploadTitle": "Backup hochladen",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Eine Kopie, deren Konto es auf diesem Server nicht mehr gibt, kann in ein anderes Konto gehen. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadButton": "Datei hochladen",
|
||||
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
|
||||
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.",
|
||||
"offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen."
|
||||
},
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück.",
|
||||
"target": {
|
||||
"title": "In ein anderes Konto wiederherstellen",
|
||||
"description": "{file} gehört zu einem Konto, das es auf diesem Server nicht mehr gibt. Wähle das Konto, in das die Datensätze gehen. Eine Wiederherstellung ersetzt die Datensätze eines Kontos, deshalb kann sie nur ein Konto ohne Datensätze aufnehmen. Die Datei wird dafür noch einmal hochgeladen.",
|
||||
"choiceLabel": "Wohin die Datensätze gehen",
|
||||
"newAccount": "Neues Konto",
|
||||
"existingAccount": "Bestehendes Konto",
|
||||
"usernameLabel": "Benutzername für das neue Konto",
|
||||
"usernameInvalid": "3 bis 30 Buchstaben, Ziffern, _ oder -, nicht mit „managed-“ am Anfang.",
|
||||
"newAccountHint": "Das Konto wird ohne Passwort und ohne E-Mail angelegt. Bevor du es übergibst, setze sein erstes Passwort unter Nutzer mit „Passwort zurücksetzen“.",
|
||||
"accountLabel": "Konto",
|
||||
"accountPlaceholder": "Konto wählen",
|
||||
"checking": "Konto wird geprüft …",
|
||||
"eligible": "Dieses Konto hat noch keine Datensätze und kann die Kopie aufnehmen.",
|
||||
"confirmLabel": "Tippe {username} zum Bestätigen",
|
||||
"submit": "In dieses Konto hochladen",
|
||||
"submitting": "Wird hochgeladen …",
|
||||
"success": "Backup für {username} gespeichert ({count} Datensätze). Stelle es über seine Zeile wieder her.",
|
||||
"failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.",
|
||||
"failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.",
|
||||
"failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.",
|
||||
"failureNotFound": "Dieses Konto gibt es nicht mehr.",
|
||||
"failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.",
|
||||
"failureUsernameTaken": "Dieser Benutzername ist schon vergeben.",
|
||||
"failureOwnerExists": "Das Konto, zu dem diese Datei gehört, ist auf diesem Server, und die Datei wird in dieses Konto wiederhergestellt. Lade sie noch einmal hoch, ohne ein Konto zu wählen."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"uploadTitle": "Upload backup",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. A copy whose account is no longer on this server can go into another account. Restoring is a separate step.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
|
||||
"uploadSuccess": "Backup uploaded ({count} records)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "The newest dump in the bucket is from {when}.",
|
||||
"offhostNeverUploaded": "No database dump has reached the bucket yet."
|
||||
},
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back."
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back.",
|
||||
"target": {
|
||||
"title": "Restore into another account",
|
||||
"description": "{file} belongs to an account that is no longer on this server. Choose the account its records go into. A restore replaces an account's records, so only an account without records can take them. The file is uploaded once more.",
|
||||
"choiceLabel": "Where the records go",
|
||||
"newAccount": "New account",
|
||||
"existingAccount": "Existing account",
|
||||
"usernameLabel": "Username for the new account",
|
||||
"usernameInvalid": "3 to 30 letters, digits, _ or -, not starting with \"managed-\".",
|
||||
"newAccountHint": "The account is created without a password or email. Before you hand it over, set its first password with Reset password under Users.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Choose an account",
|
||||
"checking": "Checking the account…",
|
||||
"eligible": "This account holds no records yet and can take the copy.",
|
||||
"confirmLabel": "Type {username} to confirm",
|
||||
"submit": "Upload into this account",
|
||||
"submitting": "Uploading…",
|
||||
"success": "Backup stored for {username} ({count} records). Restore it from its row.",
|
||||
"failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.",
|
||||
"failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.",
|
||||
"failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.",
|
||||
"failureNotFound": "This account no longer exists.",
|
||||
"failureUnconfirmed": "Type the account's username exactly to confirm.",
|
||||
"failureUsernameTaken": "This username is already taken.",
|
||||
"failureOwnerExists": "The account this file belongs to is on this server, and the file restores into that account. Upload it again without choosing an account."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger zone",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "La descarga falló",
|
||||
"downloadStarted": "Descarga iniciada",
|
||||
"uploadTitle": "Subir copia de seguridad",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. Una copia cuya cuenta ya no está en este servidor puede ir a otra cuenta. Restaurar es un paso aparte.",
|
||||
"uploadButton": "Subir archivo",
|
||||
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
|
||||
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "El volcado más reciente del bucket es del {when}.",
|
||||
"offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven."
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven.",
|
||||
"target": {
|
||||
"title": "Restaurar en otra cuenta",
|
||||
"description": "{file} pertenece a una cuenta que ya no está en este servidor. Elige la cuenta a la que van sus registros. Una restauración reemplaza los registros de una cuenta, así que solo una cuenta sin registros puede recibirlos. El archivo se sube una vez más.",
|
||||
"choiceLabel": "Adónde van los registros",
|
||||
"newAccount": "Cuenta nueva",
|
||||
"existingAccount": "Cuenta existente",
|
||||
"usernameLabel": "Nombre de usuario de la cuenta nueva",
|
||||
"usernameInvalid": "De 3 a 30 letras, números, _ o -, sin empezar por «managed-».",
|
||||
"newAccountHint": "La cuenta se crea sin contraseña ni correo. Antes de entregarla, pon su primera contraseña con Restablecer contraseña en Usuarios.",
|
||||
"accountLabel": "Cuenta",
|
||||
"accountPlaceholder": "Elige una cuenta",
|
||||
"checking": "Comprobando la cuenta…",
|
||||
"eligible": "Esta cuenta aún no tiene registros y puede recibir la copia.",
|
||||
"confirmLabel": "Escribe {username} para confirmar",
|
||||
"submit": "Subir a esta cuenta",
|
||||
"submitting": "Subiendo…",
|
||||
"success": "Copia guardada para {username} ({count} registros). Restáurala desde su fila.",
|
||||
"failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.",
|
||||
"failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.",
|
||||
"failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.",
|
||||
"failureNotFound": "Esta cuenta ya no existe.",
|
||||
"failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.",
|
||||
"failureUsernameTaken": "Este nombre de usuario ya está en uso.",
|
||||
"failureOwnerExists": "La cuenta a la que pertenece este archivo está en este servidor, y el archivo se restaura en esa cuenta. Súbelo de nuevo sin elegir una cuenta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadStarted": "Téléchargement démarré",
|
||||
"uploadTitle": "Téléverser une sauvegarde",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Le compte auquel elle appartient doit exister sur ce serveur. La restauration est une étape distincte.",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Une copie dont le compte n'existe plus sur ce serveur peut aller dans un autre compte. La restauration est une étape distincte.",
|
||||
"uploadButton": "Téléverser un fichier",
|
||||
"uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.",
|
||||
"uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Le dump le plus récent du bucket date du {when}.",
|
||||
"offhostNeverUploaded": "Aucun dump de la base de données n'est encore arrivé dans le bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas."
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas.",
|
||||
"target": {
|
||||
"title": "Restaurer dans un autre compte",
|
||||
"description": "{file} appartient à un compte qui n'existe plus sur ce serveur. Choisissez le compte qui recevra ses enregistrements. Une restauration remplace les enregistrements d'un compte : seul un compte sans enregistrements peut donc les recevoir. Le fichier est téléversé une nouvelle fois.",
|
||||
"choiceLabel": "Destination des enregistrements",
|
||||
"newAccount": "Nouveau compte",
|
||||
"existingAccount": "Compte existant",
|
||||
"usernameLabel": "Nom d'utilisateur du nouveau compte",
|
||||
"usernameInvalid": "De 3 à 30 lettres, chiffres, _ ou -, sans commencer par « managed- ».",
|
||||
"newAccountHint": "Le compte est créé sans mot de passe ni e-mail. Avant de le remettre, définissez son premier mot de passe avec Réinitialiser le mot de passe, sous Utilisateurs.",
|
||||
"accountLabel": "Compte",
|
||||
"accountPlaceholder": "Choisissez un compte",
|
||||
"checking": "Vérification du compte…",
|
||||
"eligible": "Ce compte n'a encore aucun enregistrement et peut recevoir la copie.",
|
||||
"confirmLabel": "Tapez {username} pour confirmer",
|
||||
"submit": "Téléverser dans ce compte",
|
||||
"submitting": "Téléversement…",
|
||||
"success": "Sauvegarde enregistrée pour {username} ({count} enregistrements). Restaurez-la depuis sa ligne.",
|
||||
"failureHoldsRecords": "Ce compte contient déjà des enregistrements. Une restauration remplace les enregistrements au lieu de les compléter : choisissez un compte sans enregistrements ou créez-en un nouveau.",
|
||||
"failureManagedProfile": "Ce compte est un profil géré. Les personnes responsables le gèrent, une copie ne peut donc pas y aller.",
|
||||
"failurePendingDeletion": "Ce compte est en cours de suppression. Annulez d'abord la suppression ou choisissez un autre compte.",
|
||||
"failureNotFound": "Ce compte n'existe plus.",
|
||||
"failureUnconfirmed": "Tapez exactement le nom d'utilisateur du compte pour confirmer.",
|
||||
"failureUsernameTaken": "Ce nom d'utilisateur est déjà pris.",
|
||||
"failureOwnerExists": "Le compte auquel appartient ce fichier est sur ce serveur, et le fichier se restaure dans ce compte. Téléversez-le à nouveau sans choisir de compte."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download non riuscito",
|
||||
"downloadStarted": "Download avviato",
|
||||
"uploadTitle": "Carica backup",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. Una copia il cui account non è più su questo server può andare in un altro account. Il ripristino è un passaggio a parte.",
|
||||
"uploadButton": "Carica file",
|
||||
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
|
||||
"uploadSuccess": "Backup caricato ({count} record)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Il dump più recente nel bucket è del {when}.",
|
||||
"offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano."
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano.",
|
||||
"target": {
|
||||
"title": "Ripristina in un altro account",
|
||||
"description": "{file} appartiene a un account che non è più su questo server. Scegli l’account in cui vanno i suoi record. Un ripristino sostituisce i record di un account, quindi solo un account senza record può accoglierli. Il file viene caricato un’altra volta.",
|
||||
"choiceLabel": "Dove vanno i record",
|
||||
"newAccount": "Nuovo account",
|
||||
"existingAccount": "Account esistente",
|
||||
"usernameLabel": "Nome utente del nuovo account",
|
||||
"usernameInvalid": "Da 3 a 30 lettere, cifre, _ o -, senza iniziare con “managed-”.",
|
||||
"newAccountHint": "L’account viene creato senza password né email. Prima di consegnarlo, imposta la sua prima password con Reimposta password in Utenti.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Scegli un account",
|
||||
"checking": "Controllo dell’account…",
|
||||
"eligible": "Questo account non ha ancora record e può accogliere la copia.",
|
||||
"confirmLabel": "Digita {username} per confermare",
|
||||
"submit": "Carica in questo account",
|
||||
"submitting": "Caricamento…",
|
||||
"success": "Backup salvato per {username} ({count} record). Ripristinalo dalla sua riga.",
|
||||
"failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.",
|
||||
"failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.",
|
||||
"failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.",
|
||||
"failureNotFound": "Questo account non esiste più.",
|
||||
"failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.",
|
||||
"failureUsernameTaken": "Questo nome utente è già in uso.",
|
||||
"failureOwnerExists": "L’account a cui appartiene questo file è su questo server, e il file si ripristina in quell’account. Caricalo di nuovo senza scegliere un account."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
|
||||
+27
-2
@@ -8192,7 +8192,7 @@
|
||||
"downloadFailed": "다운로드하지 못했어요",
|
||||
"downloadStarted": "다운로드를 시작했어요",
|
||||
"uploadTitle": "백업 업로드",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 계정이 이 서버에 더 이상 없는 사본은 다른 계정으로 보낼 수 있어요. 복원은 별도 단계예요.",
|
||||
"uploadButton": "파일 업로드",
|
||||
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
|
||||
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
|
||||
@@ -8351,7 +8351,32 @@
|
||||
"offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.",
|
||||
"offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다."
|
||||
},
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요."
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요.",
|
||||
"target": {
|
||||
"title": "다른 계정으로 복원",
|
||||
"description": "{file}은(는) 이 서버에 더 이상 없는 계정의 파일이에요. 기록을 넣을 계정을 골라 주세요. 복원은 계정의 기록을 바꿔 쓰기 때문에 기록이 없는 계정만 받을 수 있어요. 파일은 한 번 더 업로드돼요.",
|
||||
"choiceLabel": "기록을 넣을 곳",
|
||||
"newAccount": "새 계정",
|
||||
"existingAccount": "기존 계정",
|
||||
"usernameLabel": "새 계정의 사용자 이름",
|
||||
"usernameInvalid": "영문자, 숫자, _ 또는 - 3~30자이고, \"managed-\"로 시작하면 안 돼요.",
|
||||
"newAccountHint": "계정은 비밀번호와 이메일 없이 만들어져요. 넘겨주기 전에 사용자에서 비밀번호 재설정으로 첫 비밀번호를 정해 주세요.",
|
||||
"accountLabel": "계정",
|
||||
"accountPlaceholder": "계정을 골라 주세요",
|
||||
"checking": "계정을 확인하는 중…",
|
||||
"eligible": "이 계정에는 아직 기록이 없어서 사본을 받을 수 있어요.",
|
||||
"confirmLabel": "확인하려면 {username}을(를) 입력해 주세요",
|
||||
"submit": "이 계정으로 업로드",
|
||||
"submitting": "업로드 중…",
|
||||
"success": "{username}의 백업을 저장했어요 (기록 {count}개). 해당 행에서 복원해 주세요.",
|
||||
"failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.",
|
||||
"failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.",
|
||||
"failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.",
|
||||
"failureNotFound": "이 계정은 더 이상 없어요.",
|
||||
"failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.",
|
||||
"failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.",
|
||||
"failureOwnerExists": "이 파일의 계정이 이 서버에 있어서 파일은 그 계정으로 복원돼요. 계정을 고르지 말고 다시 업로드해 주세요."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Downloaden mislukt",
|
||||
"downloadStarted": "Download gestart",
|
||||
"uploadTitle": "Back-up uploaden",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Een kopie waarvan het account niet meer op deze server staat, kan naar een ander account. Terugzetten is een aparte stap.",
|
||||
"uploadButton": "Bestand uploaden",
|
||||
"uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.",
|
||||
"uploadSuccess": "Back-up geüpload ({count} records)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "De nieuwste dump in de bucket is van {when}.",
|
||||
"offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen."
|
||||
},
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug."
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug.",
|
||||
"target": {
|
||||
"title": "In een ander account terugzetten",
|
||||
"description": "{file} hoort bij een account dat niet meer op deze server staat. Kies het account waar de records naartoe gaan. Terugzetten vervangt de records van een account, dus alleen een account zonder records kan ze opnemen. Het bestand wordt nog een keer geüpload.",
|
||||
"choiceLabel": "Waar de records naartoe gaan",
|
||||
"newAccount": "Nieuw account",
|
||||
"existingAccount": "Bestaand account",
|
||||
"usernameLabel": "Gebruikersnaam voor het nieuwe account",
|
||||
"usernameInvalid": "3 tot 30 letters, cijfers, _ of -, niet beginnend met ‘managed-’.",
|
||||
"newAccountHint": "Het account wordt zonder wachtwoord en zonder e-mail aangemaakt. Stel het eerste wachtwoord in met Wachtwoord resetten onder Gebruikers voordat je het overdraagt.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Kies een account",
|
||||
"checking": "Account wordt gecontroleerd…",
|
||||
"eligible": "Dit account heeft nog geen records en kan de kopie opnemen.",
|
||||
"confirmLabel": "Typ {username} om te bevestigen",
|
||||
"submit": "Naar dit account uploaden",
|
||||
"submitting": "Uploaden…",
|
||||
"success": "Back-up opgeslagen voor {username} ({count} records). Zet hem terug vanuit zijn rij.",
|
||||
"failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.",
|
||||
"failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.",
|
||||
"failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.",
|
||||
"failureNotFound": "Dit account bestaat niet meer.",
|
||||
"failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.",
|
||||
"failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.",
|
||||
"failureOwnerExists": "Het account waar dit bestand bij hoort, staat op deze server, en het bestand wordt in dat account teruggezet. Upload het opnieuw zonder een account te kiezen."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gevarenzone",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Pobieranie nie powiodło się",
|
||||
"downloadStarted": "Rozpoczęto pobieranie",
|
||||
"uploadTitle": "Prześlij kopię zapasową",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Kopia, której konta nie ma już na tym serwerze, może trafić do innego konta. Przywracanie to osobny krok.",
|
||||
"uploadButton": "Prześlij plik",
|
||||
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
|
||||
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.",
|
||||
"offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych."
|
||||
},
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają."
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają.",
|
||||
"target": {
|
||||
"title": "Przywróć do innego konta",
|
||||
"description": "{file} należy do konta, którego nie ma już na tym serwerze. Wybierz konto, do którego trafią jego rekordy. Przywracanie zastępuje rekordy konta, więc przyjąć je może tylko konto bez rekordów. Plik zostanie przesłany jeszcze raz.",
|
||||
"choiceLabel": "Dokąd trafią rekordy",
|
||||
"newAccount": "Nowe konto",
|
||||
"existingAccount": "Istniejące konto",
|
||||
"usernameLabel": "Nazwa użytkownika nowego konta",
|
||||
"usernameInvalid": "Od 3 do 30 liter, cyfr, _ lub -, bez „managed-” na początku.",
|
||||
"newAccountHint": "Konto zostanie utworzone bez hasła i bez adresu e-mail. Zanim je przekażesz, ustaw jego pierwsze hasło przyciskiem Zresetuj hasło w sekcji Użytkownicy.",
|
||||
"accountLabel": "Konto",
|
||||
"accountPlaceholder": "Wybierz konto",
|
||||
"checking": "Sprawdzanie konta…",
|
||||
"eligible": "To konto nie ma jeszcze rekordów i może przyjąć kopię.",
|
||||
"confirmLabel": "Wpisz {username}, aby potwierdzić",
|
||||
"submit": "Prześlij do tego konta",
|
||||
"submitting": "Przesyłanie…",
|
||||
"success": "Kopia zapisana dla {username} ({count} rekordów). Przywróć ją z jej wiersza.",
|
||||
"failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.",
|
||||
"failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.",
|
||||
"failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.",
|
||||
"failureNotFound": "To konto już nie istnieje.",
|
||||
"failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.",
|
||||
"failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.",
|
||||
"failureOwnerExists": "Konto, do którego należy ten plik, jest na tym serwerze, a plik przywraca się do tego konta. Prześlij go ponownie bez wybierania konta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Falha no download",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"uploadTitle": "Enviar backup",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. Uma cópia cuja conta não está mais neste servidor pode ir para outra conta. Restaurar é uma etapa separada.",
|
||||
"uploadButton": "Enviar arquivo",
|
||||
"uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.",
|
||||
"uploadSuccess": "Backup enviado ({count} registros)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "O dump mais recente no bucket é de {when}.",
|
||||
"offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam."
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam.",
|
||||
"target": {
|
||||
"title": "Restaurar em outra conta",
|
||||
"description": "{file} pertence a uma conta que não está mais neste servidor. Escolha a conta para onde vão os registros. Uma restauração substitui os registros de uma conta, então só uma conta sem registros pode recebê-los. O arquivo é enviado mais uma vez.",
|
||||
"choiceLabel": "Para onde vão os registros",
|
||||
"newAccount": "Conta nova",
|
||||
"existingAccount": "Conta existente",
|
||||
"usernameLabel": "Nome de usuário da conta nova",
|
||||
"usernameInvalid": "De 3 a 30 letras, números, _ ou -, sem começar com “managed-”.",
|
||||
"newAccountHint": "A conta é criada sem senha e sem e-mail. Antes de entregá-la, defina a primeira senha com Redefinir senha em Usuários.",
|
||||
"accountLabel": "Conta",
|
||||
"accountPlaceholder": "Escolha uma conta",
|
||||
"checking": "Verificando a conta…",
|
||||
"eligible": "Esta conta ainda não tem registros e pode receber a cópia.",
|
||||
"confirmLabel": "Digite {username} para confirmar",
|
||||
"submit": "Enviar para esta conta",
|
||||
"submitting": "Enviando…",
|
||||
"success": "Backup guardado para {username} ({count} registros). Restaure-o pela linha dele.",
|
||||
"failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.",
|
||||
"failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.",
|
||||
"failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.",
|
||||
"failureNotFound": "Esta conta não existe mais.",
|
||||
"failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.",
|
||||
"failureUsernameTaken": "Este nome de usuário já está em uso.",
|
||||
"failureOwnerExists": "A conta a que este arquivo pertence está neste servidor, e o arquivo é restaurado nessa conta. Envie-o de novo sem escolher uma conta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de perigo",
|
||||
|
||||
@@ -225,6 +225,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
// newer schema) an operator has to be able to look up the same way. See
|
||||
// `src/lib/openapi/routes/admin-backups.ts`.
|
||||
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/backups/targets/{userId}": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/central-codex": {
|
||||
kind: "adminConsole",
|
||||
methods: ["DELETE", "GET"],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* GET /api/admin/backups/targets/{userId} — whether the copy of an erased
|
||||
* account may be restored into this account.
|
||||
*
|
||||
* The backups console asks before it sends a file a second time with that
|
||||
* account as its target, so a refusal costs no upload. The upload decides
|
||||
* again on its own (`checkRestoreTarget` in `restore-target.ts`); this answer
|
||||
* is advice, not a reservation. It says no for a managed profile, an account
|
||||
* pending deletion, and an account that already holds records, naming the
|
||||
* models that hold them. Counts and record content are not read.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRestoreTarget } from "@/lib/export/restore-target";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const paramsSchema = z.object({ userId: z.string().min(1).max(64) }).strict();
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ userId: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.target.check" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`admin-backups-target:${admin.id}`,
|
||||
30,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
throw new HttpError(429, "Too many account checks");
|
||||
}
|
||||
|
||||
const parsed = paramsSchema.safeParse(await params);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error);
|
||||
|
||||
const verdict = await checkRestoreTarget(prisma, parsed.data.userId);
|
||||
if (!verdict.ok && verdict.reason === "not_found") {
|
||||
throw new HttpError(404, "User not found");
|
||||
}
|
||||
const target = verdict.target!;
|
||||
return apiSuccess({
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
eligible: verdict.ok,
|
||||
reason: verdict.ok ? null : verdict.reason,
|
||||
models: verdict.ok ? [] : verdict.models,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -20,6 +20,17 @@
|
||||
* checked as it goes and stored in the same pass; a file that fails any check
|
||||
* rolls the store back, so nothing of it remains.
|
||||
*
|
||||
* The file names the account it belongs to, and that account has to exist
|
||||
* here. When it does not (the account was erased, and the operator still has
|
||||
* its disaster-recovery file), the upload answers 422 with
|
||||
* `backup.owner.missing`, and the operator can send the file again with a
|
||||
* target in the query (`backup-upload-target.ts`): an existing account that
|
||||
* holds no records yet, confirmed by typing its username, or a new account
|
||||
* created for the copy. The file's owner is then rewritten to that account as
|
||||
* the bytes come in (`backup-owner-rewrite.ts`), so the copy stored is that
|
||||
* account's copy, and the restore that follows is the ordinary one. The new
|
||||
* account has no password; an admin sets its first one in Admin, Users.
|
||||
*
|
||||
* Crucially, this route does NOT execute a restore. It only stores the
|
||||
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
|
||||
* (criterion 3) actually replaces user data — keeping the two phases
|
||||
@@ -32,6 +43,7 @@ import { Readable } from "node:stream";
|
||||
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -40,9 +52,19 @@ import {
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { BackupJsonError } from "@/lib/export/backup-json-scan";
|
||||
import {
|
||||
BackupOwnerRewriteError,
|
||||
rewriteBackupOwner,
|
||||
type BackupOwnerRewrite,
|
||||
} from "@/lib/export/backup-owner-rewrite";
|
||||
import {
|
||||
BackupUploadDecodeError,
|
||||
decodeBackupUpload,
|
||||
@@ -57,6 +79,11 @@ import {
|
||||
BACKUP_UPLOAD_TOO_LARGE_CODE,
|
||||
BackupBlobTooLargeError,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import {
|
||||
checkRestoreTarget,
|
||||
RESTORE_TARGET_REFUSALS,
|
||||
type RestoreTargetVerdict,
|
||||
} from "@/lib/export/restore-target";
|
||||
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
||||
import {
|
||||
readStreamedBackup,
|
||||
@@ -66,6 +93,14 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
BACKUP_OWNER_EXISTS_CODE,
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
BACKUP_TARGET_UNCONFIRMED_CODE,
|
||||
BACKUP_TARGET_USERNAME_TAKEN_CODE,
|
||||
parseBackupUploadTarget,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
summarizeBackup,
|
||||
@@ -111,6 +146,21 @@ class UploadRefused extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer and audit details for a target the copy may not go into. */
|
||||
function targetRefused(
|
||||
verdict: Extract<RestoreTargetVerdict, { ok: false }>,
|
||||
userId: string,
|
||||
): UploadRefused {
|
||||
const refusal = RESTORE_TARGET_REFUSALS[verdict.reason];
|
||||
const models = verdict.models.length > 0 ? { models: verdict.models } : {};
|
||||
return new UploadRefused(
|
||||
refusal.status,
|
||||
refusal.message,
|
||||
{ reason: `target_${verdict.reason}`, targetUserId: userId, ...models },
|
||||
{ errorCode: refusal.code, ...models },
|
||||
);
|
||||
}
|
||||
|
||||
/** The file's bytes, gunzipped and counted on both ends. */
|
||||
async function* decodedBytes(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
@@ -129,6 +179,196 @@ async function* decodedBytes(
|
||||
}
|
||||
}
|
||||
|
||||
/** A file's read failure as the refusal the operator sees. */
|
||||
function schemaRefusal(issues: Array<{ path: string; message: string }>) {
|
||||
return new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the file through the one streamed reader every backup reader shares
|
||||
* (`streamed-backup.ts`): the bulk sections an element at a time and the
|
||||
* documents without their stored bytes, so a document vault of hundreds of
|
||||
* megabytes is never in memory at once (#1239). It checks each measurement
|
||||
* and the canonical v2 id rule as it goes, and gathers the key ids the file's
|
||||
* inner ciphertext needs.
|
||||
*/
|
||||
async function readUploadedFile(
|
||||
source: () => AsyncIterable<Uint8Array>,
|
||||
): Promise<StreamedBackup> {
|
||||
try {
|
||||
return await readStreamedBackup(source);
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof BackupOwnerRewriteError) {
|
||||
throw schemaRefusal([{ path: "userId", message: err.message }]);
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
throw schemaRefusal([{ path: err.path, message: err.message }]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The file against the backup schema, and its version against this server. */
|
||||
async function parseUploadedPayload(raw: unknown): Promise<BackupPayload> {
|
||||
// Loaded on first use, not at import (see `backup-summary.ts`).
|
||||
const { backupPayloadSchema } = await import("@/lib/validations/backup");
|
||||
let payload: BackupPayload;
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(raw);
|
||||
} catch (err) {
|
||||
throw schemaRefusal(
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
type ChosenTarget = { id: string; username: string; created: boolean };
|
||||
|
||||
const usernameTaken = () =>
|
||||
new UploadRefused(
|
||||
409,
|
||||
"That username is already taken.",
|
||||
{ reason: "target_username_taken" },
|
||||
{ errorCode: BACKUP_TARGET_USERNAME_TAKEN_CODE },
|
||||
);
|
||||
|
||||
/**
|
||||
* The target the query names, checked before a byte of the file is read, so
|
||||
* a target that will be refused costs no upload. An existing account is
|
||||
* checked whole and confirmed by its username; a new one only for its
|
||||
* username, since it is created once the request is otherwise admissible.
|
||||
*/
|
||||
async function checkTargetBeforeRead(
|
||||
choice: BackupUploadTarget | null,
|
||||
): Promise<ChosenTarget | null> {
|
||||
if (choice?.into === "existing") {
|
||||
const verdict = await checkRestoreTarget(prisma, choice.userId);
|
||||
if (!verdict.ok) throw targetRefused(verdict, choice.userId);
|
||||
// The restore writes into this account, so the operator names it.
|
||||
if (choice.confirm.trim() !== verdict.target.username) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Type the chosen account's username to confirm. Nothing was stored.",
|
||||
{ reason: "target_unconfirmed", targetUserId: choice.userId },
|
||||
{ errorCode: BACKUP_TARGET_UNCONFIRMED_CODE },
|
||||
);
|
||||
}
|
||||
return { ...verdict.target, created: false };
|
||||
}
|
||||
if (choice?.into === "new") {
|
||||
const taken = await prisma.user.findUnique({
|
||||
where: { username: choice.username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) throw usernameTaken();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account made for a copy. Created before the file is read, because the
|
||||
* file is rewritten to its id on the way in, and deleted again when nothing
|
||||
* is stored. No password, no email: nobody can sign into it until an admin
|
||||
* sets its first password (Admin, Users, Reset password).
|
||||
*/
|
||||
async function createTargetAccount(username: string): Promise<ChosenTarget> {
|
||||
try {
|
||||
const account = await prisma.user.create({
|
||||
data: { username, role: "USER" },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
return { ...account, created: true };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
throw usernameTaken();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose copy a file sent with a target becomes, once it has been read. The
|
||||
* file now names the chosen account; the account it named before has to be
|
||||
* gone, because the copy of an account that is still here restores into that
|
||||
* account and never into another one. An existing target is checked again:
|
||||
* the file took a while to read, and the account may have changed meanwhile.
|
||||
*/
|
||||
async function ownerOfRetargetedFile(
|
||||
chosen: ChosenTarget,
|
||||
rewrite: BackupOwnerRewrite | null,
|
||||
): Promise<{ id: string; username: string }> {
|
||||
const declared = rewrite?.declaredOwner() ?? null;
|
||||
const declaredOwner = declared
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: declared },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!declared || declaredOwner) {
|
||||
throw new UploadRefused(
|
||||
409,
|
||||
"This file belongs to an account on this server and restores into that account. Upload it without choosing an account.",
|
||||
{ reason: "owner_exists", ownerId: declared },
|
||||
{ errorCode: BACKUP_OWNER_EXISTS_CODE },
|
||||
);
|
||||
}
|
||||
if (!chosen.created) {
|
||||
const again = await checkRestoreTarget(prisma, chosen.id);
|
||||
if (!again.ok) throw targetRefused(again, chosen.id);
|
||||
}
|
||||
return { id: chosen.id, username: chosen.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account an upload created for its copy, when nothing was stored.
|
||||
* It holds nothing and nobody can have signed into it (no password, no email),
|
||||
* so the row is all there is. A failure is noted, never thrown over the
|
||||
* refusal the operator needs to see.
|
||||
*/
|
||||
async function discardCreatedAccount(userId: string): Promise<void> {
|
||||
try {
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
backup_upload_account_discard_failed: true,
|
||||
backup_upload_account_discard_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
@@ -154,6 +394,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress,
|
||||
details: { reason, ...extra },
|
||||
});
|
||||
const refuse = async (err: UploadRefused) => {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
};
|
||||
|
||||
// The account the copy goes into when the file's own account is gone.
|
||||
const parsedTarget = parseBackupUploadTarget(
|
||||
new URL(request.url).searchParams,
|
||||
);
|
||||
if (!parsedTarget.success) {
|
||||
await denied("target_invalid");
|
||||
return returnAllZodIssues(parsedTarget.error);
|
||||
}
|
||||
const choice = parsedTarget.data;
|
||||
let target: ChosenTarget | null;
|
||||
try {
|
||||
target = await checkTargetBeforeRead(choice);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
throw err;
|
||||
}
|
||||
annotate({ meta: { backup_upload_into: choice?.into ?? "owner" } });
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const multipart = contentType.startsWith("multipart/form-data");
|
||||
@@ -226,6 +488,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// answers with and the key uses it already holds (`backup-preview.ts`).
|
||||
let keys: BackupKeyIdCollector | undefined;
|
||||
let created: { id: string };
|
||||
let rewrite: BackupOwnerRewrite | null = null;
|
||||
if (choice?.into === "new") {
|
||||
try {
|
||||
target = await createTargetAccount(choice.username);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const chosen = target;
|
||||
try {
|
||||
created = await storeBackupBlob(
|
||||
prisma,
|
||||
@@ -247,72 +519,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
async (write) => {
|
||||
// One pass: every chunk is stored as it is checked, and a check that
|
||||
// fails throws, which rolls the store back.
|
||||
const decoded = decodedBytes(source, limit);
|
||||
if (chosen) rewrite = rewriteBackupOwner(decoded, chosen.id);
|
||||
async function* storedAsRead() {
|
||||
for await (const chunk of decodedBytes(source, limit)) {
|
||||
for await (const chunk of rewrite ? rewrite.bytes : decoded) {
|
||||
await write(Buffer.from(chunk));
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
// Read through the one streamed reader every backup reader shares
|
||||
// (`streamed-backup.ts`): the bulk sections an element at a time and
|
||||
// the documents without their stored bytes, so a document vault of
|
||||
// hundreds of megabytes is never in memory at once (#1239). It checks
|
||||
// each measurement and the canonical v2 id rule as it goes, and
|
||||
// gathers the key ids the file's inner ciphertext needs.
|
||||
let streamed: StreamedBackup;
|
||||
try {
|
||||
streamed = await readStreamedBackup(storedAsRead);
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
const issues = [{ path: err.path, message: err.message }];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const streamed = await readUploadedFile(storedAsRead);
|
||||
measurementCount = streamed.measurementCount;
|
||||
|
||||
// Loaded on first use, not at import (see `backup-summary.ts`).
|
||||
const { backupPayloadSchema } =
|
||||
await import("@/lib/validations/backup");
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(streamed.raw);
|
||||
} catch (err) {
|
||||
const issues =
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
payload = await parseUploadedPayload(streamed.raw);
|
||||
|
||||
// A file whose inner ciphertext needs a key this host does not have
|
||||
// can be stored but never restored: the restore would refuse it, or
|
||||
@@ -335,6 +552,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (chosen) {
|
||||
owner = await ownerOfRetargetedFile(chosen, rewrite);
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the userId on the file points at a user that exists in
|
||||
// this DB. Restore would fail later anyway, but failing here gives
|
||||
// the admin a precise error before any side-effect.
|
||||
@@ -343,19 +565,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!owner) {
|
||||
// Most often an account that was erased. Its id is not written
|
||||
// into the trail: the erasure took every row that named it, and a
|
||||
// refused upload is not a reason to bring it back. The answer says
|
||||
// what the operator can do instead.
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup is for user '${payload.userId}' which does not exist in this DB`,
|
||||
{ reason: "owner_not_found", ownerId: payload.userId },
|
||||
"This file belongs to an account that does not exist on this server. Choose an account to restore it into.",
|
||||
{ reason: "owner_not_found" },
|
||||
{ errorCode: BACKUP_OWNER_MISSING_CODE },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
}
|
||||
// Nothing was stored, so the account made for the copy goes again.
|
||||
if (chosen?.created) await discardCreatedAccount(chosen.id);
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
// The file was fine; its encrypted copy is larger than the stored-copy
|
||||
// limit (`BACKUP_MAX_STORED_MB`). That is the operator's to act on, with
|
||||
// the numbers and the setting in the message, and not a server fault for
|
||||
@@ -380,6 +606,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
};
|
||||
const storedOwner = owner as { id: string; username: string } | null;
|
||||
|
||||
if (chosen?.created) {
|
||||
await auditLog("admin.user.create", {
|
||||
userId: admin.id,
|
||||
ipAddress,
|
||||
details: {
|
||||
targetUserId: chosen.id,
|
||||
username: chosen.username,
|
||||
reason: "backup_restore",
|
||||
backupId: created.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
await auditLog("admin.backups.upload", {
|
||||
userId: admin.id,
|
||||
ipAddress,
|
||||
@@ -387,6 +625,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
backupId: created.id,
|
||||
ownerId: storedOwner?.id,
|
||||
ownerUsername: storedOwner?.username,
|
||||
// Set when the copy went into a chosen account; the erased id is not.
|
||||
...(chosen ? { into: chosen.created ? "new" : "existing" } : {}),
|
||||
type: uploadType,
|
||||
schemaVersion: summary.schemaVersion,
|
||||
counts: {
|
||||
|
||||
@@ -29,7 +29,8 @@ import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
|
||||
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
|
||||
import { DatabaseDumpRow } from "./backups/database-dump-row";
|
||||
import { BackupUploadCard } from "./backups/backup-upload-card";
|
||||
import { BackupUpload } from "./backups/backup-upload-card";
|
||||
import { useBackupUpload } from "./backups/use-backup-upload";
|
||||
import {
|
||||
RestoreRefusalNotice,
|
||||
RestoreSkipReport,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
} from "./backups/restore-notices";
|
||||
import { useBackupDownload, useRunBackup } from "./backups/use-backup-actions";
|
||||
import { useBackupRestores } from "./backups/use-backup-restores";
|
||||
import { useBackupUpload } from "./backups/use-backup-upload";
|
||||
|
||||
export function BackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -55,8 +55,7 @@ export function BackupsSection() {
|
||||
// a fresh proof unless this session signed in or re-proved within minutes.
|
||||
const recentProof = useRecentProof();
|
||||
const { downloadingId, handleDownload } = useBackupDownload(recentProof);
|
||||
const { upload, fileInputRef, handleFileChange } =
|
||||
useBackupUpload(recentProof);
|
||||
const uploadFlow = useBackupUpload(recentProof);
|
||||
const {
|
||||
restoreJobs,
|
||||
dismissedRestores,
|
||||
@@ -126,11 +125,7 @@ export function BackupsSection() {
|
||||
<BackupPassActivityNotice activity={data.scheduleActivity} />
|
||||
) : null}
|
||||
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
<BackupUpload flow={uploadFlow} />
|
||||
|
||||
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
|
||||
<RestoreJobStatus
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The choice offered when an uploaded file's account is not on this server.
|
||||
*
|
||||
* SSR markup per project convention, plus the pure rules the dialog submits
|
||||
* by: a new account needs a valid, free username; an existing one needs the
|
||||
* server's yes for exactly that account and its username typed; managed
|
||||
* profiles and accounts pending deletion are never offered; and each refusal
|
||||
* the upload can answer has its own sentence.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
BackupTargetForm,
|
||||
EMPTY_TARGET_DRAFT,
|
||||
pickableAccounts,
|
||||
readyTarget,
|
||||
targetUploadFailureKey,
|
||||
type BackupTargetCheck,
|
||||
type BackupTargetDraft,
|
||||
} from "../backup-target-dialog";
|
||||
|
||||
function user(overrides: Partial<AdminUser>): AdminUser {
|
||||
return {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
mfaEnforced: false,
|
||||
documentQuotaBytes: null,
|
||||
passkeyCount: 0,
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const USERS = [
|
||||
user({ id: "u1", username: "returning-person", email: "r@example.test" }),
|
||||
user({ id: "u2", username: "managed-cafe01", managedProfile: true }),
|
||||
user({
|
||||
id: "u3",
|
||||
username: "leaving",
|
||||
deletion: { requestedAt: "x", purgeAt: null, requestedBy: "self" },
|
||||
}),
|
||||
user({ id: "u4", username: "operator", role: "ADMIN" }),
|
||||
];
|
||||
|
||||
const ELIGIBLE: BackupTargetCheck = {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
eligible: true,
|
||||
reason: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
function render(
|
||||
draft: BackupTargetDraft,
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown } = {
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={() => {}}
|
||||
disabled={false}
|
||||
users={USERS}
|
||||
check={check}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("pickableAccounts", () => {
|
||||
it("offers neither managed profiles nor accounts pending deletion", () => {
|
||||
expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([
|
||||
"returning-person",
|
||||
"operator",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readyTarget", () => {
|
||||
it("takes a new account with a valid username nobody has", () => {
|
||||
expect(
|
||||
readyTarget(
|
||||
{ ...EMPTY_TARGET_DRAFT, username: " back-again " },
|
||||
USERS,
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ into: "new", username: "back-again" });
|
||||
});
|
||||
|
||||
it("refuses a new username that is taken, malformed or reads as a managed profile", () => {
|
||||
for (const username of ["operator", "no spaces", "ab", "managed-x1"]) {
|
||||
expect(
|
||||
readyTarget({ ...EMPTY_TARGET_DRAFT, username }, USERS, undefined),
|
||||
username,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an existing account only with the server's yes and its username typed", () => {
|
||||
const draft: BackupTargetDraft = {
|
||||
into: "existing",
|
||||
username: "",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
};
|
||||
expect(readyTarget(draft, USERS, ELIGIBLE)).toEqual({
|
||||
into: "existing",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
});
|
||||
expect(readyTarget(draft, USERS, undefined)).toBeNull();
|
||||
expect(
|
||||
readyTarget(draft, USERS, {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
}),
|
||||
).toBeNull();
|
||||
// A yes for another account does not carry over.
|
||||
expect(readyTarget(draft, USERS, { ...ELIGIBLE, id: "u4" })).toBeNull();
|
||||
expect(
|
||||
readyTarget({ ...draft, confirm: "Returning-Person" }, USERS, ELIGIBLE),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetUploadFailureKey", () => {
|
||||
it("names each refusal the upload answers with, and falls back for the rest", () => {
|
||||
const key = (errorCode?: string) =>
|
||||
targetUploadFailureKey(
|
||||
new ApiError("x", 409, errorCode ? { errorCode } : undefined),
|
||||
);
|
||||
expect(key("backup.target.holds_records")).toBe(
|
||||
"admin.section.backups.target.failureHoldsRecords",
|
||||
);
|
||||
expect(key("backup.target.managed_profile")).toBe(
|
||||
"admin.section.backups.target.failureManagedProfile",
|
||||
);
|
||||
expect(key("backup.target.pending_deletion")).toBe(
|
||||
"admin.section.backups.target.failurePendingDeletion",
|
||||
);
|
||||
expect(key("backup.target.not_found")).toBe(
|
||||
"admin.section.backups.target.failureNotFound",
|
||||
);
|
||||
expect(key("backup.target.unconfirmed")).toBe(
|
||||
"admin.section.backups.target.failureUnconfirmed",
|
||||
);
|
||||
expect(key("backup.target.username_taken")).toBe(
|
||||
"admin.section.backups.target.failureUsernameTaken",
|
||||
);
|
||||
expect(key("backup.owner.exists")).toBe(
|
||||
"admin.section.backups.target.failureOwnerExists",
|
||||
);
|
||||
expect(key()).toBe("admin.section.backups.uploadFailed");
|
||||
expect(targetUploadFailureKey(new Error("offline"))).toBe(
|
||||
"admin.section.backups.uploadFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BackupTargetForm>", () => {
|
||||
it("starts on a new account, with the sign-in note", () => {
|
||||
const html = render(EMPTY_TARGET_DRAFT);
|
||||
expect(html).toContain('data-testid="backup-target-username"');
|
||||
expect(html).toContain("created without a password or email");
|
||||
expect(html).not.toContain('data-testid="backup-target-account"');
|
||||
});
|
||||
|
||||
it("says a taken username is taken", () => {
|
||||
const html = render({ ...EMPTY_TARGET_DRAFT, username: "operator" });
|
||||
expect(html).toContain("This username is already taken.");
|
||||
});
|
||||
|
||||
it("offers only pickable accounts, and asks for the username once the server says yes", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{ data: ELIGIBLE, loading: false, error: null },
|
||||
);
|
||||
expect(html).toContain("returning-person (r@example.test)");
|
||||
expect(html).toContain(">operator<");
|
||||
expect(html).not.toContain("managed-cafe01");
|
||||
expect(html).not.toContain(">leaving<");
|
||||
expect(html).toContain('data-slot="backup-target-eligible"');
|
||||
expect(html).toContain("Type returning-person to confirm");
|
||||
expect(html).toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
|
||||
it("says why an account that holds records cannot take the copy, and asks nothing more", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{
|
||||
data: {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="backup-target-refused"');
|
||||
expect(html).toContain("This account already holds records.");
|
||||
expect(html).not.toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
import type { BackupUploadTarget } from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/** What the admin has filled in so far. */
|
||||
export interface BackupTargetDraft {
|
||||
into: "new" | "existing";
|
||||
username: string;
|
||||
userId: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
export const EMPTY_TARGET_DRAFT: BackupTargetDraft = {
|
||||
into: "new",
|
||||
username: "",
|
||||
userId: "",
|
||||
confirm: "",
|
||||
};
|
||||
|
||||
/** The server's answer to "may this account take the copy?". */
|
||||
export interface BackupTargetCheck {
|
||||
id: string;
|
||||
username: string;
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts the picker offers: not a managed profile (its Guardians run
|
||||
* it) and not pending deletion. Whether one holds records is the server's to
|
||||
* say, per account, once it is picked.
|
||||
*/
|
||||
export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] {
|
||||
return users.filter((user) => !user.managedProfile && user.deletion === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target the draft describes, or null while it is not one the upload
|
||||
* would take: a new account needs a valid username nobody has, an existing
|
||||
* one needs the server's yes and its username typed exactly.
|
||||
*/
|
||||
export function readyTarget(
|
||||
draft: BackupTargetDraft,
|
||||
users: readonly AdminUser[],
|
||||
check: BackupTargetCheck | undefined,
|
||||
): BackupUploadTarget | null {
|
||||
if (draft.into === "new") {
|
||||
const username = draft.username.trim();
|
||||
if (!accountUsernameSchema.safeParse(username).success) return null;
|
||||
if (users.some((user) => user.username === username)) return null;
|
||||
return { into: "new", username };
|
||||
}
|
||||
const user = users.find((candidate) => candidate.id === draft.userId);
|
||||
if (!user || !check || check.id !== user.id || !check.eligible) return null;
|
||||
if (draft.confirm.trim() !== user.username) return null;
|
||||
return { into: "existing", userId: user.id, confirm: user.username };
|
||||
}
|
||||
|
||||
/** The sentence for a refusal, by the code the server answered with. */
|
||||
export function targetFailureKey(code: string): string {
|
||||
switch (code) {
|
||||
case "backup.target.holds_records":
|
||||
return "admin.section.backups.target.failureHoldsRecords";
|
||||
case "backup.target.managed_profile":
|
||||
return "admin.section.backups.target.failureManagedProfile";
|
||||
case "backup.target.pending_deletion":
|
||||
return "admin.section.backups.target.failurePendingDeletion";
|
||||
case "backup.target.not_found":
|
||||
return "admin.section.backups.target.failureNotFound";
|
||||
case "backup.target.unconfirmed":
|
||||
return "admin.section.backups.target.failureUnconfirmed";
|
||||
case "backup.target.username_taken":
|
||||
return "admin.section.backups.target.failureUsernameTaken";
|
||||
case "backup.owner.exists":
|
||||
return "admin.section.backups.target.failureOwnerExists";
|
||||
default:
|
||||
return "admin.section.backups.uploadFailed";
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentence for a failed upload or check. */
|
||||
export function targetUploadFailureKey(error: unknown): string {
|
||||
return targetFailureKey(
|
||||
error instanceof ApiError && typeof error.meta?.errorCode === "string"
|
||||
? error.meta.errorCode
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The form inside the dialog, apart from it so it renders on its own: where
|
||||
* the copy of an account that is no longer on this server goes. A new account
|
||||
* by default, since it cannot hold anything yet; an existing one when the
|
||||
* person has signed up again, checked by the server as soon as it is picked.
|
||||
*/
|
||||
export function BackupTargetForm({
|
||||
draft,
|
||||
onChange,
|
||||
disabled,
|
||||
users,
|
||||
check,
|
||||
}: {
|
||||
draft: BackupTargetDraft;
|
||||
onChange: (next: BackupTargetDraft) => void;
|
||||
disabled: boolean;
|
||||
/** Every account; null while the list loads. */
|
||||
users: readonly AdminUser[] | null;
|
||||
/** The server's check of the picked account. */
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown };
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const accounts = pickableAccounts(users ?? []);
|
||||
const chosen = accounts.find((user) => user.id === draft.userId);
|
||||
const username = draft.username.trim();
|
||||
const usernameTaken = (users ?? []).some(
|
||||
(user) => user.username === username,
|
||||
);
|
||||
const usernameInvalid =
|
||||
username !== "" && !accountUsernameSchema.safeParse(username).success;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="backup-target-form">
|
||||
<Segmented
|
||||
label={t("admin.section.backups.target.choiceLabel")}
|
||||
slot="backup-target-choice"
|
||||
stretch
|
||||
disabled={disabled}
|
||||
value={draft.into}
|
||||
onChange={(into) => onChange({ ...draft, into })}
|
||||
options={[
|
||||
{ value: "new", label: t("admin.section.backups.target.newAccount") },
|
||||
{
|
||||
value: "existing",
|
||||
label: t("admin.section.backups.target.existingAccount"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{draft.into === "new" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-username">
|
||||
{t("admin.section.backups.target.usernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-username"
|
||||
data-testid="backup-target-username"
|
||||
value={draft.username}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...draft, username: e.target.value })}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={usernameInvalid || usernameTaken}
|
||||
/>
|
||||
{usernameTaken ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.failureUsernameTaken")}
|
||||
</p>
|
||||
) : usernameInvalid ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.usernameInvalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.newAccountHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-account">
|
||||
{t("admin.section.backups.target.accountLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="backup-target-account"
|
||||
data-testid="backup-target-account"
|
||||
value={draft.userId}
|
||||
disabled={disabled || users === null}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, userId: e.target.value, confirm: "" })
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.section.backups.target.accountPlaceholder")}
|
||||
</option>
|
||||
{accounts.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email
|
||||
? `${user.username} (${user.email})`
|
||||
: user.username}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{chosen && check.loading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.checking")}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t(targetUploadFailureKey(check.error))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data && !check.data.eligible ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
data-slot="backup-target-refused"
|
||||
>
|
||||
{t(targetFailureKey(`backup.target.${check.data.reason}`))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data?.eligible ? (
|
||||
<>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="backup-target-eligible"
|
||||
>
|
||||
{t("admin.section.backups.target.eligible")}
|
||||
</p>
|
||||
<Label htmlFor="backup-target-confirm">
|
||||
{t("admin.section.backups.target.confirmLabel", {
|
||||
username: chosen.username,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-confirm"
|
||||
data-testid="backup-target-confirm"
|
||||
value={draft.confirm}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, confirm: e.target.value })
|
||||
}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={chosen.username}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when an upload was refused because the account the file belongs to
|
||||
* is not on this server. Choosing a target sends the file again with it; the
|
||||
* server rewrites the file's owner to that account as it stores it.
|
||||
*/
|
||||
export function BackupTargetDialog({
|
||||
file,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
/** The refused file; the dialog is open while there is one. */
|
||||
file: File | null;
|
||||
pending: boolean;
|
||||
/** The last refusal of an upload into a target, shown in the dialog. */
|
||||
error: unknown;
|
||||
onSubmit: (target: BackupUploadTarget) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [draft, setDraft] = useState<BackupTargetDraft>(EMPTY_TARGET_DRAFT);
|
||||
const users = useQuery({
|
||||
queryKey: queryKeys.adminUsers(),
|
||||
queryFn: () => apiGet<AdminUser[]>("/api/admin/users"),
|
||||
enabled: file !== null,
|
||||
});
|
||||
const check = useQuery({
|
||||
queryKey: queryKeys.adminBackupTarget(draft.userId),
|
||||
queryFn: () =>
|
||||
apiGet<BackupTargetCheck>(
|
||||
`/api/admin/backups/targets/${encodeURIComponent(draft.userId)}`,
|
||||
),
|
||||
enabled: file !== null && draft.into === "existing" && draft.userId !== "",
|
||||
retry: false,
|
||||
});
|
||||
const target = readyTarget(draft, users.data ?? [], check.data);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={file !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (next || pending) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-slot="backup-target-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.backups.target.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.backups.target.description", {
|
||||
file: file?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{file ? (
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
disabled={pending}
|
||||
users={users.isError ? [] : (users.data ?? null)}
|
||||
check={{
|
||||
data: check.data,
|
||||
loading: check.isLoading,
|
||||
error: check.error,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{error && !pending ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(targetUploadFailureKey(error))}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
data-testid="backup-target-submit"
|
||||
disabled={!target || pending}
|
||||
onClick={() => {
|
||||
if (target) onSubmit(target);
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending
|
||||
? t("admin.section.backups.target.submitting")
|
||||
: t("admin.section.backups.target.submit")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,55 @@
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { BackupTargetDialog } from "./backup-target-dialog";
|
||||
import type { useBackupUpload } from "./use-backup-upload";
|
||||
|
||||
/**
|
||||
* The upload card, and for a file whose account is not on this server, the
|
||||
* dialog that sends it again into an account the admin picks. The dialog is
|
||||
* mounted per refused file, so every file starts from a clean form.
|
||||
*/
|
||||
export function BackupUpload({
|
||||
flow,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBackupUpload>;
|
||||
}) {
|
||||
const {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
} = flow;
|
||||
return (
|
||||
<>
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending || uploadInto.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
{orphan ? (
|
||||
<BackupTargetDialog
|
||||
file={orphan}
|
||||
pending={uploadInto.isPending}
|
||||
error={
|
||||
uploadInto.error instanceof ReproofCancelledError
|
||||
? null
|
||||
: uploadInto.error
|
||||
}
|
||||
onSubmit={(target) => uploadInto.mutate(target)}
|
||||
onClose={closeOrphan}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload card: separate from the table so admins can ingest a backup file
|
||||
* independently of any existing rows. The visible button proxies a hidden
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
backupUploadTargetQuery,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/**
|
||||
* The upload body for a backup file: the file itself when it is already
|
||||
@@ -28,9 +33,11 @@ async function uploadBody(file: File): Promise<Blob> {
|
||||
* and a large export (hundreds of MB of JSON) shrinks about tenfold. No request
|
||||
* timeout, because a file of that size takes longer to check and store than
|
||||
* the default allows. The body is built here, per call, so a retry after a
|
||||
* re-proof sends the file again rather than a spent stream.
|
||||
* re-proof sends the file again rather than a spent stream. With a target, the
|
||||
* copy goes into that account instead of the one the file names, which is
|
||||
* how the copy of an account no longer on this server comes back.
|
||||
*/
|
||||
async function uploadOnce(file: File) {
|
||||
async function uploadOnce(file: File, target?: BackupUploadTarget) {
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
@@ -65,12 +72,17 @@ async function uploadOnce(file: File) {
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
}>(
|
||||
target
|
||||
? `/api/admin/backups/upload?${backupUploadTargetQuery(target)}`
|
||||
: "/api/admin/backups/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type UploadSummary = Awaited<ReturnType<typeof uploadOnce>>["summary"];
|
||||
@@ -106,9 +118,19 @@ export function uploadedRecordCount(summary: UploadSummary): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an upload was refused because the file's account is not here. */
|
||||
export function isOwnerMissing(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === BACKUP_OWNER_MISSING_CODE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-file upload flow: a fresh proof first, then the file, and the
|
||||
* file input reset after either outcome.
|
||||
* file input reset after either outcome. A file whose account is not on this
|
||||
* server is kept (`orphan`) for the target dialog, which sends it again into
|
||||
* the account the admin picks (`uploadInto`).
|
||||
*/
|
||||
export function useBackupUpload(
|
||||
recentProof: ReturnType<typeof useRecentProof>,
|
||||
@@ -118,21 +140,32 @@ export function useBackupUpload(
|
||||
|
||||
// Upload state — single-file flow, drives the file input + button.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [orphan, setOrphan] = useState<File | null>(null);
|
||||
|
||||
/** A stored copy, said once: for its own account, or for the one chosen. */
|
||||
function announceStored(summary: UploadSummary, username: string | null) {
|
||||
const count = String(uploadedRecordCount(summary));
|
||||
toast.success(
|
||||
username === null
|
||||
? t("admin.section.backups.uploadSuccess", { count })
|
||||
: t("admin.section.backups.target.success", { count, username }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
}
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
// Ask first, so a large file is not sent once only to be refused.
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return uploadOnce(file);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const total = uploadedRecordCount(data.summary);
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onSuccess: (data) => announceStored(data.summary, null),
|
||||
onError: (err, file) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
// Not a dead end: the dialog offers an account to restore it into.
|
||||
if (isOwnerMissing(err)) {
|
||||
setOrphan(file);
|
||||
return;
|
||||
}
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
@@ -147,10 +180,39 @@ export function useBackupUpload(
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInto = useMutation({
|
||||
mutationFn: async (target: BackupUploadTarget) => {
|
||||
if (!orphan) throw new Error("No file to upload");
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return { data: await uploadOnce(orphan, target), target };
|
||||
},
|
||||
onSuccess: ({ data, target }) => {
|
||||
setOrphan(null);
|
||||
announceStored(
|
||||
data.summary,
|
||||
target.into === "new" ? target.username : target.confirm,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
},
|
||||
// A refusal stays in the dialog, beside the choice it refuses.
|
||||
});
|
||||
|
||||
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload.mutate(file);
|
||||
}
|
||||
|
||||
return { upload, fileInputRef, handleFileChange };
|
||||
function closeOrphan() {
|
||||
setOrphan(null);
|
||||
uploadInto.reset();
|
||||
}
|
||||
|
||||
return {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* `rewriteBackupOwner` must change the top-level `userId` and nothing else,
|
||||
* over any chunk boundaries: the output parses to the input with that one
|
||||
* member replaced, and is byte-identical to it everywhere outside the value.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BackupJsonError } from "../backup-json-scan";
|
||||
import {
|
||||
BackupOwnerRewriteError,
|
||||
rewriteBackupOwner,
|
||||
} from "../backup-owner-rewrite";
|
||||
|
||||
async function* chunked(text: string, sizes: number[]) {
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
let at = 0;
|
||||
let i = 0;
|
||||
while (at < bytes.length) {
|
||||
const size = sizes[i++ % sizes.length]!;
|
||||
yield bytes.subarray(at, at + size);
|
||||
at += size;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(text: string, target: string, sizes: number[] = [7]) {
|
||||
const rewrite = rewriteBackupOwner(chunked(text, sizes), target);
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of rewrite.bytes) parts.push(part);
|
||||
return {
|
||||
text: Buffer.concat(parts).toString("utf8"),
|
||||
declared: rewrite.declaredOwner(),
|
||||
};
|
||||
}
|
||||
|
||||
const NESTED = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-10-01T00:00:00.000Z",
|
||||
userId: "cerased0000000000000000",
|
||||
measurements: [
|
||||
{ id: "m1", userId: "cerased0000000000000000", note: 'a "userId": x' },
|
||||
],
|
||||
profile: { userId: "cerased0000000000000000", name: "ä ö ü 🫀 } ] , :" },
|
||||
notes: ["userId", "brackets ] } [ {"],
|
||||
};
|
||||
|
||||
describe("rewriteBackupOwner", () => {
|
||||
it("replaces only the top-level owner, at every chunk size", async () => {
|
||||
const text = JSON.stringify(NESTED, null, 1);
|
||||
for (const sizes of [[1], [2], [3], [5, 1, 9], [64], [text.length]]) {
|
||||
const out = await run(text, "ctarget0000000000000000", sizes);
|
||||
expect(out.declared).toBe("cerased0000000000000000");
|
||||
expect(JSON.parse(out.text)).toEqual({
|
||||
...NESTED,
|
||||
userId: "ctarget0000000000000000",
|
||||
});
|
||||
// Outside the value, the bytes are the input's.
|
||||
expect(out.text).toBe(
|
||||
text.replace(
|
||||
'"userId": "cerased0000000000000000"',
|
||||
'"userId": "ctarget0000000000000000"',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads an escaped spelling of the key as the key", async () => {
|
||||
const text = '{"user\\u0049d":"cold","a":[1,{"userId":"keep"}]}';
|
||||
const out = await run(text, "cnew", [1]);
|
||||
expect(out.declared).toBe("cold");
|
||||
expect(JSON.parse(out.text)).toEqual({
|
||||
userId: "cnew",
|
||||
a: [1, { userId: "keep" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces every top-level owner and declares the last, as JSON.parse keeps it", async () => {
|
||||
const text = '{"userId":"first","x":1,"userId":"second"}';
|
||||
const out = await run(text, "cnew", [4]);
|
||||
expect(out.declared).toBe(JSON.parse(text).userId);
|
||||
expect(out.text).toBe('{"userId":"cnew","x":1,"userId":"cnew"}');
|
||||
});
|
||||
|
||||
it("leaves a file without an owner unchanged and declares none", async () => {
|
||||
const text = '{"schemaVersion":"2","rows":[{"userId":"nested"}]}';
|
||||
const out = await run(text, "cnew", [3]);
|
||||
expect(out.declared).toBeNull();
|
||||
expect(out.text).toBe(text);
|
||||
});
|
||||
|
||||
it("leaves a value that is a string elsewhere, or not a string, alone", async () => {
|
||||
const text = '{"name":"userId","userId":42}';
|
||||
const out = await run(text, "cnew", [2]);
|
||||
expect(out.declared).toBeNull();
|
||||
expect(out.text).toBe(text);
|
||||
});
|
||||
|
||||
it("leaves a top-level array alone", async () => {
|
||||
const text = '["userId","x"]';
|
||||
const out = await run(text, "cnew");
|
||||
expect(out.declared).toBeNull();
|
||||
expect(out.text).toBe(text);
|
||||
});
|
||||
|
||||
it("refuses an owner too long to be an id", async () => {
|
||||
const text = JSON.stringify({ userId: "x".repeat(4096) });
|
||||
await expect(run(text, "cnew", [100])).rejects.toBeInstanceOf(
|
||||
BackupOwnerRewriteError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a top-level key that is not valid JSON", async () => {
|
||||
await expect(run('{"bad\\q":1}', "cnew")).rejects.toBeInstanceOf(
|
||||
BackupJsonError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The emptiness check behind restoring an erased account's copy into another
|
||||
* account reads every backed-up model by its `userId`, except the children
|
||||
* that have none, which it reaches through their parent. Both halves are held
|
||||
* to `prisma/schema.prisma` here, so a backed-up model added later is either
|
||||
* checked or fails this file; it cannot be skipped and read as empty.
|
||||
*
|
||||
* Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first
|
||||
* case names it (no `userId`); add an owned model to it and the second does.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape";
|
||||
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
|
||||
import {
|
||||
BACKED_UP_CHILD_MODELS,
|
||||
RESTORE_TARGET_OWNED_MODELS,
|
||||
} from "@/lib/export/restore-target";
|
||||
|
||||
const models = parseWipeSchema();
|
||||
|
||||
describe("restore target models", () => {
|
||||
it("reads every owned backed-up model by its userId", () => {
|
||||
const missing = RESTORE_TARGET_OWNED_MODELS.filter(
|
||||
(model) => !models.get(model)?.scalars.includes("userId"),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
expect(RESTORE_TARGET_OWNED_MODELS.length).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it("reaches each child through a required cascade to an owned parent", () => {
|
||||
for (const [child, parent] of Object.entries(BACKED_UP_CHILD_MODELS)) {
|
||||
expect(BACKED_UP_MODELS, child).toContain(child);
|
||||
const shape = models.get(child);
|
||||
expect(shape?.scalars, child).not.toContain("userId");
|
||||
expect(
|
||||
shape?.relations.some(
|
||||
(r) => r.target === parent && r.cascade && !r.optional,
|
||||
),
|
||||
`${child} -> ${parent}`,
|
||||
).toBe(true);
|
||||
expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Point a backup file at another account, on its way in.
|
||||
*
|
||||
* A backup names its owner once, in the top-level `userId` member. The restore
|
||||
* takes the account it writes from there and from nowhere else
|
||||
* (`checkOwnership` in `restore-preflight.ts`), and every domain writes its
|
||||
* rows under that account (`ctx.ownerId`), never under an id read from a row.
|
||||
* So the copy of an account that has since been erased goes into another
|
||||
* account by changing that one member, and nothing else in the file: the copy
|
||||
* stored is then that account's copy in every respect (its preview, its
|
||||
* download, its restore), and the erased id is stored nowhere, not even inside
|
||||
* the encrypted copy.
|
||||
*
|
||||
* This rewrites the member as the bytes stream past, without holding the
|
||||
* document (a disaster-recovery file can be longer than any string V8 can
|
||||
* hold, see `backup-json-scan.ts`). It tracks only string and nesting state,
|
||||
* and parses only the top-level keys and the one value it replaces. A key is
|
||||
* compared after `JSON.parse`, so an escaped spelling of `userId` is the same
|
||||
* key, exactly as the reader behind it sees it; a `userId` nested anywhere
|
||||
* below the top level is left alone. Every other byte passes through
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
import { BackupJsonError } from "@/lib/export/backup-json-scan";
|
||||
|
||||
const QUOTE = 0x22;
|
||||
const BACKSLASH = 0x5c;
|
||||
const OPEN_BRACE = 0x7b;
|
||||
const CLOSE_BRACE = 0x7d;
|
||||
const OPEN_BRACKET = 0x5b;
|
||||
const CLOSE_BRACKET = 0x5d;
|
||||
const COMMA = 0x2c;
|
||||
|
||||
/** Longer than any top-level key a backup carries. */
|
||||
const MAX_KEY_BYTES = 256;
|
||||
/** Far longer than any account id; a longer owner is not an id. */
|
||||
const MAX_OWNER_BYTES = 1024;
|
||||
|
||||
/** The file's owner member is not something this can rewrite. */
|
||||
export class BackupOwnerRewriteError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BackupOwnerRewriteError";
|
||||
}
|
||||
}
|
||||
|
||||
type StringRole = "key" | "owner" | "other";
|
||||
|
||||
export interface BackupOwnerRewrite {
|
||||
/** The bytes, with every top-level `userId` string set to the target. */
|
||||
bytes: AsyncGenerator<Buffer>;
|
||||
/**
|
||||
* The owner the file declared, once the stream has passed it: the last
|
||||
* top-level `userId` string, which is the one `JSON.parse` keeps. Null when
|
||||
* the file has none.
|
||||
*/
|
||||
declaredOwner(): string | null;
|
||||
}
|
||||
|
||||
export function rewriteBackupOwner(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
targetId: string,
|
||||
): BackupOwnerRewrite {
|
||||
const replacement = Buffer.from(JSON.stringify(targetId), "utf8");
|
||||
let declared: string | null = null;
|
||||
|
||||
async function* bytes(): AsyncGenerator<Buffer> {
|
||||
let depth = 0;
|
||||
let topIsObject = false;
|
||||
let expectKey = false;
|
||||
let lastKey: string | null = null;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let role: StringRole = "other";
|
||||
// The key or owner string being collected, across chunks.
|
||||
let collected: Buffer[] = [];
|
||||
let collectedBytes = 0;
|
||||
|
||||
for await (const raw of source) {
|
||||
const chunk = Buffer.isBuffer(raw)
|
||||
? raw
|
||||
: Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
|
||||
const out: Buffer[] = [];
|
||||
// Where the bytes passed through unchanged start in this chunk; -1
|
||||
// while the owner value is being withheld.
|
||||
let passFrom = role === "owner" && inString ? -1 : 0;
|
||||
// Where the string being collected starts in this chunk.
|
||||
let collectFrom = 0;
|
||||
|
||||
const collect = (to: number) => {
|
||||
if (to > collectFrom) {
|
||||
collected.push(chunk.subarray(collectFrom, to));
|
||||
collectedBytes += to - collectFrom;
|
||||
}
|
||||
if (role === "key" && collectedBytes > MAX_KEY_BYTES) {
|
||||
// Not a key this rewrite cares about; stop collecting it.
|
||||
role = "other";
|
||||
collected = [];
|
||||
collectedBytes = 0;
|
||||
}
|
||||
if (role === "owner" && collectedBytes > MAX_OWNER_BYTES) {
|
||||
throw new BackupOwnerRewriteError(
|
||||
"The file's owner is too long to be an account id",
|
||||
);
|
||||
}
|
||||
};
|
||||
const take = (): string => {
|
||||
const text = Buffer.concat(collected, collectedBytes).toString("utf8");
|
||||
collected = [];
|
||||
collectedBytes = 0;
|
||||
try {
|
||||
return JSON.parse(text) as string;
|
||||
} catch {
|
||||
throw new BackupJsonError(
|
||||
"A top-level string in the file is not valid JSON",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const byte = chunk[i]!;
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (byte === BACKSLASH) escaped = true;
|
||||
else if (byte === QUOTE) {
|
||||
inString = false;
|
||||
if (role === "key") {
|
||||
collect(i + 1);
|
||||
lastKey = role === "key" ? take() : null;
|
||||
expectKey = false;
|
||||
} else if (role === "owner") {
|
||||
collect(i + 1);
|
||||
declared = take();
|
||||
out.push(replacement);
|
||||
passFrom = i + 1;
|
||||
}
|
||||
role = "other";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === QUOTE) {
|
||||
inString = true;
|
||||
if (depth === 1 && topIsObject && expectKey) {
|
||||
role = "key";
|
||||
collectFrom = i;
|
||||
} else if (depth === 1 && topIsObject && lastKey === "userId") {
|
||||
role = "owner";
|
||||
collectFrom = i;
|
||||
if (passFrom >= 0) out.push(chunk.subarray(passFrom, i));
|
||||
passFrom = -1;
|
||||
} else {
|
||||
role = "other";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === OPEN_BRACE || byte === OPEN_BRACKET) {
|
||||
depth += 1;
|
||||
if (depth === 1) {
|
||||
topIsObject = byte === OPEN_BRACE;
|
||||
expectKey = topIsObject;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (byte === COMMA && depth === 1) {
|
||||
expectKey = topIsObject;
|
||||
lastKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (inString && (role === "key" || role === "owner")) {
|
||||
collect(chunk.length);
|
||||
}
|
||||
if (passFrom >= 0) out.push(chunk.subarray(passFrom));
|
||||
const piece = out.length === 1 ? out[0]! : Buffer.concat(out);
|
||||
if (piece.byteLength > 0) yield piece;
|
||||
}
|
||||
}
|
||||
|
||||
return { bytes: bytes(), declaredOwner: () => declared };
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Which account the copy of an erased account may be restored into.
|
||||
*
|
||||
* A restore replaces: every row of the account in a section the copy carries
|
||||
* is deleted and the copy's rows are written in their place
|
||||
* (`docs/ops/backup-restore.md`). There is no merge. So putting the record of
|
||||
* an account that no longer exists into another account is only safe when
|
||||
* that account holds none of those rows, and the check is exactly that: a row
|
||||
* of any model the backup carries ({@link BACKED_UP_MODELS}) under the
|
||||
* account refuses it. Everything a restore leaves alone (credentials,
|
||||
* sessions, devices, grants) does not count.
|
||||
*
|
||||
* The other two refusals keep the account rules intact. A managed profile is
|
||||
* its Guardians' record, which an admin does not write into (the users list
|
||||
* offers no admin deletion on one for the same reason). An account pending
|
||||
* deletion is locked and will be erased, with whatever was restored into it.
|
||||
* Neither the restore nor this check touches the role, so the last-admin rule
|
||||
* is not in play.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
|
||||
|
||||
/**
|
||||
* The backed-up models with no owner column of their own, each with the owned
|
||||
* model it hangs off through a required cascade. A row of one cannot exist
|
||||
* without a row of its parent, so checking the parent checks it.
|
||||
* `restore-target-models.test.ts` holds this list to the schema.
|
||||
*/
|
||||
export const BACKED_UP_CHILD_MODELS: Readonly<Record<string, string>> = {
|
||||
MedicationSchedule: "Medication",
|
||||
MedicationScheduleRevision: "Medication",
|
||||
MedicationDoseChange: "Medication",
|
||||
MedicationEfficacyTarget: "Medication",
|
||||
MedicationInventoryEvent: "Medication",
|
||||
MedicationCategoryAssignment: "Medication",
|
||||
ReminderPhaseConfig: "Medication",
|
||||
MoodEntryTagLink: "MoodEntry",
|
||||
IllnessSymptomLink: "IllnessDayLog",
|
||||
CycleSymptomLink: "CycleDayLog",
|
||||
WorkoutRoute: "Workout",
|
||||
WorkoutSamples: "Workout",
|
||||
CoachMessage: "CoachConversation",
|
||||
CoachConversationDocument: "CoachConversation",
|
||||
};
|
||||
|
||||
/** Every backed-up model that carries `userId`, in the plan's order. */
|
||||
export const RESTORE_TARGET_OWNED_MODELS: readonly string[] =
|
||||
BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS));
|
||||
|
||||
interface OwnedDelegate {
|
||||
findFirst(args: {
|
||||
where: { userId: string };
|
||||
select: { userId: true };
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
function ownedDelegate(db: object, model: string): OwnedDelegate {
|
||||
const key = model.charAt(0).toLowerCase() + model.slice(1);
|
||||
const delegate = (db as Record<string, OwnedDelegate | undefined>)[key];
|
||||
if (!delegate || typeof delegate.findFirst !== "function") {
|
||||
// Never narrower than the plan: a model this cannot read is a model it
|
||||
// would otherwise call empty.
|
||||
throw new Error(`No Prisma delegate for backed-up model "${model}"`);
|
||||
}
|
||||
return delegate;
|
||||
}
|
||||
|
||||
/** The backed-up models under which `userId` holds at least one row. */
|
||||
export async function modelsHoldingRecords(
|
||||
db: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const held: string[] = [];
|
||||
for (const model of RESTORE_TARGET_OWNED_MODELS) {
|
||||
const row = await ownedDelegate(db, model).findFirst({
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (row) held.push(model);
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
export type RestoreTargetRefusal =
|
||||
"not_found" | "managed_profile" | "pending_deletion" | "holds_records";
|
||||
|
||||
export type RestoreTargetVerdict =
|
||||
| { ok: true; target: { id: string; username: string } }
|
||||
| {
|
||||
ok: false;
|
||||
reason: RestoreTargetRefusal;
|
||||
target: { id: string; username: string } | null;
|
||||
/** For `holds_records`: the models that hold rows, in plan order. */
|
||||
models: string[];
|
||||
};
|
||||
|
||||
/** Whether the copy of an erased account may go into `userId`. */
|
||||
export async function checkRestoreTarget(
|
||||
db: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<RestoreTargetVerdict> {
|
||||
const row = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
managedProfileAt: true,
|
||||
deletionRequestedAt: true,
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
return { ok: false, reason: "not_found", target: null, models: [] };
|
||||
}
|
||||
const target = { id: row.id, username: row.username };
|
||||
if (row.managedProfileAt) {
|
||||
return { ok: false, reason: "managed_profile", target, models: [] };
|
||||
}
|
||||
if (row.deletionRequestedAt) {
|
||||
return { ok: false, reason: "pending_deletion", target, models: [] };
|
||||
}
|
||||
const models = await modelsHoldingRecords(db, row.id);
|
||||
if (models.length > 0) {
|
||||
return { ok: false, reason: "holds_records", target, models };
|
||||
}
|
||||
return { ok: true, target };
|
||||
}
|
||||
|
||||
/** The wire code of each refusal. */
|
||||
export const BACKUP_TARGET_REFUSAL_CODES = {
|
||||
not_found: "backup.target.not_found",
|
||||
managed_profile: "backup.target.managed_profile",
|
||||
pending_deletion: "backup.target.pending_deletion",
|
||||
holds_records: "backup.target.holds_records",
|
||||
} as const satisfies Record<RestoreTargetRefusal, string>;
|
||||
|
||||
/** The status and stable code each refusal answers with. */
|
||||
export const RESTORE_TARGET_REFUSALS: Readonly<
|
||||
Record<
|
||||
RestoreTargetRefusal,
|
||||
{ status: number; code: string; message: string }
|
||||
>
|
||||
> = {
|
||||
not_found: {
|
||||
status: 404,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.not_found,
|
||||
message: "The chosen account does not exist.",
|
||||
},
|
||||
managed_profile: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.managed_profile,
|
||||
message:
|
||||
"The chosen account is a managed profile. Its Guardians run it, so a copy cannot be restored into it.",
|
||||
},
|
||||
pending_deletion: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.pending_deletion,
|
||||
message:
|
||||
"The chosen account is pending deletion. Undo the deletion first, or choose another account.",
|
||||
},
|
||||
holds_records: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.holds_records,
|
||||
message:
|
||||
"The chosen account already holds records, and a restore replaces them rather than adding to them. Choose an empty account or create a new one.",
|
||||
},
|
||||
};
|
||||
@@ -100,9 +100,17 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
backup: [
|
||||
"backup.foreign_reference",
|
||||
"backup.key.missing",
|
||||
"backup.owner.exists",
|
||||
"backup.owner.missing",
|
||||
"backup.payload.undecryptable",
|
||||
"backup.restore.active",
|
||||
"backup.section.missing",
|
||||
"backup.target.holds_records",
|
||||
"backup.target.managed_profile",
|
||||
"backup.target.not_found",
|
||||
"backup.target.pending_deletion",
|
||||
"backup.target.unconfirmed",
|
||||
"backup.target.username_taken",
|
||||
"backup.upload.too_large",
|
||||
],
|
||||
biomarkers: ["biomarkers.unit.locked"],
|
||||
|
||||
@@ -218,7 +218,26 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Admin"],
|
||||
summary: "Store an uploaded backup file as a new copy",
|
||||
description:
|
||||
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
|
||||
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>` for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy, with no password and no email (an admin sets its first password with the existing password reset). The file's owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like any other; the erased account's id is not stored. A target is refused before the file is read when the account does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
into: z.enum(["existing", "new"]).optional().meta({
|
||||
description:
|
||||
"Where the copy goes when the account the file names does not exist here. Absent: the file's own account, which has to exist.",
|
||||
}),
|
||||
userId: z.string().max(64).optional().meta({
|
||||
description: "With `into=existing`: the account's id.",
|
||||
}),
|
||||
confirm: z.string().max(64).optional().meta({
|
||||
description:
|
||||
"With `into=existing`: the account's username, typed by the operator.",
|
||||
}),
|
||||
username: z.string().min(3).max(30).optional().meta({
|
||||
description:
|
||||
"With `into=new`: the new account's username (letters, digits, `_` and `-`).",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -261,6 +280,16 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The chosen target cannot take the copy, and nothing was stored: a managed profile (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username already taken (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description:
|
||||
"Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.",
|
||||
@@ -268,7 +297,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.",
|
||||
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here (`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose `confirm` is not the account's username (`backup.target.unconfirmed`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -44,6 +44,12 @@ export const adminKeys = {
|
||||
/** v1.37.20 — restore preview: the counts a stored backup file carries. */
|
||||
adminBackupSummary: (id: string) =>
|
||||
["admin", "backups", id, "summary"] as const,
|
||||
/**
|
||||
* Whether an account can take the copy of an erased account. Under the
|
||||
* backup list's prefix, so a new copy refetches it.
|
||||
*/
|
||||
adminBackupTarget: (userId: string) =>
|
||||
["admin", "backups", "target", userId] as const,
|
||||
/**
|
||||
* v1.39.1 — the restore jobs the backups console shows. Its own prefix, so
|
||||
* invalidating the backup list does not refetch the poll and vice versa.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Where an uploaded backup goes when the account it names no longer exists.
|
||||
*
|
||||
* The upload's body is the file itself, so the choice rides in the query:
|
||||
*
|
||||
* - `into=existing&userId=<id>&confirm=<username>`: an existing account,
|
||||
* confirmed by typing its username, since the restore writes into it;
|
||||
* - `into=new&username=<name>`: a new account created for the copy.
|
||||
*
|
||||
* No query at all is the upload as it always was: the copy goes to the
|
||||
* account the file names, which has to exist.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
|
||||
/** The upload's refusal when the file's own account is not on this server. */
|
||||
export const BACKUP_OWNER_MISSING_CODE = "backup.owner.missing";
|
||||
/** The upload's refusal when a target was chosen for a file whose account exists. */
|
||||
export const BACKUP_OWNER_EXISTS_CODE = "backup.owner.exists";
|
||||
/** The typed username does not match the chosen account. */
|
||||
export const BACKUP_TARGET_UNCONFIRMED_CODE = "backup.target.unconfirmed";
|
||||
/** The username asked for a new account is taken. */
|
||||
export const BACKUP_TARGET_USERNAME_TAKEN_CODE = "backup.target.username_taken";
|
||||
|
||||
export const backupUploadTargetSchema = z.discriminatedUnion("into", [
|
||||
z
|
||||
.object({
|
||||
into: z.literal("existing"),
|
||||
userId: z.string().min(1).max(64),
|
||||
confirm: z.string().max(64),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
into: z.literal("new"),
|
||||
username: accountUsernameSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export type BackupUploadTarget = z.infer<typeof backupUploadTargetSchema>;
|
||||
|
||||
const TARGET_PARAMS = ["into", "userId", "confirm", "username"] as const;
|
||||
|
||||
/**
|
||||
* Read the target from the upload's query. Null when the query names none of
|
||||
* its parameters; a query that names any of them is held to the schema whole.
|
||||
*/
|
||||
export function parseBackupUploadTarget(
|
||||
params: URLSearchParams,
|
||||
):
|
||||
| { success: true; data: BackupUploadTarget | null }
|
||||
| { success: false; error: z.ZodError } {
|
||||
if (!TARGET_PARAMS.some((name) => params.has(name))) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
const parsed = backupUploadTargetSchema.safeParse(
|
||||
Object.fromEntries(params.entries()),
|
||||
);
|
||||
return parsed.success
|
||||
? { success: true, data: parsed.data }
|
||||
: { success: false, error: parsed.error };
|
||||
}
|
||||
|
||||
/** The query string for a target, as the admin console sends it. */
|
||||
export function backupUploadTargetQuery(target: BackupUploadTarget): string {
|
||||
const params = new URLSearchParams(
|
||||
target.into === "existing"
|
||||
? { into: "existing", userId: target.userId, confirm: target.confirm }
|
||||
: { into: "new", username: target.username },
|
||||
);
|
||||
return params.toString();
|
||||
}
|
||||
@@ -9,24 +9,43 @@
|
||||
* session, and a server that no longer knows the account.
|
||||
*
|
||||
* 1. The purge leaves nothing in the database that names the account.
|
||||
* 2. The admin upload refuses the file, because its owner is gone, and
|
||||
* stores nothing (the documented contract in `docs/ops/backup-restore.md`:
|
||||
* a restore writes into an account that exists under the same id).
|
||||
* 3. With the account row put back under its original id, the one way that
|
||||
* contract leaves, the upload and the restore bring every section back.
|
||||
* 2. The admin upload without a target refuses the file, because its owner
|
||||
* is gone, says so with `backup.owner.missing`, stores nothing, and does
|
||||
* not write the erased id into the trail either.
|
||||
* 3. With the account row put back under its original id, the upload and
|
||||
* the restore bring every section back: the same-id path, unchanged.
|
||||
* 4. Into a freshly created account, with the file's owner set to that
|
||||
* account, the restore brings every section back and nothing in the
|
||||
* database names the erased account afterwards.
|
||||
* account by hand, the restore brings every section back and nothing in
|
||||
* the database names the erased account afterwards. This is the
|
||||
* mechanism the upload's target generalises.
|
||||
* 5. The upload with a target does it for the operator: into an existing
|
||||
* empty account (typed confirmation) and into a new account it creates,
|
||||
* every section comes back, the audit trail says what happened, and
|
||||
* nothing names the erased account, the refused first upload included.
|
||||
* 6. A target that holds records, is a managed profile, is pending deletion
|
||||
* or is not confirmed is refused before the file is read; so is a target
|
||||
* for a file whose own account is still here, and a new account made for
|
||||
* a refused file is removed again. A non-admin session and a Bearer
|
||||
* token are refused outright.
|
||||
*
|
||||
* Everything runs through production code: the deletion route, the purge job,
|
||||
* the admin upload and restore routes and the restore job
|
||||
* the admin upload, target-check and restore routes and the restore job
|
||||
* (`restore-job-driver.ts` runs the queued job in-process).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import {
|
||||
openStoredBackup,
|
||||
STORED_BACKUP_SELECT,
|
||||
} from "@/lib/export/stored-backup";
|
||||
import {
|
||||
backupUploadTargetQuery,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
|
||||
import {
|
||||
OWNER_ID,
|
||||
@@ -207,10 +226,10 @@ async function eraseAfterExport(prisma: PrismaClient) {
|
||||
return { admin, json, counts };
|
||||
}
|
||||
|
||||
async function upload(body: string) {
|
||||
async function upload(body: string, query = "") {
|
||||
const { POST } = await import("@/app/api/admin/backups/upload/route");
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/admin/backups/upload", {
|
||||
new Request(`http://localhost/api/admin/backups/upload${query}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
@@ -221,7 +240,7 @@ async function upload(body: string) {
|
||||
body: (await res.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
meta?: { reason?: string; errorCode?: string };
|
||||
meta?: { reason?: string; errorCode?: string; models?: string[] };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -245,6 +264,52 @@ async function restore(backupId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The upload, with a target in the query as the backups console sends it. */
|
||||
function uploadInto(body: string, target: BackupUploadTarget) {
|
||||
return upload(body, `?${backupUploadTargetQuery(target)}`);
|
||||
}
|
||||
|
||||
async function checkTarget(userId: string) {
|
||||
const { GET } =
|
||||
await import("@/app/api/admin/backups/targets/[userId]/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/admin/backups/targets/${userId}`),
|
||||
{ params: Promise.resolve({ userId }) },
|
||||
);
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: {
|
||||
id: string;
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
models: string[];
|
||||
} | null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The stored copy as the restore will read it, as text. */
|
||||
async function storedCopyText(prisma: PrismaClient, backupId: string) {
|
||||
const row = await prisma.dataBackup.findUniqueOrThrow({
|
||||
where: { id: backupId },
|
||||
select: STORED_BACKUP_SELECT,
|
||||
});
|
||||
const source = await openStoredBackup(prisma, row);
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of source()) parts.push(Buffer.from(part));
|
||||
return { row, text: Buffer.concat(parts).toString("utf8") };
|
||||
}
|
||||
|
||||
async function auditDetails(prisma: PrismaClient, action: string) {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: { action },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((row) => JSON.parse(row.details ?? "{}") as object);
|
||||
}
|
||||
|
||||
async function countsOf(prisma: PrismaClient, userId: string) {
|
||||
const { counts } = await buildFullBackupPayload(prisma, userId, {
|
||||
purpose: "disaster-recovery",
|
||||
@@ -266,14 +331,16 @@ describe("restoring the backup of an erased account", () => {
|
||||
|
||||
const refused = await upload(json);
|
||||
expect(refused.status, JSON.stringify(refused.body)).toBe(422);
|
||||
expect(refused.body.meta?.errorCode).toBe("backup.owner.missing");
|
||||
const denial = await prisma.auditLog.findFirstOrThrow({
|
||||
where: { action: "admin.backups.upload.denied" },
|
||||
});
|
||||
expect(JSON.parse(denial.details ?? "{}")).toMatchObject({
|
||||
expect(JSON.parse(denial.details ?? "{}")).toEqual({
|
||||
reason: "owner_not_found",
|
||||
ownerId: OWNER_ID,
|
||||
});
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
// The refusal wrote a row, and that row does not bring the id back.
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
@@ -324,3 +391,342 @@ describe("restoring the backup of an erased account", () => {
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe("restoring the backup of an erased account into a chosen account", () => {
|
||||
it(
|
||||
"into an existing empty account: every section, the trail, and nothing naming the erased one",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json, counts } = await eraseAfterExport(prisma);
|
||||
const fresh = await prisma.user.create({
|
||||
data: {
|
||||
username: "returning-person",
|
||||
email: "returning-person@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
// The operator's first try, which the console turns into the choice.
|
||||
const first = await upload(json);
|
||||
expect(first.body.meta?.errorCode).toBe("backup.owner.missing");
|
||||
|
||||
const check = await checkTarget(fresh.id);
|
||||
expect(check.status).toBe(200);
|
||||
expect(check.body.data).toMatchObject({
|
||||
id: fresh.id,
|
||||
eligible: true,
|
||||
reason: null,
|
||||
models: [],
|
||||
});
|
||||
|
||||
const stored = await uploadInto(json, {
|
||||
into: "existing",
|
||||
userId: fresh.id,
|
||||
confirm: "returning-person",
|
||||
});
|
||||
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
|
||||
const backupId = stored.body.data!.id;
|
||||
|
||||
// The stored copy is the chosen account's copy, inside as well.
|
||||
const copy = await storedCopyText(prisma, backupId);
|
||||
expect(copy.row.userId).toBe(fresh.id);
|
||||
expect(JSON.parse(copy.text).userId).toBe(fresh.id);
|
||||
expect(copy.text.includes(OWNER_ID)).toBe(false);
|
||||
|
||||
const restored = await restore(backupId);
|
||||
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
|
||||
expect(restored.body.data!.skipped.links).toBe(0);
|
||||
expect(await countsOf(prisma, fresh.id)).toEqual(counts);
|
||||
|
||||
expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([
|
||||
expect.objectContaining({
|
||||
backupId,
|
||||
ownerId: fresh.id,
|
||||
ownerUsername: "returning-person",
|
||||
into: "existing",
|
||||
}),
|
||||
]);
|
||||
expect(await auditDetails(prisma, "admin.backups.restore")).toEqual([
|
||||
expect.objectContaining({ backupId, ownerId: fresh.id }),
|
||||
]);
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"into a new account: created without a password, filled, and signed into through the admin reset",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", "1");
|
||||
const { admin, json, counts } = await eraseAfterExport(prisma);
|
||||
const usersBefore = await prisma.user.count();
|
||||
|
||||
const stored = await uploadInto(json, {
|
||||
into: "new",
|
||||
username: "returning-person",
|
||||
});
|
||||
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
|
||||
const backupId = stored.body.data!.id;
|
||||
expect(await prisma.user.count()).toBe(usersBefore + 1);
|
||||
const account = await prisma.user.findUniqueOrThrow({
|
||||
where: { username: "returning-person" },
|
||||
});
|
||||
expect(account).toMatchObject({
|
||||
role: "USER",
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
managedProfileAt: null,
|
||||
deletionRequestedAt: null,
|
||||
});
|
||||
expect(
|
||||
(await prisma.dataBackup.findUniqueOrThrow({ where: { id: backupId } }))
|
||||
.userId,
|
||||
).toBe(account.id);
|
||||
|
||||
const restored = await restore(backupId);
|
||||
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
|
||||
expect(restored.body.data!.skipped.links).toBe(0);
|
||||
expect(await countsOf(prisma, account.id)).toEqual(counts);
|
||||
// Identity is never in a restore: still the account the upload made.
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({
|
||||
where: { id: account.id },
|
||||
select: { username: true, role: true, passwordHash: true },
|
||||
}),
|
||||
).toEqual({
|
||||
username: "returning-person",
|
||||
role: "USER",
|
||||
passwordHash: null,
|
||||
});
|
||||
|
||||
expect(await auditDetails(prisma, "admin.user.create")).toEqual([
|
||||
{
|
||||
targetUserId: account.id,
|
||||
username: "returning-person",
|
||||
reason: "backup_restore",
|
||||
backupId,
|
||||
},
|
||||
]);
|
||||
expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([
|
||||
expect.objectContaining({ ownerId: account.id, into: "new" }),
|
||||
]);
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
|
||||
// How the person gets in: the admin sets a first password with the
|
||||
// existing reset, which is all the new account needs.
|
||||
const { POST: resetPassword } =
|
||||
await import("@/app/api/admin/users/[id]/reset-password/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const reset = await resetPassword(
|
||||
new NextRequest(
|
||||
`http://localhost/api/admin/users/${account.id}/reset-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ password: "Harbour-lantern-47-quietly" }),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ id: account.id }) },
|
||||
);
|
||||
expect(reset.status, await reset.clone().text()).toBe(200);
|
||||
expect(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: account.id } }))
|
||||
.passwordHash,
|
||||
).not.toBeNull();
|
||||
expect(admin.role).toBe("ADMIN");
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses an account that holds records, a managed profile, an account pending deletion and a wrong confirmation, before reading the file",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json } = await eraseAfterExport(prisma);
|
||||
const used = await prisma.user.create({
|
||||
data: { username: "in-use", email: "in-use@example.test" },
|
||||
});
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: used.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-10-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const managed = await prisma.user.create({
|
||||
data: { username: "managed-cafe01", managedProfileAt: new Date() },
|
||||
});
|
||||
const leaving = await prisma.user.create({
|
||||
data: {
|
||||
username: "leaving",
|
||||
deletionRequestedAt: new Date(),
|
||||
deletionPurgeAt: new Date(Date.now() + 14 * DAY_MS),
|
||||
},
|
||||
});
|
||||
const empty = await prisma.user.create({ data: { username: "empty" } });
|
||||
|
||||
expect((await checkTarget(used.id)).body.data).toMatchObject({
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
});
|
||||
expect((await checkTarget("no-such-account")).status).toBe(404);
|
||||
|
||||
const cases: Array<[BackupUploadTarget, number, string]> = [
|
||||
[
|
||||
{ into: "existing", userId: used.id, confirm: "in-use" },
|
||||
409,
|
||||
"backup.target.holds_records",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: managed.id, confirm: "managed-cafe01" },
|
||||
409,
|
||||
"backup.target.managed_profile",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: leaving.id, confirm: "leaving" },
|
||||
409,
|
||||
"backup.target.pending_deletion",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: "no-such-account", confirm: "x" },
|
||||
404,
|
||||
"backup.target.not_found",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: empty.id, confirm: "Empty" },
|
||||
422,
|
||||
"backup.target.unconfirmed",
|
||||
],
|
||||
[
|
||||
{ into: "new", username: "in-use" },
|
||||
409,
|
||||
"backup.target.username_taken",
|
||||
],
|
||||
];
|
||||
for (const [target, status, code] of cases) {
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const refused = await uploadInto(json, target);
|
||||
expect(refused.status, JSON.stringify(refused.body)).toBe(status);
|
||||
expect(refused.body.meta?.errorCode).toBe(code);
|
||||
}
|
||||
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
expect(
|
||||
await prisma.measurement.count({ where: { userId: used.id } }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
(await auditDetails(prisma, "admin.backups.upload.denied")).map(
|
||||
(details) => (details as { reason: string }).reason,
|
||||
),
|
||||
).toEqual([
|
||||
"target_holds_records",
|
||||
"target_managed_profile",
|
||||
"target_pending_deletion",
|
||||
"target_not_found",
|
||||
"target_unconfirmed",
|
||||
"target_username_taken",
|
||||
]);
|
||||
|
||||
// A query that does not parse is refused with every issue.
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const malformed = await upload(json, "?into=new&username=no%20spaces");
|
||||
expect(malformed.status).toBe(422);
|
||||
expect(
|
||||
await prisma.user.count({ where: { username: "no spaces" } }),
|
||||
).toBe(0);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses a target for a file whose own account is still here, and removes an account made for it",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json } = await eraseAfterExport(prisma);
|
||||
await createOwner(prisma);
|
||||
const empty = await prisma.user.create({ data: { username: "empty" } });
|
||||
const usersBefore = await prisma.user.count();
|
||||
|
||||
const existing = await uploadInto(json, {
|
||||
into: "existing",
|
||||
userId: empty.id,
|
||||
confirm: "empty",
|
||||
});
|
||||
expect(existing.status, JSON.stringify(existing.body)).toBe(409);
|
||||
expect(existing.body.meta?.errorCode).toBe("backup.owner.exists");
|
||||
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const fresh = await uploadInto(json, {
|
||||
into: "new",
|
||||
username: "would-be-copy",
|
||||
});
|
||||
expect(fresh.status, JSON.stringify(fresh.body)).toBe(409);
|
||||
expect(fresh.body.meta?.errorCode).toBe("backup.owner.exists");
|
||||
expect(await prisma.user.count()).toBe(usersBefore);
|
||||
expect(
|
||||
await prisma.user.count({ where: { username: "would-be-copy" } }),
|
||||
).toBe(0);
|
||||
expect(await auditDetails(prisma, "admin.user.create")).toEqual([]);
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
|
||||
// Without a target, the same file goes to its own account as before.
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const own = await upload(json);
|
||||
expect(own.status, JSON.stringify(own.body)).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await prisma.dataBackup.findUniqueOrThrow({
|
||||
where: { id: own.body.data!.id },
|
||||
})
|
||||
).userId,
|
||||
).toBe(OWNER_ID);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses a non-admin session and an admin's Bearer token, and creates nothing",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { admin, json } = await eraseAfterExport(prisma);
|
||||
const member = await prisma.user.create({ data: { username: "member" } });
|
||||
const empty = await prisma.user.create({ data: { username: "empty" } });
|
||||
const usersBefore = await prisma.user.count();
|
||||
|
||||
cookieJar.clear();
|
||||
await signIn(prisma, member.id);
|
||||
const asMember = await uploadInto(json, {
|
||||
into: "new",
|
||||
username: "member-made",
|
||||
});
|
||||
expect(asMember.status).toBe(403);
|
||||
expect((await checkTarget(empty.id)).status).toBe(403);
|
||||
|
||||
cookieJar.clear();
|
||||
const raw = `hlk_erasedrestore_${"0".repeat(48)}`;
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
name: "wildcard",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
const asBearer = await uploadInto(json, {
|
||||
into: "existing",
|
||||
userId: empty.id,
|
||||
confirm: "empty",
|
||||
});
|
||||
expect(asBearer.status).toBe(401);
|
||||
expect((await checkTarget(empty.id)).status).toBe(401);
|
||||
|
||||
expect(await prisma.user.count()).toBe(usersBefore);
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user