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:
parent
77eb1b907b
commit
e8a729a4c7
125
core/archipelago/src/api/handler/blob.rs
Normal file
125
core/archipelago/src/api/handler/blob.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }} <strong>mime:</strong> {{ blobResult.mime }} <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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user