mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the finish of the outbound-fetch migration. - /medications/[id] becomes a read-only history surface; every setting moves into a new advanced-settings sheet, and the edit trigger becomes a two-option picker. The list page is unchanged. - A pg-boss job folds pre-v1.5.0 granular step rows into one canonical daily total per user per day and soft-deletes the originals; migration 0087 adds a partial index for the converging discovery scan. - Every remaining server-side fetch routes through safeFetch (Withings, Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new healthlog/safe-fetch-required ESLint rule guards the boundary. - Fixes: intake-edit dialog seeded from real values, consolidation skips a unique-constraint day instead of stranding it, avatar upload reads the body once under a bounded cap, egress lint rejects absolute URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d54addd61a
commit
8528c3b4fe
54 files changed
+2343
-324
No files matched your search
@@ -89,15 +89,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastDetails = "";
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await safeFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
|
||||
@@ -159,7 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await fetch(
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -26,9 +26,16 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const response = await safeFetch(settings.umamiScriptUrl, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
const response = await safeFetch(
|
||||
settings.umamiScriptUrl,
|
||||
{
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
// Operator-configured host — pin the connect-time IP so a low-TTL
|
||||
// DNS record cannot rebind the fetch at an internal range between
|
||||
// the input-time `isPublicUrl` accept and the socket connect.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return new NextResponse(NOOP_SCRIPT, {
|
||||
status: 200,
|
||||
|
||||
@@ -74,16 +74,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastResponseStatus = 404;
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await safeFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
lastResponseStatus = upstream.status;
|
||||
|
||||
|
||||
@@ -146,6 +146,41 @@ describe("POST /api/user/avatar", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversize chunked upload with no Content-Length at the stream level (413)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
// Build a multipart body as a streamed ReadableStream with NO
|
||||
// Content-Length header — the shape a `Transfer-Encoding: chunked`
|
||||
// upload takes. The bounded stream reader must trip before the body
|
||||
// is fully buffered.
|
||||
const boundary = "----healthlogtest";
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 0x41);
|
||||
const parts = [head, oversize, tail];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const p of parts) {
|
||||
controller.enqueue(new Uint8Array(p));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const req = new Request("http://localhost/api/user/avatar", {
|
||||
method: "POST",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
body: stream,
|
||||
// Node's fetch requires `duplex: "half"` for a streamed body.
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Plain text content — no JPEG/PNG/WebP magic.
|
||||
|
||||
@@ -62,6 +62,67 @@ interface UploadResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Thrown by `readBoundedBody` when the stream exceeds the byte cap. */
|
||||
class BodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body exceeds the configured byte cap");
|
||||
this.name = "BodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a request body stream into a single buffer while counting bytes,
|
||||
* throwing `BodyTooLargeError` the moment the running total passes
|
||||
* `maxBytes`. Bounds the work at the stream level so a chunked /
|
||||
* unbounded upload cannot park past the cap even when no `Content-Length`
|
||||
* header is present. The captured bytes are retained (capped at
|
||||
* `maxBytes`) so the caller can reconstruct `FormData` from them — this
|
||||
* reads the body exactly once, with no clone and no second parser racing
|
||||
* the same body, so a cap trip frees the allocation immediately instead
|
||||
* of leaving a native parse buffering the rest of an oversized upload.
|
||||
*/
|
||||
async function readBoundedBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
maxBytes: number,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
if (!body) return new Uint8Array(0);
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let retained = 0;
|
||||
let overflow = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (overflow) continue;
|
||||
if (retained + value.byteLength > maxBytes) {
|
||||
// Past the cap. Drop everything retained so far and stop
|
||||
// retaining further chunks — memory stays bounded at the cap.
|
||||
// Keep draining the stream to its natural close rather than
|
||||
// cancelling it: an abrupt cancel mid-transfer races the
|
||||
// underlying (undici) producer, which then enqueues into a
|
||||
// closed controller and surfaces as an unhandled rejection.
|
||||
chunks.length = 0;
|
||||
retained = 0;
|
||||
overflow = true;
|
||||
continue;
|
||||
}
|
||||
chunks.push(value);
|
||||
retained += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (overflow) throw new BodyTooLargeError();
|
||||
const out = new Uint8Array(retained);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -94,10 +155,31 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Stream-level cap. The Content-Length pre-flight above only fires when
|
||||
// the header is present; a `Transfer-Encoding: chunked` upload (or any
|
||||
// client that omits the header) skips it, and `request.formData()`
|
||||
// would then buffer the whole stream before the post-parse `file.size`
|
||||
// check can run. Read the body ONCE through a bounded reader that
|
||||
// aborts on the first byte past AVATAR_MAX_BYTES, then reconstruct the
|
||||
// FormData from the captured bytes. A single read means the cap trip
|
||||
// frees the allocation immediately — no clone, no tee, and no native
|
||||
// parse left buffering the rest of an oversized body. The post-parse
|
||||
// `file.size` check stays in place as defence-in-depth.
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
const bytes = await readBoundedBody(request.body, AVATAR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
// Surface the most useful failure: a cap overflow always wins.
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "stream_size_exceeded" },
|
||||
});
|
||||
return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
|
||||
}
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
|
||||
Reference in new issue
Block a user