Add files via upload
This commit is contained in:
parent
6722907b74
commit
ac18fcc92a
1 changed files with 69 additions and 88 deletions
|
|
@ -13,20 +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 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" })
|
export const userService = new Elysia({ name: "user/service" })
|
||||||
.derive(() => {
|
|
||||||
FIRST_RUN = computeFirstRun();
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
.use(
|
.use(
|
||||||
jwt({
|
jwt({
|
||||||
name: "jwt",
|
name: "jwt",
|
||||||
|
|
@ -78,8 +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 }) => {
|
||||||
const isFirstRun = computeFirstRun();
|
if (!FIRST_RUN) {
|
||||||
if (!isFirstRun) {
|
|
||||||
return redirect(`${WEBROOT}/login`, 302);
|
return redirect(`${WEBROOT}/login`, 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,88 +184,72 @@ 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 (no stale in-memory flag) + race-safe creation.
|
// first user allowed even if ACCOUNT_REGISTRATION=false
|
||||||
// We hash outside the write-lock to keep the lock window short.
|
const isFirstUser = FIRST_RUN;
|
||||||
|
|
||||||
|
if (!ACCOUNT_REGISTRATION && !isFirstUser) {
|
||||||
|
return redirect(`${WEBROOT}/login`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FIRST_RUN) {
|
||||||
|
FIRST_RUN = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 savedPassword = await Bun.password.hash(password);
|
||||||
|
|
||||||
// Acquire a write lock so only one instance can perform "count==0 then insert" at a time.
|
const role = isFirstUser ? "admin" : "user";
|
||||||
db.exec("BEGIN IMMEDIATE");
|
|
||||||
try {
|
|
||||||
const isFirstUser = computeFirstRun();
|
|
||||||
|
|
||||||
// first user allowed even if ACCOUNT_REGISTRATION=false
|
db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run(
|
||||||
if (!ACCOUNT_REGISTRATION && !isFirstUser) {
|
email,
|
||||||
db.exec("ROLLBACK");
|
savedPassword,
|
||||||
return redirect(`${WEBROOT}/login`, 302);
|
role,
|
||||||
}
|
);
|
||||||
|
|
||||||
const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email);
|
const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email);
|
||||||
if (existingUser) {
|
|
||||||
db.exec("ROLLBACK");
|
|
||||||
set.status = 400;
|
|
||||||
return {
|
|
||||||
message: "Email already in use.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = isFirstUser ? "admin" : "user";
|
if (!userRow) {
|
||||||
|
set.status = 500;
|
||||||
db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run(
|
return {
|
||||||
email,
|
message: "Failed to create user.",
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessToken = await jwt.sign({
|
||||||
|
id: String(userRow.id),
|
||||||
|
role: userRow.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
{ body: "signIn" },
|
{ body: "signIn" },
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/login",
|
"/login",
|
||||||
async ({ jwt, redirect, cookie: { auth } }) => {
|
async ({ jwt, redirect, cookie: { auth } }) => {
|
||||||
if (computeFirstRun()) {
|
if (FIRST_RUN) {
|
||||||
return redirect(`${WEBROOT}/setup`, 302);
|
return redirect(`${WEBROOT}/setup`, 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -845,7 +817,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">
|
||||||
|
|
@ -938,6 +912,14 @@ export const user = new Elysia()
|
||||||
|
|
||||||
// Atomic last-admin protection: concurrent demotions must not be able to leave zero admins.
|
// Atomic last-admin protection: concurrent demotions must not be able to leave zero admins.
|
||||||
// Use a write transaction to serialize changes and a conditional UPDATE for demotion.
|
// Use a write transaction to serialize changes and a conditional UPDATE for demotion.
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
db.exec("BEGIN IMMEDIATE");
|
db.exec("BEGIN IMMEDIATE");
|
||||||
try {
|
try {
|
||||||
// Role change
|
// Role change
|
||||||
|
|
@ -963,9 +945,8 @@ export const user = new Elysia()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password change (optional)
|
// Password change (optional)
|
||||||
if (newPassword && newPassword.trim().length > 0) {
|
if (hashedPassword) {
|
||||||
const hashed = await Bun.password.hash(newPassword);
|
db.query("UPDATE users SET password = ? WHERE id = ?").run(hashedPassword, targetId);
|
||||||
db.query("UPDATE users SET password = ? WHERE id = ?").run(hashed, targetId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.exec("COMMIT");
|
db.exec("COMMIT");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue