/** * Serves the avatar endpoints. The profile page uploads the avatar, and the * client's `Avatar` component loads it, with the user's `updatedAt` as a * cache-busting version. * * - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the * caller's user. * - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without * one. * * @module */ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import { getUserById, updateUserAvatar } from "../services/user-service.ts"; import { broadcastUserUpdated, updateClientAvatar, } from "../services/ws-service.ts"; import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { SelfUser } from "../../shared/user.ts"; import { toPublicUser, toSelfUser } from "../model/user.ts"; import { replaceUploadedFile, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; import { AVATARS_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/avatars/me", authMiddleware, async (ctx) => { const authPayload = ctx.state.user; 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 field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); // Written before the record announces it, or clients would fetch it too // early. await replaceUploadedFile(`${AVATARS_DIR}/${authPayload.userId}`, data); updateUserAvatar(authPayload.userId, mime); updateClientAvatar(authPayload.userId, mime); const user = getUserById(authPayload.userId); // Presence alone would update only the online list; `user_updated` reaches // every avatar on screen, and its bumped `updatedAt` busts their cache. broadcastUserUpdated(toPublicUser(user)); const responseBody: APIResponse = { success: true, data: toSelfUser(user), }; ctx.response.body = responseBody; }); router.get("/api/avatars/:userId", async (ctx) => { const { userId } = ctx.params; let user; try { user = getUserById(userId); } catch { ctx.response.status = 404; return; } if (!user.avatarMime) { ctx.response.status = 404; return; } await serveUploadedFile(ctx, `${AVATARS_DIR}/${userId}`, user.avatarMime); }); export default router;