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(
151 t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
152 ),
153 new_password: t.String({
154 maxLength: config.MAX_PASSWORD_BYTES,
155 }),
156 confirm_password: t.String({
157 maxLength: config.MAX_PASSWORD_BYTES,
158 }),
159 }),
160 },
161 )
162
163 .post(
164 "/settings/password/remove",
165 async ({ cookie }) => {
166 const user = await resolveSession(
167 cookie.session?.value as string | undefined,
168 );
169 if (!user) return redirect("/login");
170
171 const passkeys = await db
172 .selectFrom("passkeys")
173 .select(["id"])
174 .where("user_id", "=", user.id)
175 .execute();
176
177 if (passkeys.length === 0) {
178 return redirect(
179 "/settings?error=Cannot+remove+password+without+a+passkey",
180 );
181 }
182
183 await db
184 .updateTable("users")
185 .set({ password_hash: null })
186 .where("id", "=", user.id)
187 .execute();
188
189 return redirect("/settings?success=password_removed");
190 },
191 {
192 body: t.Object({}),
193 },
194 )
195
196 .post(
197 "/settings/passkey/revoke",
198 async ({ cookie, body }) => {
199 const user = await resolveSession(
200 cookie.session?.value as string | undefined,
201 );
202 if (!user) return redirect("/login");
203
204 const { id } = body;
205
206 const passkey = await db
207 .selectFrom("passkeys")
208 .select(["id", "user_id"])
209 .where("id", "=", id)
210 .executeTakeFirst();
211
212 if (!passkey || passkey.user_id !== user.id) {
213 return redirect("/settings?error=Passkey+not+found");
214 }
215
216 const userRow = await db
217 .selectFrom("users")
218 .select(["password_hash"])
219 .where("id", "=", user.id)
220 .executeTakeFirst();
221
222 const hasPassword = !!userRow?.password_hash;
223
224 // Wrap the count check and delete in a transaction so that two
225 // concurrent revocations can't both pass the "last auth method"
226 // guard and both succeed.
227 let lastAuthMethod = false;
228 await db.transaction().execute(async (trx) => {
229 const allPasskeys = await trx
230 .selectFrom("passkeys")
231 .select(["id"])
232 .where("user_id", "=", user.id)
233 .execute();
234
235 const remaining = allPasskeys.filter((p) => p.id !== id);
236 if (!hasPassword && remaining.length === 0) {
237 lastAuthMethod = true;
238 return;
239 }
240
241 await trx.deleteFrom("passkeys").where("id", "=", id).execute();
242 });
243
244 if (lastAuthMethod) {
245 return redirect(
246 "/settings?error=Cannot+revoke+last+auth+method",
247 );
248 }
249
250 return redirect("/settings?success=passkey_revoked");
251 },
252 {
253 body: t.Object({ id: t.Numeric() }),
254 },
255 )
256
257 .post(
258 "/settings/theme",
259 async ({ cookie, body }) => {
260 const _user = await resolveSession(
261 cookie.session?.value as string | undefined,
262 );
263 // Theme can be set even without auth, but we check session for settings redirect
264 const { theme } = body;
265
266 if (!["auto", "light", "dark"].includes(theme)) {
267 return redirect("/settings?error=Invalid+theme");
268 }
269
270 const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`;
271 return redirect("/settings?success=theme", cookieHeader);
272 },
273 {
274 body: t.Object({ theme: t.String() }),
275 },
276 )
277
278 .post(
279 "/admin/users",
280 async ({ cookie, body }) => {
281 const user = await resolveSession(
282 cookie.session?.value as string | undefined,
283 );
284 if (!user) return redirect("/login");
285 if (!user.isAdmin)
286 return new Response("Forbidden", { status: 403 });
287
288 const { username, password } = body;
289
290 if (!VALID_USERNAME_RE.test(username)) {
291 return redirect(
292 "/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores",
293 );
294 }
295 if (username === ADMIN_USERNAME) {
296 return redirect("/settings?error=That+username+is+reserved");
297 }
298 if (!password || password.length < 8) {
299 return redirect(
300 "/settings?error=Password+must+be+at+least+8+characters",
301 );
302 }
303
304 const existing = await db
305 .selectFrom("users")
306 .select("id")
307 .where("username", "=", username)
308 .executeTakeFirst();
309
310 if (existing) {
311 return redirect("/settings?error=Username+already+taken");
312 }
313
314 const hash = await argon2.hash(password);
315 const now = new Date().toISOString();
316 const result = await db
317 .insertInto("users")
318 .values({
319 username,
320 password_hash: hash,
321 created_at: now,
322 })
323 .executeTakeFirstOrThrow();
324 await createDefaultAvatar(Number(result.insertId), username);
325
326 return redirect("/settings?success=user_created");
327 },
328 {
329 body: t.Object({ username: t.String(), password: t.String() }),
330 },
331 )
332
333 .post(
334 "/admin/users/delete",
335 async ({ cookie, body }) => {
336 const user = await resolveSession(
337 cookie.session?.value as string | undefined,
338 );
339 if (!user) return redirect("/login");
340 if (!user.isAdmin)
341 return new Response("Forbidden", { status: 403 });
342
343 const { username } = body;
344 if (username === ADMIN_USERNAME) {
345 return redirect("/settings?error=Cannot+delete+admin+user");
346 }
347
348 const targetUser = await db
349 .selectFrom("users")
350 .select("id")
351 .where("username", "=", username)
352 .executeTakeFirst();
353
354 if (!targetUser) {
355 return redirect("/settings?error=User+not+found");
356 }
357
358 await db
359 .deleteFrom("users")
360 .where("id", "=", targetUser.id)
361 .execute();
362
363 return redirect("/settings?success=user_deleted");
364 },
365 {
366 body: t.Object({ username: t.String() }),
367 },
368 )
369
370 .post(
371 "/admin/users/approve",
372 async ({ cookie, body }) => {
373 const user = await resolveSession(
374 cookie.session?.value as string | undefined,
375 );
376 if (!user) return redirect("/login");
377 if (!user.isAdmin)
378 return new Response("Forbidden", { status: 403 });
379
380 await db
381 .updateTable("users")
382 .set({ is_pending: 0 })
383 .where("id", "=", body.id)
384 .where("is_pending", "=", 1)
385 .execute();
386
387 return redirect("/settings?success=user_approved");
388 },
389 { body: t.Object({ id: t.Numeric() }) },
390 )
391
392 .post(
393 "/admin/users/deny",
394 async ({ cookie, body }) => {
395 const user = await resolveSession(
396 cookie.session?.value as string | undefined,
397 );
398 if (!user) return redirect("/login");
399 if (!user.isAdmin)
400 return new Response("Forbidden", { status: 403 });
401
402 await db
403 .deleteFrom("users")
404 .where("id", "=", body.id)
405 .where("is_pending", "=", 1)
406 .execute();
407
408 return redirect("/settings?success=user_denied");
409 },
410 { body: t.Object({ id: t.Numeric() }) },
411 )
412
413 .post("/admin/users/approve-all", async ({ cookie }) => {
414 const user = await resolveSession(
415 cookie.session?.value as string | undefined,
416 );
417 if (!user) return redirect("/login");
418 if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
419
420 await db
421 .updateTable("users")
422 .set({ is_pending: 0 })
423 .where("is_pending", "=", 1)
424 .execute();
425
426 return redirect("/settings?success=all_approved");
427 })
428
429 .post("/admin/users/deny-all", async ({ cookie }) => {
430 const user = await resolveSession(
431 cookie.session?.value as string | undefined,
432 );
433 if (!user) return redirect("/login");
434 if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
435
436 await db.deleteFrom("users").where("is_pending", "=", 1).execute();
437
438 return redirect("/settings?success=all_denied");
439 })
440
441 .post(
442 "/settings/ssh-keys",
443 async ({ cookie, body }) => {
444 const user = await resolveSession(
445 cookie.session?.value as string | undefined,
446 );
447 if (!user) return redirect("/login");
448
449 const { name, public_key } = body;
450 const keyLine = public_key.trim();
451 const parts = keyLine.split(/\s+/);
452
453 if (!VALID_KEY_TYPES.has(parts[0] ?? "")) {
454 return redirect("/settings?error=Unsupported+key+type");
455 }
456
457 // Validate the key is parseable
458 try {
459 const parsed = sshUtils.parseKey(
460 Buffer.from(parts[1] ?? "", "base64"),
461 );
462 if (parsed instanceof Error) throw parsed;
463 } catch {
464 return redirect("/settings?error=Invalid+public+key");
465 }
466
467 const fingerprint = fingerprintFromLine(keyLine);
468 if (!fingerprint)
469 return redirect("/settings?error=Invalid+public+key");
470
471 try {
472 await db
473 .insertInto("ssh_keys")
474 .values({
475 user_id: user.id,
476 name: name.trim() || "Unnamed key",
477 public_key: keyLine,
478 fingerprint,
479 created_at: new Date().toISOString(),
480 })
481 .execute();
482 } catch (err) {
483 if (
484 err instanceof Error &&
485 err.message.includes(
486 "UNIQUE constraint failed: ssh_keys.fingerprint",
487 )
488 ) {
489 return redirect(
490 "/settings?error=This+key+is+already+registered",
491 );
492 }
493 throw err;
494 }
495
496 return redirect("/settings?success=ssh_key_added");
497 },
498 {
499 body: t.Object({ name: t.String(), public_key: t.String() }),
500 },
501 )
502
503 .post(
504 "/settings/ssh-keys/delete",
505 async ({ cookie, body }) => {
506 const user = await resolveSession(
507 cookie.session?.value as string | undefined,
508 );
509 if (!user) return redirect("/login");
510
511 const key = await db
512 .selectFrom("ssh_keys")
513 .select(["id", "user_id"])
514 .where("id", "=", body.id)
515 .executeTakeFirst();
516
517 if (!key || key.user_id !== user.id) {
518 return redirect("/settings?error=Key+not+found");
519 }
520
521 await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute();
522 return redirect("/settings?success=ssh_key_deleted");
523 },
524 {
525 body: t.Object({ id: t.Numeric() }),
526 },
527 );
528