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.
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());