Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions src/domains/explorer/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Option<String> {
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 }
Expand Down Expand Up @@ -80,13 +103,17 @@ impl ExplorerClient {
pub async fn get_orchestrator(&self, address: &str) -> anyhow::Result<OrchestratorProfileRow> {
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<GatewayProfileRow> {
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(
Expand Down Expand Up @@ -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);
}
}
Loading