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>
This commit is contained in:
2026-06-14 17:00:18 -04:00
co-authored by Claude Opus 4.8
parent 3007002b1b
commit f029a30ea7
6 changed files with 264 additions and 18 deletions
+6
View File
@@ -293,6 +293,12 @@ impl RoomState for IrohGossipState {
// spoofable): sanitize at ingest so every
// consumer gets a safe value (security S4).
state.name = crate::sanitize::sanitize_name(&state.name);
// A peer's avatar is equally untrusted: a
// custom image is validated (size + safe
// decode within bounds) or downgraded to a
// monogram, so a malformed/oversized/bomb
// image can't crash or exhaust us (W4).
state.avatar = state.avatar.sanitize_incoming();
let (is_new, state_changed) = {
let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author);