Files
STTracker/apps/server/src/routes/attachments.ts
T
Lucas WintherandClaude Opus 5.5 d204775235 feat(server): auth status, attachments list, user preferences
GET auth/status ({ hasUsers }), GET attachments?entity=&entityId= (owner
scoped, 404 for foreign targets) and GET/PATCH me/preferences (ratingScale,
defaultVisibility in users.preferences).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:57:08 +01:00

90 lines
3.7 KiB
TypeScript

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();
},
}),
];