mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user