settings.tsx
Raw
1import * as argon2 from "argon2";
2import { Elysia, t } from "elysia";
3import { utils as sshUtils } from "ssh2";
4import config from "../config.ts";
5import {
6 ADMIN_USERNAME,
7 VALID_KEY_TYPES,
8 VALID_USERNAME_RE,
9} from "../constants.ts";
10import { db } from "../db";
11import { redirect } from "../lib/redirect.ts";
12import { resolveSession } from "../middleware/session.ts";
13import { createDefaultAvatar } from "../services/avatar.ts";
14import { fingerprintFromLine } from "../services/sshServer.ts";
15import { html } from "../views/render.tsx";
16import { Settings } from "../views/Settings.tsx";
17
18export const settingsRoutes = new Elysia()
19 .guard({
20 cookie: t.Cookie({
21 session: t.Optional(t.String()),
22 theme: t.Optional(t.String()),
23 }),
24 })
25
26 .get(
27 "/settings",
28 async ({ cookie, query }) => {
29 const user = await resolveSession(
30 cookie.session?.value as string | undefined,
31 );
32 if (!user) return redirect("/login");
33
34 const { success, error } = query;
35
36 const userRow = await db
37 .selectFrom("users")
38 .select(["id", "password_hash"])
39 .where("id", "=", user.id)
40 .executeTakeFirst();
41
42 const passkeys = await db
43 .selectFrom("passkeys")
44 .select(["id", "created_at"])
45 .where("user_id", "=", user.id)
46 .execute();
47
48 const sshKeys = await db
49 .selectFrom("ssh_keys")
50 .select(["id", "name", "fingerprint", "created_at"])
51 .where("user_id", "=", user.id)
52 .execute();
53
54 const theme = (cookie.theme?.value as string | undefined) ?? "auto";
55 const hasPassword = !!userRow?.password_hash;
56
57 const pendingUsers =
58 user.isAdmin && config.REGISTRATION_TYPE === "queue"
59 ? await db
60 .selectFrom("users")
61 .select([
62 "id",
63 "username",
64 "register_application",
65 "created_at",
66 ])
67 .where("is_pending", "=", 1)
68 .orderBy("created_at", "desc")
69 .execute()
70 : [];
71
72 const decodedError = error
73 ? decodeURIComponent((error as string).replace(/\+/g, " "))
74 : null;
75
76 return html(
77 <Settings
78 user={user}
79 hasPassword={hasPassword}
80 passkeys={passkeys}
81 sshKeys={sshKeys}
82 theme={theme}
83 success={(success as string | null) ?? null}
84 error={decodedError}
85 pendingUsers={pendingUsers}
86 />,
87 );
88 },
89 {
90 query: t.Object({
91 success: t.Optional(t.String()),
92 error: t.Optional(t.String()),
93 }),
94 },
95 )
96
97 .post(
98 "/settings/password",
99 async ({ cookie, body }) => {
100 const user = await resolveSession(
101 cookie.session?.value as string | undefined,
102 );
103 if (!user) return redirect("/login");
104
105 const { current_password, new_password, confirm_password } = body;
106
107 if (!new_password || new_password.length < 8) {
108 return redirect(
109 "/settings?error=Password+must+be+at+least+8+characters",
110 );
111 }
112 if (new_password !== confirm_password) {
113 return redirect("/settings?error=Passwords+do+not+match");
114 }
115
116 const userRow = await db
117 .selectFrom("users")
118 .select(["id", "password_hash"])
119 .where("id", "=", user.id)
120 .executeTakeFirst();
121
122 if (userRow?.password_hash) {
123 if (!current_password) {
124 return redirect(
125 "/settings?error=Current+password+is+required",
126 );
127 }
128 const valid = await argon2.verify(
129 userRow.password_hash,
130 current_password,
131 );
132 if (!valid) {
133 return redirect(
134 "/settings?error=Current+password+is+incorrect",
135 );
136 }
137 }
138
139 const hash = await argon2.hash(new_password);
140 await db
141 .updateTable("users")
142 .set({ password_hash: hash })
143 .where("id", "=", user.id)
144 .execute();
145
146 return redirect("/settings?success=password");
147 },
148 {
149 body: t.Object({
150 current_password: t.Optional(t.String()),
151 new_password: t.String(),
152 confirm_password: t.String(),
153 }),
154 },
155 )
156
157 .post(
158 "/settings/password/remove",
159 async ({ cookie }) => {
160 const user = await resolveSession(
161 cookie.session?.value as string | undefined,
162 );
163 if (!user) return redirect("/login");
164
165 const passkeys = await db
166 .selectFrom("passkeys")
167 .select(["id"])
168 .where("user_id", "=", user.id)
169 .execute();
170
171 if (passkeys.length === 0) {
172 return redirect(
173 "/settings?error=Cannot+remove+password+without+a+passkey",
174 );
175 }
176
177 await db
178 .updateTable("users")
179 .set({ password_hash: null })
180 .where("id", "=", user.id)
181 .execute();
182
183 return redirect("/settings?success=password_removed");
184 },
185 {
186 body: t.Object({}),
187 },
188 )
189
190 .post(
191 "/settings/passkey/revoke",
192 async ({ cookie, body }) => {
193 const user = await resolveSession(
194 cookie.session?.value as string | undefined,
195 );
196 if (!user) return redirect("/login");
197
198 const { id } = body;
199
200 const passkey = await db
201 .selectFrom("passkeys")
202 .select(["id", "user_id"])
203 .where("id", "=", id)
204 .executeTakeFirst();
205
206 if (!passkey || passkey.user_id !== user.id) {
207 return redirect("/settings?error=Passkey+not+found");
208 }
209
210 const userRow = await db
211 .selectFrom("users")
212 .select(["password_hash"])
213 .where("id", "=", user.id)
214 .executeTakeFirst();
215
216 const hasPassword = !!userRow?.password_hash;
217
218 // Wrap the count check and delete in a transaction so that two
219 // concurrent revocations can't both pass the "last auth method"
220 // guard and both succeed.
221 let lastAuthMethod = false;
222 await db.transaction().execute(async (trx) => {
223 const allPasskeys = await trx
224 .selectFrom("passkeys")
225 .select(["id"])
226 .where("user_id", "=", user.id)
227 .execute();
228
229 const remaining = allPasskeys.filter((p) => p.id !== id);
230 if (!hasPassword && remaining.length === 0) {
231 lastAuthMethod = true;
232 return;
233 }
234
235 await trx.deleteFrom("passkeys").where("id", "=", id).execute();
236 });
237
238 if (lastAuthMethod) {
239 return redirect(
240 "/settings?error=Cannot+revoke+last+auth+method",
241 );
242 }
243
244 return redirect("/settings?success=passkey_revoked");
245 },
246 {
247 body: t.Object({ id: t.Numeric() }),
248 },
249 )
250
251 .post(
252 "/settings/theme",
253 async ({ cookie, body }) => {
254 const _user = await resolveSession(
255 cookie.session?.value as string | undefined,
256 );
257 // Theme can be set even without auth, but we check session for settings redirect
258 const { theme } = body;
259
260 if (!["auto", "light", "dark"].includes(theme)) {
261 return redirect("/settings?error=Invalid+theme");
262 }
263
264 const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`;
265 return redirect("/settings?success=theme", cookieHeader);
266 },
267 {
268 body: t.Object({ theme: t.String() }),
269 },
270 )
271
272 .post(
273 "/admin/users",
274 async ({ cookie, body }) => {
275 const user = await resolveSession(
276 cookie.session?.value as string | undefined,
277 );
278 if (!user) return redirect("/login");
279 if (!user.isAdmin)
280 return new Response("Forbidden", { status: 403 });
281
282 const { username, password } = body;
283
284 if (!VALID_USERNAME_RE.test(username)) {
285 return redirect(
286 "/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores",
287 );
288 }
289 if (username === ADMIN_USERNAME) {
290 return redirect("/settings?error=That+username+is+reserved");
291 }
292 if (!password || password.length < 8) {
293 return redirect(
294 "/settings?error=Password+must+be+at+least+8+characters",
295 );
296 }
297
298 const existing = await db
299 .selectFrom("users")
300 .select("id")
301 .where("username", "=", username)
302 .executeTakeFirst();
303
304 if (existing) {
305 return redirect("/settings?error=Username+already+taken");
306 }
307
308 const hash = await argon2.hash(password);
309 const now = new Date().toISOString();
310 const result = await db
311 .insertInto("users")
312 .values({
313 username,
314 password_hash: hash,
315 created_at: now,
316 })
317 .executeTakeFirstOrThrow();
318 await createDefaultAvatar(Number(result.insertId), username);
319
320 return redirect("/settings?success=user_created");
321 },
322 {
323 body: t.Object({ username: t.String(), password: t.String() }),
324 },
325 )
326
327 .post(
328 "/admin/users/delete",
329 async ({ cookie, body }) => {
330 const user = await resolveSession(
331 cookie.session?.value as string | undefined,
332 );
333 if (!user) return redirect("/login");
334 if (!user.isAdmin)
335 return new Response("Forbidden", { status: 403 });
336
337 const { username } = body;
338 if (username === ADMIN_USERNAME) {
339 return redirect("/settings?error=Cannot+delete+admin+user");
340 }
341
342 const targetUser = await db
343 .selectFrom("users")
344 .select("id")
345 .where("username", "=", username)
346 .executeTakeFirst();
347
348 if (!targetUser) {
349 return redirect("/settings?error=User+not+found");
350 }
351
352 await db
353 .deleteFrom("users")
354 .where("id", "=", targetUser.id)
355 .execute();
356
357 return redirect("/settings?success=user_deleted");
358 },
359 {
360 body: t.Object({ username: t.String() }),
361 },
362 )
363
364 .post(
365 "/admin/users/approve",
366 async ({ cookie, body }) => {
367 const user = await resolveSession(
368 cookie.session?.value as string | undefined,
369 );
370 if (!user) return redirect("/login");
371 if (!user.isAdmin)
372 return new Response("Forbidden", { status: 403 });
373
374 await db
375 .updateTable("users")
376 .set({ is_pending: 0 })
377 .where("id", "=", body.id)
378 .where("is_pending", "=", 1)
379 .execute();
380
381 return redirect("/settings?success=user_approved");
382 },
383 { body: t.Object({ id: t.Numeric() }) },
384 )
385
386 .post(
387 "/admin/users/deny",
388 async ({ cookie, body }) => {
389 const user = await resolveSession(
390 cookie.session?.value as string | undefined,
391 );
392 if (!user) return redirect("/login");
393 if (!user.isAdmin)
394 return new Response("Forbidden", { status: 403 });
395
396 await db
397 .deleteFrom("users")
398 .where("id", "=", body.id)
399 .where("is_pending", "=", 1)
400 .execute();
401
402 return redirect("/settings?success=user_denied");
403 },
404 { body: t.Object({ id: t.Numeric() }) },
405 )
406
407 .post("/admin/users/approve-all", async ({ cookie }) => {
408 const user = await resolveSession(
409 cookie.session?.value as string | undefined,
410 );
411 if (!user) return redirect("/login");
412 if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
413
414 await db
415 .updateTable("users")
416 .set({ is_pending: 0 })
417 .where("is_pending", "=", 1)
418 .execute();
419
420 return redirect("/settings?success=all_approved");
421 })
422
423 .post("/admin/users/deny-all", async ({ cookie }) => {
424 const user = await resolveSession(
425 cookie.session?.value as string | undefined,
426 );
427 if (!user) return redirect("/login");
428 if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
429
430 await db.deleteFrom("users").where("is_pending", "=", 1).execute();
431
432 return redirect("/settings?success=all_denied");
433 })
434
435 .post(
436 "/settings/ssh-keys",
437 async ({ cookie, body }) => {
438 const user = await resolveSession(
439 cookie.session?.value as string | undefined,
440 );
441 if (!user) return redirect("/login");
442
443 const { name, public_key } = body;
444 const keyLine = public_key.trim();
445 const parts = keyLine.split(/\s+/);
446
447 if (!VALID_KEY_TYPES.has(parts[0] ?? "")) {
448 return redirect("/settings?error=Unsupported+key+type");
449 }
450
451 // Validate the key is parseable
452 try {
453 const parsed = sshUtils.parseKey(
454 Buffer.from(parts[1] ?? "", "base64"),
455 );
456 if (parsed instanceof Error) throw parsed;
457 } catch {
458 return redirect("/settings?error=Invalid+public+key");
459 }
460
461 const fingerprint = fingerprintFromLine(keyLine);
462 if (!fingerprint)
463 return redirect("/settings?error=Invalid+public+key");
464
465 try {
466 await db
467 .insertInto("ssh_keys")
468 .values({
469 user_id: user.id,
470 name: name.trim() || "Unnamed key",
471 public_key: keyLine,
472 fingerprint,
473 created_at: new Date().toISOString(),
474 })
475 .execute();
476 } catch (err) {
477 if (
478 err instanceof Error &&
479 err.message.includes(
480 "UNIQUE constraint failed: ssh_keys.fingerprint",
481 )
482 ) {
483 return redirect(
484 "/settings?error=This+key+is+already+registered",
485 );
486 }
487 throw err;
488 }
489
490 return redirect("/settings?success=ssh_key_added");
491 },
492 {
493 body: t.Object({ name: t.String(), public_key: t.String() }),
494 },
495 )
496
497 .post(
498 "/settings/ssh-keys/delete",
499 async ({ cookie, body }) => {
500 const user = await resolveSession(
501 cookie.session?.value as string | undefined,
502 );
503 if (!user) return redirect("/login");
504
505 const key = await db
506 .selectFrom("ssh_keys")
507 .select(["id", "user_id"])
508 .where("id", "=", body.id)
509 .executeTakeFirst();
510
511 if (!key || key.user_id !== user.id) {
512 return redirect("/settings?error=Key+not+found");
513 }
514
515 await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute();
516 return redirect("/settings?success=ssh_key_deleted");
517 },
518 {
519 body: t.Object({ id: t.Numeric() }),
520 },
521 );
522