use std::sync::atomic::Ordering;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
extract::{Request, State},
middleware::Next,
response::{IntoResponse, Redirect, Response},
};
use crate::{
storage::admin_users::AdminUserRepository,
web::{AppState, Chrome, auth::Password, render::WebError},
};
const MIN_PASSWORD_LEN: usize = 8;
impl AppState {
async fn needs_setup(&self) -> bool {
if self.setup_done.load(Ordering::Relaxed) {
return false;
}
match self.db.admin_users().count().await {
Ok(0) => true,
Ok(_) => {
self.setup_done.store(true, Ordering::Relaxed);
false
}
Err(e) => {
tracing::warn!(error = %e, "admin_users count failed; assuming setup complete");
false
}
}
}
}
pub async fn guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
let path = req.uri().path();
if path.starts_with("/assets/") || path == "/favicon.ico" {
return next.run(req).await;
}
if state.needs_setup().await {
if path == "/setup" {
return next.run(req).await;
}
return Redirect::to("/setup").into_response();
}
if path == "/setup" {
return Redirect::to("/login").into_response();
}
next.run(req).await
}
#[derive(Debug, serde::Deserialize)]
pub struct SetupForm {
username: String,
password: String,
confirm: String,
}
impl SetupForm {
fn username(&self) -> &str {
self.username.trim()
}
fn validate(&self) -> Result<(), String> {
if self.username().is_empty() {
return Err("Username is required.".to_owned());
}
if self.password.len() < MIN_PASSWORD_LEN {
return Err(format!(
"Password must be at least {MIN_PASSWORD_LEN} characters."
));
}
if self.password != self.confirm {
return Err("Passwords do not match.".to_owned());
}
Ok(())
}
}
impl AppState {
pub async fn setup_form(State(state): State<AppState>) -> Response {
WizardTemplate {
chrome: state.bare_chrome().await,
error: None,
}
.into_response()
}
pub async fn setup_submit(
State(state): State<AppState>,
axum::Form(form): axum::Form<SetupForm>,
) -> Response {
let repo = state.db.admin_users();
if let Err(msg) = form.validate() {
return WizardTemplate {
chrome: state.bare_chrome().await,
error: Some(msg),
}
.into_response();
}
let password = match Password::hash(&form.password) {
Ok(p) => p,
Err(e) => return e.into_response(),
};
match repo
.create_initial(form.username(), password.as_str())
.await
{
Ok(Some(_)) => {}
Ok(None) => return Redirect::to("/login").into_response(),
Err(e) => return WebError::from(e).into_response(),
}
state.setup_done.store(true, Ordering::Relaxed);
Redirect::to("/login").into_response()
}
}
#[derive(Template, WebTemplate)]
#[template(path = "setup.html")]
struct WizardTemplate {
chrome: Chrome,
error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn form(username: &str, password: &str, confirm: &str) -> SetupForm {
SetupForm {
username: username.to_owned(),
password: password.to_owned(),
confirm: confirm.to_owned(),
}
}
#[test]
fn validate_accepts_good_input() {
assert!(form("admin", "longenough", "longenough").validate().is_ok());
}
#[test]
fn validate_rejects_empty_username() {
assert!(form("", "longenough", "longenough").validate().is_err());
assert!(form(" ", "longenough", "longenough").validate().is_err());
}
#[test]
fn validate_rejects_short_password() {
let err = form("admin", "short", "short").validate().unwrap_err();
assert!(err.contains("at least"));
}
#[test]
fn validate_rejects_mismatch() {
let err = form("admin", "longenough", "different")
.validate()
.unwrap_err();
assert!(err.contains("do not match"));
}
}