fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab
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:
archipelago
2026-07-22 21:10:48 -04:00
co-authored by Claude Fable 5
parent df9b9905b6
commit f339358109
4 changed files with 194 additions and 5 deletions
+16 -3
View File
@@ -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
+63 -1
View File
@@ -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)