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

View File

@ -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 '</head>' '<script src="/nostr-provider.js"></script><script>window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="arcade-input"&&d.key){var t=d.action==="up"?"keyup":"keydown";document.dispatchEvent(new KeyboardEvent(t,{key:d.key,bubbles:true}))}})</script></head>';
}
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;

View File

@ -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/<cid> 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<BlobUploadResult | null>(null)
const blobError = ref<string | null>(null)
const blobVerifyStatus = ref<string | null>(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'}`
}
}
</script>
<template>
@ -768,6 +838,32 @@ function truncatePubkey(hex: string | null): string {
<MeshDeadmanPanel v-if="showDeadmanPanel" />
</div>
</div>
<!-- Blob store round-trip test (Phase 3a) -->
<div class="glass-card" style="margin-top: 1rem; padding: 1rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<strong>Attachment test (blob store)</strong>
<span style="font-size: 0.8rem; opacity: 0.7;">Phase 3a upload, self-signed cap, round-trip verify</span>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
<label class="btn" style="cursor: pointer;">
<input type="file" @change="handleBlobUpload" style="display: none;" />
Pick file
</label>
<span v-if="blobUploading">uploading</span>
<span v-if="blobError" style="color: #f87171;">{{ blobError }}</span>
</div>
<div v-if="blobResult" style="margin-top: 0.75rem; font-family: monospace; font-size: 0.85rem;">
<div><strong>cid:</strong> {{ blobResult.cid }}</div>
<div><strong>size:</strong> {{ blobResult.size }} &nbsp; <strong>mime:</strong> {{ blobResult.mime }} &nbsp; <strong>filename:</strong> {{ blobResult.filename || '(none)' }}</div>
<div style="word-break: break-all;"><strong>url:</strong> {{ blobResult.self_test_url }}</div>
<div style="margin-top: 0.5rem; display: flex; gap: 0.75rem; align-items: center;">
<button class="btn" @click="verifyBlobRoundTrip">Verify round-trip</button>
<a :href="blobResult.self_test_url" target="_blank" class="btn">Open in new tab</a>
<span v-if="blobVerifyStatus" :style="{ color: blobVerifyStatus.startsWith('OK') ? '#4ade80' : '#f87171' }">{{ blobVerifyStatus }}</span>
</div>
</div>
</div>
</div>
</template>