mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -106,6 +106,11 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
|
||||
import {
|
||||
acquireDocumentUploadSlot,
|
||||
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
|
||||
DOCUMENT_UPLOAD_PER_USER_CONCURRENCY,
|
||||
} from "@/lib/documents/upload-policy";
|
||||
|
||||
const post = POST as unknown as (r: Request) => Promise<Response>;
|
||||
const get = GET as unknown as (r: Request) => Promise<Response>;
|
||||
@@ -311,6 +316,96 @@ describe("POST /api/documents/inbound (vault upload)", () => {
|
||||
expect(res.status).toBe(404);
|
||||
expect(txCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects before reading the body when the user's upload slots are full", async () => {
|
||||
const releases = Array.from(
|
||||
{ length: DOCUMENT_UPLOAD_PER_USER_CONCURRENCY },
|
||||
() => acquireDocumentUploadSlot("user-1"),
|
||||
);
|
||||
let bodyRead = false;
|
||||
const unreadBody = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
pull(controller) {
|
||||
bodyRead = true;
|
||||
controller.error(new Error("body must not be read"));
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
const request = new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: unreadBody,
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
try {
|
||||
const res = await post(request);
|
||||
expect(res.status).toBe(429);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
meta: { reason: "uploadBusy" },
|
||||
});
|
||||
expect(bodyRead).toBe(false);
|
||||
expect(txCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
for (const release of releases) release!();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects before reading the body when process-wide upload slots are full", async () => {
|
||||
const releases = Array.from(
|
||||
{ length: DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY },
|
||||
(_, index) => acquireDocumentUploadSlot(`other-user-${index}`),
|
||||
);
|
||||
try {
|
||||
const res = await post(mkUpload());
|
||||
expect(res.status).toBe(429);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
meta: { reason: "uploadBusy" },
|
||||
});
|
||||
expect(txCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
for (const release of releases) release!();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases an upload slot after an invalid multipart body", async () => {
|
||||
const invalid = new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=incomplete" },
|
||||
body: "--incomplete\r\n",
|
||||
});
|
||||
const invalidResponse = await post(invalid);
|
||||
expect(invalidResponse.status).toBe(400);
|
||||
|
||||
const held = acquireDocumentUploadSlot("user-1");
|
||||
expect(held).not.toBeNull();
|
||||
try {
|
||||
const validResponse = await post(mkUpload());
|
||||
expect(validResponse.status).toBe(201);
|
||||
} finally {
|
||||
held!();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not make another whole-body Blob copy before multipart parsing", async () => {
|
||||
const request = mkUpload();
|
||||
const NativeBlob = globalThis.Blob;
|
||||
let blobConstructions = 0;
|
||||
class CountingBlob extends NativeBlob {
|
||||
constructor(parts?: BlobPart[], options?: BlobPropertyBag) {
|
||||
blobConstructions += 1;
|
||||
super(parts, options);
|
||||
}
|
||||
}
|
||||
globalThis.Blob = CountingBlob;
|
||||
try {
|
||||
const res = await post(request);
|
||||
expect(res.status).toBe(201);
|
||||
expect(blobConstructions).toBe(0);
|
||||
} finally {
|
||||
globalThis.Blob = NativeBlob;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound (list)", () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -45,6 +45,7 @@ import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
|
||||
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
|
||||
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
|
||||
import {
|
||||
acquireDocumentUploadSlot,
|
||||
detectDocumentType,
|
||||
resolveDocumentLimits,
|
||||
} from "@/lib/documents/upload-policy";
|
||||
@@ -76,6 +77,7 @@ const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
|
||||
* exact per-file cap is re-enforced on the extracted file bytes below.
|
||||
*/
|
||||
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
|
||||
const UPLOAD_BODY_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
|
||||
function isoDateToUtc(value: string): Date {
|
||||
@@ -140,10 +142,11 @@ async function duplicateResponse(
|
||||
);
|
||||
}
|
||||
|
||||
/** POST — store an uploaded document encrypted at rest. No extraction. */
|
||||
async function postUpload(request: Request): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
/** Process one admitted upload. The caller owns and releases its memory slot. */
|
||||
async function processUpload(
|
||||
request: Request,
|
||||
user: AuthContext["user"],
|
||||
): Promise<Response> {
|
||||
// Opt-in module gate — even a valid Bearer token is refused when the surface
|
||||
// is off (it ships dark; the user turns it on deliberately).
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
@@ -176,14 +179,25 @@ async function postUpload(request: Request): Promise<Response> {
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
const bytes = await readBoundedBody(request.body, bodyCap);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
const bytes = await readBoundedBody(request.body, bodyCap, {
|
||||
signal: request.signal,
|
||||
deadline: Date.now() + UPLOAD_BODY_TIMEOUT_MS,
|
||||
});
|
||||
formData = await new Request(request.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
body: bytes,
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
return fileTooLarge(limits.maxFileBytes);
|
||||
}
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return apiError("Upload body read timed out.", 408, {
|
||||
errorCode: "documents.inbound.uploadTimeout",
|
||||
reason: "uploadTimeout",
|
||||
});
|
||||
}
|
||||
return apiError("Invalid multipart body", 400);
|
||||
}
|
||||
|
||||
@@ -397,6 +411,30 @@ async function postUpload(request: Request): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Authenticate and reserve memory capacity before any request-body read. */
|
||||
async function postUpload(request: Request): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const releaseUploadSlot = acquireDocumentUploadSlot(user.id);
|
||||
if (!releaseUploadSlot) {
|
||||
const response = apiError(
|
||||
"Too many uploads are already in progress. Try again shortly.",
|
||||
429,
|
||||
{
|
||||
errorCode: "documents.inbound.uploadBusy",
|
||||
reason: "uploadBusy",
|
||||
},
|
||||
);
|
||||
response.headers.set("Retry-After", "1");
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
return await processUpload(request, user);
|
||||
} finally {
|
||||
releaseUploadSlot();
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
|
||||
|
||||
/** GET — list the caller's documents (search / filter / sort / paginate). */
|
||||
|
||||
Reference in new issue
Block a user