| 1 | |
| 2 | |
| 3 | |
| 4 | use http::StatusCode; |
| 5 | use http::header::{ |
| 6 | CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, |
| 7 | IF_NONE_MATCH, X_CONTENT_TYPE_OPTIONS, |
| 8 | }; |
| 9 | use sha2::{Digest, Sha256}; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::context::{Cx, app_context}; |
| 12 | use topcoat::router::request::headers; |
| 13 | use topcoat::router::{path_param_segment, query_params, route}; |
| 14 | |
| 15 | use crate::state::AppState; |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | const PALETTE: [&str; 8] = [ |
| 21 | "#5E7D63", "#4A6750", "#3F5F46", "#4E6E68", "#3E6B63", "#5B6B4E", "#56705F", "#4F6A55", |
| 22 | ]; |
| 23 | |
| 24 | const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable"; |
| 25 | |
| 26 | const CACHE_REVALIDATE: &str = "no-cache"; |
| 27 | const CSP: &str = "default-src 'none'"; |
| 28 | |
| 29 | #[query_params(error = bad_request)] |
| 30 | struct AvatarQuery { |
| 31 | v: Option<String>, |
| 32 | } |
| 33 | |
| 34 | |
| 35 | pub fn default_svg(username: &str, user_id: &str) -> String { |
| 36 | let digest = Sha256::digest(user_id.as_bytes()); |
| 37 | let color = PALETTE[usize::from(digest[0]) % PALETTE.len()]; |
| 38 | let letter = username |
| 39 | .chars() |
| 40 | .next() |
| 41 | .map_or_else(|| "?".to_owned(), |c| c.to_uppercase().collect()); |
| 42 | let letter = xml_escape(&letter); |
| 43 | format!( |
| 44 | r##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" fill="{color}"/><text x="128" y="128" dy="0.35em" text-anchor="middle" font-family="sans-serif" font-size="128" fill="#ffffff">{letter}</text></svg>"## |
| 45 | ) |
| 46 | } |
| 47 | |
| 48 | fn xml_escape(text: &str) -> String { |
| 49 | let mut out = String::with_capacity(text.len()); |
| 50 | for c in text.chars() { |
| 51 | match c { |
| 52 | '&' => out.push_str("&"), |
| 53 | '<' => out.push_str("<"), |
| 54 | '>' => out.push_str(">"), |
| 55 | '"' => out.push_str("""), |
| 56 | '\'' => out.push_str("'"), |
| 57 | other => out.push(other), |
| 58 | } |
| 59 | } |
| 60 | out |
| 61 | } |
| 62 | |
| 63 | |
| 64 | fn etag_matches(if_none_match: &str, etag: &str) -> bool { |
| 65 | if_none_match |
| 66 | .split(',') |
| 67 | .map(str::trim) |
| 68 | .any(|token| token == "*" || token.strip_prefix("W/").unwrap_or(token) == etag) |
| 69 | } |
| 70 | |
| 71 | #[route(GET "/avatars/{username}")] |
| 72 | async fn avatar(cx: &Cx) -> Result<(StatusCode, HeaderMap, Vec<u8>)> { |
| 73 | let name = path_param_segment(cx, "username").to_owned(); |
| 74 | let version = query_params::<AvatarQuery>(cx)?.v.clone(); |
| 75 | let if_none_match = headers(cx) |
| 76 | .get(IF_NONE_MATCH) |
| 77 | .and_then(|v| v.to_str().ok()) |
| 78 | .map(str::to_owned); |
| 79 | let store = &app_context::<AppState>(cx).store; |
| 80 | |
| 81 | let Some(user) = store.user_by_username(&name).await? else { |
| 82 | return Ok(( |
| 83 | StatusCode::NOT_FOUND, |
| 84 | HeaderMap::new(), |
| 85 | b"not found".to_vec(), |
| 86 | )); |
| 87 | }; |
| 88 | |
| 89 | let (bytes, content_type, tag, cache) = match store.avatar(&user.id).await? { |
| 90 | Some((png, hash)) => { |
| 91 | let immutable = version.as_deref() == Some(hash.as_str()); |
| 92 | let cache = if immutable { |
| 93 | CACHE_IMMUTABLE |
| 94 | } else { |
| 95 | CACHE_REVALIDATE |
| 96 | }; |
| 97 | (png, "image/png", hash, cache) |
| 98 | } |
| 99 | None => { |
| 100 | let svg = default_svg(&user.username, &user.id).into_bytes(); |
| 101 | let digest = Sha256::digest(&svg); |
| 102 | let tag: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect(); |
| 103 | (svg, "image/svg+xml", tag, CACHE_REVALIDATE) |
| 104 | } |
| 105 | }; |
| 106 | |
| 107 | let etag = format!("\"{tag}\""); |
| 108 | let not_modified = if_none_match |
| 109 | .as_deref() |
| 110 | .is_some_and(|value| etag_matches(value, &etag)); |
| 111 | let status = if not_modified { |
| 112 | StatusCode::NOT_MODIFIED |
| 113 | } else { |
| 114 | StatusCode::OK |
| 115 | }; |
| 116 | let body = if not_modified { Vec::new() } else { bytes }; |
| 117 | |
| 118 | let mut h = HeaderMap::new(); |
| 119 | h.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); |
| 120 | h.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); |
| 121 | h.insert(CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP)); |
| 122 | h.insert(CACHE_CONTROL, HeaderValue::from_static(cache)); |
| 123 | h.insert(ETAG, HeaderValue::try_from(etag)?); |
| 124 | Ok((status, h, body)) |
| 125 | } |
| 126 | |
| 127 | #[cfg(test)] |
| 128 | mod tests { |
| 129 | use crate::pages::settings_profile::tests::{fixture, send_full}; |
| 130 | |
| 131 | #[test] |
| 132 | fn default_svg_escapes_the_letter_and_is_stable() { |
| 133 | let svg = super::default_svg("<", "user-1"); |
| 134 | assert!(svg.contains("><</text>"), "{svg}"); |
| 135 | assert_eq!(svg, super::default_svg("<", "user-1")); |
| 136 | assert!(super::PALETTE.iter().any(|c| svg.contains(c))); |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn etag_list_matching() { |
| 141 | assert!(super::etag_matches("\"abc\", \"def\"", "\"def\"")); |
| 142 | assert!(super::etag_matches("W/\"abc\"", "\"abc\"")); |
| 143 | assert!(super::etag_matches("*", "\"abc\"")); |
| 144 | assert!(!super::etag_matches("\"xyz\"", "\"abc\"")); |
| 145 | } |
| 146 | |
| 147 | #[tokio::test] |
| 148 | async fn unknown_user_avatar_is_404() { |
| 149 | let fx = fixture().await; |
| 150 | let reply = send_full( |
| 151 | &fx.router, |
| 152 | "GET", |
| 153 | "/avatars/nobody", |
| 154 | false, |
| 155 | None, |
| 156 | Vec::new(), |
| 157 | ) |
| 158 | .await; |
| 159 | assert_eq!(reply.status, 404); |
| 160 | } |
| 161 | } |