Files
tavolo/web/src/components/avatar.rs
T
woggioni 71493f720b Fix OIDC subject collisions by introducing canonical provider-plus-subject principals
- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>'
- Preserve provider identity across HTTP sessions and WebSockets
- Apply principals throughout: game creation/joining/seating, WebSocket views/actions,
  Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI,
  and telemetry
- Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py)
- Document provider names as stable security identifiers
- Add regression tests: same subject at different providers cannot share games, private hands,
  join codes, moves, history, or ratings; distinct seat and rating ownership verified
- All platform, integration, game, and frontend tests pass; mypy clean
2026-10-24 22:25:44 +00:00

185 lines
5.9 KiB
Rust

//! 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<String> {
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<View> = 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")
);
}
}