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