rs_maps/src/main.rs
2026-08-03 13:41:35 +01:00

174 lines
5 KiB
Rust

// src/main.rs
mod auth;
mod config;
mod error;
mod gpx;
mod mail;
mod markers;
mod ratelimit;
mod routes;
mod web;
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::routing::get;
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use tower_sessions::cookie::SameSite;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use crate::config::Config;
use crate::mail::Mailer;
use crate::ratelimit::RateLimiter;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: std::sync::Arc<Config>,
pub mailer: Mailer,
}
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rs_maps=info,tower_http=warn".into()),
)
.init();
let config = Config::from_env()?;
// GPX_DIR must exist and be writable by this user before anything else.
tokio::fs::create_dir_all(&config.gpx_dir)
.await
.with_context(|| format!("cannot create GPX_DIR at {}", config.gpx_dir.display()))?;
let db = connect_with_retry(&config.database_url).await?;
sqlx::migrate!("./migrations")
.run(&db)
.await
.context("running migrations")?;
// The session store owns its own table; let its migration create it.
let session_store = PostgresStore::new(db.clone());
session_store
.migrate()
.await
.context("running session store migration")?;
let mailer = Mailer::from_config(&config.smtp)?;
let session_layer = SessionManagerLayer::new(session_store)
.with_name("mapserver.sid")
.with_secure(true)
.with_http_only(true)
.with_same_site(SameSite::Strict)
.with_path(config.cookie_path())
.with_expiry(Expiry::OnInactivity(time::Duration::days(
config.session_duration_days,
)));
let bind_addr = config.bind_addr.clone();
let base_path = config.base_path.clone();
let state = AppState {
db,
config: std::sync::Arc::new(config),
mailer,
};
// 20 attempts per minute per IP across the auth surface: generous for two
// humans, useless for grinding REG_TOKEN or a password.
let limiter = RateLimiter::new(20, Duration::from_secs(60), true);
let auth_routes = auth::routes().layer(axum::middleware::from_fn_with_state(
limiter.clone(),
ratelimit::limit,
));
let api = Router::new()
.merge(auth_routes)
.route("/config", get(web::client_config))
.nest("/markers", markers::routes())
.nest("/gpx", gpx::routes())
.nest("/routes", routes::routes());
let app = Router::new()
.nest("/api", api)
// Explicit root route. Inside a nest, "/maps/" reduces to "/" once the
// prefix is stripped, and that empty path is the one case the fallback
// below does not catch — which happens to be the exact URL a browser
// requests. "/maps" and "/maps/anything" work via the fallback.
.route("/", get(web::serve_asset))
.fallback(web::serve_asset)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
web::origin_guard,
))
.layer(session_layer)
.layer(TraceLayer::new_for_http())
.with_state(state);
// nest("/") panics, so root deployment uses the router as-is.
let app = if base_path.is_empty() {
app
} else {
Router::new().nest(&base_path, app)
};
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.with_context(|| format!("cannot bind {bind_addr}"))?;
tracing::info!(
"mapserver listening on {bind_addr} under {}",
if base_path.is_empty() { "/" } else { &base_path }
);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("server error")?;
Ok(())
}
/// systemd's After= doesn't mean Postgres is accepting connections yet, so retry
/// rather than exiting on the first refusal.
async fn connect_with_retry(url: &str) -> Result<PgPool> {
const ATTEMPTS: u32 = 10;
for attempt in 1..=ATTEMPTS {
match PgPoolOptions::new()
.max_connections(8)
.acquire_timeout(Duration::from_secs(5))
.connect(url)
.await
{
Ok(pool) => return Ok(pool),
Err(e) if attempt < ATTEMPTS => {
let wait = Duration::from_millis(500 * u64::from(attempt));
tracing::warn!(
attempt,
error = %e,
"database not ready, retrying in {:?}",
wait
);
tokio::time::sleep(wait).await;
}
Err(e) => return Err(e).context("database unreachable after retries"),
}
}
unreachable!()
}