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:
56
src/web.rs
56
src/web.rs
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user