mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user