Files
archy/core/archipelago/src/api/rpc/content.rs
T

1712 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AccessControl> {
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::<Result<Vec<_>>>()?,
};
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<Availability> {
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::<Vec<_>>()
})
.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<Vec<u8>> {
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::<serde_json::Value>(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<Option<serde_json::Value>> {
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<serde_json::Value> {
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<crate::content_owned::OwnedItem> {
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<String> {
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<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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(&params)?,
// Legacy multi-call clients must configure visibility explicitly;
// an interrupted setup must not publish a paid file as free.
availability: parse_content_availability(&params, "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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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(&params)?;
let availability = parse_content_availability(&params, "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<serde_json::Value>,
) -> Result<serde_json::Value> {
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(&params)?;
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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(&params, "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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
let projects = crate::content_indeehub::list_projects(&self.config.data_dir).await;
let items: Vec<serde_json::Value> = 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<serde_json::Value> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let onions: Vec<String> = 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<serde_json::Value>)> = 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<serde_json::Value>,
) -> Result<serde_json::Value> {
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()
);
}
}