use axum::body::Body; use axum::extract::{DefaultBodyLimit, Form, Query, Request, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; use futures_util::{stream, StreamExt}; use hemx::IntoEffect; use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionRequest}; use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext}; use std::collections::BTreeMap; use std::convert::Infallible; use std::path::PathBuf; use std::time::{Duration, Instant}; #[tokio::main] async fn main() -> Result<(), Box> { let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned()); let store = std::env::var_os("HEMX_SAAS_STORE") .map(PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv")); let app = app(AppContext::durable(store, format!("http://{address}"))?); let listener = tokio::net::TcpListener::bind(&address).await?; axum::serve(listener, app).await?; Ok(()) } fn app(ctx: AppContext) -> Router { Router::new() .route("/", get(home).post(interact)) .route("/settings", get(settings)) .route("/projects", post(create_project)) .route("/health/live", get(health_live)) .route("/health/ready", get(health_ready)) .route("/metrics", get(metrics)) .route("/events", get(events)) .route(runtime_js_path(), get(runtime)) .route("/app.css", get(css)) .route("/metrics.js", get(metrics_js)) .layer(DefaultBodyLimit::max(8 * 1024)) .layer(middleware::from_fn(security_headers)) .with_state(ctx) } // req: security/006 req: security/009 async fn security_headers(request: Request, next: Next) -> Response { let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert( "content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"), ); headers.insert( "x-content-type-options", HeaderValue::from_static("nosniff"), ); headers.insert( "referrer-policy", HeaderValue::from_static("strict-origin-when-cross-origin"), ); response } async fn home(State(ctx): State) -> impl IntoResponse { axum::response::Html(home_page(&ctx).into_string()) } async fn settings(State(ctx): State) -> impl IntoResponse { axum::response::Html(settings_page(&ctx).into_string()) } async fn interact( State(ctx): State, request: InteractionRequest, ) -> Result { request.dispatch_async(registry(ctx)).await } async fn events( Query(params): Query>, State(ctx): State, ) -> impl IntoResponse { // The production reference exposes an ongoing server-owned stream; `once` // keeps a bounded probe for package tests without changing the public path. // req: examples/014 let event = |ctx: &AppContext| { Ok::<_, Infallible>(live_status(ctx.projects().len()).into_batch(ui::BUILD_FINGERPRINT)) }; let initial = stream::once(std::future::ready(event(&ctx))); if params.contains_key("once") { return sse(initial.left_stream()); } let updates = stream::unfold(ctx, move |ctx| async move { tokio::time::sleep(Duration::from_secs(15)).await; Some((event(&ctx), ctx)) }); sse(initial.chain(updates).right_stream()) } // req: auth/001 req: auth/002 req: auth/004 // req: security/004 req: v1_release/003 async fn create_project( State(ctx): State, headers: HeaderMap, Form(form): Form>, ) -> Response { let started = Instant::now(); let request_id = ctx.next_request_id(); let bearer = headers .get("authorization") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); let origin = headers .get("origin") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); let name = form.get("name").map(String::as_str).unwrap_or_default(); let csrf = form.get("csrf").map(String::as_str).unwrap_or_default(); if let Some(client_fingerprint) = headers .get("x-hemx-fingerprint") .and_then(|value| value.to_str().ok()) { let current_fingerprint = ui::BUILD_FINGERPRINT.0.to_string(); if client_fingerprint != current_fingerprint { ctx.record_mutation(request_id.clone(), "mismatch", started.elapsed()); return Response::builder() .status(StatusCode::CONFLICT) .header("content-type", "application/problem+json") .header("x-hemx-recovery", "reload") .header("x-hemx-fingerprint", current_fingerprint) .header("x-request-id", request_id.to_string()) .body(Body::from("{\"code\":\"deployment-mismatch\"}")) .expect("deployment mismatch response"); } } let (outcome, mut response) = match ctx.create_project_authorized(name, bearer, csrf, origin) { Ok(_) => ( "succeeded", (StatusCode::SEE_OTHER, [("location", "/")], "").into_response(), ), Err( hemx_saas_example::AppError::MissingSession | hemx_saas_example::AppError::CsrfRejected | hemx_saas_example::AppError::OriginRejected, ) => ( "denied", problem(StatusCode::FORBIDDEN, "authorization-denied"), ), Err(hemx_saas_example::AppError::Validation(_)) => ( "invalid", problem(StatusCode::BAD_REQUEST, "invalid-project"), ), Err(_) => ( "failed", problem(StatusCode::SERVICE_UNAVAILABLE, "storage-unavailable"), ), }; ctx.record_mutation(request_id.clone(), outcome, started.elapsed()); response.headers_mut().insert( "x-request-id", HeaderValue::from_str(&request_id.to_string()).expect("generated request ID is a header"), ); response } fn problem(status: StatusCode, code: &'static str) -> Response { Response::builder() .status(status) .header("content-type", "application/problem+json") .body(Body::from(format!("{{\"code\":\"{code}\"}}"))) .expect("problem response") } // req: operations/007 async fn health_live() -> Response { json_response(StatusCode::OK, "{\"status\":\"live\"}".to_owned()) } // req: operations/007 async fn health_ready(State(ctx): State) -> Response { if ctx.ready() { json_response( StatusCode::OK, format!( "{{\"status\":\"ready\",\"fingerprint\":\"{}\"}}", ui::BUILD_FINGERPRINT.0 ), ) } else { json_response( StatusCode::SERVICE_UNAVAILABLE, "{\"status\":\"not-ready\",\"code\":\"storage-unavailable\"}".to_owned(), ) } } // req: operations/005 req: operations/007 async fn metrics(State(ctx): State) -> Response { json_response(StatusCode::OK, ctx.metrics_json()) } fn json_response(status: StatusCode, body: String) -> Response { Response::builder() .status(status) .header("content-type", "application/json") .body(Body::from(body)) .expect("JSON response") } async fn runtime() -> impl IntoResponse { runtime_js() } async fn css() -> Response { Response::builder() .header("content-type", "text/css; charset=utf-8") .body(Body::from(include_str!("../templates/app.css"))) .expect("css response") } async fn metrics_js() -> Response { Response::builder() .header("content-type", "text/javascript; charset=utf-8") .body(Body::from(include_str!("../templates/metrics.js"))) .expect("metrics js response") }