lib.rs held 2,000 lines: fetching, parsing, OPML, Patreon, discovery, artwork, categories, Jev and redaction, and their tests. It is now the module list and the re-exports, so ipx and the directory call feed::fetch, feed::parse and the rest as before: fetch.rs fetch and its limits parse.rs RSS and Atom outlines.rs OPML (not opml.rs, which would shadow the opml crate) patreon.rs a creator's shows discover.rs finding a feed wordpress.rs WordPress.com's API failure.rs failures in words privacy.rs redact, looks_private artwork.rs https art, icons category.rs Apple's categories jev.rs Jev's category Moved verbatim, private items pub(crate) where a sibling calls them, calls across modules module::function. Every definition and doc comment line of the old lib.rs is in exactly one of the new files; each test went with what it tests, and all 40 run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
5.5 KiB
Rust
118 lines
5.5 KiB
Rust
//! 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<String> {
|
|
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, bool>) -> 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 `<link>` 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<String> {
|
|
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("<link") {
|
|
let start = pos + at;
|
|
let Some(end) = lower[start..].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<String, bool> =
|
|
(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#"<head><link rel="stylesheet" href="/a.css">
|
|
<link rel="shortcut icon" href="/fav.ico">
|
|
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
|
|
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
|
|
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
|
|
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
|
|
}
|
|
}
|