fabrica

hanna/fabrica

6026 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Avatars: an uploaded image when the user has one, else a deterministic
6//! identicon generated from the username. Bytes live in the `avatars` blob
7//! namespace (local disk or S3), keyed by user id; the `users.avatar_mime`
8//! column records the content type (its presence means "serve the file"). No
9//! network requests and no remote gravatar (§9.5).
10
11use std::fmt::Write as _;
12
13use axum::extract::{Multipart, Path, State};
14use axum::http::{StatusCode, header};
15use axum::response::{IntoResponse, Redirect, Response};
16use axum_extra::extract::cookie::CookieJar;
17use blob::Namespace;
18
19use crate::AppState;
20use crate::error::AppError;
21use crate::session::RequireUser;
22
23/// The largest avatar upload accepted, in bytes (1 `MiB`).
24const MAX_AVATAR_BYTES: usize = 1024 * 1024;
25
26/// The raster image types accepted for upload. SVG is deliberately excluded to
27/// avoid serving user-supplied markup; identicons are our own SVG.
28const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
29
30/// `GET /avatar/{username}` — the user's uploaded avatar, or a generated
31/// identicon. Always renders an image so `<img>` tags never break.
32pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response {
33 let user = state.store.user_by_username(&username).await.ok().flatten();
34 if let Some(user) = &user
35 && let Some(mime) = &user.avatar_mime
36 && let Ok(Some(bytes)) = state.blobs.get_bytes(Namespace::Avatars, &user.id).await
37 {
38 return (
39 [
40 (header::CONTENT_TYPE, mime.as_str()),
41 (header::CACHE_CONTROL, "private, max-age=60"),
42 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
43 ],
44 bytes,
45 )
46 .into_response();
47 }
48 (
49 [
50 (header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
51 (header::CACHE_CONTROL, "public, max-age=3600"),
52 ],
53 identicon(&username),
54 )
55 .into_response()
56}
57
58/// `POST /settings/avatar` — accept an image upload, store it, and record its
59/// content type. Multipart fields: `_csrf` (token) and `avatar` (the file).
60pub async fn upload(
61 State(state): State<AppState>,
62 RequireUser(user): RequireUser,
63 jar: CookieJar,
64 mut multipart: Multipart,
65) -> Response {
66 let mut csrf: Option<String> = None;
67 let mut mime: Option<String> = None;
68 let mut data: Option<Vec<u8>> = None;
69
70 while let Ok(Some(field)) = multipart.next_field().await {
71 match field.name() {
72 Some("_csrf") => csrf = field.text().await.ok(),
73 Some("avatar") => {
74 let content_type = field.content_type().map(str::to_string);
75 match field.bytes().await {
76 Ok(bytes) if !bytes.is_empty() => {
77 mime = content_type;
78 data = Some(bytes.to_vec());
79 }
80 _ => {}
81 }
82 }
83 _ => {}
84 }
85 }
86
87 // CSRF: the double-submit cookie must match the submitted token.
88 if crate::session::verify_csrf(&jar, &axum::http::HeaderMap::new(), csrf.as_deref()).is_err() {
89 return AppError::Forbidden.into_response();
90 }
91
92 let (Some(bytes), Some(mime)) = (data, mime) else {
93 // Nothing chosen — treat as a no-op and return to settings.
94 return Redirect::to("/settings").into_response();
95 };
96 if bytes.len() > MAX_AVATAR_BYTES {
97 return AppError::BadRequest("Avatar must be 1 MiB or smaller.".to_string())
98 .into_response();
99 }
100 if !ALLOWED_MIME.contains(&mime.as_str()) {
101 return AppError::BadRequest("Avatar must be a PNG, JPEG, GIF, or WebP image.".to_string())
102 .into_response();
103 }
104
105 if let Err(e) = state
106 .blobs
107 .put_bytes(Namespace::Avatars, &user.id, bytes, &mime)
108 .await
109 {
110 return AppError::internal(e).into_response();
111 }
112 if let Err(e) = state.store.set_avatar_mime(&user.id, Some(&mime)).await {
113 return AppError::from(e).into_response();
114 }
115 (StatusCode::SEE_OTHER, Redirect::to("/settings")).into_response()
116}
117
118/// Render a deterministic 5×5 mirrored identicon SVG for `seed` (the username).
119///
120/// The layout and hue derive from a stable FNV-1a hash, so the same name always
121/// yields the same badge across restarts and machines.
122fn identicon(seed: &str) -> Vec<u8> {
123 let hash = fnv1a(seed.to_lowercase().as_bytes());
124 // Hue from the top bits; a calm, legible fill against the light plate.
125 let hue = u32::try_from(hash % 360).unwrap_or(210);
126 let fill = format!("hsl({hue}, 55%, 55%)");
127
128 // 3 columns decide the pattern; columns 3 and 4 mirror 1 and 0.
129 let mut cells = String::new();
130 for row in 0..5u32 {
131 for col in 0..3u32 {
132 let bit = row * 3 + col;
133 if (hash >> bit) & 1 == 1 {
134 let mirror = 4 - col;
135 let _ = write!(cells, r#"<rect x="{col}" y="{row}" width="1" height="1"/>"#);
136 if mirror != col {
137 let _ = write!(
138 cells,
139 r#"<rect x="{mirror}" y="{row}" width="1" height="1"/>"#
140 );
141 }
142 }
143 }
144 }
145
146 format!(
147 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" shape-rendering="crispEdges" role="img"><rect width="5" height="5" fill="#e8e8e8"/><g fill="{fill}">{cells}</g></svg>"##
148 )
149 .into_bytes()
150}
151
152/// FNV-1a 64-bit hash — small, dependency-free, and stable across runs.
153fn fnv1a(bytes: &[u8]) -> u64 {
154 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
155 for &b in bytes {
156 hash ^= u64::from(b);
157 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
158 }
159 hash
160}