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