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:
parent
b0c9bd2a0c
commit
db7d424bff
@ -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
|
||||
|
||||
@ -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" => {
|
||||
|
||||
165
core/archipelago/src/content_owned.rs
Normal file
165
core/archipelago/src/content_owned.rs
Normal file
@ -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 `<a download>` 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<OwnedItem>,
|
||||
}
|
||||
|
||||
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<OwnedItem> {
|
||||
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<u8>)> {
|
||||
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))
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -500,10 +500,13 @@ const walletTransactions = ref<WalletTransaction[]>([])
|
||||
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
|
||||
|
||||
@ -79,14 +79,14 @@
|
||||
<div
|
||||
v-if="isMediaMime(item.mime_type)"
|
||||
class="relative aspect-video overflow-hidden cursor-pointer group"
|
||||
@click="isPlayable(item.mime_type) ? playMedia(item) : undefined"
|
||||
@click="isOwned(item) ? viewOwned(item) : (isPlayable(item.mime_type) ? playMedia(item) : undefined)"
|
||||
>
|
||||
<img
|
||||
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
|
||||
:src="previewUrls[item.id]"
|
||||
:alt="item.filename"
|
||||
class="w-full h-full object-cover"
|
||||
:style="isPaidItem(item.access) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
|
||||
:style="(isPaidItem(item.access) && !isOwned(item)) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
|
||||
/>
|
||||
<video
|
||||
v-else-if="item.mime_type.startsWith('video/') && previewUrls[item.id]"
|
||||
@ -120,15 +120,22 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Paid badge (top-right) -->
|
||||
<div v-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
|
||||
<!-- Owned badge (top-right) — purchased, unlocked for this buyer -->
|
||||
<div v-if="isPaidItem(item.access) && isOwned(item)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-green-500/20 backdrop-blur-sm">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-green-400">Owned</span>
|
||||
</div>
|
||||
<!-- Paid badge (top-right) — not yet purchased -->
|
||||
<div v-else-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-orange-400">{{ getItemPrice(item.access) }} sats</span>
|
||||
</div>
|
||||
<!-- Preview badge for paid playable items -->
|
||||
<div v-if="isPaidItem(item.access) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
|
||||
<!-- Preview badge for paid playable items (only before purchase) -->
|
||||
<div v-if="isPaidItem(item.access) && !isOwned(item) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
|
||||
<span class="text-xs text-white/70">10% preview</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -153,9 +160,27 @@
|
||||
>
|
||||
{{ accessLabel(item.access) }}
|
||||
</span>
|
||||
<!-- Play button for audio/video -->
|
||||
<!-- View button for owned content (image/video/audio), from cache -->
|
||||
<button
|
||||
v-if="isPlayable(item.mime_type)"
|
||||
v-if="isOwned(item) && isMediaMime(item.mime_type)"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
|
||||
:disabled="playing === item.id"
|
||||
@click="viewOwned(item)"
|
||||
>
|
||||
<template v-if="playing === item.id">
|
||||
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span>Opening...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</template>
|
||||
</button>
|
||||
<!-- Preview/Play button — pre-purchase only -->
|
||||
<button
|
||||
v-else-if="isPlayable(item.mime_type)"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
|
||||
:disabled="playing === item.id"
|
||||
@click="playMedia(item)"
|
||||
@ -178,9 +203,9 @@
|
||||
>
|
||||
<template v-if="downloading === item.id">
|
||||
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span>{{ isPaidItem(item.access) ? 'Paying...' : 'Loading...' }}</span>
|
||||
<span>{{ isPaidItem(item.access) && !isOwned(item) ? 'Paying...' : 'Loading...' }}</span>
|
||||
</template>
|
||||
<template v-else-if="isPaidItem(item.access)">
|
||||
<template v-else-if="isPaidItem(item.access) && !isOwned(item)">
|
||||
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
@ -190,7 +215,7 @@
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Download</span>
|
||||
<span>{{ isOwned(item) ? 'Save' : 'Download' }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
@ -260,6 +285,67 @@
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Owned-content viewer — full file from the local cache (no re-payment) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="viewerUrl && viewerItem"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4"
|
||||
@click.self="closeViewer"
|
||||
>
|
||||
<div class="relative w-full max-w-4xl mx-4">
|
||||
<button
|
||||
class="absolute -top-10 right-0 text-white/60 hover:text-white transition-colors"
|
||||
@click="closeViewer"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
v-if="viewerMime.startsWith('image/')"
|
||||
:src="viewerUrl"
|
||||
:alt="viewerItem.filename"
|
||||
class="w-full max-h-[80vh] object-contain rounded-xl bg-black"
|
||||
/>
|
||||
<video
|
||||
v-else-if="viewerMime.startsWith('video/')"
|
||||
:src="viewerUrl"
|
||||
class="w-full max-h-[80vh] rounded-xl bg-black"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
/>
|
||||
<audio
|
||||
v-else-if="viewerMime.startsWith('audio/')"
|
||||
:src="viewerUrl"
|
||||
class="w-full"
|
||||
controls
|
||||
autoplay
|
||||
/>
|
||||
<div v-else class="glass-card p-8 rounded-xl text-center">
|
||||
<p class="text-sm text-white/70">This file type can't be previewed. Use Save to download it.</p>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ viewerItem.filename.split('/').pop() }}</p>
|
||||
<p class="text-xs text-green-400">Owned · unlocked</p>
|
||||
</div>
|
||||
<button
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5 shrink-0"
|
||||
@click="saveOwned(viewerItem)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Payment method picker / Lightning invoice QR (#46) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
@ -385,7 +471,7 @@
|
||||
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
|
||||
:disabled="!ecashPlan.chosen || downloading === payItem.id"
|
||||
@click="confirmEcashPay"
|
||||
>{{ downloading === payItem.id ? 'Paying…' : `Confirm · pay with ${ecashPlan.chosen === 'fedimint' ? 'Fedimint' : 'Cashu'}` }}</button>
|
||||
>{{ downloading === payItem.id ? 'Paying…' : 'Pay' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -537,6 +623,81 @@ const transportPill = computed(() => {
|
||||
const previewUrls = reactive<Record<string, string>>({})
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// ─── Owned (purchased) content ───────────────────────────────────────────
|
||||
// A paid item the buyer has purchased stays unlocked for them: the backend
|
||||
// caches the bytes keyed by (seller onion, content_id). We load that set so the
|
||||
// gallery renders owned items unblurred and opens them in-app from the local
|
||||
// cache — no re-payment, and no reliance on a browser download (which silently
|
||||
// fails on the mobile companion, the "paid but never unlocked" report).
|
||||
const ownedKeys = ref<Set<string>>(new Set())
|
||||
function ownKey(onion: string, id: string): string { return `${onion}::${id}` }
|
||||
function isOwned(item: CatalogItem): boolean {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
return !!onion && ownedKeys.value.has(ownKey(onion, item.id))
|
||||
}
|
||||
async function loadOwned() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: { onion: string; content_id: string }[] }>({ method: 'content.owned-list', timeout: 10000 })
|
||||
const set = new Set<string>()
|
||||
for (const it of res?.items ?? []) set.add(ownKey(it.onion, it.content_id))
|
||||
ownedKeys.value = set
|
||||
} catch { /* keep last-known owned set on a transient failure */ }
|
||||
}
|
||||
|
||||
function base64ToBlob(base64: string, mime: string): Blob {
|
||||
return new Blob([Uint8Array.from(atob(base64), c => c.charCodeAt(0))], { type: mime })
|
||||
}
|
||||
|
||||
// In-app viewer for owned content (image / video / audio), served from cache.
|
||||
const viewerItem = ref<CatalogItem | null>(null)
|
||||
const viewerUrl = ref<string | null>(null)
|
||||
const viewerMime = ref<string>('')
|
||||
async function viewOwned(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
playing.value = item.id
|
||||
purchaseError.value = null
|
||||
try {
|
||||
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
||||
const mime = res.mime_type || item.mime_type
|
||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||
viewerMime.value = mime
|
||||
viewerItem.value = item
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Could not open your purchased file'
|
||||
} finally {
|
||||
playing.value = null
|
||||
}
|
||||
}
|
||||
function closeViewer() {
|
||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||
viewerUrl.value = null
|
||||
viewerItem.value = null
|
||||
viewerMime.value = ''
|
||||
}
|
||||
// Save the owned file to disk from cache (the optional "downloadable" path).
|
||||
async function saveOwned(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (res?.data) triggerDownload(res.data, item)
|
||||
else purchaseError.value = res?.error || 'Could not save your purchased file'
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Could not save your purchased file'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Payment picker (#46) ────────────────────────────────────────────────
|
||||
// When buying a paid item the user chooses how to pay: their local ecash
|
||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||
@ -603,7 +764,7 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// Continue with just the onion address
|
||||
}
|
||||
await loadCatalog()
|
||||
await Promise.all([loadCatalog(), loadOwned()])
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
@ -737,6 +898,12 @@ async function downloadFile(item: CatalogItem) {
|
||||
if (!onion) return
|
||||
purchaseError.value = null
|
||||
|
||||
// Already purchased: save from the local cache, no payment.
|
||||
if (isOwned(item)) {
|
||||
await saveOwned(item)
|
||||
return
|
||||
}
|
||||
|
||||
const price = getItemPrice(item.access)
|
||||
if (price > 0) {
|
||||
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
|
||||
@ -1020,14 +1187,25 @@ async function confirmEcashPay() {
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
try {
|
||||
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string }>({
|
||||
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price, method },
|
||||
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (result?.data) {
|
||||
triggerDownload(result.data, item)
|
||||
// The purchase is now cached + owned by this node (backend persisted it).
|
||||
// Mark it owned and open the in-app viewer rather than firing a browser
|
||||
// download — the latter silently fails on the mobile companion, which is
|
||||
// why a paid item used to never "unlock". The item stays unlocked going
|
||||
// forward; the viewer offers a Save button for an explicit download.
|
||||
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||
const mime = result.mime_type || item.mime_type
|
||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
|
||||
viewerMime.value = mime
|
||||
viewerItem.value = item
|
||||
closePayModal()
|
||||
void loadOwned()
|
||||
} else if (result?.error) {
|
||||
// Keep the confirm screen open so the user can switch backend and retry.
|
||||
purchaseError.value = result.error
|
||||
|
||||
@ -329,7 +329,7 @@ async function loadEcashBalance() {
|
||||
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
|
||||
ecashBalance.value = res.balance_sats ?? 0
|
||||
} catch {
|
||||
ecashBalance.value = 0
|
||||
// Keep last-known balance on a transient failure rather than flashing 0.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user