@@ -1,26 +1,32 @@
|
||||
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 axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use forage_core::session::{FileSessionStore, SessionStore};
|
||||
use forage_db::PgSessionStore;
|
||||
use minijinja::context;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
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;
|
||||
@@ -31,7 +37,6 @@ fn init_telemetry() {
|
||||
let fmt_layer = tracing_subscriber::fmt::layer();
|
||||
|
||||
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() {
|
||||
// OTLP exporter configured — send spans + logs to collector
|
||||
let tracer = opentelemetry_otlp::SpanExporter::builder()
|
||||
.with_tonic()
|
||||
.build()
|
||||
@@ -104,61 +109,127 @@ async fn main() -> anyhow::Result<()> {
|
||||
let forest_client = GrpcForestClient::connect_lazy(&forest_endpoint)?;
|
||||
let template_engine = TemplateEngine::new()?;
|
||||
|
||||
// Session store: PostgreSQL if DATABASE_URL is set, otherwise in-memory
|
||||
let sessions: Arc<dyn SessionStore> = 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));
|
||||
|
||||
// Session reaper for PostgreSQL
|
||||
let reaper = pg_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match reaper.reap_expired(30).await {
|
||||
Ok(n) if n > 0 => tracing::info!("session reaper: removed {n} expired sessions"),
|
||||
Err(e) => tracing::warn!("session reaper error: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pg_store
|
||||
} 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"));
|
||||
|
||||
let reaper = file_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
reaper.reap_expired();
|
||||
tracing::debug!("session reaper: {} active sessions", reaper.session_count());
|
||||
}
|
||||
});
|
||||
|
||||
file_store
|
||||
};
|
||||
|
||||
let forest_client = Arc::new(forest_client);
|
||||
let state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
|
||||
.with_grpc_client(forest_client);
|
||||
let app = build_router(state);
|
||||
|
||||
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));
|
||||
tracing::info!("listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
// 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<dyn SessionStore>, Option<Arc<dyn forage_core::integrations::IntegrationStore>>);
|
||||
|
||||
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<dyn forage_core::integrations::IntegrationStore>);
|
||||
} 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<dyn SessionStore>;
|
||||
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 base_url = std::env::var("FORAGE_BASE_URL")
|
||||
.unwrap_or_else(|_| format!("http://localhost:{port}"));
|
||||
tracing::info!("Slack OAuth enabled");
|
||||
state = state.with_slack_config(crate::state::SlackConfig {
|
||||
client_id,
|
||||
client_secret,
|
||||
base_url,
|
||||
});
|
||||
}
|
||||
|
||||
// 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") {
|
||||
if let Some(ref js) = nats_jetstream {
|
||||
// JetStream mode: ingester publishes, consumer dispatches
|
||||
tracing::info!("starting notification pipeline (JetStream)");
|
||||
mad.add(notification_ingester::NotificationIngester {
|
||||
grpc: forest_client,
|
||||
jetstream: js.clone(),
|
||||
service_token,
|
||||
});
|
||||
mad.add(notification_consumer::NotificationConsumer {
|
||||
jetstream: js.clone(),
|
||||
store: store.clone(),
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
} 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user