forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.6 KiB
1//! Serves avatars at `/avatars/{username}`: the stored PNG, or a generated SVG when the user has
2//! none. The generated SVG is built here, so only its letter needs escaping.
3
4use http::StatusCode;
5use http::header::{
6 CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue,
7 IF_NONE_MATCH, X_CONTENT_TYPE_OPTIONS,
8};
9use sha2::{Digest, Sha256};
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::request::headers;
13use topcoat::router::{path_param_segment, query_params, route};
14
15use crate::state::AppState;
16
17/// Background colors for the generated default avatar, picked by the user id hash. Muted sage,
18/// olive and teal tones, so the default avatars sit with the sage accent. Each keeps white text
19/// readable (4.5:1 or better).
20const PALETTE: [&str; 8] = [
21 "#5E7D63", "#4A6750", "#3F5F46", "#4E6E68", "#3E6B63", "#5B6B4E", "#56705F", "#4F6A55",
22];
23
24const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
25/// Without a matching `?v=`, the browser revalidates: the ETag gives a cheap 304.
26const CACHE_REVALIDATE: &str = "no-cache";
27const CSP: &str = "default-src 'none'";
28
29#[query_params(error = bad_request)]
30struct AvatarQuery {
31 v: Option<String>,
32}
33
34/// The generated default avatar: the first letter of the username on a palette color.
35pub 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
48fn 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("&amp;"),
53 '<' => out.push_str("&lt;"),
54 '>' => out.push_str("&gt;"),
55 '"' => out.push_str("&quot;"),
56 '\'' => out.push_str("&apos;"),
57 other => out.push(other),
58 }
59 }
60 out
61}
62
63/// True when an `If-None-Match` header lists `etag` (quoted), `*`, or its weak form.
64fn 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}")]
72async 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)]
128mod 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(">&lt;</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}