317 lines
12 KiB
Rust

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<serde_json::Value> {
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<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: 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) = tokio::fs::metadata(&file_path).await {
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<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 }))
}
/// 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_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<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_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::<Vec<_>>()
})
.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<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 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<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"));
}
// 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)
}
}