fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab
Demo images / Build & push demo images (push) Failing after 32s
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:
co-authored by
Claude Fable 5
parent
df9b9905b6
commit
f339358109
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user