Files
biggus-dickus/crates/server/src/lib.rs
T

191 lines
5.6 KiB
Rust

//! 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, config.db_max_connections)
.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,
cookie_secure: config.cookie_secure,
search,
default_language: config.default_language,
default_timezone: config.default_timezone,
};
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
}
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
/// drain in-flight requests before exiting.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received; draining");
}
/// 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)
.with_graceful_shutdown(shutdown_signal())
.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()
}
}
/// One-shot: apply migrations (idempotent), then seed the baseline Spectrum cataloguing
/// vocabularies + field definitions. Safe to re-run (the seed is idempotent).
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
// CLI one-shot: a tiny pool is plenty.
let db = Db::connect(database_url, 2)
.await
.context("connecting to the database")?;
// Apply migrations first so `server seed` works on a fresh DB without first
// starting the server. Migrations are idempotent.
db.migrate().await.context("running database migrations")?;
let mut tx = db.pool().begin().await?;
db::seed::seed_spectrum_cataloguing(&mut tx)
.await
.context("seeding Spectrum cataloguing baseline")?;
tx.commit().await?;
println!("seeded Spectrum cataloguing baseline (idempotent)");
Ok(())
}
/// 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}"))?
};
// CLI one-shot: a tiny pool is plenty.
let db = Db::connect(database_url, 2)
.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(())
}