import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import { getUserById, updateUserAvatar } from "../services/user-service.ts"; import { updateClientAvatar } from "../services/ws-service.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; const AVATARS_DIR = "api/uploads/avatars"; const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB const ALLOWED_AVATAR_MIMES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", ]); // Magic bytes for image validation const MAGIC: Array<{ mime: string; bytes: number[]; offset?: number }> = [ { mime: "image/jpeg", bytes: [0xFF, 0xD8, 0xFF] }, { mime: "image/png", bytes: [0x89, 0x50, 0x4E, 0x47] }, { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, { mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF ]; function checkMagicBytes(data: Uint8Array, mime: string): boolean { if (mime === "image/webp") { // RIFF....WEBP return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50; } const entry = MAGIC.find((m) => m.mime === mime); if (!entry) return false; return entry.bytes.every((b, i) => data[i] === b); } const 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( APIErrorCode.BAD_REQUEST, 400, "Expected multipart/form-data", ); } const body = await ctx.request.body.formData(); const file = body.get("file"); if (!(file instanceof File)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); } if (!ALLOWED_AVATAR_MIMES.has(file.type)) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "Only JPEG, PNG, GIF, WebP images are allowed", ); } if (file.size > MAX_AVATAR_SIZE) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)", ); } const data = new Uint8Array(await file.arrayBuffer()); if (!checkMagicBytes(data, file.type)) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "File content does not match declared type", ); } await Deno.mkdir(AVATARS_DIR, { recursive: true }); await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data); updateUserAvatar(authPayload.userId, file.type); updateClientAvatar(authPayload.userId, file.type); const user = getUserById(authPayload.userId); ctx.response.status = 200; ctx.response.body = { success: true, data: user }; }); 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; } let data: Uint8Array; try { data = await Deno.readFile(`${AVATARS_DIR}/${userId}`); } catch { ctx.response.status = 404; return; } ctx.response.headers.set("Content-Type", user.avatarMime); ctx.response.headers.set("Content-Disposition", "inline"); ctx.response.headers.set("Cache-Control", "public, max-age=3600"); ctx.response.body = data; }); export default router;