Skip to main content

sioul_sync/
favicon.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A site's own icon, for the list of sites (docs/sites.md, "The list"):
5//! asked of the site itself, never of an icon service that would learn which
6//! sites you keep. Its page is read for the icon it names (`<link rel="icon">`,
7//! the larger the better), else `/favicon.ico`; the icon is kept in the cache
8//! folder, by host, and asked again after a week.
9
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12use ureq::ResponseExt;
13
14/// Asked again after this long.
15pub const FRESH: Duration = Duration::from_secs(7 * 24 * 3600);
16/// Larger than this is no icon.
17const LARGEST: u64 = 512 * 1024;
18const AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
19
20/// Where a site's icon is kept: `<dir>/<host>.<png|ico|svg|…>`, if it is.
21pub 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
25/// Whether the icon kept for `host` is missing or older than a week.
26pub 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
34/// HTTPS only, redirects included: a page cannot send Sioul to a plain
35/// address, where anyone on the network sees which sites you keep, nor to a
36/// device of your network (a router's page has no certificate to show).
37fn 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
41/// The page's address after its redirects, and its HTML (its first 2 MB).
42fn 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
52/// One attribute of a tag's text: `rel="icon"`, `href='/i.png'`, `sizes=32x32`.
53fn 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        // A whole attribute name: after a space, before "=".
59        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
75/// The icons a page names, best first: by declared size (up to 256), PNG
76/// first; SVG last, as some draw nothing outside a browser.
77fn 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        // mask-icon is one colour, for Safari's tabs: not an icon to show.
87        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
101/// `href` read from the page at `base`.
102fn 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
121/// What an icon's bytes are, by their first bytes: its file extension; None for no image.
122fn 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        // An error page drawing its logo in SVG is still a page, not an icon.
136        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
151/// The icon of the site at `url`, asked of the site and kept in `dir`; its file.
152pub 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    // Raster icons first, then /favicon.ico, an SVG last: some draw nothing outside a browser.
159    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    // One icon per host: an older one of another format goes.
164    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    /// Icons of three real sites, kept in a folder of their own. Run by hand: it asks the sites.
191    #[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}