//! HTTP API: router, the API description and shared response helpers (the store's //! own are in `store`). pub(crate) mod auth; mod changes; mod config; #[cfg(feature = "dashboard")] mod dashboard; mod debug; mod document; mod health; pub(crate) mod listen; mod log; mod peers; mod stats; mod store; use axum::{ Json, Router, body::Body, extract::Request, http::{ HeaderValue, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE}, }, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get}, }; use serde_json::{Value as JsonValue, json}; use tracing::{error, warn}; use utoipa_swagger_ui::SwaggerUi; use crate::api::auth::{AuthState, basic_auth}; use crate::document::store::StoreError; use crate::state::AppState; /// Builds the complete HTTP router. Everything except the API description (`/swagger-ui`, /// `/openapi.yaml`), `/health` and `/ready` 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) .post(store::post_store_entry) .delete(store::delete_store_entry), ) .route("/stream", get(store::stream_store_root)) .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/changes", get(changes::get_changes)) .route("/admin/changes/{*key}", get(changes::get_changes_at)) .route("/admin/config", get(config::get_config)) .merge(crate::remake::router::()); #[cfg(feature = "dashboard")] { api = api.merge(dashboard::router(state.clone())); } if debug_api { warn!("Debug API enabled"); api = api.route( "/debug/firewall", get(debug::get_debug_firewall).put(debug::put_debug_firewall), ); } api.layer(middleware::from_fn_with_state(auth_state, basic_auth)) // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. .merge( // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that // serves the API under a path of its own too. SwaggerUi::new("/swagger-ui") .config(utoipa_swagger_ui::Config::from("../openapi.yaml")), ) .route("/openapi.yaml", get(openapi_spec)) .route("/health", get(health::node_health)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. .layer(middleware::from_fn(json_errors)) .layer(middleware::from_fn(relative_redirects)) .with_state(state) } /// The HTTP router of a node that only relays (`--relay-only`): its figures and the log /// filter (authenticated), and `/health`. #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) fn relay_only_router( transport: std::sync::Arc, auth_state: AuthState, ) -> Router { let relay = stats::RelayOnly { transport, started: tokio::time::Instant::now(), }; Router::new() .route( "/admin/log", get(log::get_log_filter).put(log::put_log_filter), ) .route("/admin/stats", get(stats::get_relay_only_stats)) .layer(middleware::from_fn_with_state(auth_state, basic_auth)) .route("/health", get(health::health)) .layer(middleware::from_fn(json_errors)) .layer(middleware::from_fn(relative_redirects)) .with_state(relay) } /// On a response that streams: a reverse proxy passes it on as it comes, not once it /// has buffered enough (nginx's `X-Accel-Buffering`). pub(crate) const UNBUFFERED: (axum::http::HeaderName, &str) = ( axum::http::HeaderName::from_static("x-accel-buffering"), "no", ); /// An error answer: `{"error": message}` with `details`' fields added (`docs/openapi.yaml`, /// `Error`). pub(crate) fn error_response( status: StatusCode, message: impl Into, details: Option, ) -> Response { let mut body = json!({ "error": message.into() }); if let (Some(JsonValue::Object(extra)), Some(object)) = (details, body.as_object_mut()) { object.extend(extra); } (status, Json(body)).into_response() } /// Redirects to a path of this API (`Location: /store/…`) made relative to the request's /// (`3`, `swagger-ui/`): they then lead to the same place behind a reverse proxy /// that serves the API under a path of its own (`/continuum/store/…`). async fn relative_redirects(request: Request, next: Next) -> Response { let from = request.uri().path().to_owned(); let mut response = next.run(request).await; let relative = response .headers() .get(axum::http::header::LOCATION) .and_then(|location| location.to_str().ok()) .filter(|location| location.starts_with('/') && !location.starts_with("//")) .map(|location| relative_to(&from, location)); if let Some(location) = relative.and_then(|location| HeaderValue::from_str(&location).ok()) { response .headers_mut() .insert(axum::http::header::LOCATION, location); } response } /// `to` (a path, with its query if any) as a reference relative to the page at `from` /// (a path). fn relative_to(from: &str, to: &str) -> String { let (path, query) = match to.split_once('?') { Some((path, query)) => (path, Some(query)), None => (to, None), }; // The page's directory, and the target's segments (the last its name). let from: Vec<&str> = from.split('/').collect(); let directory = &from[..from.len() - 1]; let target: Vec<&str> = path.split('/').collect(); let common = directory .iter() .zip(&target[..target.len() - 1]) .take_while(|(a, b)| a == b) .count(); let mut relative = "../".repeat(directory.len() - common); relative.push_str(&target[common..].join("/")); // Not empty, and not taken for a scheme (`a:b`). let first = relative.split('/').next().unwrap_or(""); if relative.is_empty() || first.contains(':') { relative.insert_str(0, "./"); } if let Some(query) = query { relative.push('?'); relative.push_str(query); } relative } /// Gives every error answer the same body (`error_response`). Handlers that fail with /// a bare status or a text message, and axum's own rejections (a malformed query or /// body), get it here: the text becomes the message, or the status's reason if there /// is none. Errors that are JSON already pass unchanged, and so do their headers /// (`WWW-Authenticate` on a 401). async fn json_errors(request: Request, next: Next) -> Response { let response = next.run(request).await; let status = response.status(); if !(status.is_client_error() || status.is_server_error()) { return response; } let is_json = response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.starts_with("application/json")); if is_json { return response; } let (mut parts, body) = response.into_parts(); // Error texts are short; a larger one is cut to its reason. let text = axum::body::to_bytes(body, 64 * 1024) .await .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_owned()) .unwrap_or_default(); let message = if text.is_empty() { status.canonical_reason().unwrap_or("error").to_lowercase() } else { text }; let body = json!({ "error": message }).to_string(); parts.headers.remove(CONTENT_LENGTH); parts .headers .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); Response::from_parts(parts, Body::from(body)) } /// `GET /openapi.yaml`: the API description (`docs/openapi.yaml`, built in). async fn openapi_spec() -> impl IntoResponse { ( [(CONTENT_TYPE, HeaderValue::from_static("application/yaml"))], include_str!("../../docs/openapi.yaml"), ) } // --- shared helpers ---------------------------------------------------------- /// An error answer: its status, and why where it is known (`error_response`); a bare /// status otherwise (`json_errors` gives it the body). pub(crate) struct ApiError(StatusCode, Option); impl ApiError { pub(crate) fn status(&self) -> StatusCode { self.0 } } impl From for ApiError { fn from(status: StatusCode) -> Self { Self(status, None) } } impl IntoResponse for ApiError { fn into_response(self) -> Response { match self.1 { Some(message) => error_response(self.0, message, None), None => self.0.into_response(), } } } /// A store error as an answer, saying why (`StoreError`'s text) where it is the /// client's; others are logged with `context`, and are a bare `500`. /// A read's (`store_status`). impl From for ApiError { fn from(error: StoreError) -> Self { store_status(error, "Failed to read store path") } } pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { let status = match error { StoreError::Conflict | StoreError::NotAList | StoreError::PastTheEnd { .. } | StoreError::NotACounter | StoreError::Retyping(_) | StoreError::NotCastable { .. } => StatusCode::CONFLICT, StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND, StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE, other => { error!(error = %format!("{other:#}"), "{context}"); return StatusCode::INTERNAL_SERVER_ERROR.into(); } }; ApiError(status, Some(error.to_string())) } /// A media type without its parameters: `text/plain; charset=utf-8` is `text/plain`. /// For `Content-Type`, and each item of an `Accept` (split at `,`). pub(crate) fn media_type(value: &str) -> &str { value.split(';').next().unwrap_or("").trim() } #[cfg(test)] mod redirect_tests { use super::relative_to; #[test] fn redirects_are_relative_to_the_request() { assert_eq!(relative_to("/store/queue/-1", "/store/queue/3"), "3"); assert_eq!( relative_to("/store/a/-1/b", "/store/a/0/b?x=1"), "../0/b?x=1" ); assert_eq!(relative_to("/store/-1", "/store/2"), "2"); assert_eq!(relative_to("/swagger-ui", "/swagger-ui/"), "swagger-ui/"); assert_eq!(relative_to("/store/a/b", "/store/a/"), "./"); assert_eq!(relative_to("/store/x", "/store/a:b"), "./a:b"); } }