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
parent b0c9bd2a0c
commit db7d424bff
12 changed files with 544 additions and 26 deletions

View File

@ -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

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."
})),
}
}
}

View File

@ -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" => {

View 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))
}

View File

@ -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.

View File

@ -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;

View File

@ -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, &reg).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) => {

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.
}
}