7170be016d
Adds `memory-serve` 2.1 as an optional workspace dependency, a `build.rs` that runs `load_directory` only when `CARGO_FEATURE_EMBED_WEB` is set, a `web_assets` module serving `web/dist` at `/` with SPA fallback (200 OK) for unknown client-side routes, and a feature-gated integration test. The default build (no feature) compiles and tests cleanly without `web/dist`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
3.8 KiB
Rust
134 lines
3.8 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)
|
|
.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(())
|
|
}
|