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());
|
||||
|
||||
Reference in New Issue
Block a user