archy/core/archipelago/src/content_server.rs
Dorian 92ac73fc20 feat: implement peers_only and specific availability access control for content
- PeersOnly access now checks X-Federation-DID header against known federation nodes
- Specific availability restricts content to named peer DIDs only
- Anonymous/unknown DID requests get 403 Forbidden
- Free content remains accessible to everyone
- Paid content still returns 402 with price info

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:27:38 +00:00

332 lines
10 KiB
Rust

//! 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<String> },
}
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<ContentItem>,
}
/// Load the content catalog from disk.
pub async fn load_catalog(data_dir: &Path) -> Result<ContentCatalog> {
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.
/// Checks the dedicated content/files/ directory first, then falls back to the
/// FileBrowser data directory (where users manage files via the web UI).
pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
// Strip leading slash from filename for path joining
let clean_name = item.filename.trim_start_matches('/');
// Primary: dedicated content directory
let primary = data_dir.join(CONTENT_DIR).join(clean_name);
if primary.exists() {
return primary;
}
// Fallback: FileBrowser data directory (users share files managed via FileBrowser)
let fb_path = data_dir.join("filebrowser").join(clean_name);
if fb_path.exists() {
return fb_path;
}
// Return primary path even if it doesn't exist (caller checks existence)
primary
}
/// Add a content item to the catalog.
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
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<ContentCatalog> {
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<u64>,
}
/// Parse an HTTP Range header value like "bytes=0-1023".
pub fn parse_range_header(header: &str) -> Option<ByteRange> {
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::<u64>().ok()?;
let end = end_str
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<u64>().ok());
Some(ByteRange { start, end })
}
/// Result of attempting to serve content.
pub enum ServeResult {
/// Content served successfully (full body).
Ok(Vec<u8>, String),
/// Partial content served (range response).
Partial {
bytes: Vec<u8>,
mime_type: String,
start: u64,
end: u64,
total: u64,
},
/// Payment required — includes price in sats.
PaymentRequired(u64),
/// Access forbidden — peer not authorized.
Forbidden,
/// 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.
/// `peer_did` is the DID from the X-Federation-DID header (if present).
pub async fn serve_content(
data_dir: &Path,
id: &str,
payment_token: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
) -> Result<ServeResult> {
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),
};
// Load known federation peers for access checks
let is_known_peer = if peer_did.is_some() {
let nodes = crate::federation::load_nodes(data_dir).await.unwrap_or_default();
nodes.iter().any(|n| Some(n.did.as_str()) == peer_did)
} else {
false
};
// Check availability
match &item.availability {
Availability::Nobody => return Ok(ServeResult::NotFound),
Availability::Specific { peers } => {
if let Some(did) = peer_did {
if !peers.iter().any(|p| p == did) {
debug!("Content '{}' not available to peer {}", id, did);
return Ok(ServeResult::Forbidden);
}
} else {
return Ok(ServeResult::Forbidden);
}
}
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 => {
if !is_known_peer {
return Ok(ServeResult::Forbidden);
}
}
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::<u64>().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
}