/** * 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/dump.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 = { 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 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;