#[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 gitea_base = content::gitea_api_base(&content_repo)?; let content_raw_base = content::gitea_raw_base(&content_repo)?; let aggregates = content::with_builtin_aggregates( content::load_aggregates_from_gitea(&content_repo, &content_branch).await?, ); let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; content::validate_questions(&questions, &aggregates)?; let site = content::load_site_from_gitea(&content_repo, &content_branch).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 aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)); let site = Arc::new(arc_swap::ArcSwap::from_pointee(site)); 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()); portal::events::store::ensure_stream(&jetstream).await?; 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.clone(), "questions".to_string(), questions.clone(), aggregates.clone(), site.clone(), )); let state = AppState { leptos_options: leptos_options.clone(), nats, jetstream, questions, aggregates, site, gitea_base, content_raw_base, content_branch: content_branch.clone(), oidc: oidc_state, garage, }; // Announced pages: keep their desk records current, close ended // windows (see announce.rs). tokio::spawn(portal::announce::run(state.clone())); // 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_light_path = format!("{}/favicon-light.svg", leptos_options.site_root); let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root); let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root); let swiper_path = format!("{}/swiper-element-bundle.min.js", 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)) .route("/gitea-repo", get(content::gitea_repo_handler)) // Content-shipped assets (hero module etc.), same-origin. .route("/site/{*path}", get(content::site_asset_handler)) .route("/automation/kv/{bucket}", get(content::automation_kv_handler)) // no-cache = "revalidate before reuse", not "don't cache": // pkg files keep the same names across releases (portal.js, // portal.wasm), and without this browsers heuristically cache // them - a stale wasm from the previous release then talks to // a server whose server-fn wire format has moved on and every // page renders as its error branch. A 304 per load is the // price of never shipping that skew again. .nest_service( "/pkg", tower::ServiceBuilder::new() .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache"), )) .service(ServeDir::new(pkg_dir)), ) .nest_service("/fonts", ServeDir::new(fonts_dir)) .route_service("/favicon-light.svg", ServeFile::new(favicon_light_path)) .route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path)) .route_service("/wordmark.svg", ServeFile::new(wordmark_path)) .route_service( "/swiper-element-bundle.min.js", ServeFile::new(swiper_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. }