diff --git a/src/db.rs b/src/db.rs index 13ff5ca..9fbb9bc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,7 +1,10 @@ //! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat. use anyhow::{Context, Result}; +use crate::entity::{sessions, users}; use rusqlite::{Connection, OptionalExtension, params}; +use sea_orm::sea_query::{Expr, Func}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use std::path::Path; use std::sync::Mutex; @@ -200,18 +203,17 @@ pub struct User { pub last_login: Option, } -/// The columns `user_row` reads, in its order. -const USER_COLS: &str = "id, name, pass_hash, is_admin, created, last_login"; - -fn user_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(User { - id: r.get(0)?, - name: r.get(1)?, - pass_hash: r.get(2)?, - is_admin: r.get::<_, i64>(3)? != 0, - created: r.get(4)?, - last_login: r.get(5)?, - }) +impl From for User { + fn from(u: users::Model) -> Self { + User { + id: u.id, + name: u.name, + pass_hash: u.pass_hash, + is_admin: u.is_admin, + created: u.created, + last_login: u.last_login, + } + } } /// A feed derived from an OPML subscription rather than written into the config. @@ -1195,141 +1197,152 @@ impl Db { // ---- users and sessions ---- - pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT INTO users (name, pass_hash, is_admin, created) VALUES (?1, ?2, ?3, ?4)", - params![name, pass_hash, admin as i64, now()], - )?; - Ok(conn.last_insert_rowid()) + pub async fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result { + let m = users::ActiveModel { + name: Set(name.to_owned()), + pass_hash: Set(pass_hash.map(str::to_owned)), + is_admin: Set(admin), + created: Set(Some(now())), + ..Default::default() + } + .insert(&self.orm) + .await?; + Ok(m.id) } /// Without regard to case, on either database: lower() both sides, which the unique index /// on lower(name) serves. SQLite's COLLATE NOCASE on the column did this before, and /// Postgres has no such thing. - pub fn user_by_name(&self, name: &str) -> Result> { - self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE lower(name) = lower(?1)"), name) + pub async fn user_by_name(&self, name: &str) -> Result> { + use sea_orm::sea_query::ExprTrait; + Ok(users::Entity::find() + .filter(Expr::expr(Func::lower(Expr::col(users::Column::Name))).eq(Func::lower(name))) + .one(&self.orm) + .await? + .map(User::from)) } - pub fn user_by_id(&self, id: i64) -> Result> { - self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), id) + pub async fn user_by_id(&self, id: i64) -> Result> { + Ok(users::Entity::find_by_id(id).one(&self.orm).await?.map(User::from)) } - fn one_user(&self, sql: &str, key: P) -> Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare(sql)?; - let mut rows = stmt.query(params![key])?; - Ok(match rows.next()? { - Some(r) => Some(user_row(r)?), - None => None, - }) + pub async fn users(&self) -> Result> { + Ok(users::Entity::find() + .order_by_asc(users::Column::Name) + .all(&self.orm) + .await? + .into_iter() + .map(User::from) + .collect()) } - pub fn users(&self) -> Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = - conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY name"))?; - let out = stmt - .query_map([], user_row)? - .collect::>>()?; - Ok(out) - } - - pub fn set_password(&self, id: i64, hash: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute("UPDATE users SET pass_hash = ?2 WHERE id = ?1", params![id, hash])?; + /// Sets some of one person's columns, whatever else is in their row. + async fn update_user(&self, id: i64, m: users::ActiveModel) -> Result<()> { + users::Entity::update_many() + .set(m) + .filter(users::Column::Id.eq(id)) + .exec(&self.orm) + .await?; Ok(()) } - pub fn set_admin(&self, id: i64, admin: bool) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute("UPDATE users SET is_admin = ?2 WHERE id = ?1", params![id, admin as i64])?; - Ok(()) + pub async fn set_password(&self, id: i64, hash: &str) -> Result<()> { + self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() }) + .await + } + + pub async fn set_admin(&self, id: i64, admin: bool) -> Result<()> { + self.update_user(id, users::ActiveModel { is_admin: Set(admin), ..Default::default() }).await } /// The proxy signs people in by the name it vouches for, so an account made before the proxy - /// was set up has to take that name to be found by it. The name is UNIQUE, so a taken one is + /// was set up has to take that name to be found by it. The name is unique, so a taken one is /// refused here as well as by the caller. - pub fn rename_user(&self, id: i64, name: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute("UPDATE users SET name = ?2 WHERE id = ?1", params![id, name])?; - Ok(()) + pub async fn rename_user(&self, id: i64, name: &str) -> Result<()> { + self.update_user(id, users::ActiveModel { name: Set(name.to_owned()), ..Default::default() }).await } /// Records a sign-in, to the hour: the proxy vouches for every request, and writing each one /// would buy nothing. - pub fn signed_in(&self, id: i64) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "UPDATE users SET last_login = ?2 WHERE id = ?1 AND coalesce(last_login, 0) <= ?2 - 3600", - params![id, now()], - )?; + pub async fn signed_in(&self, id: i64) -> Result<()> { + // Here only: it is implemented for every type, and file-wide it shadows i64::max. + use sea_orm::sea_query::ExprTrait; + let now = now(); + users::Entity::update_many() + .col_expr(users::Column::LastLogin, Expr::val(now).into()) + .filter(users::Column::Id.eq(id)) + .filter(Expr::expr(Func::coalesce([Expr::col(users::Column::LastLogin), Expr::val(0)])).lte(now - 3600)) + .exec(&self.orm) + .await?; Ok(()) } /// Sessions go with the user: a deleted account must not leave a usable cookie behind. - pub fn delete_user(&self, id: i64) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM sessions WHERE user_id = ?1", [id])?; - conn.execute("DELETE FROM users WHERE id = ?1", [id])?; + /// The foreign key would take them anyway; this does not rely on it being switched on. + pub async fn delete_user(&self, id: i64) -> Result<()> { + sessions::Entity::delete_many().filter(sessions::Column::UserId.eq(id)).exec(&self.orm).await?; + users::Entity::delete_by_id(id).exec(&self.orm).await?; Ok(()) } - pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT INTO sessions (token, user_id, seen) VALUES (?1, ?2, ?3)", - params![token, user_id, now()], - )?; + pub async fn create_session(&self, user_id: i64, token: &str) -> Result<()> { + sessions::ActiveModel { token: Set(token.to_owned()), user_id: Set(user_id), seen: Set(now()) } + .insert(&self.orm) + .await?; Ok(()) } /// The theme this person chose, and light, dark or auto; None for either until they choose. - pub fn theme(&self, user_id: i64) -> Result<(Option, Option)> { - let conn = self.conn.lock().unwrap(); - Ok(conn.query_row("SELECT theme, theme_mode FROM users WHERE id = ?1", [user_id], |r| { - Ok((r.get(0)?, r.get(1)?)) - })?) + pub async fn theme(&self, user_id: i64) -> Result<(Option, Option)> { + Ok(users::Entity::find_by_id(user_id) + .one(&self.orm) + .await? + .map(|u| (u.theme, u.theme_mode)) + .unwrap_or_default()) } - pub fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "UPDATE users SET theme = ?2, theme_mode = ?3 WHERE id = ?1", - params![user_id, theme, mode], - )?; - Ok(()) + pub async fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> { + self.update_user( + user_id, + users::ActiveModel { + theme: Set(Some(theme.to_owned())), + theme_mode: Set(Some(mode.to_owned())), + ..Default::default() + }, + ) + .await } /// The user behind a session cookie, if it is still live. Idle sessions expire after /// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive. - pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result> { - let conn = self.conn.lock().unwrap(); + pub async fn session_user(&self, token: &str, max_idle_secs: i64) -> Result> { + // Here only: it is implemented for every type, and file-wide it shadows i64::max. + use sea_orm::sea_query::ExprTrait; let cutoff = now() - max_idle_secs; - let mut stmt = conn.prepare( - "SELECT u.id, u.name, u.pass_hash, u.is_admin, u.created, u.last_login - FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.token = ?1 AND s.seen >= ?2", - )?; - let mut rows = stmt.query(params![token, cutoff])?; - let found = match rows.next()? { - Some(r) => Some(user_row(r)?), - None => None, - }; - drop(rows); - drop(stmt); + let found = sessions::Entity::find_by_id(token.to_owned()) + .filter(sessions::Column::Seen.gte(cutoff)) + .find_also_related(users::Entity) + .one(&self.orm) + .await? + .and_then(|(_, u)| u); if found.is_some() { - conn.execute("UPDATE sessions SET seen = ?2 WHERE token = ?1", params![token, now()])?; + sessions::Entity::update_many() + .col_expr(sessions::Column::Seen, Expr::val(now()).into()) + .filter(sessions::Column::Token.eq(token)) + .exec(&self.orm) + .await?; } else { // Either unknown or timed out; either way it is dead weight. - conn.execute("DELETE FROM sessions WHERE token = ?1 OR seen < ?2", params![token, cutoff])?; + sessions::Entity::delete_many() + .filter(sessions::Column::Token.eq(token).or(sessions::Column::Seen.lt(cutoff))) + .exec(&self.orm) + .await?; } - Ok(found) + Ok(found.map(User::from)) } - pub fn delete_session(&self, token: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM sessions WHERE token = ?1", [token])?; + pub async fn delete_session(&self, token: &str) -> Result<()> { + sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?; Ok(()) } @@ -1902,33 +1915,33 @@ mod tests { #[tokio::test] async fn a_renamed_account_keeps_everything_but_its_name() { let db = Db::memory().await.unwrap(); - let ray = db.create_user("rays", None, true).unwrap(); - db.create_user("sam", None, false).unwrap(); + let ray = db.create_user("rays", None, true).await.unwrap(); + db.create_user("sam", None, false).await.unwrap(); db.subscribe(ray, "f").unwrap(); - db.rename_user(ray, "rays@sdf1.net").unwrap(); - assert!(db.user_by_name("rays").unwrap().is_none()); - let renamed = db.user_by_name("RAYS@sdf1.net").unwrap().unwrap(); + db.rename_user(ray, "rays@sdf1.net").await.unwrap(); + assert!(db.user_by_name("rays").await.unwrap().is_none()); + let renamed = db.user_by_name("RAYS@sdf1.net").await.unwrap().unwrap(); assert_eq!((renamed.id, renamed.is_admin), (ray, true), "same account, still the admin"); assert_eq!(db.subscriptions_for(ray).unwrap().len(), 1, "and still subscribed"); - assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused"); + assert!(db.rename_user(ray, "sam").await.is_err(), "a taken name is refused"); } #[tokio::test] async fn an_account_knows_when_it_was_made_and_last_signed_in() { let db = Db::memory().await.unwrap(); - let id = db.create_user("ray", None, true).unwrap(); - let get = || db.user_by_id(id).unwrap().unwrap(); - assert!(get().created.is_some_and(|t| t > 0)); - assert_eq!(get().last_login, None, "made, but never signed in"); - db.signed_in(id).unwrap(); - let first = get().last_login.unwrap(); + let id = db.create_user("ray", None, true).await.unwrap(); + let get = async || db.user_by_id(id).await.unwrap().unwrap(); + assert!(get().await.created.is_some_and(|t| t > 0)); + assert_eq!(get().await.last_login, None, "made, but never signed in"); + db.signed_in(id).await.unwrap(); + let first = get().await.last_login.unwrap(); // Within the hour, the proxy vouching again writes nothing; after it, it does. db.exec_for_test(&format!("UPDATE users SET last_login = {} WHERE id = {id}", first - 60)).unwrap(); - db.signed_in(id).unwrap(); - assert_eq!(get().last_login, Some(first - 60)); + db.signed_in(id).await.unwrap(); + assert_eq!(get().await.last_login, Some(first - 60)); db.exec_for_test(&format!("UPDATE users SET last_login = {} WHERE id = {id}", first - 7200)).unwrap(); - db.signed_in(id).unwrap(); - assert!(get().last_login.unwrap() >= first); + db.signed_in(id).await.unwrap(); + assert!(get().await.last_login.unwrap() >= first); } #[tokio::test] @@ -2141,7 +2154,7 @@ mod tests { #[tokio::test] async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() { let db = Db::memory().await.unwrap(); - let me = db.create_user("pat", None, false).unwrap(); + let me = db.create_user("pat", None, false).await.unwrap(); assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin"); db.subscribe(me, "f").unwrap(); assert!(db.set_pinned(me, "f", true).unwrap()); diff --git a/src/main.rs b/src/main.rs index 134571d..764a4a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -226,7 +226,7 @@ async fn main() -> Result<()> { add(&ctx, &config_path, &url, folder, keywords).await } Command::Rm { feed } => rm(&ctx, &config_path, &feed), - Command::User { cmd } => user_cmd(&ctx, cmd), + Command::User { cmd } => user_cmd(&ctx, cmd).await, Command::Import { file } => import(&ctx, &config_path, &file).await, Command::Export { file } => export(&ctx, &file), _ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, @@ -235,7 +235,7 @@ async fn main() -> Result<()> { /// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps` /// listing. -fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { +async fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { let read_password = || -> Result { use std::io::Read; let mut buf = String::new(); @@ -253,7 +253,7 @@ fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { if name.is_empty() { anyhow::bail!("a name is required"); } - if ctx.db.user_by_name(&name)?.is_some() { + if ctx.db.user_by_name(&name).await?.is_some() { anyhow::bail!("{name} already exists"); } let hash = if no_password { @@ -262,8 +262,8 @@ fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { Some(crate::auth::hash_password(&read_password()?)?) }; // The first account runs the place; there is nobody else to grant it. - let first = ctx.db.users()?.is_empty(); - ctx.db.create_user(&name, hash.as_deref(), admin || first)?; + let first = ctx.db.users().await?.is_empty(); + ctx.db.create_user(&name, hash.as_deref(), admin || first).await?; println!( "added {name}{}{}", if admin || first { " (admin)" } else { "" }, @@ -272,7 +272,7 @@ fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { Ok(()) } UserCmd::List => { - let users = ctx.db.users()?; + let users = ctx.db.users().await?; if users.is_empty() { println!("no accounts yet: ipx user add "); } @@ -295,9 +295,9 @@ fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { let name = name.trim().to_ascii_lowercase(); let user = ctx .db - .user_by_name(&name)? + .user_by_name(&name).await? .ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; - ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?)?; + ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?; println!("password changed for {name}"); Ok(()) } @@ -308,12 +308,12 @@ fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { .ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?; let user = ctx .db - .user_by_name(&name)? + .user_by_name(&name).await? .ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; - if ctx.db.user_by_name(&new_name)?.is_some() { + if ctx.db.user_by_name(&new_name).await?.is_some() { anyhow::bail!("{new_name} already exists"); } - ctx.db.rename_user(user.id, &new_name)?; + ctx.db.rename_user(user.id, &new_name).await?; println!("renamed {name} to {new_name}"); Ok(()) } @@ -321,9 +321,9 @@ fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { let name = name.trim().to_ascii_lowercase(); let user = ctx .db - .user_by_name(&name)? + .user_by_name(&name).await? .ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; - ctx.db.delete_user(user.id)?; + ctx.db.delete_user(user.id).await?; println!("removed {name}"); Ok(()) } @@ -370,15 +370,15 @@ async fn daemon( } // A database with nobody in it cannot be signed into. - if ctx.db.users()?.is_empty() { - ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true)?; + if ctx.db.users().await?.is_empty() { + ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true).await?; tracing::warn!( "no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \ Change it with `echo -n | ipx user passwd admin`" ); } - if let Some(admin) = ctx.db.users()?.into_iter().find(|u| u.is_admin) { + if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) { let catalogue: Vec = ctx.cfg().feeds.keys().cloned().collect(); match ctx.db.adopt_catalogue(admin.id, &catalogue) { Ok(0) => {} @@ -670,7 +670,7 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path // The CLI speaks for the operator, as the shared web token does. let admin = ctx .db - .users()? + .users().await? .into_iter() .find(|u| u.is_admin) .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add --admin"))?; @@ -1317,7 +1317,7 @@ async fn sync_group( .map(|m| m.id.clone()) .chain(std::iter::once(parent_id.to_string())) { - for user in ctx.db.users()? { + for user in ctx.db.users().await? { if ctx.db.subscription(user.id, parent_id)?.is_some() { ctx.db.subscribe(user.id, &id)?; } diff --git a/src/web.rs b/src/web.rs index b40f03b..5c4fd72 100644 --- a/src/web.rs +++ b/src/web.rs @@ -107,16 +107,16 @@ async fn auth(State(state): State, 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, 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, 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, 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 { } /// The account the shared token stands for: the first admin, or the first user at all. -fn admin_user(state: &WebState) -> Option { - let users = state.ctx.db.users().ok()?; +async fn admin_user(state: &WebState) -> Option { + 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, ) -> Result { 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, 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 { 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, ) -> Result { 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, ) -> Result { 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, 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 = ", 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))) }