feat(content): owned-content persistence + Fedimint paid downloads, fmcd caps fix, FIPS warm-path perf

Buyer-side paid downloads now persist: purchases are cached on disk
(content_owned.rs) keyed by (seller onion, content_id), the gallery shows
an "Owned" badge unblurred, and items view/play in-app from the local
cache with no re-payment or reliance on a browser download (which
silently failed on the mobile companion). New RPCs content.owned-list /
content.owned-get. Validated e2e .116<-.198 (paid 100 sats via Fedimint,
166KB jpeg returns, survives restart).

fedimint-clientd manifest: restore the standard container capability set
(CHOWN/DAC_OVERRIDE/FOWNER/SETUID/SETGID) so fmcd's startup chown of an
existing-federation /data succeeds instead of dying EPERM (#7). Confirmed
the orchestrator applies these to the running container.

FIPS perf: tighten the supervisor warm-path keepalive 45s -> 25s so peer
paths stay inside the ~30-60s NAT cold window. Dials now reliably land on
FIPS instead of re-punching and falling back to Tor. Measured to the same
peer: cloud browse 18-22s -> 0.4s; full Fedimint paid download 29s -> 11s
(residual is the seller-side guardian reissue round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-20 18:58:52 -04:00
co-authored by Claude Opus 4.8
parent b0c9bd2a0c
commit db7d424bff
12 changed files with 544 additions and 26 deletions
+79
View File
@@ -532,11 +532,49 @@ impl RpcHandler {
}));
}
// 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());
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
// 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:#}");
}
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -546,6 +584,8 @@ impl RpcHandler {
"size": bytes.len(),
"paid_sats": price_sats,
"ecash_backend": used_backend,
"mime_type": mime_type,
"owned": true,
}))
}
@@ -1017,4 +1057,43 @@ impl RpcHandler {
"preview_mode": is_preview,
}))
}
/// `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."
})),
}
}
}
@@ -276,6 +276,8 @@ impl RpcHandler {
"content.browse-peer" => self.handle_content_browse_peer(params).await,
"content.download-peer" => self.handle_content_download_peer(params).await,
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.owned-list" => self.handle_content_owned_list().await,
"content.owned-get" => self.handle_content_owned_get(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await,
"content.invoice-status" => self.handle_content_invoice_status(params).await,
"content.download-peer-invoice" => {