import { Elysia, t } from "elysia"; import config from "../config.ts"; import { RATE_WINDOW_MIN_MS, UPLOAD_MAX_PER_MIN, YEAR_SECONDS, } from "../constants.ts"; import { db } from "../db/index.ts"; import { rateLimit } from "../lib/rateLimiter.ts"; import { requireAuth, resolveSession } from "../middleware/session.ts"; import { avatarJxlPath, createDefaultAvatar, processAndStoreAvatar, } from "../services/avatar.ts"; const CACHE = `public, max-age=${YEAR_SECONDS}, immutable`; async function bumpAvatarVersion(userId: number): Promise { await db .updateTable("users") .set((eb) => ({ avatar_version: eb("avatar_version", "+", 1) })) .where("id", "=", userId) .execute(); } export const avatarRoutes = new Elysia() .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }) }) .get("/avatars/:id", async ({ params, request }) => { const userId = parseInt(params.id, 10); if (Number.isNaN(userId)) return new Response("Not found", { status: 404 }); if (!new URL(request.url).searchParams.has("v")) return new Response("Not found", { status: 404 }); let file = Bun.file(avatarJxlPath(userId)); if (!(await file.exists())) { const user = await db .selectFrom("users") .select("username") .where("id", "=", userId) .executeTakeFirst(); if (!user) return new Response("Not found", { status: 404 }); await createDefaultAvatar(userId, user.username); file = Bun.file(avatarJxlPath(userId)); } return new Response(file, { headers: { "Content-Type": "image/jxl", "Cache-Control": CACHE, }, }); }) .post( "/settings/avatar", async ({ body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const limited = rateLimit( request, server, user?.id ?? null, "upload", UPLOAD_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) { return new Response("Avatar file too large", { status: 400 }); } const buffer = Buffer.from(await body.avatar.arrayBuffer()); try { await processAndStoreAvatar(user!.id, buffer); } catch (err) { if ( err instanceof Error && err.message === "Invalid image type" ) { return new Response("File is not a supported image type", { status: 400, }); } throw err; } await bumpAvatarVersion(user!.id); return new Response(null, { status: 302, headers: { Location: "/settings" }, }); }, { body: t.Object({ avatar: t.File() }), }, ) .post("/settings/avatar/delete", async ({ cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; await createDefaultAvatar(user!.id, user!.username); await bumpAvatarVersion(user!.id); return new Response(null, { status: 302, headers: { Location: "/settings" }, }); });