//! Tor-based content serving with access control. //! //! Serves only explicitly shared content items to authenticated peers. //! Content items can be free or ecash-gated (gating implemented later). use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tokio::fs; use tracing::debug; const CATALOG_FILE: &str = "content/catalog.json"; const CONTENT_DIR: &str = "content/files"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContentItem { pub id: String, pub filename: String, pub mime_type: String, pub size_bytes: u64, #[serde(default)] pub description: String, #[serde(default)] pub access: AccessControl, #[serde(default)] pub availability: Availability, #[serde(default)] pub added_at: String, } /// Who can see/access this content. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Availability { /// Nobody — content is not available. Nobody, /// All connected peers can access. AllPeers, /// Only specific peers (by onion address). Specific { peers: Vec }, } impl Default for Availability { fn default() -> Self { Availability::AllPeers } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AccessControl { Free, PeersOnly, Paid { price_sats: u64 }, } impl Default for AccessControl { fn default() -> Self { AccessControl::Free } } #[derive(Debug, Default, Serialize, Deserialize)] pub struct ContentCatalog { pub items: Vec, } /// Load the content catalog from disk. pub async fn load_catalog(data_dir: &Path) -> Result { let path = data_dir.join(CATALOG_FILE); if !path.exists() { return Ok(ContentCatalog::default()); } let content = fs::read_to_string(&path) .await .context("Failed to read content catalog")?; let catalog: ContentCatalog = serde_json::from_str(&content).unwrap_or_default(); Ok(catalog) } /// Save the content catalog to disk. pub async fn save_catalog(data_dir: &Path, catalog: &ContentCatalog) -> Result<()> { let dir = data_dir.join("content"); fs::create_dir_all(&dir).await.context("Failed to create content dir")?; let path = data_dir.join(CATALOG_FILE); let content = serde_json::to_string_pretty(catalog).context("Failed to serialize catalog")?; fs::write(&path, content).await.context("Failed to write catalog")?; Ok(()) } /// Get the full filesystem path for a content item. pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf { data_dir.join(CONTENT_DIR).join(&item.filename) } /// Add a content item to the catalog. pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result { let mut catalog = load_catalog(data_dir).await?; if catalog.items.iter().any(|i| i.id == item.id) { return Err(anyhow::anyhow!("Content item '{}' already exists", item.id)); } catalog.items.push(item); save_catalog(data_dir, &catalog).await?; Ok(catalog) } /// Remove a content item from the catalog. pub async fn remove_item(data_dir: &Path, id: &str) -> Result { let mut catalog = load_catalog(data_dir).await?; catalog.items.retain(|i| i.id != id); save_catalog(data_dir, &catalog).await?; Ok(catalog) } /// Update access control for a content item. pub async fn set_access(data_dir: &Path, id: &str, access: AccessControl) -> Result<()> { let mut catalog = load_catalog(data_dir).await?; if let Some(item) = catalog.items.iter_mut().find(|i| i.id == id) { item.access = access; save_catalog(data_dir, &catalog).await?; Ok(()) } else { Err(anyhow::anyhow!("Content item '{}' not found", id)) } } /// Update availability for a content item. pub async fn set_availability(data_dir: &Path, id: &str, availability: Availability) -> Result<()> { let mut catalog = load_catalog(data_dir).await?; if let Some(item) = catalog.items.iter_mut().find(|i| i.id == id) { item.availability = availability; save_catalog(data_dir, &catalog).await?; Ok(()) } else { Err(anyhow::anyhow!("Content item '{}' not found", id)) } } /// A byte range request (start, optional end). pub struct ByteRange { pub start: u64, pub end: Option, } /// Parse an HTTP Range header value like "bytes=0-1023". pub fn parse_range_header(header: &str) -> Option { let s = header.strip_prefix("bytes=")?; let mut parts = s.splitn(2, '-'); let start_str = parts.next()?.trim(); let end_str = parts.next().map(|s| s.trim()); let start = start_str.parse::().ok()?; let end = end_str .filter(|s| !s.is_empty()) .and_then(|s| s.parse::().ok()); Some(ByteRange { start, end }) } /// Result of attempting to serve content. pub enum ServeResult { /// Content served successfully (full body). Ok(Vec, String), /// Partial content served (range response). Partial { bytes: Vec, mime_type: String, start: u64, end: u64, total: u64, }, /// Payment required — includes price in sats. PaymentRequired(u64), /// Content not found. NotFound, } /// Serve a content item by ID with access control and optional range request. /// If the content is paid, checks for a valid payment token in the header. pub async fn serve_content( data_dir: &Path, id: &str, payment_token: Option<&str>, range: Option, ) -> Result { let catalog = load_catalog(data_dir).await?; let item = match catalog.items.iter().find(|i| i.id == id) { Some(i) => i, None => return Ok(ServeResult::NotFound), }; // Check availability match &item.availability { Availability::Nobody => return Ok(ServeResult::NotFound), Availability::Specific { peers } => { // In a real implementation, we'd check the requester's identity // For now, log that peer-specific availability is set debug!("Content '{}' restricted to {} specific peers", id, peers.len()); } Availability::AllPeers => {} } // Check access control match &item.access { AccessControl::Paid { price_sats } => { // Verify payment token if let Some(token) = payment_token { if !verify_payment_token(data_dir, token, *price_sats).await { return Ok(ServeResult::PaymentRequired(*price_sats)); } } else { return Ok(ServeResult::PaymentRequired(*price_sats)); } } AccessControl::PeersOnly => { // For now, allow all requests (peer auth is at the Tor level) } AccessControl::Free => {} } let file_path = content_file_path(data_dir, item); if !file_path.exists() { return Ok(ServeResult::NotFound); } let metadata = fs::metadata(&file_path) .await .context("Failed to read file metadata")?; let total_size = metadata.len(); // Handle range request for streaming if let Some(range) = range { let start = range.start.min(total_size.saturating_sub(1)); let end = range .end .map(|e| e.min(total_size - 1)) .unwrap_or(total_size - 1); if start > end || start >= total_size { return Ok(ServeResult::NotFound); } let len = (end - start + 1) as usize; use tokio::io::{AsyncReadExt, AsyncSeekExt}; let mut file = tokio::fs::File::open(&file_path) .await .context("Failed to open content file")?; file.seek(std::io::SeekFrom::Start(start)) .await .context("Failed to seek")?; let mut buf = vec![0u8; len]; file.read_exact(&mut buf) .await .context("Failed to read range")?; debug!( "Serving content '{}' range {}-{}/{} ({} bytes)", id, start, end, total_size, len ); return Ok(ServeResult::Partial { bytes: buf, mime_type: item.mime_type.clone(), start, end, total: total_size, }); } let bytes = fs::read(&file_path) .await .context("Failed to read content file")?; debug!("Serving content '{}' ({} bytes)", id, bytes.len()); Ok(ServeResult::Ok(bytes, item.mime_type.clone())) } /// Verify a payment token covers the required amount. /// Tokens are ecash strings that we validate and mark as spent. async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool { // Parse cashu token format to verify amount if token.starts_with("cashuSend_") { let amount = token .split('_') .nth(1) .and_then(|s| s.parse::().ok()) .unwrap_or(0); if amount >= required_sats { // Record the payment (receive the token into our wallet) if let Ok(wallet_mod) = crate::wallet::ecash::receive_token(data_dir, token).await { debug!("Payment verified: {} sats for {} required", wallet_mod, required_sats); return true; } } } false }