0044: Pair avatars are small JPEGs

On this page

Status: accepted

Context

People in a Pair call show as their initials. A picture tells them apart faster, but it's a file from someone else's computer: the host passes each guest's avatar on to everyone in the call, and the app shows it. It has to stay small, because it travels in signaling messages, which the share listener caps at 256 KiB. It also mustn't become a way to send arbitrary bytes (an SVG with script, a decompression bomb, a photo's location metadata) to the people you call.

Decision

  • Where it lives: one file, pair/avatar.jpg under Short Leash's home, 0600 in a 0700 folder (fsx::write_private_atomic). It isn't in config.toml or Short Leash's database. The state shows only whether you have one (pairSettings.avatar). GET /v1/pair/avatar gives the picture as a data: URL, POST /v1/pair/avatar (base64, up to 10 MB) sets it, and DELETE removes it.
  • What Short Leash keeps: pair::avatar::normalise decodes PNG, JPEG or WebP and nothing else (no SVG, GIF or HEIC). It turns the picture the way its EXIF says, crops the middle square, shrinks it to 96 × 96 on white, and encodes a new JPEG at quality 80, lowering the quality until it's at most 16 KB. Only that JPEG is saved. Bytes that don't decode are never written, and nothing of the original (metadata, other frames) survives.
  • How it travels: a guest sends its avatar once, as base64 in Signal::Hello (avatar). The host passes avatars on in Admitted (avatars, its own included, as [peer, base64] pairs) and Joined (avatar). Every new field is optional with a serde default, so older versions of Short Leash still read these messages and send theirs without one. With four people, the biggest Admitted carries three avatars, under 70 KB.
  • Untrusted on arrival: the host runs every guest's avatar through pair::avatar::from_peer before keeping it. It refuses anything over 16 KB, decodes with tight limits (512 pixels a side, 8 MB of memory; a Short Leash sends 96), and encodes it again the same way. A guest checks what the host sends the same way. One that fails is dropped and that person shows with their initials. Browser guests (ADR 0042) send none and ignore those they're sent. Amended 2026-10-16: browser guests now show the JPEGs the host checked, as data:image/jpeg images beside each name. They still send none. Their page takes only base64 text, and its content security policy loads images from data: URLs and nowhere else.
  • The app shows avatars as data:image/jpeg;base64,… URLs in PairView.avatar (yours) and PairPersonView.avatar (others'), with an empty alt, because the name is next to them.

Consequences

  • An avatar is sent when you join. A change during a call shows in your own view at once, but others see it from your next call (or when your connection to the host comes back). There's no message to update it mid-call.
  • People who would rather not show a picture don't have to: without one, they keep their initials, as before.
  • Short Leash gains the image crate (MIT or Apache-2.0) with only its PNG, JPEG and WebP codecs, which are pure Rust. HEIC isn't supported, because its decoders are LGPL or need system libraries. On a Mac, pictures from Photos can be exported as JPEG.
  • Decoding a 10 MB picture runs off the async runtime (spawn_blocking), within 256 MB and 16,384 pixels a side.
  • config.toml stays plain text you can read and share, and the database keeps only what ADR 0029 lists. The avatar is a picture you chose, not captured data, so it doesn't need a retention rule.