// 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##""##
)
.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
}