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:
Generated
+32
@@ -4602,11 +4602,13 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"dirs",
|
"dirs",
|
||||||
"iced",
|
"iced",
|
||||||
|
"image",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-gossip",
|
"iroh-gossip",
|
||||||
"opus",
|
"opus",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
|
"rfd",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4773,6 +4775,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pollster"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -5275,6 +5283,30 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.17.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
|
||||||
|
dependencies = [
|
||||||
|
"block2 0.6.2",
|
||||||
|
"dispatch2",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"percent-encoding",
|
||||||
|
"pollster",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rgb"
|
name = "rgb"
|
||||||
version = "0.8.53"
|
version = "0.8.53"
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ base64 = "0.22.1"
|
|||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||||
|
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||||
|
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
|
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||||
iroh = "1.0.0-rc.0"
|
iroh = "1.0.0-rc.0"
|
||||||
iroh-gossip = "0.99.0"
|
iroh-gossip = "0.99.0"
|
||||||
opus = "0.3.1"
|
opus = "0.3.1"
|
||||||
|
|||||||
+71
-11
@@ -165,6 +165,11 @@ pub enum AppMessage {
|
|||||||
SelectTheme(AppTheme),
|
SelectTheme(AppTheme),
|
||||||
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
|
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
|
||||||
SelectAvatar(crate::avatar::Avatar),
|
SelectAvatar(crate::avatar::Avatar),
|
||||||
|
/// Open the native file picker to choose a custom avatar image (W4 Phase 3).
|
||||||
|
PickAvatarFile,
|
||||||
|
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
|
||||||
|
/// if the user cancelled.
|
||||||
|
AvatarFilePicked(Option<Vec<u8>>),
|
||||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||||
ToggleDrawerChat,
|
ToggleDrawerChat,
|
||||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||||
@@ -790,6 +795,39 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
// Re-announce to the room if we're in a call (no-op otherwise).
|
// Re-announce to the room if we're in a call (no-op otherwise).
|
||||||
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
||||||
}
|
}
|
||||||
|
AppMessage::PickAvatarFile => {
|
||||||
|
// Open the native picker off the UI thread; the result comes back as
|
||||||
|
// AvatarFilePicked. Filter to the formats we can actually decode.
|
||||||
|
return Task::perform(
|
||||||
|
async {
|
||||||
|
let handle = rfd::AsyncFileDialog::new()
|
||||||
|
.add_filter("Images", &["png", "jpg", "jpeg"])
|
||||||
|
.set_title("Choose an avatar image")
|
||||||
|
.pick_file()
|
||||||
|
.await;
|
||||||
|
match handle {
|
||||||
|
Some(h) => Some(h.read().await),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AppMessage::AvatarFilePicked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AppMessage::AvatarFilePicked(picked) => {
|
||||||
|
if let Some(bytes) = picked {
|
||||||
|
match crate::avatar::process_upload(&bytes) {
|
||||||
|
Ok(avatar) => {
|
||||||
|
state.config.avatar = avatar.clone();
|
||||||
|
state.config.save();
|
||||||
|
state.status_message = "Avatar updated.".to_string();
|
||||||
|
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.status_message = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::ToggleDrawerChat => {
|
AppMessage::ToggleDrawerChat => {
|
||||||
state.drawer_chat_open = !state.drawer_chat_open;
|
state.drawer_chat_open = !state.drawer_chat_open;
|
||||||
}
|
}
|
||||||
@@ -1254,17 +1292,37 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.align_x(iced::alignment::Horizontal::Center)
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
let mut avatar_tiles: Vec<Element<'_, AppMessage>> =
|
let mut avatar_tiles: Vec<Element<'_, AppMessage>> = Vec::new();
|
||||||
vec![avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())];
|
// Show the current custom avatar (if any) as the first, selected tile.
|
||||||
|
if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) {
|
||||||
|
avatar_tiles.push(avatar_choice(state.config.avatar.clone(), "Custom".to_string()));
|
||||||
|
}
|
||||||
|
avatar_tiles.push(avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string()));
|
||||||
for i in 0..crate::avatar::PRESET_COUNT {
|
for i in 0..crate::avatar::PRESET_COUNT {
|
||||||
avatar_tiles.push(avatar_choice(
|
avatar_tiles.push(avatar_choice(
|
||||||
crate::avatar::Avatar::Preset(i),
|
crate::avatar::Avatar::Preset(i),
|
||||||
format!("Preset {}", i + 1),
|
format!("Preset {}", i + 1),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Wrap into rows of four (no flex-wrap in iced 0.14) so the tiles don't
|
||||||
|
// run off a narrow Settings panel.
|
||||||
|
let mut tile_rows: Vec<Element<'_, AppMessage>> = Vec::new();
|
||||||
|
let mut tiles_iter = avatar_tiles.into_iter();
|
||||||
|
loop {
|
||||||
|
let chunk: Vec<Element<'_, AppMessage>> = tiles_iter.by_ref().take(4).collect();
|
||||||
|
if chunk.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tile_rows.push(iced::widget::Row::with_children(chunk).spacing(12).into());
|
||||||
|
}
|
||||||
|
let upload_btn = button(text("Upload image…").size(13))
|
||||||
|
.on_press(AppMessage::PickAvatarFile)
|
||||||
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||||
|
.padding(8);
|
||||||
let avatar_section = column![
|
let avatar_section = column![
|
||||||
iced::widget::Row::with_children(avatar_tiles).spacing(12),
|
iced::widget::Column::with_children(tile_rows).spacing(12),
|
||||||
text("Shown next to your name in the room and chat. Applies live.")
|
upload_btn,
|
||||||
|
text("Shown next to your name in the room and chat. Custom images are PNG/JPEG, auto-resized. Applies live.")
|
||||||
.size(11)
|
.size(11)
|
||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
]
|
]
|
||||||
@@ -2865,13 +2923,15 @@ fn avatar_view<'a>(
|
|||||||
key: &str,
|
key: &str,
|
||||||
size: f32,
|
size: f32,
|
||||||
) -> Element<'a, AppMessage> {
|
) -> Element<'a, AppMessage> {
|
||||||
match avatar.preset_png() {
|
let png_bytes: Option<bytes::Bytes> = avatar
|
||||||
Some(png) => iced::widget::image(
|
.preset_png()
|
||||||
iced::widget::image::Handle::from_bytes(bytes::Bytes::from_static(png)),
|
.map(bytes::Bytes::from_static)
|
||||||
)
|
.or_else(|| avatar.custom_png().map(bytes::Bytes::from));
|
||||||
.width(iced::Length::Fixed(size))
|
match png_bytes {
|
||||||
.height(iced::Length::Fixed(size))
|
Some(b) => iced::widget::image(iced::widget::image::Handle::from_bytes(b))
|
||||||
.into(),
|
.width(iced::Length::Fixed(size))
|
||||||
|
.height(iced::Length::Fixed(size))
|
||||||
|
.into(),
|
||||||
None => avatar_badge(name, key, size),
|
None => avatar_badge(name, key, size),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-4
@@ -23,10 +23,23 @@ pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [
|
|||||||
include_bytes!("../assets/avatars/preset6.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`)
|
/// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`)
|
||||||
/// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered
|
/// 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
|
/// from name + a colour key, no image). `Preset(i)` selects a bundled preset by
|
||||||
/// zero-based index. (Custom uploaded images are W4 Phase 3 — a future variant.)
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub enum Avatar {
|
pub enum Avatar {
|
||||||
/// Initials-on-colour fallback.
|
/// Initials-on-colour fallback.
|
||||||
@@ -34,17 +47,91 @@ pub enum Avatar {
|
|||||||
Monogram,
|
Monogram,
|
||||||
/// A bundled preset by zero-based index; out-of-range falls back to monogram.
|
/// A bundled preset by zero-based index; out-of-range falls back to monogram.
|
||||||
Preset(u8),
|
Preset(u8),
|
||||||
|
/// A custom image as a base64-encoded PNG (capped, see [`CUSTOM_MAX_B64`]).
|
||||||
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Avatar {
|
impl Avatar {
|
||||||
/// The embedded PNG bytes for this avatar, or `None` when it's a monogram
|
/// The embedded PNG bytes for this avatar, or `None` when it isn't a preset
|
||||||
/// (or an out-of-range preset index, which renders as a monogram).
|
/// (or the preset index is out of range, which renders as a monogram).
|
||||||
pub fn preset_png(&self) -> Option<&'static [u8]> {
|
pub fn preset_png(&self) -> Option<&'static [u8]> {
|
||||||
match self {
|
match self {
|
||||||
Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
|
Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
|
||||||
Avatar::Monogram => None,
|
_ => 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
|
/// Curated palette the monogram background is chosen from. Picked to be distinct
|
||||||
@@ -159,6 +246,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[test]
|
||||||
fn dark_text_chosen_on_light_backgrounds() {
|
fn dark_text_chosen_on_light_backgrounds() {
|
||||||
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
|
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
|
||||||
|
|||||||
+13
-3
@@ -42,8 +42,12 @@ impl CoreController {
|
|||||||
Self { cmd_tx }
|
Self { cmd_tx }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&self, cmd: CoreCommand) -> Result<(), mpsc::error::TrySendError<CoreCommand>> {
|
/// Queue a command for the core loop, best-effort. Returns `true` if it was
|
||||||
self.cmd_tx.try_send(cmd)
|
/// accepted, `false` if the channel is full or closed. (We return a plain
|
||||||
|
/// bool rather than the channel's `Result` so the bulky `CoreCommand` isn't
|
||||||
|
/// carried back by value in every caller's error type.)
|
||||||
|
pub fn send(&self, cmd: CoreCommand) -> bool {
|
||||||
|
self.cmd_tx.try_send(cmd).is_ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +545,13 @@ async fn run_core_loop(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize Gossip and Transport
|
// Initialize Gossip and Transport
|
||||||
let gossip = Gossip::builder().spawn(endpoint.clone());
|
// Raise the gossip frame budget above the 4 KB default so a
|
||||||
|
// hard-capped custom avatar (W4) can ride presence inline. All
|
||||||
|
// peers must use the same value (already a breaking build req from
|
||||||
|
// the avatar field). 64 KB leaves generous headroom over our cap.
|
||||||
|
let gossip = Gossip::builder()
|
||||||
|
.max_message_size(65536)
|
||||||
|
.spawn(endpoint.clone());
|
||||||
let (transport, audio_proto) = IrohTransport::new(endpoint.clone());
|
let (transport, audio_proto) = IrohTransport::new(endpoint.clone());
|
||||||
let transport = Arc::new(transport);
|
let transport = Arc::new(transport);
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,12 @@ impl RoomState for IrohGossipState {
|
|||||||
// spoofable): sanitize at ingest so every
|
// spoofable): sanitize at ingest so every
|
||||||
// consumer gets a safe value (security S4).
|
// consumer gets a safe value (security S4).
|
||||||
state.name = crate::sanitize::sanitize_name(&state.name);
|
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 (is_new, state_changed) = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
let is_new = !peer_map.contains_key(&payload.author);
|
let is_new = !peer_map.contains_key(&payload.author);
|
||||||
|
|||||||
Reference in New Issue
Block a user