#[cfg(feature = "ssr")] #[tokio::main] async fn main() -> anyhow::Result<()> { use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router}; use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; use portal::app::{shell, App}; use portal::content; use portal::server::{oidc, AppState}; use portal::upload::{self, Garage}; use std::sync::Arc; use tower_http::services::{ServeDir, ServeFile}; use tower_sessions::{MemoryStore, SessionManagerLayer}; dotenvy::dotenv().ok(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,portal=debug".into()), ) .init(); let conf = get_configuration(None)?; let leptos_options = conf.leptos_options; let addr = leptos_options.site_addr; let routes = generate_route_list(App); let content_repo = std::env::var("CONTENT_REPO") .unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string()); let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string()); let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content"); let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions)); let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string()); tracing::info!(%nats_url, "connecting to NATS"); let parsed = url::Url::parse(&nats_url)?; let mut nats_opts = async_nats::ConnectOptions::new(); if !parsed.username().is_empty() { nats_opts = nats_opts.user_and_password( parsed.username().to_string(), parsed.password().unwrap_or_default().to_string(), ); } let nats = nats_opts.connect(&nats_url).await?; // No fixed, pre-declared list of buckets to initialize here - content // names whatever bucket an alternative's `record_as` should write // into, and `answers::store_answer` creates it on first use. let jetstream = async_nats::jetstream::new(nats.clone()); let oidc_state = Arc::new(oidc::Oidc::from_env().await?); let garage = Garage::from_env(); if garage.is_none() { tracing::warn!("GARAGE_* env vars not set - file upload fields will fail closed"); } tokio::spawn(content::watch_for_reload( nats.clone(), content_repo, content_branch, "questions".to_string(), questions.clone(), )); let state = AppState { leptos_options: leptos_options.clone(), nats, jetstream, questions, oidc: oidc_state, garage, }; // Dev-friendly defaults: in-memory sessions, secure cookies only when // COOKIE_SECURE=true (set it behind TLS in production) - same // defaults cnats uses. SameSite=Lax (tower-sessions defaults to // Strict) because the OIDC callback is, by definition, a top-level // GET arriving via a redirect *from* the IdP's origin - Strict // withholds the cookie on exactly that request, breaking login // whenever the app and Kanidm aren't under the same registrable // domain (e.g. this app on 127.0.0.1 during local dev vs. Kanidm's // real domain). Lax is the standard, correct setting for this // pattern, not a workaround. let cookie_secure = std::env::var("COOKIE_SECURE") .map(|v| v == "true" || v == "1") .unwrap_or(false); let session_layer = SessionManagerLayer::new(MemoryStore::default()) .with_secure(cookie_secure) .with_same_site(tower_sessions::cookie::SameSite::Lax) .with_name("portal_session"); async fn server_fn_handler( State(state): State, request: Request, ) -> impl IntoResponse { leptos_axum::handle_server_fns_with_context( move || provide_context(state.clone()), request, ) .await } // The app's own Leptos route list includes a root-level wildcard // (`/*any`, since a question's `id` doubles as its URL path), which // would otherwise swallow every request - including these static // assets - before `.fallback()` ever got a chance to serve them. // Registering them explicitly here lets axum's route matching pick // the more specific match over the wildcard. let pkg_dir = format!( "{}/{}", leptos_options.site_root, leptos_options.site_pkg_dir ); let favicon_path = format!("{}/favicon.svg", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root); let app = Router::new() .route("/auth/login", get(oidc::login)) .route("/auth/callback", get(oidc::callback)) .route("/auth/logout", get(oidc::logout)) .route("/api/{*fn_name}", any(server_fn_handler)) .route("/upload", post(upload::upload)) .nest_service("/pkg", ServeDir::new(pkg_dir)) .nest_service("/fonts", ServeDir::new(fonts_dir)) .route_service("/favicon.svg", ServeFile::new(favicon_path)) .leptos_routes_with_context( &state, routes, { let state = state.clone(); move || provide_context(state.clone()) }, { let leptos_options = leptos_options.clone(); move || shell(leptos_options.clone()) }, ) .fallback(leptos_axum::file_and_error_handler::(shell)) .layer(session_layer) .with_state(state); tracing::info!("listening on http://{addr}"); let listener = tokio::net::TcpListener::bind(&addr).await?; axum::serve(listener, app.into_make_service()).await?; Ok(()) } #[cfg(not(feature = "ssr"))] fn main() { // The browser build is a cdylib; this stub only exists so `cargo check` // without features still succeeds. }