276f40ee59
Use a real enhanceable Settings URL, remove response-simulated navigation, and replace the one-shot public SSE endpoint with an initial canonical event plus ongoing server-owned updates. Keep a bounded one-event probe for production-reference tests. req: examples/014 req: nav/001 req: nav/002 req: nav/004 req: push/003
229 lines
8.0 KiB
Rust
229 lines
8.0 KiB
Rust
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<dyn std::error::Error>> {
|
|
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<AppContext>) -> impl IntoResponse {
|
|
axum::response::Html(home_page(&ctx).into_string())
|
|
}
|
|
|
|
async fn settings(State(ctx): State<AppContext>) -> impl IntoResponse {
|
|
axum::response::Html(settings_page(&ctx).into_string())
|
|
}
|
|
|
|
async fn interact(
|
|
State(ctx): State<AppContext>,
|
|
request: InteractionRequest,
|
|
) -> Result<EffectResponse, impl IntoResponse> {
|
|
request.dispatch_async(registry(ctx)).await
|
|
}
|
|
|
|
async fn events(
|
|
Query(params): Query<BTreeMap<String, String>>,
|
|
State(ctx): State<AppContext>,
|
|
) -> 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<AppContext>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<BTreeMap<String, String>>,
|
|
) -> 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<AppContext>) -> 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<AppContext>) -> 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")
|
|
}
|