diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 37366154..6d647955 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -412,6 +412,55 @@ impl RpcHandler { return Err(anyhow::anyhow!("Invalid v3 onion address")); } + // NEVER pay twice for content we already own (2026-07-22: a file + // shared twice produced two catalog ids for the same bytes and the + // buyer paid both). Guard BEFORE any ecash is minted, matching both + // by exact (onion, content_id) and by (onion, filename) — the latter + // catches duplicate ids pointing at the same file on the same + // seller. The owned copy is served from the local cache instead. + { + let filename = params.get("filename").and_then(|v| v.as_str()); + let owned = crate::content_owned::list_owned(&self.config.data_dir).await; + let already = owned.iter().find(|o| { + o.onion == onion + && (o.content_id == content_id + || filename.is_some_and(|f| { + !f.is_empty() + && o.filename.trim_start_matches('/') + == f.trim_start_matches('/') + })) + }); + if let Some(o) = already { + tracing::info!( + onion, + content_id, + owned_as = %o.content_id, + "paid download: already owned — serving cached copy, NOT paying again" + ); + if let Some((mime, bytes)) = crate::content_owned::read_owned( + &self.config.data_dir, + &o.onion, + &o.content_id, + ) + .await + { + use base64::Engine; + return Ok(serde_json::json!({ + "owned": true, + "already_owned": true, + "filename": o.filename, + "mime_type": mime, + "size_bytes": bytes.len(), + "paid_sats": 0, + "data_base64": + base64::engine::general_purpose::STANDARD.encode(&bytes), + })); + } + // Cache record exists but bytes are gone — fall through and + // repurchase rather than stranding the user. + } + } + // `method` pins the backend the user confirmed in the UI ("cashu" | // "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's // verify_payment_token accepts either, so a node whose balance lives in @@ -590,6 +639,54 @@ impl RpcHandler { tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}"); } + // Auto-file the purchase into the user's Files area (2026-07-22): + // Photos for images/video, Music for audio, Documents otherwise — + // same buckets the Cloud view uses. The in-app viewer still plays + // from the purchase cache; this makes the file ALSO show up where + // files live, on every device, without relying on a browser + // download. Best-effort: never fail a paid download over it. + { + let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") { + "Photos" + } else if mime_type.starts_with("audio/") { + "Music" + } else { + "Documents" + }; + let base = std::path::Path::new(&filename) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("download") + .to_string(); + let dir = self.config.data_dir.join("filebrowser").join(folder); + if let Err(e) = tokio::fs::create_dir_all(&dir).await { + tracing::warn!("paid download: cannot create {}: {e}", dir.display()); + } else { + // Don't clobber an existing file of the same name: "x.jpg" + // → "x (2).jpg" etc. + let mut target = dir.join(&base); + let (stem, ext) = match base.rsplit_once('.') { + Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), + _ => (base.clone(), String::new()), + }; + let mut n = 2; + while target.exists() { + target = dir.join(format!("{stem} ({n}){ext}")); + n += 1; + } + match tokio::fs::write(&target, &bytes).await { + Ok(()) => tracing::info!( + "paid download: filed into {}", + target.display() + ), + Err(e) => tracing::warn!( + "paid download: filing into {} failed (non-fatal): {e}", + target.display() + ), + } + } + } + use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 07f81568..3265b2e9 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -126,12 +126,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf { } /// Add a content item to the catalog. +/// +/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on +/// every call, so id-only dedup let the same file be shared twice as two +/// separately-priced entries — and a buyer paid twice for one file +/// (2026-07-22). Same filename → update the existing entry in place and +/// keep its id, so existing buyers' owned records stay valid. pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result { let mut catalog = load_catalog(data_dir).await?; if catalog.items.iter().any(|i| i.id == item.id) { return Err(anyhow::anyhow!("Content item '{}' already exists", item.id)); } - catalog.items.push(item); + let norm = |f: &str| f.trim_start_matches('/').to_string(); + if let Some(existing) = catalog + .items + .iter_mut() + .find(|i| norm(&i.filename) == norm(&item.filename)) + { + let keep_id = existing.id.clone(); + *existing = item; + existing.id = keep_id; + } else { + catalog.items.push(item); + } save_catalog(data_dir, &catalog).await?; Ok(catalog) } diff --git a/neode-ui/src/components/cloud/ShareModal.vue b/neode-ui/src/components/cloud/ShareModal.vue index 77c61519..5af2e80f 100644 --- a/neode-ui/src/components/cloud/ShareModal.vue +++ b/neode-ui/src/components/cloud/ShareModal.vue @@ -145,6 +145,19 @@ const errorMsg = ref(null) const successMsg = ref(null) // If we have an existing item, load its state + +/** Catalog entries store the slash-stripped path; props carry a leading + * slash (filepath) or just the basename (filename). Normalize both sides — + * the old exact compare never matched, so every re-share created a brand + * new priced entry and buyers could pay twice for one file (2026-07-22). */ +function matchesThisFile(catalogFilename: string): boolean { + const strip = (v: string) => v.replace(/^\/+/, '') + return ( + strip(catalogFilename) === strip(props.filepath || '') || + strip(catalogFilename) === strip(props.filename || '') + ) +} + onMounted(async () => { try { const res = await rpcClient.call<{ items: Array<{ @@ -154,7 +167,7 @@ onMounted(async () => { availability: string | { allpeers?: unknown; nobody?: unknown } }> }>({ method: 'content.list-mine' }) const match = res.items.find( - (i) => i.filename === props.filename || i.filename === props.filepath + (i) => matchesThisFile(i.filename) ) if (match) { shared.value = true @@ -188,7 +201,7 @@ async function save() { method: 'content.list-mine', }) const match = res.items.find( - (i) => i.filename === props.filename || i.filename === props.filepath + (i) => matchesThisFile(i.filename) ) if (match) { await rpcClient.call({ method: 'content.remove', params: { id: match.id } }) @@ -200,7 +213,7 @@ async function save() { method: 'content.list-mine', }) let itemId = res.items.find( - (i) => i.filename === props.filename || i.filename === props.filepath + (i) => matchesThisFile(i.filename) )?.id // Add if not in catalog diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index c5bbadc8..090e68ba 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -148,6 +148,36 @@ + +
+
Loading purchases…
+
+ Nothing purchased yet — files you buy from peers appear here and are saved into your folders automatically. +
+
+
+ {{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }} +
+

{{ it.filename.split('/').pop() }}

+

+ {{ (it.size_bytes / 1024).toFixed(0) }} KB · + {{ it.paid_sats.toLocaleString() }} sats + · {{ new Date(it.purchased_at).toLocaleDateString() }} +

+
+ Paid +
+
+
+
@@ -374,13 +404,14 @@ const sectionCounts = ref>({}) const countsLoading = ref(false) // ── Tabs / categories / search state ──────────────────────────────────────── -type TabId = 'folders' | 'mine' | 'peers' +type TabId = 'folders' | 'mine' | 'peers' | 'paid' type CategoryId = 'all' | 'photos' | 'music' | 'documents' const TABS: Array<{ id: TabId; name: string }> = [ { id: 'folders', name: 'Folders' }, { id: 'mine', name: 'My Files' }, { id: 'peers', name: 'Peer Files' }, + { id: 'paid', name: 'Paid Files' }, ] const CATEGORIES: Array<{ id: CategoryId; name: string }> = [ { id: 'all', name: 'All' }, @@ -390,6 +421,37 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [ ] const activeTab = ref('folders') + +// ── Paid Files tab ────────────────────────────────────────────────────────── +interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string } +const paidItems = ref([]) +const paidLoading = ref(false) +async function loadPaidItems() { + paidLoading.value = true + try { + const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' }) + paidItems.value = (res.items || []).slice().reverse() + } catch { paidItems.value = [] } finally { paidLoading.value = false } +} +async function viewPaidItem(it: PaidItem) { + try { + const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({ + method: 'content.owned-get', + params: { onion: it.onion, content_id: it.content_id }, + timeout: 60000, + }) + const b64 = res.data_base64 || res.data + if (!b64) return + const bin = atob(b64) + const arr = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) + const url = URL.createObjectURL(new Blob([arr], { type: res.mime_type || it.mime_type })) + window.open(url, '_blank', 'noopener') + setTimeout(() => URL.revokeObjectURL(url), 60000) + } catch { /* viewer is best-effort; the file is also in the user's folders */ } +} +watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() }) + const selectedCategory = ref('all') const searchQuery = ref('') const searchActive = computed(() => searchQuery.value.trim().length > 0)