fix(core): serialise attachment file mutations, atomic writes, reject svg

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-30 19:26:16 +01:00
1 parent 8f58ba4cdc
commit 4acb368c87
4 files changed
+157 -43

No files matched your search

+77 -41
View File
@@ -1,4 +1,4 @@
import { mkdir, rm } from "node:fs/promises"; import { mkdir, rename, rm } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import type { Db } from "@sttracker/db/db"; import type { Db } from "@sttracker/db/db";
import { newId, nowIso } from "@sttracker/db/ids"; import { newId, nowIso } from "@sttracker/db/ids";
@@ -9,10 +9,34 @@ export type Attachment = AttachmentsTable;
const MAX_BYTES = 25 * 1024 * 1024; const MAX_BYTES = 25 * 1024 * 1024;
/** Lowercase and strip parameters (`; charset=...`). */
function normalizeMime(mime: string): string {
return (mime.split(";")[0] ?? "").trim().toLowerCase();
}
function mimeAllowed(mime: string): boolean { function mimeAllowed(mime: string): boolean {
if (mime === "image/svg+xml") return false; // scriptable; never serve user SVG
return mime.startsWith("image/") || mime === "application/pdf" || mime === "text/plain"; return mime.startsWith("image/") || mime === "application/pdf" || mime === "text/plain";
} }
const locks = new Map<string, Promise<void>>();
/** Serialise file mutations per sha within this process. */
async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const run = prev.then(fn, fn);
const tail = run.then(
() => undefined,
() => undefined,
);
locks.set(key, tail);
try {
return await run;
} finally {
if (locks.get(key) === tail) locks.delete(key);
}
}
function pathFor(dataDir: string, sha: string): string { function pathFor(dataDir: string, sha: string): string {
return join(dataDir, "attachments", sha.slice(0, 2), sha.slice(2, 4), sha); return join(dataDir, "attachments", sha.slice(0, 2), sha.slice(2, 4), sha);
} }
@@ -28,21 +52,18 @@ export async function storeAttachment(
file: { bytes: Uint8Array; mime: string; filename: string; caption?: string; takenAt?: string }, file: { bytes: Uint8Array; mime: string; filename: string; caption?: string; takenAt?: string },
link: { entity: AttachmentEntity; entityId: string }, link: { entity: AttachmentEntity; entityId: string },
): Promise<Attachment> { ): Promise<Attachment> {
if (!mimeAllowed(file.mime)) throw new ValidationError("mime", "unsupported file type"); const mime = normalizeMime(file.mime);
if (!mimeAllowed(mime)) throw new ValidationError("mime", "unsupported file type");
if (file.bytes.byteLength > MAX_BYTES) throw new ValidationError("file", "larger than 25 MB"); if (file.bytes.byteLength > MAX_BYTES) throw new ValidationError("file", "larger than 25 MB");
const sha256 = new Bun.CryptoHasher("sha256").update(file.bytes).digest("hex"); const sha256 = new Bun.CryptoHasher("sha256").update(file.bytes).digest("hex");
const path = pathFor(dataDir, sha256); const path = pathFor(dataDir, sha256);
if (!(await Bun.file(path).exists())) {
await mkdir(dirname(path), { recursive: true });
await Bun.write(path, file.bytes);
}
const row: Attachment = { const row: Attachment = {
id: newId(), id: newId(),
user_id: userId, user_id: userId,
sha256, sha256,
mime: file.mime, mime,
size: file.bytes.byteLength, size: file.bytes.byteLength,
filename: file.filename, filename: file.filename,
caption: file.caption ?? null, caption: file.caption ?? null,
@@ -50,12 +71,25 @@ export async function storeAttachment(
visibility: null, visibility: null,
created_at: nowIso(), created_at: nowIso(),
}; };
await db.k.transaction().execute(async (tx) => { await withLock(sha256, async () => {
await tx.insertInto("attachments").values(row).execute(); if (!(await Bun.file(path).exists())) {
await tx await mkdir(dirname(path), { recursive: true });
.insertInto("attachment_links") const tmp = `${path}.tmp-${newId()}`;
.values({ id: newId(), attachment_id: row.id, entity: link.entity, entity_id: link.entityId }) try {
.execute(); await Bun.write(tmp, file.bytes);
await rename(tmp, path);
} catch (e) {
await rm(tmp, { force: true });
throw e;
}
}
await db.k.transaction().execute(async (tx) => {
await tx.insertInto("attachments").values(row).execute();
await tx
.insertInto("attachment_links")
.values({ id: newId(), attachment_id: row.id, entity: link.entity, entity_id: link.entityId })
.execute();
});
}); });
return row; return row;
} }
@@ -108,33 +142,35 @@ export async function unlinkAttachment(
entity: AttachmentEntity, entity: AttachmentEntity,
entityId: string, entityId: string,
): Promise<void> { ): Promise<void> {
const orphanSha = await db.k.transaction().execute(async (tx) => { const att = await db.k
const att = await tx .selectFrom("attachments")
.selectFrom("attachments") .select("sha256")
.select("sha256") .where("id", "=", attachmentId)
.where("id", "=", attachmentId) .where("user_id", "=", userId)
.where("user_id", "=", userId) .executeTakeFirst();
.executeTakeFirst(); if (!att) return;
if (!att) return null; await withLock(att.sha256, async () => {
await tx const orphan = await db.k.transaction().execute(async (tx) => {
.deleteFrom("attachment_links") await tx
.where("attachment_id", "=", attachmentId) .deleteFrom("attachment_links")
.where("entity", "=", entity) .where("attachment_id", "=", attachmentId)
.where("entity_id", "=", entityId) .where("entity", "=", entity)
.execute(); .where("entity_id", "=", entityId)
const left = await tx .execute();
.selectFrom("attachment_links") const left = await tx
.select("id") .selectFrom("attachment_links")
.where("attachment_id", "=", attachmentId) .select("id")
.executeTakeFirst(); .where("attachment_id", "=", attachmentId)
if (left) return null; .executeTakeFirst();
await tx.deleteFrom("attachments").where("id", "=", attachmentId).execute(); if (left) return false;
const shared = await tx await tx.deleteFrom("attachments").where("id", "=", attachmentId).execute();
.selectFrom("attachments") const shared = await tx
.select("id") .selectFrom("attachments")
.where("sha256", "=", att.sha256) .select("id")
.executeTakeFirst(); .where("sha256", "=", att.sha256)
return shared ? null : att.sha256; .executeTakeFirst();
return !shared;
});
if (orphan) await rm(pathFor(dataDir, att.sha256), { force: true });
}); });
if (orphanSha) await rm(pathFor(dataDir, orphanSha), { force: true });
} }
+2 -2
View File
@@ -39,7 +39,7 @@ export async function decryptJson<T>(secret: string, blob: string): Promise<T> {
const key = await deriveKey(secret); const key = await deriveKey(secret);
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return JSON.parse(new TextDecoder().decode(plain)) as T; return JSON.parse(new TextDecoder().decode(plain)) as T;
} catch { } catch (cause) {
throw new CryptoError("decryption failed"); throw new CryptoError("decryption failed", { cause });
} }
} }
+65
View File
@@ -105,4 +105,69 @@ forEachDialect("attachments", (getDb) => {
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(0); expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(0);
expect(await db.k.selectFrom("attachments").selectAll().execute()).toHaveLength(0); expect(await db.k.selectFrom("attachments").selectAll().execute()).toHaveLength(0);
}); });
test("rejects svg and normalizes mime", async () => {
const db = getDb();
const u = await addUser(db, "u1");
const link = { entity: "work", entityId: "w1" } as const;
for (const mime of ["image/svg+xml", "IMAGE/SVG+XML; charset=utf-8"]) {
await expect(storeAttachment(db, dataDir, u, { ...png("x"), mime }, link)).rejects.toBeInstanceOf(
ValidationError,
);
}
const a = await storeAttachment(db, dataDir, u, { ...png("x"), mime: "Image/PNG; foo=bar" }, link);
expect(a.mime).toBe("image/png");
expect((await listAttachments(db, u, "work", "w1"))[0]?.mime).toBe("image/png");
});
test("shared bytes across users survive the other's unlink", async () => {
const db = getDb();
const ua = await addUser(db, "a");
const ub = await addUser(db, "b");
const a = await storeAttachment(db, dataDir, ua, png("same"), { entity: "work", entityId: "w1" });
const b = await storeAttachment(db, dataDir, ub, png("same"), { entity: "work", entityId: "w1" });
await unlinkAttachment(db, dataDir, ua, a.id, "work", "w1");
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
const r = await readAttachment(db, dataDir, ub, b.id);
expect(await r?.file.text()).toBe("same");
});
test("another user's unlink is a no-op", async () => {
const db = getDb();
const ua = await addUser(db, "a");
const ub = await addUser(db, "b");
const a = await storeAttachment(db, dataDir, ua, png("mine"), { entity: "work", entityId: "w1" });
await unlinkAttachment(db, dataDir, ub, a.id, "work", "w1");
expect(await listAttachments(db, ua, "work", "w1")).toHaveLength(1);
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
});
test("listAttachments is scoped to the user", async () => {
const db = getDb();
const ua = await addUser(db, "a");
const ub = await addUser(db, "b");
await storeAttachment(db, dataDir, ua, png("one"), { entity: "work", entityId: "w1" });
expect(await listAttachments(db, ub, "work", "w1")).toHaveLength(0);
expect(await listAttachments(db, ua, "work", "w1")).toHaveLength(1);
});
test("concurrent store and unlink never leave a row without its file", async () => {
const db = getDb();
const u = await addUser(db, "u1");
const first = await storeAttachment(db, dataDir, u, png("race"), { entity: "work", entityId: "w1" });
const [, second] = await Promise.all([
unlinkAttachment(db, dataDir, u, first.id, "work", "w1"),
storeAttachment(db, dataDir, u, png("race"), { entity: "work", entityId: "w2" }),
]);
const r = await readAttachment(db, dataDir, u, second.id);
expect(await r?.file.text()).toBe("race");
});
test("leaves no temp files behind", async () => {
const db = getDb();
const u = await addUser(db, "u1");
await storeAttachment(db, dataDir, u, png("clean"), { entity: "work", entityId: "w1" });
const files = await filesUnder(join(dataDir, "attachments"));
expect(files.some((f) => f.includes(".tmp-"))).toBe(false);
});
}); });
+13
View File
@@ -23,4 +23,17 @@ describe("crypto", () => {
await expect(decryptJson("k", "garbage")).rejects.toBeInstanceOf(CryptoError); await expect(decryptJson("k", "garbage")).rejects.toBeInstanceOf(CryptoError);
await expect(decryptJson("k", "v2.AA==.AA==")).rejects.toBeInstanceOf(CryptoError); await expect(decryptJson("k", "v2.AA==.AA==")).rejects.toBeInstanceOf(CryptoError);
}); });
test("IV decodes to 12 bytes", async () => {
const blob = await encryptJson("k", { a: 1 });
expect(Buffer.from(blob.split(".")[1]!, "base64")).toHaveLength(12);
});
test("flipping a ciphertext byte throws CryptoError", async () => {
const [v, iv, ct] = (await encryptJson("k", { a: 1 })).split(".");
const bytes = Buffer.from(ct!, "base64");
bytes[0] = bytes[0]! ^ 0xff;
const tampered = `${v}.${iv}.${bytes.toString("base64")}`;
await expect(decryptJson("k", tampered)).rejects.toBeInstanceOf(CryptoError);
});
}); });