/** * Stores and serves the users' avatars. The profile page uploads the avatar, * and the client's `Avatar` component loads it, with the user's `updatedAt` as * a cache-busting version. * * - `PUT /api/me/avatar` — 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/users/:key/avatar` — public. Serves a user's avatar; 404 without * one. * * @module */ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import { getUserById, resolveUserKey, 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.put("/api/me/avatar", 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/users/:key/avatar", async (ctx) => { let user; try { user = resolveUserKey(ctx.params.key); } catch { ctx.response.status = 404; return; } if (!user.avatarMime) { ctx.response.status = 404; return; } await serveUploadedFile(ctx, `${AVATARS_DIR}/${user.id}`, user.avatarMime); }); export default router;