Add files via upload

This commit is contained in:
Kosztyk 2026-01-14 14:38:37 +02:00 committed by GitHub
parent 03e964bad7
commit 4c6513a693
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -13,19 +13,9 @@ import {
WEBROOT, WEBROOT,
} from "../helpers/env"; } from "../helpers/env";
function computeFirstRun(): boolean { export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
// 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 const userService = new Elysia({ name: "user/service" }) export const userService = new Elysia({ name: "user/service" })
.derive(() => {
FIRST_RUN = computeFirstRun();
return {};
})
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
@ -77,7 +67,7 @@ export const userService = new Elysia({ name: "user/service" })
export const user = new Elysia() export const user = new Elysia()
.use(userService) .use(userService)
.get("/setup", ({ redirect }) => { .get("/setup", ({ redirect }) => {
if (!computeFirstRun()) { if (!FIRST_RUN) {
return redirect(`${WEBROOT}/login`, 302); return redirect(`${WEBROOT}/login`, 302);
} }
@ -192,29 +182,28 @@ export const user = new Elysia()
); );
}) })
.post( .post(
"/register", "/register",
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { 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();
// first user allowed even if ACCOUNT_REGISTRATION=false // first user allowed even if ACCOUNT_REGISTRATION=false
const isFirstUser = FIRST_RUN;
if (!ACCOUNT_REGISTRATION && !isFirstUser) { if (!ACCOUNT_REGISTRATION && !isFirstUser) {
db.exec("ROLLBACK");
return redirect(`${WEBROOT}/login`, 302); return redirect(`${WEBROOT}/login`, 302);
} }
const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email); if (FIRST_RUN) {
if (existingUser) { FIRST_RUN = false;
db.exec("ROLLBACK");
set.status = 400;
return { message: "Email already in use." };
} }
const existingUser = await db.query("SELECT * FROM users WHERE email = ?").get(email);
if (existingUser) {
set.status = 400;
return {
message: "Email already in use.",
};
}
const savedPassword = await Bun.password.hash(password);
const role = isFirstUser ? "admin" : "user"; const role = isFirstUser ? "admin" : "user";
db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run(
@ -224,25 +213,24 @@ export const user = new Elysia()
); );
const userRow = 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 (!userRow) { if (!userRow) {
db.exec("ROLLBACK");
set.status = 500; 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({ const accessToken = await jwt.sign({
id: String(userRow.id), id: String(userRow.id),
role: userRow.role ?? "user", role: userRow.role,
}); });
if (!auth) { if (!auth) {
set.status = 500; 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 // set cookie
@ -255,18 +243,9 @@ export const user = new Elysia()
}); });
return redirect(`${WEBROOT}/`, 302); return redirect(`${WEBROOT}/`, 302);
} catch (e) { },
try { { body: "signIn" },
db.exec("ROLLBACK"); )
} catch (rollbackErr) {
console.warn("[user/register] ROLLBACK failed:", rollbackErr);
}
throw e;
}
},
{ body: "signIn" },
)
.get( .get(
"/login", "/login",
async ({ jwt, redirect, cookie: { auth } }) => { async ({ jwt, redirect, cookie: { auth } }) => {
@ -350,7 +329,9 @@ export const user = new Elysia()
.post( .post(
"/login", "/login",
async function handler({ body, set, redirect, jwt, cookie: { auth } }) { 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) { if (!existingUser) {
set.status = 403; set.status = 403;
@ -526,7 +507,11 @@ export const user = new Elysia()
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Role Role
<select name="newUserRole" class="rounded-sm bg-neutral-800 p-3" required> <select
name="newUserRole"
class="rounded-sm bg-neutral-800 p-3"
required
>
<option value="user">Normal user</option> <option value="user">Normal user</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
@ -567,39 +552,75 @@ export const user = new Elysia()
<tr> <tr>
<td>{u.email}</td> <td>{u.email}</td>
<td class="capitalize">{u.role}</td> <td class="capitalize">{u.role}</td>
<td>
<div class="flex flex-wrap items-center gap-3">
<form method="get" action={`${WEBROOT}/account/edit-user`}>
<input type="hidden" name="userId" value={String(u.id)} />
<button
type="submit"
class="btn-secondary px-3 py-2"
title="Edit user"
>
Edit
</button>
</form>
<form <td>
method="post" <div class="flex items-center gap-6">
action={`${WEBROOT}/account/delete-user`} {/* Edit / details icon */}
onsubmit="return confirm('Are you sure you want to delete this user?');" <form method="get" action={`${WEBROOT}/account/edit-user`}>
> <input type="hidden" name="userId" value={String(u.id)} />
<input <button
type="hidden" type="submit"
name="deleteUserId" class={`
value={String(u.id)} inline-flex items-center justify-center text-accent-400
/> hover:text-accent-500
<button `}
type="submit" title="Edit user"
class="btn-secondary px-3 py-2" >
title="Delete user" <svg
> xmlns="http://www.w3.org/2000/svg"
Delete viewBox="0 0 24 24"
</button> class="h-6 w-6"
</form> fill="none"
</div> stroke="currentColor"
</td> 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> </tr>
))} ))}
</tbody> </tbody>
@ -629,7 +650,9 @@ export const user = new Elysia()
if (!tokenUser) { if (!tokenUser) {
return redirect(`${WEBROOT}/login`, 302); return redirect(`${WEBROOT}/login`, 302);
} }
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(
tokenUser.id,
);
if (!existingUser) { if (!existingUser) {
if (auth?.value) { if (auth?.value) {
@ -803,7 +826,9 @@ export const user = new Elysia()
<form <form
method="post" method="post"
action={`${WEBROOT}/account/edit-user`} action={`${WEBROOT}/account/edit-user`}
class="flex flex-col gap-4" class={`
flex flex-col gap-4
`}
> >
<input type="hidden" name="userId" value={String(targetUser.id)} /> <input type="hidden" name="userId" value={String(targetUser.id)} />
<fieldset class="mb-4 flex flex-col gap-4"> <fieldset class="mb-4 flex flex-col gap-4">
@ -818,7 +843,11 @@ export const user = new Elysia()
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Role Role
<select name="role" class="rounded-sm bg-neutral-800 p-3 capitalize" required> <select
name="role"
class="rounded-sm bg-neutral-800 p-3 capitalize"
required
>
<option value="user" selected={targetUser.role === "user"}> <option value="user" selected={targetUser.role === "user"}>
Normal user Normal user
</option> </option>
@ -839,7 +868,10 @@ export const user = new Elysia()
</label> </label>
</fieldset> </fieldset>
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<a href={`${WEBROOT}/account`} class="w-full btn-secondary text-center"> <a
href={`${WEBROOT}/account`}
class="w-full btn-secondary text-center"
>
Cancel Cancel
</a> </a>
<button type="submit" class="w-full btn-primary"> <button type="submit" class="w-full btn-primary">
@ -894,53 +926,35 @@ export const user = new Elysia()
return redirect(`${WEBROOT}/account`, 302); return redirect(`${WEBROOT}/account`, 302);
} }
// Atomic last-admin protection: concurrent demotions must not be able to leave zero admins. // Prevent demoting the last admin
// Use a write transaction to serialize changes and a conditional UPDATE for demotion. if (targetUser.role === "admin" && role !== "admin") {
// const adminCountRow = db
// IMPORTANT: do not hold a SQLite write transaction open across an `await`. .query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'")
// Hash any password outside the transaction to avoid lock contention. .get() as { cnt: number };
let hashedPassword: string | null = null; if (adminCountRow.cnt <= 1) {
if (newPassword && newPassword.trim().length > 0) { set.status = 400;
hashedPassword = await Bun.password.hash(newPassword); return { message: "You cannot demote the last remaining admin." };
}
} }
db.exec("BEGIN IMMEDIATE"); const fields: string[] = [];
try { // eslint-disable-next-line @typescript-eslint/no-explicit-any
// Role change const values: any[] = [];
if (role === "admin") {
db.query("UPDATE users SET role = 'admin' WHERE id = ?").run(targetId);
} else if (role === "user") {
// If demoting an admin, only allow if there is more than 1 admin at the time of the update.
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) { if (role === "admin" || role === "user") {
db.exec("ROLLBACK"); fields.push("role");
set.status = 400; values.push(role);
return { message: "You cannot demote the last remaining admin." }; }
}
}
// Password change (optional) if (newPassword && newPassword.trim().length > 0) {
if (hashedPassword) { fields.push("password");
db.query("UPDATE users SET password = ? WHERE id = ?").run(hashedPassword, targetId); values.push(await Bun.password.hash(newPassword));
} }
db.exec("COMMIT"); if (fields.length > 0) {
} catch (e) { db.query(
try { `UPDATE users SET ${fields.map((f) => `${f}=?`).join(", ")} WHERE id=?`,
db.exec("ROLLBACK"); ).run(...values, targetId);
} catch (rollbackErr) {
console.warn("[user/edit-user] ROLLBACK failed:", rollbackErr);
}
throw e;
} }
return redirect(`${WEBROOT}/account`, 302); return redirect(`${WEBROOT}/account`, 302);
@ -1032,3 +1046,4 @@ export const user = new Elysia()
cookie: "session", cookie: "session",
}, },
); );