//! Server wiring: configuration and startup. mod config; #[cfg(feature = "embed-web")] mod web_assets; pub use config::Config; use anyhow::Context; use api::{AppState, build_app, migrate_sessions}; use db::Db; use domain::{AuditActor, Email, NewUser, Role}; use tokio::net::TcpListener; /// Connect dependencies from `config` and serve until shutdown. pub async fn run(config: Config) -> anyhow::Result<()> { let db = Db::connect(&config.database_url) .await .context("connecting to the database")?; db.migrate().await.context("running database migrations")?; migrate_sessions(&db) .await .context("creating the session store")?; let search = match (&config.meili_url, &config.meili_master_key) { (Some(url), Some(key)) => { let client = search::SearchClient::connect(url, key, &config.meili_index) .context("connecting to Meilisearch")?; client .ensure_index() .await .context("ensuring the search index exists")?; tracing::info!(index = %config.meili_index, "search indexing enabled"); Some(client) } _ => { tracing::warn!( "MEILI_URL/MEILI_MASTER_KEY not set — search indexing disabled (reindex_all remains the rebuild path)" ); None } }; let state = AppState { db, app_name: config.app_name.clone(), cookie_secure: config.cookie_secure, search, }; let listener = TcpListener::bind(&config.bind_addr) .await .with_context(|| format!("binding to {}", config.bind_addr))?; tracing::info!(addr = %config.bind_addr, "server listening"); serve(listener, state).await } /// Serve the API on an already-bound listener (used by `run` and tests). pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { let app = build_app(state); #[cfg(feature = "embed-web")] let app = app.merge(web_assets::routes()); axum::serve(listener, app) .await .context("running the HTTP server")?; Ok(()) } #[cfg(feature = "embed-web")] pub mod test_support { /// The SPA-asset router, for tests. pub fn web_router() -> axum::Router { super::web_assets::routes() } } /// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI /// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set, /// otherwise prompts (hidden input). The plaintext is not zeroized, but it is /// confined to the scope below and dropped before any network I/O. pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> { let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?; // Read, validate, and hash the password in a scope so the plaintext `String` is // dropped before we open a connection / run any awaits. let password_hash = { let password = match std::env::var("BOOTSTRAP_PASSWORD") { Ok(p) => p, Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, }; anyhow::ensure!( password.chars().count() >= 8, "password must be at least 8 characters" ); auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))? }; let db = Db::connect(database_url) .await .context("connecting to the database")?; let mut tx = db.pool().begin().await?; let id = db::users::create_user( &mut tx, AuditActor::System, &NewUser { email, password_hash, role, }, ) .await .context("creating the user (is the email already taken?)")?; tx.commit().await?; println!("created user {id} ({role:?})"); Ok(()) }