SeaORM: accounts, sessions and themes

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

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

View File

@@ -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)?;
}