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