The .github/workflows/ci.yml Rust job runs cargo fmt --check, clippy
with -D warnings, and tests. All three were failing. This commit:
- Applies rustfmt across the tree (the bulk of the diff — untouched
since the last toolchain bump, so a wide sweep was unavoidable).
- Fixes the correctness-level clippy errors:
container/bitcoin_simulator.rs wildcard-in-or-pattern
container/manifest.rs from_str rename to parse (reserved name)
container/podman_client.rs .get(0) -> .first()
container/runtime.rs manual += collapse
archipelago/src/constants.rs doc-comment → module-doc
api/rpc/package/install.rs stray /// comment above a non-item
container/docker_packages.rs redundant field init
streaming/advertisement.rs missing Metric import in tests
tests/orchestration_tests.rs `vec!` in non-Vec contexts
mesh/listener/dispatch.rs unused store_plain_message import
api/rpc/tor/mod.rs and mesh/steganography.rs: push-after-new → vec!
- Quiets wide legacy surfaces with crate-level allows in main.rs for
stylistic lints (too_many_arguments, type_complexity, doc indent,
enum variant prefix, wildcard-in-or, assertions-on-constants,
drop_non_drop, unused_io_amount, ptr_arg) — these fired in dozens
of places with no correctness payoff and have been churning every
toolchain bump.
- Tags intentional-dead-code helpers: wallet/ and streaming/ modules
are WIP, mesh::send_chunked_payload and DM_V1_MARKER are kept for
rollback compatibility, vpn::get_nostr_vpn_status is surface-area
for a not-yet-landed RPC.
cargo fmt --check, cargo clippy --all-targets --all-features
-- -D warnings, and cargo test --all-features now all pass locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
434 lines
14 KiB
Rust
434 lines
14 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")]
|
|
#[derive(Default)]
|
|
pub enum Availability {
|
|
/// Nobody — content is not available.
|
|
Nobody,
|
|
/// All connected peers can access.
|
|
#[default]
|
|
AllPeers,
|
|
/// Only specific peers (by onion address).
|
|
Specific { peers: Vec<String> },
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[derive(Default)]
|
|
pub enum AccessControl {
|
|
#[default]
|
|
Free,
|
|
PeersOnly,
|
|
Paid { price_sats: u64 },
|
|
}
|
|
|
|
|
|
#[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()))
|
|
}
|
|
|
|
/// Result of attempting to serve a preview.
|
|
pub enum PreviewResult {
|
|
/// Full content (free/peers-only items — redirect to normal serve).
|
|
FullContent(Vec<u8>, String),
|
|
/// Blurred preview for paid image (full bytes, frontend applies blur).
|
|
BlurPreview(Vec<u8>, String),
|
|
/// Truncated preview for paid video (first ~2% of bytes).
|
|
TruncatedPreview(Vec<u8>, String, u64),
|
|
/// Content not found.
|
|
NotFound,
|
|
}
|
|
|
|
/// Serve a preview of content by ID. For paid content, returns degraded previews:
|
|
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
|
|
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
|
|
/// - Other: not available
|
|
/// For free/peers-only content, returns the full file.
|
|
pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewResult> {
|
|
let catalog = load_catalog(data_dir).await?;
|
|
let item = match catalog.items.iter().find(|i| i.id == id) {
|
|
Some(i) => i,
|
|
None => return Ok(PreviewResult::NotFound),
|
|
};
|
|
|
|
// Check availability — don't preview hidden items
|
|
if matches!(item.availability, Availability::Nobody) {
|
|
return Ok(PreviewResult::NotFound);
|
|
}
|
|
|
|
let file_path = content_file_path(data_dir, item);
|
|
if !file_path.exists() {
|
|
return Ok(PreviewResult::NotFound);
|
|
}
|
|
|
|
match &item.access {
|
|
AccessControl::Paid { .. } => {
|
|
let mime = &item.mime_type;
|
|
if mime.starts_with("image/") {
|
|
// Serve full image — frontend applies CSS blur
|
|
let bytes = fs::read(&file_path)
|
|
.await
|
|
.context("Failed to read preview file")?;
|
|
debug!(
|
|
"Serving blur preview for paid image '{}' ({} bytes)",
|
|
id,
|
|
bytes.len()
|
|
);
|
|
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
|
|
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
|
|
// Serve first 10% of video/audio, minimum 512KB for codec headers
|
|
let metadata = fs::metadata(&file_path)
|
|
.await
|
|
.context("Failed to read file metadata")?;
|
|
let total_size = metadata.len();
|
|
let preview_bytes = ((total_size * 10) / 100).max(512 * 1024).min(total_size);
|
|
|
|
use tokio::io::AsyncReadExt;
|
|
let mut file = tokio::fs::File::open(&file_path)
|
|
.await
|
|
.context("Failed to open file")?;
|
|
let mut buf = vec![0u8; preview_bytes as usize];
|
|
file.read_exact(&mut buf)
|
|
.await
|
|
.context("Failed to read preview bytes")?;
|
|
|
|
let kind = if mime.starts_with("video/") {
|
|
"video"
|
|
} else {
|
|
"audio"
|
|
};
|
|
debug!(
|
|
"Serving truncated preview for paid {} '{}' ({}/{} bytes)",
|
|
kind, id, preview_bytes, total_size
|
|
);
|
|
Ok(PreviewResult::TruncatedPreview(
|
|
buf,
|
|
item.mime_type.clone(),
|
|
total_size,
|
|
))
|
|
} else {
|
|
// Non-media paid content — no preview available
|
|
Ok(PreviewResult::NotFound)
|
|
}
|
|
}
|
|
_ => {
|
|
// Free or peers-only — serve full content as preview
|
|
let bytes = fs::read(&file_path)
|
|
.await
|
|
.context("Failed to read content file")?;
|
|
Ok(PreviewResult::FullContent(bytes, item.mime_type.clone()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verify a payment token covers the required amount.
|
|
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
|
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
|
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
|
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
|
Ok(received) => {
|
|
debug!(
|
|
"Payment verified: {} sats received for {} required",
|
|
received, required_sats
|
|
);
|
|
// Record the content sale for profit tracking
|
|
if let Err(e) = crate::wallet::profits::record_content_sale(
|
|
data_dir,
|
|
received,
|
|
"Content download payment",
|
|
)
|
|
.await
|
|
{
|
|
debug!("Failed to record content sale profit (non-fatal): {}", e);
|
|
}
|
|
true
|
|
}
|
|
Err(e) => {
|
|
debug!("Payment verification failed: {}", e);
|
|
false
|
|
}
|
|
}
|
|
}
|