release(v1.7.13-alpha): proxy app catalog server-side (CORS + CSP fix)
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) <noreply@anthropic.com>
This commit is contained in:
parent
26630e5ffd
commit
687c216e65
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.12-alpha"
|
version = "1.7.13-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.12-alpha"
|
version = "1.7.13-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
|
|||||||
@ -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<Response<hyper::Body>> {
|
||||||
|
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.
|
/// Build a 401 Unauthorized JSON response.
|
||||||
fn unauthorized() -> Response<hyper::Body> {
|
fn unauthorized() -> Response<hyper::Body> {
|
||||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||||
@ -352,6 +399,18 @@ impl ApiHandler {
|
|||||||
// Electrs status — unauthenticated (read-only sync status)
|
// Electrs status — unauthenticated (read-only sync status)
|
||||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
(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),
|
// LND connect info — nginx validates session cookie (presence check),
|
||||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
// 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
|
// No backend auth check here because the LND UI iframe fetches this
|
||||||
|
|||||||
@ -22,13 +22,13 @@ let cachedCatalog: AppCatalog | null = null
|
|||||||
let catalogFetchedAt = 0
|
let catalogFetchedAt = 0
|
||||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
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 = [
|
const CATALOG_URLS = [
|
||||||
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
|
'/api/app-catalog',
|
||||||
'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)
|
|
||||||
'/catalog.json',
|
'/catalog.json',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
|||||||
|
|
||||||
for (const url of CATALOG_URLS) {
|
for (const url of CATALOG_URLS) {
|
||||||
try {
|
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
|
if (!res.ok) continue
|
||||||
const data = await res.json() as AppCatalog
|
const data = await res.json() as AppCatalog
|
||||||
if (!data.apps?.length) continue
|
if (!data.apps?.length) continue
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user