avatars.ts
Raw
1import { Elysia, t } from "elysia";
2import config from "../config.ts";
3import {
4 RATE_WINDOW_MIN_MS,
5 UPLOAD_MAX_PER_MIN,
6 YEAR_SECONDS,
7} from "../constants.ts";
8import { db } from "../db/index.ts";
9import { rateLimit } from "../lib/rateLimiter.ts";
10import { requireAuth, resolveSession } from "../middleware/session.ts";
11import {
12 avatarJxlPath,
13 createDefaultAvatar,
14 processAndStoreAvatar,
15} from "../services/avatar.ts";
16
17const CACHE = `public, max-age=${YEAR_SECONDS}, immutable`;
18
19async function bumpAvatarVersion(userId: number): Promise<void> {
20 await db
21 .updateTable("users")
22 .set((eb) => ({ avatar_version: eb("avatar_version", "+", 1) }))
23 .where("id", "=", userId)
24 .execute();
25}
26
27export const avatarRoutes = new Elysia()
28 .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }) })
29
30 .get("/avatars/:id", async ({ params, request }) => {
31 const userId = parseInt(params.id, 10);
32 if (Number.isNaN(userId))
33 return new Response("Not found", { status: 404 });
34
35 if (!new URL(request.url).searchParams.has("v"))
36 return new Response("Not found", { status: 404 });
37
38 let file = Bun.file(avatarJxlPath(userId));
39 if (!(await file.exists())) {
40 const user = await db
41 .selectFrom("users")
42 .select("username")
43 .where("id", "=", userId)
44 .executeTakeFirst();
45 if (!user) return new Response("Not found", { status: 404 });
46 await createDefaultAvatar(userId, user.username);
47 file = Bun.file(avatarJxlPath(userId));
48 }
49
50 return new Response(file, {
51 headers: {
52 "Content-Type": "image/jxl",
53 "Cache-Control": CACHE,
54 },
55 });
56 })
57
58 .post(
59 "/settings/avatar",
60 async ({ body, cookie, request, server }) => {
61 const user = await resolveSession(cookie.session.value);
62 const deny = requireAuth(user);
63 if (deny) return deny;
64 const limited = rateLimit(
65 request,
66 server,
67 user?.id ?? null,
68 "upload",
69 UPLOAD_MAX_PER_MIN,
70 RATE_WINDOW_MIN_MS,
71 );
72 if (limited) return limited;
73 if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) {
74 return new Response("Avatar file too large", { status: 400 });
75 }
76 const buffer = Buffer.from(await body.avatar.arrayBuffer());
77 try {
78 await processAndStoreAvatar(user!.id, buffer);
79 } catch (err) {
80 if (
81 err instanceof Error &&
82 err.message === "Invalid image type"
83 ) {
84 return new Response("File is not a supported image type", {
85 status: 400,
86 });
87 }
88 throw err;
89 }
90 await bumpAvatarVersion(user!.id);
91 return new Response(null, {
92 status: 302,
93 headers: { Location: "/settings" },
94 });
95 },
96 {
97 body: t.Object({ avatar: t.File() }),
98 },
99 )
100
101 .post("/settings/avatar/delete", async ({ cookie }) => {
102 const user = await resolveSession(cookie.session.value);
103 const deny = requireAuth(user);
104 if (deny) return deny;
105 await createDefaultAvatar(user!.id, user!.username);
106 await bumpAvatarVersion(user!.id);
107 return new Response(null, {
108 status: 302,
109 headers: { Location: "/settings" },
110 });
111 });
112