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