Files
peerspeak/src/avatar.rs
T
molluskandClaude Opus 4.8 f029a30ea7 feat(avatars): custom image upload — W4 Phase 3
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>
2026-06-14 17:00:18 -04:00

302 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 12 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
}
}