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

249
src/db.rs
View File

@@ -1,7 +1,10 @@
//! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat. //! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::entity::{sessions, users};
use rusqlite::{Connection, OptionalExtension, params}; 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::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
@@ -200,18 +203,17 @@ pub struct User {
pub last_login: Option<i64>, pub last_login: Option<i64>,
} }
/// The columns `user_row` reads, in its order. impl From<users::Model> for User {
const USER_COLS: &str = "id, name, pass_hash, is_admin, created, last_login"; fn from(u: users::Model) -> Self {
User {
fn user_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<User> { id: u.id,
Ok(User { name: u.name,
id: r.get(0)?, pass_hash: u.pass_hash,
name: r.get(1)?, is_admin: u.is_admin,
pass_hash: r.get(2)?, created: u.created,
is_admin: r.get::<_, i64>(3)? != 0, last_login: u.last_login,
created: r.get(4)?, }
last_login: r.get(5)?, }
})
} }
/// A feed derived from an OPML subscription rather than written into the config. /// A feed derived from an OPML subscription rather than written into the config.
@@ -1195,141 +1197,152 @@ impl Db {
// ---- users and sessions ---- // ---- users and sessions ----
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> { pub async fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
let conn = self.conn.lock().unwrap(); let m = users::ActiveModel {
conn.execute( name: Set(name.to_owned()),
"INSERT INTO users (name, pass_hash, is_admin, created) VALUES (?1, ?2, ?3, ?4)", pass_hash: Set(pass_hash.map(str::to_owned)),
params![name, pass_hash, admin as i64, now()], is_admin: Set(admin),
)?; created: Set(Some(now())),
Ok(conn.last_insert_rowid()) ..Default::default()
}
.insert(&self.orm)
.await?;
Ok(m.id)
} }
/// Without regard to case, on either database: lower() both sides, which the unique index /// 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 /// on lower(name) serves. SQLite's COLLATE NOCASE on the column did this before, and
/// Postgres has no such thing. /// Postgres has no such thing.
pub fn user_by_name(&self, name: &str) -> Result<Option<User>> { pub async fn user_by_name(&self, name: &str) -> Result<Option<User>> {
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE lower(name) = lower(?1)"), name) 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<Option<User>> { pub async fn user_by_id(&self, id: i64) -> Result<Option<User>> {
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), id) Ok(users::Entity::find_by_id(id).one(&self.orm).await?.map(User::from))
} }
fn one_user<P: rusqlite::ToSql>(&self, sql: &str, key: P) -> Result<Option<User>> { pub async fn users(&self) -> Result<Vec<User>> {
let conn = self.conn.lock().unwrap(); Ok(users::Entity::find()
let mut stmt = conn.prepare(sql)?; .order_by_asc(users::Column::Name)
let mut rows = stmt.query(params![key])?; .all(&self.orm)
Ok(match rows.next()? { .await?
Some(r) => Some(user_row(r)?), .into_iter()
None => None, .map(User::from)
}) .collect())
} }
pub fn users(&self) -> Result<Vec<User>> { /// Sets some of one person's columns, whatever else is in their row.
let conn = self.conn.lock().unwrap(); async fn update_user(&self, id: i64, m: users::ActiveModel) -> Result<()> {
let mut stmt = users::Entity::update_many()
conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY name"))?; .set(m)
let out = stmt .filter(users::Column::Id.eq(id))
.query_map([], user_row)? .exec(&self.orm)
.collect::<rusqlite::Result<Vec<_>>>()?; .await?;
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])?;
Ok(()) Ok(())
} }
pub fn set_admin(&self, id: i64, admin: bool) -> Result<()> { pub async fn set_password(&self, id: i64, hash: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() })
conn.execute("UPDATE users SET is_admin = ?2 WHERE id = ?1", params![id, admin as i64])?; .await
Ok(()) }
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 /// 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. /// refused here as well as by the caller.
pub fn rename_user(&self, id: i64, name: &str) -> Result<()> { pub async fn rename_user(&self, id: i64, name: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); self.update_user(id, users::ActiveModel { name: Set(name.to_owned()), ..Default::default() }).await
conn.execute("UPDATE users SET name = ?2 WHERE id = ?1", params![id, name])?;
Ok(())
} }
/// Records a sign-in, to the hour: the proxy vouches for every request, and writing each one /// Records a sign-in, to the hour: the proxy vouches for every request, and writing each one
/// would buy nothing. /// would buy nothing.
pub fn signed_in(&self, id: i64) -> Result<()> { pub async fn signed_in(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap(); // Here only: it is implemented for every type, and file-wide it shadows i64::max.
conn.execute( use sea_orm::sea_query::ExprTrait;
"UPDATE users SET last_login = ?2 WHERE id = ?1 AND coalesce(last_login, 0) <= ?2 - 3600", let now = now();
params![id, 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(()) Ok(())
} }
/// Sessions go with the user: a deleted account must not leave a usable cookie behind. /// Sessions go with the user: a deleted account must not leave a usable cookie behind.
pub fn delete_user(&self, id: i64) -> Result<()> { /// The foreign key would take them anyway; this does not rely on it being switched on.
let conn = self.conn.lock().unwrap(); pub async fn delete_user(&self, id: i64) -> Result<()> {
conn.execute("DELETE FROM sessions WHERE user_id = ?1", [id])?; sessions::Entity::delete_many().filter(sessions::Column::UserId.eq(id)).exec(&self.orm).await?;
conn.execute("DELETE FROM users WHERE id = ?1", [id])?; users::Entity::delete_by_id(id).exec(&self.orm).await?;
Ok(()) Ok(())
} }
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> { pub async fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); sessions::ActiveModel { token: Set(token.to_owned()), user_id: Set(user_id), seen: Set(now()) }
conn.execute( .insert(&self.orm)
"INSERT INTO sessions (token, user_id, seen) VALUES (?1, ?2, ?3)", .await?;
params![token, user_id, now()],
)?;
Ok(()) Ok(())
} }
/// The theme this person chose, and light, dark or auto; None for either until they choose. /// 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<String>, Option<String>)> { pub async fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> {
let conn = self.conn.lock().unwrap(); Ok(users::Entity::find_by_id(user_id)
Ok(conn.query_row("SELECT theme, theme_mode FROM users WHERE id = ?1", [user_id], |r| { .one(&self.orm)
Ok((r.get(0)?, r.get(1)?)) .await?
})?) .map(|u| (u.theme, u.theme_mode))
.unwrap_or_default())
} }
pub fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> { pub async fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); self.update_user(
conn.execute( user_id,
"UPDATE users SET theme = ?2, theme_mode = ?3 WHERE id = ?1", users::ActiveModel {
params![user_id, theme, mode], theme: Set(Some(theme.to_owned())),
)?; theme_mode: Set(Some(mode.to_owned())),
Ok(()) ..Default::default()
},
)
.await
} }
/// The user behind a session cookie, if it is still live. Idle sessions expire after /// 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. /// `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<Option<User>> { pub async fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap(); // 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 cutoff = now() - max_idle_secs;
let mut stmt = conn.prepare( let found = sessions::Entity::find_by_id(token.to_owned())
"SELECT u.id, u.name, u.pass_hash, u.is_admin, u.created, u.last_login .filter(sessions::Column::Seen.gte(cutoff))
FROM sessions s JOIN users u ON u.id = s.user_id .find_also_related(users::Entity)
WHERE s.token = ?1 AND s.seen >= ?2", .one(&self.orm)
)?; .await?
let mut rows = stmt.query(params![token, cutoff])?; .and_then(|(_, u)| u);
let found = match rows.next()? {
Some(r) => Some(user_row(r)?),
None => None,
};
drop(rows);
drop(stmt);
if found.is_some() { 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 { } else {
// Either unknown or timed out; either way it is dead weight. // 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<()> { pub async fn delete_session(&self, token: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
conn.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
Ok(()) Ok(())
} }
@@ -1902,33 +1915,33 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_renamed_account_keeps_everything_but_its_name() { async fn a_renamed_account_keeps_everything_but_its_name() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
let ray = db.create_user("rays", None, true).unwrap(); let ray = db.create_user("rays", None, true).await.unwrap();
db.create_user("sam", None, false).unwrap(); db.create_user("sam", None, false).await.unwrap();
db.subscribe(ray, "f").unwrap(); db.subscribe(ray, "f").unwrap();
db.rename_user(ray, "rays@sdf1.net").unwrap(); db.rename_user(ray, "rays@sdf1.net").await.unwrap();
assert!(db.user_by_name("rays").unwrap().is_none()); assert!(db.user_by_name("rays").await.unwrap().is_none());
let renamed = db.user_by_name("RAYS@sdf1.net").unwrap().unwrap(); 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!((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_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] #[tokio::test]
async fn an_account_knows_when_it_was_made_and_last_signed_in() { async fn an_account_knows_when_it_was_made_and_last_signed_in() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
let id = db.create_user("ray", None, true).unwrap(); let id = db.create_user("ray", None, true).await.unwrap();
let get = || db.user_by_id(id).unwrap().unwrap(); let get = async || db.user_by_id(id).await.unwrap().unwrap();
assert!(get().created.is_some_and(|t| t > 0)); assert!(get().await.created.is_some_and(|t| t > 0));
assert_eq!(get().last_login, None, "made, but never signed in"); assert_eq!(get().await.last_login, None, "made, but never signed in");
db.signed_in(id).unwrap(); db.signed_in(id).await.unwrap();
let first = get().last_login.unwrap(); let first = get().await.last_login.unwrap();
// Within the hour, the proxy vouching again writes nothing; after it, it does. // 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.exec_for_test(&format!("UPDATE users SET last_login = {} WHERE id = {id}", first - 60)).unwrap();
db.signed_in(id).unwrap(); db.signed_in(id).await.unwrap();
assert_eq!(get().last_login, Some(first - 60)); 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.exec_for_test(&format!("UPDATE users SET last_login = {} WHERE id = {id}", first - 7200)).unwrap();
db.signed_in(id).unwrap(); db.signed_in(id).await.unwrap();
assert!(get().last_login.unwrap() >= first); assert!(get().await.last_login.unwrap() >= first);
} }
#[tokio::test] #[tokio::test]
@@ -2141,7 +2154,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() { async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
let db = Db::memory().await.unwrap(); 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"); assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
db.subscribe(me, "f").unwrap(); db.subscribe(me, "f").unwrap();
assert!(db.set_pinned(me, "f", true).unwrap()); assert!(db.set_pinned(me, "f", true).unwrap());

View File

@@ -226,7 +226,7 @@ async fn main() -> Result<()> {
add(&ctx, &config_path, &url, folder, keywords).await add(&ctx, &config_path, &url, folder, keywords).await
} }
Command::Rm { feed } => rm(&ctx, &config_path, &feed), 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::Import { file } => import(&ctx, &config_path, &file).await,
Command::Export { file } => export(&ctx, &file), Command::Export { file } => export(&ctx, &file),
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, _ => 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` /// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
/// listing. /// listing.
fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> { async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
let read_password = || -> Result<String> { let read_password = || -> Result<String> {
use std::io::Read; use std::io::Read;
let mut buf = String::new(); let mut buf = String::new();
@@ -253,7 +253,7 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
if name.is_empty() { if name.is_empty() {
anyhow::bail!("a name is required"); 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"); anyhow::bail!("{name} already exists");
} }
let hash = if no_password { let hash = if no_password {
@@ -262,8 +262,8 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
Some(crate::auth::hash_password(&read_password()?)?) Some(crate::auth::hash_password(&read_password()?)?)
}; };
// The first account runs the place; there is nobody else to grant it. // The first account runs the place; there is nobody else to grant it.
let first = ctx.db.users()?.is_empty(); let first = ctx.db.users().await?.is_empty();
ctx.db.create_user(&name, hash.as_deref(), admin || first)?; ctx.db.create_user(&name, hash.as_deref(), admin || first).await?;
println!( println!(
"added {name}{}{}", "added {name}{}{}",
if admin || first { " (admin)" } else { "" }, if admin || first { " (admin)" } else { "" },
@@ -272,7 +272,7 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
Ok(()) Ok(())
} }
UserCmd::List => { UserCmd::List => {
let users = ctx.db.users()?; let users = ctx.db.users().await?;
if users.is_empty() { if users.is_empty() {
println!("no accounts yet: ipx user add <name>"); println!("no accounts yet: ipx user add <name>");
} }
@@ -295,9 +295,9 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
let name = name.trim().to_ascii_lowercase(); let name = name.trim().to_ascii_lowercase();
let user = ctx let user = ctx
.db .db
.user_by_name(&name)? .user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; .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}"); println!("password changed for {name}");
Ok(()) Ok(())
} }
@@ -308,12 +308,12 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?; .ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
let user = ctx let user = ctx
.db .db
.user_by_name(&name)? .user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; .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"); 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}"); println!("renamed {name} to {new_name}");
Ok(()) Ok(())
} }
@@ -321,9 +321,9 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
let name = name.trim().to_ascii_lowercase(); let name = name.trim().to_ascii_lowercase();
let user = ctx let user = ctx
.db .db
.user_by_name(&name)? .user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; .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}"); println!("removed {name}");
Ok(()) Ok(())
} }
@@ -370,15 +370,15 @@ async fn daemon(
} }
// A database with nobody in it cannot be signed into. // A database with nobody in it cannot be signed into.
if ctx.db.users()?.is_empty() { if ctx.db.users().await?.is_empty() {
ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true)?; ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true).await?;
tracing::warn!( tracing::warn!(
"no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \ "no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \
Change it with `echo -n <password> | ipx user passwd admin`" Change it with `echo -n <password> | 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<String> = ctx.cfg().feeds.keys().cloned().collect(); let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
match ctx.db.adopt_catalogue(admin.id, &catalogue) { match ctx.db.adopt_catalogue(admin.id, &catalogue) {
Ok(0) => {} 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. // The CLI speaks for the operator, as the shared web token does.
let admin = ctx let admin = ctx
.db .db
.users()? .users().await?
.into_iter() .into_iter()
.find(|u| u.is_admin) .find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?; .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
@@ -1317,7 +1317,7 @@ async fn sync_group(
.map(|m| m.id.clone()) .map(|m| m.id.clone())
.chain(std::iter::once(parent_id.to_string())) .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() { if ctx.db.subscription(user.id, parent_id)?.is_some() {
ctx.db.subscribe(user.id, &id)?; ctx.db.subscribe(user.id, &id)?;
} }

View File

@@ -107,16 +107,16 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let mut user = None; let mut user = None;
if let Some(name) = vouched { 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(Some(u)) => Some(u),
Ok(None) if cfg.web.auto_create_users => { Ok(None) if cfg.web.auto_create_users => {
tracing::info!(user = %name, "creating an account for a name the proxy vouched for"); tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
state // The first account made is the admin.
.ctx let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
.db match state.ctx.db.create_user(&name, None, first).await {
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false)) Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
.ok() Err(_) => None,
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten()) }
} }
Ok(None) => { Ok(None) => {
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off"); 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 // 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. // must not turn anyone away, so its error goes unanswered.
if let Some(u) = &user { 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(); 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 user = state
.ctx .ctx
.db .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); .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() { if user.is_none() && !token.is_empty() {
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE)); let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) { 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() { if from_query.is_some() {
// The token link is a sign-in; the cookie it leaves behind is not one each time. // The token link is a sign-in; the cookie it leaves behind is not one each time.
if let Some(u) = &user { 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!( set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000" "{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. /// 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> { async fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().ok()?; let users = state.ctx.db.users().await.ok()?;
users users
.iter() .iter()
.find(|u| u.is_admin) .find(|u| u.is_admin)
@@ -267,7 +267,7 @@ async fn login(
Json(body): Json<Credentials>, Json(body): Json<Credentials>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase(); 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. // The same answer either way: whether a name exists is not something to leak.
let ok = user let ok = user
.as_ref() .as_ref()
@@ -280,8 +280,8 @@ async fn login(
let user = user.expect("verified above"); let user = user.expect("verified above");
let token = crate::auth::new_session_token(); let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token)?; state.ctx.db.create_session(user.id, &token).await?;
state.ctx.db.signed_in(user.id)?; state.ctx.db.signed_in(user.id).await?;
tracing::info!(user = %user.name, "signed in"); tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1); 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 { async fn logout(State(state): State<WebState>, req: Request) -> Response {
if let Some(sid) = cookie(&req, SESSION_COOKIE) { 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(); let mut resp = StatusCode::NO_CONTENT.into_response();
for c in [ for c in [
@@ -320,7 +320,7 @@ async fn me(
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone(); let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url); 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!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode, "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) { if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme")); 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) Ok(StatusCode::NO_CONTENT)
} }
@@ -378,7 +378,7 @@ async fn list_users(
let users: Vec<_> = state let users: Vec<_> = state
.ctx .ctx
.db .db
.users()? .users().await?
.iter() .iter()
.map(|u| { .map(|u| {
serde_json::json!({ serde_json::json!({
@@ -409,7 +409,7 @@ async fn add_user(
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| { 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") 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"))); return Err(ApiError::bad_request(format!("{name} already exists")));
} }
// No password is someone the proxy signs in, as with `ipx user add --no-password`. // No password is someone the proxy signs in, as with `ipx user add --no-password`.
@@ -418,7 +418,7 @@ async fn add_user(
} else { } else {
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?) 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"); tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@@ -435,7 +435,7 @@ async fn patch_user(
Json(body): Json<UserPatch>, Json(body): Json<UserPatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
require_admin(&user)?; require_admin(&user)?;
let users = state.ctx.db.users()?; let users = state.ctx.db.users().await?;
let target = users let target = users
.iter() .iter()
.find(|u| u.id == id) .find(|u| u.id == id)
@@ -446,7 +446,7 @@ async fn patch_user(
target.name 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"); tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -457,7 +457,7 @@ async fn remove_user(
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
require_admin(&user)?; require_admin(&user)?;
let users = state.ctx.db.users()?; let users = state.ctx.db.users().await?;
let target = users let target = users
.iter() .iter()
.find(|u| u.id == id) .find(|u| u.id == id)
@@ -468,7 +468,7 @@ async fn remove_user(
target.name 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"); tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT) 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 { if !user.is_admin {
return Redirect::to("/").into_response(); 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); let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response() ([(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 /// 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. /// 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 { 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))) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
} }