fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab
Some checks failed
Demo images / Build & push demo images (push) Failing after 32s
Some checks failed
Demo images / Build & push demo images (push) Failing after 32s
The double-share/double-pay chain (user hit it live, 2026-07-22): ShareModal's already-shared lookup compared the slash-stripped stored path against the leading-slash filepath — never matched — so every re-share minted a NEW catalog entry with a new id, and the buyer's owned-guard (keyed by id) saw the duplicate as unowned and paid again. Three independent walls now: (1) ShareModal normalizes both sides so re-shares reuse the entry; (2) content_server::add_item dedupes by filename server-side (updates in place, keeps the id so buyers' owned records stay valid); (3) the buyer REFUSES to pay for content it already owns — matched by (onion, content_id) OR (onion, filename) — and serves the cached copy instead, before any ecash is minted. Purchases also auto-file into Photos/Music/Documents on the node (same buckets as the Cloud view, collision-safe naming) so bought files show up where files live on every device, and Cloud gains a Paid Files tab listing every purchase (name, size, sats paid, date) with in-app view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
df9b9905b6
commit
f339358109
@ -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);
|
||||
|
||||
|
||||
@ -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<ContentCatalog> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -145,6 +145,19 @@ const errorMsg = ref<string | null>(null)
|
||||
const successMsg = ref<string | null>(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
|
||||
|
||||
@ -148,6 +148,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Paid Files tab — everything this node has purchased ═════════════
|
||||
Source of truth is the purchase cache (content.owned-list): filename,
|
||||
type, price paid and when — the file itself was also auto-filed into
|
||||
Photos/Music/Documents at purchase time (2026-07-22). -->
|
||||
<div v-else-if="activeTab === 'paid'">
|
||||
<div v-if="paidLoading" class="glass-card p-8 text-center text-white/50 text-sm">Loading purchases…</div>
|
||||
<div v-else-if="paidItems.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
Nothing purchased yet — files you buy from peers appear here and are saved into your folders automatically.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="it in paidItems"
|
||||
:key="it.onion + it.content_id"
|
||||
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
@click="viewPaidItem(it)"
|
||||
>
|
||||
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-white/90 truncate">{{ it.filename.split('/').pop() }}</p>
|
||||
<p class="text-[11px] text-white/40">
|
||||
{{ (it.size_bytes / 1024).toFixed(0) }} KB ·
|
||||
<span class="text-orange-300/80">{{ it.paid_sats.toLocaleString() }} sats</span>
|
||||
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
||||
<div v-else-if="activeTab === 'peers'">
|
||||
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
@ -374,13 +404,14 @@ const sectionCounts = ref<Record<string, number>>({})
|
||||
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<TabId>('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<PaidItem[]>([])
|
||||
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<CategoryId>('all')
|
||||
const searchQuery = ref('')
|
||||
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user