Completes W4: users can upload a custom avatar image. - `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept), re-encodes PNG, base64s, and rejects anything over a hard cap. - Settings "Avatar" gains an "Upload image…" button (native picker via rfd's xdg-portal backend, off-thread through Task::perform) and shows the current custom avatar as a selected tile. - Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`): a custom image must be within the byte cap and decode as a PNG within bounds (image-crate decode limits guard against decompression bombs) or it's downgraded to a monogram. - Raised the gossip max message size to 64 KB so a capped custom avatar fits inline on the presence plane (all peers already need a matching build). - Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster` are actually new in the lockfile (rest were already transitive). cargo audit clean (0 vulns; the 2 unmaintained warnings are pre-existing S7). - `Controller::send` now returns bool (was a Result carrying the now-larger CoreCommand by value, which tripped result_large_err). - +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest accept/reject). 227 lib tests green, clippy clean. Manual check: Settings → Avatar → Upload; confirm the picker opens and the image shows for you and (after redeploy) for a peer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
12 KiB
Rust
302 lines
12 KiB
Rust
//! Avatar helpers: a participant's chosen [`Avatar`] plus the deterministic
|
||
//! monogram + colour used as the fallback.
|
||
//!
|
||
//! Every participant gets a visual avatar (W4). When they haven't chosen a preset
|
||
//! or uploaded an image, we fall back to a *monogram*: their initial(s) on a
|
||
//! colour deterministically derived from a stable key (their node id, or their
|
||
//! name where the id isn't available, e.g. a chat line). Pure + dependency-free
|
||
//! so it's unit-testable and needs no image decoding for the common case.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Number of bundled preset avatars (`preset1.png`..`preset6.png`).
|
||
pub const PRESET_COUNT: u8 = 6;
|
||
|
||
/// The raw PNG bytes of each bundled preset, embedded so they're available
|
||
/// offline and identical on every peer. Index 0 = preset 1, etc.
|
||
pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [
|
||
include_bytes!("../assets/avatars/preset1.png"),
|
||
include_bytes!("../assets/avatars/preset2.png"),
|
||
include_bytes!("../assets/avatars/preset3.png"),
|
||
include_bytes!("../assets/avatars/preset4.png"),
|
||
include_bytes!("../assets/avatars/preset5.png"),
|
||
include_bytes!("../assets/avatars/preset6.png"),
|
||
];
|
||
|
||
/// Longest side a custom avatar is downscaled to on ingest (both our own upload
|
||
/// and the limit incoming peer images are validated against). Small on purpose:
|
||
/// avatars render tiny, and it keeps the transported bytes well within the gossip
|
||
/// frame budget.
|
||
pub const CUSTOM_MAX_PX: u32 = 128;
|
||
|
||
/// Hard cap on the base64 length of a custom avatar. Bounds the presence payload
|
||
/// (which rides a 64 KB gossip frame) and the memory a malicious peer can make us
|
||
/// hold. A 128px PNG is comfortably under this; anything larger is rejected.
|
||
pub const CUSTOM_MAX_B64: usize = 48 * 1024;
|
||
|
||
/// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`)
|
||
/// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered
|
||
/// from name + a colour key, no image). `Preset(i)` selects a bundled preset by
|
||
/// zero-based index. `Custom` carries a base64-encoded, hard-capped PNG (W4
|
||
/// Phase 3): produced by [`process_upload`] for our own uploads, and validated by
|
||
/// [`sanitize_incoming`] for untrusted peer ones.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||
pub enum Avatar {
|
||
/// Initials-on-colour fallback.
|
||
#[default]
|
||
Monogram,
|
||
/// A bundled preset by zero-based index; out-of-range falls back to monogram.
|
||
Preset(u8),
|
||
/// A custom image as a base64-encoded PNG (capped, see [`CUSTOM_MAX_B64`]).
|
||
Custom(String),
|
||
}
|
||
|
||
impl Avatar {
|
||
/// The embedded PNG bytes for this avatar, or `None` when it isn't a preset
|
||
/// (or the preset index is out of range, which renders as a monogram).
|
||
pub fn preset_png(&self) -> Option<&'static [u8]> {
|
||
match self {
|
||
Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The decoded PNG bytes for a custom avatar, or `None` for monogram/preset
|
||
/// (or if the stored base64 is malformed). Used to build an image handle.
|
||
pub fn custom_png(&self) -> Option<Vec<u8>> {
|
||
use base64::Engine;
|
||
match self {
|
||
Avatar::Custom(b64) => base64::engine::general_purpose::STANDARD
|
||
.decode(b64.as_bytes())
|
||
.ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Validate an avatar that arrived from an untrusted peer (over presence),
|
||
/// returning a safe value. Monogram/Preset pass through (an out-of-range
|
||
/// preset just renders as a monogram). A `Custom` is accepted only if its
|
||
/// base64 is within [`CUSTOM_MAX_B64`], decodes, and the PNG decodes within
|
||
/// [`CUSTOM_MAX_PX`] bounds (guards against decompression bombs / junk);
|
||
/// otherwise it's downgraded to a monogram. Re-encode-free: we keep the
|
||
/// original (already-validated) base64 to avoid re-deriving it every receipt.
|
||
pub fn sanitize_incoming(self) -> Avatar {
|
||
match self {
|
||
Avatar::Custom(ref b64) => {
|
||
if b64.len() <= CUSTOM_MAX_B64 && custom_b64_is_valid_png(b64) {
|
||
self
|
||
} else {
|
||
Avatar::Monogram
|
||
}
|
||
}
|
||
other => other,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Decode a base64 PNG and confirm it's a real PNG within [`CUSTOM_MAX_PX`]
|
||
/// bounds, using the `image` crate's decode limits so a malicious image can't
|
||
/// allocate unbounded memory. Returns false on any error.
|
||
fn custom_b64_is_valid_png(b64: &str) -> bool {
|
||
use base64::Engine;
|
||
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()) else {
|
||
return false;
|
||
};
|
||
let mut limits = image::Limits::default();
|
||
// Cap dimensions a bit above our resize target to allow for slight slack.
|
||
limits.max_image_width = Some(CUSTOM_MAX_PX * 2);
|
||
limits.max_image_height = Some(CUSTOM_MAX_PX * 2);
|
||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes));
|
||
reader.set_format(image::ImageFormat::Png);
|
||
reader.limits(limits);
|
||
match reader.decode() {
|
||
Ok(img) => img.width() <= CUSTOM_MAX_PX * 2 && img.height() <= CUSTOM_MAX_PX * 2,
|
||
Err(_) => false,
|
||
}
|
||
}
|
||
|
||
/// Process a user-picked image file (arbitrary png/jpeg bytes) into a capped
|
||
/// custom [`Avatar`]: decode, downscale so the longest side is [`CUSTOM_MAX_PX`]
|
||
/// (aspect preserved), re-encode as PNG, base64. Errors (undecodable, or the
|
||
/// result somehow exceeds the cap) are returned as a message for the UI.
|
||
pub fn process_upload(raw: &[u8]) -> Result<Avatar, String> {
|
||
use base64::Engine;
|
||
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
|
||
let small = img.thumbnail(CUSTOM_MAX_PX, CUSTOM_MAX_PX);
|
||
let mut png = std::io::Cursor::new(Vec::new());
|
||
small
|
||
.write_to(&mut png, image::ImageFormat::Png)
|
||
.map_err(|e| format!("Couldn't encode image: {e}"))?;
|
||
let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
|
||
if b64.len() > CUSTOM_MAX_B64 {
|
||
return Err("Image is too large even after resizing.".to_string());
|
||
}
|
||
Ok(Avatar::Custom(b64))
|
||
}
|
||
|
||
/// Curated palette the monogram background is chosen from. Picked to be distinct
|
||
/// and legible under the app's (mostly dark) themes; the matching text colour is
|
||
/// decided per-background by [`use_dark_text_on`]. Order is part of the stable
|
||
/// mapping — don't reorder without accepting that everyone's colour shifts.
|
||
pub const PALETTE: [(u8, u8, u8); 12] = [
|
||
(0xE5, 0x73, 0x73), // red
|
||
(0xE5, 0x9E, 0x57), // orange
|
||
(0xE5, 0xC0, 0x7B), // amber
|
||
(0x8C, 0xC2, 0x65), // green
|
||
(0x5E, 0xC8, 0xA0), // teal
|
||
(0x5C, 0xB3, 0xE5), // blue
|
||
(0x6E, 0x8C, 0xE5), // azure
|
||
(0x9A, 0x8C, 0xE5), // indigo
|
||
(0xC0, 0x7B, 0xE5), // violet
|
||
(0xE5, 0x7B, 0xC0), // pink
|
||
(0xB0, 0x8B, 0x6E), // brown
|
||
(0x8A, 0x9B, 0xA8), // slate
|
||
];
|
||
|
||
/// Pick a stable palette colour for `key` (a node id string, or a name). Uses an
|
||
/// FNV-1a hash so the same key always maps to the same colour across machines.
|
||
pub fn color_for_key(key: &str) -> (u8, u8, u8) {
|
||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||
for b in key.as_bytes() {
|
||
hash ^= u64::from(*b);
|
||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||
}
|
||
PALETTE[(hash % PALETTE.len() as u64) as usize]
|
||
}
|
||
|
||
/// Whether dark text is more legible than white on the given background, by
|
||
/// relative luminance (sRGB-weighted). Light backgrounds → dark text.
|
||
pub fn use_dark_text_on((r, g, b): (u8, u8, u8)) -> bool {
|
||
let lum = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
|
||
lum > 150.0
|
||
}
|
||
|
||
/// The 1–2 character monogram for a display name: the first letters of its first
|
||
/// two alphanumeric words, uppercased. Falls back to "?" when nothing usable
|
||
/// (empty name, or only emoji/punctuation) remains.
|
||
pub fn initials(name: &str) -> String {
|
||
let mut firsts = name
|
||
.split_whitespace()
|
||
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()));
|
||
match (firsts.next(), firsts.next()) {
|
||
(Some(a), Some(b)) => format!("{}{}", a.to_uppercase(), b.to_uppercase()),
|
||
(Some(a), None) => a.to_uppercase().to_string(),
|
||
_ => "?".to_string(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn initials_takes_first_two_words() {
|
||
assert_eq!(initials("Alice"), "A");
|
||
assert_eq!(initials("alice bob"), "AB");
|
||
assert_eq!(initials(" spaced out name "), "SO");
|
||
}
|
||
|
||
#[test]
|
||
fn initials_handles_unicode_and_skips_non_alnum() {
|
||
assert_eq!(initials("héllo"), "H");
|
||
// Leading emoji word is skipped; the next alphanumeric word is used.
|
||
assert_eq!(initials("🎙 Mike"), "M");
|
||
}
|
||
|
||
#[test]
|
||
fn initials_falls_back_to_question_mark() {
|
||
assert_eq!(initials(""), "?");
|
||
assert_eq!(initials(" "), "?");
|
||
assert_eq!(initials("🎉🎊"), "?");
|
||
}
|
||
|
||
#[test]
|
||
fn color_is_deterministic_and_in_palette() {
|
||
let c1 = color_for_key("node-abc");
|
||
let c2 = color_for_key("node-abc");
|
||
assert_eq!(c1, c2, "same key must map to same colour");
|
||
assert!(PALETTE.contains(&c1));
|
||
// Different keys generally differ; at minimum the function is total.
|
||
let _ = color_for_key("");
|
||
}
|
||
|
||
#[test]
|
||
fn avatar_default_is_monogram() {
|
||
assert_eq!(Avatar::default(), Avatar::Monogram);
|
||
assert!(Avatar::default().preset_png().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn preset_png_in_range_and_out_of_range() {
|
||
// Every declared preset index resolves to embedded bytes.
|
||
for i in 0..PRESET_COUNT {
|
||
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing");
|
||
}
|
||
// Out-of-range index gracefully yields None (→ monogram fallback).
|
||
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
||
assert!(Avatar::Preset(250).preset_png().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn avatar_serde_round_trips_and_defaults() {
|
||
// Round-trips through JSON (it rides presence + persists in config).
|
||
for a in [Avatar::Monogram, Avatar::Preset(0), Avatar::Preset(5)] {
|
||
let s = serde_json::to_string(&a).unwrap();
|
||
assert_eq!(serde_json::from_str::<Avatar>(&s).unwrap(), a);
|
||
}
|
||
}
|
||
|
||
/// A valid PNG of the given size, as raw bytes (test helper).
|
||
fn make_png(w: u32, h: u32) -> Vec<u8> {
|
||
let img = image::DynamicImage::new_rgba8(w, h);
|
||
let mut buf = std::io::Cursor::new(Vec::new());
|
||
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||
buf.into_inner()
|
||
}
|
||
|
||
#[test]
|
||
fn process_upload_resizes_and_round_trips() {
|
||
// A 400x200 image downscales so the longest side is CUSTOM_MAX_PX.
|
||
let raw = make_png(400, 200);
|
||
let avatar = process_upload(&raw).expect("should process");
|
||
assert!(matches!(avatar, Avatar::Custom(_)));
|
||
// The stored base64 decodes back to a PNG within bounds.
|
||
let png = avatar.custom_png().expect("custom png decodes");
|
||
let decoded = image::load_from_memory(&png).unwrap();
|
||
assert_eq!(decoded.width().max(decoded.height()), CUSTOM_MAX_PX);
|
||
assert!(decoded.width() <= CUSTOM_MAX_PX && decoded.height() <= CUSTOM_MAX_PX);
|
||
}
|
||
|
||
#[test]
|
||
fn process_upload_rejects_non_image() {
|
||
assert!(process_upload(b"definitely not an image").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_incoming_passes_monogram_and_presets() {
|
||
assert_eq!(Avatar::Monogram.sanitize_incoming(), Avatar::Monogram);
|
||
assert_eq!(Avatar::Preset(2).sanitize_incoming(), Avatar::Preset(2));
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_incoming_accepts_valid_custom() {
|
||
let avatar = process_upload(&make_png(64, 64)).unwrap();
|
||
assert_eq!(avatar.clone().sanitize_incoming(), avatar);
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_incoming_rejects_junk_and_oversize() {
|
||
// Not valid base64 / not a PNG → downgraded to monogram.
|
||
assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram);
|
||
// Over the byte cap → downgraded without even decoding.
|
||
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
|
||
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
|
||
}
|
||
|
||
#[test]
|
||
fn dark_text_chosen_on_light_backgrounds() {
|
||
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
|
||
assert!(!use_dark_text_on((0x10, 0x10, 0x10))); // near-black bg → light text
|
||
}
|
||
}
|