mod auth; mod forest_client; mod notification_consumer; mod notification_ingester; mod notification_worker; mod routes; mod serve_http; mod session_reaper; mod state; mod templates; use std::net::SocketAddr; use std::sync::Arc; use forage_core::session::{FileSessionStore, SessionStore}; use forage_db::PgSessionStore; use opentelemetry::trace::TracerProvider as _; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use axum::Router; use axum::extract::State; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; use minijinja::context; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use crate::forest_client::GrpcForestClient; use crate::state::AppState; use crate::templates::TemplateEngine; fn init_telemetry() { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,h2=warn,tonic=info".into()); let fmt_layer = tracing_subscriber::fmt::layer(); if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() { let tracer = opentelemetry_otlp::SpanExporter::builder() .with_tonic() .build() .expect("failed to create OTLP span exporter"); let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_batch_exporter(tracer) .with_resource( opentelemetry_sdk::Resource::builder() .with_service_name( std::env::var("OTEL_SERVICE_NAME") .unwrap_or_else(|_| "forage-server".into()), ) .build(), ) .build(); let otel_layer = tracing_opentelemetry::layer() .with_tracer(tracer_provider.tracer("forage-server")); tracing_subscriber::registry() .with(env_filter) .with(fmt_layer) .with(otel_layer) .init(); tracing::info!("OpenTelemetry enabled — exporting to OTLP endpoint"); } else { tracing_subscriber::registry() .with(env_filter) .with(fmt_layer) .init(); } } async fn fallback_404(State(state): State) -> Response { let html = state.templates.render( "pages/error.html.jinja", context! { title => "Not Found - Forage", description => "The page you're looking for doesn't exist.", status => 404u16, heading => "Page not found", message => "The page you're looking for doesn't exist.", }, ); match html { Ok(body) => (StatusCode::NOT_FOUND, Html(body)).into_response(), Err(_) => StatusCode::NOT_FOUND.into_response(), } } pub fn build_router(state: AppState) -> Router { Router::new() .merge(routes::router()) .nest_service("/static", ServeDir::new("static")) .fallback(fallback_404) .layer(TraceLayer::new_for_http()) .with_state(state) } #[tokio::main] async fn main() -> anyhow::Result<()> { init_telemetry(); let forest_endpoint = std::env::var("FOREST_SERVER_URL").unwrap_or_else(|_| "http://localhost:4040".into()); tracing::info!("connecting to forest-server at {forest_endpoint}"); let forest_client = GrpcForestClient::connect_lazy(&forest_endpoint)?; let template_engine = TemplateEngine::new()?; let port: u16 = std::env::var("PORT") .ok() .and_then(|p| p.parse().ok()) .unwrap_or(3000); let addr = SocketAddr::from(([0, 0, 0, 0], port)); // Build components based on available configuration let mut mad = notmad::Mad::builder(); // Session store + integration store: PostgreSQL if DATABASE_URL is set let (sessions, integration_store): (Arc, Option>); if let Ok(database_url) = std::env::var("DATABASE_URL") { tracing::info!("using PostgreSQL session store"); let pool = sqlx::PgPool::connect(&database_url).await?; forage_db::migrate(&pool).await?; let pg_store = Arc::new(PgSessionStore::new(pool.clone())); // Integration store (uses same pool) let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY") .unwrap_or_else(|_| { tracing::warn!("INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)"); "forage-dev-key-not-for-production!!".to_string() }); let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(pool, encryption_key.into_bytes())); // Session reaper component mad.add(session_reaper::PgSessionReaper { store: pg_store.clone(), max_inactive_days: 30, }); sessions = pg_store; integration_store = Some(pg_integrations as Arc); } else { let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into()); tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)"); let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir")); // File session reaper component mad.add(session_reaper::FileSessionReaper { store: file_store.clone(), }); sessions = file_store as Arc; integration_store = None; }; let forest_client = Arc::new(forest_client); let mut state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions) .with_grpc_client(forest_client.clone()); // Slack OAuth config (optional, enables "Add to Slack" button) if let (Ok(client_id), Ok(client_secret)) = ( std::env::var("SLACK_CLIENT_ID"), std::env::var("SLACK_CLIENT_SECRET"), ) { let redirect_host = std::env::var("SLACK_REDIRECT_HOST") .unwrap_or_else(|_| format!("http://localhost:{port}")); tracing::info!("Slack OAuth enabled"); state = state.with_slack_config(crate::state::SlackConfig { client_id, client_secret, redirect_host, }); } // NATS JetStream connection (optional, enables durable notification delivery) let nats_jetstream = if let Ok(nats_url) = std::env::var("NATS_URL") { match async_nats::connect(&nats_url).await { Ok(client) => { tracing::info!("connected to NATS at {nats_url}"); Some(async_nats::jetstream::new(client)) } Err(e) => { tracing::error!(error = %e, "failed to connect to NATS — falling back to direct dispatch"); None } } } else { None }; if let Some(ref store) = integration_store { state = state.with_integration_store(store.clone()); if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") { let forage_url = std::env::var("FORAGE_URL") .or_else(|_| std::env::var("SLACK_REDIRECT_HOST")) .unwrap_or_else(|_| format!("http://localhost:{port}")); if let Some(ref js) = nats_jetstream { // JetStream mode: ingester publishes, consumer dispatches tracing::info!("starting notification pipeline (JetStream)"); let grpc_for_consumer = forest_client.clone(); let token_for_consumer = service_token.clone(); mad.add(notification_ingester::NotificationIngester { grpc: forest_client, jetstream: js.clone(), service_token, }); mad.add(notification_consumer::NotificationConsumer { jetstream: js.clone(), store: store.clone(), forage_url, grpc: grpc_for_consumer, service_token: token_for_consumer, }); } else { // Fallback: direct dispatch (no durability) tracing::warn!("NATS_URL not set — using direct notification dispatch (no durability)"); mad.add(notification_worker::NotificationListener { grpc: forest_client, store: store.clone(), service_token, forage_url, }); } } else { tracing::warn!("FORAGE_SERVICE_TOKEN not set — notification listener disabled"); } } // HTTP server component mad.add(serve_http::ServeHttp { addr, state, }); mad.run().await?; Ok(()) } #[cfg(test)] mod test_support; #[cfg(test)] mod tests;