feat(core): attachments and credential encryption
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d392a65aa7
commit
8f58ba4cdc
@@ -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
|
||||
|
||||
|
||||
@@ -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<Attachment> {
|
||||
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<typeof Bun.file>; 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<Attachment[]> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<CryptoKey> {
|
||||
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.<b64 iv>.<b64 ciphertext>`. */
|
||||
export async function encryptJson(secret: string, value: unknown): Promise<string> {
|
||||
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<T>(secret: string, blob: string): Promise<T> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user