diff options
| author | tv <tv@krebsco.de> | 2026-10-31 03:57:45 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-31 03:57:45 +0100 |
| commit | 16957af7eb771d492af9c4fd8e50b765d3e597a1 (patch) | |
| tree | 156facab85f518aee89cbc77a040740c3cc82885 /src | |
| parent | e197b04b0eb498298fbfe14d8f034dc84dc89e1e (diff) | |
move store helpers to store module
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 97 | ||||
| -rw-r--r-- | src/api/store.rs | 96 |
2 files changed, 97 insertions, 96 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 58967f3..a777724 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ -//! HTTP API: router, the API description and shared response helpers. +//! HTTP API: router, the API description and shared response helpers (the store's +//! own are in `store`). pub(crate) mod auth; #[cfg(feature = "dashboard")] @@ -17,19 +18,17 @@ use axum::{ extract::{Request, State}, http::{ HeaderValue, StatusCode, - header::{CONTENT_LENGTH, CONTENT_TYPE, ETAG}, + header::{CONTENT_LENGTH, CONTENT_TYPE}, }, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get}, }; use serde_json::{Value as JsonValue, json}; -use sha2::{Digest, Sha256}; use tracing::{error, warn}; use utoipa_swagger_ui::SwaggerUi; -use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth}; -use crate::document::meta; +use crate::api::auth::{AuthState, basic_auth}; use crate::document::store::StoreError; use crate::state::AppState; @@ -196,91 +195,3 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> StatusCode { } } } - -fn etag_for(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let hex = digest - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::<String>(); - - format!("\"{hex}\"") -} - -fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> { - let segments: Vec<String> = 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<Vec<String>, StatusCode> { - let path = parse_key_path(key)?; - if !meta::is_protocol(&path) { - return Ok(path); - } - // `_continuum` itself, epoch metadata and the swarm ID: nobody. - let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM; - if fixed || !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<Response, StatusCode> { - 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) -} diff --git a/src/api/store.rs b/src/api/store.rs index 0b0a40f..bc43824 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -9,17 +9,19 @@ use axum::{ extract::{Path, Query, RawQuery, State}, http::{ HeaderMap, HeaderValue, StatusCode, - header::{ACCEPT, CONTENT_TYPE, LOCATION}, + header::{ACCEPT, CONTENT_TYPE, ETAG, LOCATION}, }, response::{IntoResponse, Response}, }; use serde::Deserialize; use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; use tokio::sync::broadcast; -use tracing::{debug, error, instrument}; +use tracing::{debug, error, instrument, warn}; use super::auth::AuthenticatedUser; -use super::{parse_key_path, parse_write_path, response_for_value, store_status}; +use super::store_status; +use crate::document::meta; use crate::document::store::{self, StoreError, paths_related}; use crate::state::{AppState, ReadSource}; @@ -220,6 +222,94 @@ pub(crate) async fn put_store_entry( response_for_value(&value, headers.get(ACCEPT)) } +fn etag_for(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let hex = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::<String>(); + + format!("\"{hex}\"") +} + +fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> { + let segments: Vec<String> = 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<Vec<String>, StatusCode> { + let path = parse_key_path(key)?; + if !meta::is_protocol(&path) { + return Ok(path); + } + // `_continuum` itself, epoch metadata and the swarm ID: nobody. + let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM; + if fixed || !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<Response, StatusCode> { + 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) +} + /// A write's body as a value: JSON (the default), or text as a string. fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<JsonValue, StatusCode> { let content_type = headers |
