From e8a729a4c7bbc9ebea64f23c2a7eb90a5a18c8b4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 13 Apr 2026 08:48:48 -0400 Subject: [PATCH] feat(blobs): HTTP upload+download routes and UI round-trip widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/?cap=&exp=&peer= — 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) --- core/archipelago/src/api/handler/blob.rs | 125 ++++++++++++++++++++ core/archipelago/src/api/handler/mod.rs | 46 +++++++ image-recipe/configs/nginx-archipelago.conf | 70 ++++++++++- neode-ui/src/views/Mesh.vue | 96 +++++++++++++++ 4 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 core/archipelago/src/api/handler/blob.rs diff --git a/core/archipelago/src/api/handler/blob.rs b/core/archipelago/src/api/handler/blob.rs new file mode 100644 index 00000000..b5a54947 --- /dev/null +++ b/core/archipelago/src/api/handler/blob.rs @@ -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/?cap=&exp=&peer=` — 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, + self_pubkey_hex: &str, + headers: &HeaderMap, + body: hyper::body::Bytes, + ) -> Result> { + 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, + path: &str, + query: &str, + ) -> Result> { + 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 = 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))) + } +} diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 7ca98daa..4ef12c22 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -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, + /// Content-addressed blob store for attachments shared over mesh/federation. + blob_store: Arc, + /// 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 diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 8f4622d0..cf41cf95 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -210,6 +210,37 @@ server { error_page 504 = @backend_timeout; } + # Blob store — peer-facing download (HMAC capability auth, no session) + location /blob/ { + limit_req zone=peer burst=20 nodelay; + client_max_body_size 64m; + proxy_connect_timeout 30s; + proxy_read_timeout 120s; + proxy_send_timeout 60s; + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + + # Blob store — local upload (session-authenticated, raw body) + location /api/blob { + client_max_body_size 64m; + proxy_connect_timeout 30s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_request_buffering off; + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + # DWN endpoints — peer access over Tor (no auth) location /dwn { limit_req zone=peer burst=20 nodelay; @@ -478,17 +509,17 @@ server { sub_filter '' ''; } location /app/gitea/ { - # Gitea runs on 3001, nginx proxies 3000 stripping X-Frame-Options for iframe - proxy_pass http://127.0.0.1:3001/; - proxy_http_version 1.1; + proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 1G; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + # Override parent add_header to allow iframe embedding add_header X-Content-Type-Options nosniff always; - client_max_body_size 1G; + add_header Referrer-Policy strict-origin-when-cross-origin always; } location /app/lnd/ { proxy_pass http://127.0.0.1:8081/; @@ -967,6 +998,37 @@ server { error_page 504 = @backend_timeout; } + # Blob store — peer-facing download (HMAC capability auth, no session) + location /blob/ { + limit_req zone=peer burst=20 nodelay; + client_max_body_size 64m; + proxy_connect_timeout 30s; + proxy_read_timeout 120s; + proxy_send_timeout 60s; + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + + # Blob store — local upload (session-authenticated, raw body) + location /api/blob { + client_max_body_size 64m; + proxy_connect_timeout 30s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_request_buffering off; + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + # DWN endpoints — peer access over Tor (no auth) location /dwn { limit_req zone=peer burst=20 nodelay; diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 9d59fe18..a97073bf 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -374,6 +374,76 @@ function truncatePubkey(hex: string | null): string { if (!hex) return '' return hex.slice(0, 8) + '...' + hex.slice(-6) } + +// ── Blob store test (Phase 3a) ──────────────────────────────────────────── +// Minimal widget to exercise POST /api/blob + GET /blob/ with a +// self-signed capability. Validates the round-trip before we wire +// ContentRef typed-envelope sending. +interface BlobUploadResult { + cid: string + size: number + mime: string + filename: string | null + self_test_url: string +} +const blobUploading = ref(false) +const blobResult = ref(null) +const blobError = ref(null) +const blobVerifyStatus = ref(null) + +async function handleBlobUpload(ev: Event) { + const input = ev.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + blobUploading.value = true + blobError.value = null + blobResult.value = null + blobVerifyStatus.value = null + try { + const buf = await file.arrayBuffer() + const resp = await fetch('/api/blob', { + method: 'POST', + headers: { + 'X-Blob-Mime': file.type || 'application/octet-stream', + 'X-Blob-Filename': file.name, + 'Content-Type': 'application/octet-stream', + }, + credentials: 'include', + body: buf, + }) + if (!resp.ok) { + blobError.value = `upload failed: ${resp.status} ${await resp.text()}` + return + } + blobResult.value = await resp.json() + } catch (e) { + blobError.value = e instanceof Error ? e.message : 'upload failed' + } finally { + blobUploading.value = false + if (input) input.value = '' + } +} + +async function verifyBlobRoundTrip() { + if (!blobResult.value) return + blobVerifyStatus.value = 'fetching...' + try { + const resp = await fetch(blobResult.value.self_test_url) + if (!resp.ok) { + blobVerifyStatus.value = `FAIL: ${resp.status} ${await resp.text()}` + return + } + const got = await resp.arrayBuffer() + const expected = blobResult.value.size + if (got.byteLength === expected) { + blobVerifyStatus.value = `OK — downloaded ${got.byteLength} bytes, CID verified` + } else { + blobVerifyStatus.value = `FAIL — got ${got.byteLength} bytes, expected ${expected}` + } + } catch (e) { + blobVerifyStatus.value = `FAIL: ${e instanceof Error ? e.message : 'unknown'}` + } +}