Qualify durable purchase and media primitives and preserve app launch paths

This commit is contained in:
archipelago
2026-10-06 20:50:44 -04:00
parent a876dc3d0b
commit b52214f7a0
31 changed files with 4417 additions and 83 deletions
@@ -349,6 +349,18 @@ impl ApiHandler {
));
}
if let Err(error) =
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
{
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(serde_json::to_vec(
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
)?),
));
}
let memo = format!("Archipelago peer file {content_id}");
match self
.rpc_handler
@@ -468,6 +480,30 @@ impl ApiHandler {
}
};
// Match the node wallet's existing sendcoins minimum before exposing a
// payable address for an amount its own payment flow cannot broadcast.
if let Err(error) = content_server::validate_onchain_payment_price(price_sats) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(serde_json::to_vec(
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
)?),
));
}
if let Err(error) =
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
{
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(serde_json::to_vec(
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
)?),
));
}
match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending_method(
+7
View File
@@ -1,6 +1,7 @@
mod blob;
mod cdp;
mod content;
mod registered_media;
mod dwn;
mod model_proxy;
mod node_message;
@@ -584,6 +585,12 @@ impl ApiHandler {
Self::handle_blob_download(&self.blob_store, p, &query_string).await
}
// Immutable registered rentals use durable seller receipts and their
// first-open window, never legacy mutable filename shares.
(Method::GET, p) if p.starts_with("/content/registered_") && p.contains("/rental/") => {
self.handle_registered_rental(p, &headers).await
}
// Content preview — degraded previews for paid content (no auth, no payment)
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
Self::handle_content_preview(p, &self.config).await
@@ -0,0 +1,310 @@
//! Authenticated immutable rental streaming, separate from legacy mutable shares.
use super::{build_response, ApiHandler};
use crate::{content_server::ByteRange, identity::NodeIdentity, registered_media::OpenedMedia};
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Response, StatusCode};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
fn route(path: &str) -> Result<(&str, &str)> {
let (content, purchase) = path
.strip_prefix("/content/")
.and_then(|value| value.split_once("/rental/"))
.context("Invalid rental route")?;
anyhow::ensure!(
content.starts_with("registered_") && !content.contains('/') && !purchase.contains('/'),
"Invalid rental identifiers"
);
let id = uuid::Uuid::parse_str(purchase)?;
anyhow::ensure!(
id.to_string() == purchase && id.get_version_num() == 4,
"Invalid purchase identifier"
);
Ok((content, purchase))
}
fn bounds(range: Option<ByteRange>, total: u64) -> Result<Option<(u64, u64)>> {
let Some(range) = range else {
anyhow::ensure!(total > 0, "Registered media is empty");
return Ok(None);
};
let last = total.checked_sub(1).context("Registered media is empty")?;
let (start, end) = match range {
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
ByteRange::Suffix(count) => {
anyhow::ensure!(count > 0, "Invalid suffix range");
(total.saturating_sub(count), last)
}
};
anyhow::ensure!(start <= end && start < total, "Invalid rental byte range");
Ok(Some((start, end)))
}
fn clock() -> u64 {
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
}
fn denied(message: &'static str) -> Response<Body> {
build_response(StatusCode::FORBIDDEN, "text/plain", Body::from(message))
}
impl ApiHandler {
pub(super) async fn handle_registered_rental(
&self,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
let (content, purchase) = match route(path) {
Ok(ids) => ids,
Err(_) => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from("Invalid rental route"),
))
}
};
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = match crate::content_auth::incoming(
headers,
&audience,
path,
chrono::Utc::now().timestamp(),
) {
Ok(Some(buyer)) => buyer,
_ => return Ok(denied("Authenticated node proof is required")),
};
let capability = match headers
.get("x-content-capability")
.and_then(|v| v.to_str().ok())
{
Some(value) if value.len() == 64 => value.to_owned(),
_ => return Ok(denied("Original purchase capability is required")),
};
let requested_range = match headers.get("range") {
None => None,
Some(value) => match value
.to_str()
.ok()
.and_then(crate::content_server::parse_range_header)
{
Some(range) => Some(range),
None => {
return Ok(build_response(
StatusCode::RANGE_NOT_SATISFIABLE,
"text/plain",
Body::from("Invalid byte range"),
))
}
},
};
let identity =
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let data = self.config.data_dir.clone();
let selected = content.to_owned();
let key = identity.clone();
let metadata = tokio::task::spawn_blocking(move || {
crate::registered_media::registered_terms(&data, &key, &selected)
})
.await?;
let (receipt, _) = match metadata {
Ok(value) => value,
Err(_) => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
Body::from("Registered content is unavailable"),
))
}
};
let total = receipt.size_bytes.parse::<u64>()?;
let range = match bounds(requested_range, total) {
Ok(value) => value,
Err(_) => {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(Body::from("Invalid byte range"))?)
}
};
// All malformed/out-of-bounds requests are rejected before first-open
// rental creation. No payment or new receipt is attempted by this route.
let opened = match crate::registered_media::open_paid(
self.config.data_dir.clone(),
identity,
content.into(),
purchase.into(),
buyer,
capability,
)
.await
{
Ok(opened) => opened,
Err(_) => {
return Ok(denied(
"This purchase is not settled, does not match, or its rental has expired",
))
}
};
rental_response(opened, range, Arc::new(clock)).await
}
}
async fn rental_response(
opened: OpenedMedia,
range: Option<(u64, u64)>,
now: Arc<dyn Fn() -> u64 + Send + Sync>,
) -> Result<Response<Body>> {
anyhow::ensure!(
opened.still_authorized(now()),
"Rental expired before streaming"
);
let total = opened.size_bytes;
let started = opened.started_at;
let expires = opened.expires_at;
let (start, length) = range.map_or((0, total), |(start, end)| (start, end - start + 1));
let mut file = tokio::fs::File::from_std(opened.file);
file.seek(std::io::SeekFrom::Start(start)).await?;
let chunks = futures_util::stream::try_unfold(
(file, length, now),
move |(mut file, left, now)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let instant = now();
if instant < started || instant >= expires {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Rental window ended",
));
}
let mut bytes = vec![0; left.min(64 * 1024) as usize];
let count = tokio::time::timeout(
std::time::Duration::from_secs(expires - instant),
file.read(&mut bytes),
)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended")
})??;
let instant = now();
if instant < started || instant >= expires {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Rental window ended",
));
}
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Registered snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64, now))))
},
);
let mut response = Response::builder()
.status(if range.is_some() {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header("Content-Type", opened.mime_type)
.header("Content-Length", length)
.header("Accept-Ranges", "bytes")
.header("X-Content-Type-Options", "nosniff")
.header("Cache-Control", "private, no-store")
.header("X-Rental-Expires-At", expires);
if let Some((start, end)) = range {
response = response.header("Content-Range", format!("bytes {start}-{end}/{total}"));
}
Ok(response.body(Body::wrap_stream(chunks))?)
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::body::HttpBody;
use std::sync::atomic::{AtomicU64, Ordering};
#[test]
fn invalid_routes_and_ranges_cannot_reach_rental_creation() {
let id = uuid::Uuid::new_v4();
assert!(route(&format!("/content/registered_{id}/rental/{id}")).is_ok());
for path in [
format!("/content/registered_{id}/rental/{id}/extra"),
format!("/content/../rental/{id}"),
format!("/content/registered_{id}/rental/not-a-purchase"),
] {
assert!(route(&path).is_err());
}
assert!(bounds(
Some(ByteRange::From {
start: 20,
end: None
}),
20
)
.is_err());
assert!(bounds(
Some(ByteRange::From {
start: 9,
end: Some(8)
}),
20
)
.is_err());
assert_eq!(
bounds(Some(ByteRange::Suffix(5)), 20).unwrap(),
Some((15, 19))
);
}
fn opened(size: u64) -> OpenedMedia {
let file = tempfile::tempfile().unwrap();
file.set_len(size).unwrap();
OpenedMedia {
file,
size_bytes: size,
mime_type: "video/mp4".into(),
started_at: 1000,
expires_at: 1060,
}
}
#[tokio::test]
async fn bounded_stream_stops_at_persisted_deadline_without_restarting_window() {
let clock = Arc::new(AtomicU64::new(1000));
let read_clock = clock.clone();
let mut response = rental_response(
opened(200_000),
None,
Arc::new(move || read_clock.load(Ordering::SeqCst)),
)
.await
.unwrap();
assert_eq!(response.headers()["x-rental-expires-at"], "1060");
assert_eq!(
response.body_mut().data().await.unwrap().unwrap().len(),
64 * 1024
);
clock.store(1060, Ordering::SeqCst);
assert!(response.body_mut().data().await.unwrap().is_err());
}
#[tokio::test]
async fn suffix_response_has_exact_length_and_expired_or_rollback_stream_denies() {
let mut response = rental_response(opened(20), Some((15, 19)), Arc::new(|| 1000))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.headers()["content-range"], "bytes 15-19/20");
assert_eq!(
hyper::body::to_bytes(response.body_mut())
.await
.unwrap()
.len(),
5
);
assert!(rental_response(opened(20), None, Arc::new(|| 1060))
.await
.is_err());
assert!(rental_response(opened(20), None, Arc::new(|| 999))
.await
.is_err());
}
}
+66 -6
View File
@@ -896,6 +896,62 @@ impl RpcHandler {
Ok(result)
}
/// Owner-authenticated local recovery lookup. No mint, peer request or
/// wallet mutation occurs here, and private tokens/capabilities are omitted.
pub(super) async fn handle_content_payment_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.context("Missing payment lookup parameters")?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.context("Missing seller address")?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.context("Missing content identifier")?;
anyhow::ensure!(is_valid_v3_onion(onion), "Invalid seller address");
crate::content_owned::validate_identity(onion, content_id)?;
let peer =
crate::federation::load_unique_payment_peer(&self.config.data_dir, onion).await?;
let (data, _) = self.state_manager.get_snapshot().await;
let buyer_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let journal = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
let records = if let Some(id) = params.get("operation_id") {
let id = id
.as_str()
.context("Invalid purchase operation identifier")?;
match journal.buyer(id).await? {
Some(record) => {
anyhow::ensure!(
record.contract.buyer_did == buyer_did
&& record.contract.seller_did == peer.did
&& record.contract.content_id == content_id,
"Purchase belongs to another buyer, seller or content item"
);
vec![record]
}
None => Vec::new(),
}
} else {
journal
.find_buyers(&buyer_did, &peer.did, content_id)
.await?
};
let attempts: Vec<_> = records
.iter()
.map(|record| record.public_status())
.collect();
Ok(serde_json::json!({
"state": if attempts.is_empty() { "unknown" } else { "recorded" },
"attempts": attempts,
// Absence is not evidence that a legacy payment failed/reclaimed.
"can_start_new_payment": false,
"legacy_recovery_unresolved": attempts.is_empty(),
}))
}
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
/// paid item so the buyer can pay from any external wallet. Returns the
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
@@ -943,9 +999,11 @@ impl RpcHandler {
};
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Seller could not create an invoice ({}).", response.status())
}));
let status = response.status();
let body = bounded_seller_error(response).await;
return Ok(
serde_json::json!({ "error": seller_error_message(status, &body), "payment_started": false }),
);
}
let body: serde_json::Value = response
.json()
@@ -1196,9 +1254,11 @@ impl RpcHandler {
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Seller could not provide an address ({}).", response.status())
}));
let status = response.status();
let body = bounded_seller_error(response).await;
return Ok(
serde_json::json!({ "error": seller_error_message(status, &body), "payment_started": false }),
);
}
let body: serde_json::Value = response
.json()
@@ -332,6 +332,7 @@ impl RpcHandler {
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.payment-status" => self.handle_content_payment_status(params).await,
"content.owned-list" => self.handle_content_owned_list().await,
"content.owned-get" => self.handle_content_owned_get(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await,