//! User avatar: the provider's `picture` claim when available, a //! deterministic identicon generated from the canonical user principal //! otherwise. //! //! The identicon is a 5x5 mirrored pixel grid derived from an inline //! FNV-1a hash. The hash is implemented here (instead of //! `DefaultHasher`) so the picture is stable across Rust versions and //! the helper functions stay unit-testable on the host, with no DOM. use sycamore::prelude::*; /// FNV-1a 64-bit: a stable hash for identicon seeds. fn fnv1a(input: &str) -> u64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in input.as_bytes() { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } hash } /// The identicon hue (0..360) for a seed. fn hue_for(seed: &str) -> u32 { (fnv1a(seed) % 360) as u32 } /// The identicon cell grid as 5 columns x 5 rows. Only 15 bits (3 /// columns) are derived from the hash; the left half is mirrored onto /// the right so the icon is symmetric. The center cell is forced on so /// an unlucky hash can never produce a blank icon. fn cells_for(seed: &str) -> [[bool; 5]; 5] { let hash = fnv1a(seed); let mut half = [false; 15]; for (index, cell) in half.iter_mut().enumerate() { *cell = (hash >> index) & 1 == 1; } if !half.iter().any(|set| *set) { half[8] = true; // center of the half grid: row 2, column 2 } let mut cells = [[false; 5]; 5]; for row in 0..5 { for column in 0..3 { let set = half[row * 3 + column]; cells[row][column] = set; if column < 2 { cells[row][4 - column] = set; } } } cells } /// Only absolute http(s) picture URLs are rendered. The backend filters /// the claim the same way; this is the client-side half of the rule. fn safe_picture_url(url: &str) -> Option { let trimmed = url.trim(); let lower = trimmed.to_ascii_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") { Some(trimmed.to_string()) } else { None } } /// The user's avatar: their profile picture, or an identicon generated /// from the canonical principal. A picture that fails to load falls back to /// the identicon as well. pub fn avatar(sub: &str, picture: Option<&str>) -> View { let Some(url) = picture.and_then(safe_picture_url) else { return identicon(sub); }; let seed = sub.to_string(); let failed = create_signal(false); view! { (move || { if failed.get() { identicon(&seed) } else { let src = url.clone(); view! { img(class="avatar", src=src, alt="", on:error=move |_| failed.set(true)) } } }) } } /// The generated fallback avatar. fn identicon(seed: &str) -> View { let hue = hue_for(seed); let background = format!("hsl({hue}, 45%, 22%)"); let foreground = format!("hsl({hue}, 65%, 62%)"); let cells = cells_for(seed); let mut squares: Vec = Vec::new(); for (row, line) in cells.iter().enumerate() { for (column, set) in line.iter().enumerate() { if *set { let x = column.to_string(); let y = row.to_string(); let fill = foreground.clone(); squares.push(view! { rect(x=x, y=y, width="1", height="1", fill=fill) }); } } } view! { svg(class="avatar", viewBox="0 0 5 5", "aria-hidden"="true") { rect(x="0", y="0", width="5", height="5", fill=background) (squares) } } } #[cfg(test)] mod tests { use super::*; #[test] fn hash_is_deterministic_and_seed_dependent() { assert_eq!(fnv1a("alice"), fnv1a("alice")); assert_ne!(fnv1a("alice"), fnv1a("bob")); // Pinned vectors: guard against accidental changes to the hash // (it must stay stable for the identicons users see). assert_eq!(149_266_183, fnv1a("alice") % 1_000_000_000); assert_eq!(23, hue_for("alice")); } #[test] fn hue_stays_in_range() { for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] { assert!(hue_for(seed) < 360); } } #[test] fn pattern_is_symmetric_and_never_blank() { for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] { let cells = cells_for(seed); assert!( cells.iter().flatten().any(|cell| *cell), "blank for {seed:?}" ); for row in 0..5 { for column in 0..5 { assert_eq!( cells[row][column], cells[row][4 - column], "asymmetric for {seed:?} at ({row}, {column})" ); } } } } #[test] fn different_seeds_draw_different_patterns() { assert_ne!(cells_for("alice"), cells_for("bob")); } #[test] fn only_absolute_http_picture_urls_are_rendered() { assert_eq!( Some("https://cdn.example.com/a.png".to_string()), safe_picture_url("https://cdn.example.com/a.png") ); assert_eq!( Some("https://cdn.example.com/a.png".to_string()), safe_picture_url(" https://cdn.example.com/a.png ") ); assert_eq!( Some("HTTPS://cdn.example.com/a.png".to_string()), safe_picture_url("HTTPS://cdn.example.com/a.png") ); assert_eq!(None, safe_picture_url("httpz://x")); assert_eq!(None, safe_picture_url("/relative.png")); assert_eq!(None, safe_picture_url("javascript:alert(1)")); assert_eq!( None, safe_picture_url("data:image/svg+xml;base64,PHN2ZyB4bW") ); } }