//! HTTP API: router, OpenAPI document and shared response helpers. pub(crate) mod auth; mod debug; mod document; mod log; mod peers; mod stats; mod store; use axum::{ Json, Router, extract::State, http::{ HeaderValue, StatusCode, header::{CONTENT_TYPE, ETAG}, }, response::{IntoResponse, Response}, routing::{delete, get, put}, }; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use tracing::{error, warn}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth}; use crate::document::store::StoreError; use crate::state::AppState; /// Builds the complete HTTP router. Everything except Swagger is authenticated. pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> Router { let mut api: Router = Router::new() .route("/store", get(store::get_store_root)) .route( "/store/{*key}", get(store::get_store_entry) .put(store::put_store_entry) .delete(store::delete_store_entry), ) .route("/stream/{*key}", get(store::stream_store_entry)) .route("/peers", get(peers::list_peers)) .route("/peers/{peer}", delete(peers::delete_peer)) .route( "/admin/log", get(log::get_log_filter).put(log::put_log_filter), ) .route("/admin/stats", get(stats::get_stats)) .route("/admin/document", get(document::get_document)) .route("/admin/config", get(get_config)) .merge(crate::remake::router::()); if debug_api { warn!("Debug API enabled"); api = api .route( "/debug/firewall", get(debug::get_debug_firewall).put(debug::put_debug_firewall), ) .route("/debug/blacklist", put(debug::put_debug_blacklist)); } api.layer(axum::middleware::from_fn_with_state(auth_state, basic_auth)) .merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi())) .with_state(state) } /// `GET /admin/config`: the effective configuration with each value's source; /// imported values are left out. async fn get_config(State(state): State) -> Json { Json((*state.config).clone()) } // --- shared helpers ---------------------------------------------------------- pub(crate) fn store_status(error: StoreError, context: &str) -> StatusCode { match error { StoreError::Conflict => StatusCode::CONFLICT, StoreError::NotFound => StatusCode::NOT_FOUND, other => { error!(error = %format!("{other:#}"), "{context}"); StatusCode::INTERNAL_SERVER_ERROR } } } fn etag_for(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); let hex = digest .iter() .map(|byte| format!("{byte:02x}")) .collect::(); format!("\"{hex}\"") } fn parse_key_path(key: &str) -> Result, StatusCode> { let segments: Vec = key.split('/').map(str::to_owned).collect(); if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) { return Err(StatusCode::BAD_REQUEST); } Ok(segments) } /// A path for a write. Protocol data (`_continuum`: remake records, members, forget /// records, epoch metadata) is what every node acts on, so a write there acts on the /// whole swarm: only users listed in `--protocol-writers` may, and each such write is /// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never /// writable: the protocol reads it at the snapshot change, so an edit only misleads /// whoever reads it later. `403` otherwise. fn parse_write_path( state: &AppState, user: &AuthenticatedUser, key: &str, method: &str, ) -> Result, StatusCode> { let path = parse_key_path(key)?; if path.first().map(String::as_str) != Some(crate::remake::snapshot::META_ROOT) { return Ok(path); } let epochs = path.len() < 2 || path[1] == crate::remake::snapshot::EPOCHS; if epochs || !state.protocol_writers.contains(&user.0) { warn!(user = %user.0, method, path = %key, "Refused a write to protocol data"); return Err(StatusCode::FORBIDDEN); } warn!(user = %user.0, method, path = %key, "Protocol data written through the store API; this acts on the whole swarm"); Ok(path) } fn response_for_value( value: &JsonValue, accept: Option<&HeaderValue>, ) -> Result { let wants_text = accept .and_then(|value| value.to_str().ok()) .is_some_and(|value| { value .split(',') .any(|item| item.trim().starts_with("text/plain")) }); let (content_type, body) = if wants_text { let body = match value { JsonValue::String(value) => value.clone(), other => serde_json::to_string(other).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, }; ("text/plain; charset=utf-8", body) } else { ( "application/json", serde_json::to_string(value).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, ) }; let etag = etag_for(body.as_bytes()); let mut response = body.into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); response.headers_mut().insert( ETAG, HeaderValue::from_str(&etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, ); Ok(response) } // --- openapi spec ------------------------------------------------------------ // TODO spec is mostly out of sync #[derive(OpenApi)] #[openapi( paths( store::get_store_root, store::get_store_entry, store::put_store_entry, store::delete_store_entry, peers::list_peers, peers::delete_peer, ), components( schemas( peers::PeerStatus, peers::PeerListResponse, peers::PeerLivenessStatus, peers::EpochInfo, crate::transport::traffic::PeerTraffic, crate::transport::traffic::ByKind, crate::transport::traffic::Counter, crate::state::Incidents, peers::ThroughputInfo, ) ), tags( (name = "Store", description = "Key-value store operations"), (name = "Peers", description = "Peer discovery and retirement") ), info( title = "Automerge Replication API", version = "1.0.0", description = "HTTP API for a replicated Automerge key-value store" ) )] struct ApiDoc;