mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'fix/backup-rotation-and-deletion' into release/v1.39.3
# Conflicts: # docs/api/openapi.yaml # src/lib/jobs/apple-health-import-worker.ts
This commit is contained in:
commit
4409529c89
74 files changed
+4022
-307
No files matched your search
@@ -22,9 +22,15 @@ import {
|
||||
storedBackupRefusal,
|
||||
STORED_BACKUP_SELECT,
|
||||
} from "@/lib/export/stored-backup";
|
||||
import {
|
||||
assessBackupKeys,
|
||||
BACKUP_KEY_MISSING_CODE,
|
||||
describeBackupKeyProblem,
|
||||
} from "@/lib/export/backup-key-ids";
|
||||
import {
|
||||
readStreamedBackup,
|
||||
type BackupSource,
|
||||
type StreamedBackup,
|
||||
} from "@/lib/export/streamed-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -72,8 +78,9 @@ export const GET = apiHandler(
|
||||
|
||||
let payload;
|
||||
let measurementCount: number;
|
||||
let streamed: StreamedBackup;
|
||||
try {
|
||||
const streamed = await readStreamedBackup(source);
|
||||
streamed = await readStreamedBackup(source);
|
||||
measurementCount = streamed.measurementCount;
|
||||
payload = parseBackupPayload(streamed.raw);
|
||||
} catch (err) {
|
||||
@@ -93,6 +100,22 @@ export const GET = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// The preview is where the operator decides, so it says what the restore
|
||||
// would refuse: a key the file's inner ciphertext needs and this host
|
||||
// does not have. The instance settings are left out here, as the restore
|
||||
// leaves them out unless asked; the restore itself checks them when they
|
||||
// are asked for.
|
||||
const keyVerdict = assessBackupKeys(streamed.keys, {
|
||||
ignoreSections: new Set(["appSettings"]),
|
||||
});
|
||||
const keyProblem = describeBackupKeyProblem(keyVerdict);
|
||||
if (keyProblem) {
|
||||
return apiError(keyProblem, 422, {
|
||||
errorCode: BACKUP_KEY_MISSING_CODE,
|
||||
keyIds: [...keyVerdict.missing, ...keyVerdict.unreadable],
|
||||
});
|
||||
}
|
||||
|
||||
const summary = {
|
||||
...summarizeBackup(payload),
|
||||
measurements: measurementCount,
|
||||
|
||||
@@ -29,12 +29,23 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
|
||||
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
|
||||
offhostPurgeRequest: {
|
||||
count: async () => 2,
|
||||
findFirst: async () => ({
|
||||
requestedAt: new Date("2026-09-20T10:00:00.000Z"),
|
||||
lastFailure: "AccessDenied",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const configuredMock = vi.fn(() => true);
|
||||
vi.mock("@/lib/jobs/offhost-backup", () => ({
|
||||
offhostBackupConfigured: () => configuredMock(),
|
||||
probeOffhostLifecycle: async () => ({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -139,10 +150,12 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
|
||||
const body = await read();
|
||||
|
||||
expect(body.offhost).toEqual({
|
||||
expect(body.offhost).toMatchObject({
|
||||
configured: false,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
// No bucket to ask on a host that is not configured.
|
||||
lifecycle: { state: "unknown", expirationDays: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,4 +209,20 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
]);
|
||||
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
|
||||
});
|
||||
|
||||
it("carries the bucket's lifecycle verdict and the deletions it still owes", async () => {
|
||||
configuredMock.mockReturnValue(true);
|
||||
queryRawMock.mockResolvedValue([]);
|
||||
findManyMock.mockResolvedValue([]);
|
||||
const body = await read();
|
||||
expect(body.offhost.lifecycle).toEqual({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
});
|
||||
expect(body.offhost.pendingDeletions).toEqual({
|
||||
count: 2,
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFailure: "AccessDenied",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
|
||||
import { readLastQueueRun } from "@/lib/jobs/job-failures";
|
||||
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
||||
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
@@ -164,6 +167,17 @@ export const GET = apiHandler(async () => {
|
||||
a.username.localeCompare(b.username),
|
||||
);
|
||||
|
||||
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
|
||||
prisma.offhostPurgeRequest.count(),
|
||||
prisma.offhostPurgeRequest.findFirst({
|
||||
orderBy: { requestedAt: "asc" },
|
||||
select: { requestedAt: true, lastFailure: true },
|
||||
}),
|
||||
configured
|
||||
? probeOffhostLifecycle()
|
||||
: Promise.resolve({ state: "unknown" as const, expirationDays: null }),
|
||||
]);
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows: list,
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
@@ -182,6 +196,12 @@ export const GET = apiHandler(async () => {
|
||||
// An unconfigured host has nothing to list, and the card says so rather
|
||||
// than painting an empty table that reads as "no problems".
|
||||
rows: configured ? offhostRows : [],
|
||||
pendingDeletions: {
|
||||
count: purgeCount,
|
||||
oldestRequestedAt: oldestPurge?.requestedAt.toISOString() ?? null,
|
||||
lastFailure: oldestPurge?.lastFailure ?? null,
|
||||
},
|
||||
lifecycle,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
|
||||
*/
|
||||
import { Readable } from "node:stream";
|
||||
import { createGunzip } from "node:zlib";
|
||||
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -44,6 +43,16 @@ import {
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
|
||||
import {
|
||||
BackupUploadDecodeError,
|
||||
decodeBackupUpload,
|
||||
} from "@/lib/export/backup-upload-decode";
|
||||
import {
|
||||
assessBackupKeys,
|
||||
BACKUP_KEY_MISSING_CODE,
|
||||
BackupKeyIdCollector,
|
||||
describeBackupKeyProblem,
|
||||
} from "@/lib/export/backup-key-ids";
|
||||
import {
|
||||
BACKUP_UPLOAD_TOO_LARGE_CODE,
|
||||
BackupBlobTooLargeError,
|
||||
@@ -101,50 +110,22 @@ class UploadRefused extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes → bytes, gunzipped when the file starts with the gzip magic. */
|
||||
/** The file's bytes, gunzipped and counted on both ends. */
|
||||
async function* decodedBytes(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
limit: number,
|
||||
): AsyncGenerator<Uint8Array> {
|
||||
let seen = 0;
|
||||
async function* counted() {
|
||||
for await (const chunk of source) {
|
||||
seen += chunk.byteLength;
|
||||
if (seen > limit) {
|
||||
throw new UploadRefused(
|
||||
413,
|
||||
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
|
||||
{ reason: "file_size_exceeded", size: seen },
|
||||
);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
const iterator = counted()[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
if (first.done) return;
|
||||
const rest = (async function* () {
|
||||
yield first.value;
|
||||
for (;;) {
|
||||
const next = await iterator.next();
|
||||
if (next.done) return;
|
||||
yield next.value;
|
||||
}
|
||||
})();
|
||||
const head = first.value;
|
||||
if (head.byteLength >= 2 && head[0] === 0x1f && head[1] === 0x8b) {
|
||||
const gunzip = Readable.from(rest).pipe(createGunzip());
|
||||
try {
|
||||
for await (const chunk of gunzip) yield chunk as Buffer;
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) throw err;
|
||||
throw new UploadRefused(422, "Uploaded file is not valid gzip", {
|
||||
reason: "invalid_gzip",
|
||||
try {
|
||||
yield* decodeBackupUpload(source, { compressedLimit: limit });
|
||||
} catch (err) {
|
||||
if (err instanceof BackupUploadDecodeError) {
|
||||
throw new UploadRefused(err.status, err.message, {
|
||||
reason: err.reason,
|
||||
...(err.size !== undefined ? { size: err.size } : {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
yield* rest;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
@@ -259,6 +240,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
let firstWithoutId: number | null = null;
|
||||
const keys = new BackupKeyIdCollector();
|
||||
let scanned;
|
||||
try {
|
||||
scanned = await scanBackupJson(storedAsRead(), {
|
||||
@@ -275,6 +257,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!parsed.data.id && firstWithoutId === null) {
|
||||
firstWithoutId = index;
|
||||
}
|
||||
keys.visit(element, "measurements");
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -343,6 +326,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
// worse, a restore on a host that had lost the key would write rows
|
||||
// nobody can open. Refused here, where the operator is looking, with
|
||||
// the key named. The instance settings are left to the restore, which
|
||||
// checks them only when they are asked for back.
|
||||
keys.visit(scanned.document);
|
||||
const keyVerdict = assessBackupKeys(keys, {
|
||||
ignoreSections: new Set(["appSettings"]),
|
||||
});
|
||||
const keyProblem = describeBackupKeyProblem(keyVerdict);
|
||||
if (keyProblem) {
|
||||
const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
keyProblem,
|
||||
{ reason: "key_missing", keyIds },
|
||||
{ errorCode: BACKUP_KEY_MISSING_CODE, keyIds },
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -19,6 +19,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
@@ -100,6 +104,100 @@ async function readRotationState(): Promise<RotationState> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which keys the backups still need.
|
||||
*
|
||||
* Rotation re-encrypts every row in the database and re-seals every stored
|
||||
* backup's envelope, but the content INSIDE a backup keeps the key it was
|
||||
* written under: a restore writes it back verbatim. So "every row is on the
|
||||
* active key" is not the same as "the old key can go". This answers the second
|
||||
* question per key id, from what each copy recorded as it was written: the
|
||||
* stored copies that need it and the oldest of them, and for the off-host
|
||||
* bucket the last night an object needing it went in, plus how long the
|
||||
* bucket's lifecycle rule keeps objects after that.
|
||||
*/
|
||||
export interface BackupKeyNeeds {
|
||||
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
|
||||
/** Copies written before v1.39.3, which recorded no key ids. */
|
||||
unrecorded: { copies: number; oldestAt: string | null };
|
||||
offhost: Array<{
|
||||
keyId: string;
|
||||
firstWrittenAt: string;
|
||||
lastWrittenAt: string;
|
||||
/** When the last object needing this key expires; null when unknown. */
|
||||
neededUntil: string | null;
|
||||
}>;
|
||||
/** The bucket's expiry, when it has one the credential may read. */
|
||||
offhostExpirationDays: number | null;
|
||||
/** Retired key ids some backup still needs (never the active one). */
|
||||
retiredKeysStillNeeded: string[];
|
||||
}
|
||||
|
||||
async function readBackupKeyNeeds(
|
||||
activeKeyId: string,
|
||||
now: Date,
|
||||
): Promise<BackupKeyNeeds> {
|
||||
const [stored, unrecorded, offhostRows] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
Array<{ key_id: string; copies: number; oldest_at: Date }>
|
||||
>`
|
||||
SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at
|
||||
FROM data_backups, unnest(inner_key_ids) AS k
|
||||
WHERE inner_key_ids_recorded
|
||||
GROUP BY k
|
||||
ORDER BY k
|
||||
`,
|
||||
prisma.dataBackup.aggregate({
|
||||
where: { innerKeyIdsRecorded: false },
|
||||
_count: { _all: true },
|
||||
_min: { createdAt: true },
|
||||
}),
|
||||
prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }),
|
||||
]);
|
||||
const lifecycle =
|
||||
offhostRows.length > 0 && offhostBackupConfigured()
|
||||
? await probeOffhostLifecycle()
|
||||
: null;
|
||||
const expirationDays =
|
||||
lifecycle?.state === "configured" ? lifecycle.expirationDays : null;
|
||||
|
||||
const offhost = offhostRows.map((row) => ({
|
||||
keyId: row.keyId,
|
||||
firstWrittenAt: row.firstWrittenAt.toISOString(),
|
||||
lastWrittenAt: row.lastWrittenAt.toISOString(),
|
||||
neededUntil:
|
||||
expirationDays === null
|
||||
? null
|
||||
: new Date(
|
||||
row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
const needed = new Set<string>();
|
||||
for (const row of stored) needed.add(row.key_id);
|
||||
for (const row of offhost) {
|
||||
if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) {
|
||||
needed.add(row.keyId);
|
||||
}
|
||||
}
|
||||
needed.delete(activeKeyId);
|
||||
|
||||
return {
|
||||
stored: stored.map((row) => ({
|
||||
keyId: row.key_id,
|
||||
copies: row.copies,
|
||||
oldestAt: row.oldest_at.toISOString(),
|
||||
})),
|
||||
unrecorded: {
|
||||
copies: unrecorded._count._all,
|
||||
oldestAt: unrecorded._min.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
offhost,
|
||||
offhostExpirationDays: expirationDays,
|
||||
retiredKeysStillNeeded: [...needed].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
@@ -114,6 +212,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const rotation = await readRotationState();
|
||||
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -133,5 +232,14 @@ export const GET = apiHandler(async () => {
|
||||
staleRows: scan.staleRows,
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
backups,
|
||||
// Every row rotated AND no backup still needs a retired key: the one
|
||||
// state in which dropping an old key loses nothing. Copies that recorded
|
||||
// no key ids count against it, because they may hold any key that
|
||||
// existed when they were written.
|
||||
safeToDropRetiredKeys:
|
||||
scan.rotationComplete &&
|
||||
backups.retiredKeysStillNeeded.length === 0 &&
|
||||
backups.unrecorded.copies === 0,
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runningImportFindFirst: vi.fn(async () => null),
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
|
||||
getGlobalBoss: vi.fn(),
|
||||
@@ -60,6 +61,16 @@ vi.mock("@/lib/db", () => ({
|
||||
findFirst: mocks.importJobFindFirst,
|
||||
update: mocks.importJobUpdate,
|
||||
},
|
||||
// The one-running-import check and the new row, in one transaction.
|
||||
// The check finds nothing running unless a test says otherwise.
|
||||
$transaction: async (fn: (tx: unknown) => unknown) =>
|
||||
fn({
|
||||
$executeRaw: async () => 0,
|
||||
importJob: {
|
||||
findFirst: mocks.runningImportFindFirst,
|
||||
create: mocks.importJobCreate,
|
||||
},
|
||||
}),
|
||||
user: {
|
||||
findUnique: mocks.userFindUnique,
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import {
|
||||
createImportJobUnlessBusy,
|
||||
discardStagedUpload,
|
||||
IMPORT_BUSY_CODE,
|
||||
} from "@/lib/import/apple-health-staging";
|
||||
import { unlink } from "node:fs/promises";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -96,6 +101,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "missing_user_id" },
|
||||
});
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
return apiError("Multipart field 'userId' is required", 422);
|
||||
}
|
||||
|
||||
@@ -109,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "target_user_missing", targetUserId },
|
||||
});
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
return apiError(`Target user '${targetUserId}' does not exist`, 422);
|
||||
}
|
||||
|
||||
@@ -149,19 +156,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "worker_not_running" },
|
||||
});
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
throw new HttpError(503, "Background worker is not running");
|
||||
}
|
||||
|
||||
const importJob = await prisma.importJob.create({
|
||||
data: {
|
||||
userId: targetUser.id,
|
||||
triggeredByAdminId: admin.id,
|
||||
status: "queued",
|
||||
uploadBytes: uploaded.bytes,
|
||||
uploadSha256: uploaded.sha256,
|
||||
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
|
||||
},
|
||||
const staged = await createImportJobUnlessBusy(prisma, {
|
||||
userId: targetUser.id,
|
||||
triggeredByAdminId: admin.id,
|
||||
status: "queued",
|
||||
uploadBytes: uploaded.bytes,
|
||||
uploadSha256: uploaded.sha256,
|
||||
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
|
||||
});
|
||||
if ("busy" in staged) {
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
return apiError(
|
||||
"An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
|
||||
409,
|
||||
{ errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
|
||||
);
|
||||
}
|
||||
const importJob = staged.created;
|
||||
|
||||
const payload: AppleHealthImportPayload = {
|
||||
userId: targetUser.id,
|
||||
@@ -178,6 +193,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
payload,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
);
|
||||
if (!bossJobId) {
|
||||
await prisma.importJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: {
|
||||
status: "failed",
|
||||
failureReason:
|
||||
"The import could not be queued. Upload the export again.",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
throw new HttpError(503, "The import could not be queued");
|
||||
}
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: importJob.id },
|
||||
|
||||
Reference in new issue
Block a user