From 8f58ba4cdc496fde6c2cdf15272075b5441f873a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:54:33 +0100 Subject: [PATCH] feat(core): attachments and credential encryption Co-Authored-By: Claude Opus 5.5 --- docs/TODO.md | 1 + packages/core/src/attachments.ts | 140 +++++++++++++++++++++++++ packages/core/src/crypto.ts | 45 ++++++++ packages/core/test/attachments.test.ts | 108 +++++++++++++++++++ packages/core/test/crypto.test.ts | 26 +++++ 5 files changed, 320 insertions(+) create mode 100644 packages/core/src/attachments.ts create mode 100644 packages/core/src/crypto.ts create mode 100644 packages/core/test/attachments.test.ts create mode 100644 packages/core/test/crypto.test.ts diff --git a/docs/TODO.md b/docs/TODO.md index d737840..290ba2b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -9,6 +9,7 @@ Small, specific, independently actionable. Remove only when actually done. ## Next - [ ] Phase 2 and Phase 3 plans (spec ยง12). +- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS). ## Someday diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts new file mode 100644 index 0000000..1c53f00 --- /dev/null +++ b/packages/core/src/attachments.ts @@ -0,0 +1,140 @@ +import { mkdir, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import type { AttachmentEntity, AttachmentsTable } from "@sttracker/db/schema"; +import { ValidationError } from "./errors.ts"; + +export type Attachment = AttachmentsTable; + +const MAX_BYTES = 25 * 1024 * 1024; + +function mimeAllowed(mime: string): boolean { + return mime.startsWith("image/") || mime === "application/pdf" || mime === "text/plain"; +} + +function pathFor(dataDir: string, sha: string): string { + return join(dataDir, "attachments", sha.slice(0, 2), sha.slice(2, 4), sha); +} + +/** + * Store a file content-addressed by sha256 (deduplicated on disk) and link it to an entity. + * Each call creates a new attachment row and link; identical bytes share one file. + */ +export async function storeAttachment( + db: Db, + dataDir: string, + userId: string, + file: { bytes: Uint8Array; mime: string; filename: string; caption?: string; takenAt?: string }, + link: { entity: AttachmentEntity; entityId: string }, +): Promise { + if (!mimeAllowed(file.mime)) throw new ValidationError("mime", "unsupported file type"); + 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 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 = { + id: newId(), + user_id: userId, + sha256, + mime: file.mime, + size: file.bytes.byteLength, + filename: file.filename, + caption: file.caption ?? null, + taken_at: file.takenAt ?? null, + visibility: null, + created_at: nowIso(), + }; + 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; +} + +/** Open an attachment's file. Returns null when it does not exist or belongs to another user. */ +export async function readAttachment( + db: Db, + dataDir: string, + userId: string, + id: string, +): Promise<{ file: ReturnType; mime: string } | null> { + const row = await db.k + .selectFrom("attachments") + .select(["sha256", "mime"]) + .where("id", "=", id) + .where("user_id", "=", userId) + .executeTakeFirst(); + if (!row) return null; + return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime }; +} + +/** Attachments of `userId` linked to the given entity, oldest first. */ +export async function listAttachments( + db: Db, + userId: string, + entity: AttachmentEntity, + entityId: string, +): Promise { + return db.k + .selectFrom("attachments") + .innerJoin("attachment_links", "attachment_links.attachment_id", "attachments.id") + .selectAll("attachments") + .where("attachments.user_id", "=", userId) + .where("attachment_links.entity", "=", entity) + .where("attachment_links.entity_id", "=", entityId) + .orderBy("attachments.created_at") + .orderBy("attachments.id") + .execute(); +} + +/** + * Remove an attachment's link to an entity. When no links remain the attachment row is + * deleted, and the file too if no other row references its content. No-op for other users. + */ +export async function unlinkAttachment( + db: Db, + dataDir: string, + userId: string, + attachmentId: string, + entity: AttachmentEntity, + entityId: string, +): Promise { + const orphanSha = await db.k.transaction().execute(async (tx) => { + const att = await tx + .selectFrom("attachments") + .select("sha256") + .where("id", "=", attachmentId) + .where("user_id", "=", userId) + .executeTakeFirst(); + if (!att) return null; + await tx + .deleteFrom("attachment_links") + .where("attachment_id", "=", attachmentId) + .where("entity", "=", entity) + .where("entity_id", "=", entityId) + .execute(); + const left = await tx + .selectFrom("attachment_links") + .select("id") + .where("attachment_id", "=", attachmentId) + .executeTakeFirst(); + if (left) return null; + await tx.deleteFrom("attachments").where("id", "=", attachmentId).execute(); + const shared = await tx + .selectFrom("attachments") + .select("id") + .where("sha256", "=", att.sha256) + .executeTakeFirst(); + return shared ? null : att.sha256; + }); + if (orphanSha) await rm(pathFor(dataDir, orphanSha), { force: true }); +} diff --git a/packages/core/src/crypto.ts b/packages/core/src/crypto.ts new file mode 100644 index 0000000..604bc46 --- /dev/null +++ b/packages/core/src/crypto.ts @@ -0,0 +1,45 @@ +import { CryptoError } from "./errors.ts"; + +const SALT = new TextEncoder().encode("sttracker-v1"); +const INFO = new TextEncoder().encode("sttracker-credentials"); + +function b64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +async function deriveKey(secret: string): Promise { + const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), "HKDF", false, [ + "deriveKey", + ]); + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: SALT, info: INFO }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +/** Encrypt a JSON value with AES-256-GCM. Returns `v1..`. */ +export async function encryptJson(secret: string, value: unknown): Promise { + const key = await deriveKey(secret); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plain = new TextEncoder().encode(JSON.stringify(value)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain)); + return `v1.${b64(iv)}.${b64(ct)}`; +} + +/** Decrypt a blob from {@link encryptJson}. Throws `CryptoError` on a wrong secret or bad blob. */ +export async function decryptJson(secret: string, blob: string): Promise { + const parts = blob.split("."); + if (parts.length !== 3 || parts[0] !== "v1") throw new CryptoError("malformed credential blob"); + try { + const iv = new Uint8Array(Buffer.from(parts[1]!, "base64")); + const ct = new Uint8Array(Buffer.from(parts[2]!, "base64")); + const key = await deriveKey(secret); + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); + return JSON.parse(new TextDecoder().decode(plain)) as T; + } catch { + throw new CryptoError("decryption failed"); + } +} diff --git a/packages/core/test/attachments.test.ts b/packages/core/test/attachments.test.ts new file mode 100644 index 0000000..3acf6ac --- /dev/null +++ b/packages/core/test/attachments.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { forEachDialect } from "@sttracker/db/testing"; +import type { Db } from "@sttracker/db/db"; +import { + listAttachments, + readAttachment, + storeAttachment, + unlinkAttachment, +} from "../src/attachments.ts"; +import { ValidationError } from "../src/errors.ts"; + +async function addUser(db: Db, username: string): Promise { + const id = newId(); + const now = nowIso(); + await db.k + .insertInto("users") + .values({ + id, + username, + display_name: username, + password_hash: "x", + role: "member", + preferences: "{}", + created_at: now, + updated_at: now, + }) + .execute(); + return id; +} + +async function filesUnder(dir: string): Promise { + const all = await readdir(dir, { recursive: true, withFileTypes: true }); + return all.filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name)); +} + +forEachDialect("attachments", (getDb) => { + let dataDir: string; + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "sttracker-att-")); + }); + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + const png = (s: string) => ({ + bytes: new TextEncoder().encode(s), + mime: "image/png", + filename: "a.png", + }); + + test("same bytes twice: one file, two links", async () => { + const db = getDb(); + const u = await addUser(db, "u1"); + const a = await storeAttachment(db, dataDir, u, png("hello"), { entity: "work", entityId: "w1" }); + const b = await storeAttachment(db, dataDir, u, png("hello"), { entity: "entry", entityId: "e1" }); + expect(a.sha256).toBe(b.sha256); + expect(a.id).not.toBe(b.id); + const files = await filesUnder(join(dataDir, "attachments")); + expect(files).toHaveLength(1); + expect(files[0]).toBe( + join(dataDir, "attachments", a.sha256.slice(0, 2), a.sha256.slice(2, 4), a.sha256), + ); + const links = await db.k.selectFrom("attachment_links").selectAll().execute(); + expect(links).toHaveLength(2); + expect(await listAttachments(db, u, "work", "w1")).toHaveLength(1); + }); + + test("rejects disallowed mime and oversize", async () => { + const db = getDb(); + const u = await addUser(db, "u1"); + const link = { entity: "work", entityId: "w1" } as const; + await expect( + storeAttachment(db, dataDir, u, { ...png("x"), mime: "application/x-msdownload", filename: "a.exe" }, link), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + storeAttachment(db, dataDir, u, { ...png("x"), bytes: new Uint8Array(25 * 1024 * 1024 + 1) }, link), + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("reads own file; another user gets null", async () => { + const db = getDb(); + const u1 = await addUser(db, "u1"); + const u2 = await addUser(db, "u2"); + const a = await storeAttachment(db, dataDir, u1, png("secret"), { entity: "work", entityId: "w1" }); + const r = await readAttachment(db, dataDir, u1, a.id); + expect(r?.mime).toBe("image/png"); + expect(await r?.file.text()).toBe("secret"); + expect(await readAttachment(db, dataDir, u2, a.id)).toBeNull(); + expect(await readAttachment(db, dataDir, u1, "nope")).toBeNull(); + }); + + test("unlink removes link, orphan row and unshared file", async () => { + const db = getDb(); + const u = await addUser(db, "u1"); + const a = await storeAttachment(db, dataDir, u, png("bye"), { entity: "work", entityId: "w1" }); + const b = await storeAttachment(db, dataDir, u, png("bye"), { entity: "work", entityId: "w2" }); + await unlinkAttachment(db, dataDir, u, a.id, "work", "w1"); + expect(await listAttachments(db, u, "work", "w1")).toHaveLength(0); + expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1); + await unlinkAttachment(db, dataDir, u, b.id, "work", "w2"); + expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(0); + expect(await db.k.selectFrom("attachments").selectAll().execute()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/crypto.test.ts b/packages/core/test/crypto.test.ts new file mode 100644 index 0000000..6102f7f --- /dev/null +++ b/packages/core/test/crypto.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { decryptJson, encryptJson } from "../src/crypto.ts"; +import { CryptoError } from "../src/errors.ts"; + +describe("crypto", () => { + test("round-trips JSON", async () => { + const value = { token: "abc", n: [1, 2, { x: null }] }; + const blob = await encryptJson("s3cret", value); + expect(blob).toMatch(/^v1\.[A-Za-z0-9+/=]+\.[A-Za-z0-9+/=]+$/); + expect(await decryptJson("s3cret", blob)).toEqual(value); + }); + + test("uses a fresh IV each time", async () => { + expect(await encryptJson("k", 1)).not.toBe(await encryptJson("k", 1)); + }); + + test("wrong secret throws CryptoError", async () => { + const blob = await encryptJson("right", { a: 1 }); + await expect(decryptJson("wrong", blob)).rejects.toBeInstanceOf(CryptoError); + }); + + test("malformed blob throws CryptoError", async () => { + await expect(decryptJson("k", "garbage")).rejects.toBeInstanceOf(CryptoError); + await expect(decryptJson("k", "v2.AA==.AA==")).rejects.toBeInstanceOf(CryptoError); + }); +});