From 687c216e65c735ce743dfd1ef47c7b4c9b7b7d2b Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Apr 2026 15:43:45 -0400 Subject: [PATCH] release(v1.7.13-alpha): proxy app catalog server-side (CORS + CSP fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discover / Marketplace page fetched the app catalog directly from git.tx1138.com/lfg2025/app-catalog/raw/.../catalog.json in the browser. Two blockers hit the fleet simultaneously: (1) tx1138's Gitea doesn't emit Access-Control-Allow-Origin so the HTTPS fetch got CORS-blocked; (2) the HTTP IP-port fallback (http://23.182.128.160:3000/...) falls outside the node's `connect-src` CSP. Users saw the hardcoded fallback instead of the live catalog. Backend: new authenticated GET /api/app-catalog handler uses reqwest to pull catalog.json server-side (15s timeout) and returns it with application/json + 1h Cache-Control. Tries the HTTPS URL first, HTTP IP-port second. Frontend: curatedApps.ts now calls /api/app-catalog (same-origin, no CORS/CSP) with credentials included so the session cookie authenticates the proxy. Baked /catalog.json stays as the last resort. Artefacts: archipelago 0aaf7262…b979f22c 40371192 archipelago-frontend-1.7.13-alpha.tar.gz 27505811…efc6f4142 76982505 Co-Authored-By: Claude Opus 4.7 (1M context) --- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/api/handler/mod.rs | 59 ++++++++++++++++++++++ neode-ui/src/views/discover/curatedApps.ts | 14 ++--- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index b97fcdfb..fe7f5725 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.12-alpha" +version = "1.7.13-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index d75b7af6..74f2bb59 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.12-alpha" +version = "1.7.13-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 3c1f49ed..ce049ab7 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -113,6 +113,53 @@ impl ApiHandler { } } + /// Server-side fetch of the upstream app catalog so the browser can + /// load it without fighting CORS (git.tx1138.com emits no ACAO) or + /// CSP (the fallback IP-port URL isn't in `connect-src`). Tries the + /// upstream URLs in the same order the frontend used, returns the + /// first 2xx response. 15s total timeout. + async fn handle_app_catalog_proxy() -> Result> { + const UPSTREAMS: &[&str] = &[ + "https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json", + "http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json", + ]; + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + { + Ok(c) => c, + Err(e) => { + return Ok(build_response( + hyper::StatusCode::INTERNAL_SERVER_ERROR, + "text/plain", + hyper::Body::from(format!("client build failed: {}", e)), + )); + } + }; + for url in UPSTREAMS { + match client.get(*url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(bytes) = resp.bytes().await { + return Ok(Response::builder() + .status(hyper::StatusCode::OK) + .header("Content-Type", "application/json") + .header("Cache-Control", "public, max-age=3600") + .body(hyper::Body::from(bytes)) + .unwrap_or_else(|_| { + Response::new(hyper::Body::from("proxy response build failed")) + })); + } + } + _ => continue, + } + } + Ok(build_response( + hyper::StatusCode::BAD_GATEWAY, + "text/plain", + hyper::Body::from("all upstream catalog URLs failed"), + )) + } + /// Build a 401 Unauthorized JSON response. fn unauthorized() -> Response { let body = serde_json::json!({ "error": "Unauthorized" }); @@ -352,6 +399,18 @@ impl ApiHandler { // Electrs status — unauthenticated (read-only sync status) (Method::GET, "/electrs-status") => Self::handle_electrs_status().await, + // App-catalog proxy — fetches catalog.json from the configured + // upstream URLs server-side so the browser doesn't hit CORS + // (git.tx1138.com has no ACAO header) or CSP (IP-port upstream + // falls outside `connect-src`). Session-authenticated so only + // the logged-in node owner can spin up fetches. + (Method::GET, "/api/app-catalog") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + Self::handle_app_catalog_proxy().await + } + // LND connect info — nginx validates session cookie (presence check), // backend is bound to 127.0.0.1 so only nginx can reach it. // No backend auth check here because the LND UI iframe fetches this diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index b15996f8..36809aa2 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -22,13 +22,13 @@ let cachedCatalog: AppCatalog | null = null let catalogFetchedAt = 0 const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache -/** Remote catalog URLs — tried in order. First success wins. */ +/** Catalog URLs tried in order. First success wins. + * Primary is the backend proxy (`/api/app-catalog`) — server-side fetch + * bypasses CORS on git.tx1138.com and CSP restrictions on the IP-port + * fallback. If the backend is offline (mid-restart etc.) we fall back + * to the static copy baked into the frontend build. */ const CATALOG_URLS = [ - // Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild) - 'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json', - // Fallback: direct IP (HTTP, only works if CSP allows http://$host:*) - 'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json', - // Last resort: local static file (baked into frontend build) + '/api/app-catalog', '/catalog.json', ] @@ -40,7 +40,7 @@ export async function fetchAppCatalog(): Promise { for (const url of CATALOG_URLS) { try { - const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) + const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) }) if (!res.ok) continue const data = await res.json() as AppCatalog if (!data.apps?.length) continue