Integrate recoverable native purchases, registered rentals and explicit payment consent
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
//! Permanent purchase caching. Hash verification occurs inside the stream before
|
||||
//! owned-cache publication; receipt remains recoverable after any interruption.
|
||||
use crate::content_purchase::{Contract, Journal, Receipt};
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::StreamExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) async fn cache(
|
||||
data_dir: &Path,
|
||||
onion: &str,
|
||||
contract: &Contract,
|
||||
receipt: &Receipt,
|
||||
) -> Result<crate::content_owned::OwnedItem> {
|
||||
anyhow::ensure!(
|
||||
!contract.content_id.starts_with("registered_"),
|
||||
"Rentals use the scoped playback proxy, not permanent caching"
|
||||
);
|
||||
let envelope = {
|
||||
let journal = Journal::open(data_dir).await?;
|
||||
let buyer = journal
|
||||
.buyer(&contract.id)
|
||||
.await?
|
||||
.context("Buyer purchase is missing")?;
|
||||
anyhow::ensure!(
|
||||
buyer.contract == *contract && buyer.receipt() == Some(receipt),
|
||||
"Original buyer receipt changed"
|
||||
);
|
||||
journal
|
||||
.protocol_envelope("buyer", &contract.id)
|
||||
.await?
|
||||
.context("Original delivery metadata is missing")?
|
||||
};
|
||||
let peer = crate::federation::load_unique_payment_peer(data_dir, onion).await?;
|
||||
anyhow::ensure!(peer.did == contract.seller_did, "Delivery seller changed");
|
||||
let path = format!("/content/{}/purchase/{}", contract.content_id, contract.id);
|
||||
let (response, _) =
|
||||
crate::fips::dial::PeerRequest::new(peer.fips_npub.as_deref(), onion, &path)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.header("X-Content-Capability", receipt.capability.clone())
|
||||
.send_content_get(data_dir)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
response.status() == reqwest::StatusCode::OK,
|
||||
"Original purchase delivery is unavailable; no new payment sent"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
response.content_length() == Some(contract.content_size),
|
||||
"Original snapshot length changed"
|
||||
);
|
||||
let stream = verified_stream(
|
||||
response.bytes_stream(),
|
||||
contract.content_sha256.clone(),
|
||||
contract.content_size,
|
||||
);
|
||||
let owned = crate::content_owned::record_purchase_stream(
|
||||
data_dir,
|
||||
crate::content_owned::OwnedItem {
|
||||
onion: onion.into(),
|
||||
content_id: contract.content_id.clone(),
|
||||
filename: envelope.offer.filename,
|
||||
mime_type: envelope.offer.mime_type,
|
||||
size_bytes: contract.content_size,
|
||||
paid_sats: contract.gross_token_sats,
|
||||
ecash_backend: "cashu".into(),
|
||||
purchased_at: chrono::Utc::now().to_rfc3339(),
|
||||
download_complete: false,
|
||||
},
|
||||
Box::pin(stream),
|
||||
Some(contract.content_size),
|
||||
)
|
||||
.await?;
|
||||
Journal::open(data_dir)
|
||||
.await?
|
||||
.record_delivery(contract, &contract.content_sha256, contract.content_size)
|
||||
.await?;
|
||||
Ok(owned)
|
||||
}
|
||||
|
||||
fn verified_stream<S, E>(
|
||||
stream: S,
|
||||
expected_hash: String,
|
||||
expected_size: u64,
|
||||
) -> impl futures_util::Stream<Item = std::io::Result<bytes::Bytes>>
|
||||
where
|
||||
S: futures_util::Stream<Item = Result<bytes::Bytes, E>>,
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
futures_util::stream::try_unfold(
|
||||
(Box::pin(stream), Sha256::new(), 0u64),
|
||||
move |(mut stream, mut hash, total)| {
|
||||
let expected_hash = expected_hash.clone();
|
||||
async move {
|
||||
match stream.next().await {
|
||||
Some(chunk) => {
|
||||
let chunk = chunk.map_err(std::io::Error::other)?;
|
||||
let total = total
|
||||
.checked_add(chunk.len() as u64)
|
||||
.filter(|n| *n <= expected_size)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::other("Snapshot exceeded accepted size")
|
||||
})?;
|
||||
hash.update(&chunk);
|
||||
Ok(Some((chunk, (stream, hash, total))))
|
||||
}
|
||||
None => {
|
||||
if total != expected_size || hex::encode(hash.finalize()) != expected_hash {
|
||||
return Err(std::io::Error::other(
|
||||
"Snapshot did not match accepted hash/size",
|
||||
));
|
||||
}
|
||||
Ok::<_, std::io::Error>(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content_owned::{self, OwnedItem};
|
||||
fn item() -> OwnedItem {
|
||||
OwnedItem {
|
||||
onion: "seller.onion".into(),
|
||||
content_id: "video".into(),
|
||||
filename: "clip.mp4".into(),
|
||||
mime_type: "video/mp4".into(),
|
||||
size_bytes: 4,
|
||||
paid_sats: 8,
|
||||
ecash_backend: "cashu".into(),
|
||||
purchased_at: "now".into(),
|
||||
download_complete: false,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupted_truncated_and_excess_bytes_remain_recoverable_until_original_hash_arrives() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let hash = hex::encode(Sha256::digest(b"good"));
|
||||
for bytes in [b"evil".as_slice(), b"goo".as_slice(), b"good!".as_slice()] {
|
||||
let input = futures_util::stream::iter([Ok::<_, std::io::Error>(
|
||||
bytes::Bytes::copy_from_slice(bytes),
|
||||
)]);
|
||||
let stream = Box::pin(verified_stream(input, hash.clone(), 4));
|
||||
assert!(
|
||||
content_owned::record_purchase_stream(root.path(), item(), stream, Some(4))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let entries = content_owned::list_owned_checked(root.path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(!entries[0].download_complete);
|
||||
assert_eq!(entries[0].paid_sats, 8);
|
||||
let error = content_owned::open_owned(root.path(), "seller.onion", "video")
|
||||
.await
|
||||
.err()
|
||||
.expect("Incomplete paid bytes must not be served");
|
||||
assert!(
|
||||
error.to_string().contains("delivery is incomplete"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
let input = futures_util::stream::iter([
|
||||
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"go")),
|
||||
Ok(bytes::Bytes::from_static(b"od")),
|
||||
]);
|
||||
let stream = Box::pin(verified_stream(input, hash, 4));
|
||||
let completed = content_owned::record_purchase_stream(root.path(), item(), stream, Some(4))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(completed.download_complete);
|
||||
let (_, mut file) = content_owned::open_owned(root.path(), "seller.onion", "video")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut file, &mut bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bytes, b"good");
|
||||
assert_eq!(
|
||||
content_owned::list_owned_checked(root.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user