//! Artwork: on https when its host serves it there, and a site icon where a feed has none. use crate::{discover, fetch}; use std::collections::HashMap; use std::time::Duration; /// Artwork for a feed that has none: the icon its site's page names, or else the site's /// `/favicon.ico`. None if neither is there. #[tracing::instrument(skip_all, fields(site = site))] pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option { let timeout = Duration::from_secs(20); let resp = client.get(site).timeout(timeout).send().await.ok()?; // Relative to where the page ended up, not where it was asked for: a site that redirects // to /en/ would otherwise have its icon looked for in the wrong place. let base = resp.url().clone(); // The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its // /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again. if resp.status().is_success() && let Ok(page) = fetch::read_capped(resp).await && let Some(href) = page_icon(&page) && let Ok(url) = base.join(&href) && is_image(client, url.as_str()).await { return Some(url.into()); } let ico = base.join("/favicon.ico").ok()?; is_image(client, ico.as_str()).await.then(|| ico.into()) } /// How many hosts one scan of a feed asks whether its artwork is on https. pub(crate) const MAX_ART_HOSTS: usize = 8; /// Artwork's address on https when its host serves it there, else as it was. The page is https /// and must not load http; the host is asked once per `known` (one feed's read), and an http /// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five /// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN /// presents another name's certificate (#110). pub async fn prefer_https(client: &reqwest::Client, url: &str, known: &mut HashMap) -> String { let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() }; let host = rest.split('/').next().unwrap_or("").to_owned(); let secure = format!("https://{rest}"); let ok = match known.get(&host) { Some(ok) => *ok, // Each new host is a probe of up to 20 s, one after another on the scan, and the feed's // author decides how many hosts there are: ports and user names make as many as they like // of one server (#339). Real feeds put their artwork on a few. None if known.len() >= MAX_ART_HOSTS => false, None => { let ok = is_image(client, &secure).await; known.insert(host, ok); ok } }; if ok { secure } else { url.to_owned() } } /// Whether `url` answers with an image. A site with no favicon often answers 200 with its home /// page, which is not an icon. pub async fn is_image(client: &reqwest::Client, url: &str) -> bool { let timeout = Duration::from_secs(20); let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false }; resp.status().is_success() && resp .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .is_some_and(|t| t.starts_with("image/")) } /// The icon a web page names in its `` tags, the larger apple-touch-icon first: a plain /// `icon` is often 16 pixels, which blurs at the size the list draws artwork. pub(crate) fn page_icon(bytes: &[u8]) -> Option { let text = String::from_utf8_lossy(bytes); // ASCII only, so byte offsets in the lowered copy stay valid in the original. let lower = text.to_ascii_lowercase(); let (mut touch, mut icon) = (None, None); let mut pos = 0; while let Some(at) = lower[pos..].find("').map(|e| start + e) else { break }; pos = end + 1; let tag = &text[start..end]; let Some(rel) = discover::tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue }; let rels: Vec<&str> = rel.split_whitespace().collect(); if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) { touch = discover::tag_attr(tag, "href"); } else if icon.is_none() && rels.contains(&"icon") { icon = discover::tag_attr(tag, "href"); } } touch.or(icon).filter(|h| !h.is_empty()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn artwork_on_more_hosts_than_the_limit_is_not_asked_about() { let mut known: std::collections::HashMap = (0..MAX_ART_HOSTS).map(|n| (format!("h{n}:80"), false)).collect(); // No server answers here: were it asked, this would wait on a connection, not return. let url = "http://192.0.2.1:9/a.png"; assert_eq!(prefer_https(&reqwest::Client::new(), url, &mut known).await, url); assert_eq!(known.len(), MAX_ART_HOSTS); } #[test] fn page_icon_prefers_the_touch_icon() { let page = br#" "#; assert_eq!(page_icon(page).as_deref(), Some("/touch.png")); assert_eq!(page_icon(br#""#).as_deref(), Some("i.svg")); assert_eq!(page_icon(br#""#), None); } }