1use std::path::{Path, PathBuf};
11use std::time::Duration;
12use ureq::ResponseExt;
13
14pub const FRESH: Duration = Duration::from_secs(7 * 24 * 3600);
16const LARGEST: u64 = 512 * 1024;
18const AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
19
20pub fn cached(dir: &Path, host: &str) -> Option<PathBuf> {
22 ["png", "ico", "svg", "gif", "jpg", "webp"].iter().map(|ext| dir.join(format!("{}.{ext}", safe(host)))).find(|p| p.is_file())
23}
24
25pub fn stale(dir: &Path, host: &str) -> bool {
27 cached(dir, host).and_then(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_none_or(|age| age > FRESH)
28}
29
30fn safe(host: &str) -> String {
31 host.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-').collect()
32}
33
34fn agent() -> ureq::Agent {
38 ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(15))).http_status_as_error(false).https_only(true).build().into()
39}
40
41fn page(url: &str) -> Option<(String, String)> {
43 let mut answer = agent().get(url).header("User-Agent", AGENT).header("Accept", "text/html").call().ok()?;
44 if !answer.status().is_success() {
45 return None;
46 }
47 let final_url = answer.get_uri().to_string();
48 let html = answer.body_mut().with_config().limit(2 * 1024 * 1024).read_to_string().ok()?;
49 Some((final_url, html))
50}
51
52fn attribute(tag: &str, name: &str) -> Option<String> {
54 let lower = tag.to_ascii_lowercase();
55 let mut from = 0;
56 while let Some(at) = lower[from..].find(name).map(|i| i + from) {
57 from = at + name.len();
58 let before_ok = at > 0 && lower.as_bytes()[at - 1].is_ascii_whitespace();
60 let rest = lower[from..].trim_start();
61 if !before_ok || !rest.starts_with('=') {
62 continue;
63 }
64 let start = tag.len() - rest.len() + 1;
65 let value = tag[start..].trim_start();
66 let value = match value.chars().next()? {
67 q @ ('"' | '\'') => value[1..].split(q).next()?.to_string(),
68 _ => value.split(|c: char| c.is_whitespace() || c == '>').next()?.to_string(),
69 };
70 return Some(value);
71 }
72 None
73}
74
75fn named_icons(html: &str) -> Vec<String> {
78 let lower = html.to_ascii_lowercase();
79 let mut found: Vec<(i64, String)> = Vec::new();
80 let mut from = 0;
81 while let Some(at) = lower[from..].find("<link").map(|i| i + from) {
82 let end = lower[at..].find('>').map_or(lower.len(), |e| at + e);
83 let tag = &html[at..end];
84 from = end;
85 let Some(rel) = attribute(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
86 if !rel.split_whitespace().any(|w| w == "icon" || w == "apple-touch-icon") || rel.contains("mask-icon") {
88 continue;
89 }
90 let Some(href) = attribute(tag, "href").filter(|h| !h.trim().is_empty() && !h.starts_with("data:")) else { continue };
91 let size = attribute(tag, "sizes").and_then(|s| s.split(['x', 'X']).next().and_then(|n| n.trim().parse::<i64>().ok())).unwrap_or(if rel.contains("apple-touch-icon") { 180 } else { 16 });
92 let kind = href.to_ascii_lowercase();
93 let bonus = if kind.contains(".svg") { -1000 } else if kind.contains(".png") { 20 } else { 0 };
94 let score = size.min(256) + bonus;
95 found.push((score, href));
96 }
97 found.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
98 found.into_iter().map(|(_, href)| href).collect()
99}
100
101fn join(base: &str, href: &str) -> String {
103 let href = href.trim();
104 if href.starts_with("https://") || href.starts_with("http://") {
105 return href.to_string();
106 }
107 let scheme = base.split("://").next().unwrap_or("https");
108 if let Some(rest) = href.strip_prefix("//") {
109 return format!("{scheme}://{rest}");
110 }
111 let after = base.split("://").nth(1).unwrap_or("");
112 let host = after.split(['/', '?', '#']).next().unwrap_or("");
113 if href.starts_with('/') {
114 return format!("{scheme}://{host}{href}");
115 }
116 let path = after[host.len()..].split(['?', '#']).next().unwrap_or("/");
117 let dir = path.rfind('/').map_or("/", |i| &path[..=i]);
118 format!("{scheme}://{host}{dir}{href}")
119}
120
121fn kind_of(bytes: &[u8]) -> Option<&'static str> {
123 let head = &bytes[..bytes.len().min(512)];
124 if head.starts_with(b"\x89PNG") {
125 Some("png")
126 } else if head.starts_with(&[0, 0, 1, 0]) {
127 Some("ico")
128 } else if head.starts_with(b"GIF8") {
129 Some("gif")
130 } else if head.starts_with(&[0xff, 0xd8]) {
131 Some("jpg")
132 } else if head.starts_with(b"RIFF") && head.get(8..12) == Some(b"WEBP") {
133 Some("webp")
134 } else {
135 let text = String::from_utf8_lossy(head).to_ascii_lowercase();
137 (text.contains("<svg") && !text.contains("<html") && !text.contains("<!doctype html")).then_some("svg")
138 }
139}
140
141fn download(url: &str) -> Option<(Vec<u8>, &'static str)> {
142 let mut answer = agent().get(url).header("User-Agent", AGENT).header("Accept", "image/*").call().ok()?;
143 if !answer.status().is_success() {
144 return None;
145 }
146 let bytes = answer.body_mut().with_config().limit(LARGEST).read_to_vec().ok()?;
147 let kind = kind_of(&bytes)?;
148 Some((bytes, kind))
149}
150
151pub fn fetch(url: &str, dir: &Path) -> Result<PathBuf, String> {
153 let host = url.split("://").nth(1).unwrap_or("").split(['/', '?', '#', ':']).next().unwrap_or("").to_lowercase();
154 if host.is_empty() {
155 return Err("no host".into());
156 }
157 let (base, html) = page(url).unwrap_or_else(|| (url.to_string(), String::new()));
158 let (svg, raster): (Vec<String>, Vec<String>) = named_icons(&html).iter().map(|h| join(&base, h)).partition(|u| u.to_ascii_lowercase().contains(".svg"));
160 let candidates: Vec<String> = raster.into_iter().chain([join(&base, "/favicon.ico"), format!("https://{host}/favicon.ico")]).chain(svg).collect();
161 let (bytes, kind) = candidates.iter().find_map(|c| download(c)).ok_or_else(|| format!("{host}: no icon"))?;
162 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
163 if let Some(old) = cached(dir, &host) {
165 let _ = std::fs::remove_file(old);
166 }
167 let path = dir.join(format!("{}.{kind}", safe(&host)));
168 std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
169 Ok(path)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn icons_a_page_names() {
178 let html = r#"<head><link rel="stylesheet" href="/s.css"><link rel="icon" type="image/png" sizes="32x32" href="/i32.png"><LINK REL='shortcut icon' href=/favicon.ico><link rel="apple-touch-icon" href="https://cdn.example.org/touch.png"><link rel="mask-icon" href="/mask.svg"><link rel="icon" href="data:image/png;base64,AAAA"></head>"#;
179 assert_eq!(named_icons(html), vec!["https://cdn.example.org/touch.png", "/i32.png", "/favicon.ico"]);
180 assert_eq!(join("https://www.example.org/a/b?x=1", "/i.png"), "https://www.example.org/i.png");
181 assert_eq!(join("https://www.example.org/a/b", "i.png"), "https://www.example.org/a/i.png");
182 assert_eq!(join("https://www.example.org/", "//cdn.example.net/i.png"), "https://cdn.example.net/i.png");
183 assert_eq!(kind_of(b"\x89PNG\r\n"), Some("png"));
184 assert_eq!(kind_of(b"<?xml version=\"1.0\"?><svg xmlns"), Some("svg"));
185 assert_eq!(kind_of(b"<html>not found</html>"), None, "an error page is no icon");
186 assert_eq!(kind_of(b"<!DOCTYPE html><body><svg viewBox=\"0 0 9 9\"></svg>Not found</body>"), None, "nor one with a drawing in it");
187 assert_eq!(attribute(r#"<link data-rel="x" rel="icon""#, "rel").as_deref(), Some("icon"), "a whole attribute name");
188 }
189
190 #[test]
192 #[ignore]
193 fn real_sites_give_their_icons() {
194 let dir = std::env::temp_dir().join(format!("sioul-favicons-{}", std::process::id()));
195 for url in ["https://app.element.io/", "https://www.urssaf.fr/accueil/se-connecter.html", "https://discord.com/app"] {
196 let path = fetch(url, &dir).unwrap();
197 assert!(std::fs::metadata(&path).unwrap().len() > 0, "{url}");
198 println!("{url} → {}", path.display());
199 }
200 let _ = std::fs::remove_dir_all(&dir);
201 }
202}