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
+126 -2
View File
@@ -58,6 +58,43 @@ enum Command {
/// Inspect the resolved configuration.
#[command(subcommand)]
Config(ConfigCommand),
/// Manage dashboard users without taking the pipeline run lock.
#[command(subcommand)]
Users(UsersCommand),
}
#[derive(Debug, Subcommand)]
enum UsersCommand {
/// Add a user.
Add(UserAddArgs),
/// Change a user's password and revoke existing sessions.
Passwd(UserPasswordArgs),
/// Change a user's role.
Role { username: String, role: String },
/// Disable a user and revoke existing sessions.
Disable { username: String },
/// Enable a user.
Enable { username: String },
/// List users and open-session counts.
List,
/// Revoke all sessions for a user.
Logout { username: String },
}
#[derive(Debug, clap::Args)]
struct UserAddArgs {
username: String,
#[arg(long)]
admin: bool,
#[arg(long)]
password_stdin: bool,
}
#[derive(Debug, clap::Args)]
struct UserPasswordArgs {
username: String,
#[arg(long)]
password_stdin: bool,
}
#[derive(Debug, Subcommand)]
@@ -286,7 +323,8 @@ async fn main() -> Result<()> {
}
Command::Serve => {
let db = Db::open_and_migrate(&config.database_path).await?;
server::serve(config, db).await?;
let config_path = Config::resolve_path(cli.config.as_deref());
server::serve(config, config_path, db).await?;
}
Command::Profile(ProfileCommand::Rebuild) => {
let db = Db::open_and_migrate(&config.database_path).await?;
@@ -326,6 +364,10 @@ async fn main() -> Result<()> {
println!("{line}");
}
}
Command::Users(command) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_users(&db, command).await?;
}
}
Ok(())
}
@@ -345,10 +387,91 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
| Command::Stats(_)
| Command::Features(FeaturesCommand::Prune)
| Command::Db(_)
| Command::Config(_) => None,
| Command::Config(_)
| Command::Users(_) => None,
}
}
async fn cmd_users(db: &Db, command: UsersCommand) -> Result<()> {
use daily_epub::web::users;
match command {
UsersCommand::Add(args) => {
let password = read_new_password(args.password_stdin)?;
let user = users::add(db, &args.username, &password, args.admin).await?;
println!("added {} ({})", user.username, user.role);
}
UsersCommand::Passwd(args) => {
let password = read_new_password(args.password_stdin)?;
let sessions = users::passwd(db, &args.username, &password).await?;
println!(
"changed password for {} and revoked {sessions} session(s)",
args.username
);
}
UsersCommand::Role { username, role } => {
let role = role.parse().map_err(anyhow::Error::msg)?;
users::set_role(db, &username, role).await?;
println!("set {username} role to {role}");
}
UsersCommand::Disable { username } => {
let sessions = users::set_disabled(db, &username, true).await?;
println!("disabled {username} and revoked {sessions} session(s)");
}
UsersCommand::Enable { username } => {
users::set_disabled(db, &username, false).await?;
println!("enabled {username}");
}
UsersCommand::List => {
for row in users::list(db).await? {
let last_login = row
.user
.last_login_at
.map(|timestamp| timestamp.to_string())
.unwrap_or_else(|| "never".into());
println!(
"{} · {}{} · created {} · last login {} · {} open session(s)",
row.user.username,
row.user.role,
if row.user.disabled {
" · disabled"
} else {
""
},
row.user.created_at,
last_login,
row.open_sessions
);
}
}
UsersCommand::Logout { username } => {
let sessions = users::logout(db, &username).await?;
println!("revoked {sessions} session(s) for {username}");
}
}
Ok(())
}
fn read_new_password(from_stdin: bool) -> Result<String> {
if from_stdin {
let mut password = String::new();
std::io::stdin().read_line(&mut password)?;
while password.ends_with(['\n', '\r']) {
password.pop();
}
return Ok(password);
}
let first = read_password_hidden("Password: ")?;
let second = read_password_hidden("Confirm password: ")?;
if first != second {
anyhow::bail!("passwords do not match");
}
Ok(first)
}
fn read_password_hidden(prompt: &str) -> Result<String> {
rpassword::prompt_password(prompt).context("reading the password from the terminal")
}
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
@@ -562,6 +685,7 @@ async fn append_cli_event(
};
let event = RatingEvent {
id: 0,
user_id: None,
article_id,
issue_date: db.latest_issue_date_for_article(article_id).await?,
kind: "explicit".into(),