When PORT=0, the backend now writes its actual bound port to data/test/.port. test-env.sh reads that file when TT_TEST_PORT=0 so all targets (test-up, test-reset, test-down) resolve the real URL. test-up waits for .port to appear before the health-check loop.
131 lines
4.2 KiB
Rust
131 lines
4.2 KiB
Rust
mod auth;
|
|
mod db;
|
|
mod error;
|
|
mod models;
|
|
mod routes;
|
|
|
|
#[cfg(test)]
|
|
mod test_helpers;
|
|
|
|
use axum::routing::get;
|
|
use std::net::SocketAddr;
|
|
use tower_http::services::{ServeDir, ServeFile};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub pool: sqlx::SqlitePool,
|
|
pub jwt_secret: String,
|
|
pub test_mode: bool,
|
|
}
|
|
|
|
impl axum::extract::FromRef<AppState> for sqlx::SqlitePool {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.pool.clone()
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::from_default_env())
|
|
.init();
|
|
|
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
|
|
|
#[cfg(debug_assertions)]
|
|
let test_mode = std::env::var("TT_TEST_MODE").as_deref() == Ok("1");
|
|
#[cfg(not(debug_assertions))]
|
|
let test_mode = false;
|
|
|
|
if test_mode {
|
|
// Extra safeguard: panic if someone tries to enable test mode in what looks like production
|
|
if std::env::var("APP_ENV").as_deref() == Ok("production") {
|
|
panic!("TT_TEST_MODE cannot be active when APP_ENV=production");
|
|
}
|
|
let seed_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("demo/demo_seed.sql");
|
|
let seed = std::fs::read_to_string(&seed_path).expect("demo/demo_seed.sql not found");
|
|
routes::test_reset::SEED_SQL.set(seed).ok();
|
|
tracing::warn!("TT_TEST_MODE active — /__test__/reset is enabled");
|
|
}
|
|
|
|
let pool = db::init().await.expect("db init failed");
|
|
db::maybe_seed_demo(&pool).await;
|
|
|
|
let state = AppState {
|
|
pool,
|
|
jwt_secret,
|
|
test_mode,
|
|
};
|
|
|
|
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/build".into());
|
|
|
|
let app = routes::build(state, test_mode)
|
|
.route("/health", get(|| async { "ok" }))
|
|
.fallback_service(
|
|
ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))),
|
|
)
|
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
|
axum::http::header::CONTENT_SECURITY_POLICY,
|
|
axum::http::HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self';"),
|
|
))
|
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
|
axum::http::header::X_CONTENT_TYPE_OPTIONS,
|
|
axum::http::HeaderValue::from_static("nosniff"),
|
|
))
|
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
|
axum::http::header::X_FRAME_OPTIONS,
|
|
axum::http::HeaderValue::from_static("DENY"),
|
|
))
|
|
.layer(tower_http::trace::TraceLayer::new_for_http());
|
|
|
|
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
|
|
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address");
|
|
|
|
let listener = tokio::net::TcpListener::bind(&addr)
|
|
.await
|
|
.expect("failed to bind");
|
|
let actual_addr = listener.local_addr().expect("failed to get local addr");
|
|
tracing::info!("listening on {}", actual_addr);
|
|
|
|
// When started with PORT=0 (random), write actual port so the test harness can discover it
|
|
if port == "0" {
|
|
let _ = std::fs::create_dir_all("data/test");
|
|
let _ = std::fs::write("data/test/.port", actual_addr.port().to_string());
|
|
}
|
|
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(shutdown_signal())
|
|
.await
|
|
.expect("failed to serve");
|
|
}
|
|
|
|
async fn shutdown_signal() {
|
|
let ctrl_c = async {
|
|
tokio::signal::ctrl_c()
|
|
.await
|
|
.expect("failed to install Ctrl+C handler");
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
let terminate = async {
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
.expect("failed to install signal handler")
|
|
.recv()
|
|
.await;
|
|
};
|
|
|
|
#[cfg(not(unix))]
|
|
let terminate = std::future::pending::<()>();
|
|
|
|
tokio::select! {
|
|
_ = ctrl_c => {},
|
|
_ = terminate => {},
|
|
}
|
|
|
|
tracing::info!("signal received, starting graceful shutdown");
|
|
}
|