From c37def317ecab2bf3ea20cbdd990a3ba94fa2d25 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:12:42 +0200 Subject: [PATCH 01/18] added user management added user management --- src/pages/root.tsx | 30 ++- src/pages/user.tsx | 561 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 574 insertions(+), 17 deletions(-) diff --git a/src/pages/root.tsx b/src/pages/root.tsx index f2a83dec..61347226 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -30,18 +30,22 @@ export const root = new Elysia().use(userService).get( } // validate jwt - let user: ({ id: string } & JWTPayloadSpec) | false = false; + let user: ({ id: string; role: string } & JWTPayloadSpec) | false = false; + if (ALLOW_UNAUTHENTICATED) { const newUserId = String( UNAUTHENTICATED_USER_SHARING ? 0 : randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)), ); + const accessToken = await jwt.sign({ id: newUserId, + role: "user", }); - user = { id: newUserId }; + user = { id: newUserId, role: "user" }; + if (!auth) { return { message: "No auth cookie, perhaps your browser is blocking cookies.", @@ -57,7 +61,24 @@ export const root = new Elysia().use(userService).get( sameSite: "strict", }); } else if (auth?.value) { - user = await jwt.verify(auth.value); + const verified = await jwt.verify(auth.value); + + // If verification fails, keep user as false + if (verified === false) { + user = false; + } else { + // Ensure role is present (defensive, for older tokens) + const verifiedUser = verified as { id?: string; role?: string } & JWTPayloadSpec; + + if (!verifiedUser.id) { + user = false; + } else if (!verifiedUser.role) { + // fallback: treat as normal user if role missing + user = { ...verifiedUser, id: verifiedUser.id, role: "user" }; + } else { + user = verifiedUser as ({ id: string; role: string } & JWTPayloadSpec); + } + } if ( user !== false && @@ -73,6 +94,9 @@ export const root = new Elysia().use(userService).get( } return redirect(`${WEBROOT}/login`, 302); } + + // Optional: if you want DB to be the source of truth for role, uncomment below: + // user = { ...user, role: existingUser.role ?? "user" }; } } diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 0fd90651..1d14ccdc 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -21,6 +21,7 @@ export const userService = new Elysia({ name: "user/service" }) name: "jwt", schema: t.Object({ id: t.String(), + role: t.String(), // user role in JWT }), secret: process.env.JWT_SECRET ?? randomUUID(), exp: "7d", @@ -183,7 +184,10 @@ export const user = new Elysia() .post( "/register", async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { - if (!ACCOUNT_REGISTRATION && !FIRST_RUN) { + // first user allowed even if ACCOUNT_REGISTRATION=false + const isFirstUser = FIRST_RUN; + + if (!ACCOUNT_REGISTRATION && !isFirstUser) { return redirect(`${WEBROOT}/login`, 302); } @@ -200,11 +204,17 @@ export const user = new Elysia() } const savedPassword = await Bun.password.hash(password); - db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(email, savedPassword); + const role = isFirstUser ? "admin" : "user"; + + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + email, + savedPassword, + role, + ); - const user = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); - if (!user) { + if (!userRow) { set.status = 500; return { message: "Failed to create user.", @@ -212,7 +222,8 @@ export const user = new Elysia() } const accessToken = await jwt.sign({ - id: String(user.id), + id: String(userRow.id), + role: userRow.role, }); if (!auth) { @@ -318,7 +329,9 @@ export const user = new Elysia() .post( "/login", async function handler({ body, set, redirect, jwt, cookie: { auth } }) { - const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email); + const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get( + body.email, + ); if (!existingUser) { set.status = 403; @@ -338,6 +351,7 @@ export const user = new Elysia() const accessToken = await jwt.sign({ id: String(existingUser.id), + role: existingUser.role ?? "user", }); if (!auth) { @@ -387,6 +401,13 @@ export const user = new Elysia() return redirect(`${WEBROOT}/`, 302); } + let otherUsers: { id: number; email: string; role: string }[] = []; + if (userData.role === "admin") { + otherUsers = db + .query("SELECT id, email, role FROM users WHERE id != ? ORDER BY email ASC") + .all(userData.id) as { id: number; email: string; role: string }[]; + } + return ( <> @@ -445,6 +466,176 @@ export const user = new Elysia() + + {userData.role === "admin" && ( + <> +
+
+

Add new user

+

+ Create additional users for this ConvertX instance. Admins can create other + admins or normal users. +

+
+
+
+ + + +
+
+ +
+
+
+ + {otherUsers.length > 0 && ( +
+
+

Manage users

+

+ Edit or delete users from this instance. You cannot delete yourself or the + last remaining admin. +

+
+
+ + + + + + + + + + {otherUsers.map((u) => ( + + + + + + ))} + +
EmailRoleActions
{u.email}{u.role} +
+ {/* Edit / details icon */} +
+ + +
+ + {/* Delete icon */} +
+ + +
+
+
+
+
+ )} + + )}
@@ -461,11 +652,13 @@ export const user = new Elysia() return redirect(`${WEBROOT}/login`, 302); } - const user = await jwt.verify(auth.value); - if (!user) { + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { return redirect(`${WEBROOT}/login`, 302); } - const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id); + const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get( + tokenUser.id, + ); if (!existingUser) { if (auth?.value) { @@ -483,15 +676,16 @@ export const user = new Elysia() }; } - const fields = []; - const values = []; + const fields: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const values: any[] = []; if (body.email) { - const existingUser = await db + const existingUserWithEmail = await db .query("SELECT id FROM users WHERE email = ?") .as(User) .get(body.email); - if (existingUser && existingUser.id.toString() !== user.id) { + if (existingUserWithEmail && existingUserWithEmail.id.toString() !== tokenUser.id) { set.status = 409; return { message: "Email already in use." }; } @@ -506,7 +700,7 @@ export const user = new Elysia() if (fields.length > 0) { db.query( `UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`, - ).run(...values, user.id); + ).run(...values, tokenUser.id); } return redirect(`${WEBROOT}/`, 302); @@ -519,4 +713,343 @@ export const user = new Elysia() }), cookie: "session", }, + ) + .post( + "/account/add-user", + async ({ body, set, redirect, jwt, cookie: { auth } }) => { + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } + + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); + + if (!actingUser) { + if (auth?.value) { + auth.remove(); + } + return redirect(`${WEBROOT}/login`, 302); + } + + if (actingUser.role !== "admin") { + set.status = 403; + return { + message: "Only admins can create new users.", + }; + } + + const { newUserEmail, newUserPassword, newUserRole } = body as { + newUserEmail: string; + newUserPassword: string; + newUserRole: string; + }; + + if (!newUserEmail || !newUserPassword) { + set.status = 400; + return { + message: "Missing email or password.", + }; + } + + const existingNewUser = db.query("SELECT id FROM users WHERE email = ?").get(newUserEmail); + if (existingNewUser) { + set.status = 400; + return { + message: "A user with this email already exists.", + }; + } + + const hashedPassword = await Bun.password.hash(newUserPassword); + const role = newUserRole === "admin" ? "admin" : "user"; + + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + newUserEmail, + hashedPassword, + role, + ); + + return redirect(`${WEBROOT}/account`, 302); + }, + { + body: t.Object({ + newUserEmail: t.String(), + newUserPassword: t.String(), + newUserRole: t.String(), + }), + cookie: "session", + }, + ) + .get( + "/account/edit-user", + async ({ query, user, redirect }) => { + if (!user) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id); + if (!actingUser || actingUser.role !== "admin") { + return redirect(`${WEBROOT}/account`, 302); + } + + const targetId = Number.parseInt(query.userId, 10); + if (!Number.isFinite(targetId) || targetId === actingUser.id) { + return redirect(`${WEBROOT}/account`, 302); + } + + const targetUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(targetId); + if (!targetUser) { + return redirect(`${WEBROOT}/account`, 302); + } + + return ( + + <> +
+
+
+
+

Edit user

+

+ Change this user's role or set a new password. Leave password blank to keep + it unchanged. +

+
+
+ +
+ + + +
+
+ + Cancel + + +
+
+
+
+ + + ); + }, + { + auth: true, + query: t.Object({ + userId: t.String(), + }), + }, + ) + .post( + "/account/edit-user", + async ({ body, set, redirect, jwt, cookie: { auth } }) => { + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } + + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); + if (!actingUser || actingUser.role !== "admin") { + set.status = 403; + return { message: "Only admins can edit users." }; + } + + const { userId, role, newPassword } = body as { + userId: string; + role: string; + newPassword?: string; + }; + + const targetId = Number.parseInt(userId, 10); + if (!Number.isFinite(targetId) || targetId === actingUser.id) { + return redirect(`${WEBROOT}/account`, 302); + } + + const targetUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(targetId); + if (!targetUser) { + return redirect(`${WEBROOT}/account`, 302); + } + + // Prevent demoting the last admin + if (targetUser.role === "admin" && role !== "admin") { + const adminCountRow = db + .query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'") + .get() as { cnt: number }; + if (adminCountRow.cnt <= 1) { + set.status = 400; + return { message: "You cannot demote the last remaining admin." }; + } + } + + const fields: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const values: any[] = []; + + if (role === "admin" || role === "user") { + fields.push("role"); + values.push(role); + } + + if (newPassword && newPassword.trim().length > 0) { + fields.push("password"); + values.push(await Bun.password.hash(newPassword)); + } + + if (fields.length > 0) { + db.query( + `UPDATE users SET ${fields.map((f) => `${f}=?`).join(", ")} WHERE id=?`, + ).run(...values, targetId); + } + + return redirect(`${WEBROOT}/account`, 302); + }, + { + body: t.Object({ + userId: t.String(), + role: t.String(), + newPassword: t.MaybeEmpty(t.String()), + }), + cookie: "session", + }, + ) + .post( + "/account/delete-user", + async ({ body, set, redirect, jwt, cookie: { auth } }) => { + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } + + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); + + if (!actingUser) { + if (auth?.value) { + auth.remove(); + } + return redirect(`${WEBROOT}/login`, 302); + } + + if (actingUser.role !== "admin") { + set.status = 403; + return { message: "Only admins can delete users." }; + } + + const { deleteUserId } = body as { deleteUserId: string }; + const targetId = Number.parseInt(deleteUserId, 10); + + if (!Number.isFinite(targetId)) { + set.status = 400; + return { message: "Invalid user id." }; + } + + if (targetId === actingUser.id) { + set.status = 400; + return { message: "You cannot delete your own account from here." }; + } + + const targetUser = db + .query("SELECT * FROM users WHERE id = ?") + .as(User) + .get(targetId as unknown as number); + + if (!targetUser) { + return redirect(`${WEBROOT}/account`, 302); + } + + if (targetUser.role === "admin") { + const adminCountRow = db + .query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'") + .get() as { cnt: number }; + if (adminCountRow.cnt <= 1) { + set.status = 400; + return { message: "You cannot delete the last remaining admin." }; + } + } + + // delete this user's jobs and files (to avoid FK issues) in a single transaction + const deleteUserTx = db.transaction((id: number) => { + db.query( + "DELETE FROM file_names WHERE job_id IN (SELECT id FROM jobs WHERE user_id = ?)", + ).run(id); + db.query("DELETE FROM jobs WHERE user_id = ?").run(id); + db.query("DELETE FROM users WHERE id = ?").run(id); + }); + + deleteUserTx(targetId); + + return redirect(`${WEBROOT}/account`, 302); + }, + { + body: t.Object({ + deleteUserId: t.String(), + }), + cookie: "session", + }, ); + From 1896ebf469bb12dab1655b6aeedaefa0b57be4ff Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:13:53 +0200 Subject: [PATCH 02/18] updated db to work with the new user management functionality updated db to work with the new user management functionality --- src/db/db.ts | 13 +++++++++++++ src/db/types.ts | 2 ++ 2 files changed, 15 insertions(+) diff --git a/src/db/db.ts b/src/db/db.ts index de572685..2c4f6f00 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -31,12 +31,25 @@ PRAGMA user_version = 1;`); } const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version; + +// existing migration: add status column to file_names if (dbVersion === 0) { db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';"); db.exec("PRAGMA user_version = 1;"); console.log("Updated database to version 1."); } +/** + * Ensure `role` column exists on users table. + * This works for both fresh installs and existing DBs, without touching user_version. + */ +const userColumns = db.query("PRAGMA table_info(users)").all() as { name: string }[]; +const hasRoleColumn = userColumns.some((col) => col.name === "role"); +if (!hasRoleColumn) { + db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';"); + console.log("Added 'role' column to users table."); +} + // enable WAL mode db.exec("PRAGMA journal_mode = WAL;"); diff --git a/src/db/types.ts b/src/db/types.ts index 48257119..3993b810 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -20,4 +20,6 @@ export class User { id!: number; email!: string; password!: string; + role!: string; // 'admin' | 'user' } + From 76686a0cec9ae13241b0b3c4d5076d6ce4ab64f1 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:09:46 +0200 Subject: [PATCH 03/18] Add files via upload --- src/pages/user.tsx | 64 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 1d14ccdc..bea6baae 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -13,9 +13,20 @@ import { WEBROOT, } from "../helpers/env"; -export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false; +function computeFirstRun(): boolean { + // DB-driven, so it stays correct across reloads and per-request updates. + return db.query("SELECT 1 FROM users LIMIT 1").get() === null; +} + +// Kept as an exported boolean for backwards-compatibility (e.g. root.tsx imports FIRST_RUN), +// but it is refreshed per-request via userService. Do not rely on it being constant. +export let FIRST_RUN = computeFirstRun(); export const userService = new Elysia({ name: "user/service" }) + .derive(() => { + FIRST_RUN = computeFirstRun(); + return {}; + }) .use( jwt({ name: "jwt", @@ -67,7 +78,8 @@ export const userService = new Elysia({ name: "user/service" }) export const user = new Elysia() .use(userService) .get("/setup", ({ redirect }) => { - if (!FIRST_RUN) { + const isFirstRun = computeFirstRun(); + if (!isFirstRun) { return redirect(`${WEBROOT}/login`, 302); } @@ -182,27 +194,31 @@ export const user = new Elysia() ); }) .post( - "/register", - async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { - // first user allowed even if ACCOUNT_REGISTRATION=false - const isFirstUser = FIRST_RUN; + "/register", + async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { + // DB-driven "first user" detection (no stale in-memory flag) + race-safe creation. + // We hash outside the write-lock to keep the lock window short. + const savedPassword = await Bun.password.hash(password); + + // Acquire a write lock so only one instance can perform "count==0 then insert" at a time. + db.exec("BEGIN IMMEDIATE"); + try { + const isFirstUser = computeFirstRun(); + // first user allowed even if ACCOUNT_REGISTRATION=false if (!ACCOUNT_REGISTRATION && !isFirstUser) { + db.exec("ROLLBACK"); return redirect(`${WEBROOT}/login`, 302); } - if (FIRST_RUN) { - FIRST_RUN = false; - } - - const existingUser = await db.query("SELECT * FROM users WHERE email = ?").get(email); + const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); if (existingUser) { + db.exec("ROLLBACK"); set.status = 400; return { message: "Email already in use.", }; } - const savedPassword = await Bun.password.hash(password); const role = isFirstUser ? "admin" : "user"; @@ -215,15 +231,19 @@ export const user = new Elysia() const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); if (!userRow) { + db.exec("ROLLBACK"); set.status = 500; return { message: "Failed to create user.", }; } + db.exec("COMMIT"); + FIRST_RUN = false; + const accessToken = await jwt.sign({ id: String(userRow.id), - role: userRow.role, + role: userRow.role ?? "user", }); if (!auth) { @@ -243,13 +263,23 @@ export const user = new Elysia() }); return redirect(`${WEBROOT}/`, 302); - }, - { body: "signIn" }, - ) + } catch (e) { + try { + db.exec("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; + } + }, + { body: "signIn" }, +) + + .get( "/login", async ({ jwt, redirect, cookie: { auth } }) => { - if (FIRST_RUN) { + if (computeFirstRun()) { return redirect(`${WEBROOT}/setup`, 302); } From a09aa1ef502e7e6e3a8b922173c6b52f41fab205 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:28:16 +0200 Subject: [PATCH 04/18] Remove comment about role column in users table Removed unnecessary comment about ensuring 'role' column in users table. --- src/db/db.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/db/db.ts b/src/db/db.ts index 2c4f6f00..6a0fc604 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -39,10 +39,6 @@ if (dbVersion === 0) { console.log("Updated database to version 1."); } -/** - * Ensure `role` column exists on users table. - * This works for both fresh installs and existing DBs, without touching user_version. - */ const userColumns = db.query("PRAGMA table_info(users)").all() as { name: string }[]; const hasRoleColumn = userColumns.some((col) => col.name === "role"); if (!hasRoleColumn) { From 721e8edf336fd94211f1a598363ad9c5ca753d3a Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:35:42 +0200 Subject: [PATCH 05/18] Simplify class attribute assignment in user form --- src/pages/user.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/user.tsx b/src/pages/user.tsx index bea6baae..d14f22b8 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -862,9 +862,7 @@ export const user = new Elysia()
From d5e334d882f215f49212ca4b2dc2ad567706f58a Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:54:00 +0200 Subject: [PATCH 06/18] Add files via upload From 611bd57ba638ed85651be54f64c8a221b5dbbe4b Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:54:32 +0200 Subject: [PATCH 07/18] Add files via upload --- src/pages/user.tsx | 166 ++++++++++++++++++++++----------------------- 1 file changed, 82 insertions(+), 84 deletions(-) diff --git a/src/pages/user.tsx b/src/pages/user.tsx index d14f22b8..e31389e5 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -193,90 +193,88 @@ export const user = new Elysia() ); }) - .post( - "/register", - async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { - // DB-driven "first user" detection (no stale in-memory flag) + race-safe creation. - // We hash outside the write-lock to keep the lock window short. - const savedPassword = await Bun.password.hash(password); - - // Acquire a write lock so only one instance can perform "count==0 then insert" at a time. - db.exec("BEGIN IMMEDIATE"); - try { - const isFirstUser = computeFirstRun(); - - // first user allowed even if ACCOUNT_REGISTRATION=false - if (!ACCOUNT_REGISTRATION && !isFirstUser) { - db.exec("ROLLBACK"); - return redirect(`${WEBROOT}/login`, 302); - } - - const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); - if (existingUser) { - db.exec("ROLLBACK"); - set.status = 400; - return { - message: "Email already in use.", - }; - } - - const role = isFirstUser ? "admin" : "user"; - - db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( - email, - savedPassword, - role, - ); - - const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); - - if (!userRow) { - db.exec("ROLLBACK"); - set.status = 500; - return { - message: "Failed to create user.", - }; - } - - db.exec("COMMIT"); - FIRST_RUN = false; - - const accessToken = await jwt.sign({ - id: String(userRow.id), - role: userRow.role ?? "user", - }); - - if (!auth) { - set.status = 500; - return { - message: "No auth cookie, perhaps your browser is blocking cookies.", - }; - } - - // set cookie - auth.set({ - value: accessToken, - httpOnly: true, - secure: !HTTP_ALLOWED, - maxAge: 60 * 60 * 24 * 7, - sameSite: "strict", - }); - - return redirect(`${WEBROOT}/`, 302); - } catch (e) { - try { - db.exec("ROLLBACK"); - } catch { - // ignore rollback errors - } - throw e; - } - }, - { body: "signIn" }, -) - - - .get( + .post( + "/register", + async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { + // DB-driven "first user" detection (no stale in-memory flag) + race-safe creation. + // We hash outside the write-lock to keep the lock window short. + const savedPassword = await Bun.password.hash(password); + + // Acquire a write lock so only one instance can perform "count==0 then insert" at a time. + db.exec("BEGIN IMMEDIATE"); + try { + const isFirstUser = computeFirstRun(); + + // first user allowed even if ACCOUNT_REGISTRATION=false + if (!ACCOUNT_REGISTRATION && !isFirstUser) { + db.exec("ROLLBACK"); + return redirect(`${WEBROOT}/login`, 302); + } + + const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); + if (existingUser) { + db.exec("ROLLBACK"); + set.status = 400; + return { + message: "Email already in use.", + }; + } + + const role = isFirstUser ? "admin" : "user"; + + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + email, + savedPassword, + role, + ); + + const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + + if (!userRow) { + db.exec("ROLLBACK"); + set.status = 500; + return { + message: "Failed to create user.", + }; + } + + db.exec("COMMIT"); + FIRST_RUN = false; + + const accessToken = await jwt.sign({ + id: String(userRow.id), + role: userRow.role ?? "user", + }); + + if (!auth) { + set.status = 500; + return { + message: "No auth cookie, perhaps your browser is blocking cookies.", + }; + } + + // set cookie + auth.set({ + value: accessToken, + httpOnly: true, + secure: !HTTP_ALLOWED, + maxAge: 60 * 60 * 24 * 7, + sameSite: "strict", + }); + + return redirect(`${WEBROOT}/`, 302); + } catch (e) { + try { + db.exec("ROLLBACK"); + } catch (rollbackErr) { + console.warn("[user/register] ROLLBACK failed:", rollbackErr); + } + throw e; + } + }, + { body: "signIn" }, + ) +.get( "/login", async ({ jwt, redirect, cookie: { auth } }) => { if (computeFirstRun()) { From 59b2fd19c9860459e60372bf31d2af3976fd64da Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:20:12 +0200 Subject: [PATCH 08/18] Add files via upload --- src/db/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/db/types.ts b/src/db/types.ts index 3993b810..3324fd6e 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -22,4 +22,3 @@ export class User { password!: string; role!: string; // 'admin' | 'user' } - From 8baa7c2c55d3a781ca4bb717a370c29271736e50 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:21:04 +0200 Subject: [PATCH 09/18] Add files via upload --- src/pages/root.tsx | 2 +- src/pages/user.tsx | 198 ++++++++++++++++++++------------------------- 2 files changed, 89 insertions(+), 111 deletions(-) diff --git a/src/pages/root.tsx b/src/pages/root.tsx index 61347226..22528514 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -76,7 +76,7 @@ export const root = new Elysia().use(userService).get( // fallback: treat as normal user if role missing user = { ...verifiedUser, id: verifiedUser.id, role: "user" }; } else { - user = verifiedUser as ({ id: string; role: string } & JWTPayloadSpec); + user = verifiedUser as { id: string; role: string } & JWTPayloadSpec; } } diff --git a/src/pages/user.tsx b/src/pages/user.tsx index e31389e5..133852dd 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -193,88 +193,88 @@ export const user = new Elysia() ); }) - .post( + .post( "/register", async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { - // DB-driven "first user" detection (no stale in-memory flag) + race-safe creation. - // We hash outside the write-lock to keep the lock window short. - const savedPassword = await Bun.password.hash(password); - - // Acquire a write lock so only one instance can perform "count==0 then insert" at a time. - db.exec("BEGIN IMMEDIATE"); - try { - const isFirstUser = computeFirstRun(); - - // first user allowed even if ACCOUNT_REGISTRATION=false - if (!ACCOUNT_REGISTRATION && !isFirstUser) { - db.exec("ROLLBACK"); - return redirect(`${WEBROOT}/login`, 302); - } - - const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); - if (existingUser) { - db.exec("ROLLBACK"); - set.status = 400; - return { - message: "Email already in use.", - }; - } - - const role = isFirstUser ? "admin" : "user"; - - db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( - email, - savedPassword, - role, - ); - - const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); - - if (!userRow) { - db.exec("ROLLBACK"); - set.status = 500; - return { - message: "Failed to create user.", - }; - } - - db.exec("COMMIT"); - FIRST_RUN = false; - - const accessToken = await jwt.sign({ - id: String(userRow.id), - role: userRow.role ?? "user", - }); - - if (!auth) { - set.status = 500; - return { - message: "No auth cookie, perhaps your browser is blocking cookies.", - }; - } - - // set cookie - auth.set({ - value: accessToken, - httpOnly: true, - secure: !HTTP_ALLOWED, - maxAge: 60 * 60 * 24 * 7, - sameSite: "strict", - }); - - return redirect(`${WEBROOT}/`, 302); - } catch (e) { - try { - db.exec("ROLLBACK"); - } catch (rollbackErr) { - console.warn("[user/register] ROLLBACK failed:", rollbackErr); - } - throw e; - } + // DB-driven "first user" detection (no stale in-memory flag) + race-safe creation. + // We hash outside the write-lock to keep the lock window short. + const savedPassword = await Bun.password.hash(password); + + // Acquire a write lock so only one instance can perform "count==0 then insert" at a time. + db.exec("BEGIN IMMEDIATE"); + try { + const isFirstUser = computeFirstRun(); + + // first user allowed even if ACCOUNT_REGISTRATION=false + if (!ACCOUNT_REGISTRATION && !isFirstUser) { + db.exec("ROLLBACK"); + return redirect(`${WEBROOT}/login`, 302); + } + + const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); + if (existingUser) { + db.exec("ROLLBACK"); + set.status = 400; + return { + message: "Email already in use.", + }; + } + + const role = isFirstUser ? "admin" : "user"; + + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + email, + savedPassword, + role, + ); + + const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + + if (!userRow) { + db.exec("ROLLBACK"); + set.status = 500; + return { + message: "Failed to create user.", + }; + } + + db.exec("COMMIT"); + FIRST_RUN = false; + + const accessToken = await jwt.sign({ + id: String(userRow.id), + role: userRow.role ?? "user", + }); + + if (!auth) { + set.status = 500; + return { + message: "No auth cookie, perhaps your browser is blocking cookies.", + }; + } + + // set cookie + auth.set({ + value: accessToken, + httpOnly: true, + secure: !HTTP_ALLOWED, + maxAge: 60 * 60 * 24 * 7, + sameSite: "strict", + }); + + return redirect(`${WEBROOT}/`, 302); + } catch (e) { + try { + db.exec("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; + } }, { body: "signIn" }, ) -.get( + .get( "/login", async ({ jwt, redirect, cookie: { auth } }) => { if (computeFirstRun()) { @@ -357,9 +357,7 @@ export const user = new Elysia() .post( "/login", async function handler({ body, set, redirect, jwt, cookie: { auth } }) { - const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get( - body.email, - ); + const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email); if (!existingUser) { set.status = 403; @@ -535,11 +533,7 @@ export const user = new Elysia()
- + Cancel - {/* Delete icon */}
@@ -817,9 +803,7 @@ export const user = new Elysia()
From 4c6513a69377e760aae68b4b0de06c99cc865fce Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:38:37 +0200 Subject: [PATCH 15/18] Add files via upload --- src/pages/user.tsx | 273 ++++++++++++++++++++++++--------------------- 1 file changed, 144 insertions(+), 129 deletions(-) diff --git a/src/pages/user.tsx b/src/pages/user.tsx index c1330972..0650afc8 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -13,19 +13,9 @@ import { WEBROOT, } from "../helpers/env"; -function computeFirstRun(): boolean { - // DB-driven, so it stays correct across reloads and multi-process setups. - return db.query("SELECT 1 FROM users LIMIT 1").get() === null; -} - -// Exported for backwards compatibility; refreshed per-request via userService.derive(). -export let FIRST_RUN = computeFirstRun(); +export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false; export const userService = new Elysia({ name: "user/service" }) - .derive(() => { - FIRST_RUN = computeFirstRun(); - return {}; - }) .use( jwt({ name: "jwt", @@ -77,7 +67,7 @@ export const userService = new Elysia({ name: "user/service" }) export const user = new Elysia() .use(userService) .get("/setup", ({ redirect }) => { - if (!computeFirstRun()) { + if (!FIRST_RUN) { return redirect(`${WEBROOT}/login`, 302); } @@ -192,28 +182,27 @@ export const user = new Elysia() ); }) .post( - "/register", - async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { - // DB-driven "first user" detection + race-safe creation. - // Hash outside the write-lock to keep the lock window short. - const savedPassword = await Bun.password.hash(password); - - db.exec("BEGIN IMMEDIATE"); - try { - const isFirstUser = computeFirstRun(); - + "/register", + async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { // first user allowed even if ACCOUNT_REGISTRATION=false + const isFirstUser = FIRST_RUN; + if (!ACCOUNT_REGISTRATION && !isFirstUser) { - db.exec("ROLLBACK"); return redirect(`${WEBROOT}/login`, 302); } - const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); + if (FIRST_RUN) { + FIRST_RUN = false; + } + + const existingUser = await db.query("SELECT * FROM users WHERE email = ?").get(email); if (existingUser) { - db.exec("ROLLBACK"); set.status = 400; - return { message: "Email already in use." }; + return { + message: "Email already in use.", + }; } + const savedPassword = await Bun.password.hash(password); const role = isFirstUser ? "admin" : "user"; @@ -224,25 +213,24 @@ export const user = new Elysia() ); const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + if (!userRow) { - db.exec("ROLLBACK"); set.status = 500; - return { message: "Failed to create user." }; + return { + message: "Failed to create user.", + }; } - db.exec("COMMIT"); - - // Refresh after successful creation - FIRST_RUN = computeFirstRun(); - const accessToken = await jwt.sign({ id: String(userRow.id), - role: userRow.role ?? "user", + role: userRow.role, }); if (!auth) { set.status = 500; - return { message: "No auth cookie, perhaps your browser is blocking cookies." }; + return { + message: "No auth cookie, perhaps your browser is blocking cookies.", + }; } // set cookie @@ -255,18 +243,9 @@ export const user = new Elysia() }); return redirect(`${WEBROOT}/`, 302); - } catch (e) { - try { - db.exec("ROLLBACK"); - } catch (rollbackErr) { - console.warn("[user/register] ROLLBACK failed:", rollbackErr); - } - throw e; - } - }, - { body: "signIn" }, -) - + }, + { body: "signIn" }, + ) .get( "/login", async ({ jwt, redirect, cookie: { auth } }) => { @@ -350,7 +329,9 @@ export const user = new Elysia() .post( "/login", async function handler({ body, set, redirect, jwt, cookie: { auth } }) { - const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email); + const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get( + body.email, + ); if (!existingUser) { set.status = 403; @@ -526,7 +507,11 @@ export const user = new Elysia()
- + Cancel