Web dashboard step 1: migration, sessions, users CLI, public site

Migration 0004 (users, sessions, config_changes, profile_versions, jobs,
rating_events.user_id, runs.report_json, issues.issue_json), the issue
snapshot writer and loader, the web module skeleton with layout and static
assets, axum-login/tower-sessions over a sqlx session store, password-auth
users with a CLI, the login throttle, the origin check, security headers,
and the public issue pages, archive, Atom feed and robots.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 04:38:22 +00:00
co-authored by Claude Fable 5.1
parent a70de4af06
commit 69cd736455
35 changed files with 3801 additions and 69 deletions
+312
View File
@@ -0,0 +1,312 @@
use std::fmt;
use std::str::FromStr;
use jiff::Timestamp;
use sqlx::Row;
use crate::db::{Db, DbError, fmt_ts, parse_ts};
pub const MIN_PASSWORD_LEN: usize = 12;
pub const MAX_PASSWORD_LEN: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
User,
Admin,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Admin => "admin",
}
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Role {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"user" => Ok(Self::User),
"admin" => Ok(Self::Admin),
_ => Err(format!("invalid role {value:?}; expected user or admin")),
}
}
}
#[derive(Clone)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub role: Role,
pub disabled: bool,
pub created_at: Timestamp,
pub last_login_at: Option<Timestamp>,
}
impl fmt::Debug for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password_hash", &"[REDACTED]")
.field("role", &self.role)
.field("disabled", &self.disabled)
.field("created_at", &self.created_at)
.field("last_login_at", &self.last_login_at)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct UserListRow {
pub user: User,
pub open_sessions: i64,
}
pub fn validate_username(username: &str) -> anyhow::Result<()> {
if username.is_empty()
|| username.len() > 32
|| !username
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-'))
{
anyhow::bail!("username must be 1-32 characters from A-Z, a-z, 0-9, '.', '_' or '-'");
}
Ok(())
}
pub fn validate_password(password: &str) -> anyhow::Result<()> {
if password.len() < MIN_PASSWORD_LEN {
anyhow::bail!("password must be at least {MIN_PASSWORD_LEN} characters");
}
if password.len() > MAX_PASSWORD_LEN {
anyhow::bail!("password must be at most {MAX_PASSWORD_LEN} characters");
}
Ok(())
}
pub fn hash_password(plain: &str) -> String {
password_auth::generate_hash(plain)
}
pub fn verify_password(hash: &str, plain: &str) -> bool {
password_auth::verify_password(plain, hash).is_ok()
}
pub async fn find_by_username(db: &Db, username: &str) -> Result<Option<User>, DbError> {
let row = sqlx::query(
"SELECT id, username, password_hash, role, disabled, created_at, last_login_at
FROM users WHERE username = ? COLLATE NOCASE",
)
.bind(username)
.fetch_optional(db.pool())
.await?;
row.as_ref().map(user_from_row).transpose()
}
pub async fn find_by_id(db: &Db, id: i64) -> Result<Option<User>, DbError> {
let row = sqlx::query(
"SELECT id, username, password_hash, role, disabled, created_at, last_login_at
FROM users WHERE id = ?",
)
.bind(id)
.fetch_optional(db.pool())
.await?;
row.as_ref().map(user_from_row).transpose()
}
pub async fn add(db: &Db, username: &str, password: &str, admin: bool) -> anyhow::Result<User> {
validate_username(username)?;
validate_password(password)?;
if find_by_username(db, username).await?.is_some() {
anyhow::bail!("user {username:?} already exists");
}
let hash = hash_password(password);
let created_at = Timestamp::now();
let role = if admin { Role::Admin } else { Role::User };
let id: i64 = sqlx::query_scalar(
"INSERT INTO users (username, password_hash, role, created_at)
VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(username)
.bind(&hash)
.bind(role.as_str())
.bind(fmt_ts(created_at))
.fetch_one(db.pool())
.await?;
Ok(User {
id,
username: username.to_string(),
password_hash: hash,
role,
disabled: false,
created_at,
last_login_at: None,
})
}
pub async fn passwd(db: &Db, username: &str, password: &str) -> anyhow::Result<u64> {
validate_password(password)?;
let hash = hash_password(password);
let result =
sqlx::query("UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE")
.bind(hash)
.bind(username)
.execute(db.pool())
.await?;
require_one(username, result.rows_affected())?;
logout(db, username).await
}
pub async fn set_role(db: &Db, username: &str, role: Role) -> anyhow::Result<()> {
let result = sqlx::query("UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE")
.bind(role.as_str())
.bind(username)
.execute(db.pool())
.await?;
require_one(username, result.rows_affected())
}
pub async fn set_disabled(db: &Db, username: &str, disabled: bool) -> anyhow::Result<u64> {
let result = sqlx::query("UPDATE users SET disabled = ? WHERE username = ? COLLATE NOCASE")
.bind(disabled)
.bind(username)
.execute(db.pool())
.await?;
require_one(username, result.rows_affected())?;
if disabled {
logout(db, username).await
} else {
Ok(0)
}
}
pub async fn logout(db: &Db, username: &str) -> anyhow::Result<u64> {
let Some(user) = find_by_username(db, username).await? else {
anyhow::bail!("user {username:?} was not found");
};
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
.bind(user.id)
.execute(db.pool())
.await?;
Ok(result.rows_affected())
}
pub async fn list(db: &Db) -> anyhow::Result<Vec<UserListRow>> {
let rows = sqlx::query(
"SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.created_at,
u.last_login_at, COUNT(s.id) AS open_sessions
FROM users u LEFT JOIN sessions s ON s.user_id = u.id AND s.expiry > unixepoch()
GROUP BY u.id ORDER BY u.username COLLATE NOCASE",
)
.fetch_all(db.pool())
.await?;
rows.iter()
.map(|row| {
Ok(UserListRow {
user: user_from_row(row)?,
open_sessions: row.get("open_sessions"),
})
})
.collect()
}
fn require_one(username: &str, count: u64) -> anyhow::Result<()> {
if count == 0 {
anyhow::bail!("user {username:?} was not found");
}
Ok(())
}
fn user_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<User, DbError> {
let role_raw: String = row.get("role");
let role = role_raw.parse().map_err(|_| DbError::Decode {
column: "users.role",
value: role_raw,
})?;
Ok(User {
id: row.get("id"),
username: row.get("username"),
password_hash: row.get("password_hash"),
role,
disabled: row.get("disabled"),
created_at: parse_ts("users.created_at", &row.get::<String, _>("created_at"))?,
last_login_at: row
.get::<Option<String>, _>("last_login_at")
.map(|value| parse_ts("users.last_login_at", &value))
.transpose()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_hashes_round_trip_and_fail_safely() {
let hash = hash_password("correct horse battery");
assert!(verify_password(&hash, "correct horse battery"));
assert!(!verify_password(&hash, "wrong password"));
assert!(!verify_password("not a phc string", "anything"));
assert!(
!format!(
"{:?}",
User {
id: 1,
username: "reader".into(),
password_hash: hash.clone(),
role: Role::User,
disabled: false,
created_at: Timestamp::now(),
last_login_at: None,
}
)
.contains(&hash)
);
}
#[tokio::test]
async fn user_operations_validate_and_are_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
assert!(add(&db, "reader", "too-short", false).await.is_err());
let user = add(&db, "Reader", "correct horse battery", true)
.await
.unwrap();
assert_eq!(user.role, Role::Admin);
assert!(
add(&db, "reader", "another valid password", false)
.await
.is_err()
);
set_role(&db, "READER", Role::User).await.unwrap();
assert_eq!(
find_by_username(&db, "reader").await.unwrap().unwrap().role,
Role::User
);
passwd(&db, "reader", "a replacement password")
.await
.unwrap();
set_disabled(&db, "reader", true).await.unwrap();
assert!(
find_by_username(&db, "reader")
.await
.unwrap()
.unwrap()
.disabled
);
set_disabled(&db, "reader", false).await.unwrap();
}
}