//! Pictures of accounts and organisations (hub spec §6.3): an uploaded //! photo or logo, re-encoded so no uploaded bytes are ever served, or an //! identicon: a five-by-five mirrored pattern and a colour drawn from //! BLAKE3 of the lower-cased name, so everyone has a stable picture. use crate::db::accounts; use crate::Shared; use axum::extract::{Path, State}; use axum::http::header; use axum::response::{IntoResponse, Response}; use std::io::Cursor; /// Largest upload accepted. pub const MAX_BYTES: usize = 5 << 20; /// Largest width or height decoded. const MAX_SIDE: u32 = 8000; /// The stored picture is this many pixels square. pub const SIZE: u32 = 400; /// A re-encoded picture. pub struct Picture { pub bytes: Vec, /// `png` or `jpeg`. pub format: &'static str, } /// Decode an upload (PNG, JPEG or WebP by content), crop it to the middle /// square, scale it to `SIZE`, and encode it afresh: PNG when it has /// transparency, JPEG otherwise. pub fn process(input: &[u8]) -> Result { if input.len() > MAX_BYTES { return Err(format!("A picture can be at most {} MiB.", MAX_BYTES >> 20)); } let format = image::guess_format(input).map_err(|_| "That is not a PNG, JPEG or WebP image.".to_string())?; if !matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP) { return Err("Pictures must be PNG, JPEG or WebP.".into()); } let mut reader = image::ImageReader::with_format(Cursor::new(input), format); let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_SIDE); limits.max_image_height = Some(MAX_SIDE); limits.max_alloc = Some(512 << 20); reader.limits(limits); let img = reader.decode().map_err(|e| format!("The image could not be read: {e}"))?; let side = img.width().min(img.height()); if side == 0 { return Err("The image is empty.".into()); } let square = img.crop_imm((img.width() - side) / 2, (img.height() - side) / 2, side, side); let scaled = square.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3); let mut out = Vec::new(); if scaled.color().has_alpha() { scaled.to_rgba8().write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?; Ok(Picture { bytes: out, format: "png" }) } else { image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 88).encode_image(&scaled.to_rgb8()).map_err(|e| e.to_string())?; Ok(Picture { bytes: out, format: "jpeg" }) } } fn file_of(app: &Shared, kind: &str, id: i64) -> std::path::PathBuf { app.config.data.join("avatars").join(format!("{kind}-{id}")) } /// Process and store an upload as the picture of an account (`account`) or /// organisation (`org`). pub async fn store(app: &Shared, kind: &str, id: i64, upload: Vec) -> Result<(), String> { let pic = tokio::task::spawn_blocking(move || process(&upload)).await.map_err(|e| e.to_string())??; let path = file_of(app, kind, id); let dir = path.parent().unwrap().to_path_buf(); tokio::fs::create_dir_all(&dir).await.map_err(|e| e.to_string())?; let tmp = dir.join(format!(".{kind}-{id}.tmp")); tokio::fs::write(&tmp, &pic.bytes).await.map_err(|e| e.to_string())?; tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?; let hash = blake3::hash(&pic.bytes).to_hex(); accounts::set_avatar(&app.db, kind, id, &format!("{}:{}", pic.format, &hash[..16])).await.map_err(|e| e.to_string()) } /// Go back to the identicon. pub async fn remove(app: &Shared, kind: &str, id: i64) -> Result<(), String> { accounts::set_avatar(&app.db, kind, id, "").await.map_err(|e| e.to_string())?; let _ = tokio::fs::remove_file(file_of(app, kind, id)).await; Ok(()) } /// The SVG for `name`: 15 bits choose the cells of the left three columns /// (mirrored to five), two bytes choose the hue. pub fn identicon(name: &str) -> String { let h = blake3::hash(name.to_ascii_lowercase().as_bytes()); let b = h.as_bytes(); let hue = u16::from_le_bytes([b[0], b[1]]) % 360; let bits = u32::from_le_bytes([b[2], b[3], b[4], b[5]]); let mut cells = String::new(); for row in 0..5u32 { for col in 0..3u32 { if bits >> (row * 3 + col) & 1 == 1 { for x in [col, 4 - col] { cells.push_str(&format!(r#""#, x + 1, row + 1)); if x == 2 { break; } } } } } format!( r#"{cells}"# ) } /// A picture form: the CSRF token, the file, and `action=remove` to clear it. pub struct Upload { pub csrf: String, pub file: Vec, pub remove: bool, } pub async fn read_upload(mut multipart: axum::extract::Multipart) -> Upload { let mut u = Upload { csrf: String::new(), file: Vec::new(), remove: false }; while let Ok(Some(field)) = multipart.next_field().await { match field.name() { Some("_csrf") => u.csrf = field.text().await.unwrap_or_default(), Some("picture") => u.file = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(), Some("action") => u.remove = field.text().await.unwrap_or_default() == "remove", _ => {} } } u } /// Apply a picture form to an account or organisation; the message to show. pub async fn apply(app: &Shared, kind: &str, id: i64, u: Upload) -> Result { if u.remove { remove(app, kind, id).await?; return Ok("Picture removed; the generated one is back.".into()); } if u.file.is_empty() { return Err("Choose a picture to upload.".into()); } store(app, kind, id, u.file).await?; Ok("Picture saved.".into()) } /// The brand mark for an AI agent, by the name its identity carries /// (hub spec §6): `Claude Code` → Claude's, `ChatGPT` → OpenAI's, and so /// on; `None` for a name the hub has no mark for. pub fn agent_mark(name: &str) -> Option<&'static str> { const MARKS: &[(&str, &str)] = &[ ("claude", include_str!("../../../../assets/agents/claude.svg")), ("anthropic", include_str!("../../../../assets/agents/claude.svg")), ("chatgpt", include_str!("../../../../assets/agents/chatgpt.svg")), ("openai", include_str!("../../../../assets/agents/chatgpt.svg")), ("codex", include_str!("../../../../assets/agents/chatgpt.svg")), ("perplexity", include_str!("../../../../assets/agents/perplexity.svg")), ("deepseek", include_str!("../../../../assets/agents/deepseek.svg")), ("mistral", include_str!("../../../../assets/agents/mistral.svg")), ("antigravity", include_str!("../../../../assets/agents/antigravity.svg")), ("gemini", include_str!("../../../../assets/agents/antigravity.svg")), ]; let lower = name.to_lowercase(); MARKS.iter().find(|(k, _)| lower.contains(k)).map(|(_, svg)| *svg) } /// `GET /avatar/agent/{name}`: an AI agent's brand mark, or its identicon. pub async fn agent(Path(name): Path) -> Response { let name = name.trim_end_matches(".svg"); let svg = agent_mark(name).map(String::from).unwrap_or_else(|| identicon(name)); ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")], svg).into_response() } /// `GET /avatar/{name}`: the uploaded picture, or the identicon. pub async fn avatar(State(app): State, Path(name): Path) -> Response { let name = name.trim_end_matches(".svg"); let uploaded = match accounts::account_by_name(&app.db, name).await.ok().flatten() { Some(a) => Some(("account", a.id, a.avatar)), None => accounts::org_by_name(&app.db, name).await.ok().flatten().map(|o| ("org", o.id, o.avatar)), }; if let Some((kind, id, value)) = uploaded.filter(|(_, _, v)| !v.is_empty()) { if let (Some((format, hash)), Ok(bytes)) = (value.split_once(':'), tokio::fs::read(file_of(&app, kind, id)).await) { let mime = if format == "png" { "image/png" } else { "image/jpeg" }; return ( [ (header::CONTENT_TYPE, mime.to_string()), (header::CACHE_CONTROL, "public, max-age=300".to_string()), (header::ETAG, format!("\"{hash}\"")), (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), ], bytes, ) .into_response(); } } // The hub's own accounts have their marks until someone uploads another. let svg = system_mark(name).map(String::from).unwrap_or_else(|| identicon(name)); ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=300")], svg).into_response() } /// The mark of an account the hub runs itself: Diffusion's ripples from a /// source out to its consumers. pub fn system_mark(name: &str) -> Option<&'static str> { (name == crate::diffusion::ACCOUNT).then_some(include_str!("../../../../assets/icons/diffusion.svg")) } #[cfg(test)] mod tests { use super::{identicon, process, system_mark, SIZE}; fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { let mut out = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); out } #[test] fn uploads_are_cropped_scaled_and_reencoded() { // A wide photo becomes a square JPEG from its middle. let photo = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(900, 300, |x, _| image::Rgb([(x / 4) as u8, 80, 160]))); let pic = process(&encoded(photo, image::ImageFormat::Png)).unwrap(); assert_eq!(pic.format, "jpeg"); let back = image::load_from_memory(&pic.bytes).unwrap(); assert_eq!((back.width(), back.height()), (SIZE, SIZE)); // A logo with transparency stays PNG with its alpha. let logo = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(64, 64, |x, _| { image::Rgba([0, 114, 255, if x < 32 { 0 } else { 255 }]) })); let pic = process(&encoded(logo, image::ImageFormat::Png)).unwrap(); assert_eq!(pic.format, "png"); assert!(image::load_from_memory(&pic.bytes).unwrap().color().has_alpha()); // JPEG in, too. let jpeg = encoded(image::DynamicImage::ImageRgb8(image::RgbImage::new(50, 80)), image::ImageFormat::Jpeg); assert_eq!(process(&jpeg).unwrap().format, "jpeg"); } #[test] fn anything_else_is_refused() { assert!(process(b"").is_err(), "no SVG"); assert!(process(b"GIF89a\x01\0\x01\0").is_err(), "no GIF"); assert!(process(b"\x89PNG\r\n\x1a\n broken").is_err(), "a truncated PNG"); assert!(process(&vec![0u8; super::MAX_BYTES + 1]).err().unwrap().contains("at most 5 MiB")); // Larger than the side limit: refused before allocating it. let big = encoded(image::DynamicImage::ImageLuma8(image::GrayImage::new(8001, 1)), image::ImageFormat::Png); assert!(process(&big).is_err(), "8001 pixels wide"); } #[test] fn the_diffusion_account_has_its_mark() { let m = system_mark("diffusion").expect("a mark"); assert!(m.starts_with("Diffusion")); assert!(system_mark("andy").is_none() && system_mark("Diffusion").is_none(), "only the hub's own account, by its exact name"); } #[test] fn identicons_are_stable_symmetric_and_case_blind() { let a = identicon("andy"); assert_eq!(a, identicon("Andy"), "names are case-insensitive"); assert_ne!(a, identicon("bob")); assert!(a.starts_with("")); // Every cell has its mirror image. let cells: Vec<(u32, u32)> = a .split(r#"