2026-03-09 07:43:12 +00:00
|
|
|
use super::RpcHandler;
|
|
|
|
|
use crate::content_server::{self, AccessControl, Availability, ContentItem};
|
2026-03-14 03:50:44 +00:00
|
|
|
use crate::network::dwn_store::DwnStore;
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
use crate::wallet::ecash;
|
2026-03-09 07:43:12 +00:00
|
|
|
use anyhow::{Context, Result};
|
|
|
|
|
use tracing::debug;
|
|
|
|
|
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
/// 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];
|
chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
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>
2026-04-18 17:23:46 -04:00
|
|
|
prefix
|
|
|
|
|
.chars()
|
|
|
|
|
.all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 03:50:44 +00:00
|
|
|
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
|
|
|
|
|
2026-06-20 12:16:02 -04:00
|
|
|
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
|
|
|
|
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
|
|
|
|
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
|
|
|
|
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
|
|
|
|
/// the seller already claimed the token (then the value is genuinely gone).
|
|
|
|
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
|
|
|
|
let res = match backend {
|
|
|
|
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
|
|
|
|
.await
|
|
|
|
|
.map(|(sats, _fed)| sats),
|
|
|
|
|
_ => ecash::receive_token(data_dir, token).await,
|
|
|
|
|
};
|
|
|
|
|
match res {
|
|
|
|
|
Ok(sats) => tracing::info!(
|
|
|
|
|
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
|
|
|
|
),
|
|
|
|
|
Err(e) => tracing::warn!(
|
|
|
|
|
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
|
|
|
|
claimed it): {e:#}"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 07:43:12 +00:00
|
|
|
impl RpcHandler {
|
|
|
|
|
/// List content I'm sharing.
|
chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
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>
2026-04-18 17:23:46 -04:00
|
|
|
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
2026-03-09 07:43:12 +00:00
|
|
|
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"))?;
|
2026-03-19 20:35:41 +00:00
|
|
|
// 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('\\') {
|
2026-03-18 19:45:10 +00:00
|
|
|
anyhow::bail!("Invalid filename: path traversal not allowed");
|
|
|
|
|
}
|
2026-03-19 20:35:41 +00:00
|
|
|
// 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");
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
}
|
2026-03-19 20:35:41 +00:00
|
|
|
// Reject any path segment starting with . (hidden dirs)
|
chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
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>
2026-04-18 17:23:46 -04:00
|
|
|
if filename
|
|
|
|
|
.split('/')
|
|
|
|
|
.any(|seg| seg.starts_with('.') || seg.is_empty())
|
|
|
|
|
{
|
2026-03-19 20:35:41 +00:00
|
|
|
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");
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
}
|
2026-03-09 07:43:12 +00:00
|
|
|
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("");
|
|
|
|
|
|
2026-03-13 02:20:55 +00:00
|
|
|
let mut item = ContentItem {
|
2026-03-09 07:43:12 +00:00
|
|
|
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(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 02:20:55 +00:00
|
|
|
// Resolve actual file size from disk
|
|
|
|
|
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
|
2026-03-22 03:30:21 +00:00
|
|
|
if let Ok(metadata) = tokio::fs::metadata(&file_path).await {
|
2026-03-13 02:20:55 +00:00
|
|
|
item.size_bytes = metadata.len();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 07:43:12 +00:00
|
|
|
content_server::add_item(&self.config.data_dir, item.clone()).await?;
|
2026-03-14 03:50:44 +00:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 07:43:12 +00:00
|
|
|
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 }
|
|
|
|
|
}
|
chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
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>
2026-04-18 17:23:46 -04:00
|
|
|
_ => {
|
|
|
|
|
return Err(anyhow::anyhow!(
|
|
|
|
|
"Invalid availability: {}",
|
|
|
|
|
availability_type
|
|
|
|
|
))
|
|
|
|
|
}
|
2026-03-09 07:43:12 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
content_server::set_availability(&self.config.data_dir, id, availability).await?;
|
|
|
|
|
Ok(serde_json::json!({ "updated": true }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 01:29:13 -04:00
|
|
|
/// Download content from a peer. Prefers FIPS when the peer is known
|
|
|
|
|
/// in our federation and has advertised a FIPS npub; falls back to
|
|
|
|
|
/// Tor on any network failure.
|
2026-03-13 02:37:59 +00:00
|
|
|
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"))?;
|
|
|
|
|
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
// 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"));
|
2026-03-13 02:37:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
|
|
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
2026-04-28 15:00:58 -04:00
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
2026-04-19 01:29:13 -04:00
|
|
|
|
|
|
|
|
let path = format!("/content/{}", content_id);
|
2026-06-15 13:02:13 -04:00
|
|
|
let (response, transport) =
|
2026-04-19 01:29:13 -04:00
|
|
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
2026-04-19 01:44:41 -04:00
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
2026-04-19 01:29:13 -04:00
|
|
|
.header("X-Federation-DID", local_did)
|
|
|
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to connect to peer")?;
|
2026-06-15 13:02:13 -04:00
|
|
|
// Record which transport actually reached the peer (B14) so the UI
|
|
|
|
|
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
|
2026-06-15 13:15:01 -04:00
|
|
|
let _ = crate::federation::record_peer_transport(
|
2026-06-15 13:02:13 -04:00
|
|
|
&self.config.data_dir,
|
|
|
|
|
None,
|
|
|
|
|
Some(onion),
|
|
|
|
|
&transport.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2026-03-13 02:37:59 +00:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 05:03:18 -04:00
|
|
|
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
|
|
|
|
|
// the host's federation peers only — federate first"). Surface that to
|
|
|
|
|
// the user instead of a bare "Peer returned: 403 Forbidden".
|
|
|
|
|
if response.status() == reqwest::StatusCode::FORBIDDEN {
|
|
|
|
|
let status = response.status();
|
|
|
|
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
|
|
|
|
let msg = body
|
|
|
|
|
.get("error")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
.unwrap_or_else(|| format!("Peer returned: {status}"));
|
|
|
|
|
return Err(anyhow::anyhow!(msg));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 02:37:59 +00:00
|
|
|
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(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 01:29:13 -04:00
|
|
|
/// Browse a peer's content catalog. FIPS if the peer is federated,
|
|
|
|
|
/// otherwise Tor.
|
2026-03-09 07:43:12 +00:00
|
|
|
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"))?;
|
|
|
|
|
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
// 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"));
|
2026-03-09 07:43:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 15:00:58 -04:00
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
2026-03-09 07:43:12 +00:00
|
|
|
|
2026-04-28 15:00:58 -04:00
|
|
|
debug!(
|
|
|
|
|
"Browsing peer content at {} (fips={})",
|
|
|
|
|
onion,
|
|
|
|
|
fips_npub.is_some()
|
|
|
|
|
);
|
2026-03-09 07:43:12 +00:00
|
|
|
|
2026-06-15 13:02:13 -04:00
|
|
|
let (response, transport) =
|
2026-04-19 01:29:13 -04:00
|
|
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
2026-04-19 01:44:41 -04:00
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
2026-04-19 01:29:13 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to connect to peer")?;
|
2026-06-15 13:02:13 -04:00
|
|
|
// Record which transport actually reached the peer (B14).
|
2026-06-15 13:15:01 -04:00
|
|
|
let _ = crate::federation::record_peer_transport(
|
2026-06-15 13:02:13 -04:00
|
|
|
&self.config.data_dir,
|
|
|
|
|
None,
|
|
|
|
|
Some(onion),
|
|
|
|
|
&transport.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2026-03-09 07:43:12 +00:00
|
|
|
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Err(anyhow::anyhow!(
|
|
|
|
|
"Peer returned error: {}",
|
|
|
|
|
response.status()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 13:25:39 -04:00
|
|
|
let mut body: serde_json::Value = response
|
2026-03-09 07:43:12 +00:00
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to parse peer catalog")?;
|
|
|
|
|
|
2026-06-15 13:25:39 -04:00
|
|
|
// Surface the transport that actually reached the peer so the cloud
|
|
|
|
|
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
|
|
|
|
if let Some(obj) = body.as_object_mut() {
|
|
|
|
|
obj.insert(
|
|
|
|
|
"transport".to_string(),
|
|
|
|
|
serde_json::Value::String(transport.to_string()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 07:43:12 +00:00
|
|
|
Ok(body)
|
|
|
|
|
}
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
|
|
|
|
|
/// Download paid content from a peer: mint ecash token, send with request.
|
|
|
|
|
pub(super) async fn handle_content_download_peer_paid(
|
|
|
|
|
&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"))?;
|
|
|
|
|
let price_sats = params
|
|
|
|
|
.get("price_sats")
|
|
|
|
|
.and_then(|v| v.as_u64())
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
|
|
|
|
|
|
|
|
|
|
if price_sats == 0 {
|
|
|
|
|
return Err(anyhow::anyhow!("price_sats must be > 0"));
|
|
|
|
|
}
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 12:16:02 -04:00
|
|
|
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
|
|
|
|
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
|
|
|
|
// verify_payment_token accepts either, so a node whose balance lives in
|
|
|
|
|
// one system can still pay (#3).
|
|
|
|
|
let method = params.get("method").and_then(|v| v.as_str());
|
|
|
|
|
|
|
|
|
|
let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats);
|
|
|
|
|
let mint_fedimint =
|
|
|
|
|
|| crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats);
|
|
|
|
|
|
|
|
|
|
let (token_str, used_backend) = match method {
|
|
|
|
|
Some("cashu") => match mint_cashu().await {
|
|
|
|
|
Ok(t) => (t, "cashu"),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}");
|
|
|
|
|
return Ok(serde_json::json!({ "error": format!(
|
|
|
|
|
"Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \
|
|
|
|
|
Fund it, or choose Fedimint."
|
|
|
|
|
) }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Some("fedimint") => match mint_fedimint().await {
|
|
|
|
|
Ok((notes, fed)) => {
|
2026-06-30 05:08:17 -04:00
|
|
|
tracing::info!(
|
|
|
|
|
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
|
|
|
|
|
);
|
2026-06-20 12:16:02 -04:00
|
|
|
(notes, "fedimint")
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2026-06-30 05:08:17 -04:00
|
|
|
tracing::warn!(
|
|
|
|
|
"paid download: fedimint spend failed for {price_sats} sats: {e:#}"
|
|
|
|
|
);
|
2026-06-20 12:16:02 -04:00
|
|
|
return Ok(serde_json::json!({ "error": format!(
|
|
|
|
|
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
|
|
|
|
|
Fund it, or choose Cashu."
|
|
|
|
|
) }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
_ => match mint_cashu().await {
|
|
|
|
|
Ok(t) => (t, "cashu"),
|
|
|
|
|
Err(cashu_err) => match mint_fedimint().await {
|
|
|
|
|
Ok((notes, _fed)) => (notes, "fedimint"),
|
2026-06-20 08:13:23 -04:00
|
|
|
Err(fedi_err) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"paid download: no ecash backend could pay {price_sats} sats \
|
|
|
|
|
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
|
|
|
|
|
);
|
2026-06-20 12:16:02 -04:00
|
|
|
return Ok(serde_json::json!({ "error": format!(
|
|
|
|
|
"Couldn't pay {price_sats} sats from your ecash wallet \
|
|
|
|
|
(Cashu or Fedimint). Fund either wallet and try again."
|
|
|
|
|
) }));
|
2026-06-20 08:13:23 -04:00
|
|
|
}
|
2026-06-20 12:16:02 -04:00
|
|
|
},
|
|
|
|
|
},
|
2026-06-20 08:13:23 -04:00
|
|
|
};
|
2026-06-30 05:08:17 -04:00
|
|
|
tracing::info!(
|
|
|
|
|
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
|
|
|
|
|
);
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
|
|
|
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
|
|
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
2026-04-28 15:00:58 -04:00
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
2026-04-19 01:29:13 -04:00
|
|
|
|
|
|
|
|
let path = format!("/content/{}", content_id);
|
2026-06-17 03:10:21 -04:00
|
|
|
// Surface a real reason instead of the generic sanitized error (#30):
|
|
|
|
|
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
|
|
|
|
// here means the peer is genuinely unreachable on both transports.
|
2026-06-17 19:50:46 -04:00
|
|
|
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
|
|
|
|
fips_npub.as_deref(),
|
|
|
|
|
onion,
|
|
|
|
|
&path,
|
|
|
|
|
)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
|
|
|
|
.header("X-Federation-DID", local_did)
|
2026-06-20 12:16:02 -04:00
|
|
|
.header("X-Payment-Token", token_str.clone())
|
2026-06-17 19:50:46 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(900))
|
|
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
2026-06-20 12:16:02 -04:00
|
|
|
// The token was already minted/spent — reclaim it so the buyer
|
|
|
|
|
// doesn't lose the value when the seller was simply unreachable.
|
|
|
|
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
2026-06-17 19:50:46 -04:00
|
|
|
return Ok(serde_json::json!({
|
2026-06-20 12:16:02 -04:00
|
|
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
2026-06-17 19:50:46 -04:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-06-15 13:02:13 -04:00
|
|
|
// Record which transport actually reached the peer (B14).
|
2026-06-15 13:15:01 -04:00
|
|
|
let _ = crate::federation::record_peer_transport(
|
2026-06-15 13:02:13 -04:00
|
|
|
&self.config.data_dir,
|
|
|
|
|
None,
|
|
|
|
|
Some(onion),
|
|
|
|
|
&transport.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
|
|
|
|
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
2026-06-20 12:16:02 -04:00
|
|
|
// Payment was rejected by the seller. Surface the most likely cause
|
|
|
|
|
// per backend — for ecash both sides must share a redemption network
|
|
|
|
|
// (a Cashu mint, or a Fedimint federation).
|
|
|
|
|
let body = response.text().await.unwrap_or_default();
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
|
|
|
|
);
|
|
|
|
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
|
|
|
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
|
|
|
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
|
|
|
|
let hint = match used_backend {
|
|
|
|
|
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
|
|
|
|
_ => "the seller doesn't accept your Cashu mint",
|
|
|
|
|
};
|
2026-06-17 03:10:21 -04:00
|
|
|
return Ok(serde_json::json!({
|
2026-06-20 12:16:02 -04:00
|
|
|
"error": format!(
|
|
|
|
|
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
|
|
|
|
your wallet. Try the other ecash type, or use a shared mint/federation."
|
|
|
|
|
)
|
2026-06-17 03:10:21 -04:00
|
|
|
}));
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !response.status().is_success() {
|
2026-06-20 12:16:02 -04:00
|
|
|
let status = response.status();
|
|
|
|
|
let body = response.text().await.unwrap_or_default();
|
|
|
|
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
|
|
|
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
2026-06-17 03:10:21 -04:00
|
|
|
return Ok(serde_json::json!({
|
2026-06-20 12:16:02 -04:00
|
|
|
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
2026-06-17 03:10:21 -04:00
|
|
|
}));
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 18:58:52 -04:00
|
|
|
// Capture the content type BEFORE consuming the body so the local cache
|
|
|
|
|
// can render the right viewer (image vs video) later.
|
|
|
|
|
let mime_type = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get(reqwest::header::CONTENT_TYPE)
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
|
|
|
|
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
let bytes = response
|
|
|
|
|
.bytes()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to read response body")?;
|
|
|
|
|
|
2026-06-20 18:58:52 -04:00
|
|
|
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
|
|
|
|
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
|
|
|
|
// it unblurred and views it in-app from this cache — no re-payment and no
|
|
|
|
|
// reliance on a browser download (which silently fails on the mobile
|
|
|
|
|
// companion, the original "paid but never unlocked" report). Best-effort:
|
|
|
|
|
// a cache-write failure must not fail an already-paid download.
|
|
|
|
|
let filename = params
|
|
|
|
|
.get("filename")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or(content_id)
|
|
|
|
|
.to_string();
|
|
|
|
|
let purchased_at = chrono::Utc::now().to_rfc3339();
|
|
|
|
|
if let Err(e) = crate::content_owned::record_purchase(
|
|
|
|
|
&self.config.data_dir,
|
|
|
|
|
onion,
|
|
|
|
|
content_id,
|
|
|
|
|
&filename,
|
|
|
|
|
&mime_type,
|
|
|
|
|
&bytes,
|
|
|
|
|
price_sats,
|
|
|
|
|
used_backend,
|
|
|
|
|
&purchased_at,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
|
|
|
|
}
|
|
|
|
|
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
use base64::Engine;
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
|
|
|
|
2026-06-20 12:16:02 -04:00
|
|
|
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"data": encoded,
|
|
|
|
|
"size": bytes.len(),
|
|
|
|
|
"paid_sats": price_sats,
|
2026-06-20 12:16:02 -04:00
|
|
|
"ecash_backend": used_backend,
|
2026-06-20 18:58:52 -04:00
|
|
|
"mime_type": mime_type,
|
|
|
|
|
"owned": true,
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
|
|
|
|
/// paid item so the buyer can pay from any external wallet. Returns the
|
|
|
|
|
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
|
|
|
|
|
pub(super) async fn handle_content_request_invoice(
|
|
|
|
|
&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"))?;
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
|
|
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
|
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
|
|
|
|
|
2026-06-19 05:03:18 -04:00
|
|
|
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
|
|
|
|
|
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
|
|
|
|
|
// give Tor a short-but-real window (onion circuits need a few seconds).
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
let path = format!("/content/{}/invoice", content_id);
|
|
|
|
|
let (response, _transport) =
|
|
|
|
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
|
|
|
|
.header("X-Federation-DID", local_did)
|
2026-06-19 05:03:18 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(25))
|
|
|
|
|
.fips_timeout(std::time::Duration::from_secs(6))
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e);
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": "Could not reach the peer over mesh or Tor — it may be offline."
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": format!("Seller could not create an invoice ({}).", response.status())
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
let body: serde_json::Value = response
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to parse invoice response")?;
|
|
|
|
|
Ok(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Buyer side (#46): poll the selling node for invoice settlement.
|
|
|
|
|
pub(super) async fn handle_content_invoice_status(
|
|
|
|
|
&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"))?;
|
|
|
|
|
let payment_hash = params
|
|
|
|
|
.get("payment_hash")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
// Payment hash is hex from the seller; keep it strictly hex so it's safe
|
|
|
|
|
// to interpolate into the request path.
|
|
|
|
|
if payment_hash.is_empty()
|
|
|
|
|
|| payment_hash.len() > 128
|
|
|
|
|
|| !payment_hash.chars().all(|c| c.is_ascii_hexdigit())
|
|
|
|
|
{
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
2026-06-19 05:03:18 -04:00
|
|
|
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
|
|
|
|
|
// FIPS and keep a short Tor window; an unreachable peer just reads as
|
|
|
|
|
// "not yet paid" and the UI polls again.
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
|
|
|
|
let (response, _transport) =
|
|
|
|
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
2026-06-19 05:03:18 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
|
|
|
.fips_timeout(std::time::Duration::from_secs(6))
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
// Treat an unreachable peer as "not yet paid" so the UI keeps polling.
|
|
|
|
|
return Ok(serde_json::json!({ "paid": false, "unreachable": true }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(serde_json::json!({ "paid": false }));
|
|
|
|
|
}
|
|
|
|
|
let body: serde_json::Value = response
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to parse invoice-status response")?;
|
|
|
|
|
Ok(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Buyer side (#46): download a paid item after the invoice settled, passing
|
|
|
|
|
/// the payment hash so the seller's content gate releases the file.
|
|
|
|
|
pub(super) async fn handle_content_download_peer_invoice(
|
|
|
|
|
&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"))?;
|
|
|
|
|
let payment_hash = params
|
|
|
|
|
.get("payment_hash")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
|
|
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
|
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
|
|
|
|
|
|
|
|
|
let path = format!("/content/{}", content_id);
|
2026-06-17 19:50:46 -04:00
|
|
|
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
|
|
|
|
fips_npub.as_deref(),
|
|
|
|
|
onion,
|
|
|
|
|
&path,
|
|
|
|
|
)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
|
|
|
|
.header("X-Federation-DID", local_did)
|
|
|
|
|
.header("X-Invoice-Hash", payment_hash.to_string())
|
|
|
|
|
.timeout(std::time::Duration::from_secs(900))
|
|
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
let _ = crate::federation::record_peer_transport(
|
|
|
|
|
&self.config.data_dir,
|
|
|
|
|
None,
|
|
|
|
|
Some(onion),
|
|
|
|
|
&transport.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": format!("Peer returned an error ({}).", 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(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
|
|
|
|
pub(super) async fn handle_content_request_onchain(
|
|
|
|
|
&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"))?;
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
|
|
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
|
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
|
|
|
|
|
2026-06-19 05:03:18 -04:00
|
|
|
// Issuing an address is a tiny request/response — fast-fail FIPS, short
|
|
|
|
|
// Tor window (same budget shape as the invoice path, #6).
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
let path = format!("/content/{}/onchain", content_id);
|
|
|
|
|
let (response, _transport) =
|
|
|
|
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
|
|
|
|
.header("X-Federation-DID", local_did)
|
2026-06-19 05:03:18 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(25))
|
|
|
|
|
.fips_timeout(std::time::Duration::from_secs(6))
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e);
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": "Could not reach the peer over mesh or Tor — it may be offline."
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": format!("Seller could not provide an address ({}).", response.status())
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
let body: serde_json::Value = response
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to parse onchain response")?;
|
|
|
|
|
Ok(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Buyer side (#46): poll the selling node for on-chain payment detection.
|
|
|
|
|
pub(super) async fn handle_content_onchain_status(
|
|
|
|
|
&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"))?;
|
|
|
|
|
let address = params
|
|
|
|
|
.get("address")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
|
2026-06-17 19:50:46 -04:00
|
|
|
if address.is_empty()
|
|
|
|
|
|| address.len() > 100
|
|
|
|
|
|| !address.chars().all(|c| c.is_ascii_alphanumeric())
|
|
|
|
|
{
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
return Err(anyhow::anyhow!("Invalid address"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
|
|
|
|
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
|
|
|
|
let (response, _transport) =
|
|
|
|
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
2026-06-19 05:03:18 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
|
|
|
.fips_timeout(std::time::Duration::from_secs(6))
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })),
|
|
|
|
|
};
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(serde_json::json!({ "paid": false }));
|
|
|
|
|
}
|
|
|
|
|
let body: serde_json::Value = response
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to parse onchain-status response")?;
|
|
|
|
|
Ok(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Buyer side (#46): download a paid item after the on-chain payment was
|
|
|
|
|
/// detected, passing the address so the seller's content gate releases it.
|
|
|
|
|
pub(super) async fn handle_content_download_peer_onchain(
|
|
|
|
|
&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"))?;
|
|
|
|
|
let address = params
|
|
|
|
|
.get("address")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid address"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
|
|
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
|
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
|
|
|
|
|
|
|
|
|
let path = format!("/content/{}", content_id);
|
2026-06-17 19:50:46 -04:00
|
|
|
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
|
|
|
|
fips_npub.as_deref(),
|
|
|
|
|
onion,
|
|
|
|
|
&path,
|
|
|
|
|
)
|
|
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
|
|
|
|
.header("X-Federation-DID", local_did)
|
|
|
|
|
.header("X-Onchain-Address", address.to_string())
|
|
|
|
|
.timeout(std::time::Duration::from_secs(900))
|
|
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
decoupled-update wiring; Fedimint Client core app in catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
|
|
|
let _ = crate::federation::record_peer_transport(
|
|
|
|
|
&self.config.data_dir,
|
|
|
|
|
None,
|
|
|
|
|
Some(onion),
|
|
|
|
|
&transport.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": "Seller has not registered this payment yet — wait for confirmation and retry."
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(serde_json::json!({
|
|
|
|
|
"error": format!("Peer returned an error ({}).", 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(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
/// Fetch a preview of paid content from a peer (no payment required).
|
|
|
|
|
pub(super) async fn handle_content_preview_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"))?;
|
|
|
|
|
|
|
|
|
|
if !is_valid_v3_onion(onion) {
|
|
|
|
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 15:00:58 -04:00
|
|
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
|
2026-04-19 01:29:13 -04:00
|
|
|
let path = format!("/content/{}/preview", content_id);
|
2026-04-28 15:00:58 -04:00
|
|
|
debug!(
|
|
|
|
|
"Fetching content preview from {}{} (fips={})",
|
|
|
|
|
onion,
|
|
|
|
|
path,
|
|
|
|
|
fips_npub.is_some()
|
|
|
|
|
);
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
|
2026-06-15 13:02:13 -04:00
|
|
|
let (response, transport) =
|
2026-04-19 01:29:13 -04:00
|
|
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
2026-04-19 01:44:41 -04:00
|
|
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
2026-04-19 01:29:13 -04:00
|
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
|
|
|
.send_get()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to connect to peer for preview")?;
|
2026-06-15 13:02:13 -04:00
|
|
|
// Record which transport actually reached the peer (B14).
|
2026-06-15 13:15:01 -04:00
|
|
|
let _ = crate::federation::record_peer_transport(
|
2026-06-15 13:02:13 -04:00
|
|
|
&self.config.data_dir,
|
|
|
|
|
None,
|
|
|
|
|
Some(onion),
|
|
|
|
|
&transport.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
|
|
|
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Err(anyhow::anyhow!(
|
|
|
|
|
"Peer returned error for preview: {}",
|
|
|
|
|
response.status()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let is_preview = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("X-Content-Preview")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let content_type = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("content-type")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.unwrap_or("application/octet-stream")
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let bytes = response
|
|
|
|
|
.bytes()
|
|
|
|
|
.await
|
|
|
|
|
.context("Failed to read preview response")?;
|
|
|
|
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"data": encoded,
|
|
|
|
|
"size": bytes.len(),
|
|
|
|
|
"content_type": content_type,
|
|
|
|
|
"preview_mode": is_preview,
|
|
|
|
|
}))
|
|
|
|
|
}
|
2026-06-20 18:58:52 -04:00
|
|
|
|
|
|
|
|
/// `content.owned-list` — every paid item this node has purchased, so the
|
|
|
|
|
/// gallery can render owned items unblurred/viewable without re-payment.
|
|
|
|
|
pub(super) async fn handle_content_owned_list(&self) -> Result<serde_json::Value> {
|
|
|
|
|
let items = crate::content_owned::list_owned(&self.config.data_dir).await;
|
|
|
|
|
Ok(serde_json::json!({ "items": items }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `content.owned-get` — return a purchased item's bytes (base64) from the
|
|
|
|
|
/// local cache for in-app viewing/saving. No network, no re-payment.
|
|
|
|
|
pub(super) async fn handle_content_owned_get(
|
|
|
|
|
&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"))?;
|
|
|
|
|
|
|
|
|
|
match crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await {
|
|
|
|
|
Some((mime_type, bytes)) => {
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"data": encoded,
|
|
|
|
|
"size": bytes.len(),
|
|
|
|
|
"mime_type": mime_type,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
None => Ok(serde_json::json!({
|
|
|
|
|
"error": "You don't own this item yet, or its cached copy is missing."
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 07:43:12 +00:00
|
|
|
}
|