feat(server): REST API, auth, sources, changes, export
createApp() serves /api/v1 from a typed route table (zod bodies and queries), with session-cookie and Bearer app-token auth, a CSRF header for cookie writes, login/register rate limiting, body caps, JSON error mapping, OpenAPI generation and a 404 /ingest mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b0a6738dd
commit
6963e297b0
19 files changed
+1599
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import { listAttachmentLinks, 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: "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,
|
||||
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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
Reference in new issue
Block a user