fix(content): owner never pays for their own files; purchased serves from cache
- serve_content takes owner_session: a validated operator session skips the availability/paid gates (Availability::Nobody stays delisted); the cookie is re-validated in the content handler, same discipline as the model proxy - the Tor proxy serves already-purchased items from the local content_owned cache with Range slicing (206) instead of re-hitting the seller's 402 — the buyer-side store exists so an owned item is never bought twice, and its cards were rendering as permanent placeholders - adapter: 'own'-scope items never render locked (a locked card suppresses the playable URL — the placeholder-only grid the operator reported) - broker: normalize 'purchased' OwnedRpcItems per item with the seller's onion, and group 'peers' items per seller onion, so buildMediaUrl gets a peerOnion and card URLs stop coming out empty Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -250,6 +250,7 @@ pub async fn serve_content(
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
) -> Result<ServeResult> {
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user