Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s

This commit is contained in:
archipelago
2026-10-01 10:31:55 -04:00
parent 227174e541
commit f4d3455496
31 changed files with 1638 additions and 269 deletions
+11
View File
@@ -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.
+63 -26
View File
@@ -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;
}
}
+84 -9
View File
@@ -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"]);
}
}
+101 -4
View File
@@ -473,6 +473,7 @@ impl RpcHandler {
));
}
let fee_query = close_channel_fee_query(&params)?;
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(&params).is_err(), "{params}");
}
}
}
+128
View File
@@ -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());
+140 -19
View File
@@ -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();
+136 -61
View File
@@ -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());
}
}
+130 -12
View File
@@ -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());
}
}
+1 -1
View File
@@ -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;
}
+45 -17
View File
@@ -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));
}
}
}
+206
View File
@@ -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.
+3
View File
@@ -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')
})
})
+37 -1
View File
@@ -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 })
})
})
+36
View File
@@ -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,
+3 -16
View File
@@ -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'
+6 -22
View File
@@ -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) {
+3 -16
View File
@@ -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'
+74 -50
View File
@@ -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([])
})
+3
View File
@@ -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.