This commit is contained in:
Kosztyk 2026-06-24 17:07:32 +01:00 committed by GitHub
commit feee452d2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 616 additions and 35 deletions

View file

@ -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)
status TEXT NOT NULL DEFAULT 'queued',
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
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)
);
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;

View file

@ -20,4 +20,5 @@ export class User {
id!: number;
email!: string;
password!: string;
role!: string; // 'admin' | 'user'
}

View file

@ -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" };
}
}

View file

@ -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 (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
@ -445,6 +464,165 @@ export const user = new Elysia()
</div>
</form>
</article>
{userData.role === "admin" && (
<>
<article class="article mt-6">
<header class="mb-4">
<h2 class="text-xl font-semibold">Add new user</h2>
<p class="text-sm text-neutral-400">
Create additional users for this ConvertX instance. Admins can create other
admins or normal users.
</p>
</header>
<form
method="post"
action={`${WEBROOT}/account/add-user`}
class="flex flex-col gap-4"
>
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="newUserEmail"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="newUserPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
required
/>
</label>
<label class="flex flex-col gap-1">
Role
<select name="newUserRole" class="rounded-sm bg-neutral-800 p-3" required>
<option value="user">Normal user</option>
<option value="admin">Admin</option>
</select>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Add user" class="w-full btn-secondary" />
</div>
</form>
</article>
{otherUsers.length > 0 && (
<article class="article mt-6">
<header class="mb-4">
<h2 class="text-xl font-semibold">Manage users</h2>
<p class="text-sm text-neutral-400">
Edit or delete users from this instance. You cannot delete yourself or the
last remaining admin.
</p>
</header>
<div class="scrollbar-thin max-h-[50vh] overflow-y-auto">
<table
class={`
w-full table-auto rounded bg-neutral-900
[&_td]:border-b [&_td]:border-neutral-800 [&_td]:p-2
[&_th]:border-b [&_th]:border-neutral-800 [&_th]:p-2
`}
>
<thead>
<tr>
<th class="text-left">Email</th>
<th class="text-left">Role</th>
<th class="text-left">Actions</th>
</tr>
</thead>
<tbody>
{otherUsers.map((u) => (
<tr>
<td>{u.email}</td>
<td class="capitalize">{u.role}</td>
<td>
<div class="flex items-center gap-6">
{/* Edit / details icon */}
<form method="get" action={`${WEBROOT}/account/edit-user`}>
<input type="hidden" name="userId" value={String(u.id)} />
<button
type="submit"
class={`
inline-flex items-center justify-center text-accent-400
hover:text-accent-500
`}
title="Edit user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5s8.268 2.943 9.542 7c-1.274 4.057-5.065 7-9.542 7s-8.268-2.943-9.542-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</form>
{/* Delete icon */}
<form
method="post"
action={`${WEBROOT}/account/delete-user`}
onsubmit="return confirm('Are you sure you want to delete this user?');"
>
<input
type="hidden"
name="deleteUserId"
value={String(u.id)}
/>
<button
type="submit"
class={`
inline-flex items-center justify-center text-accent-400
hover:text-accent-500
`}
title="Delete user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12" />
<path d="M9 4h6a1 1 0 0 1 1 1v2H8V5a1 1 0 0 1 1-1z" />
</svg>
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
)}
</>
)}
</main>
</>
</BaseHtml>
@ -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 (
<BaseHtml webroot={WEBROOT} title="ConvertX | Edit user">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<header class="mb-4">
<h1 class="text-xl font-semibold">Edit user</h1>
<p class="text-sm text-neutral-400">
Change this user&apos;s role or set a new password. Leave password blank to keep
it unchanged.
</p>
</header>
<form
method="post"
action={`${WEBROOT}/account/edit-user`}
class={`
flex flex-col gap-4
`}
>
<input type="hidden" name="userId" value={String(targetUser.id)} />
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
value={targetUser.email}
class="rounded-sm bg-neutral-800 p-3"
disabled
/>
</label>
<label class="flex flex-col gap-1">
Role
<select name="role" class="rounded-sm bg-neutral-800 p-3 capitalize" required>
<option value="user" selected={targetUser.role === "user"}>
Normal user
</option>
<option value="admin" selected={targetUser.role === "admin"}>
Admin
</option>
</select>
</label>
<label class="flex flex-col gap-1">
New password (optional)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Leave blank to keep current password"
autocomplete="new-password"
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
<a href={`${WEBROOT}/account`} class="w-full btn-secondary text-center">
Cancel
</a>
<button type="submit" class="w-full btn-primary">
Save changes
</button>
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
},
{
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",
},
);