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 }); }