141 lines
4.6 KiB
TypeScript
141 lines
4.6 KiB
TypeScript
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 });
|
|
}
|