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:
co-authored by
Claude Opus 4.8
parent
b0c9bd2a0c
commit
db7d424bff
@@ -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" => {
|
||||
|
||||
Reference in New Issue
Block a user