| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | use std::fmt::Write as _; |
| 12 | |
| 13 | use axum::extract::{Multipart, Path, State}; |
| 14 | use axum::http::{StatusCode, header}; |
| 15 | use axum::response::{IntoResponse, Redirect, Response}; |
| 16 | use axum_extra::extract::cookie::CookieJar; |
| 17 | use blob::Namespace; |
| 18 | |
| 19 | use crate::AppState; |
| 20 | use crate::error::AppError; |
| 21 | use crate::session::RequireUser; |
| 22 | |
| 23 | |
| 24 | const MAX_AVATAR_BYTES: usize = 1024 * 1024; |
| 25 | |
| 26 | |
| 27 | |
| 28 | const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; |
| 29 | |
| 30 | |
| 31 | |
| 32 | pub 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 | |
| 59 | |
| 60 | pub 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 | |
| 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 | |
| 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 | |
| 119 | |
| 120 | |
| 121 | |
| 122 | fn identicon(seed: &str) -> Vec<u8> { |
| 123 | let hash = fnv1a(seed.to_lowercase().as_bytes()); |
| 124 | |
| 125 | let hue = u32::try_from(hash % 360).unwrap_or(210); |
| 126 | let fill = format!("hsl({hue}, 55%, 55%)"); |
| 127 | |
| 128 | |
| 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 | |
| 153 | fn 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 | } |