feat(blobs): HTTP upload+download routes and UI round-trip widget

Plumbs the BlobStore from blobs.rs into ApiHandler. The HMAC capability
key is derived from the node's Ed25519 signing key via a domain-separated
SHA-256 — rotating the identity rotates every outstanding cap (intentional
so a replaced node cannot honour old tokens).

New routes (added to nginx config in both server blocks):
- POST /api/blob — session-authenticated raw upload, returns
  {cid, size, mime, filename, self_test_url}. The self_test_url is a
  pre-signed cap pointing at the local node so the UI can verify the
  round-trip without needing a peer pubkey.
- GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey> — peer-facing,
  HMAC-verified in constant time, expiry-checked, then streams bytes.

Mesh.vue gets a minimal "Attachment test (blob store)" section: file
picker → upload → cid display → "Verify round-trip" and "Open in new
tab" buttons. This validates Phase 3a end-to-end before we layer the
ContentRef typed envelope variant on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 08:48:48 -04:00
parent 77eb1b907b
commit e8a729a4c7
4 changed files with 333 additions and 4 deletions

View File

@@ -0,0 +1,125 @@
//! HTTP handlers for the content-addressed blob store.
//!
//! - `POST /api/blob` — session-authenticated. Raw body is the blob;
//! headers set mime/filename. Returns `{cid, size, mime}`.
//! - `GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey>` — peer-facing.
//! Capability verified against the stored HMAC key; bytes streamed back.
use super::{build_response, ApiHandler};
use crate::blobs::BlobStore;
use anyhow::Result;
use hyper::{Body, HeaderMap, Response, StatusCode};
use std::sync::Arc;
impl ApiHandler {
pub(super) async fn handle_blob_upload(
store: &Arc<BlobStore>,
self_pubkey_hex: &str,
headers: &HeaderMap,
body: hyper::body::Bytes,
) -> Result<Response<Body>> {
let mime = headers
.get("x-blob-mime")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let filename = headers
.get("x-blob-filename")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = body.to_vec();
match store.put(&bytes, &mime, filename, None).await {
Ok(meta) => {
// Include a self-signed capability URL so the UI can round-trip
// the upload end-to-end without any peer. 7-day expiry.
let exp = (chrono::Utc::now().timestamp() as u64)
+ crate::blobs::DEFAULT_CAP_TTL_SECS;
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
let self_test_url = format!(
"/blob/{}?cap={}&exp={}&peer={}",
meta.cid, cap, exp, self_pubkey_hex
);
let resp = serde_json::json!({
"cid": meta.cid,
"size": meta.size,
"mime": meta.mime,
"filename": meta.filename,
"self_test_url": self_test_url,
});
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&resp).unwrap_or_default()),
))
}
Err(e) => Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from(format!("blob upload failed: {}", e)),
)),
}
}
pub(super) async fn handle_blob_download(
store: &Arc<BlobStore>,
path: &str,
query: &str,
) -> Result<Response<Body>> {
let cid = path.strip_prefix("/blob/").unwrap_or("");
if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_hexdigit()) || cid.len() != 64 {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from("invalid cid"),
));
}
// Parse query params: cap, exp, peer (all required)
let mut cap = None;
let mut exp: Option<u64> = None;
let mut peer = None;
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
match (it.next(), it.next()) {
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
(Some("exp"), Some(v)) => exp = v.parse().ok(),
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
_ => {}
}
}
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
return Ok(build_response(
StatusCode::UNAUTHORIZED,
"text/plain",
Body::from("missing cap/exp/peer"),
));
};
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
Body::from(format!("capability rejected: {}", e)),
));
}
let bytes = match store.get(cid).await {
Ok(b) => b,
Err(_) => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
Body::from("blob not found"),
))
}
};
let mime = store
.meta(cid)
.await
.map(|m| m.mime)
.unwrap_or_else(|_| "application/octet-stream".to_string());
Ok(build_response(StatusCode::OK, &mime, Body::from(bytes)))
}
}

View File

@@ -1,3 +1,4 @@
mod blob;
mod content;
mod dwn;
mod node_message;
@@ -7,12 +8,14 @@ mod remote_relay;
mod websocket;
use crate::api::rpc::RpcHandler;
use crate::blobs::BlobStore;
use crate::config::Config;
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use hyper::{Method, Request, Response, StatusCode};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::debug;
@@ -36,6 +39,10 @@ pub struct ApiHandler {
session_store: SessionStore,
/// Broadcast channel for relaying companion app input to remote browsers.
input_relay_tx: broadcast::Sender<String>,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
self_pubkey_hex: String,
}
impl ApiHandler {
@@ -56,6 +63,21 @@ impl ApiHandler {
);
let (input_relay_tx, _) = broadcast::channel(64);
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
// every outstanding capability token (intentional — prevents a
// replaced node from honouring old caps).
let identity_dir = config.data_dir.join("identity");
let identity =
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let mut hasher = Sha256::new();
hasher.update(identity.signing_key().to_bytes());
hasher.update(b"|archipelago-blob-cap-v1");
let mut cap_key = [0u8; 32];
cap_key.copy_from_slice(&hasher.finalize());
let blob_store = Arc::new(BlobStore::open(&config.data_dir, cap_key).await?);
let self_pubkey_hex = hex::encode(identity.signing_key().verifying_key().as_bytes());
Ok(Self {
config,
rpc_handler,
@@ -63,6 +85,8 @@ impl ApiHandler {
metrics_store,
session_store,
input_relay_tx,
blob_store,
self_pubkey_hex,
})
}
@@ -167,6 +191,7 @@ impl ApiHandler {
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
let (parts, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
@@ -205,6 +230,27 @@ impl ApiHandler {
Self::handle_node_message(body_bytes).await
}
// Blob upload — local/session use only. Session-authenticated so
// only the node owner can push attachments into the blob store.
(Method::POST, "/api/blob") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
Self::handle_blob_upload(
&self.blob_store,
&self.self_pubkey_hex,
&headers,
body_bytes,
)
.await
}
// Blob download — peer-facing. No session required; authenticated
// by HMAC capability token signed when the blob ref was shared.
(Method::GET, p) if p.starts_with("/blob/") => {
Self::handle_blob_download(&self.blob_store, p, &query_string).await
}
// Content preview — degraded previews for paid content (no auth, no payment)
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
Self::handle_content_preview(p, &self.config).await