174 lines
5 KiB
Rust
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!()
|
|
}
|