//! 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))) } }