Stream purchased files into durable cache and avoid duplicate concurrent payments

This commit is contained in:
archipelago
2026-10-06 05:48:05 -04:00
parent df7677d23f
commit 9ce04627dd
9 changed files with 709 additions and 178 deletions
+193 -110
View File
@@ -43,6 +43,22 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
} }
} }
async fn bounded_seller_error(mut response: reqwest::Response) -> String {
let mut bytes = Vec::new();
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while bytes.len() < 4096 {
match response.chunk().await {
Ok(Some(chunk)) => {
bytes.extend_from_slice(&chunk[..chunk.len().min(4096 - bytes.len())])
}
_ => break,
}
}
})
.await;
String::from_utf8_lossy(&bytes).into_owned()
}
/// Only pass through the peer's bounded, printable explanation; refund status /// Only pass through the peer's bounded, printable explanation; refund status
/// is always determined locally and must never come from the peer's wording. /// is always determined locally and must never come from the peer's wording.
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String { fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
@@ -80,6 +96,7 @@ async fn existing_paid_content(
onion: &str, onion: &str,
content_id: &str, content_id: &str,
filename: Option<&str>, filename: Option<&str>,
cache_only: bool,
) -> Result<Option<serde_json::Value>> { ) -> Result<Option<serde_json::Value>> {
let owned = crate::content_owned::list_owned_checked(data_dir) let owned = crate::content_owned::list_owned_checked(data_dir)
.await .await
@@ -93,35 +110,80 @@ async fn existing_paid_content(
}) else { }) else {
return Ok(None); return Ok(None);
}; };
let (mime, bytes) = crate::content_owned::read_owned(data_dir, &item.onion, &item.content_id) let mut response = cached_purchase_response(data_dir, &item.onion, &item.content_id, cache_only, 0).await
.await.context("This purchase is recorded, but its cached file is unavailable. No new payment was sent. Restore the cached file or contact the seller.")?; .context("This purchase is recorded, but its cached file is unavailable. No new payment was sent. Recover delivery without paying again.")?;
let mut response = paid_content_response(&bytes, &mime, 0);
response["already_owned"] = serde_json::json!(true); response["already_owned"] = serde_json::json!(true);
response["filename"] = serde_json::json!(item.filename); response["filename"] = serde_json::json!(item.filename);
Ok(Some(response)) Ok(Some(response))
} }
// Updated clients open the persisted file through the Range-capable HTTP async fn cached_purchase_response(
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply. data_dir: &std::path::Path,
// Keep older clients compatible until both sides have upgraded. onion: &str,
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value { content_id: &str,
if cache_only { cache_only: bool,
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() }) paid_sats: u64,
} else { ) -> Result<serde_json::Value> {
paid_content_response(bytes, mime, 0) use tokio::io::AsyncReadExt;
let (mime, file) = crate::content_owned::open_owned(data_dir, onion, content_id)
.await?
.context("Purchased content is not cached")?;
let size = file.metadata().await?.len();
if cache_only || size > 16 * 1024 * 1024 {
return Ok(
serde_json::json!({"owned":true,"mime_type":mime,"size":size,"size_bytes":size,"paid_sats":paid_sats,"owned_content_id":content_id}),
);
} }
let mut bytes = Vec::with_capacity(size as usize);
file.take(16 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.await?;
anyhow::ensure!(
bytes.len() as u64 == size,
"Purchased file changed during reading"
);
let mut result = paid_content_response(&bytes, &mime, paid_sats);
result["owned_content_id"] = serde_json::json!(content_id);
Ok(result)
} }
/// File purchases through an atomic no-clobber write in Files' own namespace. async fn cache_peer_response(
async fn file_purchase_in_files(
data_dir: &std::path::Path, data_dir: &std::path::Path,
onion: &str,
content_id: &str,
filename: &str, filename: &str,
mime: &str, mime: &str,
bytes: &[u8], paid_sats: u64,
) -> Result<String> { backend: &str,
let folder = if mime.starts_with("image/") || mime.starts_with("video/") { response: reqwest::Response,
) -> Result<crate::content_owned::OwnedItem> {
let expected = response.content_length();
crate::content_owned::record_purchase_stream(
data_dir,
crate::content_owned::OwnedItem {
onion: onion.into(),
content_id: content_id.into(),
filename: filename.into(),
mime_type: mime.into(),
size_bytes: expected.unwrap_or(0),
paid_sats,
ecash_backend: backend.into(),
purchased_at: chrono::Utc::now().to_rfc3339(),
download_complete: false,
},
response.bytes_stream(),
expected,
)
.await
}
async fn file_cached_purchase_in_files(
data_dir: &std::path::Path,
item: &crate::content_owned::OwnedItem,
) -> Result<()> {
let folder = if item.mime_type.starts_with("image/") || item.mime_type.starts_with("video/") {
"Photos" "Photos"
} else if mime.starts_with("audio/") { } else if item.mime_type.starts_with("audio/") {
"Music" "Music"
} else { } else {
"Documents" "Documents"
@@ -131,19 +193,16 @@ async fn file_purchase_in_files(
tokio::fs::metadata(&root).await?.is_dir(), tokio::fs::metadata(&root).await?.is_dir(),
"Files storage is unavailable" "Files storage is unavailable"
); );
let name = std::path::Path::new(filename) let name = std::path::Path::new(&item.filename)
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.filter(|n| !n.is_empty()) .filter(|n| !n.is_empty())
.unwrap_or("download"); .unwrap_or("download");
let path = let (_, file) = crate::content_owned::open_owned(data_dir, &item.onion, &item.content_id)
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?; .await?
Ok(format!( .context("Purchase unavailable")?;
"{folder}/{}", crate::container::filebrowser::save_new_file_from(&root.join(folder), name, file).await?;
path.file_name() Ok(())
.and_then(|n| n.to_str())
.context("Invalid Files name")?
))
} }
impl RpcHandler { impl RpcHandler {
@@ -540,6 +599,9 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address")); return Err(anyhow::anyhow!("Invalid v3 onion address"));
} }
crate::content_owned::validate_identity(onion, content_id)?;
let _purchase_lock = crate::content_owned::lock_seller_purchases(onion).await;
// NEVER pay twice for content we already own (2026-07-22: a file // NEVER pay twice for content we already own (2026-07-22: a file
// shared twice produced two catalog ids for the same bytes and the // shared twice produced two catalog ids for the same bytes and the
// buyer paid both). Guard BEFORE any ecash is minted, matching both // buyer paid both). Guard BEFORE any ecash is minted, matching both
@@ -551,6 +613,10 @@ impl RpcHandler {
onion, onion,
content_id, content_id,
params.get("filename").and_then(|v| v.as_str()), params.get("filename").and_then(|v| v.as_str()),
params
.get("cache_only")
.and_then(|v| v.as_bool())
.unwrap_or(false),
) )
.await? .await?
{ {
@@ -668,12 +734,11 @@ impl RpcHandler {
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// A 402 can mean mint validation, network failure, underpayment, // A 402 can mean mint validation, network failure, underpayment,
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis. // or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
let body = response.text().await.unwrap_or_default(); drop(response);
tracing::warn!( tracing::warn!(
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}" "paid download: seller rejected {used_backend} payment of {price_sats} sats"
); );
// Seller couldn't redeem the token — reclaim it so the buyer keeps // Reclaim only proofs the mint still considers unspent.
// their funds (the spent-but-unredeemed-notes case the user hit).
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"error": format!("The seller could not verify the payment. {refund}") "error": format!("The seller could not verify the payment. {refund}")
@@ -682,8 +747,8 @@ impl RpcHandler {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = bounded_seller_error(response).await;
tracing::warn!("paid download: seller {onion} returned {status}: {body}"); tracing::warn!("paid download: seller {onion} returned {status}");
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"error": format!("{} {refund}", seller_error_message(status, &body)) "error": format!("{} {refund}", seller_error_message(status, &body))
@@ -700,59 +765,26 @@ impl RpcHandler {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string()); .unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!("paid download: response body failed: {error}");
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
}));
}
};
// Persist the purchase so it "stays unlocked" for this buyer: cache the
// bytes + metadata keyed by (onion, content_id). The gallery then renders
// it unblurred and views it in-app from this cache — no re-payment and no
// reliance on a browser download (which silently fails on the mobile
// companion, the original "paid but never unlocked" report). Best-effort:
// a cache-write failure must not fail an already-paid download.
let filename = params let filename = params
.get("filename") .get("filename")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(content_id) .unwrap_or(content_id);
.to_string(); let item = cache_peer_response(&self.config.data_dir,onion,content_id,filename,&mime_type,price_sats,used_backend,response)
let purchased_at = chrono::Utc::now().to_rfc3339(); .await.context("Paid file delivery could not be saved. Do not send another payment; recover this purchase first")?;
if let Err(e) = crate::content_owned::record_purchase( if let Err(error) = file_cached_purchase_in_files(&self.config.data_dir, &item).await {
tracing::warn!("Purchase cached; optional Files copy failed: {error:#}");
}
let mut result = cached_purchase_response(
&self.config.data_dir, &self.config.data_dir,
onion, onion,
content_id, content_id,
&filename, params
&mime_type, .get("cache_only")
&bytes, .and_then(|v| v.as_bool())
.unwrap_or(false),
price_sats, price_sats,
used_backend,
&purchased_at,
) )
.await .await?;
{
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
}
// The durable purchased-content cache above is primary. A Files copy
// remains optional: a stopped FileBrowser must not undo a paid download.
let filed =
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
match filed {
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
Err(error) => tracing::warn!(
"paid download: optional Files copy failed; purchase cache retained: {error}"
),
}
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
result["ecash_backend"] = serde_json::json!(used_backend); result["ecash_backend"] = serde_json::json!(used_backend);
Ok(result) Ok(result)
} }
@@ -900,14 +932,27 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid payment_hash")); return Err(anyhow::anyhow!("Invalid payment_hash"));
} }
crate::content_owned::validate_identity(onion, content_id)?;
let _purchase_lock = crate::content_owned::lock_seller_purchases(onion).await;
let cache_only = params let cache_only = params
.get("cache_only") .get("cache_only")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
if let Some((mime, bytes)) = if crate::content_owned::list_owned_checked(&self.config.data_dir)
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await .await?
.iter()
.any(|item| {
item.onion == onion && item.content_id == content_id && item.download_complete
})
{ {
return Ok(invoice_download_response(&bytes, &mime, cache_only)); return cached_purchase_response(
&self.config.data_dir,
onion,
content_id,
cache_only,
0,
)
.await;
} }
// Older sellers only mark settlement during status polling. Always // Older sellers only mark settlement during status polling. Always
// perform that handshake before requesting bytes; retries never pay. // perform that handshake before requesting bytes; retries never pay.
@@ -976,36 +1021,29 @@ impl RpcHandler {
.next() .next()
.unwrap_or("application/octet-stream") .unwrap_or("application/octet-stream")
.to_string(); .to_string();
let bytes = response
.bytes()
.await
.context("Paid file transfer interrupted; retry the download without paying again")?;
let filename = params let filename = params
.get("filename") .get("filename")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(content_id); .unwrap_or(content_id);
crate::content_owned::record_purchase( let item = cache_peer_response(
&self.config.data_dir, &self.config.data_dir,
onion, onion,
content_id, content_id,
filename, filename,
&mime, &mime,
&bytes,
params params
.get("price_sats") .get("price_sats")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0), .unwrap_or(0),
"lightning", "lightning",
&chrono::Utc::now().to_rfc3339(), response,
) )
.await .await
.context("Paid file could not be saved; retry the download without paying again")?; .context("Paid file could not be saved; retry delivery without paying again")?;
if let Err(error) = if let Err(error) = file_cached_purchase_in_files(&self.config.data_dir, &item).await {
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
{
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}"); tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
} }
Ok(invoice_download_response(&bytes, &mime, cache_only)) cached_purchase_response(&self.config.data_dir, onion, content_id, cache_only, 0).await
} }
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay. /// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
@@ -1462,20 +1500,18 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
match crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await { existing_paid_content(
Some((mime_type, bytes)) => { &self.config.data_dir,
use base64::Engine; onion,
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); content_id,
Ok(serde_json::json!({ params.get("filename").and_then(|v| v.as_str()),
"data": encoded, params
"size": bytes.len(), .get("cache_only")
"mime_type": mime_type, .and_then(|v| v.as_bool())
})) .unwrap_or(false),
} )
None => Ok(serde_json::json!({ .await?
"error": "You don't own this item yet, or its cached copy is missing." .context("Purchased content is not cached")
})),
}
} }
} }
@@ -1486,15 +1522,62 @@ mod tests;
#[cfg(test)] #[cfg(test)]
mod invoice_delivery_response_tests { mod invoice_delivery_response_tests {
use super::*; use super::*;
#[test] #[tokio::test]
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() { async fn cached_delivery_avoids_base64_and_legacy_small_reads_remain_compatible() {
let cached = invoice_download_response(b"paid bytes", "video/mp4", true); let dir = tempfile::tempdir().unwrap();
crate::content_owned::record_purchase(
dir.path(),
"seller.onion",
"film",
"film",
"video/mp4",
b"paid bytes",
1,
"cashu",
"now",
)
.await
.unwrap();
let cached = cached_purchase_response(dir.path(), "seller.onion", "film", true, 0)
.await
.unwrap();
assert_eq!(cached["owned"], true); assert_eq!(cached["owned"], true);
assert_eq!(cached["size_bytes"], 10); assert_eq!(cached["size_bytes"], 10);
assert!(cached.get("data").is_none()); assert!(cached.get("data").is_none());
assert!(cached.get("data_base64").is_none()); assert!(cached.get("data_base64").is_none());
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false); let alias = existing_paid_content(
dir.path(),
"seller.onion",
"new-catalog-id",
Some("film"),
true,
)
.await
.unwrap()
.unwrap();
assert_eq!(alias["owned_content_id"], "film");
let legacy = cached_purchase_response(dir.path(), "seller.onion", "film", false, 0)
.await
.unwrap();
assert_eq!(legacy["data"], "cGFpZCBieXRlcw=="); assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
assert_eq!(legacy["data"], legacy["data_base64"]); assert_eq!(legacy["data"], legacy["data_base64"]);
let mut entry = crate::content_owned::list_owned_checked(dir.path())
.await
.unwrap()
.remove(0);
entry.content_id = "incomplete".into();
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
bytes::Bytes::from_static(b"part"),
)]);
assert!(
crate::content_owned::record_purchase_stream(dir.path(), entry, stream, Some(10))
.await
.is_err()
);
assert!(
existing_paid_content(dir.path(), "seller.onion", "incomplete", None, true)
.await
.is_err()
);
} }
} }
+97 -2
View File
@@ -212,6 +212,27 @@ pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathB
save_new_file_with(dir, name, bytes, write_via_userns).await save_new_file_with(dir, name, bytes, write_via_userns).await
} }
/// Copy the already-owned file with bounded buffers, retaining the no-clobber
/// and Files namespace rules used by small purchases.
pub async fn save_new_file_from(dir: &Path, name: &str, mut source: fs::File) -> Result<PathBuf> {
use tokio::io::AsyncSeekExt;
validate_filename(name)?;
match fs::symlink_metadata(dir).await {
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
source.seek(std::io::SeekFrom::Start(0)).await?;
match write_direct_stream(dir, name, &mut source).await {
Ok(path) => Ok(path),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
source.seek(std::io::SeekFrom::Start(0)).await?;
write_via_userns_stream(dir.to_owned(), name.to_owned(), source).await
}
Err(error) => Err(error).context("Saving purchased file"),
}
}
fn validate_filename(name: &str) -> Result<()> { fn validate_filename(name: &str) -> Result<()> {
anyhow::ensure!( anyhow::ensure!(
!name.is_empty() !name.is_empty()
@@ -291,8 +312,15 @@ impl Drop for PendingFile {
} }
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> { async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
write_direct_stream(dir, name, &mut &bytes[..]).await
}
async fn write_direct_stream<R: tokio::io::AsyncRead + Unpin>(
dir: &Path,
name: &str,
source: &mut R,
) -> std::io::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use tokio::io::AsyncWriteExt;
fs::create_dir_all(dir).await?; fs::create_dir_all(dir).await?;
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4())); let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
let mut file = fs::OpenOptions::new() let mut file = fs::OpenOptions::new()
@@ -302,7 +330,7 @@ async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<P
.open(&temp_path) .open(&temp_path)
.await?; .await?;
let temp = PendingFile(temp_path); let temp = PendingFile(temp_path);
file.write_all(bytes).await?; tokio::io::copy(source, &mut file).await?;
file.set_permissions(std::fs::Permissions::from_mode(0o644)) file.set_permissions(std::fs::Permissions::from_mode(0o644))
.await?; .await?;
file.sync_all().await?; file.sync_all().await?;
@@ -397,10 +425,77 @@ async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<
.context("Files namespace writer timed out")? .context("Files namespace writer timed out")?
} }
async fn write_via_userns_stream(
dir: PathBuf,
name: String,
mut source: fs::File,
) -> Result<PathBuf> {
let expected = source.metadata().await?.len();
let mut child = tokio::process::Command::new("podman")
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
.arg(&dir)
.arg(&name)
.arg(expected.to_string())
.kill_on_drop(true)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.context("Starting Files namespace writer")?;
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
tokio::time::timeout(std::time::Duration::from_secs(900), async {
let count = tokio::io::copy(&mut source, &mut stdin).await?;
drop(stdin);
let output = child.wait_with_output().await?;
anyhow::ensure!(
count == expected && output.status.success(),
"Files copy failed; purchased cache is retained"
);
let chosen = String::from_utf8(output.stdout).context("Invalid Files response")?;
validate_filename(&chosen)?;
anyhow::ensure!(
(1..=100).any(|n| numbered_name(&name, n) == chosen),
"Unexpected Files destination"
);
Ok(dir.join(chosen))
})
.await
.context("Files namespace writer timed out")?
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn streamed_purchase_preserves_existing_file_and_exact_large_copy() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("film.mp4"), b"keep")
.await
.unwrap();
let source = dir.path().join("source");
let bytes = vec![17; 2 * 1024 * 1024];
fs::write(&source, &bytes).await.unwrap();
let path = save_new_file_from(
dir.path(),
"film.mp4",
fs::File::open(&source).await.unwrap(),
)
.await
.unwrap();
assert_eq!(path.file_name().unwrap(), "film (2).mp4");
assert_eq!(fs::read(path).await.unwrap(), bytes);
assert_eq!(
fs::read(dir.path().join("film.mp4")).await.unwrap(),
b"keep"
);
assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".archy-saving")));
}
#[tokio::test] #[tokio::test]
async fn cloud_credentials_use_unique_record_and_never_default_password() { async fn cloud_credentials_use_unique_record_and_never_default_password() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+288 -24
View File
@@ -16,6 +16,29 @@ use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(()); static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
// Serialize purchases per seller so duplicate UI requests cannot both pass the
// ownership check and spend. Weak entries are pruned between acquisitions.
pub async fn lock_seller_purchases(onion: &str) -> tokio::sync::OwnedMutexGuard<()> {
use std::sync::{Arc, OnceLock, Weak};
static LOCKS: OnceLock<std::sync::Mutex<std::collections::HashMap<String, Weak<Mutex<()>>>>> =
OnceLock::new();
let lock = {
let mut locks = LOCKS
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner());
locks.retain(|_, value| value.strong_count() > 0);
if let Some(lock) = locks.get(onion).and_then(Weak::upgrade) {
lock
} else {
let lock = Arc::new(Mutex::new(()));
locks.insert(onion.into(), Arc::downgrade(&lock));
lock
}
};
lock.lock_owned().await
}
const OWNED_DIR: &str = "purchased-content"; const OWNED_DIR: &str = "purchased-content";
const OWNED_INDEX: &str = "owned.json"; const OWNED_INDEX: &str = "owned.json";
@@ -32,6 +55,13 @@ pub struct OwnedItem {
pub ecash_backend: String, pub ecash_backend: String,
/// RFC3339 timestamp; best-effort, empty if the clock was unavailable. /// RFC3339 timestamp; best-effort, empty if the clock was unavailable.
pub purchased_at: String, pub purchased_at: String,
/// False means payment succeeded but delivery still needs recovery.
#[serde(default = "completed")]
pub download_complete: bool,
}
fn completed() -> bool {
true
} }
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
@@ -81,27 +111,49 @@ async fn load_index(data_dir: &Path) -> OwnedIndex {
load_index_checked(data_dir).await.unwrap_or_default() load_index_checked(data_dir).await.unwrap_or_default()
} }
struct PendingFile(PathBuf);
impl Drop for PendingFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
pub fn validate_identity(onion: &str, content_id: &str) -> Result<()> {
anyhow::ensure!(
!onion.is_empty()
&& !content_id.is_empty()
&& onion != "."
&& content_id != "."
&& !onion.contains("..")
&& !content_id.contains("..")
&& sanitize(onion) == onion
&& sanitize(content_id) == content_id,
"Invalid purchase path"
);
Ok(())
}
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().context("Purchase path has no parent")?; let parent = path.parent().context("Purchase path has no parent")?;
fs::create_dir_all(parent).await?; fs::create_dir_all(parent).await?;
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4())); let temp = PendingFile(parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4())));
let result = async { let result = async {
let mut file = fs::OpenOptions::new() let mut file = fs::OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
.mode(0o600) .mode(0o600)
.open(&temp) .open(&temp.0)
.await?; .await?;
file.write_all(bytes).await?; file.write_all(bytes).await?;
file.sync_all().await?; file.sync_all().await?;
drop(file); drop(file);
fs::rename(&temp, path).await?; fs::rename(&temp.0, path).await?;
fs::File::open(parent).await?.sync_all().await?; fs::File::open(parent).await?.sync_all().await?;
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
} }
.await; .await;
if result.is_err() { if result.is_err() {
let _ = fs::remove_file(&temp).await; let _ = fs::remove_file(&temp.0).await;
} }
result result
} }
@@ -131,6 +183,7 @@ pub async fn record_purchase(
ecash_backend: &str, ecash_backend: &str,
purchased_at: &str, purchased_at: &str,
) -> Result<()> { ) -> Result<()> {
validate_identity(onion, content_id)?;
// Read-modify-write must be one serialized transaction. Never replace a // Read-modify-write must be one serialized transaction. Never replace a
// damaged index with an empty one, and never expose partially written bytes. // damaged index with an empty one, and never expose partially written bytes.
let _lock = PURCHASE_WRITES.lock().await; let _lock = PURCHASE_WRITES.lock().await;
@@ -149,6 +202,7 @@ pub async fn record_purchase(
paid_sats, paid_sats,
ecash_backend: ecash_backend.to_string(), ecash_backend: ecash_backend.to_string(),
purchased_at: purchased_at.to_string(), purchased_at: purchased_at.to_string(),
download_complete: true,
}; };
if let Some(existing) = index if let Some(existing) = index
.items .items
@@ -162,6 +216,110 @@ pub async fn record_purchase(
save_index(data_dir, &index).await save_index(data_dir, &index).await
} }
fn require_cache_space(file: &fs::File, additional: u64) -> Result<()> {
use std::os::fd::AsRawFd;
let mut value = std::mem::MaybeUninit::<libc::statvfs>::uninit();
if unsafe { libc::fstatvfs(file.as_raw_fd(), value.as_mut_ptr()) } != 0 {
return Err(std::io::Error::last_os_error()).context("Checking purchase storage");
}
let value = unsafe { value.assume_init() };
let available = (value.f_bavail as u64).saturating_mul(value.f_frsize as u64);
anyhow::ensure!(
additional
.checked_add(256 * 1024 * 1024)
.is_some_and(|n| n <= available),
"Insufficient purchase storage; payment remains recorded for recovery"
);
Ok(())
}
/// Save a peer response without holding its body in memory. A durable incomplete
/// ownership record precedes body consumption, so interrupted delivery cannot be
/// mistaken for permission to send another payment. Invoice retries may complete it.
pub async fn record_purchase_stream<S, E>(
data_dir: &Path,
mut entry: OwnedItem,
mut stream: S,
expected: Option<u64>,
) -> Result<OwnedItem>
where
S: futures_util::Stream<Item = std::result::Result<bytes::Bytes, E>> + Unpin,
E: std::error::Error + Send + Sync + 'static,
{
use futures_util::StreamExt;
validate_identity(&entry.onion, &entry.content_id)?;
let path = bytes_path(data_dir, &entry.onion, &entry.content_id);
let parent = path.parent().context("Purchase has no parent")?;
fs::create_dir_all(parent).await?;
let temporary = PendingFile(parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4())));
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary.0)
.await?;
entry.download_complete = false;
entry.size_bytes = expected.unwrap_or(0);
{
let _lock = PURCHASE_WRITES.lock().await;
let mut index = load_index_checked(data_dir).await?;
if let Some(old) = index
.items
.iter_mut()
.find(|i| i.onion == entry.onion && i.content_id == entry.content_id)
{
anyhow::ensure!(
!old.download_complete,
"Purchase is already cached; use the owned copy"
);
*old = entry.clone();
} else {
index.items.push(entry.clone());
}
save_index(data_dir, &index).await?;
}
require_cache_space(&file, expected.unwrap_or(0))?;
let mut received = 0u64;
while let Some(chunk) = stream.next().await {
let chunk =
chunk.context("Purchased file transfer interrupted; no new payment should be sent")?;
received = received
.checked_add(chunk.len() as u64)
.context("Content size overflow")?;
anyhow::ensure!(
expected.is_none_or(|n| received <= n),
"Seller exceeded the declared content length"
);
require_cache_space(&file, chunk.len() as u64)?;
for part in chunk.chunks(64 * 1024) {
file.write_all(part).await?;
}
}
anyhow::ensure!(
expected.is_none_or(|n| received == n),
"Purchased file transfer ended early"
);
file.sync_all().await?;
drop(file);
let _lock = PURCHASE_WRITES.lock().await;
let mut index = load_index_checked(data_dir).await?;
fs::rename(&temporary.0, &path).await?;
fs::File::open(parent).await?.sync_all().await?;
entry.size_bytes = received;
entry.download_complete = true;
if let Some(old) = index
.items
.iter_mut()
.find(|i| i.onion == entry.onion && i.content_id == entry.content_id)
{
*old = entry.clone();
} else {
anyhow::bail!("Purchase ownership record disappeared; no new payment should be sent");
}
save_index(data_dir, &index).await?;
Ok(entry)
}
/// Payment decisions must not interpret an unreadable index as no purchases. /// Payment decisions must not interpret an unreadable index as no purchases.
pub async fn list_owned_checked(data_dir: &Path) -> Result<Vec<OwnedItem>> { pub async fn list_owned_checked(data_dir: &Path) -> Result<Vec<OwnedItem>> {
Ok(load_index_checked(data_dir).await?.items) Ok(load_index_checked(data_dir).await?.items)
@@ -197,17 +355,10 @@ pub async fn open_owned(
else { else {
return Ok(None); return Ok(None);
}; };
// Reject path components even when called outside the HTTP route. validate_identity(onion, content_id)?;
anyhow::ensure!( anyhow::ensure!(
!onion.is_empty() item.download_complete,
&& !content_id.is_empty() "Payment is recorded, but delivery is incomplete. Retry delivery without paying again."
&& onion != "."
&& content_id != "."
&& !onion.contains("..")
&& !content_id.contains("..")
&& sanitize(onion) == onion
&& sanitize(content_id) == content_id,
"Invalid purchase path"
); );
let file = fs::OpenOptions::new() let file = fs::OpenOptions::new()
.read(true) .read(true)
@@ -228,22 +379,135 @@ pub async fn read_owned(
onion: &str, onion: &str,
content_id: &str, content_id: &str,
) -> Option<(String, Vec<u8>)> { ) -> Option<(String, Vec<u8>)> {
let bytes = fs::read(bytes_path(data_dir, onion, content_id)) use tokio::io::AsyncReadExt;
.await let (mime, mut file) = open_owned(data_dir, onion, content_id).await.ok()??;
.ok()?; let mut bytes = Vec::new();
let mime = load_index(data_dir) file.read_to_end(&mut bytes).await.ok()?;
.await
.items
.into_iter()
.find(|i| i.onion == onion && i.content_id == content_id)
.map(|i| i.mime_type)
.unwrap_or_else(|| "application/octet-stream".to_string());
Some((mime, bytes)) Some((mime, bytes))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn entry() -> OwnedItem {
OwnedItem {
onion: "seller.onion".into(),
content_id: "film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
size_bytes: 0,
paid_sats: 1,
ecash_backend: "lightning".into(),
purchased_at: "now".into(),
download_complete: false,
}
}
#[tokio::test]
async fn stream_records_incomplete_delivery_then_recovers_without_losing_ownership() {
let dir = tempfile::tempdir().unwrap();
let short = futures_util::stream::iter([Ok::<_, std::io::Error>(
bytes::Bytes::from_static(b"part"),
)]);
assert!(record_purchase_stream(dir.path(), entry(), short, Some(10))
.await
.is_err());
let pending = list_owned_checked(dir.path()).await.unwrap();
assert_eq!(pending.len(), 1);
assert!(!pending[0].download_complete);
assert!(open_owned(dir.path(), "seller.onion", "film")
.await
.is_err());
assert_eq!(
std::fs::read_dir(owned_root(dir.path()).join("seller.onion"))
.unwrap()
.count(),
0
);
let chunks = futures_util::stream::iter(
(0..64).map(|_| Ok::<_, std::io::Error>(bytes::Bytes::from(vec![42; 65536]))),
);
let item = record_purchase_stream(dir.path(), entry(), chunks, Some(4 * 1024 * 1024))
.await
.unwrap();
assert!(item.download_complete);
let (_, bytes) = read_owned(dir.path(), "seller.onion", "film")
.await
.unwrap();
assert_eq!(bytes.len(), 4 * 1024 * 1024);
assert!(bytes.iter().all(|b| *b == 42));
assert_eq!(list_owned_checked(dir.path()).await.unwrap().len(), 1);
}
#[tokio::test]
async fn cancelled_stream_cleans_partial_bytes_but_keeps_payment_record() {
let dir = tempfile::tempdir().unwrap();
let started = std::sync::Arc::new(tokio::sync::Notify::new());
let notify = started.clone();
let root = dir.path().to_path_buf();
let task = tokio::spawn(async move {
let stream = Box::pin(futures_util::stream::once(async move {
notify.notify_one();
std::future::pending::<std::result::Result<bytes::Bytes, std::io::Error>>().await
}));
record_purchase_stream(&root, entry(), stream, Some(10)).await
});
tokio::time::timeout(std::time::Duration::from_secs(10), started.notified())
.await
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert!(!list_owned_checked(dir.path()).await.unwrap()[0].download_complete);
assert_eq!(
std::fs::read_dir(owned_root(dir.path()).join("seller.onion"))
.unwrap()
.count(),
0
);
}
#[tokio::test]
async fn unsafe_purchase_paths_are_rejected_before_writing() {
let dir = tempfile::tempdir().unwrap();
for id in ["..", "../private", "/absolute", "a/b"] {
assert!(record_purchase(
dir.path(),
"seller.onion",
id,
"x",
"x",
b"x",
1,
"cashu",
"now"
)
.await
.is_err());
}
assert!(!owned_root(dir.path()).exists());
}
#[tokio::test]
async fn seller_purchase_lock_blocks_duplicates_but_not_another_seller() {
let first = lock_seller_purchases("one.onion").await;
assert!(tokio::time::timeout(
std::time::Duration::from_millis(10),
lock_seller_purchases("one.onion")
)
.await
.is_err());
let other = tokio::time::timeout(
std::time::Duration::from_secs(1),
lock_seller_purchases("two.onion"),
)
.await
.unwrap();
drop(first);
let _next = tokio::time::timeout(
std::time::Duration::from_secs(1),
lock_seller_purchases("one.onion"),
)
.await
.unwrap();
drop(other);
}
#[tokio::test] #[tokio::test]
async fn owned_stream_preserves_ownership_on_missing_corrupt_or_symlinked_bytes() { async fn owned_stream_preserves_ownership_on_missing_corrupt_or_symlinked_bytes() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+36
View File
@@ -268,3 +268,39 @@ Reticulum daemons, live Minibits and the subprocess permission helper (which its
parent test executes separately). A production build of the earlier integration parent test executes separately). A production build of the earlier integration
is running from detached `11f016a9`; it does not include this new preview fix and is running from detached `11f016a9`; it does not include this new preview fix and
must not be described as the final release candidate. must not be described as the final release candidate.
### Bounded peer delivery and preview qualification
Paid-preview source at `051dc7e3` passed all four isolated regression cases
(`/tmp/archy-preview-boundary-tests.log`). No live deployment is claimed.
The earlier production backend at `11f016a9` also built successfully and was
archived with its SHA256/source receipt under the private qualification directory;
it excludes these later fixes and is not the final candidate.
`2fee0339` replaces whole-file seller buffering with bounded file-backed responses.
Bearer payments prepare a complete private anonymous snapshot before redemption;
free/owner/durable-invoice transfers can stream an open file directly. Rootless
Files reads consume bounded subprocess stdout and require successful completion
before payment. Malformed ranges are rejected, suffix ranges are supported, and
free public previews also stream. New tests cover source deletion during payment,
invalid payment, multi-gigabyte sparse files, truncated preparation and ranges.
Full isolated backend qualification is running from the separate frozen media
worktree (`/tmp/archy-bounded-media-backend-tests.log`); results remain pending.
Buyer-side caching still needs bounded transfer and recovery work.
Buyer follow-up now streams successful ecash/Lightning deliveries into the owned
cache, records incomplete delivery before consuming the response, removes partial
temporary files on cancellation, and blocks duplicate concurrent ecash purchases
per seller. Owned-file opens and saves use local HTTP streaming rather than base64
for current clients; small legacy reads remain supported. Optional Files copies
stream through the existing no-clobber namespace writer. Interrupted receipt/body
handling is not equivalent to a durable end-to-end ecash retry protocol: loss
before response headers or during mint settlement remains an explicit review gate.
No real funds were spent. Nineteen focused UI tests passed before the final two
stream-viewer cases were added; backend/production qualification is pending.
Seller streaming's first full compile found a lifetime error in one new test;
`df7677d2` corrects it. The repeated isolated full suite is compiling from that
frozen source (`/tmp/archy-bounded-media-backend-tests-2.log`). The failed run is
retained and is not counted as a pass.
@@ -75,6 +75,26 @@ describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/
vi.stubGlobal('atob', vi.fn(() => 'binarydata')) vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
}) })
it('opens a large cached video through authenticated HTTP without decoding or a blob allocation', async () => {
mockedRpc.call.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 200000000 })
const viewer = usePaidItemViewer()
await viewer.open(VIDEO_ITEM)
expect(await viewer.resolveBlobUrl(viewer.lightboxItems.value[0]!.path)).toBe('/api/peer-content/abc123.onion/content-2')
expect(createObjectURLSpy).not.toHaveBeenCalled()
expect(atob).not.toHaveBeenCalled()
expect(mockedRpc.call).toHaveBeenCalledWith(expect.objectContaining({ params: { onion: VIDEO_ITEM.onion, content_id: VIDEO_ITEM.content_id, cache_only: true } }))
})
it('does not open incomplete delivery or start another purchase', async () => {
mockedRpc.call.mockRejectedValue(new Error('Delivery incomplete'))
const viewer = usePaidItemViewer()
await viewer.open(VIDEO_ITEM)
expect(viewer.lightboxIndex.value).toBeNull()
expect(viewer.error.value).toBeTruthy()
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('routes an image mime to the lightbox, not window.open', async () => { it('routes an image mime to the lightbox, not window.open', async () => {
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' }) mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
const viewer = usePaidItemViewer() const viewer = usePaidItemViewer()
+11 -12
View File
@@ -22,7 +22,7 @@ export function paidItemKey(it: OwnedItemLike): string {
} }
/** /**
* Fetch, decode, route-to-viewer and loading/error state for a purchased * Validate ownership, stream to viewer and manage loading/error state for a purchased
* item (UIFIX-04 / UIFIX-06). * item (UIFIX-04 / UIFIX-06).
* *
* - image/video → routed into the caller's MediaLightbox via `lightboxItems` * - image/video → routed into the caller's MediaLightbox via `lightboxItems`
@@ -42,7 +42,7 @@ export function usePaidItemViewer() {
const lightboxItems = ref<FileBrowserItem[]>([]) const lightboxItems = ref<FileBrowserItem[]>([])
const lightboxIndex = ref<number | null>(null) const lightboxIndex = ref<number | null>(null)
// Synthetic-path -> already-fetched blob URL. Populated only for items // Synthetic-path -> authenticated cache URL (or legacy blob URL). Populated only for items
// routed to the lightbox; resolveBlobUrl reads from here and never fetches. // routed to the lightbox; resolveBlobUrl reads from here and never fetches.
const urlByPath = new Map<string, string>() const urlByPath = new Map<string, string>()
// One in-flight fetch per item key — a second open() for the same key // One in-flight fetch per item key — a second open() for the same key
@@ -61,21 +61,20 @@ export function usePaidItemViewer() {
opening.value = key opening.value = key
error.value = null error.value = null
try { try {
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({ const res = await rpcClient.call<{ data_base64?: string; data?: string; owned?: boolean; mime_type?: string; error?: string }>({
method: 'content.owned-get', method: 'content.owned-get',
params: { onion: it.onion, content_id: it.content_id }, params: { onion: it.onion, content_id: it.content_id, cache_only: true },
timeout: 60000, timeout: 60000,
}) })
const b64 = res.data_base64 || res.data const b64 = res.data_base64 ?? res.data
if (!b64) { if (b64 === undefined && res.owned !== true) {
error.value = "Couldn't open this file — the peer returned no data." error.value = res.error || "Couldn't open this file — its cached copy is unavailable."
return return
} }
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
const mime = res.mime_type || it.mime_type const mime = res.mime_type || it.mime_type
const url = URL.createObjectURL(new Blob([arr], { type: mime })) const url = b64 === undefined
? `/api/peer-content/${encodeURIComponent(it.onion)}/${encodeURIComponent(it.content_id)}`
: URL.createObjectURL(new Blob([Uint8Array.from(atob(b64), c => c.charCodeAt(0))], { type: mime }))
// Music ALWAYS plays in the global bottom-bar player — never a // Music ALWAYS plays in the global bottom-bar player — never a
// lightbox (blob URL stays alive for the bar; it owns playback now). // lightbox (blob URL stays alive for the bar; it owns playback now).
@@ -104,7 +103,7 @@ export function usePaidItemViewer() {
// No in-app viewer (documents, etc.) — keep today's behaviour. // No in-app viewer (documents, etc.) — keep today's behaviour.
window.open(url, '_blank', 'noopener') window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60000) if (url.startsWith("blob:")) setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch { } catch {
error.value = "Couldn't open this file — it may be unavailable right now." error.value = "Couldn't open this file — it may be unavailable right now."
} finally { } finally {
+21 -19
View File
@@ -701,7 +701,7 @@ function base64ToBlob(base64: string, mime: string): Blob {
} }
// In-app viewer (lightbox) for owned content AND free images. Owned content is // In-app viewer (lightbox) for owned content AND free images. Owned content is
// loaded as a blob URL from the purchase cache; free images point straight at // loaded through local cached streaming; free images point straight at
// the Range-capable streaming proxy (no base64 round-trip). // the Range-capable streaming proxy (no base64 round-trip).
const viewerItem = ref<CatalogItem | null>(null) const viewerItem = ref<CatalogItem | null>(null)
const viewerUrl = ref<string | null>(null) const viewerUrl = ref<string | null>(null)
@@ -717,22 +717,23 @@ async function viewOwned(item: CatalogItem) {
playing.value = item.id playing.value = item.id
purchaseError.value = null purchaseError.value = null
try { try {
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({ const res = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.owned-get', method: 'content.owned-get',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id, filename: item.filename, cache_only: true },
timeout: 60000, timeout: 60000,
}) })
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return } if (res?.data === undefined && res?.owned !== true) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
const mime = res.mime_type || item.mime_type const mime = res.mime_type || item.mime_type
const url = res.data !== undefined ? URL.createObjectURL(base64ToBlob(res.data, mime))
: `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(res.owned_content_id || item.id)}`
// Audio always plays in the global bottom-bar player — never the lightbox // Audio always plays in the global bottom-bar player — never the lightbox
// (the blob URL is intentionally not revoked while the bar plays it). // (the blob URL is intentionally not revoked while the bar plays it).
if (mime.startsWith('audio/')) { if (mime.startsWith('audio/')) {
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
audioPlayer.play(url, item.filename.split('/').pop() || item.filename) audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
return return
} }
releaseViewerUrl() releaseViewerUrl()
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime)) viewerUrl.value = url
viewerMime.value = mime viewerMime.value = mime
viewerItem.value = item viewerItem.value = item
} catch (e: unknown) { } catch (e: unknown) {
@@ -797,12 +798,13 @@ async function saveOwned(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
if (!onion) return if (!onion) return
try { try {
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({ const res = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.owned-get', method: 'content.owned-get',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id, filename: item.filename, cache_only: true },
timeout: 60000, timeout: 60000,
}) })
if (res?.data) triggerDownload(res.data, item) if (res?.owned === true) streamDownload(`/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(res.owned_content_id || item.id)}`, item)
else if (res?.data) triggerDownload(res.data, item)
else purchaseError.value = res?.error || 'Could not save your purchased file' else purchaseError.value = res?.error || 'Could not save your purchased file'
} catch (e: unknown) { } catch (e: unknown) {
purchaseError.value = e instanceof Error ? e.message : 'Could not save your purchased file' purchaseError.value = e instanceof Error ? e.message : 'Could not save your purchased file'
@@ -1362,11 +1364,11 @@ async function prepareEcashPay() {
* mobile companion ("paid but never unlocked"); the viewer's Save button * mobile companion ("paid but never unlocked"); the viewer's Save button
* still offers an explicit download. * still offers an explicit download.
*/ */
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) { function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string, cachedId?: string) {
const onion = seller || props.peerId || currentPeer.value?.onion const onion = seller || props.peerId || currentPeer.value?.onion
const url = base64Data !== undefined const url = base64Data !== undefined
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type)) ? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}` : `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(cachedId || item.id)}`
if (onion) { if (onion) {
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ } try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
lnReceipt.value = null lnReceipt.value = null
@@ -1395,20 +1397,20 @@ async function confirmEcashPay() {
const item = payItem.value const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
const method = ecashPlan.value?.chosen const method = ecashPlan.value?.chosen
if (!item || !onion || !method) return if (!item || !onion || !method || downloading.value) return
const price = getItemPrice(item.access) const price = getItemPrice(item.access)
downloading.value = item.id downloading.value = item.id
purchaseError.value = null purchaseError.value = null
try { try {
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string; mime_type?: string }>({ const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
method: 'content.download-peer-paid', method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename }, params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
timeout: 120000, timeout: 960000,
}) })
if (result?.data) { if (result?.data !== undefined || result?.owned === true) {
// The purchase is now cached + owned by this node (backend persisted it). // The purchase is now cached + owned by this node (backend persisted it).
openPurchased(item, result.data, result.mime_type) openPurchased(item, result.data, result.mime_type, onion, result.owned_content_id)
} else if (result?.error) { } else if (result?.error) {
// Keep the confirm screen open so the user can switch backend and retry. // Keep the confirm screen open so the user can switch backend and retry.
purchaseError.value = result.error purchaseError.value = result.error
@@ -1486,7 +1488,7 @@ async function payWithLightning() {
} }
} }
lnReceipt.value = inv lnReceipt.value = inv
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({ const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice', method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000, timeout: 960000,
@@ -1517,7 +1519,7 @@ async function pollInvoice() {
if (res?.paid) { if (res?.paid) {
// Settled — pull the file using the payment hash as the gate token. // Settled — pull the file using the payment hash as the gate token.
invoiceWaiting.value = false invoiceWaiting.value = false
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({ const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice', method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000, timeout: 960000,
@@ -30,6 +30,35 @@ beforeEach(() => {
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never) vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
}) })
describe('Lightning file delivery recovery', () => { describe('Lightning file delivery recovery', () => {
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.download-peer-paid') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [] }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
await vm.confirmEcashPay()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].params).toMatchObject({ cache_only: true, method: 'cashu' })
wrapper.unmount()
})
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.download-peer-paid') return await new Promise(resolve => { finish = resolve })
return { items: [] }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
const first = vm.confirmEcashPay()
await vm.confirmEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.download-peer-paid')).toHaveLength(1)
finish({ error: 'Delivery needs recovery; do not pay again' })
await first
expect(vm.purchaseError).toContain('do not pay again')
wrapper.unmount()
})
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => { it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' }) download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
const { wrapper, vm } = await open() const { wrapper, vm } = await open()
+14 -11
View File
@@ -612,26 +612,29 @@ async function purchaseAndDownload(item: PeerContentItem) {
purchasingId.value = item.id purchasingId.value = item.id
try { try {
const result = await rpcClient.call<{ data?: string; error?: string }>({ const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string }>({
method: 'content.download-peer-paid', method: 'content.download-peer-paid',
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price }, params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price, filename: item.filename, cache_only: true },
timeout: 120000, timeout: 960000,
}) })
if (result?.data) { if (result?.owned === true) {
const blob = new Blob( const a = document.createElement('a')
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], a.href = `/api/peer-content/${encodeURIComponent(browsePeerOnion.value)}/${encodeURIComponent(result.owned_content_id || item.id)}`
{ type: item.mime_type }, a.download = item.filename.split('/').pop() || item.filename
) a.click()
emit('toast', 'Purchase saved — download started')
} else if (result?.data) {
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { type: item.mime_type })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = item.filename.split('/').pop() || item.filename a.download = item.filename.split('/').pop() || item.filename
a.click() a.click()
URL.revokeObjectURL(url) setTimeout(() => URL.revokeObjectURL(url), 60000)
emit('toast', `Downloaded for ${price} sats`) emit('toast', 'Purchase saved — download started')
} else { } else {
emit('toast', 'Purchase failed — no data received') emit('toast', result?.error || 'Purchase could not be completed')
} }
} catch (e: unknown) { } catch (e: unknown) {
emit('toast', e instanceof Error ? e.message : 'Purchase failed') emit('toast', e instanceof Error ? e.message : 'Purchase failed')