diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml index aba52999..3da43588 100644 --- a/apps/fedimint-clientd/manifest.yml +++ b/apps/fedimint-clientd/manifest.yml @@ -33,7 +33,14 @@ app: disk_limit: 2Gi security: - capabilities: [] + # fmcd's `fmcd-run` launcher chowns its /data (existing federation DB) on + # every start. With the default `cap_drop: ALL` and no caps added back, that + # chown fails and fmcd dies "Operation not permitted (os error 1)" — but ONLY + # once /data holds a joined federation (a fresh/empty dir needs no chown, so + # it appeared to work). Restore the standard container capability set so the + # startup chown succeeds (#7). Verified by bisection on .116: these caps make + # fmcd boot + serve /v2/*; DAC_OVERRIDE or SETUID/SETGID alone do NOT. + capabilities: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"] readonly_root: true # NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh # relays to reach iroh-transport federations. `bridge` gives NAT'd outbound diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index a6034510..8aa7738b 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -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 { + 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, + ) -> Result { + 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." + })), + } + } } diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 4ed83aad..f35e436c 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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" => { diff --git a/core/archipelago/src/content_owned.rs b/core/archipelago/src/content_owned.rs new file mode 100644 index 00000000..56755c7f --- /dev/null +++ b/core/archipelago/src/content_owned.rs @@ -0,0 +1,165 @@ +//! Buyer-side store of paid content the node has purchased. +//! +//! A paid peer download used to be ephemeral: the bytes were handed to the +//! browser as a one-shot `` and then thrown away. On the mobile +//! companion that download silently fails, so the item appeared to never +//! "unlock" even though the ecash was spent. This module persists every +//! successful purchase — bytes + metadata — keyed by (seller onion, content_id), +//! so the gallery can render owned items unblurred and play/view them in-app +//! from the local cache, with no re-payment and no reliance on a browser +//! download. The buyer can still save the file later from the cached copy. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const OWNED_DIR: &str = "purchased-content"; +const OWNED_INDEX: &str = "owned.json"; + +/// One purchased item. `onion` + `content_id` are the identity; everything else +/// is display/metadata captured at purchase time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnedItem { + pub onion: String, + pub content_id: String, + pub filename: String, + pub mime_type: String, + pub size_bytes: u64, + pub paid_sats: u64, + pub ecash_backend: String, + /// RFC3339 timestamp; best-effort, empty if the clock was unavailable. + pub purchased_at: String, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct OwnedIndex { + items: Vec, +} + +fn owned_root(data_dir: &Path) -> PathBuf { + data_dir.join(OWNED_DIR) +} + +fn index_path(data_dir: &Path) -> PathBuf { + owned_root(data_dir).join(OWNED_INDEX) +} + +/// Sanitize an onion into a safe directory component (it's already [a-z2-7].onion +/// for valid v3, but be defensive against path traversal regardless). +fn sanitize(component: &str) -> String { + component + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect() +} + +fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf { + owned_root(data_dir) + .join(sanitize(onion)) + .join(sanitize(content_id)) +} + +async fn load_index(data_dir: &Path) -> OwnedIndex { + match fs::read_to_string(index_path(data_dir)).await { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => OwnedIndex::default(), + } +} + +async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> { + let root = owned_root(data_dir); + fs::create_dir_all(&root) + .await + .with_context(|| format!("creating {}", root.display()))?; + let content = serde_json::to_string_pretty(index).context("serializing owned index")?; + fs::write(index_path(data_dir), content) + .await + .context("writing owned index") +} + +/// Persist a successful purchase: write the bytes to disk and upsert the index +/// entry. Idempotent on (onion, content_id) — re-buying overwrites with the +/// latest copy/metadata rather than duplicating. +pub async fn record_purchase( + data_dir: &Path, + onion: &str, + content_id: &str, + filename: &str, + mime_type: &str, + bytes: &[u8], + paid_sats: u64, + ecash_backend: &str, + purchased_at: &str, +) -> Result<()> { + let path = bytes_path(data_dir, onion, content_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("creating {}", parent.display()))?; + } + fs::write(&path, bytes) + .await + .with_context(|| format!("writing purchased bytes to {}", path.display()))?; + + let mut index = load_index(data_dir).await; + let entry = OwnedItem { + onion: onion.to_string(), + content_id: content_id.to_string(), + filename: filename.to_string(), + mime_type: mime_type.to_string(), + size_bytes: bytes.len() as u64, + paid_sats, + ecash_backend: ecash_backend.to_string(), + purchased_at: purchased_at.to_string(), + }; + if let Some(existing) = index + .items + .iter_mut() + .find(|i| i.onion == onion && i.content_id == content_id) + { + *existing = entry; + } else { + index.items.push(entry); + } + save_index(data_dir, &index).await +} + +/// Every item this node owns. +pub async fn list_owned(data_dir: &Path) -> Vec { + load_index(data_dir).await.items +} + +/// True if the node has already purchased this (onion, content_id). +#[allow(dead_code)] // used by the upcoming seller-side signed-entitlement path (#8) +pub async fn is_owned(data_dir: &Path, onion: &str, content_id: &str) -> bool { + bytes_path(data_dir, onion, content_id).exists() + && load_index(data_dir) + .await + .items + .iter() + .any(|i| i.onion == onion && i.content_id == content_id) +} + +/// Read a purchased item's bytes + mime type from the local cache, if present. +pub async fn read_owned( + data_dir: &Path, + onion: &str, + content_id: &str, +) -> Option<(String, Vec)> { + let bytes = fs::read(bytes_path(data_dir, onion, content_id)).await.ok()?; + let mime = load_index(data_dir) + .await + .items + .into_iter() + .find(|i| i.onion == onion && i.content_id == content_id) + .map(|i| i.mime_type) + .unwrap_or_else(|| "application/octet-stream".to_string()); + Some((mime, bytes)) +} diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index 90e4ee4e..c89f79dc 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -60,14 +60,23 @@ pub async fn ensure_activated(data_dir: &std::path::Path) { tracing::info!("FIPS auto-activated"); } -/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding +/// Spawn the FIPS supervisor: every 25s it (1) auto-activates FIPS if onboarding /// is done but the service is down — so it comes up with zero user interaction, /// and (2) keeps hole-punched paths to known federation peers warm, so on-demand /// dials land on FIPS instead of falling back to Tor. Warms peers concurrently /// so one slow/offline peer doesn't delay the rest. +/// +/// The interval MUST be shorter than the NAT/hole-punch cold window +/// (`warm_path` docs it at ~30-60s). The previous 45s sat at the edge of that +/// window: a path that went cold at ~30s stayed cold until the next 45s tick, +/// so real peer dials in that gap hit a cold path and fell back to Tor (~18s +/// onion latency instead of FIPS's ~2-3s). 25s keeps every path refreshed +/// inside the minimum cold window, which is what actually makes FIPS — not Tor — +/// the transport peer requests land on. Measured: warm FIPS browse ~2.6s vs a +/// cold-path fallback browse ~18-22s over Tor to the same peer. pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) { tokio::spawn(async move { - let mut tick = tokio::time::interval(std::time::Duration::from_secs(45)); + let mut tick = tokio::time::interval(std::time::Duration::from_secs(25)); loop { tick.tick().await; // Bring FIPS up on its own once onboarding has materialised the key. diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 41501e55..3ca0aba4 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -39,6 +39,7 @@ mod constants; mod container; mod content_hash; mod content_invoice; +mod content_owned; mod content_server; mod crash_recovery; mod credentials; diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs index dd57e980..42f2d5e7 100644 --- a/core/archipelago/src/wallet/fedimint_client.rs +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -169,6 +169,34 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> { Ok(c) => c, Err(_) => return Ok(()), // clientd not configured yet }; + + // Fast path: if fmcd already reports a joined federation, do NOT re-issue the + // POST /v2/admin/join. That call re-syncs federation config against the + // guardians and adds seconds of latency — and ensure_default_federation runs + // on every wallet.fedimint-list / spend / reissue, so the join was being paid + // on each balance refresh (the "mints take ages to load" report). The cheap + // GET /v2/admin/info is enough to confirm membership; just reconcile the local + // registry against the live joined set and return. + let joined = client.joined_federation_ids().await; + if !joined.is_empty() { + let mut reg = load_registry(data_dir).await?; + let mut changed = false; + for id in joined { + if !reg.federations.iter().any(|f| f.federation_id == id) { + reg.federations.push(JoinedFederation { + federation_id: id, + name: Some("Archipelago Federation".to_string()), + }); + changed = true; + } + } + if changed { + save_registry(data_dir, ®).await?; + } + return Ok(()); + } + + // Cold start only: nothing joined yet, so join the default federation once. let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await { Ok(id) => id, Err(e) => { diff --git a/neode-ui/src/main.ts b/neode-ui/src/main.ts index 50c00d43..b34b39eb 100644 --- a/neode-ui/src/main.ts +++ b/neode-ui/src/main.ts @@ -110,10 +110,39 @@ function recordError(source: string, err: unknown, info?: string) { app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info) +// After a frontend deploy the browser's cached index.html still points at the +// OLD hashed chunks (e.g. AppSession-Cq93o4ao.js), which 404 — Vite then throws +// "Failed to fetch dynamically imported module". The fix is to reload once so +// the browser pulls the fresh index + chunk map. Guard with sessionStorage so a +// genuinely-broken chunk can't trap us in a reload loop. +function isStaleChunkError(err: unknown): boolean { + const msg = (err as { message?: string })?.message ?? String(err ?? '') + return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg) +} +function reloadOnceForStaleChunk(err: unknown): boolean { + if (!isStaleChunkError(err)) return false + try { + const KEY = 'archy-chunk-reload-at' + const last = Number(sessionStorage.getItem(KEY) || '0') + // Only reload if we haven't already tried in the last 10s (loop guard). + if (Date.now() - last < 10_000) return false + sessionStorage.setItem(KEY, String(Date.now())) + } catch { /* sessionStorage unavailable — reload anyway, once is better than stuck */ } + // Cache-bust the navigation so the stale index isn't served again. + location.reload() + return true +} + // Vue's errorHandler only catches errors raised synchronously inside Vue's // lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS // API missing in an older WebView) slip past it, so catch those too. -window.addEventListener('error', (ev) => recordError('window.error', ev.error ?? ev.message)) -window.addEventListener('unhandledrejection', (ev) => recordError('unhandledrejection', ev.reason)) +window.addEventListener('error', (ev) => { + if (reloadOnceForStaleChunk(ev.error ?? ev.message)) return + recordError('window.error', ev.error ?? ev.message) +}) +window.addEventListener('unhandledrejection', (ev) => { + if (reloadOnceForStaleChunk(ev.reason)) return + recordError('unhandledrejection', ev.reason) +}) app.mount('#app') diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index f37073b7..14594d25 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -413,4 +413,21 @@ router.afterEach((to) => { } }) +// A route whose lazy-loaded component chunk 404s (stale index after a deploy) +// rejects through router.onError rather than window.unhandledrejection. Reload +// once so the browser fetches the fresh index + chunk map; the sessionStorage +// guard (10s) prevents a reload loop if the chunk is genuinely broken. +router.onError((err) => { + const msg = (err as { message?: string })?.message ?? String(err ?? '') + if (!/Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg)) { + return + } + try { + const KEY = 'archy-chunk-reload-at' + if (Date.now() - Number(sessionStorage.getItem(KEY) || '0') < 10_000) return + sessionStorage.setItem(KEY, String(Date.now())) + } catch { /* sessionStorage unavailable — reload anyway */ } + location.reload() +}) + export default router diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index ad099e25..81468a21 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -500,10 +500,13 @@ const walletTransactions = ref([]) function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) } async function loadWeb5Status() { - try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false; walletOnchain.value = 0; walletLightning.value = 0 } - try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { walletEcash.value = 0 } - try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { walletFedimint.value = 0 } - try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] } + // A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when + // there is a balance"). On failure keep the last-known value — the refs start + // at 0, so only the very first load before any success shows 0. + try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false } + try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } + try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } + try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { /* keep last-known transactions */ } } // System stats diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 278e42c6..7a6cdb70 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -79,14 +79,14 @@
- -
+ +
+ + + + Owned +
+ +
{{ getItemPrice(item.access) }} sats
- -
+ +
10% preview
@@ -153,9 +160,27 @@ > {{ accessLabel(item.access) }} - + + +