mod assets; mod domain; mod http; mod hub; mod ports; mod security; mod seed; mod services; mod sqlite; mod views; mod webauthn; use std::env; use std::path::Path as FilePath; use std::sync::Arc; use tokio::net::TcpListener; use tracing::{info, warn}; use crate::http::{AppState, build_router}; use crate::hub::InMemoryHub; use crate::ports::{ CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository, PasswordHasher, RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator, UserRepository, }; use crate::security::{Argon2PasswordHasher, RandomTokenGenerator}; use crate::services::{ AuthService, InvitationService, ListService, MealService, RegistrationMode, RewardsCardService, }; use crate::sqlite::{ SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository, SqliteListMealRepository, SqliteListRepository, SqliteMealCategoryRepository, SqliteMealIngredientRepository, SqliteMealRepository, SqlitePasskeyRepository, SqliteRewardsCardRepository, SqliteSessionRepository, SqliteUserRepository, }; use crate::webauthn::{AppWebauthnConfig, WebAuthnService}; #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter( env::var("RUST_LOG").unwrap_or_else(|_| "sustenance=info,tower_http=info".into()), ) .init(); let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into()); let database_in_memory = env::var("DATABASE_IN_MEMORY") .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) .unwrap_or(false); let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into()); // For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers // reject IP addresses as RP IDs). Access the app via http://localhost:PORT. let bind_host = bind_address.split(':').next().unwrap_or("127.0.0.1"); let is_loopback = bind_host == "127.0.0.1" || bind_host == "::1" || bind_host == "localhost"; let public_host = if is_loopback { "localhost" } else { bind_host }; let public_base_url = env::var("PUBLIC_BASE_URL").unwrap_or_else(|_| { let port = bind_address.rsplit(':').next().unwrap_or("3000"); format!("http://{}:{}", public_host, port) }); let cookie_secure = env::var("COOKIE_SECURE") .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) .unwrap_or(false); let registration_mode = match env::var("REGISTRATION_MODE") .unwrap_or_else(|_| "invite_only".into()) .to_ascii_lowercase() .as_str() { "open" => RegistrationMode::Open, "invite_only" | "invite-only" => RegistrationMode::InviteOnly, value => { warn!(value, "unknown REGISTRATION_MODE; using invite_only"); RegistrationMode::InviteOnly } }; // Build the adapters (ports) and wire them into application services. let db = if database_in_memory { SqliteDatabase::open_in_memory().await? } else { SqliteDatabase::open(&database_path).await? }; let users: Arc = Arc::new(SqliteUserRepository); let sessions: Arc = Arc::new(SqliteSessionRepository); let lists: Arc = Arc::new(SqliteListRepository); let categories: Arc = Arc::new(SqliteCategoryRepository); let items: Arc = Arc::new(SqliteItemRepository); let list_meals: Arc = Arc::new(SqliteListMealRepository); let meals: Arc = Arc::new(SqliteMealRepository); let meal_ingredients: Arc = Arc::new(SqliteMealIngredientRepository); let meal_categories: Arc = Arc::new(SqliteMealCategoryRepository); let invitations: Arc = Arc::new(SqliteInvitationRepository); let passkeys: Arc = Arc::new(SqlitePasskeyRepository); let rewards_cards: Arc = Arc::new(SqliteRewardsCardRepository); let hasher: Arc = Arc::new(Argon2PasswordHasher); let tokens: Arc = Arc::new(RandomTokenGenerator); let realtime: Arc = Arc::new(InMemoryHub::default()); let auth = Arc::new(AuthService::new( db.clone(), Arc::clone(&users), Arc::clone(&sessions), Arc::clone(&invitations), Arc::clone(&hasher), registration_mode, )); let lists_service = Arc::new(ListService::new( db.clone(), Arc::clone(&lists), Arc::clone(&categories), Arc::clone(&items), Arc::clone(&realtime), )); let invitations_service = Arc::new(InvitationService::new( db.clone(), Arc::clone(&invitations), Arc::clone(&tokens), )); let rewards_cards_service = Arc::new(RewardsCardService::new( db.clone(), Arc::clone(&rewards_cards), )); let meals_service = Arc::new(MealService::new( db.clone(), Arc::clone(&meals), Arc::clone(&meal_ingredients), Arc::clone(&meal_categories), Arc::clone(&lists), Arc::clone(&items), Arc::clone(&list_meals), Arc::clone(&realtime), )); // WebAuthn config from env vars. RP_ID must match the host users access the site from. let rp_id = env::var("RP_ID").unwrap_or_else(|_| { let host = public_base_url .trim_start_matches("http://") .trim_start_matches("https://") .split('/') .next() .unwrap_or("localhost") .split(':') .next() .unwrap_or("localhost") .to_owned(); host }); let rp_name = env::var("RP_NAME").unwrap_or_else(|_| "Sustenance".into()); let origin = url::Url::parse(&public_base_url).map_err(|e| format!("invalid PUBLIC_BASE_URL: {e}"))?; let webauthn_service = Arc::new(WebAuthnService::new( db.clone(), AppWebauthnConfig::new(rp_id, rp_name, origin), Arc::clone(&users), Arc::clone(&passkeys), )); let seed_path = env::var("SEED_CONFIG").unwrap_or_else(|_| "seed.json".into()); seed::seed_if_needed(&db, &users, &hasher, FilePath::new(&seed_path)).await; let state = AppState { auth, lists: lists_service, meals: meals_service, invitations: invitations_service, rewards_cards: rewards_cards_service, webauthn: webauthn_service, realtime, cookie_secure, public_base_url, }; let app = build_router(state); let listener = TcpListener::bind(&bind_address).await?; info!(address = %bind_address, "sustenance listening"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; info!("shutdown complete; closing database"); // Explicitly close the pool so SQLite can checkpoint and remove the // WAL/SHM sidecar files. Without this, the pool's background close task // races with process exit and the sidecars can be left behind. db.close().await; Ok(()) } 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 => {}, } info!("signal received; starting graceful shutdown"); }