aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/store.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-31 03:57:45 +0100
committertv <tv@krebsco.de>2026-10-31 03:57:45 +0100
commit98c1a9eefdc1e7d5b5359a437248418c4a6c5d3f (patch)
treedd220cfe9ee8817419167032f86238968f410664 /src/api/store.rs
parent0f010a383ae256d7e794c85e4332ee45841625aa (diff)
move store helpers to store module
Diffstat (limited to 'src/api/store.rs')
-rw-r--r--src/api/store.rs96
1 files changed, 93 insertions, 3 deletions
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