use super::RpcHandler; use crate::content_server::{self, AccessControl, Availability, ContentItem}; use crate::network::dwn_store::DwnStore; use crate::wallet::ecash; use anyhow::{Context, Result}; use tracing::debug; /// Validate a v3 Tor onion address. /// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion". fn is_valid_v3_onion(addr: &str) -> bool { if addr.len() != 62 || !addr.ends_with(".onion") { return false; } let prefix = &addr[..56]; prefix .chars() .all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)) } const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1"; fn parse_content_access(params: &serde_json::Value) -> Result { let access_type = match params.get("access") { None => "free", Some(value) => value.as_str().context("Invalid access type")?, }; match access_type { "free" => Ok(AccessControl::Free), "peers_only" => Ok(AccessControl::PeersOnly), "paid" => { let price = params .get("price_sats") .and_then(|v| v.as_u64()) .unwrap_or(0); if price == 0 { return Err(anyhow::anyhow!("Paid content requires price_sats > 0")); } // Optional list of payment methods the sharer accepts. // Absent/empty = all methods (backward compatible). const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"]; let accepted = match params.get("accepted_methods") { None => Vec::new(), Some(value) => value .as_array() .context("Invalid accepted methods")? .iter() .map(|method| { let method = method.as_str().context("Invalid payment method")?; anyhow::ensure!( KNOWN_METHODS.contains(&method), "Unsupported payment method" ); Ok(method.to_owned()) }) .collect::>>()?, }; Ok(AccessControl::Paid { price_sats: price, accepted, }) } _ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)), } } fn parse_content_availability(params: &serde_json::Value, default: &str) -> Result { let availability_type = match params.get("availability") { None => default, Some(value) => value.as_str().context("Invalid availability")?, }; match availability_type { "nobody" => Ok(Availability::Nobody), "all_peers" => Ok(Availability::AllPeers), "specific" => { let peers = params .get("peers") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect::>() }) .unwrap_or_default(); Ok(Availability::Specific { peers }) } _ => { return Err(anyhow::anyhow!( "Invalid availability: {}", availability_type )) } } } /// Best-effort reclaim of an ecash payment token that was minted but the sale /// didn't complete (seller unreachable or couldn't redeem it), so the buyer /// doesn't lose the value. For Fedimint the spender can reissue its own /// un-redeemed notes; for Cashu the proofs are received back. Report the actual /// recovered amount, or explicitly say when a refund could not be confirmed. async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String { let res = match backend { "fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token) .await .map(|(sats, _fed)| sats), _ => ecash::receive_token(data_dir, token).await, }; match res { Ok(sats) => { tracing::info!("paid download: reclaimed {sats} sats after failed sale"); format!("Refunded {sats} sats to your wallet.") } Err(e) => { tracing::warn!("paid download: refund not confirmed: {e}"); "Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string() } } } // Inline RPC responses are for previews/small legacy downloads. Films use the // Range-capable HTTP path; never let a peer force whole-film base64 allocation. async fn bounded_content_bytes(mut response: reqwest::Response, limit: usize) -> Result> { anyhow::ensure!( response .content_length() .is_none_or(|size| size <= limit as u64), "Content exceeds the inline limit; open it through the streaming viewer" ); let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await? { anyhow::ensure!( chunk.len() <= limit.saturating_sub(bytes.len()), "Content exceeds the inline limit; use streaming" ); bytes.extend_from_slice(&chunk); } Ok(bytes) } async fn bounded_seller_error(mut response: reqwest::Response) -> String { let mut bytes = Vec::new(); let _ = tokio::time::timeout(std::time::Duration::from_secs(5), async { while bytes.len() < 4096 { match response.chunk().await { Ok(Some(chunk)) => { bytes.extend_from_slice(&chunk[..chunk.len().min(4096 - bytes.len())]) } _ => break, } } }) .await; String::from_utf8_lossy(&bytes).into_owned() } /// Only pass through the peer's bounded, printable explanation; refund status /// is always determined locally and must never come from the peer's wording. fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String { let reason = serde_json::from_str::(body) .ok() .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned)); match reason { Some(reason) if !reason.trim().is_empty() => { let clean: String = reason .chars() .filter(|c| !c.is_control()) .take(240) .collect(); format!("Seller response ({status}): {clean}") } _ => format!("Peer returned an error ({status})."), } } /// Keep first purchases and cached repeats compatible with both existing clients. fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value { use base64::Engine; let data = base64::engine::general_purpose::STANDARD.encode(bytes); serde_json::json!({ "data": data, "data_base64": data, "size": bytes.len(), "size_bytes": bytes.len(), "mime_type": mime, "paid_sats": paid_sats, "owned": true, }) } // Resolve known purchases BEFORE any mint/spend. Missing bytes or an unreadable // index require recovery; neither is authorization to charge the buyer again. async fn existing_paid_content( data_dir: &std::path::Path, onion: &str, content_id: &str, filename: Option<&str>, cache_only: bool, ) -> Result> { let owned = crate::content_owned::list_owned_checked(data_dir) .await .context("Could not verify previous purchases; no new payment was sent")?; let Some(item) = owned.iter().find(|o| { o.onion == onion && (o.content_id == content_id || filename.is_some_and(|f| { !f.is_empty() && o.filename.trim_start_matches('/') == f.trim_start_matches('/') })) }) else { return Ok(None); }; let mut response = cached_purchase_response(data_dir, &item.onion, &item.content_id, cache_only, 0).await .context("This purchase is recorded, but its cached file is unavailable. No new payment was sent. Recover delivery without paying again.")?; response["already_owned"] = serde_json::json!(true); response["filename"] = serde_json::json!(item.filename); Ok(Some(response)) } async fn cached_purchase_response( data_dir: &std::path::Path, onion: &str, content_id: &str, cache_only: bool, paid_sats: u64, ) -> Result { use tokio::io::AsyncReadExt; let (mime, file) = crate::content_owned::open_owned(data_dir, onion, content_id) .await? .context("Purchased content is not cached")?; let size = file.metadata().await?.len(); if cache_only || size > 16 * 1024 * 1024 { return Ok( serde_json::json!({"owned":true,"mime_type":mime,"size":size,"size_bytes":size,"paid_sats":paid_sats,"owned_content_id":content_id}), ); } let mut bytes = Vec::with_capacity(size as usize); file.take(16 * 1024 * 1024 + 1) .read_to_end(&mut bytes) .await?; anyhow::ensure!( bytes.len() as u64 == size, "Purchased file changed during reading" ); let mut result = paid_content_response(&bytes, &mime, paid_sats); result["owned_content_id"] = serde_json::json!(content_id); Ok(result) } async fn cache_peer_response( data_dir: &std::path::Path, onion: &str, content_id: &str, filename: &str, mime: &str, paid_sats: u64, backend: &str, response: reqwest::Response, ) -> Result { let expected = response.content_length(); crate::content_owned::record_purchase_stream( data_dir, crate::content_owned::OwnedItem { onion: onion.into(), content_id: content_id.into(), filename: filename.into(), mime_type: mime.into(), size_bytes: expected.unwrap_or(0), paid_sats, ecash_backend: backend.into(), purchased_at: chrono::Utc::now().to_rfc3339(), download_complete: false, }, response.bytes_stream(), expected, ) .await } async fn file_cached_purchase_in_files( data_dir: &std::path::Path, item: &crate::content_owned::OwnedItem, ) -> Result { let folder = if item.mime_type.starts_with("image/") || item.mime_type.starts_with("video/") { "Photos" } else if item.mime_type.starts_with("audio/") { "Music" } else { "Documents" }; let root = data_dir.join("filebrowser"); anyhow::ensure!( tokio::fs::metadata(&root).await?.is_dir(), "Files storage is unavailable" ); let name = std::path::Path::new(&item.filename) .file_name() .and_then(|n| n.to_str()) .filter(|n| !n.is_empty()) .unwrap_or("download"); let (_, file) = crate::content_owned::open_owned(data_dir, &item.onion, &item.content_id) .await? .context("Purchase unavailable")?; let path = crate::container::filebrowser::save_new_file_from(&root.join(folder), name, file).await?; Ok(format!( "{folder}/{}", path.file_name() .and_then(|name| name.to_str()) .context("Invalid Files name")? )) } impl RpcHandler { /// List content I'm sharing. pub(super) async fn handle_content_list_mine(&self) -> Result { let catalog = content_server::load_catalog(&self.config.data_dir).await?; Ok(serde_json::json!({ "items": catalog.items })) } /// Explicit atomic publication endpoint. Older servers reject this method /// instead of accepting an add request while ignoring its pricing fields. pub(super) async fn handle_content_publish( &self, params: Option, ) -> Result { let policy = params.as_ref().context("Missing params")?; anyhow::ensure!( policy.get("access").is_some() && policy.get("availability").is_some(), "A complete sharing policy is required" ); self.handle_content_add(params).await } /// Add content to my catalog. pub(super) async fn handle_content_add( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let filename = params .get("filename") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing filename"))?; // Validate filename: prevent path traversal and null bytes // Allow forward slashes for subdirectories (e.g., "Music/song.mp3") if filename.contains("..") || filename.contains('\0') || filename.contains('\\') { anyhow::bail!("Invalid filename: path traversal not allowed"); } // Reject paths starting with / (absolute) or . (hidden) if filename.starts_with('/') || filename.starts_with('.') { anyhow::bail!("Invalid filename: absolute paths and hidden files not allowed"); } // Reject any path segment starting with . (hidden dirs) if filename .split('/') .any(|seg| seg.starts_with('.') || seg.is_empty()) { anyhow::bail!("Invalid filename: hidden files/dirs or empty segments not allowed"); } if filename.is_empty() || filename.len() > 512 { anyhow::bail!("Invalid filename: must be 1-512 characters"); } let mime_type = params .get("mime_type") .and_then(|v| v.as_str()) .unwrap_or("application/octet-stream"); let description = params .get("description") .and_then(|v| v.as_str()) .unwrap_or(""); let mut item = ContentItem { id: uuid::Uuid::new_v4().to_string(), filename: filename.to_string(), mime_type: mime_type.to_string(), size_bytes: 0, description: description.to_string(), access: parse_content_access(¶ms)?, // Legacy multi-call clients must configure visibility explicitly; // an interrupted setup must not publish a paid file as free. availability: parse_content_availability(¶ms, "nobody")?, added_at: chrono::Utc::now().to_rfc3339(), }; // Resolve actual file size from disk let file_path = content_server::content_file_path(&self.config.data_dir, &item); if let Ok(metadata) = tokio::fs::metadata(&file_path).await { item.size_bytes = metadata.len(); } let catalog = content_server::add_item(&self.config.data_dir, item.clone()).await?; let item = catalog .items .into_iter() .find(|saved| saved.filename == item.filename) .context("Saved content item is unavailable")?; // Export only explicitly public metadata. A staged or peer-restricted // share must not leak its filename through the public DWN catalog. if matches!(&item.availability, Availability::AllPeers) && !matches!(&item.access, AccessControl::PeersOnly) { // Also store as DWN message for interoperable file catalog if let Ok(store) = DwnStore::new(&self.config.data_dir).await { let did = crate::identity::did_key_from_pubkey_hex( &self.state_manager.get_snapshot().await.0.server_info.pubkey, ) .unwrap_or_default(); let dwn_data = serde_json::json!({ "id": item.id, "title": item.filename, "description": item.description, "content_type": item.mime_type, "size_bytes": item.size_bytes, "access": format!("{:?}", item.access).to_lowercase(), "created_at": item.added_at, }); if let Err(e) = store .write_message( &did, Some(FILE_CATALOG_PROTOCOL), Some("https://archipelago.dev/schemas/file-entry/v1"), Some("application/json"), Some(dwn_data), ) .await { debug!("DWN file catalog write (non-fatal): {}", e); } } } Ok(serde_json::json!({ "item": item })) } /// Remove content from my catalog. pub(super) async fn handle_content_remove( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let id = params .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing id"))?; content_server::remove_item(&self.config.data_dir, id).await?; Ok(serde_json::json!({ "removed": true })) } /// Save a complete sharing policy without an intermediate public/free state. pub(super) async fn handle_content_configure( &self, params: Option, ) -> Result { let params = params.context("Missing params")?; let id = params .get("id") .and_then(|v| v.as_str()) .context("Missing id")?; anyhow::ensure!( params.get("access").is_some() && params.get("availability").is_some(), "A complete sharing policy is required" ); let access = parse_content_access(¶ms)?; let availability = parse_content_availability(¶ms, "nobody")?; content_server::configure_item(&self.config.data_dir, id, access, availability).await?; Ok(serde_json::json!({"updated":true})) } /// Set pricing for a content item. pub(super) async fn handle_content_set_pricing( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let id = params .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing id"))?; let access = parse_content_access(¶ms)?; content_server::set_access(&self.config.data_dir, id, access).await?; Ok(serde_json::json!({ "updated": true })) } /// Set availability for a content item. pub(super) async fn handle_content_set_availability( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let id = params .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing id"))?; let availability = parse_content_availability(¶ms, "all_peers")?; content_server::set_availability(&self.config.data_dir, id, availability).await?; Ok(serde_json::json!({ "updated": true })) } /// Download content from a peer. Prefers FIPS when the peer is known /// in our federation and has advertised a FIPS npub; falls back to /// Tor on any network failure. pub(super) async fn handle_content_download_peer( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; // Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let path = format!("/content/{}", content_id); let (response, transport) = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .require_fips() .header("X-Federation-DID", local_did) .timeout(std::time::Duration::from_secs(120)) .fips_timeout(std::time::Duration::from_secs(8)) .send_content_get(&self.config.data_dir) .await .context("Failed to connect to peer")?; // Record which transport actually reached the peer (B14) so the UI // reflects FIPS vs Tor truthfully instead of always showing Tor/none. if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, None, Some(onion), &transport.to_string(), ) .await { tracing::warn!("Failed to persist peer transport badge: {e:#}"); } if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { let body: serde_json::Value = response.json().await.unwrap_or_default(); return Ok(serde_json::json!({ "error": "payment_required", "price_sats": body.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(0), })); } // A 403 carries an actionable reason in its JSON body (e.g. "shared with // the host's federation peers only — federate first"). Surface that to // the user instead of a bare "Peer returned: 403 Forbidden". if response.status() == reqwest::StatusCode::FORBIDDEN { let status = response.status(); let body: serde_json::Value = response.json().await.unwrap_or_default(); let msg = body .get("error") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| format!("Peer returned: {status}")); return Err(anyhow::anyhow!(msg)); } if !response.status().is_success() { return Err(anyhow::anyhow!("Peer returned: {}", response.status())); } let bytes = bounded_content_bytes(response, 16 * 1024 * 1024) .await .context("Failed to read bounded content")?; use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); Ok(serde_json::json!({ "data": encoded, "size": bytes.len(), })) } /// Browse a peer's content catalog. FIPS if the peer is federated, /// otherwise Tor. pub(super) async fn handle_content_browse_peer( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; // Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; debug!( "Browsing peer content at {} (fips={})", onion, fips_npub.is_some() ); let (response, transport) = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content") .service(crate::settings::transport::PeerService::PeerFiles) .timeout(std::time::Duration::from_secs(30)) // The Cloud page's hottest call: without a fast-fail cap a // cold FIPS path burned ~16.6s before Tor even started, // against the UI's 30s deadline — users saw errors, not // fallback. .fips_timeout(std::time::Duration::from_secs(6)) .send_content_get(&self.config.data_dir) .await .context("Failed to connect to peer")?; // Record which transport actually reached the peer (B14). if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, None, Some(onion), &transport.to_string(), ) .await { tracing::warn!("Failed to persist peer transport badge: {e:#}"); } if !response.status().is_success() { return Err(anyhow::anyhow!( "Peer returned error: {}", response.status() )); } let mut body: serde_json::Value = response .json() .await .context("Failed to parse peer catalog")?; // Surface the transport that actually reached the peer so the cloud // browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21). if let Some(obj) = body.as_object_mut() { obj.insert( "transport".to_string(), serde_json::Value::String(transport.to_string()), ); } Ok(body) } /// Download paid content from a peer: mint ecash token, send with request. pub(super) async fn handle_content_download_peer_paid( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; let price_sats = params .get("price_sats") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?; if price_sats == 0 { return Err(anyhow::anyhow!("price_sats must be > 0")); } if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } crate::content_owned::validate_identity(onion, content_id)?; let _purchase_lock = crate::content_owned::lock_seller_purchases(onion).await; // NEVER pay twice for content we already own (2026-07-22: a file // shared twice produced two catalog ids for the same bytes and the // buyer paid both). Guard BEFORE any ecash is minted, matching both // by exact (onion, content_id) and by (onion, filename) — the latter // catches duplicate ids pointing at the same file on the same // seller. The owned copy is served from the local cache instead. if let Some(cached) = existing_paid_content( &self.config.data_dir, onion, content_id, params.get("filename").and_then(|v| v.as_str()), params .get("cache_only") .and_then(|v| v.as_bool()) .unwrap_or(false), ) .await? { return Ok(cached); } let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; if fips_npub.is_none() { return Ok( serde_json::json!({ "error": "Connect with this node over FIPS before buying its files. No payment was made." }), ); } // `method` pins the backend the user confirmed in the UI ("cashu" | // "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's // verify_payment_token accepts either, so a node whose balance lives in // one system can still pay (#3). let method = params.get("method").and_then(|v| v.as_str()); let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats); let mint_fedimint = || crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats); let (token_str, used_backend) = match method { Some("cashu") => match mint_cashu().await { Ok(t) => (t, "cashu"), Err(e) => { tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}"); return Ok(serde_json::json!({ "error": format!( "Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \ Fund it, or choose Fedimint." ) })); } }, Some("fedimint") => match mint_fedimint().await { Ok((notes, fed)) => { tracing::info!( "paid download: spending {price_sats} sats Fedimint notes from {fed}" ); (notes, "fedimint") } Err(e) => { tracing::warn!( "paid download: fedimint spend failed for {price_sats} sats: {e:#}" ); return Ok(serde_json::json!({ "error": format!( "Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \ Fund it, or choose Cashu." ) })); } }, _ => match mint_cashu().await { Ok(t) => (t, "cashu"), Err(cashu_err) => match mint_fedimint().await { Ok((notes, _fed)) => (notes, "fedimint"), Err(fedi_err) => { tracing::warn!( "paid download: no ecash backend could pay {price_sats} sats \ (cashu: {cashu_err:#}; fedimint: {fedi_err:#})" ); return Ok(serde_json::json!({ "error": format!( "Couldn't pay {price_sats} sats from your ecash wallet \ (Cashu or Fedimint). Fund either wallet and try again." ) })); } }, }, }; tracing::info!( "paid download: paying {price_sats} sats to {onion} via {used_backend} ecash" ); let path = format!("/content/{}", content_id); // Surface a real reason instead of the generic sanitized error (#30): // A bearer token must not be replayed after an ambiguous delivery. // A transport error can mean the seller received it without replying. let (response, transport) = match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .require_fips() .header("X-Federation-DID", local_did) .header("X-Payment-Token", token_str.clone()) .single_delivery() .timeout(std::time::Duration::from_secs(900)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(e) => { tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e); // The token was already minted/spent — reclaim it so the buyer // doesn't lose the value when the seller was simply unreachable. let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ "error": format!("The purchase could not be completed. {refund}") })); } }; // Record which transport actually reached the peer (B14). if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, None, Some(onion), &transport.to_string(), ) .await { tracing::warn!("Failed to persist peer transport badge: {e:#}"); } if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { // A 402 can mean mint validation, network failure, underpayment, // or an unaccepted mint. Do not invent a mint-mismatch diagnosis. drop(response); tracing::warn!( "paid download: seller rejected {used_backend} payment of {price_sats} sats" ); // Reclaim only proofs the mint still considers unspent. let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ "error": format!("The seller could not verify the payment. {refund}") })); } if !response.status().is_success() { let status = response.status(); let body = bounded_seller_error(response).await; tracing::warn!("paid download: seller {onion} returned {status}"); let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ "error": format!("{} {refund}", seller_error_message(status, &body)) })); } // Capture the content type BEFORE consuming the body so the local cache // can render the right viewer (image vs video) later. let mime_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.split(';').next().unwrap_or(s).trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "application/octet-stream".to_string()); let filename = params .get("filename") .and_then(|v| v.as_str()) .unwrap_or(content_id); let item = cache_peer_response(&self.config.data_dir,onion,content_id,filename,&mime_type,price_sats,used_backend,response) .await.context("Paid file delivery could not be saved. Do not send another payment; recover this purchase first")?; if let Err(error) = file_cached_purchase_in_files(&self.config.data_dir, &item).await { tracing::warn!("Purchase cached; optional Files copy failed: {error:#}"); } let mut result = cached_purchase_response( &self.config.data_dir, onion, content_id, params .get("cache_only") .and_then(|v| v.as_bool()) .unwrap_or(false), price_sats, ) .await?; result["ecash_backend"] = serde_json::json!(used_backend); Ok(result) } /// Buyer side (#46): ask the selling node to mint a Lightning invoice for a /// paid item so the buyer can pay from any external wallet. Returns the /// bolt11 invoice + payment hash to render as a QR and poll for settlement. pub(super) async fn handle_content_request_invoice( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; // Minting a bolt11 is a tiny request/response — keep it snappy. Cap the // FIPS attempt hard so a cold overlay can't burn the whole budget, and // give Tor a short-but-real window (onion circuits need a few seconds). let path = format!("/content/{}/invoice", content_id); let (response, _transport) = match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .header("X-Federation-DID", local_did) .timeout(std::time::Duration::from_secs(25)) .fips_timeout(std::time::Duration::from_secs(6)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(e) => { tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e); return Ok(serde_json::json!({ "error": "Could not reach the peer over mesh or Tor — it may be offline." })); } }; if !response.status().is_success() { return Ok(serde_json::json!({ "error": format!("Seller could not create an invoice ({}).", response.status()) })); } let body: serde_json::Value = response .json() .await .context("Failed to parse invoice response")?; Ok(body) } /// Buyer side (#46): poll the selling node for invoice settlement. pub(super) async fn handle_content_invoice_status( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; let payment_hash = params .get("payment_hash") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } // Payment hash is hex from the seller; keep it strictly hex so it's safe // to interpolate into the request path. if payment_hash.is_empty() || payment_hash.len() > 128 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(anyhow::anyhow!("Invalid payment_hash")); } let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; // Settlement poll — runs repeatedly, so each call must be quick. Fast-fail // FIPS and keep a short Tor window; an unreachable peer just reads as // "not yet paid" and the UI polls again. let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash); let (response, _transport) = match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .timeout(std::time::Duration::from_secs(15)) .fips_timeout(std::time::Duration::from_secs(6)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(_) => { // Treat an unreachable peer as "not yet paid" so the UI keeps polling. return Ok(serde_json::json!({ "paid": false, "unreachable": true })); } }; if !response.status().is_success() { return Ok(serde_json::json!({ "paid": false })); } let body: serde_json::Value = response .json() .await .context("Failed to parse invoice-status response")?; Ok(body) } /// Buyer side (#46): download a paid item after the invoice settled, passing /// the payment hash so the seller's content gate releases the file. pub(super) async fn handle_content_download_peer_invoice( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; let payment_hash = params .get("payment_hash") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(anyhow::anyhow!("Invalid payment_hash")); } crate::content_owned::validate_identity(onion, content_id)?; let _purchase_lock = crate::content_owned::lock_seller_purchases(onion).await; let cache_only = params .get("cache_only") .and_then(|v| v.as_bool()) .unwrap_or(false); if crate::content_owned::list_owned_checked(&self.config.data_dir) .await? .iter() .any(|item| { item.onion == onion && item.content_id == content_id && item.download_complete }) { return cached_purchase_response( &self.config.data_dir, onion, content_id, cache_only, 0, ) .await; } // Older sellers only mark settlement during status polling. Always // perform that handshake before requesting bytes; retries never pay. // The download gate remains authoritative: a file may have become // free, and newer sellers verify directly if status polling fails. let _ = self .handle_content_invoice_status(Some(serde_json::json!({ "onion": onion, "content_id": content_id, "payment_hash": payment_hash, }))) .await; let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let path = format!("/content/{}", content_id); let (response, transport) = match crate::fips::dial::PeerRequest::new( fips_npub.as_deref(), onion, &path, ) .service(crate::settings::transport::PeerService::PeerFiles) .require_fips() .header("X-Federation-DID", local_did) .header("X-Invoice-Hash", payment_hash.to_string()) .timeout(std::time::Duration::from_secs(900)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(e) => { tracing::warn!("invoice download dial failed for {}: {:#}", onion, e); return Ok(serde_json::json!({ "error": "The peer’s FIPS connection is unavailable. Retry when it reconnects; do not pay again." })); } }; if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, None, Some(onion), &transport.to_string(), ) .await { tracing::warn!("Failed to persist peer transport badge: {e:#}"); } if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { return Ok(serde_json::json!({ "error": "The seller has not confirmed access yet. Retry the download without paying again." })); } if !response.status().is_success() { return Ok(serde_json::json!({ "error": format!("Peer returned an error ({}).", response.status()) })); } let mime = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .split(';') .next() .unwrap_or("application/octet-stream") .to_string(); let filename = params .get("filename") .and_then(|v| v.as_str()) .unwrap_or(content_id); let item = cache_peer_response( &self.config.data_dir, onion, content_id, filename, &mime, params .get("price_sats") .and_then(|v| v.as_u64()) .unwrap_or(0), "lightning", response, ) .await .context("Paid file could not be saved; retry delivery without paying again")?; if let Err(error) = file_cached_purchase_in_files(&self.config.data_dir, &item).await { tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}"); } cached_purchase_response(&self.config.data_dir, onion, content_id, cache_only, 0).await } /// Buyer side (#46): ask the seller for a fresh on-chain address to pay. pub(super) async fn handle_content_request_onchain( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; // Issuing an address is a tiny request/response — fast-fail FIPS, short // Tor window (same budget shape as the invoice path, #6). let path = format!("/content/{}/onchain", content_id); let (response, _transport) = match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .header("X-Federation-DID", local_did) .timeout(std::time::Duration::from_secs(25)) .fips_timeout(std::time::Duration::from_secs(6)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(e) => { tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e); return Ok(serde_json::json!({ "error": "Could not reach the peer over mesh or Tor — it may be offline." })); } }; if !response.status().is_success() { return Ok(serde_json::json!({ "error": format!("Seller could not provide an address ({}).", response.status()) })); } let body: serde_json::Value = response .json() .await .context("Failed to parse onchain response")?; Ok(body) } /// Buyer side (#46): poll the selling node for on-chain payment detection. pub(super) async fn handle_content_onchain_status( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; let address = params .get("address") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing address"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } // Bitcoin addresses are alphanumeric; keep strictly so for safe path use. if address.is_empty() || address.len() > 100 || !address.chars().all(|c| c.is_ascii_alphanumeric()) { return Err(anyhow::anyhow!("Invalid address")); } let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let path = format!("/content/{}/onchain-status/{}", content_id, address); let (response, _transport) = match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .timeout(std::time::Duration::from_secs(15)) .fips_timeout(std::time::Duration::from_secs(6)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })), }; if !response.status().is_success() { return Ok(serde_json::json!({ "paid": false })); } let body: serde_json::Value = response .json() .await .context("Failed to parse onchain-status response")?; Ok(body) } /// Buyer side (#46): download a paid item after the on-chain payment was /// detected, passing the address so the seller's content gate releases it. pub(super) async fn handle_content_download_peer_onchain( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; let address = params .get("address") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing address"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) { return Err(anyhow::anyhow!("Invalid address")); } crate::content_owned::validate_identity(onion, content_id)?; let _purchase_lock = crate::content_owned::lock_seller_purchases(onion).await; let cache_only = params .get("cache_only") .and_then(|v| v.as_bool()) .unwrap_or(false); if crate::content_owned::list_owned_checked(&self.config.data_dir) .await? .iter() .any(|item| { item.onion == onion && item.content_id == content_id && item.download_complete }) { return cached_purchase_response( &self.config.data_dir, onion, content_id, cache_only, 0, ) .await; } let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let path = format!("/content/{}", content_id); let (response, transport) = match crate::fips::dial::PeerRequest::new( fips_npub.as_deref(), onion, &path, ) .service(crate::settings::transport::PeerService::PeerFiles) .require_fips() .header("X-Federation-DID", local_did) .header("X-Onchain-Address", address.to_string()) .timeout(std::time::Duration::from_secs(900)) .send_content_get(&self.config.data_dir) .await { Ok(v) => v, Err(e) => { tracing::warn!("onchain download dial failed for {}: {:#}", onion, e); return Ok(serde_json::json!({ "error": "The peer’s FIPS connection is unavailable. Retry when it reconnects; do not pay again." })); } }; if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, None, Some(onion), &transport.to_string(), ) .await { tracing::warn!("Failed to persist peer transport badge: {e:#}"); } if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { return Ok(serde_json::json!({ "error": "Seller has not registered this payment yet — wait for confirmation and retry." })); } if !response.status().is_success() { return Ok(serde_json::json!({ "error": format!("Peer returned an error ({}).", response.status()) })); } let mime = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .split(';') .next() .unwrap_or("application/octet-stream") .to_string(); let filename = params .get("filename") .and_then(|v| v.as_str()) .unwrap_or(content_id); let item = cache_peer_response( &self.config.data_dir, onion, content_id, filename, &mime, params .get("price_sats") .and_then(|v| v.as_u64()) .unwrap_or(0), "onchain", response, ) .await .context("Paid file could not be saved; retry delivery without paying again")?; if let Err(error) = file_cached_purchase_in_files(&self.config.data_dir, &item).await { tracing::warn!("On-chain purchase cached; optional Files copy failed: {error:#}"); } cached_purchase_response(&self.config.data_dir, onion, content_id, cache_only, 0).await } /// Fetch a preview of paid content from a peer (no payment required). pub(super) async fn handle_content_preview_peer( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; if !is_valid_v3_onion(onion) { return Err(anyhow::anyhow!("Invalid v3 onion address")); } let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let path = format!("/content/{}/preview", content_id); debug!( "Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some() ); let (response, transport) = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) .service(crate::settings::transport::PeerService::PeerFiles) .require_fips() .timeout(std::time::Duration::from_secs(30)) .fips_timeout(std::time::Duration::from_secs(6)) .send_content_get(&self.config.data_dir) .await .context("Failed to connect to peer for preview")?; // Record which transport actually reached the peer (B14). if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, None, Some(onion), &transport.to_string(), ) .await { tracing::warn!("Failed to persist peer transport badge: {e:#}"); } if !response.status().is_success() { return Err(anyhow::anyhow!( "Peer returned error for preview: {}", response.status() )); } let is_preview = response .headers() .get("X-Content-Preview") .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); let content_type = response .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); let bytes = bounded_content_bytes(response, 8 * 1024 * 1024) .await .context("Failed to read bounded preview")?; use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); Ok(serde_json::json!({ "data": encoded, "size": bytes.len(), "content_type": content_type, "preview_mode": is_preview, })) } /// `content.owned-list` — every paid item this node has purchased, so the /// gallery can render owned items unblurred/viewable without re-payment. pub(super) async fn handle_content_owned_list(&self) -> Result { let items = crate::content_owned::list_owned(&self.config.data_dir).await; Ok(serde_json::json!({ "items": items })) } /// `content.indeehub-projects` — films from the IndeeHub app. /// /// Node-side because the interesting half needs a Nostr session, and /// signing that in the browser would put identity material next to the /// model. Returns titles only. pub(super) async fn handle_content_indeehub_projects(&self) -> Result { let projects = crate::content_indeehub::list_projects(&self.config.data_dir).await; let items: Vec = projects .iter() .filter_map(|p| { let title = p.title.as_deref()?.trim(); if title.is_empty() { return None; } Some(serde_json::json!({ "id": p.id.clone().unwrap_or_else(|| title.to_string()), "title": title, "synopsis": p.synopsis.clone().unwrap_or_default(), "poster": p.poster.clone().unwrap_or_default(), "year": p.year_num(), // Films are video by definition. The UI adapter buckets // purely on mime/extension, so an item carrying neither // silently classified 'excluded' and this scope's // surface could never render a card. "mime_type": "video/mp4", })) }) .collect(); Ok(serde_json::json!({ "count": items.len(), "items": items })) } /// `content.browse-all-peers` — every federated peer's catalogue in one /// call. /// /// The dashboard fans this out client-side, but the assistant needs a /// SINGLE tool call to answer "what films do my peers have" — asking a /// model to enumerate peers and loop is how it ends up saying it has no /// tool for this at all. /// /// One peer failing (offline, Tor timeout) contributes nothing rather than /// failing the whole call: with a dozen peers, any of them being down is /// the normal case, not an error. pub(super) async fn handle_content_browse_all_peers(&self) -> Result { let nodes = crate::federation::load_nodes(&self.config.data_dir) .await .unwrap_or_default(); let onions: Vec = nodes .iter() .filter_map(|n| { let o = n.onion.clone(); if o.trim().is_empty() { None } else { Some(o) } }) .collect(); // CONCURRENT with a cap, mirroring Cloud.vue's peer-files fan-out // (BROWSE_PEER_CONCURRENCY = 3, 10s per peer, one attempt). That is // the implementation the operator already trusts, and it is why the // Cloud tab answers while a sequential version here did not: with 16 // peers at 8s each, going one at a time reached only one or two inside // any sane budget. // // 02-08 is the reason for the CAP rather than an unbounded fan-out — // 13 of 14 simultaneous browse-peer calls never settled and starved // the connection pool. Three at a time keeps a dead peer from costing // anything but its own slot. // Cloud.vue uses 3, but that cap exists because CHROMIUM's connection // pool was being starved (02-08) — a browser constraint the daemon does // not share. Measured here: at 3, a 20s budget only got through 2 // batches of 16 peers and reached none. At 8 every peer is attempted // inside the budget, which is the point. const BROWSE_PEER_CONCURRENCY: usize = 8; const PER_PEER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // Headroom matters: 16 peers at concurrency 8 is two batches, and a // batch only finishes when its SLOWEST peer does. At a 20s budget // one slow peer in batch 1 left batch 2 no time at all. let overall = std::time::Duration::from_secs(45); let deadline = tokio::time::Instant::now() + overall; let mut items = Vec::new(); let mut reached = 0usize; let mut unreachable = 0usize; // Accumulate per batch rather than wrapping the whole loop in one // `timeout(..).unwrap_or_default()`. That construction DISCARDED // every completed batch the moment the budget expired, so a single // slow peer turned a partly-successful fan-out into "0 reached, 16 // unreachable" — indistinguishable, downstream, from the peers // having no content at all. Observed live on archi-dev-box: back to // back calls returned real peer items and then nothing. let mut results: Vec<(String, Option)> = Vec::new(); for chunk in onions.chunks(BROWSE_PEER_CONCURRENCY) { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { break; } let mut set = Vec::new(); for onion in chunk { let params = Some(serde_json::json!({ "onion": onion })); set.push(async move { let v = tokio::time::timeout( PER_PEER_TIMEOUT, self.handle_content_browse_peer(params), ) .await .ok() .and_then(|r| r.ok()); (onion.clone(), v) }); } // No batch-level timeout: every future in `set` is ALREADY // bounded by PER_PEER_TIMEOUT, so this join can't outrun it, and // adding an outer timeout here would reintroduce exactly the // discard-on-expiry bug above. The deadline check at the top of // the loop is what stops a long peer list from running forever. results.extend(futures_util::future::join_all(set).await); } for (onion, v) in &results { match v { Some(v) => { reached += 1; if let Some(arr) = v.get("items").and_then(|i| i.as_array()) { for it in arr { let mut it = it.clone(); if let Some(obj) = it.as_object_mut() { obj.insert("peer".into(), serde_json::json!(onion)); } items.push(it); } } } None => unreachable += 1, } } // Peers the overall budget never got to are unreachable for this call, // not silently absent. unreachable += onions.len().saturating_sub(results.len()); Ok(serde_json::json!({ "items": items, "peers_reached": reached, "peers_unreachable": unreachable, "peers_total": onions.len(), "partial": unreachable > 0, })) } /// `content.owned-get` — return a purchased item's bytes (base64) from the /// local cache for in-app viewing/saving. No network, no re-payment. pub(super) async fn handle_content_owned_get( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; let content_id = params .get("content_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; existing_paid_content( &self.config.data_dir, onion, content_id, params.get("filename").and_then(|v| v.as_str()), params .get("cache_only") .and_then(|v| v.as_bool()) .unwrap_or(false), ) .await? .context("Purchased content is not cached") } } #[cfg(test)] #[path = "content_tests.rs"] mod tests; #[cfg(test)] mod invoice_delivery_response_tests { use super::*; #[tokio::test] async fn cached_delivery_avoids_base64_and_legacy_small_reads_remain_compatible() { let dir = tempfile::tempdir().unwrap(); crate::content_owned::record_purchase( dir.path(), "seller.onion", "film", "film", "video/mp4", b"paid bytes", 1, "cashu", "now", ) .await .unwrap(); let cached = cached_purchase_response(dir.path(), "seller.onion", "film", true, 0) .await .unwrap(); assert_eq!(cached["owned"], true); assert_eq!(cached["size_bytes"], 10); assert!(cached.get("data").is_none()); assert!(cached.get("data_base64").is_none()); let alias = existing_paid_content( dir.path(), "seller.onion", "new-catalog-id", Some("film"), true, ) .await .unwrap() .unwrap(); assert_eq!(alias["owned_content_id"], "film"); let legacy = cached_purchase_response(dir.path(), "seller.onion", "film", false, 0) .await .unwrap(); assert_eq!(legacy["data"], "cGFpZCBieXRlcw=="); assert_eq!(legacy["data"], legacy["data_base64"]); let mut entry = crate::content_owned::list_owned_checked(dir.path()) .await .unwrap() .remove(0); entry.content_id = "incomplete".into(); let stream = futures_util::stream::iter([Ok::<_, std::io::Error>( bytes::Bytes::from_static(b"part"), )]); assert!( crate::content_owned::record_purchase_stream(dir.path(), entry, stream, Some(10)) .await .is_err() ); assert!( existing_paid_content(dir.path(), "seller.onion", "incomplete", None, true) .await .is_err() ); } }