diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index 65a2862f..e1fb9ade 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -47,6 +47,7 @@ impl ApiHandler { } pub(super) async fn handle_content_request( + &self, path: &str, headers: &hyper::HeaderMap, config: &Config, @@ -87,6 +88,16 @@ impl ApiHandler { .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // The authenticated local operator never pays for their own node's + // content: validate the session cookie (same discipline as the model + // proxy — re-derived here, never trusted to the front door) and hand + // serve_content the owner bypass. No cookie / bad session is simply + // the buyer path, unchanged. + let owner_session = match crate::session::extract_session_cookie(headers) { + Some(token) => self.session_store.validate(&token).await, + None => false, + }; + // Parse Range header for streaming support let range = headers .get("range") @@ -100,6 +111,7 @@ impl ApiHandler { invoice_hash.as_deref(), peer_did.as_deref(), range, + owner_session, ) .await { diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 48e25788..c16a323a 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -568,9 +568,10 @@ impl ApiHandler { self.handle_content_onchain(p).await } - // Content serving — peers access shared content over Tor (no session auth) + // Content serving — peers access shared content over Tor (no session auth); + // a valid operator session cookie unlocks the owner path inside. (Method::GET, p) if p.starts_with("/content/") => { - Self::handle_content_request(p, &headers, &self.config).await + self.handle_content_request(p, &headers, &self.config).await } // Content catalog — list available content (no session auth, for peers) diff --git a/core/archipelago/src/api/handler/proxy.rs b/core/archipelago/src/api/handler/proxy.rs index 466d9af7..dcaae1ad 100644 --- a/core/archipelago/src/api/handler/proxy.rs +++ b/core/archipelago/src/api/handler/proxy.rs @@ -225,6 +225,53 @@ impl ApiHandler { return bad("invalid onion or content id"); } + // Already purchased? Serve the local cache — no network, no + // re-payment. The seller's node charges every fetch by design; the + // buyer-side store (content_owned) exists precisely so an owned item + // never has to be bought twice, and the content surface's cards were + // hitting the seller's 402 and rendering as permanent placeholders. + // Range is honoured by slicing, so seek/playback works from cache. + if crate::content_owned::is_owned(&self.config.data_dir, onion, content_id).await { + if let Some((mime_type, bytes)) = + crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await + { + let total = bytes.len(); + let range = headers + .get("range") + .and_then(|v| v.to_str().ok()) + .and_then(crate::content_server::parse_range_header); + if let Some(r) = range { + let start = (r.start as usize).min(total); + let end = r + .end + .map(|e| e as usize) + .unwrap_or(total.saturating_sub(1)) + .min(total.saturating_sub(1)); + if start <= end && total > 0 { + let slice = &bytes[start..=end]; + return Ok(Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header("Content-Type", mime_type) + .header("Content-Length", slice.len().to_string()) + .header("Content-Range", format!("bytes {}-{}/{}", start, end, total)) + .header("Accept-Ranges", "bytes") + .body(hyper::Body::from(slice.to_vec())) + .unwrap_or_else(|_| Response::new(hyper::Body::empty()))); + } + } + return Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", total.to_string()) + .header("Accept-Ranges", "bytes") + .body(hyper::Body::from(bytes)) + .unwrap_or_else(|_| Response::new(hyper::Body::empty()))); + } + // Indexed as owned but bytes missing — fall through to the peer + // rather than erroring: the seller can still serve it (for the + // price already paid, the operator can re-fetch and re-cache). + } + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let peer_path = format!("/content/{}", content_id); // Generous overall timeout: this endpoint serves both seek/Range diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 65cf5844..8779031e 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -250,6 +250,7 @@ pub async fn serve_content( invoice_hash: Option<&str>, peer_did: Option<&str>, range: Option, + owner_session: bool, ) -> Result { let catalog = load_catalog(data_dir).await?; let item = match catalog.items.iter().find(|i| i.id == id) { @@ -257,6 +258,16 @@ pub async fn serve_content( None => return Ok(ServeResult::NotFound), }; + // The authenticated local operator never pays for — and is never fenced + // out of — their own node's content. The paid/peers-only gates exist for + // buyers and peers on OTHER nodes; charging the owner for their own file + // made the owner's own dashboard render 402s and lock overlays on their + // own photos. `Availability::Nobody` still means delisted: not served + // even here. + if owner_session && matches!(item.availability, Availability::Nobody) { + return Ok(ServeResult::NotFound); + } + // Load known federation peers for access checks let is_known_peer = if peer_did.is_some() { let nodes = crate::federation::load_nodes(data_dir) @@ -268,23 +279,26 @@ pub async fn serve_content( }; // Check availability - match &item.availability { - Availability::Nobody => return Ok(ServeResult::NotFound), - Availability::Specific { peers } => { - if let Some(did) = peer_did { - if !peers.iter().any(|p| p == did) { - debug!("Content '{}' not available to peer {}", id, did); + if !owner_session { + match &item.availability { + Availability::Nobody => return Ok(ServeResult::NotFound), + Availability::Specific { peers } => { + if let Some(did) = peer_did { + if !peers.iter().any(|p| p == did) { + debug!("Content '{}' not available to peer {}", id, did); + return Ok(ServeResult::Forbidden); + } + } else { return Ok(ServeResult::Forbidden); } - } else { - return Ok(ServeResult::Forbidden); } + Availability::AllPeers => {} } - Availability::AllPeers => {} } // Check access control - match &item.access { + if !owner_session { + match &item.access { AccessControl::Paid { price_sats, .. } => { // Two ways to satisfy payment: // (a) a valid ecash token (the local-wallet fast path), or @@ -319,6 +333,7 @@ pub async fn serve_content( } } AccessControl::Free => {} + } } let file_path = content_file_path(data_dir, item); @@ -651,7 +666,7 @@ mod prune_missing_content_tests { .unwrap(); // File was never written to disk under content/files/ or filebrowser/. - let result = serve_content(data_dir, "missing-item", None, None, None, None) + let result = serve_content(data_dir, "missing-item", None, None, None, None, false) .await .unwrap(); assert!(matches!(result, ServeResult::NotFound)); @@ -701,7 +716,7 @@ mod prune_missing_content_tests { .await .unwrap(); - let _ = serve_content(data_dir, "missing-item", None, None, None, None) + let _ = serve_content(data_dir, "missing-item", None, None, None, None, false) .await .unwrap(); diff --git a/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts b/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts index 8dc28291..90df0363 100644 --- a/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts +++ b/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts @@ -98,11 +98,21 @@ describe('adaptContentItems', () => { expect(bundle.images[0]!.url).toBe('/content/img-1') }) - it('locks a paid image: price carried, no URL to fetch bytes the user has not bought', () => { + it('never locks an OWN paid image: the node serves the authenticated owner (owner-bypass), price stays as a badge', () => { const bundle = adaptContentItems( [item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })], { source: 'own' }, ) + expect(bundle.images[0]!.locked).toBe(false) + expect(bundle.images[0]!.priceSats).toBe(100) + expect(bundle.images[0]!.url).toBe('/content/p-1') + }) + + it('locks a PEER paid image: price carried, no URL to fetch bytes the user has not bought', () => { + const bundle = adaptContentItems( + [item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })], + { source: 'peer', peerOnion: 'seller.onion' }, + ) expect(bundle.images[0]!.locked).toBe(true) expect(bundle.images[0]!.priceSats).toBe(100) expect(bundle.images[0]!.url).toBe('') diff --git a/neode-ui/src/composables/archyContentAdapter.ts b/neode-ui/src/composables/archyContentAdapter.ts index 6cbf956e..6c32f40f 100644 --- a/neode-ui/src/composables/archyContentAdapter.ts +++ b/neode-ui/src/composables/archyContentAdapter.ts @@ -314,7 +314,12 @@ function buildMediaUrl(item: ArchyContentItem, opts: AdaptContentOptions): strin export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions): Film { const priceSats = paidPriceSats(item.access) - const locked = priceSats !== null + // 'own' items are served to the authenticated owner by the node's + // owner-bypass (`serve_content`) even when they're listed paid for + // buyers — the operator never pays for their own files, so never lock + // them (a locked card suppresses the playable URL, which is exactly the + // placeholder-only grid the operator reported). + const locked = opts.source !== 'own' && priceSats !== null const sourceType = FILM_SOURCE_TYPE[opts.source] return { id: item.id, @@ -348,7 +353,12 @@ export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions): */ export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions): ImageItem { const priceSats = paidPriceSats(item.access) - const locked = priceSats !== null + // 'own' items are served to the authenticated owner by the node's + // owner-bypass (`serve_content`) even when they're listed paid for + // buyers — the operator never pays for their own files, so never lock + // them (a locked card suppresses the playable URL, which is exactly the + // placeholder-only grid the operator reported). + const locked = opts.source !== 'own' && priceSats !== null const title = stripExtension(item.filename || '') return { id: item.id, @@ -366,7 +376,12 @@ export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions): export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): Song { const priceSats = paidPriceSats(item.access) - const locked = priceSats !== null + // 'own' items are served to the authenticated owner by the node's + // owner-bypass (`serve_content`) even when they're listed paid for + // buyers — the operator never pays for their own files, so never lock + // them (a locked card suppresses the playable URL, which is exactly the + // placeholder-only grid the operator reported). + const locked = opts.source !== 'own' && priceSats !== null const sourceType = SONG_SOURCE_TYPE[opts.source] return { id: item.id, @@ -391,7 +406,12 @@ export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): * rather than a song. */ export function adaptToPodcast(item: ArchyContentItem, opts: AdaptContentOptions): Podcast { const priceSats = paidPriceSats(item.access) - const locked = priceSats !== null + // 'own' items are served to the authenticated owner by the node's + // owner-bypass (`serve_content`) even when they're listed paid for + // buyers — the operator never pays for their own files, so never lock + // them (a locked card suppresses the playable URL, which is exactly the + // placeholder-only grid the operator reported). + const locked = opts.source !== 'own' && priceSats !== null const sourceType = PODCAST_SOURCE_TYPE[opts.source] return { id: item.id, diff --git a/neode-ui/src/services/__tests__/contextBroker.test.ts b/neode-ui/src/services/__tests__/contextBroker.test.ts index 58a68721..3499d3bb 100644 --- a/neode-ui/src/services/__tests__/contextBroker.test.ts +++ b/neode-ui/src/services/__tests__/contextBroker.test.ts @@ -361,4 +361,81 @@ describe('ContextBroker', () => { ) }) }) + + describe('adaptChatSurfaces', () => { + const callAdapt = (surfaces: unknown) => + ( + broker as unknown as { + adaptChatSurfaces: (s?: unknown) => { tool: string; scope?: string; bundle: { films: unknown[]; songs: unknown[]; podcasts: unknown[]; images: { id: string; url: string; locked: boolean }[] } }[] | undefined + } + ).adaptChatSurfaces(surfaces) + + // Live bug (archi-dev-box 2026-08-07): a purchased-scope chat surface + // adapted the OwnedRpcItem wire shape as if it were ArchyContentItem — + // id came out undefined, peerOnion was never passed, every URL was '' + // — so three purchased images rendered as placeholders beside a correct + // prose answer. + it('purchased scope normalizes owned items and builds per-seller URLs', () => { + const perms = useAIPermissionsStore() + perms.enableAll() + const out = callAdapt([ + { + tool: 'content_list', + scope: 'purchased', + data: { + items: [ + { + onion: 'peer-one.onion', + content_id: 'cid-1', + filename: 'signal-test.jpeg', + mime_type: 'image/jpeg', + size_bytes: 170000, + paid_sats: 100, + purchased_at: '2026-06-20T00:00:00Z', + }, + { + onion: 'peer-two.onion', + content_id: 'cid-2', + filename: 'got it!.jpg', + mime_type: 'image/jpeg', + size_bytes: 253000, + paid_sats: 100, + purchased_at: '2026-08-04T00:00:00Z', + }, + ], + }, + }, + ]) + expect(out).toHaveLength(1) + const images = out![0]!.bundle.images + expect(images).toHaveLength(2) + // Real ids, real per-seller URLs, and already-paid items are unlocked. + expect(images.map((i) => i.id)).toEqual(expect.arrayContaining(['cid-1', 'cid-2'])) + expect(images[0]!.url).toBe('/api/peer-content/peer-one.onion/cid-1') + expect(images[1]!.url).toBe('/api/peer-content/peer-two.onion/cid-2') + expect(images.every((i) => !i.locked)).toBe(true) + }) + + it('peers scope adapts per-seller so every item URL carries its own onion', () => { + const perms = useAIPermissionsStore() + perms.enableAll() + const out = callAdapt([ + { + tool: 'content_list', + scope: 'peers', + data: { + items: [ + { id: 'x', filename: 'a.jpg', mime_type: 'image/jpeg', size_bytes: 1, peer: 'seller-a.onion' }, + { id: 'y', filename: 'b.jpg', mime_type: 'image/jpeg', size_bytes: 1, peer: 'seller-b.onion' }, + ], + }, + }, + ]) + const images = out![0]!.bundle.images + expect(images.map((i) => i.url).sort()).toEqual([ + '/api/peer-content/seller-a.onion/x', + '/api/peer-content/seller-b.onion/y', + ]) + }) + }) }) diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 41c340e2..f8b89ad6 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -307,13 +307,35 @@ export class ContextBroker { if (!items.length) return [] // The adapter uses `source` to decide the badge and how a playable // URL is built, so a wrong value renders a peer's paid item as - // freely local — map each scope to what it actually is. - const source = - s.scope === 'peers' || s.scope === 'purchased' - ? 'peer' - : s.scope === 'films' - ? 'indeehub' - : 'own' + // freely local — map each scope to what it actually is. Two scopes + // need more than a label: + // + // `purchased` arrives as OwnedRpcItem (content_id/onion/paid_sats), + // not ArchyContentItem — normalize per item AND carry the seller's + // onion, or the card adapts with id=undefined and url='' and renders + // as a permanent placeholder (the live "shows a placeholder" bug). + if (s.scope === 'purchased') { + const bundles = (items as unknown as OwnedRpcItem[]).map((owned) => + adaptContentItems([normalizeOwnedItem(owned)], { source: 'peer', peerOnion: owned.onion }), + ) + return [{ tool: s.tool, scope: s.scope, bundle: mergeBundles(bundles) }] + } + // `peers` items each carry their seller's onion (the node stamps + // `peer` per item in the fan-out) — adapt per-peer or every URL + // comes out '' (`buildMediaUrl` refuses peer URLs without one). + if (s.scope === 'peers') { + const byOnion = new Map() + for (const it of items) { + const onion = typeof (it as { peer?: unknown }).peer === 'string' ? (it as { peer: string }).peer : '' + if (!onion) continue + byOnion.set(onion, [...(byOnion.get(onion) ?? []), it]) + } + const bundles = [...byOnion.entries()].map(([onion, its]) => + adaptContentItems(its, { source: 'peer', peerOnion: onion }), + ) + return [{ tool: s.tool, scope: s.scope, bundle: mergeBundles(bundles) }] + } + const source = s.scope === 'films' ? 'indeehub' : 'own' return [{ tool: s.tool, scope: s.scope, bundle: adaptContentItems(items, { source }) }] }) return adapted.length ? adapted : undefined