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