diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index d9669056..331331b6 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -24,7 +24,7 @@ use crate::container::{ContainerOrchestrator, DevContainerOrchestrator}; use crate::monitoring::MetricsStore; use crate::session::{self, SessionStore}; use crate::state::StateManager; -use anyhow::Result; +use anyhow::{Context, Result}; use hyper::{Method, Request, Response, StatusCode}; use sha2::{Digest, Sha256}; use std::sync::Arc; @@ -225,6 +225,50 @@ impl ApiHandler { )) } + /// Serve IndeeHub's sanitized public project catalog on the FIPS peer + /// listener. The app container remains on loopback; peers never receive + /// its session-gated port directly. + async fn handle_indeehub_public_catalog(&self) -> Result> { + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()? + .get("http://127.0.0.1:7778/api/projects/public-catalog") + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await; + let response = match response { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "IndeeHub public catalog loopback fetch failed"); + return Ok(build_response( + StatusCode::BAD_GATEWAY, + "application/json", + hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#), + )); + } + }; + if !response.status().is_success() { + return Ok(build_response( + StatusCode::BAD_GATEWAY, + "application/json", + hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#), + )); + } + let bytes = response.bytes().await?; + anyhow::ensure!( + bytes.len() <= 8 * 1024 * 1024, + "IndeeHub catalog exceeds limit" + ); + let value: serde_json::Value = + serde_json::from_slice(&bytes).context("IndeeHub catalog is not valid JSON")?; + anyhow::ensure!(value.is_array(), "IndeeHub catalog must be an array"); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(bytes), + )) + } + /// Serve an encrypted backup archive (`/backups/.bak`) as a /// browser download. The archive is passphrase-encrypted at rest; the /// session gate at the route controls who can fetch it. @@ -795,6 +839,12 @@ impl ApiHandler { .body(hyper::Body::from(body))?) } + // Public IndeeHub metadata is also exposed on the FIPS peer + // listener, whose path filter is the only ingress gate here. + (Method::GET, "/api/public-catalog/indeedhub") => { + self.handle_indeehub_public_catalog().await + } + (Method::GET, "/api/app-catalog") => { if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 08eb7d03..0791f063 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -1418,6 +1418,76 @@ impl RpcHandler { })) } + /// Browse IndeeHub's public film catalogs through the native peer bridge. + /// The bridge selects FIPS first (with the normal safe fallback policy), + /// so the browser never needs a peer LAN address or an onion connection. + pub(super) async fn handle_content_browse_indeehub_peers(&self) -> Result { + let nodes = crate::federation::load_nodes(&self.config.data_dir) + .await + .unwrap_or_default(); + let peers: Vec<_> = nodes + .into_iter() + .filter(|node| is_valid_v3_onion(&node.onion)) + .collect(); + let calls = peers.into_iter().map(|node| async move { + let onion = node.onion.clone(); + let request_onion = onion.clone(); + let fips = node.fips_npub.clone(); + let result = tokio::time::timeout(std::time::Duration::from_secs(12), async move { + let (response, transport) = crate::fips::dial::PeerRequest::new( + fips.as_deref(), + &request_onion, + "/api/public-catalog/indeedhub", + ) + .service(crate::settings::transport::PeerService::PeerFiles) + .timeout(std::time::Duration::from_secs(10)) + .fips_timeout(std::time::Duration::from_secs(6)) + .send_get() + .await?; + if !response.status().is_success() { + anyhow::bail!("peer returned {}", response.status()); + } + let listings: serde_json::Value = response + .json() + .await + .context("invalid IndeeHub peer catalog")?; + Ok::<_, anyhow::Error>((listings, transport)) + }) + .await + .ok() + .and_then(Result::ok); + (onion, result) + }); + let mut items = Vec::new(); + let mut reached = 0usize; + for (onion, result) in futures_util::future::join_all(calls).await { + let Some((listings, transport)) = result else { + continue; + }; + let Some(listings) = listings.as_array() else { + continue; + }; + reached += 1; + for listing in listings { + let Some(mut listing) = listing.as_object().cloned() else { + continue; + }; + listing.insert("peer".to_string(), serde_json::json!(onion)); + listing.insert( + "transport".to_string(), + serde_json::json!(transport.to_string()), + ); + items.push(serde_json::Value::Object(listing)); + } + } + Ok(serde_json::json!({ + "items": items, + "peers_reached": reached, + "peers_total": reached, + "partial": false, + })) + } + /// `content.owned-get` — return a purchased item's bytes (base64) from the /// local cache for in-app viewing/saving. No network, no re-payment. pub(super) async fn handle_content_owned_get( diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index cb88d757..82b4dcf9 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -354,6 +354,7 @@ impl RpcHandler { "content.download-peer-paid" => self.handle_content_download_peer_paid(params).await, "content.indeehub-projects" => self.handle_content_indeehub_projects().await, "content.browse-all-peers" => self.handle_content_browse_all_peers().await, + "content.browse-indeehub-peers" => self.handle_content_browse_indeehub_peers().await, "content.playback-handle" => self.handle_playback_handle(params, session_token).await, "content.playback-prepare" => self.handle_playback_prepare(params, session_token).await, "content.playback-start" => self.handle_playback_start(params, session_token).await, diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 0c0c677d..78ea58ab 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1468,6 +1468,9 @@ pub fn is_peer_allowed_path(path: &str) -> bool { | "/archipelago/mesh-typed" | "/dwn" | "/transport/inbox" + // IndeeHub public metadata is loopback-fetched by the native + // handler; the app port itself is never exposed on FIPS. + | "/api/public-catalog/indeedhub" // Content *catalog* — the peer-browse entry point. This is the // exact path `/content` (no trailing slash); the prefix match // below only covers `/content/` item fetches, so without