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:
249
src/db.rs
249
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<i64>,
|
||||
}
|
||||
|
||||
/// 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<User> {
|
||||
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<users::Model> 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<i64> {
|
||||
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<i64> {
|
||||
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<Option<User>> {
|
||||
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<Option<User>> {
|
||||
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>> {
|
||||
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), id)
|
||||
pub async fn user_by_id(&self, id: i64) -> Result<Option<User>> {
|
||||
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>> {
|
||||
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<Vec<User>> {
|
||||
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<Vec<User>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
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<String>, Option<String>)> {
|
||||
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<String>, Option<String>)> {
|
||||
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<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
pub async fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {
|
||||
// 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());
|
||||
|
||||
36
src/main.rs
36
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<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
let read_password = || -> Result<String> {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
@@ -253,7 +253,7 @@ fn user_cmd(ctx: &Arc<Ctx>, 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<Ctx>, 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<Ctx>, 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 <name>");
|
||||
}
|
||||
@@ -295,9 +295,9 @@ fn user_cmd(ctx: &Arc<Ctx>, 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<Ctx>, 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<Ctx>, 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 <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();
|
||||
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 <name> --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)?;
|
||||
}
|
||||
|
||||
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