From 2fd35ee30d0adee7a7887f4aa6f04581b426cde8 Mon Sep 17 00:00:00 2001 From: Mike Zupper Date: Thu, 25 Jun 2026 06:33:59 -0400 Subject: [PATCH] feat(explorer): absolutize locally-cached avatar URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explorer now serves ENS avatars it resolved to image bytes (NFT / ipfs records) as a root-relative path /api/v1/orchestrators/{addr}/avatar. Url::parse() fails on a relative URL, so valid_thumbnail_url silently dropped these, and Discord needs an absolute URL to fetch a thumbnail server-side anyway. Absolutize avatar_url against the public EXPLORER_BASE_URL in get_orchestrator/get_gateway — covering webhook embeds, DMs, and commands in one place. Absolute http(s) values and unresolved ipfs/eip155 records pass through unchanged (downstream validation still drops the non-http ones), so no regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/domains/explorer/client.rs | 70 +++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/domains/explorer/client.rs b/src/domains/explorer/client.rs index 612aa2b..af033a8 100644 --- a/src/domains/explorer/client.rs +++ b/src/domains/explorer/client.rs @@ -14,6 +14,29 @@ pub struct ExplorerClient { base_url: Url, } +/// Resolve a profile `avatar_url` from the explorer into an absolute, +/// publicly-fetchable URL. +/// +/// TD-033: the explorer serves locally-cached avatars (ENS records it +/// resolved to image bytes — including ipfs:// and eip155 NFT references) +/// as a root-relative path `/api/v1/orchestrators/{addr}/avatar`. Discord +/// fetches embed thumbnails server-side and needs an absolute URL, so we +/// join the relative path onto the explorer's public base URL. Absolute +/// values (http(s) passthrough, or still-unresolved ipfs/eip155 records) +/// are left untouched — downstream thumbnail validation drops the non-http +/// ones, exactly as before. +fn absolutize_avatar(base: &Url, raw: Option) -> Option { + let raw = raw?; + if raw.starts_with('/') { + match base.join(&raw) { + Ok(abs) => Some(abs.to_string()), + Err(_) => Some(raw), + } + } else { + Some(raw) + } +} + impl ExplorerClient { pub fn new(client: Client, base_url: Url) -> Self { Self { client, base_url } @@ -80,13 +103,17 @@ impl ExplorerClient { pub async fn get_orchestrator(&self, address: &str) -> anyhow::Result { let url = self.url(&format!("api/v1/orchestrators/{address}"))?; let resp = self.client.get(url).send().await?.error_for_status()?; - Ok(resp.json().await?) + let mut row: OrchestratorProfileRow = resp.json().await?; + row.avatar_url = absolutize_avatar(&self.base_url, row.avatar_url.take()); + Ok(row) } pub async fn get_gateway(&self, address: &str) -> anyhow::Result { let url = self.url(&format!("api/v1/gateways/{address}/profile"))?; let resp = self.client.get(url).send().await?.error_for_status()?; - Ok(resp.json().await?) + let mut row: GatewayProfileRow = resp.json().await?; + row.avatar_url = absolutize_avatar(&self.base_url, row.avatar_url.take()); + Ok(row) } pub async fn payout_summary( @@ -171,3 +198,42 @@ impl ExplorerClient { Ok(resp.json().await?) } } + +#[cfg(test)] +mod tests { + use super::absolutize_avatar; + use url::Url; + + fn base() -> Url { + Url::parse("https://livepeer-network-api.cloudspe.com").unwrap() + } + + #[test] + fn relative_cached_avatar_is_absolutized() { + let got = absolutize_avatar( + &base(), + Some("/api/v1/orchestrators/0xabc/avatar".to_string()), + ); + assert_eq!( + got.as_deref(), + Some("https://livepeer-network-api.cloudspe.com/api/v1/orchestrators/0xabc/avatar") + ); + } + + #[test] + fn absolute_http_passthrough_is_untouched() { + let got = absolutize_avatar(&base(), Some("https://override.example/a.png".to_string())); + assert_eq!(got.as_deref(), Some("https://override.example/a.png")); + } + + #[test] + fn non_http_records_are_left_for_downstream_to_drop() { + // ipfs/eip155 records that the explorer couldn't cache pass through + // unchanged; the embed thumbnail validator drops them. + assert_eq!( + absolutize_avatar(&base(), Some("eip155:1/erc721:0xabc/123".to_string())).as_deref(), + Some("eip155:1/erc721:0xabc/123") + ); + assert_eq!(absolutize_avatar(&base(), None), None); + } +}