317 lines
12 KiB
Rust
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)
|
|
}
|
|
}
|