diff --git a/src/db/db.ts b/src/db/db.ts index de572685..84012838 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -4,40 +4,70 @@ import { Database } from "bun:sqlite"; mkdirSync("./data", { recursive: true }); const db = new Database("./data/mydb.sqlite", { create: true }); -if (!db.query("SELECT * FROM sqlite_master WHERE type='table'").get()) { +// Always keep foreign keys on (SQLite defaults to off). +db.exec("PRAGMA foreign_keys = ON;"); + +const hasAnyTable = db.query("SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1").get(); +if (!hasAnyTable) { db.exec(` CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, - password TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' ); + +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + date_created TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + num_files INTEGER NOT NULL DEFAULT 0, + finished_files INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS file_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, file_name TEXT NOT NULL, output_file_name TEXT NOT NULL, - status TEXT DEFAULT 'not started', - FOREIGN KEY (job_id) REFERENCES jobs(id) -); -CREATE TABLE IF NOT EXISTS jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - date_created TEXT NOT NULL, - status TEXT DEFAULT 'not started', - num_files INTEGER DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users(id) + status TEXT NOT NULL DEFAULT 'queued', + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE ); -PRAGMA user_version = 1;`); + +PRAGMA user_version = 1; +`); } -const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version; -if (dbVersion === 0) { - db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';"); + +const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }) + .user_version; + +if ((dbVersion ?? 0) === 0) { db.exec("PRAGMA user_version = 1;"); console.log("Updated database to version 1."); } -// enable WAL mode +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';"); + + const oldest = db + .query("SELECT id FROM users ORDER BY id ASC LIMIT 1") + .get() as { id: number } | null; + + if (oldest) { + db.query("UPDATE users SET role = 'admin' WHERE id = ?").run(oldest.id); + console.log("Added 'role' column; promoted oldest existing user to admin (Policy A)."); + } else { + console.log("Added 'role' column to users table (no users to promote)."); + } +} + +// enable WAL mode (better concurrency for Bun + SQLite) db.exec("PRAGMA journal_mode = WAL;"); export default db; diff --git a/src/db/types.ts b/src/db/types.ts index 48257119..3324fd6e 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -20,4 +20,5 @@ export class User { id!: number; email!: string; password!: string; + role!: string; // 'admin' | 'user' } diff --git a/src/pages/root.tsx b/src/pages/root.tsx index f2a83dec..22528514 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..1e5e59d1 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"; - const user = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + email, + savedPassword, + role, + ); - if (!user) { + const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + + 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) { @@ -338,6 +349,7 @@ export const user = new Elysia() const accessToken = await jwt.sign({ id: String(existingUser.id), + role: existingUser.role ?? "user", }); if (!auth) { @@ -387,6 +399,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 +464,165 @@ 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 +639,11 @@ 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 +661,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 +685,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 +698,351 @@ 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); + } + + // IMPORTANT: do not hold a SQLite write transaction open across an `await`. + // Hash any password outside the transaction to avoid lock contention. + let hashedPassword: string | null = null; + if (newPassword && newPassword.trim().length > 0) { + hashedPassword = await Bun.password.hash(newPassword); + } + + // Atomic last-admin protection: concurrent demotions must not be able to leave zero admins. + // Serialize writers and make demotion conditional in a single statement. + db.exec("BEGIN IMMEDIATE"); + try { + // Role change + if (role === "admin") { + db.query("UPDATE users SET role = 'admin' WHERE id = ?").run(targetId); + } else if (role === "user") { + const demoteRes = db + .query( + `UPDATE users + SET role = 'user' + WHERE id = ? + AND role = 'admin' + AND (SELECT COUNT(*) FROM users WHERE role = 'admin') > 1`, + ) + .run(targetId); + + if (targetUser.role === "admin" && demoteRes.changes === 0) { + db.exec("ROLLBACK"); + set.status = 400; + return { message: "You cannot demote the last remaining admin." }; + } + } + + // Password change (optional) + if (hashedPassword) { + db.query("UPDATE users SET password = ? WHERE id = ?").run(hashedPassword, targetId); + } + + db.exec("COMMIT"); + } catch (e) { + try { + db.exec("ROLLBACK"); + } catch (rollbackErr) { + console.warn("[user/edit-user] ROLLBACK failed:", rollbackErr); + } + throw e; + } + + 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", + }, );