mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(import): bound what an export may inflate to, allow one import per account, and never leave the upload behind
The extractor held the inflated export.xml to a flat 8 GiB whatever the member occupied, and the header ratio check reads sizes the archive writes itself: a member declaring an honest ratio could inflate at 1000:1, so an 8 MB upload could fill the temp directory. The streamed byte count is now held to 200 times the member's compressed size on disk (never less than 64 MiB, never more than 8 GiB), and a stored member may not grow at all. An account may have one Apple Health import queued or running. A second upload with different bytes is refused with 409 import.apple_health.busy and the running job's id; the check and the new row are one transaction under an advisory lock. The staged upload is the person's export in plain text. It is now removed on every exit that leaves no worker to read it: a refused or broken multipart body, no worker running, a queue that takes no job (the row is failed as well), the admin route's missing or unknown target account, and an account deleted before the worker reached it. The worker removes the files before recording a failure, so a row that went with a deleted account no longer strands them. The 15-minute import reconcile pass also sweeps staged files older than the longest an import may run.
This commit is contained in:
1 parent
1c7785c23b
commit
1b4594e69f
16 files changed
+549
-57
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, unlinkSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
|
||||
@@ -112,6 +114,29 @@ describe("streamMultipartToDisk", () => {
|
||||
).rejects.toThrow(/size cap/);
|
||||
});
|
||||
|
||||
// What was written before the refusal is the person's export in plain
|
||||
// text; it must not stay behind in the temp directory.
|
||||
it("removes the partial file when the upload is refused part-way", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "healthlog-stream-"));
|
||||
const body = buildMultipartBody("export.zip", Buffer.alloc(4096, 0x41));
|
||||
const chunked = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < body.length; i += 256) {
|
||||
controller.enqueue(new Uint8Array(body.subarray(i, i + 256)));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
streamMultipartToDisk(
|
||||
chunked,
|
||||
`multipart/form-data; boundary=${BOUNDARY}`,
|
||||
{ maxBytes: 1000, fieldName: "file", tmpDir: dir },
|
||||
),
|
||||
).rejects.toThrow(/size cap/);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws when the named field is missing", async () => {
|
||||
const body = buildMultipartBody("export.zip", Buffer.from("x"), {
|
||||
something: "else",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
@@ -293,22 +294,41 @@ export async function streamMultipartToDisk(
|
||||
}
|
||||
};
|
||||
|
||||
while (!streamDone) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
streamDone = true;
|
||||
break;
|
||||
try {
|
||||
while (!streamDone) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
streamDone = true;
|
||||
break;
|
||||
}
|
||||
buffer = Buffer.concat([buffer, Buffer.from(value)]);
|
||||
await processBuffer();
|
||||
}
|
||||
buffer = Buffer.concat([buffer, Buffer.from(value)]);
|
||||
await processBuffer();
|
||||
}
|
||||
await processBuffer();
|
||||
await closeFileSink();
|
||||
await closeFileSink();
|
||||
|
||||
if (bytesWritten === 0) {
|
||||
throw new Error(
|
||||
`Multipart body did not include a '${opts.fieldName}' file field`,
|
||||
);
|
||||
if (bytesWritten === 0) {
|
||||
throw new Error(
|
||||
`Multipart body did not include a '${opts.fieldName}' file field`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Whatever was written so far is the user's plaintext health export,
|
||||
// and it must not outlive a refused upload in the shared temp
|
||||
// directory: the size cap, a malformed body and a dropped connection
|
||||
// all end here. The reader is cancelled so the rest of the body is not
|
||||
// read for nothing.
|
||||
await reader.cancel().catch(() => {});
|
||||
const sink = fileSink as Writable | null;
|
||||
if (sink && !sink.closed) {
|
||||
fileSinkOpen = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
sink.once("close", () => resolve());
|
||||
sink.destroy();
|
||||
});
|
||||
}
|
||||
await rm(filePath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user