Files
gerbeur/api/routes/attachments.ts
khannurien c3594e4c27 v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:16:52 +00:00

98 lines
3.0 KiB
TypeScript

/**
* Stores and serves the images pasted or dropped into the text editor, which
* puts each one in the body as a link to its `GET` URL.
*
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id.
* - `GET /api/attachments/:attachmentId?token=` — public, but an image in a
* private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image.
*
* @module
*/
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import {
createAttachment,
getAttachmentAccess,
} from "../services/attachment-service.ts";
import { authenticateMedia } from "../lib/auth.ts";
import { type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { AttachmentResponse } from "../../shared/attachment.ts";
import { serveUploadedFile, validateImageUpload } from "../lib/upload.ts";
import { ATTACHMENTS_DIR } from "../config.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router();
router.post("/api/attachments", authMiddleware, async (ctx) => {
const contentType = ctx.request.headers.get("content-type") ?? "";
if (!contentType.includes("multipart/form-data")) {
throw new APIException(400, "Expected multipart/form-data");
}
const body = await ctx.request.body.formData();
const file = body.get("file");
if (!(file instanceof File)) {
throw new APIException(400, "Missing file");
}
const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data);
const attachmentId = crypto.randomUUID();
await Deno.mkdir(ATTACHMENTS_DIR, { recursive: true });
await Deno.writeFile(`${ATTACHMENTS_DIR}/${attachmentId}`, data);
try {
createAttachment(attachmentId, mime);
} catch (err) {
await Deno.remove(`${ATTACHMENTS_DIR}/${attachmentId}`).catch(() => {});
throw err;
}
const responseBody: APIResponse<AttachmentResponse> = {
success: true,
data: { id: attachmentId },
};
ctx.response.status = 201;
ctx.response.body = responseBody;
});
router.get("/api/attachments/:attachmentId", async (ctx) => {
const { attachmentId } = ctx.params;
let access;
try {
access = getAttachmentAccess(attachmentId);
} catch {
ctx.response.status = 404;
return;
}
if (access.ownerId) {
// An <img> can't send an Authorization header, so the viewer's media
// token comes in the query string.
const token = ctx.request.url.searchParams.get("token");
const viewerId = token ? await authenticateMedia(token) : null;
if (viewerId !== access.ownerId) {
ctx.response.status = 404;
return;
}
}
await serveUploadedFile(
ctx,
`${ATTACHMENTS_DIR}/${attachmentId}`,
access.mime,
3600,
!!access.ownerId,
);
});
export default router;