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