use super::RpcHandler; use crate::content_server::{self, AccessControl, Availability, ContentItem}; use crate::network::dwn_store::DwnStore; 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"; 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 })) } /// 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: AccessControl::Free, availability: Availability::default(), 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) = std::fs::metadata(&file_path) { item.size_bytes = metadata.len(); } content_server::add_item(&self.config.data_dir, item.clone()).await?; // 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 })) } /// 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_type = params .get("access") .and_then(|v| v.as_str()) .unwrap_or("free"); let access = match access_type { "free" => AccessControl::Free, "peers_only" => 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")); } AccessControl::Paid { price_sats: price } } _ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)), }; 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_type = params .get("availability") .and_then(|v| v.as_str()) .unwrap_or("all_peers"); let availability = match availability_type { "nobody" => Availability::Nobody, "all_peers" => 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(); Availability::Specific { peers } } _ => return Err(anyhow::anyhow!("Invalid availability: {}", availability_type)), }; content_server::set_availability(&self.config.data_dir, id, availability).await?; Ok(serde_json::json!({ "updated": true })) } /// Download content from a peer over Tor, returning base64-encoded data. 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 socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY) .context("Failed to create SOCKS proxy")?; let client = reqwest::Client::builder() .proxy(socks_proxy) .timeout(std::time::Duration::from_secs(120)) .build() .context("Failed to build Tor HTTP client")?; let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let url = format!("http://{}/content/{}", onion, content_id); let response = client .get(&url) .header("X-Federation-DID", &local_did) .send() .await .context("Failed to connect to peer over Tor")?; 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), })); } if !response.status().is_success() { return Err(anyhow::anyhow!("Peer returned: {}", response.status())); } let bytes = response .bytes() .await .context("Failed to read response body")?; 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 over 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")); } // Connect via Tor SOCKS proxy to the peer's content catalog endpoint let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY) .context("Failed to create SOCKS proxy")?; let client = reqwest::Client::builder() .proxy(socks_proxy) .timeout(std::time::Duration::from_secs(30)) .build() .context("Failed to build Tor HTTP client")?; let url = format!("http://{}/content", onion); debug!("Browsing peer content at {}", url); let response = client .get(&url) .send() .await .context("Failed to connect to peer over Tor")?; if !response.status().is_success() { return Err(anyhow::anyhow!( "Peer returned error: {}", response.status() )); } let body: serde_json::Value = response .json() .await .context("Failed to parse peer catalog")?; Ok(body) } }