import { listAttachmentLinks, listAttachments, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments"; import { NotFoundError } from "@sttracker/core/errors"; import type { AttachmentEntity } from "@sttracker/db/schema"; import { z } from "zod"; import { json, noContent, route } from "../http.ts"; import { formFile } from "./form.ts"; const fields = z.object({ entity: z.enum(["entry", "engagement", "copy", "list", "work"]), entityId: z.string().min(1), caption: z.string().max(10_000).optional(), }); /** A filename safe inside a quoted Content-Disposition value. */ export function safeFilename(name: string): string { const cleaned = name.replace(/[^\w.\- ]/g, "_").slice(0, 200).trim(); return cleaned === "" ? "file" : cleaned; } export const attachmentRoutes = [ route({ method: "GET", path: "attachments", summary: "Attachments linked to one of your records (oldest first), each with its links.", auth: "any:read", query: z.object({ entity: fields.shape.entity, entityId: z.string().min(1) }), handler: async ({ deps, user, query }) => { await requireLinkTarget(deps.db, user.id, query.entity as AttachmentEntity, query.entityId); const atts = await listAttachments(deps.db, user.id, query.entity as AttachmentEntity, query.entityId); return Promise.all(atts.map(async (a) => ({ ...a, links: await listAttachmentLinks(deps.db, user.id, a.id) }))); }, }), route({ method: "POST", path: "attachments", summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.", auth: "any:write", multipart: true, formFields: fields, handler: async ({ deps, user, form }) => { const file = formFile(form); const f = fields.parse({ entity: form.get("entity") ?? undefined, entityId: form.get("entityId") ?? undefined, caption: form.get("caption") ?? undefined, }); await requireLinkTarget(deps.db, user.id, f.entity as AttachmentEntity, f.entityId); const att = await storeAttachment( deps.db, deps.dataDir, user.id, { bytes: new Uint8Array(await file.arrayBuffer()), mime: file.type || "application/octet-stream", filename: file.name, caption: f.caption }, { entity: f.entity as AttachmentEntity, entityId: f.entityId }, ); return json({ ...att, links: await listAttachmentLinks(deps.db, user.id, att.id) }, 201); }, }), route({ method: "GET", path: "attachments/:id", summary: "Download an attachment's file.", auth: "any:read", handler: async ({ deps, user, params }) => { const a = await readAttachment(deps.db, deps.dataDir, user.id, params.id!); if (!a || !(await a.file.exists())) throw new NotFoundError("attachment"); return new Response(a.file, { headers: { "content-type": a.mime, "x-content-type-options": "nosniff", "content-disposition": `inline; filename="${safeFilename(a.filename)}"`, "content-security-policy": "default-src 'none'; sandbox", "cache-control": "private, max-age=3600", }, }); }, }), route({ method: "DELETE", path: "attachments/:id/links/:linkId", summary: "Unlink an attachment; it is deleted when no links remain.", auth: "any:write", handler: async ({ deps, user, params }) => { const link = (await listAttachmentLinks(deps.db, user.id, params.id!)).find((l) => l.id === params.linkId); if (!link) throw new NotFoundError("attachment link"); await unlinkAttachment(deps.db, deps.dataDir, user.id, params.id!, link.entity, link.entityId); return noContent(); }, }), ];