SeaORM: accounts, sessions and themes

The fourteen user and session functions move from rusqlite to SeaORM and become
async; their callers await them (auth, admin_user, user_cmd, the account
handlers). Checked against a copy of production, where the yes/no columns are
still INTEGER: the admin flag reads back right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:17:52 +00:00
parent 484aaa1849
commit 4927677e66
3 changed files with 177 additions and 164 deletions

View File

@@ -107,16 +107,16 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let mut user = None;
if let Some(name) = vouched {
user = match state.ctx.db.user_by_name(&name) {
user = match state.ctx.db.user_by_name(&name).await {
Ok(Some(u)) => Some(u),
Ok(None) if cfg.web.auto_create_users => {
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
state
.ctx
.db
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
.ok()
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
// The first account made is the admin.
let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
match state.ctx.db.create_user(&name, None, first).await {
Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
Err(_) => None,
}
}
Ok(None) => {
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
@@ -130,7 +130,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
// must not turn anyone away, so its error goes unanswered.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id);
let _ = state.ctx.db.signed_in(u.id).await;
}
}
let by_proxy = user.is_some();
@@ -141,7 +141,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
user = state
.ctx
.db
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
.session_user(&sid, cfg.web.session_days.max(1) * 86_400).await
.unwrap_or(None);
}
}
@@ -155,11 +155,11 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
if user.is_none() && !token.is_empty() {
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
user = admin_user(&state);
user = admin_user(&state).await;
if from_query.is_some() {
// The token link is a sign-in; the cookie it leaves behind is not one each time.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id);
let _ = state.ctx.db.signed_in(u.id).await;
}
set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
@@ -247,8 +247,8 @@ fn cookie(req: &Request, name: &str) -> Option<String> {
}
/// The account the shared token stands for: the first admin, or the first user at all.
fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().ok()?;
async fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().await.ok()?;
users
.iter()
.find(|u| u.is_admin)
@@ -267,7 +267,7 @@ async fn login(
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name)?;
let user = state.ctx.db.user_by_name(&name).await?;
// The same answer either way: whether a name exists is not something to leak.
let ok = user
.as_ref()
@@ -280,8 +280,8 @@ async fn login(
let user = user.expect("verified above");
let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token)?;
state.ctx.db.signed_in(user.id)?;
state.ctx.db.create_session(user.id, &token).await?;
state.ctx.db.signed_in(user.id).await?;
tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1);
@@ -298,7 +298,7 @@ async fn login(
async fn logout(State(state): State<WebState>, req: Request) -> Response {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
let _ = state.ctx.db.delete_session(&sid);
let _ = state.ctx.db.delete_session(&sid).await;
}
let mut resp = StatusCode::NO_CONTENT.into_response();
for c in [
@@ -320,7 +320,7 @@ async fn me(
) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default();
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
}))
@@ -343,7 +343,7 @@ async fn patch_me(
if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?;
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -378,7 +378,7 @@ async fn list_users(
let users: Vec<_> = state
.ctx
.db
.users()?
.users().await?
.iter()
.map(|u| {
serde_json::json!({
@@ -409,7 +409,7 @@ async fn add_user(
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
})?;
if state.ctx.db.user_by_name(&name)?.is_some() {
if state.ctx.db.user_by_name(&name).await?.is_some() {
return Err(ApiError::bad_request(format!("{name} already exists")));
}
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
@@ -418,7 +418,7 @@ async fn add_user(
} else {
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
};
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
state.ctx.db.create_user(&name, hash.as_deref(), body.admin).await?;
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
Ok(StatusCode::CREATED)
}
@@ -435,7 +435,7 @@ async fn patch_user(
Json(body): Json<UserPatch>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
let users = state.ctx.db.users()?;
let users = state.ctx.db.users().await?;
let target = users
.iter()
.find(|u| u.id == id)
@@ -446,7 +446,7 @@ async fn patch_user(
target.name
)));
}
state.ctx.db.set_admin(id, body.admin)?;
state.ctx.db.set_admin(id, body.admin).await?;
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
Ok(StatusCode::NO_CONTENT)
}
@@ -457,7 +457,7 @@ async fn remove_user(
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
let users = state.ctx.db.users()?;
let users = state.ctx.db.users().await?;
let target = users
.iter()
.find(|u| u.id == id)
@@ -468,7 +468,7 @@ async fn remove_user(
target.name
)));
}
state.ctx.db.delete_user(id)?;
state.ctx.db.delete_user(id).await?;
tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT)
}
@@ -510,7 +510,7 @@ async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Res
if !user.is_admin {
return Redirect::to("/").into_response();
}
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
}
@@ -584,7 +584,7 @@ const ADMIN_LINK: &str = "<a id=admin ";
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
/// script instead showed it for a moment on every load, until /api/me answered.
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
}