Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s
Demo images / Build & push demo images (push) Failing after 1m10s
This commit is contained in:
@@ -29,3 +29,14 @@ unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||
tests still reached real service commands. The runner isolates wallet data,
|
||||
service buses, container storage, networking, and process IDs. Compilation with
|
||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||
|
||||
## Active release regression checklist
|
||||
|
||||
Before resuming release work, read
|
||||
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
|
||||
The operator requested that every reported issue be tracked, fixed and tested
|
||||
before another OTA/ISO. Keep source/unit-test results separate from actual-node
|
||||
acceptance. In particular, paid-file recovery must not send another payment,
|
||||
and app cleanup must preserve wallets, persistent data and uninstall decisions.
|
||||
Do not mark the new paid-file incident resolved merely because the earlier
|
||||
Framework LND startup incident was closed.
|
||||
|
||||
@@ -74,7 +74,7 @@ impl ApiHandler {
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
@@ -98,6 +98,46 @@ impl ApiHandler {
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Payment settlement is verified on the seller even when no status
|
||||
// poll preceded this download (e.g. direct payment from another node).
|
||||
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
|
||||
content_server::load_catalog(&config.data_dir)
|
||||
.await?
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item.id == content_id
|
||||
&& matches!(item.access, content_server::AccessControl::Paid { .. })
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if requires_payment {
|
||||
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
|
||||
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid payment hash"),
|
||||
));
|
||||
}
|
||||
if let Err(error) = self
|
||||
.rpc_handler
|
||||
.settle_content_invoice(hash, content_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
@@ -249,7 +289,13 @@ impl ApiHandler {
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&payment_hash,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
@@ -309,26 +355,10 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
let paid = self
|
||||
.rpc_handler
|
||||
.settle_content_invoice(payment_hash, content_id)
|
||||
.await?;
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
@@ -389,7 +419,13 @@ impl ApiHandler {
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&address,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
@@ -439,7 +475,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
@@ -450,10 +486,11 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
let mut paid =
|
||||
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
||||
})
|
||||
}
|
||||
|
||||
// Updated clients open the persisted file through the Range-capable HTTP
|
||||
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
|
||||
// Keep older clients compatible until both sides have upgraded.
|
||||
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value {
|
||||
if cache_only {
|
||||
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() })
|
||||
} else {
|
||||
paid_content_response(bytes, mime, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
data_dir: &std::path::Path,
|
||||
@@ -870,10 +881,29 @@ impl RpcHandler {
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
let cache_only = params
|
||||
.get("cache_only")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
|
||||
{
|
||||
return Ok(invoice_download_response(&bytes, &mime, cache_only));
|
||||
}
|
||||
// Older sellers only mark settlement during status polling. Always
|
||||
// perform that handshake before requesting bytes; retries never pay.
|
||||
// The download gate remains authoritative: a file may have become
|
||||
// free, and newer sellers verify directly if status polling fails.
|
||||
let _ = self
|
||||
.handle_content_invoice_status(Some(serde_json::json!({
|
||||
"onion": onion, "content_id": content_id, "payment_hash": payment_hash,
|
||||
})))
|
||||
.await;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
@@ -912,7 +942,7 @@ impl RpcHandler {
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
||||
"error": "The seller has not confirmed access yet. Retry the download without paying again."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
@@ -921,16 +951,45 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
let mime = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
.context("Paid file transfer interrupted; retry the download without paying again")?;
|
||||
let filename = params
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(content_id);
|
||||
crate::content_owned::record_purchase(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
filename,
|
||||
&mime,
|
||||
&bytes,
|
||||
params
|
||||
.get("price_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
"lightning",
|
||||
&chrono::Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.context("Paid file could not be saved; retry the download without paying again")?;
|
||||
if let Err(error) =
|
||||
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
|
||||
{
|
||||
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
|
||||
}
|
||||
Ok(invoice_download_response(&bytes, &mime, cache_only))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||
@@ -1405,3 +1464,19 @@ impl RpcHandler {
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod invoice_delivery_response_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() {
|
||||
let cached = invoice_download_response(b"paid bytes", "video/mp4", true);
|
||||
assert_eq!(cached["owned"], true);
|
||||
assert_eq!(cached["size_bytes"], 10);
|
||||
assert!(cached.get("data").is_none());
|
||||
assert!(cached.get("data_base64").is_none());
|
||||
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false);
|
||||
assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
|
||||
assert_eq!(legacy["data"], legacy["data_base64"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +473,7 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let fee_query = close_channel_fee_query(¶ms)?;
|
||||
let force = params
|
||||
.get("force")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -498,13 +499,11 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
|
||||
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.query(&fee_query)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -572,3 +571,101 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
|
||||
/// With neither parameter LND uses a lax target; keep legacy clients on our
|
||||
/// explicit Standard target rather than silently accepting that default.
|
||||
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
|
||||
let force = match params.get("force") {
|
||||
None | Some(serde_json::Value::Null) => false,
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
|
||||
};
|
||||
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
|
||||
match params.get(key) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(value) => {
|
||||
let n = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
|
||||
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = integer("target_conf", 1008)?;
|
||||
let rate = integer("sat_per_vbyte", 5000)?;
|
||||
anyhow::ensure!(
|
||||
target.is_none() || rate.is_none(),
|
||||
"Specify either target_conf or sat_per_vbyte, not both"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!force || (target.is_none() && rate.is_none()),
|
||||
"Closing fee selection requires a cooperative close"
|
||||
);
|
||||
let mut query = vec![("force", force.to_string())];
|
||||
if !force {
|
||||
if let Some(rate) = rate {
|
||||
query.push(("sat_per_vbyte", rate.to_string()));
|
||||
} else {
|
||||
query.push(("target_conf", target.unwrap_or(6).to_string()));
|
||||
}
|
||||
}
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod close_fee_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
|
||||
for target in [1, 3, 6, 1008] {
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
|
||||
vec![
|
||||
("force", "false".into()),
|
||||
("target_conf", target.to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
for rate in [1, 25, 5000] {
|
||||
let query =
|
||||
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
|
||||
let request = reqwest::Client::new()
|
||||
.delete("http://localhost/v1/channels/test/0")
|
||||
.query(&query)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), reqwest::Method::DELETE);
|
||||
assert_eq!(
|
||||
request.url().query(),
|
||||
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({})).unwrap(),
|
||||
vec![("force", "false".into()), ("target_conf", "6".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
|
||||
vec![("force", "true".into())]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
|
||||
for params in [
|
||||
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
|
||||
serde_json::json!({"force":true,"target_conf":1}),
|
||||
serde_json::json!({"force":"false"}),
|
||||
serde_json::json!({"target_conf":0}),
|
||||
serde_json::json!({"target_conf":1009}),
|
||||
serde_json::json!({"sat_per_vbyte":5001}),
|
||||
serde_json::json!({"sat_per_vbyte":-1}),
|
||||
serde_json::json!({"sat_per_vbyte":1.5}),
|
||||
serde_json::json!({"sat_per_vbyte":"25"}),
|
||||
] {
|
||||
assert!(close_channel_fee_query(¶ms).is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +453,56 @@ impl RpcHandler {
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Verify against LND at download time, rather than relying on a browser
|
||||
/// having polled first. The memo/amount also recover pre-upgrade in-memory
|
||||
/// entitlements after restart; unrelated invoices never unlock a file.
|
||||
pub(crate) async fn settle_content_invoice(
|
||||
&self,
|
||||
hash: &str,
|
||||
content_id: &str,
|
||||
) -> Result<bool> {
|
||||
anyhow::ensure!(
|
||||
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid payment hash"
|
||||
);
|
||||
let hash = hash.to_ascii_lowercase();
|
||||
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
|
||||
if let Some((id, _)) = &existing {
|
||||
if id != content_id {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
|
||||
return Ok(true);
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let response = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = response.error_for_status()?.json().await?;
|
||||
let Some(price) = content_invoice_amount(&body, content_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|(_, expected)| *expected != price)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
|
||||
.await?;
|
||||
let settled = content_invoice_fully_settled(&body, price);
|
||||
if settled {
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
|
||||
}
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -1444,3 +1494,81 @@ mod tests {
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
// LND REST uses decimal strings for int64 fields. Match the complete seller
|
||||
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
|
||||
fn json_u64(value: &serde_json::Value) -> Option<u64> {
|
||||
value.as_u64().or_else(|| value.as_str()?.parse().ok())
|
||||
}
|
||||
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
|
||||
let settled = match body.get("state").and_then(|v| v.as_str()) {
|
||||
Some(state) => state == "SETTLED",
|
||||
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
|
||||
};
|
||||
settled
|
||||
&& price > 0
|
||||
&& body
|
||||
.get("amt_paid_sat")
|
||||
.and_then(json_u64)
|
||||
.is_some_and(|paid| paid >= price)
|
||||
}
|
||||
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
|
||||
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
|
||||
return None;
|
||||
}
|
||||
body.get("value").and_then(json_u64).filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod peer_file_invoice_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn settlement_requires_terminal_state_and_full_amount() {
|
||||
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
|
||||
7
|
||||
));
|
||||
}
|
||||
for amount in [
|
||||
serde_json::json!(6),
|
||||
serde_json::json!("-1"),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!("bad"),
|
||||
] {
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
||||
7
|
||||
));
|
||||
}
|
||||
for amount in [serde_json::json!(7), serde_json::json!("8")] {
|
||||
assert!(content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
||||
7
|
||||
));
|
||||
}
|
||||
assert!(content_invoice_fully_settled(
|
||||
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
|
||||
7
|
||||
));
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
|
||||
0
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
|
||||
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
|
||||
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
|
||||
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
|
||||
for value in [
|
||||
serde_json::json!("-1"),
|
||||
serde_json::json!(0),
|
||||
serde_json::json!("bad"),
|
||||
] {
|
||||
let mut invalid = invoice.clone();
|
||||
invalid["value"] = value;
|
||||
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ use crate::data_model::{
|
||||
/// stopped-app restoration path in agreement with live-container discovery.
|
||||
fn canonical_package_id(name: &str) -> &str {
|
||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||
"immich_server" => "immich",
|
||||
"immich_server" | "immich-server" => "immich",
|
||||
"immich-postgres" => "immich_postgres",
|
||||
"immich-redis" => "immich_redis",
|
||||
"mempool-web" | "mempool-frontend" => "mempool",
|
||||
name => name,
|
||||
}
|
||||
@@ -443,6 +445,19 @@ mod lifecycle_regression_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn immich_dependency_aliases_share_the_hidden_component_ids() {
|
||||
for id in [
|
||||
"immich-postgres",
|
||||
"immich_postgres",
|
||||
"archy-immich-postgres",
|
||||
] {
|
||||
assert_eq!(canonical_package_id(id), "immich_postgres");
|
||||
}
|
||||
assert_eq!(canonical_package_id("immich-redis"), "immich_redis");
|
||||
assert_eq!(canonical_package_id("immich-server"), "immich");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||
|
||||
@@ -3153,7 +3153,12 @@ impl ProdContainerOrchestrator {
|
||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
||||
let restart_for_managed_override =
|
||||
quadlet::redundant_managed_network_override(&unit, &unit_dir)
|
||||
.await?
|
||||
.is_some();
|
||||
let needs_restart = restart_required
|
||||
|| restart_for_managed_override
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
@@ -4881,6 +4886,26 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
/// here (production volumes live under `/var/lib/archipelago` — removal is a
|
||||
/// separate operation owned by the data layer, not this orchestrator).
|
||||
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
|
||||
// A removed catalog entry must remain uninstallable. The RPC caller
|
||||
// still removes legacy containers and persists uninstall intent after
|
||||
// confirming they are gone; do not block it on a missing manifest.
|
||||
if !self.state.read().await.manifests.contains_key(app_id) {
|
||||
anyhow::ensure!(
|
||||
!app_id.is_empty()
|
||||
&& app_id.len() <= 128
|
||||
&& app_id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')),
|
||||
"Invalid app id"
|
||||
);
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
||||
self.remove_quadlet_unit_if_present(&name).await?;
|
||||
}
|
||||
self.state.write().await.disabled.insert(app_id.to_string());
|
||||
return Ok(());
|
||||
}
|
||||
let lm = self.loaded(app_id).await?;
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
@@ -7435,6 +7460,19 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_catalog_entry_does_not_block_legacy_uninstall() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.remove("cryptpad", true).await.unwrap();
|
||||
assert!(orch.state.read().await.disabled.contains("cryptpad"));
|
||||
assert!(
|
||||
rt.calls().is_empty(),
|
||||
"legacy RPC teardown owns the actual containers"
|
||||
);
|
||||
assert!(orch.remove("../other", true).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -713,29 +713,76 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Atomically write `unit` into `dir/<name>.container` if the bytes
|
||||
/// differ from what's already there. Returns true if the file changed.
|
||||
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
||||
/// the manifest supplies slirp, the two Network= entries are additive and
|
||||
/// Podman rejects startup. Retire only that exact redundant managed override;
|
||||
/// arbitrary operator settings must survive reconciliation.
|
||||
pub async fn redundant_managed_network_override(
|
||||
unit: &QuadletUnit,
|
||||
dir: &Path,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
if unit.name != "portainer" || !matches!(unit.network, NetworkMode::Slirp4netns) {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = dir.join("portainer.container.d/archy-same-node-network.conf");
|
||||
let body = match fs::read_to_string(&path).await {
|
||||
Ok(body) => body,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error).context("read managed Portainer network override"),
|
||||
};
|
||||
let lines: Vec<&str> = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||
.collect();
|
||||
Ok((lines == ["[Container]", "Network=slirp4netns"]).then_some(path))
|
||||
}
|
||||
|
||||
async fn retire_managed_network_override(path: &Path) -> Result<()> {
|
||||
let backup = path.with_extension("conf.retired");
|
||||
match fs::hard_link(path, &backup).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
anyhow::ensure!(
|
||||
fs::read(path).await? == fs::read(&backup).await?,
|
||||
"Existing Portainer override backup differs; preserve both for operator review"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error).context("back up managed Portainer network override"),
|
||||
}
|
||||
fs::remove_file(path)
|
||||
.await
|
||||
.context("retire redundant Portainer network override")?;
|
||||
tracing::info!("Retired redundant managed Portainer network override; backup retained");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically write the manifest unit and retire known redundant managed
|
||||
/// overrides. Returns true whenever systemd needs a daemon-reload.
|
||||
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
||||
let path = dir.join(unit.unit_filename());
|
||||
let new_bytes = unit.render();
|
||||
|
||||
if let Ok(old) = fs::read_to_string(&path).await {
|
||||
if old == new_bytes {
|
||||
return Ok(false);
|
||||
}
|
||||
let redundant = redundant_managed_network_override(unit, dir).await?;
|
||||
let changed = fs::read_to_string(&path)
|
||||
.await
|
||||
.map(|old| old != new_bytes)
|
||||
.unwrap_or(true);
|
||||
if changed {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||
let tmp = path.with_extension("container.tmp");
|
||||
fs::write(&tmp, new_bytes.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("write tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||
let tmp = path.with_extension("container.tmp");
|
||||
fs::write(&tmp, new_bytes.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("write tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(true)
|
||||
if let Some(override_path) = &redundant {
|
||||
retire_managed_network_override(override_path).await?;
|
||||
}
|
||||
Ok(changed || redundant.is_some())
|
||||
}
|
||||
|
||||
/// Reload the user systemd manager. Required after any quadlet write
|
||||
@@ -1991,6 +2038,80 @@ app:
|
||||
assert!(!network_aliases_changed(new, new));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redundant_portainer_override_is_backed_up_and_retired_even_when_base_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
let path = dir
|
||||
.path()
|
||||
.join("portainer.container.d/archy-same-node-network.conf");
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||
fs::write(&path, old).await.unwrap();
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(path.with_extension("conf.retired"))
|
||||
.await
|
||||
.unwrap(),
|
||||
old
|
||||
);
|
||||
assert!(!write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("portainer.container"))
|
||||
.await
|
||||
.unwrap()
|
||||
.matches("Network=slirp4netns")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn network_override_migration_preserves_operator_customizations_and_failed_backups() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
||||
let mut unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
||||
let path = dir
|
||||
.path()
|
||||
.join("portainer.container.d/archy-same-node-network.conf");
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
for custom in [
|
||||
"[Container]\nNetwork=custom-net\n",
|
||||
"[Container]\nNetwork=slirp4netns\nEnvironment=OPERATOR_SETTING=1\n",
|
||||
] {
|
||||
fs::write(&path, custom).await.unwrap();
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
write_if_changed(&unit, dir.path()).await.unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).await.unwrap(), custom);
|
||||
}
|
||||
fs::write(&path, "[Container]\nNetwork=slirp4netns\n")
|
||||
.await
|
||||
.unwrap();
|
||||
unit.network = NetworkMode::Pasta;
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
unit.network = NetworkMode::Slirp4netns;
|
||||
fs::write(path.with_extension("conf.retired"), "different backup")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write_if_changed(&unit, dir.path()).await.is_err());
|
||||
assert!(path.exists(), "failure must preserve the active override");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,80 +1,155 @@
|
||||
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
||||
//!
|
||||
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
||||
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
|
||||
//! invoice on its own LND and records a pending entitlement here, keyed by the
|
||||
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
|
||||
//! for settlement; once the seller's LND confirms the invoice is settled we mark
|
||||
//! the entitlement paid, and the content gate (`content_server::serve_content`)
|
||||
//! then releases the file to anyone presenting that payment hash.
|
||||
//!
|
||||
//! State is in-memory and bounded by a TTL. If the seller restarts before the
|
||||
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
|
||||
//! because an unpaid invoice represents no money.
|
||||
//! Durable seller-side entitlements for peer-file invoices and on-chain sales.
|
||||
//! Payment records must outlive browser polling, process restarts and invoice
|
||||
//! expiry: an invoice can settle while the buyer is disconnected.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
/// How long a pending/paid entitlement is retained. Generous enough for a human
|
||||
/// to pay an invoice and download, short enough to keep the map small.
|
||||
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||
static WRITES: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Entitlement {
|
||||
content_id: String,
|
||||
price_sats: u64,
|
||||
paid: bool,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Drop expired entries. Caller must hold the lock.
|
||||
fn prune(map: &mut HashMap<String, Entitlement>) {
|
||||
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
||||
fn path(data_dir: &Path, token: &str) -> PathBuf {
|
||||
data_dir.join("content-entitlements").join(format!(
|
||||
"{}.json",
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
))
|
||||
}
|
||||
|
||||
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
||||
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.insert(
|
||||
payment_hash.to_string(),
|
||||
Entitlement {
|
||||
content_id: content_id.to_string(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
|
||||
pub async fn mark_paid(payment_hash: &str) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
if let Some(e) = map.get_mut(payment_hash) {
|
||||
e.paid = true;
|
||||
async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> {
|
||||
match fs::read(path(data_dir, token)).await {
|
||||
Ok(bytes) => Ok(Some(
|
||||
serde_json::from_slice(&bytes).context("Invalid payment entitlement")?,
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).context("Reading payment entitlement"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content_id + price an entitlement was issued for, if still live.
|
||||
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| (e.content_id.clone(), e.price_sats))
|
||||
async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
|
||||
let target = path(data_dir, token);
|
||||
let dir = target.parent().unwrap();
|
||||
fs::create_dir_all(dir).await?;
|
||||
let tmp = target.with_extension("tmp");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await?;
|
||||
file.write_all(&serde_json::to_vec(entry)?).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&tmp, &target).await?;
|
||||
fs::File::open(dir).await?.sync_all().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
|
||||
/// This is the gate the content server consults to release a file.
|
||||
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
/// Save before exposing an invoice/address to the buyer. Never overwrite an
|
||||
/// existing payment or silently rebind its token to another item or price.
|
||||
pub async fn record_pending(
|
||||
data_dir: &Path,
|
||||
token: &str,
|
||||
content_id: &str,
|
||||
price_sats: u64,
|
||||
) -> Result<()> {
|
||||
let _lock = WRITES.lock().await;
|
||||
if let Some(existing) = read(data_dir, token).await? {
|
||||
anyhow::ensure!(
|
||||
existing.content_id == content_id && existing.price_sats == price_sats,
|
||||
"Payment entitlement mismatch"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
write(
|
||||
data_dir,
|
||||
token,
|
||||
&Entitlement {
|
||||
content_id: content_id.into(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_paid(data_dir: &Path, token: &str) -> Result<()> {
|
||||
let _lock = WRITES.lock().await;
|
||||
let mut entry = read(data_dir, token)
|
||||
.await?
|
||||
.context("Unknown payment entitlement")?;
|
||||
entry.paid = true;
|
||||
write(data_dir, token, &entry).await
|
||||
}
|
||||
|
||||
pub async fn lookup(data_dir: &Path, token: &str) -> Result<Option<(String, u64)>> {
|
||||
Ok(read(data_dir, token)
|
||||
.await?
|
||||
.map(|e| (e.content_id, e.price_sats)))
|
||||
}
|
||||
|
||||
pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool {
|
||||
read(data_dir, token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|e| e.paid && e.content_id == content_id)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn paid_entitlement_survives_reload_and_cannot_be_rebound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_pending(dir.path(), "hash", "file", 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!is_paid_for(dir.path(), "hash", "file").await);
|
||||
mark_paid(dir.path(), "hash").await.unwrap();
|
||||
// All reads reopen disk; no process-local entitlement map exists.
|
||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
||||
assert!(!is_paid_for(dir.path(), "hash", "other").await);
|
||||
record_pending(dir.path(), "hash", "file", 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
||||
assert!(record_pending(dir.path(), "hash", "other", 12)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(record_pending(dir.path(), "hash", "file", 13)
|
||||
.await
|
||||
.is_err());
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
assert!(!is_paid_for(other.path(), "hash", "file").await);
|
||||
assert!(mark_paid(dir.path(), "unknown").await.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_or_unwritable_records_fail_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_pending(dir.path(), "../../token", "file", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(path(dir.path(), "../../token"), b"broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(lookup(dir.path(), "../../token").await.is_err());
|
||||
assert!(!is_paid_for(dir.path(), "../../token", "file").await);
|
||||
assert!(record_pending(dir.path(), "../../token", "file", 1)
|
||||
.await
|
||||
.is_err());
|
||||
let file = dir.path().join("not-directory");
|
||||
fs::write(&file, b"x").await.unwrap();
|
||||
assert!(record_pending(&file, "hash", "file", 1).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
const OWNED_DIR: &str = "purchased-content";
|
||||
const OWNED_INDEX: &str = "owned.json";
|
||||
@@ -66,20 +68,51 @@ fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
||||
.join(sanitize(content_id))
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
async fn load_index_checked(data_dir: &Path) -> Result<OwnedIndex> {
|
||||
match fs::read_to_string(index_path(data_dir)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => OwnedIndex::default(),
|
||||
Ok(s) => serde_json::from_str(&s)
|
||||
.context("Invalid purchase index; existing records were preserved"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OwnedIndex::default()),
|
||||
Err(error) => Err(error).context("Reading purchase index"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
load_index_checked(data_dir).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().context("Purchase path has no parent")?;
|
||||
fs::create_dir_all(parent).await?;
|
||||
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4()));
|
||||
let result = async {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp)
|
||||
.await?;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&temp, path).await?;
|
||||
fs::File::open(parent).await?.sync_all().await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temp).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||
let root = owned_root(data_dir);
|
||||
fs::create_dir_all(&root)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", root.display()))?;
|
||||
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||
fs::write(index_path(data_dir), content)
|
||||
atomic_write(&index_path(data_dir), content.as_bytes())
|
||||
.await
|
||||
.context("writing owned index")
|
||||
}
|
||||
@@ -98,17 +131,15 @@ pub async fn record_purchase(
|
||||
ecash_backend: &str,
|
||||
purchased_at: &str,
|
||||
) -> Result<()> {
|
||||
// Read-modify-write must be one serialized transaction. Never replace a
|
||||
// damaged index with an empty one, and never expose partially written bytes.
|
||||
let _lock = PURCHASE_WRITES.lock().await;
|
||||
let mut index = load_index_checked(data_dir).await?;
|
||||
let path = bytes_path(data_dir, onion, content_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
fs::write(&path, bytes)
|
||||
atomic_write(&path, bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||
|
||||
let mut index = load_index(data_dir).await;
|
||||
let entry = OwnedItem {
|
||||
onion: onion.to_string(),
|
||||
content_id: content_id.to_string(),
|
||||
@@ -165,3 +196,90 @@ pub async fn read_owned(
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
Some((mime, bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn concurrent_purchases_preserve_every_item_and_exact_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut jobs = tokio::task::JoinSet::new();
|
||||
for n in 0..24 {
|
||||
let root = dir.path().to_path_buf();
|
||||
jobs.spawn(async move {
|
||||
let id = format!("file-{n}");
|
||||
record_purchase(
|
||||
&root,
|
||||
"seller.onion",
|
||||
&id,
|
||||
&id,
|
||||
"text/plain",
|
||||
id.as_bytes(),
|
||||
5,
|
||||
"lightning",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
while let Some(result) = jobs.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
||||
for n in 0..24 {
|
||||
let id = format!("file-{n}");
|
||||
assert!(is_owned(dir.path(), "seller.onion", &id).await);
|
||||
let (mime, bytes) = read_owned(dir.path(), "seller.onion", &id).await.unwrap();
|
||||
assert_eq!(mime, "text/plain");
|
||||
assert_eq!(bytes, id.as_bytes());
|
||||
}
|
||||
record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"file-0",
|
||||
"file-0",
|
||||
"text/plain",
|
||||
b"updated",
|
||||
5,
|
||||
"lightning",
|
||||
"later",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
||||
assert_eq!(
|
||||
read_owned(dir.path(), "seller.onion", "file-0")
|
||||
.await
|
||||
.unwrap()
|
||||
.1,
|
||||
b"updated"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn damaged_index_is_preserved_instead_of_erasing_prior_ownership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(owned_root(dir.path())).await.unwrap();
|
||||
fs::write(index_path(dir.path()), b"damaged but preserve me")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"new",
|
||||
"new",
|
||||
"text/plain",
|
||||
b"bytes",
|
||||
5,
|
||||
"lightning",
|
||||
"now"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
fs::read(index_path(dir.path())).await.unwrap(),
|
||||
b"damaged but preserve me"
|
||||
);
|
||||
assert!(!bytes_path(dir.path(), "seller.onion", "new").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,7 @@ where
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||
&& crate::content_invoice::is_paid_for(data_dir, hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
|
||||
@@ -664,6 +664,10 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
|
||||
start_stopped_app_stacks(data_dir).await
|
||||
}
|
||||
|
||||
fn stack_member_needs_recovery(state: Option<&str>, user_stopped: bool) -> bool {
|
||||
!user_stopped && matches!(state, Some("exited" | "stopped" | "created" | "configured"))
|
||||
}
|
||||
|
||||
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
let user_stopped = load_user_stopped(data_dir).await;
|
||||
let mut report = RecoveryReport {
|
||||
@@ -677,24 +681,27 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Recovering stopped {} stack containers after boot",
|
||||
stack.name
|
||||
);
|
||||
// Healthy members must never acquire a restarting overlay merely
|
||||
// because the periodic recovery scan ran. Queue existing stopped
|
||||
// members only; recheck each immediately before starting below.
|
||||
let mut pending = Vec::new();
|
||||
for container in stack.containers {
|
||||
let state = container_state(container).await;
|
||||
if stack_member_needs_recovery(state.as_deref(), user_stopped.contains(*container)) {
|
||||
pending.push((*container).to_string());
|
||||
}
|
||||
}
|
||||
if pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!("Recovering stopped {} stack containers", stack.name);
|
||||
repair_stack_network_aliases(stack).await;
|
||||
|
||||
// Register the whole stack up front: the per-member dependency waits
|
||||
// below can take minutes, and the UI should say "Restarting", not
|
||||
// "Stopped", for members still queued behind them.
|
||||
pending_boot_starts_add(
|
||||
stack
|
||||
.containers
|
||||
.iter()
|
||||
.filter(|c| !user_stopped.contains(**c))
|
||||
.map(|c| (*c).to_string()),
|
||||
);
|
||||
pending_boot_starts_add(pending.iter().cloned());
|
||||
|
||||
for container in stack.containers {
|
||||
if !pending.iter().any(|name| name.as_str() == *container) {
|
||||
continue;
|
||||
}
|
||||
if user_stopped.contains(*container) {
|
||||
info!("Skipping user-stopped container: {}", container);
|
||||
continue;
|
||||
@@ -706,8 +713,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
Some(state) if stack_member_needs_recovery(Some(&state), false) => {}
|
||||
_ => {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
@@ -1534,3 +1541,24 @@ mod installed_concurrency_tests {
|
||||
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stack_recovery_overlay_tests {
|
||||
use super::stack_member_needs_recovery;
|
||||
#[test]
|
||||
fn only_existing_stopped_members_receive_recovery_overlay() {
|
||||
for state in [
|
||||
None,
|
||||
Some("running"),
|
||||
Some("paused"),
|
||||
Some("restarting"),
|
||||
Some("removing"),
|
||||
] {
|
||||
assert!(!stack_member_needs_recovery(state, false));
|
||||
}
|
||||
for state in ["exited", "stopped", "created", "configured"] {
|
||||
assert!(stack_member_needs_recovery(Some(state), false));
|
||||
assert!(!stack_member_needs_recovery(Some(state), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Post-1.8.22 regressions and retained release checklist
|
||||
|
||||
Status: OPEN. New regressions reported after publication on 2026-10-01.
|
||||
Do not mark complete from source changes alone. Preserve wallets, app state and
|
||||
operator uninstall decisions. Never send a second payment to recover delivery.
|
||||
The earlier Framework startup incident remains separately closed with operator
|
||||
acceptance; this is a new paid-file incident.
|
||||
|
||||
## Current tasks
|
||||
|
||||
- [ ] Recover the Framework's Lightning paid-file purchase without another payment;
|
||||
inspect buyer/seller evidence and verify delivered bytes.
|
||||
- [ ] Correct seller settlement verification when local-node payment skips polling.
|
||||
- [ ] Durable seller entitlements and safe buyer retry after navigation/restart;
|
||||
do not issue another payment on an uncertain or successful attempt.
|
||||
- [ ] Cache Lightning purchases, preserve ownership, optional Files copy, free repeat.
|
||||
- [ ] Diagnose mobile companion uploads on the affected route/device.
|
||||
- [ ] Real progress in the existing compact upload bar; no increased height.
|
||||
- [ ] Preserve uploads/progress across screens and original batch destination.
|
||||
- [ ] Cancel active transfer and queued files; truthful partial/error/server-save status.
|
||||
- [ ] Transparent transaction-filter container; single horizontal scrolling mobile row.
|
||||
- [ ] Immich displayed as one app, internal components hidden; diagnose restarting services.
|
||||
- [ ] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal.
|
||||
- [ ] Identify the other removed unexpected service from affected-node records.
|
||||
- [ ] Upgrade regression matrix: installed/stopped/restarting/removed/legacy apps,
|
||||
aliases, dependencies, inventory, desired-state reconciliation and data preservation.
|
||||
|
||||
- [ ] Portainer duplicate-network migration: retire the redundant managed repair
|
||||
override, preserve operator settings/state, verify generated command,
|
||||
actual request namespace, dashboard readiness and repeated reconciliation.
|
||||
|
||||
- [ ] Lightning cooperative-close fees: Standard/Medium/Fast/Custom selection,
|
||||
explicit default target, strict backend validation and forwarding, error
|
||||
handling, mobile layout and no real channel closure during tests.
|
||||
|
||||
- [ ] Apps search clear control: My Apps, Services and App Store, desktop/mobile,
|
||||
existing design tokens, right-aligned icon, no size change, keyboard focus.
|
||||
|
||||
## Retained release work (previous acceptance is not new-regression acceptance)
|
||||
|
||||
- Mempool patched image/catalog version agreement, update-button clearing, one card.
|
||||
- Minibits PR160, Lightning address availability, concise single-column backup copy.
|
||||
- Framework LND startup/Receive and unknown-vs-zero balance behavior.
|
||||
- Friendly Bitcoin warmup; LND waiting for install/sync; Bitcoin UI during IBD;
|
||||
headless Phoenixd without self-waiting or bogus launch action.
|
||||
- Cashu same-mint paid files, exact amounts/change/refund, errors, stored bytes,
|
||||
Files copy and repeat access without re-payment.
|
||||
- mempool.space public explorer fallback, preserving local/custom configuration.
|
||||
- Optional install pruning and consistent automatic-pruning policy.
|
||||
- X250 kiosk version picker layering/contrast and pruning layout.
|
||||
- AIUI single desktop/mobile background, transparent embedded layers,
|
||||
preserved standalone wallpaper.
|
||||
- PR review/fixes/tests and normal merge/closure (160 previously shipped;
|
||||
161/162 merged and included in 1.8.22).
|
||||
- Installed inventory retained during app restart/hard-refresh.
|
||||
- Correct iframe/browser launch readiness, useful errors and delayed startup.
|
||||
- GitWorkshop payload/build contexts, progress and persistence after refresh.
|
||||
- Gitea/Portainer same-server Git from actual request namespace; URLs, auth,
|
||||
fresh installation in either order, migration/rollback, restart/reboot,
|
||||
Git/SSH/LFS/registry/browser compatibility and data/stack preservation.
|
||||
- NPM correct admin port/URL, malformed URL behavior, bind-aware readiness,
|
||||
persistent backed-up tunnel/LND port-conflict repair on OTA and ISO.
|
||||
- Angor headless indexer on DEV BOX only, full unpruned Bitcoin/Mempool/ElectrumX
|
||||
prerequisites, optional separate relay, official icon with green white areas.
|
||||
- Compact named readiness messages and bottom-aligned app-card actions.
|
||||
- Remove unused integration/build fixtures from Apps/Services, preserve app data.
|
||||
- Safe network doctor, no all-app stop/reset on failed egress probe.
|
||||
- No orphan companion resurrection; retain existing companion security repairs.
|
||||
- Current companion image registry, build contexts, runtime asset promotion order,
|
||||
generated-service argument quoting and graceful Bitcoin/LND shutdown.
|
||||
- OTA + RAW ISO, root signatures/catalog compatibility/checksums, independently
|
||||
verified public files, Git/ngit source/releases and fleet discovery.
|
||||
- Correct LAN SCP command for the new ISO.
|
||||
|
||||
## Explicit boundaries/follow-ups
|
||||
|
||||
- Full-chain Angor indexing awaits development Bitcoin IBD.
|
||||
- Primal automatic comment exceeding Minibits metadata limit: previously accepted
|
||||
upstream limitation, no unsupported local identity/metadata rewrite.
|
||||
- Lost-response ecash seller receipt redesign is a separately accepted follow-up;
|
||||
do not claim an uncertain refund completed or automatically pay twice.
|
||||
- Optional external-provider/hardware tests must be labelled if not exercised.
|
||||
|
||||
## Initial source evidence
|
||||
|
||||
`PeerFiles.vue::payWithLightning` immediately downloaded after buyer payment,
|
||||
while only seller `handle_content_invoice_status` marked a pending invoice paid.
|
||||
Seller download checked only that cached flag. This matches the reported error
|
||||
and was confirmed against the live seller: LND retained a settled invoice while
|
||||
the seller invoice-status endpoint returned HTTP 404 after management restart.
|
||||
`content_invoice.rs` stored all entitlements only in process memory with a
|
||||
one-hour TTL, losing both pending and paid access on restart/expiry.
|
||||
Lightning download returned transient base64 without the Cashu ownership cache.
|
||||
CloudFolder's view-local spinner had no byte progress/cancel; batch upload read
|
||||
`currentPath` independently for each file, allowing navigation to move destinations.
|
||||
Immich's underscore dependencies are scanner-excluded; hyphen manifest IDs are
|
||||
not. Live inventory confirmed both hyphenated synthetic entries while the
|
||||
actual underscore-named containers had remained running for nine days.
|
||||
|
||||
## Access / acceptance
|
||||
|
||||
Operator provided updated Framework SSH authentication privately in chat.
|
||||
Do not put credentials or deployment addresses in this public document.
|
||||
Framework was reached over SSH. Native Bitcoin, LND and all three Immich
|
||||
container identities/start times were recorded before candidate deployment.
|
||||
The kiosk is at its login page. Dashboard password authentication succeeds but
|
||||
requires the operator's second factor; normal uninstall acceptance remains pending.
|
||||
|
||||
Confirmed live evidence:
|
||||
|
||||
- A 10,000-sat peer-file invoice settled at 12:19:29 UTC. After the management
|
||||
service restarted at 12:50, invoice-status returned unknown invoice. Buyer
|
||||
identity and confirmation that this is the reported sale remain pending.
|
||||
- The matching item currently allows free access; preserve that operator setting.
|
||||
- CryptPad has no container but remains in installed-apps metadata. Uninstall
|
||||
repeatedly aborts because the removed catalog ID has no manifest.
|
||||
- Immich server/database/cache are running; synthetic hyphenated dependencies
|
||||
appear stopped and the recovery overlay briefly advertises restarting.
|
||||
- The other removed service was Core Lightning; uninstall tombstones exist.
|
||||
- No Android resource-upload POST appears in the inspected recent nginx log.
|
||||
This does not establish why the affected companion failed.
|
||||
|
||||
## Candidate implementation and validation
|
||||
|
||||
Source changes persist seller entitlements with atomic writes, verify settlement
|
||||
at delivery, recover older Lightning entitlements from the seller's LND invoice,
|
||||
perform the status handshake for older sellers, and cache delivered Lightning
|
||||
files. Buyer purchase bytes and the shared ownership index now use atomic,
|
||||
synced writes and a serialized read/modify/write transaction; a corrupt index
|
||||
fails the write instead of silently replacing existing ownership. The browser saves the invoice before payment and retries delivery without
|
||||
another payment. Browser receipts are not yet a node-wide recovery store.
|
||||
|
||||
The upload queue now belongs to the shared Cloud store, captures its original
|
||||
folder, reports actual sent bytes and server completion, and cancels its active
|
||||
XHR and remaining queue. The fixed-height bar remains available across routes.
|
||||
Transaction filters use a transparent container and one scrollable row. Immich
|
||||
aliases normalize to their real component names and internal cards are hidden.
|
||||
Unknown catalog entries no longer prevent the regular uninstall flow.
|
||||
|
||||
Validation so far (additional acceptance still pending):
|
||||
|
||||
- Final isolated backend suite: **1,631 passed**, zero failed, four optional
|
||||
tests ignored. This includes invoice settlement/amount boundaries, durable
|
||||
seller records, concurrent buyer ownership, damaged-index preservation,
|
||||
Portainer override retirement/idempotence/customization/backup failures,
|
||||
recovery overlays and channel-close fee forwarding/validation.
|
||||
- Final frontend suite: **1,157 passed** across 142 files. Production build
|
||||
passed. Six payment-recovery and twelve channel-close tests are included.
|
||||
- Real FileBrowser uploads at 1440px and 390px: exact bytes and original folder
|
||||
verified after navigation, 44px bar, cancellation and queue stop passed.
|
||||
- Mobile viewport acceptance is not physical Android companion acceptance.
|
||||
- Release backend compiled successfully. Candidate has not yet been deployed
|
||||
or included in another OTA/ISO. Published 1.8.22 artifacts remain unchanged.
|
||||
|
||||
Release gates still include actual-node payment recovery/delivery, durable
|
||||
CryptPad removal through normal controls, Immich inventory after refresh/restart,
|
||||
physical companion diagnosis, and remaining upgrade regression acceptance.
|
||||
No new payments, native-service restarts or wallet changes were used in testing.
|
||||
|
||||
## Additional live Portainer regression
|
||||
|
||||
The X250 user service exited 125 because the generated command supplied
|
||||
`--network slirp4netns` twice. The manifest already supplies the network, while
|
||||
an older Archipelago-created `archy-same-node-network.conf` drop-in adds it
|
||||
again. Quadlet's Network directives accumulate; they do not override each other.
|
||||
This repair artifact should have been retired when the declarative fix shipped.
|
||||
|
||||
The live repair backed up the override and Portainer state, removed only the
|
||||
exact redundant override, reloaded user systemd and restarted Portainer. API
|
||||
status returned HTTP 200 with version 2.45.0; the actual kiosk's package state
|
||||
reported running and UI-ready. Bitcoin/LND and the production site's container
|
||||
identities/start times remained unchanged. The source migration now detects
|
||||
this exact managed override before preparing the persistent restart obligation,
|
||||
backs up app state, retires the redundant file with a retained copy, and reloads
|
||||
and restarts through normal reconciliation. Custom overrides are preserved.
|
||||
Automated migration coverage passed; final candidate deployment remains pending.
|
||||
|
||||
## Channel-close fee selection
|
||||
|
||||
The existing close UI sent only the channel point, and the backend forwarded
|
||||
only `force=false`. LND therefore used its lax default confirmation target.
|
||||
The candidate reuses the channel-opening fee choices (six/three/one block target,
|
||||
or custom target/rate), explicitly sends six blocks for legacy clients that omit
|
||||
fees, and validates query parameters before accessing the wallet. Cooperative
|
||||
fees are never silently applied to force closes. Close RPC retries are disabled
|
||||
so a timeout cannot silently repeat this mutation.
|
||||
|
||||
Protocol reference: [LND CloseChannel](https://lightning.engineering/api-docs/api/lnd/lightning/close-channel/).
|
||||
Fee targets are estimates, not guaranteed confirmation times. Tests use mocked
|
||||
requests; no production channel is closed to verify the feature.
|
||||
|
||||
Additional browser acceptance:
|
||||
|
||||
- Transaction filters at 390px: computed transparent background, one row and
|
||||
horizontal overflow verified.
|
||||
- Close-channel selector at 1440px and 390px: preset/custom controls visible,
|
||||
no overflow, custom 25 sat/vB forwarded. The close request was intercepted;
|
||||
no real channel closure or wallet mutation occurred.
|
||||
- All three Apps search screens at both widths: clear icon stays inside the
|
||||
field; click/Escape clear; input retains focus; desktop 40px/mobile 52px heights
|
||||
stay unchanged. Shared design-system search-field classes are retained.
|
||||
- Portainer remained active with zero service restarts and no pending marker.
|
||||
Its real network namespace read smart HTTP Git refs and the Compose file.
|
||||
Original persistent mounts were unchanged. The old integration test containers
|
||||
are absent from dev, Framework and X250. One leftover upload-test folder was
|
||||
removed after checking it contained only this task's test files.
|
||||
@@ -19,6 +19,8 @@
|
||||
<AppLauncherOverlay />
|
||||
<AppCredentialInterstitial />
|
||||
|
||||
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
|
||||
|
||||
<!-- Global toast notifications -->
|
||||
<ToastStack />
|
||||
|
||||
@@ -96,6 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UploadProgress from '@/components/cloud/UploadProgress.vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
|
||||
@@ -336,3 +336,45 @@ describe('sanitizePath', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload transport progress and cancellation', () => {
|
||||
class UploadXHR {
|
||||
static instances: UploadXHR[] = []
|
||||
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
|
||||
onload: (() => void) | null = null
|
||||
onabort: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
status = 200
|
||||
withCredentials = false
|
||||
contentType = 'application/json'
|
||||
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
|
||||
abort = vi.fn(() => this.onabort?.())
|
||||
getResponseHeader() { return this.contentType }
|
||||
constructor() { UploadXHR.instances.push(this) }
|
||||
}
|
||||
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
|
||||
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
|
||||
const controller = new AbortController(); const onProgress = vi.fn()
|
||||
const file = new File(['data'], 'a #.txt')
|
||||
let complete = false
|
||||
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
const xhr = UploadXHR.instances[0]!
|
||||
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
|
||||
expect(xhr.send).toHaveBeenCalledWith(file)
|
||||
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
|
||||
expect(complete).toBe(false)
|
||||
xhr.onload?.(); await job; expect(complete).toBe(true)
|
||||
})
|
||||
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
|
||||
const controller = new AbortController()
|
||||
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
|
||||
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
|
||||
await expect(next).rejects.toThrow('login page')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,7 +201,43 @@ class FileBrowserClient {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
|
||||
if (options) {
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
|
||||
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
|
||||
const send = () => new Promise<number>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const cleanup = () => options.signal.removeEventListener('abort', abort)
|
||||
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
||||
xhr.open('POST', url)
|
||||
xhr.withCredentials = true
|
||||
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
|
||||
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
|
||||
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
|
||||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
||||
xhr.onload = () => {
|
||||
cleanup()
|
||||
if (xhr.status === 401) { resolve(401); return }
|
||||
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
|
||||
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
|
||||
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
|
||||
}
|
||||
resolve(xhr.status)
|
||||
}
|
||||
options.signal.addEventListener('abort', abort, { once: true })
|
||||
if (options.signal.aborted) { abort(); return }
|
||||
xhr.send(file)
|
||||
})
|
||||
if (await send() === 401) {
|
||||
this._authenticated = false
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
|
||||
}
|
||||
return
|
||||
}
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="label"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
@keydown.esc.prevent="clear"
|
||||
/>
|
||||
<button
|
||||
v-if="query.length"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
|
||||
@click="clear"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
defineProps<{ placeholder: string; label: string }>()
|
||||
const query = defineModel<string>({ default: '' })
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
async function clear() {
|
||||
query.value = ''
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
}
|
||||
</script>
|
||||
@@ -353,15 +353,57 @@
|
||||
|
||||
<!-- Close Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (closeTarget = null)">
|
||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||
<!-- Fee selection -->
|
||||
<div class="mb-4">
|
||||
<label class="text-white/60 text-sm block mb-1">Fee</label>
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="preset in feePresets"
|
||||
:key="preset.key"
|
||||
@click="closeForm.feePreset = preset.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
|
||||
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
|
||||
<input
|
||||
v-model.number="closeForm.customConfTarget"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1008"
|
||||
placeholder="6"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
|
||||
<input
|
||||
v-model.number="closeForm.customSatPerVbyte"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5000"
|
||||
placeholder="—"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="closeError" class="mb-3 alert-error">
|
||||
<p class="text-xs">{{ closeError }}</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="closeChannel"
|
||||
:disabled="closingChannel"
|
||||
@@ -457,8 +499,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
|
||||
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
|
||||
{ key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 },
|
||||
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
@@ -577,22 +619,24 @@ function loadChannels(): Promise<void> {
|
||||
return main
|
||||
}
|
||||
|
||||
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
const form = openForm.value
|
||||
function feeParams(
|
||||
form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value,
|
||||
setError: (message: string) => void = message => { openError.value = message },
|
||||
): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (form.feePreset !== 'custom') {
|
||||
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
|
||||
}
|
||||
const rate = form.customSatPerVbyte
|
||||
const conf = form.customConfTarget
|
||||
if (rate != null && rate !== 0) {
|
||||
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
|
||||
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('Sats per vByte must be a whole number between 1 and 5000'); return null }
|
||||
return { sat_per_vbyte: Math.floor(rate) }
|
||||
}
|
||||
if (conf != null && conf !== 0) {
|
||||
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
|
||||
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null }
|
||||
return { target_conf: Math.floor(conf) }
|
||||
}
|
||||
openError.value = 'Custom fee requires target confirmations or sats per vByte'
|
||||
setError('Custom fee requires target confirmations or sats per vByte')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -629,7 +673,12 @@ async function openChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
|
||||
const closeForm = ref(defaultCloseForm())
|
||||
|
||||
function confirmClose(ch: Channel) {
|
||||
if (closingChannel.value) return
|
||||
closeForm.value = defaultCloseForm()
|
||||
closeTarget.value = ch
|
||||
closeError.value = null
|
||||
}
|
||||
@@ -637,12 +686,15 @@ function confirmClose(ch: Channel) {
|
||||
async function closeChannel() {
|
||||
if (closingChannel.value || !closeTarget.value) return
|
||||
closeError.value = null
|
||||
const fee = feeParams(closeForm.value, message => { closeError.value = message })
|
||||
if (!fee) return
|
||||
closingChannel.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'lnd.closechannel',
|
||||
params: { channel_point: closeTarget.value.channel_point },
|
||||
timeout: 30000,
|
||||
params: { channel_point: closeTarget.value.channel_point, ...fee },
|
||||
timeout: 45000,
|
||||
maxRetries: 1,
|
||||
})
|
||||
closeTarget.value = null
|
||||
await loadChannels()
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
|
||||
backdrop blur alone keeps the pinned tabs legible over scrolling
|
||||
rows without painting an opaque container onto the modal. -->
|
||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
|
||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
class="px-2.5 py-1 rounded-full text-xs transition-colors"
|
||||
class="shrink-0 whitespace-nowrap px-2.5 py-1 rounded-full text-xs transition-colors"
|
||||
:class="activeFilter === f.key
|
||||
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
|
||||
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
|
||||
vi.mock('@/composables/useCachedResource', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
|
||||
})
|
||||
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
|
||||
function open() {
|
||||
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
|
||||
const vm = (wrapper.vm as any).$.setupState
|
||||
vm.confirmClose(channel)
|
||||
return { wrapper, vm }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
|
||||
describe('channel closing fee choice', () => {
|
||||
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
|
||||
await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
|
||||
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
|
||||
})
|
||||
it('sends the custom rate instead of a confirmation target', async () => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
|
||||
await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
|
||||
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
|
||||
})
|
||||
it('requires a custom value and accepts a custom confirmation target', async () => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
|
||||
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
|
||||
})
|
||||
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
|
||||
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
|
||||
})
|
||||
it('prevents duplicate submits while a close is pending', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
|
||||
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
|
||||
<button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
@@ -61,6 +61,7 @@
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:disabled="uploading"
|
||||
class="hidden"
|
||||
multiple
|
||||
@change="handleFileSelect"
|
||||
@@ -74,6 +75,7 @@ import { ref } from 'vue'
|
||||
defineProps<{
|
||||
breadcrumbs: { name: string; path: string }[]
|
||||
viewMode: 'list' | 'grid'
|
||||
uploading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
|
||||
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
|
||||
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
|
||||
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
|
||||
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
|
||||
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
|
||||
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useCloudStore } from '@/stores/cloud'
|
||||
defineProps<{ floating?: boolean }>()
|
||||
const store = useCloudStore()
|
||||
const task = computed(() => store.upload)
|
||||
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
|
||||
const label = computed(() => {
|
||||
const t = task.value
|
||||
if (!t) return ''
|
||||
if (t.error) return t.error
|
||||
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
|
||||
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
|
||||
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
|
||||
})
|
||||
</script>
|
||||
@@ -231,3 +231,50 @@ describe('useCloudStore', () => {
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistent upload batch', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||
it('retains the destination and byte progress across folder navigation', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
|
||||
let release!: () => void
|
||||
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
|
||||
options!.onProgress(2)
|
||||
await new Promise<void>(resolve => { release = resolve })
|
||||
}).mockResolvedValueOnce(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
|
||||
expect(store.upload?.sent).toBe(2)
|
||||
expect(store.upload?.total).toBe(6)
|
||||
await store.navigate('/elsewhere')
|
||||
expect(useCloudStore().upload?.active).toBe(true)
|
||||
release(); await job
|
||||
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
|
||||
expect(store.currentPath).toBe('/elsewhere')
|
||||
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
|
||||
})
|
||||
it('aborts the active request, stops the queue and preserves completed files', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
|
||||
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
|
||||
}))
|
||||
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
|
||||
const job = store.uploadFiles(files)
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
store.cancelUpload(); await job
|
||||
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
|
||||
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
|
||||
store.dismissUpload(); expect(store.upload).toBeNull()
|
||||
})
|
||||
it('keeps failures visible and does not start a second overlapping batch', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
let fail!: (error: Error) => void
|
||||
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
|
||||
const file = new File(['x'], 'one')
|
||||
const job = store.uploadFiles([file]); await store.uploadFiles([file])
|
||||
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
|
||||
fail(new Error('No space')); await job
|
||||
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,41 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const authenticated = ref(false)
|
||||
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
|
||||
let uploadController: AbortController | null = null
|
||||
function cancelUpload() { uploadController?.abort() }
|
||||
function dismissUpload() { if (!upload.value?.active) upload.value = null }
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (!files.length || upload.value?.active) return
|
||||
const destination = currentPath.value
|
||||
const controller = new AbortController()
|
||||
uploadController = controller
|
||||
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
|
||||
upload.value = task
|
||||
let completedBytes = 0
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
upload.value.filename = file.name
|
||||
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
|
||||
if (upload.value?.active) upload.value.sent = completedBytes + sent
|
||||
} })
|
||||
completedBytes += file.size
|
||||
upload.value.sent = completedBytes
|
||||
upload.value.completed++
|
||||
}
|
||||
} catch (error) {
|
||||
upload.value.cancelled = controller.signal.aborted
|
||||
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
|
||||
} finally {
|
||||
upload.value.active = false
|
||||
uploadController = null
|
||||
// Navigation must never redirect subsequent files into the new folder.
|
||||
pathCache.delete(destination)
|
||||
if (currentPath.value === destination) await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Per-path listing cache: re-entering a folder paints the last listing
|
||||
// immediately (no spinner) while the fresh listing loads behind it.
|
||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
||||
@@ -131,6 +166,7 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
upload, uploadFiles, cancelUpload, dismissUpload,
|
||||
currentPath,
|
||||
items,
|
||||
loading,
|
||||
|
||||
@@ -56,14 +56,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
@@ -106,14 +99,7 @@
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
@@ -374,6 +360,7 @@ let appsAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppSearchField from '@/components/AppSearchField.vue'
|
||||
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadProgress />
|
||||
|
||||
<!-- App Not Installed -->
|
||||
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -75,19 +77,11 @@
|
||||
<p class="text-sm text-white/50">Files will be added to the current folder</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-white/70">Uploading...</span>
|
||||
</div>
|
||||
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
|
||||
<span class="text-sm text-red-400">{{ uploadError }}</span>
|
||||
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<CloudToolbar
|
||||
:breadcrumbs="cloudStore.breadcrumbs"
|
||||
:view-mode="viewMode"
|
||||
:uploading="!!cloudStore.upload?.active"
|
||||
@navigate="navigateCloudPath"
|
||||
@refresh="cloudStore.refresh()"
|
||||
@upload="handleUpload"
|
||||
@@ -105,6 +99,7 @@
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
:uploading="!!cloudStore.upload?.active"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@@ -159,6 +154,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UploadProgress from '@/components/cloud/UploadProgress.vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@@ -193,7 +189,6 @@ watch(() => cloudStore.currentPath, (path) => {
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||
|
||||
@@ -348,7 +343,6 @@ function handleShare(path: string, name: string, isDir: boolean) {
|
||||
shareTarget.value = { path, name, isDir }
|
||||
}
|
||||
|
||||
const uploadError = ref<string | null>(null)
|
||||
const draggingOver = ref(false)
|
||||
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -372,17 +366,7 @@ function onDrop(e: DragEvent) {
|
||||
}
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
uploading.value = true
|
||||
uploadError.value = null
|
||||
try {
|
||||
for (const file of files) {
|
||||
await cloudStore.uploadFile(file)
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
await cloudStore.uploadFiles(files)
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
|
||||
@@ -49,14 +49,7 @@
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
|
||||
@@ -82,14 +75,7 @@
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,6 +331,7 @@ let discoverAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppSearchField from '@/components/AppSearchField.vue'
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
accepts for this item are offered -->
|
||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||
<button
|
||||
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
|
||||
v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="ecashPreparing || downloading === payItem.id"
|
||||
@click="prepareEcashPay"
|
||||
@@ -420,8 +420,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
|
||||
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
|
||||
<span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span>
|
||||
<span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -843,6 +843,22 @@ const onchainError = ref('')
|
||||
const onchainCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
|
||||
const lnReceipt = ref<LightningReceipt | null>(null)
|
||||
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
|
||||
function readReceipt(onion: string, id: string): LightningReceipt | null {
|
||||
const raw = localStorage.getItem(receiptKey(onion, id))
|
||||
if (!raw) return null
|
||||
const receipt = JSON.parse(raw) as LightningReceipt
|
||||
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
|
||||
return receipt
|
||||
}
|
||||
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
|
||||
// Must succeed before handing an invoice to a payer. A failed transfer or
|
||||
// navigation must never turn Retry into a second payment.
|
||||
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
||||
lnReceipt.value = receipt
|
||||
}
|
||||
const onchainPaying = ref(false)
|
||||
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -1095,6 +1111,8 @@ function openPayModal(item: CatalogItem) {
|
||||
onchainCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
|
||||
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
@@ -1116,6 +1134,8 @@ function closePayModal() {
|
||||
* immediately for any external wallet).
|
||||
*/
|
||||
function openQrPay() {
|
||||
payMode.value = 'qr'
|
||||
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
|
||||
payMode.value = 'qr'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
@@ -1139,6 +1159,10 @@ function openQrPay() {
|
||||
* forth doesn't silently stop watching for payment). */
|
||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
if (qrTab.value === tab) return
|
||||
if (tab === 'onchain' && lnReceipt.value) {
|
||||
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
|
||||
return
|
||||
}
|
||||
qrTab.value = tab
|
||||
if (tab === 'onchain') {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
@@ -1338,20 +1362,27 @@ async function prepareEcashPay() {
|
||||
* mobile companion ("paid but never unlocked"); the viewer's Save button
|
||||
* still offers an explicit download.
|
||||
*/
|
||||
function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) {
|
||||
const onion = seller || props.peerId || currentPeer.value?.onion
|
||||
const url = base64Data !== undefined
|
||||
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
|
||||
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
|
||||
if (onion) {
|
||||
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
|
||||
lnReceipt.value = null
|
||||
}
|
||||
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||
const mime = mimeType || item.mime_type
|
||||
if (mime.startsWith('audio/')) {
|
||||
// Straight to the bottom-bar player — the blob URL intentionally stays
|
||||
// alive while the bar owns playback.
|
||||
audioPlayer.play(
|
||||
URL.createObjectURL(base64ToBlob(base64Data, mime)),
|
||||
url,
|
||||
item.filename.split('/').pop() || item.filename,
|
||||
)
|
||||
} else {
|
||||
releaseViewerUrl()
|
||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime))
|
||||
viewerUrl.value = url
|
||||
viewerMime.value = mime
|
||||
viewerItem.value = item
|
||||
}
|
||||
@@ -1399,7 +1430,7 @@ async function payWithInvoice() {
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
@@ -1410,6 +1441,7 @@ async function payWithInvoice() {
|
||||
return
|
||||
}
|
||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
||||
keepReceipt(onion, item.id, invoiceData.value)
|
||||
try {
|
||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
||||
} catch {
|
||||
@@ -1424,54 +1456,46 @@ async function payWithInvoice() {
|
||||
|
||||
/**
|
||||
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
||||
* release the file. payLightningInvoice resolves to a real terminal state, so
|
||||
* on success the payment_hash is immediately valid as the download gate token.
|
||||
* release the file. Keep the invoice before payment so uncertain outcomes can
|
||||
* retry seller verification and delivery without sending a second payment.
|
||||
*/
|
||||
async function payWithLightning() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || lnPaying.value) return
|
||||
|
||||
lnPaying.value = true
|
||||
lnError.value = ''
|
||||
try {
|
||||
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
return
|
||||
let inv = readReceipt(onion, item.id)
|
||||
if (!inv) {
|
||||
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
|
||||
})
|
||||
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
|
||||
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
|
||||
keepReceipt(onion, item.id, inv)
|
||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||
if (pay.status === 'failed') {
|
||||
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
|
||||
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
||||
return
|
||||
}
|
||||
if (pay.status === 'pending') {
|
||||
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
||||
return
|
||||
}
|
||||
}
|
||||
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
|
||||
// multi-hop route resolves via status polling instead of a false failure.
|
||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||
if (pay.status === 'failed') {
|
||||
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
||||
return
|
||||
}
|
||||
if (pay.status === 'pending') {
|
||||
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
|
||||
return
|
||||
}
|
||||
// 3. Settled — pull the file using the payment hash as the gate token.
|
||||
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
lnReceipt.value = inv
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 960000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
openPurchased(item, dl.data, dl.mime_type)
|
||||
} else {
|
||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
|
||||
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
|
||||
} catch (e: unknown) {
|
||||
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
|
||||
} finally {
|
||||
lnPaying.value = false
|
||||
}
|
||||
lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
|
||||
} finally { lnPaying.value = false }
|
||||
}
|
||||
|
||||
function scheduleInvoicePoll() {
|
||||
@@ -1487,19 +1511,19 @@ async function pollInvoice() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
method: 'content.invoice-status',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 30000,
|
||||
})
|
||||
if (res?.paid) {
|
||||
// Settled — pull the file using the payment hash as the gate token.
|
||||
invoiceWaiting.value = false
|
||||
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 960000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
openPurchased(item, dl.data, dl.mime_type)
|
||||
if (dl?.data !== undefined || dl?.owned === true) {
|
||||
openPurchased(item, dl.data, dl.mime_type, onion)
|
||||
} else {
|
||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
|
||||
const hash = 'a'.repeat(64)
|
||||
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
|
||||
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
|
||||
const download = vi.fn()
|
||||
async function open() {
|
||||
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
await flushPromises()
|
||||
// Drive the actual component payment handlers, asserting RPC effects rather
|
||||
// than a duplicate implementation of the payment state machine.
|
||||
const vm = (wrapper.vm as any).$.setupState
|
||||
vm.openPayModal(item)
|
||||
return { wrapper, vm }
|
||||
}
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); vi.clearAllMocks()
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
||||
if (method === 'content.download-peer-invoice') return download()
|
||||
return { items: [] }
|
||||
})
|
||||
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
|
||||
})
|
||||
describe('Lightning file delivery recovery', () => {
|
||||
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
|
||||
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
|
||||
vm.closePayModal(); vm.openPayModal(item)
|
||||
await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
|
||||
expect(download).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
|
||||
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
|
||||
download.mockResolvedValue({ error: 'Pending' })
|
||||
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
|
||||
const second = await open(); await second.vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
second.wrapper.unmount()
|
||||
})
|
||||
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
|
||||
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||
expect(vm.viewerMime).toBe('video/mp4')
|
||||
expect(localStorage.getItem(receiptKey)).toBeNull()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
|
||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('never pays again when the saved receipt is corrupt', async () => {
|
||||
localStorage.setItem(receiptKey, '{broken')
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
|
||||
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
|
||||
const { wrapper, vm } = await open()
|
||||
vm.openQrPay(); await flushPromises()
|
||||
expect(vm.payMode).toBe('qr')
|
||||
expect(vm.qrTab).toBe('lightning')
|
||||
vm.selectQrTab('onchain'); await flushPromises()
|
||||
expect(vm.qrTab).toBe('lightning')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not send payment if the recovery record cannot be saved', async () => {
|
||||
const { wrapper, vm } = await open()
|
||||
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
|
||||
await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
save.mockRestore(); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -246,3 +246,9 @@ it('does not display a stale Mempool frontend alias beside its live package', ()
|
||||
expect(shown.map(([id]) => id)).toEqual(['mempool'])
|
||||
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
|
||||
})
|
||||
|
||||
it('shows Immich as one app without internal database/cache cards in either tab', () => {
|
||||
const entries: [string, PackageDataEntry][] = ['immich', 'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis'].map(id => [id, makePkg(id, id, 'media')])
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['immich'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export const isServiceContainer = sharedIsServiceContainer
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
// Stack internals belong to their parent app, including cached inventories
|
||||
// from nodes predating backend alias normalization.
|
||||
'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis',
|
||||
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
||||
// package; showing the generated container as a second Services entry
|
||||
// defeats the one-app presentation.
|
||||
|
||||
Reference in New Issue
Block a user