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 secure = config.PUBLIC_HTTPS ? "; Secure" : "";
272 const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax${secure}; Max-Age=${YEAR_SECONDS}`;
273 return redirect("/settings?success=theme", cookieHeader);
274 },
275 {
276 body: t.Object({ theme: t.String() }),
277 },
278 )
279
280 .post(
281 "/admin/users",
282 async ({ cookie, body }) => {
283 const user = await resolveSession(
284 cookie.session?.value as string | undefined,
285 );
286 if (!user) return redirect("/login");
287 if (!user.isAdmin)
288 return new Response("Forbidden", { status: 403 });
289
290 const { username, password } = body;
291
292 if (!VALID_USERNAME_RE.test(username)) {
293 return redirect(
294 "/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores",
295 );
296 }
297 if (username === ADMIN_USERNAME) {
298 return redirect("/settings?error=That+username+is+reserved");
299 }
300 if (!password || password.length < 8) {
301 return redirect(
302 "/settings?error=Password+must+be+at+least+8+characters",
303 );
304 }
305
306 const existing = await db
307 .selectFrom("users")
308 .select("id")
309 .where("username", "=", username)
310 .executeTakeFirst();
311
312 if (existing) {
313 return redirect("/settings?error=Username+already+taken");
314 }
315
316 const hash = await argon2.hash(password);
317 const now = new Date().toISOString();
318 const result = await db
319 .insertInto("users")
320 .values({
321 username,
322 password_hash: hash,
323 created_at: now,
324 })
325 .executeTakeFirstOrThrow();
326 await createDefaultAvatar(Number(result.insertId), username);
327
328 return redirect("/settings?success=user_created");
329 },
330 {
331 body: t.Object({ username: t.String(), password: t.String() }),
332 },
333 )
334
335 .post(
336 "/admin/users/delete",
337 async ({ cookie, body }) => {
338 const user = await resolveSession(
339 cookie.session?.value as string | undefined,
340 );
341 if (!user) return redirect("/login");
342 if (!user.isAdmin)
343 return new Response("Forbidden", { status: 403 });
344
345 const { username } = body;
346 if (username === ADMIN_USERNAME) {
347 return redirect("/settings?error=Cannot+delete+admin+user");
348 }
349
350 const targetUser = await db
351 .selectFrom("users")
352 .select("id")
353 .where("username", "=", username)
354 .executeTakeFirst();
355
356 if (!targetUser) {
357 return redirect("/settings?error=User+not+found");
358 }
359
360 await db
361 .deleteFrom("users")
362 .where("id", "=", targetUser.id)
363 .execute();
364
365 return redirect("/settings?success=user_deleted");
366 },
367 {
368 body: t.Object({ username: t.String() }),
369 },
370 )
371
372 .post(
373 "/admin/users/approve",
374 async ({ cookie, body }) => {
375 const user = await resolveSession(
376 cookie.session?.value as string | undefined,
377 );
378 if (!user) return redirect("/login");
379 if (!user.isAdmin)
380 return new Response("Forbidden", { status: 403 });
381
382 await db
383 .updateTable("users")
384 .set({ is_pending: 0 })
385 .where("id", "=", body.id)
386 .where("is_pending", "=", 1)
387 .execute();
388
389 return redirect("/settings?success=user_approved");
390 },
391 { body: t.Object({ id: t.Numeric() }) },
392 )
393
394 .post(
395 "/admin/users/deny",
396 async ({ cookie, body }) => {
397 const user = await resolveSession(
398 cookie.session?.value as string | undefined,
399 );
400 if (!user) return redirect("/login");
401 if (!user.isAdmin)
402 return new Response("Forbidden", { status: 403 });
403
404 await db
405 .deleteFrom("users")
406 .where("id", "=", body.id)
407 .where("is_pending", "=", 1)
408 .execute();
409
410 return redirect("/settings?success=user_denied");
411 },
412 { body: t.Object({ id: t.Numeric() }) },
413 )
414
415 .post("/admin/users/approve-all", async ({ cookie }) => {
416 const user = await resolveSession(
417 cookie.session?.value as string | undefined,
418 );
419 if (!user) return redirect("/login");
420 if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
421
422 await db
423 .updateTable("users")
424 .set({ is_pending: 0 })
425 .where("is_pending", "=", 1)
426 .execute();
427
428 return redirect("/settings?success=all_approved");
429 })
430
431 .post("/admin/users/deny-all", async ({ cookie }) => {
432 const user = await resolveSession(
433 cookie.session?.value as string | undefined,
434 );
435 if (!user) return redirect("/login");
436 if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
437
438 await db.deleteFrom("users").where("is_pending", "=", 1).execute();
439
440 return redirect("/settings?success=all_denied");
441 })
442
443 .post(
444 "/settings/ssh-keys",
445 async ({ cookie, body }) => {
446 const user = await resolveSession(
447 cookie.session?.value as string | undefined,
448 );
449 if (!user) return redirect("/login");
450
451 const { name, public_key } = body;
452 const keyLine = public_key.trim();
453 const parts = keyLine.split(/\s+/);
454
455 if (!VALID_KEY_TYPES.has(parts[0] ?? "")) {
456 return redirect("/settings?error=Unsupported+key+type");
457 }
458
459 // Validate the key is parseable
460 try {
461 const parsed = sshUtils.parseKey(
462 Buffer.from(parts[1] ?? "", "base64"),
463 );
464 if (parsed instanceof Error) throw parsed;
465 } catch {
466 return redirect("/settings?error=Invalid+public+key");
467 }
468
469 const fingerprint = fingerprintFromLine(keyLine);
470 if (!fingerprint)
471 return redirect("/settings?error=Invalid+public+key");
472
473 try {
474 await db
475 .insertInto("ssh_keys")
476 .values({
477 user_id: user.id,
478 name: name.trim() || "Unnamed key",
479 public_key: keyLine,
480 fingerprint,
481 created_at: new Date().toISOString(),
482 })
483 .execute();
484 } catch (err) {
485 if (
486 err instanceof Error &&
487 err.message.includes(
488 "UNIQUE constraint failed: ssh_keys.fingerprint",
489 )
490 ) {
491 return redirect(
492 "/settings?error=This+key+is+already+registered",
493 );
494 }
495 throw err;
496 }
497
498 return redirect("/settings?success=ssh_key_added");
499 },
500 {
501 body: t.Object({ name: t.String(), public_key: t.String() }),
502 },
503 )
504
505 .post(
506 "/settings/ssh-keys/delete",
507 async ({ cookie, body }) => {
508 const user = await resolveSession(
509 cookie.session?.value as string | undefined,
510 );
511 if (!user) return redirect("/login");
512
513 const key = await db
514 .selectFrom("ssh_keys")
515 .select(["id", "user_id"])
516 .where("id", "=", body.id)
517 .executeTakeFirst();
518
519 if (!key || key.user_id !== user.id) {
520 return redirect("/settings?error=Key+not+found");
521 }
522
523 await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute();
524 return redirect("/settings?success=ssh_key_deleted");
525 },
526 {
527 body: t.Object({ id: t.Numeric() }),
528 },
529 );
530