Merge branch 'feat/v1463-restore' into release/v1.46.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:46:23 +01:00
commit 00d129ad42
28 files changed
+2504 -154

No files matched your search

@@ -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,
});
},
);
+307 -67
View File
@@ -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: {