//! Serves avatars at `/avatars/{username}`: the stored PNG, or a generated SVG when the user has //! none. The generated SVG is built here, so only its letter needs escaping. use http::StatusCode; use http::header::{ CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_NONE_MATCH, X_CONTENT_TYPE_OPTIONS, }; use sha2::{Digest, Sha256}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::request::headers; use topcoat::router::{path_param_segment, query_params, route}; use crate::state::AppState; /// Background colors for the generated default avatar, picked by the user id hash. Muted sage, /// olive and teal tones, so the default avatars sit with the sage accent. Each keeps white text /// readable (4.5:1 or better). const PALETTE: [&str; 8] = [ "#5E7D63", "#4A6750", "#3F5F46", "#4E6E68", "#3E6B63", "#5B6B4E", "#56705F", "#4F6A55", ]; const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable"; /// Without a matching `?v=`, the browser revalidates: the ETag gives a cheap 304. const CACHE_REVALIDATE: &str = "no-cache"; const CSP: &str = "default-src 'none'"; #[query_params(error = bad_request)] struct AvatarQuery { v: Option, } /// The generated default avatar: the first letter of the username on a palette color. pub fn default_svg(username: &str, user_id: &str) -> String { let digest = Sha256::digest(user_id.as_bytes()); let color = PALETTE[usize::from(digest[0]) % PALETTE.len()]; let letter = username .chars() .next() .map_or_else(|| "?".to_owned(), |c| c.to_uppercase().collect()); let letter = xml_escape(&letter); format!( r##"{letter}"## ) } fn xml_escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), other => out.push(other), } } out } /// True when an `If-None-Match` header lists `etag` (quoted), `*`, or its weak form. fn etag_matches(if_none_match: &str, etag: &str) -> bool { if_none_match .split(',') .map(str::trim) .any(|token| token == "*" || token.strip_prefix("W/").unwrap_or(token) == etag) } #[route(GET "/avatars/{username}")] async fn avatar(cx: &Cx) -> Result<(StatusCode, HeaderMap, Vec)> { let name = path_param_segment(cx, "username").to_owned(); let version = query_params::(cx)?.v.clone(); let if_none_match = headers(cx) .get(IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) .map(str::to_owned); let store = &app_context::(cx).store; let Some(user) = store.user_by_username(&name).await? else { return Ok(( StatusCode::NOT_FOUND, HeaderMap::new(), b"not found".to_vec(), )); }; let (bytes, content_type, tag, cache) = match store.avatar(&user.id).await? { Some((png, hash)) => { let immutable = version.as_deref() == Some(hash.as_str()); let cache = if immutable { CACHE_IMMUTABLE } else { CACHE_REVALIDATE }; (png, "image/png", hash, cache) } None => { let svg = default_svg(&user.username, &user.id).into_bytes(); let digest = Sha256::digest(&svg); let tag: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect(); (svg, "image/svg+xml", tag, CACHE_REVALIDATE) } }; let etag = format!("\"{tag}\""); let not_modified = if_none_match .as_deref() .is_some_and(|value| etag_matches(value, &etag)); let status = if not_modified { StatusCode::NOT_MODIFIED } else { StatusCode::OK }; let body = if not_modified { Vec::new() } else { bytes }; let mut h = HeaderMap::new(); h.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); h.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); h.insert(CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP)); h.insert(CACHE_CONTROL, HeaderValue::from_static(cache)); h.insert(ETAG, HeaderValue::try_from(etag)?); Ok((status, h, body)) } #[cfg(test)] mod tests { use crate::pages::settings_profile::tests::{fixture, send_full}; #[test] fn default_svg_escapes_the_letter_and_is_stable() { let svg = super::default_svg("<", "user-1"); assert!(svg.contains("><"), "{svg}"); assert_eq!(svg, super::default_svg("<", "user-1")); assert!(super::PALETTE.iter().any(|c| svg.contains(c))); } #[test] fn etag_list_matching() { assert!(super::etag_matches("\"abc\", \"def\"", "\"def\"")); assert!(super::etag_matches("W/\"abc\"", "\"abc\"")); assert!(super::etag_matches("*", "\"abc\"")); assert!(!super::etag_matches("\"xyz\"", "\"abc\"")); } #[tokio::test] async fn unknown_user_avatar_is_404() { let fx = fixture().await; let reply = send_full( &fx.router, "GET", "/avatars/nobody", false, None, Vec::new(), ) .await; assert_eq!(reply.status, 404); } }