| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use crate::db::accounts; |
| 7 | | use crate::Shared; |
| 8 | | use axum::extract::{Path, State}; |
| 9 | | use axum::http::header; |
| 10 | | use axum::response::{IntoResponse, Response}; |
| 11 | | use std::io::Cursor; |
| 12 | |
|
| 13 | |
|
| 14 | | pub const MAX_BYTES: usize = 5 << 20; |
| 15 | |
|
| 16 | | const MAX_SIDE: u32 = 8000; |
| 17 | |
|
| 18 | | pub const SIZE: u32 = 400; |
| 19 | |
|
| 20 | |
|
| 21 | | pub struct Picture { |
| 22 | | pub bytes: Vec<u8>, |
| 23 | | |
| 24 | | pub format: &'static str, |
| 25 | | } |
| 26 | |
|
| 27 | |
|
| 28 | |
|
| 29 | |
|
| 30 | 11× | pub fn process(input: &[u8]) -> Result<Picture, String> { |
| 31 | 11× | if input.len() > MAX_BYTES { |
| 32 | 1× | return Err(format!("A picture can be at most {} MiB.", MAX_BYTES >> 20)); |
| 33 | 10× | } |
| 34 | 10× | let format = image::guess_format(input).map_err(|_| "That is not a PNG, JPEG or WebP image.".to_string())?; |
| 35 | 8× | if !matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP) { |
| 36 | 1× | return Err("Pictures must be PNG, JPEG or WebP.".into()); |
| 37 | 7× | } |
| 38 | 7× | let mut reader = image::ImageReader::with_format(Cursor::new(input), format); |
| 39 | 7× | let mut limits = image::Limits::default(); |
| 40 | 7× | limits.max_image_width = Some(MAX_SIDE); |
| 41 | 7× | limits.max_image_height = Some(MAX_SIDE); |
| 42 | 7× | limits.max_alloc = Some(512 << 20); |
| 43 | 7× | reader.limits(limits); |
| 44 | 7× | let img = reader.decode().map_err(|e| format!("The image could not be read: {e}"))?; |
| 45 | 5× | let side = img.width().min(img.height()); |
| 46 | 5× | if side == 0 { |
| 47 | 0× | return Err("The image is empty.".into()); |
| 48 | 5× | } |
| 49 | 5× | let square = img.crop_imm((img.width() - side) / 2, (img.height() - side) / 2, side, side); |
| 50 | 5× | let scaled = square.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3); |
| 51 | 5× | let mut out = Vec::new(); |
| 52 | 5× | if scaled.color().has_alpha() { |
| 53 | 2× | scaled.to_rgba8().write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?; |
| 54 | 2× | Ok(Picture { bytes: out, format: "png" }) |
| 55 | | } else { |
| 56 | 3× | image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 88).encode_image(&scaled.to_rgb8()).map_err(|e| e.to_string())?; |
| 57 | 3× | Ok(Picture { bytes: out, format: "jpeg" }) |
| 58 | | } |
| 59 | 11× | } |
| 60 | |
|
| 61 | 7× | fn file_of(app: &Shared, kind: &str, id: i64) -> std::path::PathBuf { |
| 62 | 7× | app.config.data.join("avatars").join(format!("{kind}-{id}")) |
| 63 | 7× | } |
| 64 | |
|
| 65 | |
|
| 66 | |
|
| 67 | 3× | pub async fn store(app: &Shared, kind: &str, id: i64, upload: Vec<u8>) -> Result<(), String> { |
| 68 | 3× | let pic = tokio::task::spawn_blocking(move || process(&upload)).await.map_err(|e| e.to_string())??; |
| 69 | 2× | let path = file_of(app, kind, id); |
| 70 | 2× | let dir = path.parent().unwrap().to_path_buf(); |
| 71 | 2× | tokio::fs::create_dir_all(&dir).await.map_err(|e| e.to_string())?; |
| 72 | 2× | let tmp = dir.join(format!(".{kind}-{id}.tmp")); |
| 73 | 2× | tokio::fs::write(&tmp, &pic.bytes).await.map_err(|e| e.to_string())?; |
| 74 | 2× | tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?; |
| 75 | 2× | let hash = blake3::hash(&pic.bytes).to_hex(); |
| 76 | 2× | accounts::set_avatar(&app.db, kind, id, &format!("{}:{}", pic.format, &hash[..16])).await.map_err(|e| e.to_string()) |
| 77 | 3× | } |
| 78 | |
|
| 79 | |
|
| 80 | 1× | pub async fn remove(app: &Shared, kind: &str, id: i64) -> Result<(), String> { |
| 81 | 1× | accounts::set_avatar(&app.db, kind, id, "").await.map_err(|e| e.to_string())?; |
| 82 | 1× | let _ = tokio::fs::remove_file(file_of(app, kind, id)).await; |
| 83 | 1× | Ok(()) |
| 84 | 1× | } |
| 85 | |
|
| 86 | |
|
| 87 | |
|
| 88 | 8× | pub fn identicon(name: &str) -> String { |
| 89 | 8× | let h = blake3::hash(name.to_ascii_lowercase().as_bytes()); |
| 90 | 8× | let b = h.as_bytes(); |
| 91 | 8× | let hue = u16::from_le_bytes([b[0], b[1]]) % 360; |
| 92 | 8× | let bits = u32::from_le_bytes([b[2], b[3], b[4], b[5]]); |
| 93 | 8× | let mut cells = String::new(); |
| 94 | 40× | for row in 0..5u32 { |
| 95 | 120× | for col in 0..3u32 { |
| 96 | 120× | if bits >> (row * 3 + col) & 1 == 1 { |
| 97 | 95× | for x in [col, 4 - col] { |
| 98 | 95× | cells.push_str(&format!(r#"<rect x="{}" y="{}" width="1" height="1"/>"#, x + 1, row + 1)); |
| 99 | 95× | if x == 2 { |
| 100 | 21× | break; |
| 101 | 74× | } |
| 102 | | } |
| 103 | 62× | } |
| 104 | | } |
| 105 | | } |
| 106 | 8× | format!( |
| 107 | | r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 7" shape-rendering="crispEdges"><rect width="7" height="7" fill="hsl({hue},45%,92%)"/><g fill="hsl({hue},65%,45%)">{cells}</g></svg>"# |
| 108 | | ) |
| 109 | 8× | } |
| 110 | |
|
| 111 | |
|
| 112 | | pub struct Upload { |
| 113 | | pub csrf: String, |
| 114 | | pub file: Vec<u8>, |
| 115 | | pub remove: bool, |
| 116 | | } |
| 117 | |
|
| 118 | 5× | pub async fn read_upload(mut multipart: axum::extract::Multipart) -> Upload { |
| 119 | 5× | let mut u = Upload { csrf: String::new(), file: Vec::new(), remove: false }; |
| 120 | 20× | while let Ok(Some(field)) = multipart.next_field().await { |
| 121 | 15× | match field.name() { |
| 122 | 15× | Some("_csrf") => u.csrf = field.text().await.unwrap_or_default(), |
| 123 | 10× | Some("picture") => u.file = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(), |
| 124 | 5× | Some("action") => u.remove = field.text().await.unwrap_or_default() == "remove", |
| 125 | 0× | _ => {} |
| 126 | | } |
| 127 | | } |
| 128 | 5× | u |
| 129 | 5× | } |
| 130 | |
|
| 131 | |
|
| 132 | 4× | pub async fn apply(app: &Shared, kind: &str, id: i64, u: Upload) -> Result<String, String> { |
| 133 | 4× | if u.remove { |
| 134 | 1× | remove(app, kind, id).await?; |
| 135 | 1× | return Ok("Picture removed; the generated one is back.".into()); |
| 136 | 3× | } |
| 137 | 3× | if u.file.is_empty() { |
| 138 | 0× | return Err("Choose a picture to upload.".into()); |
| 139 | 3× | } |
| 140 | 3× | store(app, kind, id, u.file).await?; |
| 141 | 2× | Ok("Picture saved.".into()) |
| 142 | 4× | } |
| 143 | |
|
| 144 | |
|
| 145 | |
|
| 146 | |
|
| 147 | 7× | pub fn agent_mark(name: &str) -> Option<&'static str> { |
| 148 | | const MARKS: &[(&str, &str)] = &[ |
| 149 | | ("claude", include_str!("../../../../assets/agents/claude.svg")), |
| 150 | | ("anthropic", include_str!("../../../../assets/agents/claude.svg")), |
| 151 | | ("chatgpt", include_str!("../../../../assets/agents/chatgpt.svg")), |
| 152 | | ("openai", include_str!("../../../../assets/agents/chatgpt.svg")), |
| 153 | | ("codex", include_str!("../../../../assets/agents/chatgpt.svg")), |
| 154 | | ("perplexity", include_str!("../../../../assets/agents/perplexity.svg")), |
| 155 | | ("deepseek", include_str!("../../../../assets/agents/deepseek.svg")), |
| 156 | | ("mistral", include_str!("../../../../assets/agents/mistral.svg")), |
| 157 | | ("antigravity", include_str!("../../../../assets/agents/antigravity.svg")), |
| 158 | | ("gemini", include_str!("../../../../assets/agents/antigravity.svg")), |
| 159 | | ("kimi", include_str!("../../../../assets/agents/kimi.svg")), |
| 160 | | ("moonshot", include_str!("../../../../assets/agents/kimi.svg")), |
| 161 | | ]; |
| 162 | 7× | let lower = name.to_lowercase(); |
| 163 | 69× | MARKS.iter().find(|(k, _)| lower.contains(k)).map(|(_, svg)| *svg) |
| 164 | 7× | } |
| 165 | |
|
| 166 | |
|
| 167 | 2× | pub async fn agent(Path(name): Path<String>) -> Response { |
| 168 | 2× | let name = name.trim_end_matches(".svg"); |
| 169 | 2× | let svg = agent_mark(name).map(String::from).unwrap_or_else(|| identicon(name)); |
| 170 | 2× | ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")], svg).into_response() |
| 171 | 2× | } |
| 172 | |
|
| 173 | |
|
| 174 | 8× | pub async fn avatar(State(app): State<Shared>, Path(name): Path<String>) -> Response { |
| 175 | 8× | let name = name.trim_end_matches(".svg"); |
| 176 | 8× | let uploaded = match accounts::account_by_name(&app.db, name).await.ok().flatten() { |
| 177 | 6× | Some(a) => Some(("account", a.id, a.avatar)), |
| 178 | 2× | None => accounts::org_by_name(&app.db, name).await.ok().flatten().map(|o| ("org", o.id, o.avatar)), |
| 179 | | }; |
| 180 | 8× | if let Some((kind, id, value)) = uploaded.filter(|(_, _, v)| !v.is_empty()) { |
| 181 | 4× | if let (Some((format, hash)), Ok(bytes)) = (value.split_once(':'), tokio::fs::read(file_of(&app, kind, id)).await) { |
| 182 | 4× | let mime = if format == "png" { "image/png" } else { "image/jpeg" }; |
| 183 | 4× | return ( |
| 184 | 4× | [ |
| 185 | 4× | (header::CONTENT_TYPE, mime.to_string()), |
| 186 | 4× | (header::CACHE_CONTROL, "public, max-age=300".to_string()), |
| 187 | 4× | (header::ETAG, format!("\"{hash}\"")), |
| 188 | 4× | (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), |
| 189 | 4× | ], |
| 190 | 4× | bytes, |
| 191 | 4× | ) |
| 192 | 4× | .into_response(); |
| 193 | 0× | } |
| 194 | 4× | } |
| 195 | | |
| 196 | 4× | let svg = system_mark(name).map(String::from).unwrap_or_else(|| identicon(name)); |
| 197 | 4× | ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=300")], svg).into_response() |
| 198 | 8× | } |
| 199 | |
|
| 200 | |
|
| 201 | |
|
| 202 | 7× | pub fn system_mark(name: &str) -> Option<&'static str> { |
| 203 | 7× | (name == crate::diffusion::ACCOUNT).then_some(include_str!("../../../../assets/icons/diffusion.svg")) |
| 204 | 7× | } |
| 205 | |
|
| 206 | | #[cfg(test)] |
| 207 | | mod tests { |
| 208 | | use super::{agent_mark, identicon, process, system_mark, SIZE}; |
| 209 | |
|
| 210 | 4× | fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> { |
| 211 | 4× | let mut out = Vec::new(); |
| 212 | 4× | img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); |
| 213 | 4× | out |
| 214 | 4× | } |
| 215 | |
|
| 216 | | #[test] |
| 217 | 1× | fn uploads_are_cropped_scaled_and_reencoded() { |
| 218 | | |
| 219 | 270000× | let photo = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(900, 300, |x, _| image::Rgb([(x / 4) as u8, 80, 160]))); |
| 220 | 1× | let pic = process(&encoded(photo, image::ImageFormat::Png)).unwrap(); |
| 221 | 1× | assert_eq!(pic.format, "jpeg"); |
| 222 | 1× | let back = image::load_from_memory(&pic.bytes).unwrap(); |
| 223 | 1× | assert_eq!((back.width(), back.height()), (SIZE, SIZE)); |
| 224 | | |
| 225 | 4096× | let logo = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(64, 64, |x, _| { |
| 226 | 4096× | image::Rgba([0, 114, 255, if x < 32 { 0 } else { 255 }]) |
| 227 | 4096× | })); |
| 228 | 1× | let pic = process(&encoded(logo, image::ImageFormat::Png)).unwrap(); |
| 229 | 1× | assert_eq!(pic.format, "png"); |
| 230 | 1× | assert!(image::load_from_memory(&pic.bytes).unwrap().color().has_alpha()); |
| 231 | | |
| 232 | 1× | let jpeg = encoded(image::DynamicImage::ImageRgb8(image::RgbImage::new(50, 80)), image::ImageFormat::Jpeg); |
| 233 | 1× | assert_eq!(process(&jpeg).unwrap().format, "jpeg"); |
| 234 | 1× | } |
| 235 | |
|
| 236 | | #[test] |
| 237 | 1× | fn anything_else_is_refused() { |
| 238 | 1× | assert!(process(b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>").is_err(), "no SVG"); |
| 239 | 1× | assert!(process(b"GIF89a\x01\0\x01\0").is_err(), "no GIF"); |
| 240 | 1× | assert!(process(b"\x89PNG\r\n\x1a\n broken").is_err(), "a truncated PNG"); |
| 241 | 1× | assert!(process(&vec![0u8; super::MAX_BYTES + 1]).err().unwrap().contains("at most 5 MiB")); |
| 242 | | |
| 243 | 1× | let big = encoded(image::DynamicImage::ImageLuma8(image::GrayImage::new(8001, 1)), image::ImageFormat::Png); |
| 244 | 1× | assert!(process(&big).is_err(), "8001 pixels wide"); |
| 245 | 1× | } |
| 246 | |
|
| 247 | | #[test] |
| 248 | 1× | fn kimi_has_its_mark() { |
| 249 | 4× | for name in ["kimi", "Kimi", "agent:kimi@andymccall", "Moonshot Kimi"] { |
| 250 | 4× | let svg = agent_mark(name).unwrap_or_else(|| panic!("{name} has no mark")); |
| 251 | 4× | assert!(svg.contains("aria-label=\"Kimi\""), "{name}"); |
| 252 | | } |
| 253 | 1× | assert!(agent_mark("kim").is_none(), "only the name itself matches"); |
| 254 | 1× | } |
| 255 | |
|
| 256 | | #[test] |
| 257 | 1× | fn the_diffusion_account_has_its_mark() { |
| 258 | 1× | let m = system_mark("diffusion").expect("a mark"); |
| 259 | 1× | assert!(m.starts_with("<svg") && m.contains("<title>Diffusion</title>")); |
| 260 | 1× | assert!(system_mark("andy").is_none() && system_mark("Diffusion").is_none(), "only the hub's own account, by its exact name"); |
| 261 | 1× | } |
| 262 | |
|
| 263 | | #[test] |
| 264 | 1× | fn identicons_are_stable_symmetric_and_case_blind() { |
| 265 | 1× | let a = identicon("andy"); |
| 266 | 1× | assert_eq!(a, identicon("Andy"), "names are case-insensitive"); |
| 267 | 1× | assert_ne!(a, identicon("bob")); |
| 268 | 1× | assert!(a.starts_with("<svg") && a.ends_with("</svg>")); |
| 269 | | |
| 270 | 1× | let cells: Vec<(u32, u32)> = a |
| 271 | 1× | .split(r#"<rect x=""#) |
| 272 | 1× | .skip(1) |
| 273 | 11× | .filter_map(|r| { |
| 274 | 11× | let x: u32 = r.split('"').next()?.parse().ok()?; |
| 275 | 11× | let y: u32 = r.split(r#"y=""#).nth(1)?.split('"').next()?.parse().ok()?; |
| 276 | 11× | Some((x, y)) |
| 277 | 11× | }) |
| 278 | 11× | .filter(|(x, _)| (1..=5).contains(x)) |
| 279 | 1× | .collect(); |
| 280 | 11× | for (x, y) in &cells { |
| 281 | 11× | assert!(cells.contains(&(6 - x, *y)), "({x},{y}) has no mirror in {a}"); |
| 282 | | } |
| 283 | 1× | } |
| 284 | | } |