// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Avatars: an uploaded image when the user has one, else a deterministic //! identicon generated from the username. Bytes live in the `avatars` blob //! namespace (local disk or S3), keyed by user id; the `users.avatar_mime` //! column records the content type (its presence means "serve the file"). No //! network requests and no remote gravatar (§9.5). use std::fmt::Write as _; use axum::extract::{Multipart, Path, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use blob::Namespace; use crate::AppState; use crate::error::AppError; use crate::session::RequireUser; /// The largest avatar upload accepted, in bytes (1 `MiB`). const MAX_AVATAR_BYTES: usize = 1024 * 1024; /// The raster image types accepted for upload. SVG is deliberately excluded to /// avoid serving user-supplied markup; identicons are our own SVG. const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; /// `GET /avatar/{username}` — the user's uploaded avatar, or a generated /// identicon. Always renders an image so `` tags never break. pub async fn show(State(state): State, Path(username): Path) -> Response { let user = state.store.user_by_username(&username).await.ok().flatten(); if let Some(user) = &user && let Some(mime) = &user.avatar_mime && let Ok(Some(bytes)) = state.blobs.get_bytes(Namespace::Avatars, &user.id).await { return ( [ (header::CONTENT_TYPE, mime.as_str()), (header::CACHE_CONTROL, "private, max-age=60"), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), ], bytes, ) .into_response(); } ( [ (header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600"), ], identicon(&username), ) .into_response() } /// `POST /settings/avatar` — accept an image upload, store it, and record its /// content type. Multipart fields: `_csrf` (token) and `avatar` (the file). pub async fn upload( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, mut multipart: Multipart, ) -> Response { let mut csrf: Option = None; let mut mime: Option = None; let mut data: Option> = None; while let Ok(Some(field)) = multipart.next_field().await { match field.name() { Some("_csrf") => csrf = field.text().await.ok(), Some("avatar") => { let content_type = field.content_type().map(str::to_string); match field.bytes().await { Ok(bytes) if !bytes.is_empty() => { mime = content_type; data = Some(bytes.to_vec()); } _ => {} } } _ => {} } } // CSRF: the double-submit cookie must match the submitted token. if crate::session::verify_csrf(&jar, &axum::http::HeaderMap::new(), csrf.as_deref()).is_err() { return AppError::Forbidden.into_response(); } let (Some(bytes), Some(mime)) = (data, mime) else { // Nothing chosen — treat as a no-op and return to settings. return Redirect::to("/settings").into_response(); }; if bytes.len() > MAX_AVATAR_BYTES { return AppError::BadRequest("Avatar must be 1 MiB or smaller.".to_string()) .into_response(); } if !ALLOWED_MIME.contains(&mime.as_str()) { return AppError::BadRequest("Avatar must be a PNG, JPEG, GIF, or WebP image.".to_string()) .into_response(); } if let Err(e) = state .blobs .put_bytes(Namespace::Avatars, &user.id, bytes, &mime) .await { return AppError::internal(e).into_response(); } if let Err(e) = state.store.set_avatar_mime(&user.id, Some(&mime)).await { return AppError::from(e).into_response(); } (StatusCode::SEE_OTHER, Redirect::to("/settings")).into_response() } /// Render a deterministic 5×5 mirrored identicon SVG for `seed` (the username). /// /// The layout and hue derive from a stable FNV-1a hash, so the same name always /// yields the same badge across restarts and machines. fn identicon(seed: &str) -> Vec { let hash = fnv1a(seed.to_lowercase().as_bytes()); // Hue from the top bits; a calm, legible fill against the light plate. let hue = u32::try_from(hash % 360).unwrap_or(210); let fill = format!("hsl({hue}, 55%, 55%)"); // 3 columns decide the pattern; columns 3 and 4 mirror 1 and 0. let mut cells = String::new(); for row in 0..5u32 { for col in 0..3u32 { let bit = row * 3 + col; if (hash >> bit) & 1 == 1 { let mirror = 4 - col; let _ = write!(cells, r#""#); if mirror != col { let _ = write!( cells, r#""# ); } } } } format!( r##"{cells}"## ) .into_bytes() } /// FNV-1a 64-bit hash — small, dependency-free, and stable across runs. fn fnv1a(bytes: &[u8]) -> u64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for &b in bytes { hash ^= u64::from(b); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } hash }