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>
90 lines
3.7 KiB
TypeScript
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();
|
|
},
|
|
}),
|
|
];
|