import * as argon2 from "argon2"; import { Elysia, t } from "elysia"; import { utils as sshUtils } from "ssh2"; import config from "../config.ts"; import { ADMIN_USERNAME, VALID_KEY_TYPES, VALID_USERNAME_RE, YEAR_SECONDS, } from "../constants.ts"; import { db } from "../db"; import { redirect } from "../lib/redirect.ts"; import { resolveSession } from "../middleware/session.ts"; import { createDefaultAvatar } from "../services/avatar.ts"; import { fingerprintFromLine } from "../services/sshServer.ts"; import { html } from "../views/render.tsx"; import { Settings } from "../views/Settings.tsx"; export const settingsRoutes = new Elysia() .guard({ cookie: t.Cookie({ session: t.Optional(t.String()), theme: t.Optional(t.String()), }), }) .get( "/settings", async ({ cookie, query }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); const { success, error } = query; const userRow = await db .selectFrom("users") .select(["id", "password_hash"]) .where("id", "=", user.id) .executeTakeFirst(); const passkeys = await db .selectFrom("passkeys") .select(["id", "created_at"]) .where("user_id", "=", user.id) .execute(); const sshKeys = await db .selectFrom("ssh_keys") .select(["id", "name", "fingerprint", "created_at"]) .where("user_id", "=", user.id) .execute(); const theme = (cookie.theme?.value as string | undefined) ?? "auto"; const hasPassword = !!userRow?.password_hash; const pendingUsers = user.isAdmin && config.REGISTRATION_TYPE === "queue" ? await db .selectFrom("users") .select([ "id", "username", "register_application", "created_at", ]) .where("is_pending", "=", 1) .orderBy("created_at", "desc") .execute() : []; const decodedError = error ? decodeURIComponent((error as string).replace(/\+/g, " ")) : null; return html( , ); }, { query: t.Object({ success: t.Optional(t.String()), error: t.Optional(t.String()), }), }, ) .post( "/settings/password", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); const { current_password, new_password, confirm_password } = body; if (!new_password || new_password.length < 8) { return redirect( "/settings?error=Password+must+be+at+least+8+characters", ); } if (new_password !== confirm_password) { return redirect("/settings?error=Passwords+do+not+match"); } const userRow = await db .selectFrom("users") .select(["id", "password_hash"]) .where("id", "=", user.id) .executeTakeFirst(); if (userRow?.password_hash) { if (!current_password) { return redirect( "/settings?error=Current+password+is+required", ); } const valid = await argon2.verify( userRow.password_hash, current_password, ); if (!valid) { return redirect( "/settings?error=Current+password+is+incorrect", ); } } const hash = await argon2.hash(new_password); await db .updateTable("users") .set({ password_hash: hash }) .where("id", "=", user.id) .execute(); return redirect("/settings?success=password"); }, { body: t.Object({ current_password: t.Optional( t.String({ maxLength: config.MAX_PASSWORD_BYTES }), ), new_password: t.String({ maxLength: config.MAX_PASSWORD_BYTES, }), confirm_password: t.String({ maxLength: config.MAX_PASSWORD_BYTES, }), }), }, ) .post( "/settings/password/remove", async ({ cookie }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); const passkeys = await db .selectFrom("passkeys") .select(["id"]) .where("user_id", "=", user.id) .execute(); if (passkeys.length === 0) { return redirect( "/settings?error=Cannot+remove+password+without+a+passkey", ); } await db .updateTable("users") .set({ password_hash: null }) .where("id", "=", user.id) .execute(); return redirect("/settings?success=password_removed"); }, { body: t.Object({}), }, ) .post( "/settings/passkey/revoke", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); const { id } = body; const passkey = await db .selectFrom("passkeys") .select(["id", "user_id"]) .where("id", "=", id) .executeTakeFirst(); if (!passkey || passkey.user_id !== user.id) { return redirect("/settings?error=Passkey+not+found"); } const userRow = await db .selectFrom("users") .select(["password_hash"]) .where("id", "=", user.id) .executeTakeFirst(); const hasPassword = !!userRow?.password_hash; // Wrap the count check and delete in a transaction so that two // concurrent revocations can't both pass the "last auth method" // guard and both succeed. let lastAuthMethod = false; await db.transaction().execute(async (trx) => { const allPasskeys = await trx .selectFrom("passkeys") .select(["id"]) .where("user_id", "=", user.id) .execute(); const remaining = allPasskeys.filter((p) => p.id !== id); if (!hasPassword && remaining.length === 0) { lastAuthMethod = true; return; } await trx.deleteFrom("passkeys").where("id", "=", id).execute(); }); if (lastAuthMethod) { return redirect( "/settings?error=Cannot+revoke+last+auth+method", ); } return redirect("/settings?success=passkey_revoked"); }, { body: t.Object({ id: t.Numeric() }), }, ) .post( "/settings/theme", async ({ cookie, body }) => { const _user = await resolveSession( cookie.session?.value as string | undefined, ); // Theme can be set even without auth, but we check session for settings redirect const { theme } = body; if (!["auto", "light", "dark"].includes(theme)) { return redirect("/settings?error=Invalid+theme"); } const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax${secure}; Max-Age=${YEAR_SECONDS}`; return redirect("/settings?success=theme", cookieHeader); }, { body: t.Object({ theme: t.String() }), }, ) .post( "/admin/users", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); if (!user.isAdmin) return new Response("Forbidden", { status: 403 }); const { username, password } = body; if (!VALID_USERNAME_RE.test(username)) { return redirect( "/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores", ); } if (username === ADMIN_USERNAME) { return redirect("/settings?error=That+username+is+reserved"); } if (!password || password.length < 8) { return redirect( "/settings?error=Password+must+be+at+least+8+characters", ); } const existing = await db .selectFrom("users") .select("id") .where("username", "=", username) .executeTakeFirst(); if (existing) { return redirect("/settings?error=Username+already+taken"); } const hash = await argon2.hash(password); const now = new Date().toISOString(); const result = await db .insertInto("users") .values({ username, password_hash: hash, created_at: now, }) .executeTakeFirstOrThrow(); await createDefaultAvatar(Number(result.insertId), username); return redirect("/settings?success=user_created"); }, { body: t.Object({ username: t.String(), password: t.String() }), }, ) .post( "/admin/users/delete", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); if (!user.isAdmin) return new Response("Forbidden", { status: 403 }); const { username } = body; if (username === ADMIN_USERNAME) { return redirect("/settings?error=Cannot+delete+admin+user"); } const targetUser = await db .selectFrom("users") .select("id") .where("username", "=", username) .executeTakeFirst(); if (!targetUser) { return redirect("/settings?error=User+not+found"); } await db .deleteFrom("users") .where("id", "=", targetUser.id) .execute(); return redirect("/settings?success=user_deleted"); }, { body: t.Object({ username: t.String() }), }, ) .post( "/admin/users/approve", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); if (!user.isAdmin) return new Response("Forbidden", { status: 403 }); await db .updateTable("users") .set({ is_pending: 0 }) .where("id", "=", body.id) .where("is_pending", "=", 1) .execute(); return redirect("/settings?success=user_approved"); }, { body: t.Object({ id: t.Numeric() }) }, ) .post( "/admin/users/deny", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); if (!user.isAdmin) return new Response("Forbidden", { status: 403 }); await db .deleteFrom("users") .where("id", "=", body.id) .where("is_pending", "=", 1) .execute(); return redirect("/settings?success=user_denied"); }, { body: t.Object({ id: t.Numeric() }) }, ) .post("/admin/users/approve-all", async ({ cookie }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); if (!user.isAdmin) return new Response("Forbidden", { status: 403 }); await db .updateTable("users") .set({ is_pending: 0 }) .where("is_pending", "=", 1) .execute(); return redirect("/settings?success=all_approved"); }) .post("/admin/users/deny-all", async ({ cookie }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); if (!user.isAdmin) return new Response("Forbidden", { status: 403 }); await db.deleteFrom("users").where("is_pending", "=", 1).execute(); return redirect("/settings?success=all_denied"); }) .post( "/settings/ssh-keys", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); const { name, public_key } = body; const keyLine = public_key.trim(); const parts = keyLine.split(/\s+/); if (!VALID_KEY_TYPES.has(parts[0] ?? "")) { return redirect("/settings?error=Unsupported+key+type"); } // Validate the key is parseable try { const parsed = sshUtils.parseKey( Buffer.from(parts[1] ?? "", "base64"), ); if (parsed instanceof Error) throw parsed; } catch { return redirect("/settings?error=Invalid+public+key"); } const fingerprint = fingerprintFromLine(keyLine); if (!fingerprint) return redirect("/settings?error=Invalid+public+key"); try { await db .insertInto("ssh_keys") .values({ user_id: user.id, name: name.trim() || "Unnamed key", public_key: keyLine, fingerprint, created_at: new Date().toISOString(), }) .execute(); } catch (err) { if ( err instanceof Error && err.message.includes( "UNIQUE constraint failed: ssh_keys.fingerprint", ) ) { return redirect( "/settings?error=This+key+is+already+registered", ); } throw err; } return redirect("/settings?success=ssh_key_added"); }, { body: t.Object({ name: t.String(), public_key: t.String() }), }, ) .post( "/settings/ssh-keys/delete", async ({ cookie, body }) => { const user = await resolveSession( cookie.session?.value as string | undefined, ); if (!user) return redirect("/login"); const key = await db .selectFrom("ssh_keys") .select(["id", "user_id"]) .where("id", "=", body.id) .executeTakeFirst(); if (!key || key.user_id !== user.id) { return redirect("/settings?error=Key+not+found"); } await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute(); return redirect("/settings?success=ssh_key_deleted"); }, { body: t.Object({ id: t.Numeric() }), }, );