Files
STTracker/packages/core/src/attachments.ts
T

264 lines
9.1 KiB
TypeScript

import { mkdir, rename, 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 { NotFoundError, ValidationError } from "./errors.ts";
export type Attachment = AttachmentsTable;
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
/** Lowercase and strip parameters (`; charset=...`). */
export function normalizeMime(mime: string): string {
return (mime.split(";")[0] ?? "").trim().toLowerCase();
}
export 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";
}
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 {
return join(dataDir, "attachments", sha.slice(0, 2), sha.slice(2, 4), sha);
}
export function sha256Hex(bytes: Uint8Array): string {
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
}
/**
* Write bytes to the content-addressed store (idempotent, deduplicated) and return their sha256.
* Callers that insert rows afterwards should call it again after committing, since an unlink of
* identical bytes in between may have removed the file.
*/
export async function writeAttachmentFile(dataDir: string, bytes: Uint8Array): Promise<string> {
const sha256 = sha256Hex(bytes);
const path = pathFor(dataDir, sha256);
await withLock(sha256, async () => {
if (await Bun.file(path).exists()) return;
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${newId()}`;
try {
await Bun.write(tmp, bytes);
await rename(tmp, path);
} catch (e) {
await rm(tmp, { force: true });
throw e;
}
});
return sha256;
}
/**
* 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> {
const mime = normalizeMime(file.mime);
if (!mimeAllowed(mime)) throw new ValidationError("mime", "unsupported file type");
if (file.bytes.byteLength > MAX_ATTACHMENT_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);
const row: Attachment = {
id: newId(),
user_id: userId,
sha256,
mime,
size: file.bytes.byteLength,
filename: file.filename,
caption: file.caption ?? null,
taken_at: file.takenAt ?? null,
visibility: null,
created_at: nowIso(),
};
await withLock(sha256, async () => {
if (!(await Bun.file(path).exists())) {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${newId()}`;
try {
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;
}
/** 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; filename: string } | null> {
const row = await db.k
.selectFrom("attachments")
.select(["sha256", "mime", "filename"])
.where("id", "=", id)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!row) return null;
return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime, filename: row.filename };
}
/** 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 att = await db.k
.selectFrom("attachments")
.select("sha256")
.where("id", "=", attachmentId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!att) return;
await withLock(att.sha256, async () => {
const orphan = await db.k.transaction().execute(async (tx) => {
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 false;
await tx.deleteFrom("attachments").where("id", "=", attachmentId).execute();
const shared = await tx
.selectFrom("attachments")
.select("id")
.where("sha256", "=", att.sha256)
.executeTakeFirst();
return !shared;
});
if (orphan) await rm(pathFor(dataDir, att.sha256), { force: true });
});
}
const LINK_ENTITIES: readonly AttachmentEntity[] = ["entry", "engagement", "copy", "list", "work"];
/**
* Throw `NotFoundError` unless `entityId` is a live `entity` the user may attach to: their own entry,
* engagement, copy or list, or a work that is shared or theirs. Call before `storeAttachment`.
*/
export async function requireLinkTarget(db: Db, userId: string, entity: AttachmentEntity, entityId: string): Promise<void> {
if (!LINK_ENTITIES.includes(entity)) throw new ValidationError("entity", "invalid");
const k = db.k;
let found: unknown;
switch (entity) {
case "entry":
found = await k.selectFrom("entries").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
break;
case "engagement":
found = await k
.selectFrom("engagements as g")
.innerJoin("entries as e", "e.id", "g.entry_id")
.select("g.id")
.where("g.id", "=", entityId)
.where("e.user_id", "=", userId)
.where("g.deleted_at", "is", null)
.where("e.deleted_at", "is", null)
.executeTakeFirst();
break;
case "copy":
found = await k.selectFrom("copies").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
break;
case "list":
found = await k.selectFrom("lists").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
break;
case "work":
found = await k
.selectFrom("works")
.select("id")
.where("id", "=", entityId)
.where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)]))
.executeTakeFirst();
break;
}
if (!found) throw new NotFoundError(entity);
}
/** The links of one of the user's attachments (empty for another user's or a missing attachment). */
export async function listAttachmentLinks(
db: Db,
userId: string,
attachmentId: string,
): Promise<{ id: string; entity: AttachmentEntity; entityId: string }[]> {
const rows = await db.k
.selectFrom("attachment_links as l")
.innerJoin("attachments as a", "a.id", "l.attachment_id")
.select(["l.id", "l.entity", "l.entity_id"])
.where("a.id", "=", attachmentId)
.where("a.user_id", "=", userId)
.orderBy("l.id")
.execute();
return rows.map((r) => ({ id: r.id, entity: r.entity, entityId: r.entity_id }));
}