atagen

malice

public
Star0
Watch0

Commit 7643577c76bb

tarball

atagen <boss@atagen.co>2026-10-25 08:50 UTC

17 files changed+444-62
media: resize avatars with lanczos variants
Modified file: Cargo.lock+133 additions−1 deletions
@@ -407,12 +407,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
407407 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
408408
409409 [[package]]
410+name = "bytemuck"
411+version = "1.25.2"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
414+
415+[[package]]
410416 name = "byteorder"
411417 version = "1.5.0"
412418 source = "registry+https://github.com/rust-lang/crates.io-index"
413419 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
414420
415421 [[package]]
422+name = "byteorder-lite"
423+version = "0.1.0"
424+source = "registry+https://github.com/rust-lang/crates.io-index"
425+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
426+
427+[[package]]
416428 name = "bytes"
417429 version = "1.12.1"
418430 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -533,6 +545,12 @@ dependencies = [
533545 ]
534546
535547 [[package]]
548+name = "color_quant"
549+version = "1.1.0"
550+source = "registry+https://github.com/rust-lang/crates.io-index"
551+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
552+
553+[[package]]
536554 name = "combine"
537555 version = "4.6.8"
538556 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1051,6 +1069,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
10511069 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
10521070
10531071 [[package]]
1072+name = "fdeflate"
1073+version = "0.3.7"
1074+source = "registry+https://github.com/rust-lang/crates.io-index"
1075+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
1076+dependencies = [
1077+ "simd-adler32",
1078+]
1079+
1080+[[package]]
10541081 name = "fiat-crypto"
10551082 version = "0.2.9"
10561083 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1069,7 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
10691096 checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
10701097 dependencies = [
10711098 "crc32fast",
1072- "miniz_oxide",
1099+ "miniz_oxide 0.9.1",
10731100 "zlib-rs",
10741101 ]
10751102
@@ -1264,6 +1291,16 @@ dependencies = [
12641291 ]
12651292
12661293 [[package]]
1294+name = "gif"
1295+version = "0.14.2"
1296+source = "registry+https://github.com/rust-lang/crates.io-index"
1297+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
1298+dependencies = [
1299+ "color_quant",
1300+ "weezl",
1301+]
1302+
1303+[[package]]
12671304 name = "gloo-timers"
12681305 version = "0.3.0"
12691306 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1623,6 +1660,34 @@ dependencies = [
16231660 ]
16241661
16251662 [[package]]
1663+name = "image"
1664+version = "0.25.10"
1665+source = "registry+https://github.com/rust-lang/crates.io-index"
1666+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
1667+dependencies = [
1668+ "bytemuck",
1669+ "byteorder-lite",
1670+ "color_quant",
1671+ "gif",
1672+ "image-webp",
1673+ "moxcms",
1674+ "num-traits",
1675+ "png",
1676+ "zune-core",
1677+ "zune-jpeg",
1678+]
1679+
1680+[[package]]
1681+name = "image-webp"
1682+version = "0.2.4"
1683+source = "registry+https://github.com/rust-lang/crates.io-index"
1684+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
1685+dependencies = [
1686+ "byteorder-lite",
1687+ "quick-error",
1688+]
1689+
1690+[[package]]
16261691 name = "imbl"
16271692 version = "6.1.0"
16281693 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2090,6 +2155,7 @@ name = "malice-core"
20902155 version = "0.1.0"
20912156 dependencies = [
20922157 "futures-util",
2158+ "image",
20932159 "macltok",
20942160 "serde",
20952161 "serde_json",
@@ -2548,6 +2614,16 @@ dependencies = [
25482614
25492615 [[package]]
25502616 name = "miniz_oxide"
2617+version = "0.8.9"
2618+source = "registry+https://github.com/rust-lang/crates.io-index"
2619+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
2620+dependencies = [
2621+ "adler2",
2622+ "simd-adler32",
2623+]
2624+
2625+[[package]]
2626+name = "miniz_oxide"
25512627 version = "0.9.1"
25522628 source = "registry+https://github.com/rust-lang/crates.io-index"
25532629 checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
@@ -2568,6 +2644,16 @@ dependencies = [
25682644 ]
25692645
25702646 [[package]]
2647+name = "moxcms"
2648+version = "0.8.1"
2649+source = "registry+https://github.com/rust-lang/crates.io-index"
2650+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
2651+dependencies = [
2652+ "num-traits",
2653+ "pxfm",
2654+]
2655+
2656+[[package]]
25712657 name = "multer"
25722658 version = "3.1.0"
25732659 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2853,6 +2939,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
28532939 checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
28542940
28552941 [[package]]
2942+name = "png"
2943+version = "0.18.1"
2944+source = "registry+https://github.com/rust-lang/crates.io-index"
2945+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2946+dependencies = [
2947+ "bitflags",
2948+ "crc32fast",
2949+ "fdeflate",
2950+ "flate2",
2951+ "miniz_oxide 0.8.9",
2952+]
2953+
2954+[[package]]
28562955 name = "poly1305"
28572956 version = "0.8.0"
28582957 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2956,6 +3055,18 @@ dependencies = [
29563055 ]
29573056
29583057 [[package]]
3058+name = "pxfm"
3059+version = "0.1.30"
3060+source = "registry+https://github.com/rust-lang/crates.io-index"
3061+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
3062+
3063+[[package]]
3064+name = "quick-error"
3065+version = "2.0.1"
3066+source = "registry+https://github.com/rust-lang/crates.io-index"
3067+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
3068+
3069+[[package]]
29593070 name = "quinn"
29603071 version = "0.11.12"
29613072 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4640,6 +4751,12 @@ dependencies = [
46404751 ]
46414752
46424753 [[package]]
4754+name = "weezl"
4755+version = "0.1.12"
4756+source = "registry+https://github.com/rust-lang/crates.io-index"
4757+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
4758+
4759+[[package]]
46434760 name = "wildmatch"
46444761 version = "2.6.1"
46454762 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5007,3 +5124,18 @@ name = "zmij"
50075124 version = "1.0.23"
50085125 source = "registry+https://github.com/rust-lang/crates.io-index"
50095126 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5127+
5128+[[package]]
5129+name = "zune-core"
5130+version = "0.5.3"
5131+source = "registry+https://github.com/rust-lang/crates.io-index"
5132+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
5133+
5134+[[package]]
5135+name = "zune-jpeg"
5136+version = "0.5.15"
5137+source = "registry+https://github.com/rust-lang/crates.io-index"
5138+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
5139+dependencies = [
5140+ "zune-core",
5141+]
Modified file: crates/malice-core/Cargo.toml+1 additions
@@ -11,6 +11,7 @@ macltok = { workspace = true, features = ["serde", "mirror"] }
1111 futures-util = { version = "0.3", default-features = false, features = ["std"] }
1212 serde = { workspace = true }
1313 serde_json = { workspace = true }
14+image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
1415
1516 [lints]
1617 workspace = true
Modified file: crates/malice-core/src/media.rs+68 additions
@@ -1,5 +1,73 @@
1+use std::io::Cursor;
2+
3+use image::{
4+ DynamicImage, ImageReader, Limits,
5+ codecs::{jpeg::JpegEncoder, png::PngEncoder},
6+ imageops::FilterType,
7+};
8+
19 pub const AVATAR_SIZE: u32 = 256;
10+pub const AVATAR_MIN_SIZE: u32 = 16;
11+pub const AVATAR_MAX_SIZE: u32 = 512;
212 pub const THUMBNAIL_SIZE: u32 = 800;
13+const AVATAR_DECODE_DIMENSION: u32 = 4096;
14+const AVATAR_DECODE_ALLOC: u64 = 64 * 1024 * 1024;
15+
16+/// Fetched avatar bytes still to be resized. Rendering is CPU bound, so the
17+/// server runs it off the async runtime.
18+#[derive(Debug)]
19+pub struct AvatarSource {
20+ pub bytes: Vec<u8>,
21+ pub content_type: &'static str,
22+ pub size: u32,
23+}
24+
25+impl AvatarSource {
26+ pub fn render(self) -> (Vec<u8>, &'static str) {
27+ // GIF would lose its animation, and this build carries no AVIF decoder
28+ if matches!(self.content_type, "image/gif" | "image/avif") {
29+ return (self.bytes, self.content_type);
30+ }
31+ match resize_avatar(&self.bytes, self.size) {
32+ Some(rendered) => rendered,
33+ None => (self.bytes, self.content_type),
34+ }
35+ }
36+}
37+
38+fn resize_avatar(bytes: &[u8], size: u32) -> Option<(Vec<u8>, &'static str)> {
39+ let size = size.clamp(AVATAR_MIN_SIZE, AVATAR_MAX_SIZE);
40+ let mut reader = ImageReader::new(Cursor::new(bytes))
41+ .with_guessed_format()
42+ .ok()?;
43+ let mut limits = Limits::default();
44+ limits.max_image_width = Some(AVATAR_DECODE_DIMENSION);
45+ limits.max_image_height = Some(AVATAR_DECODE_DIMENSION);
46+ limits.max_alloc = Some(AVATAR_DECODE_ALLOC);
47+ reader.limits(limits);
48+ let image = reader.decode().ok()?;
49+ let (width, height) = (image.width(), image.height());
50+ let shortest = width.min(height);
51+ if shortest <= size {
52+ return None;
53+ }
54+ let scale = f64::from(size) / f64::from(shortest);
55+ let scaled = |side: u32| ((f64::from(side) * scale).round() as u32).max(1);
56+ // custom emotes share this path and are often wide, so keep the aspect and let CSS crop avatars
57+ let resized = image.resize_exact(scaled(width), scaled(height), FilterType::Lanczos3);
58+ let mut output = Vec::new();
59+ if resized.color().has_alpha() {
60+ resized
61+ .write_with_encoder(PngEncoder::new(&mut output))
62+ .ok()?;
63+ Some((output, "image/png"))
64+ } else {
65+ DynamicImage::ImageRgb8(resized.to_rgb8())
66+ .write_with_encoder(JpegEncoder::new_with_quality(&mut output, 88))
67+ .ok()?;
68+ Some((output, "image/jpeg"))
69+ }
70+}
371
472 pub fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
573 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Modified file: crates/malice-core/src/request.rs+27 additions−12 deletions
@@ -10,13 +10,20 @@ use serde_json::json;
1010
1111 use crate::{
1212 error::CoreError,
13- media::{AVATAR_SIZE, THUMBNAIL_SIZE, attachment_content_type, image_content_type},
13+ media::{
14+ AVATAR_MAX_SIZE, AVATAR_MIN_SIZE, AVATAR_SIZE, AvatarSource, THUMBNAIL_SIZE,
15+ attachment_content_type, image_content_type,
16+ },
1417 parse::{
1518 parse_event_id, parse_mentions, parse_mxc, parse_room_id, parse_room_or_alias,
1619 parse_user_id,
1720 },
1821 };
1922
23+fn default_avatar_size() -> u32 {
24+ AVATAR_SIZE
25+}
26+
2027 #[derive(Debug, Deserialize)]
2128 #[serde(tag = "op", rename_all = "snake_case")]
2229 pub enum Request {
@@ -193,6 +200,8 @@ pub enum Request {
193200 },
194201 Avatar {
195202 mxc: String,
203+ #[serde(default = "default_avatar_size")]
204+ size: u32,
196205 },
197206 Media {
198207 media_id: String,
@@ -455,10 +464,7 @@ pub enum SendKind {
455464 pub enum Reply {
456465 Done,
457466 Json(serde_json::Value),
458- Avatar {
459- bytes: Vec<u8>,
460- content_type: &'static str,
461- },
467+ Avatar(AvatarSource),
462468 Attachment {
463469 bytes: Vec<u8>,
464470 content_type: Option<&'static str>,
@@ -808,23 +814,32 @@ pub async fn dispatch(
808814 handle.verify(action).await?;
809815 Ok(Reply::Done)
810816 }
811- Request::Avatar { mxc } => {
817+ Request::Avatar { mxc, size } => {
812818 if !mxc.starts_with("mxc://") {
813819 return Err(CoreError::bad_request("invalid mxc URI"));
814820 }
815821 let media_id = parse_media_id(mxc)?;
816- let size = MediaSize::Thumbnail {
817- width: AVATAR_SIZE,
818- height: AVATAR_SIZE,
822+ let size = size.clamp(AVATAR_MIN_SIZE, AVATAR_MAX_SIZE);
823+ let thumbnail = MediaSize::Thumbnail {
824+ width: size,
825+ height: size,
826+ };
827+ let bytes = match handle.media(&media_id, thumbnail).await {
828+ Ok(media) if image_content_type(&media.bytes).is_some() => media.bytes.to_vec(),
829+ _ => handle
830+ .media(&media_id, MediaSize::Full)
831+ .await?
832+ .bytes
833+ .to_vec(),
819834 };
820- let bytes = handle.media(&media_id, size).await?.bytes.to_vec();
821835 let Some(content_type) = image_content_type(&bytes) else {
822836 return Err(CoreError::unsupported("avatar is not a supported image"));
823837 };
824- Ok(Reply::Avatar {
838+ Ok(Reply::Avatar(AvatarSource {
825839 bytes,
826840 content_type,
827- })
841+ size,
842+ }))
828843 }
829844 Request::Media { media_id, size } => {
830845 let media_id = parse_media_id(media_id)?;
Modified file: crates/malice-server/src/routes.rs+38 additions−24 deletions
@@ -143,6 +143,7 @@ struct VerifyBody {
143143 #[derive(Debug, Deserialize)]
144144 struct MediaQuery {
145145 mxc: String,
146+ size: Option<u32>,
146147 }
147148 #[derive(Debug, Deserialize)]
148149 struct MessageMediaQuery {
@@ -216,27 +217,31 @@ async fn auth(
216217 }
217218 }
218219
220+fn avatar_response(bytes: Vec<u8>, content_type: &'static str) -> Response {
221+ (
222+ [
223+ (axum::http::header::CONTENT_TYPE, content_type),
224+ // mxc uris name immutable content, a changed avatar gets a new uri
225+ (
226+ axum::http::header::CACHE_CONTROL,
227+ "private, max-age=31536000, immutable",
228+ ),
229+ (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
230+ ],
231+ bytes,
232+ )
233+ .into_response()
234+}
235+
219236 /// commands answer when they're done, so the browser gets the outcome in the response
220237 fn respond(result: Result<Reply, malice_core::error::CoreError>) -> Response {
221238 match result {
222239 Ok(Reply::Done) => no_content(None),
223240 Ok(Reply::Json(value)) => json_ok(&value, None),
224- Ok(Reply::Avatar {
225- bytes,
226- content_type,
227- }) => (
228- [
229- (axum::http::header::CONTENT_TYPE, content_type),
230- // mxc uris name immutable content, a changed avatar gets a new uri
231- (
232- axum::http::header::CACHE_CONTROL,
233- "private, max-age=31536000, immutable",
234- ),
235- (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
236- ],
237- bytes,
238- )
239- .into_response(),
241+ Ok(Reply::Avatar(source)) => {
242+ let (bytes, content_type) = source.render();
243+ avatar_response(bytes, content_type)
244+ }
240245 Ok(Reply::Attachment {
241246 bytes,
242247 content_type,
@@ -935,14 +940,23 @@ async fn get_media(
935940 Ok(pair) => pair,
936941 Err(error) => return error.into_response(),
937942 };
938- respond(
939- dispatch(
940- &user.handle,
941- &user.mirror,
942- Request::Avatar { mxc: query.mxc },
943- )
944- .await,
945- )
943+ let request = Request::Avatar {
944+ mxc: query.mxc,
945+ size: query.size.unwrap_or(malice_core::media::AVATAR_SIZE),
946+ };
947+ match dispatch(&user.handle, &user.mirror, request).await {
948+ Ok(Reply::Avatar(source)) => {
949+ match tokio::task::spawn_blocking(move || source.render()).await {
950+ Ok((bytes, content_type)) => avatar_response(bytes, content_type),
951+ Err(_) => ApiError::new(
952+ StatusCode::INTERNAL_SERVER_ERROR,
953+ "avatar could not be rendered",
954+ )
955+ .into_response(),
956+ }
957+ }
958+ other => respond(other),
959+ }
946960 }
947961
948962 async fn get_message_media(
Modified file: crates/malice-worker/Cargo.lock+133 additions−1 deletions
@@ -320,12 +320,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
320320 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
321321
322322 [[package]]
323+name = "bytemuck"
324+version = "1.25.2"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
327+
328+[[package]]
323329 name = "byteorder"
324330 version = "1.5.0"
325331 source = "registry+https://github.com/rust-lang/crates.io-index"
326332 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
327333
328334 [[package]]
335+name = "byteorder-lite"
336+version = "0.1.0"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
339+
340+[[package]]
329341 name = "bytes"
330342 version = "1.12.1"
331343 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -446,6 +458,12 @@ dependencies = [
446458 ]
447459
448460 [[package]]
461+name = "color_quant"
462+version = "1.1.0"
463+source = "registry+https://github.com/rust-lang/crates.io-index"
464+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
465+
466+[[package]]
449467 name = "combine"
450468 version = "4.6.8"
451469 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -898,6 +916,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
898916 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
899917
900918 [[package]]
919+name = "fdeflate"
920+version = "0.3.7"
921+source = "registry+https://github.com/rust-lang/crates.io-index"
922+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
923+dependencies = [
924+ "simd-adler32",
925+]
926+
927+[[package]]
901928 name = "fiat-crypto"
902929 version = "0.2.9"
903930 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -916,7 +943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
916943 checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
917944 dependencies = [
918945 "crc32fast",
919- "miniz_oxide",
946+ "miniz_oxide 0.9.1",
920947 "zlib-rs",
921948 ]
922949
@@ -1095,6 +1122,16 @@ dependencies = [
10951122 ]
10961123
10971124 [[package]]
1125+name = "gif"
1126+version = "0.14.2"
1127+source = "registry+https://github.com/rust-lang/crates.io-index"
1128+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
1129+dependencies = [
1130+ "color_quant",
1131+ "weezl",
1132+]
1133+
1134+[[package]]
10981135 name = "gloo-timers"
10991136 version = "0.3.0"
11001137 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1436,6 +1473,34 @@ dependencies = [
14361473 ]
14371474
14381475 [[package]]
1476+name = "image"
1477+version = "0.25.10"
1478+source = "registry+https://github.com/rust-lang/crates.io-index"
1479+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
1480+dependencies = [
1481+ "bytemuck",
1482+ "byteorder-lite",
1483+ "color_quant",
1484+ "gif",
1485+ "image-webp",
1486+ "moxcms",
1487+ "num-traits",
1488+ "png",
1489+ "zune-core",
1490+ "zune-jpeg",
1491+]
1492+
1493+[[package]]
1494+name = "image-webp"
1495+version = "0.2.4"
1496+source = "registry+https://github.com/rust-lang/crates.io-index"
1497+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
1498+dependencies = [
1499+ "byteorder-lite",
1500+ "quick-error",
1501+]
1502+
1503+[[package]]
14391504 name = "imbl"
14401505 version = "6.1.0"
14411506 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1854,6 +1919,7 @@ name = "malice-core"
18541919 version = "0.1.0"
18551920 dependencies = [
18561921 "futures-util",
1922+ "image",
18571923 "macltok",
18581924 "serde",
18591925 "serde_json",
@@ -2262,6 +2328,16 @@ dependencies = [
22622328
22632329 [[package]]
22642330 name = "miniz_oxide"
2331+version = "0.8.9"
2332+source = "registry+https://github.com/rust-lang/crates.io-index"
2333+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
2334+dependencies = [
2335+ "adler2",
2336+ "simd-adler32",
2337+]
2338+
2339+[[package]]
2340+name = "miniz_oxide"
22652341 version = "0.9.1"
22662342 source = "registry+https://github.com/rust-lang/crates.io-index"
22672343 checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
@@ -2282,6 +2358,16 @@ dependencies = [
22822358 ]
22832359
22842360 [[package]]
2361+name = "moxcms"
2362+version = "0.8.1"
2363+source = "registry+https://github.com/rust-lang/crates.io-index"
2364+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
2365+dependencies = [
2366+ "num-traits",
2367+ "pxfm",
2368+]
2369+
2370+[[package]]
22852371 name = "new_debug_unreachable"
22862372 version = "1.0.6"
22872373 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2481,6 +2567,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
24812567 checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
24822568
24832569 [[package]]
2570+name = "png"
2571+version = "0.18.1"
2572+source = "registry+https://github.com/rust-lang/crates.io-index"
2573+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2574+dependencies = [
2575+ "bitflags",
2576+ "crc32fast",
2577+ "fdeflate",
2578+ "flate2",
2579+ "miniz_oxide 0.8.9",
2580+]
2581+
2582+[[package]]
24842583 name = "poly1305"
24852584 version = "0.8.0"
24862585 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2584,6 +2683,18 @@ dependencies = [
25842683 ]
25852684
25862685 [[package]]
2686+name = "pxfm"
2687+version = "0.1.30"
2688+source = "registry+https://github.com/rust-lang/crates.io-index"
2689+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
2690+
2691+[[package]]
2692+name = "quick-error"
2693+version = "2.0.1"
2694+source = "registry+https://github.com/rust-lang/crates.io-index"
2695+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
2696+
2697+[[package]]
25872698 name = "quinn"
25882699 version = "0.11.12"
25892700 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4202,6 +4313,12 @@ dependencies = [
42024313 ]
42034314
42044315 [[package]]
4316+name = "weezl"
4317+version = "0.1.12"
4318+source = "registry+https://github.com/rust-lang/crates.io-index"
4319+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
4320+
4321+[[package]]
42054322 name = "wildmatch"
42064323 version = "2.6.1"
42074324 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4524,3 +4641,18 @@ name = "zmij"
45244641 version = "1.0.23"
45254642 source = "registry+https://github.com/rust-lang/crates.io-index"
45264643 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4644+
4645+[[package]]
4646+name = "zune-core"
4647+version = "0.5.3"
4648+source = "registry+https://github.com/rust-lang/crates.io-index"
4649+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
4650+
4651+[[package]]
4652+name = "zune-jpeg"
4653+version = "0.5.15"
4654+source = "registry+https://github.com/rust-lang/crates.io-index"
4655+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
4656+dependencies = [
4657+ "zune-core",
4658+]
Modified file: crates/malice-worker/src/wire.rs+4 additions−4 deletions
@@ -34,10 +34,10 @@ pub fn reply_value(reply: Reply) -> JsValue {
3434 match reply {
3535 Reply::Done => js_sys::Object::new().into(),
3636 Reply::Json(value) => to_js_value(&serde_json::json!({ "json": value })),
37- Reply::Avatar {
38- bytes,
39- content_type,
40- } => bytes_value(bytes, Some(content_type)),
37+ Reply::Avatar(source) => {
38+ let (bytes, content_type) = source.render();
39+ bytes_value(bytes, Some(content_type))
40+ }
4141 Reply::Attachment {
4242 bytes,
4343 content_type,
Modified file: web/src/App.svelte+1 additions−1 deletions
@@ -2779,7 +2779,7 @@
27792779 on:pointerenter={() => (emoteIndex = i)}
27802780 >
27812781 {#if candidate.kind === 'custom'}
2782- <img class="finder-emote" src={mediaUrl(candidate.mxc) ?? ''} alt="" />
2782+ <img class="finder-emote" src={mediaUrl(candidate.mxc, 64) ?? ''} alt="" />
27832783 {:else}
27842784 <span class="finder-emote"><EmojiText text={candidate.emoji} /></span>
27852785 {/if}
Modified file: web/src/lib/MessageRow.svelte+2 additions−2 deletions
@@ -286,7 +286,7 @@
286286 on:click={() => react(r.key, r.shortcode)}
287287 >
288288 {#if r.key.startsWith('mxc://')}
289- <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
289+ <img class="emote" src={mediaUrl(r.key, 64) ?? ''} alt="" />
290290 {:else}
291291 <Emoji emoji={r.key} size={18} />
292292 {/if}
@@ -456,7 +456,7 @@
456456 {#if r.image}
457457 <img class="emote" src={messageMediaUrl(r.image, 'Thumbnail')} alt={r.shortcode ?? ''} title={r.shortcode ?? ''} />
458458 {:else if r.key.startsWith('mxc://')}
459- <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
459+ <img class="emote" src={mediaUrl(r.key, 64) ?? ''} alt="" />
460460 {:else}
461461 <Emoji emoji={r.key} size={16} />
462462 {/if}
Modified file: web/src/lib/MxcAvatar.svelte+3 additions−1 deletions
@@ -10,7 +10,9 @@
1010 export let shape: 'circle' | 'rounded' = 'circle';
1111 export let seed: string | null = null;
1212
13- $: src = mediaUrl(mxc);
13+ const density = typeof window === 'undefined' ? 1 : Math.min(window.devicePixelRatio || 1, 3);
14+ // rounding up to a 32px step keeps the number of cached variants per avatar small
15+ $: src = mediaUrl(mxc, Math.ceil((size * density) / 32) * 32);
1416
1517 let failed = false;
1618 $: if (mxc) failed = false;
Modified file: web/src/lib/ReactionPicker.svelte+1 additions−1 deletions
@@ -108,7 +108,7 @@
108108 {#each quick as r (r.key)}
109109 <span class="quick-item">
110110 {#if r.key.startsWith('mxc://')}
111- <img src={mediaUrl(r.key) ?? ''} alt={quickLabel(r)} />
111+ <img src={mediaUrl(r.key, 64) ?? ''} alt={quickLabel(r)} />
112112 {:else}
113113 <Emoji emoji={r.key} size={18} />
114114 {/if}
Modified file: web/src/lib/RichNodes.svelte+1 additions−1 deletions
@@ -14,4 +14,4 @@
1414 export let verbatim = false;
1515 </script>
1616
17-{#each nodes as n}{#if n.kind === 'text'}{#if verbatim}{n.text}{:else if plain}<EmojiText text={n.text} />{:else}{#each segmentInline(n.text, mentions) as p}{#if p.kind === 'mention'}<span class="mention" class:me={p.user_id === self} title={p.user_id}><EmojiText text={p.text === p.user_id ? (nameOf(p.user_id) ?? p.text) : p.text} /></span>{:else if p.kind === 'link'}<a class="link" href={p.href} target="_blank" rel="noopener noreferrer"><EmojiText text={p.text} /></a>{:else}<EmojiText text={p.text} />{/if}{/each}{/if}{:else if n.kind === 'mention'}<span class="mention" class:me={n.user_id === self} title={n.user_id}><RichNodes nodes={n.children} plain {verbatim} /></span>{:else if n.kind === 'spoiler'}<span class="spoiler" role="button" tabindex="0"><RichNodes nodes={n.children} {mentions} {self} {nameOf} {plain} {verbatim} /></span>{:else if n.kind === 'emote'}<img class="emote" src={mediaUrl(n.mxc) ?? ''} alt={n.alt} title={n.alt} />{:else if n.tag === 'br'}<br />{:else if n.tag === 'hr'}<hr />{:else if n.tag === 'a'}<a class="link" href={n.attrs['href']} target="_blank" rel="noopener noreferrer"><RichNodes nodes={n.children} plain {verbatim} /></a>{:else}<svelte:element this={n.tag} {...n.attrs}><RichNodes nodes={n.children} {mentions} {self} {nameOf} plain={plain || n.tag === 'code' || n.tag === 'pre'} verbatim={verbatim || n.tag === 'code' || n.tag === 'pre'} /></svelte:element>{/if}{/each}
17+{#each nodes as n}{#if n.kind === 'text'}{#if verbatim}{n.text}{:else if plain}<EmojiText text={n.text} />{:else}{#each segmentInline(n.text, mentions) as p}{#if p.kind === 'mention'}<span class="mention" class:me={p.user_id === self} title={p.user_id}><EmojiText text={p.text === p.user_id ? (nameOf(p.user_id) ?? p.text) : p.text} /></span>{:else if p.kind === 'link'}<a class="link" href={p.href} target="_blank" rel="noopener noreferrer"><EmojiText text={p.text} /></a>{:else}<EmojiText text={p.text} />{/if}{/each}{/if}{:else if n.kind === 'mention'}<span class="mention" class:me={n.user_id === self} title={n.user_id}><RichNodes nodes={n.children} plain {verbatim} /></span>{:else if n.kind === 'spoiler'}<span class="spoiler" role="button" tabindex="0"><RichNodes nodes={n.children} {mentions} {self} {nameOf} {plain} {verbatim} /></span>{:else if n.kind === 'emote'}<img class="emote" src={mediaUrl(n.mxc, 64) ?? ''} alt={n.alt} title={n.alt} />{:else if n.tag === 'br'}<br />{:else if n.tag === 'hr'}<hr />{:else if n.tag === 'a'}<a class="link" href={n.attrs['href']} target="_blank" rel="noopener noreferrer"><RichNodes nodes={n.children} plain {verbatim} /></a>{:else}<svelte:element this={n.tag} {...n.attrs}><RichNodes nodes={n.children} {mentions} {self} {nameOf} plain={plain || n.tag === 'code' || n.tag === 'pre'} verbatim={verbatim || n.tag === 'code' || n.tag === 'pre'} /></svelte:element>{/if}{/each}
Modified file: web/src/lib/api.ts+2 additions−2 deletions
@@ -102,9 +102,9 @@ export function getMembers(room_id: string): Promise<{ members: MemberSummary[]
102102 }
103103
104104 /** Same URL shape in both builds: the hosted server and the device service worker both answer it. */
105-export function mediaUrl(mxc: string | null): string | null {
105+export function mediaUrl(mxc: string | null, size = 256): string | null {
106106 if (!mxc) return null;
107- return `/api/media?mxc=${encodeURIComponent(mxc)}`;
107+ return `/api/media?mxc=${encodeURIComponent(mxc)}&size=${Math.max(16, Math.min(512, Math.round(size)))}`;
108108 }
109109
110110 export function messageMediaUrl(mediaId: string, size: 'Thumbnail' | 'Full'): string {
Modified file: web/src/lib/avatar-cache.ts+7 additions−7 deletions
@@ -5,12 +5,12 @@ export interface AvatarCache {
55
66 export function createAvatarLoader(
77 cache: () => Promise<AvatarCache>,
8- load: (mxc: string) => Promise<Response>
9-): (key: string, mxc: string) => Promise<Response> {
8+ load: (query: string) => Promise<Response>
9+): (key: string, query: string) => Promise<Response> {
1010 const pending = new Map<string, Promise<Response>>();
1111
12- return async (key, mxc) => {
13- const active = pending.get(mxc);
12+ return async (key, query) => {
13+ const active = pending.get(key);
1414 if (active) return (await active).clone();
1515
1616 const request = (async () => {
@@ -23,7 +23,7 @@ export function createAvatarLoader(
2323 store = null;
2424 }
2525
26- const response = await load(mxc);
26+ const response = await load(query);
2727 if (response.ok && store) {
2828 try {
2929 await store.put(key, response.clone());
@@ -33,11 +33,11 @@ export function createAvatarLoader(
3333 }
3434 return response;
3535 })();
36- pending.set(mxc, request);
36+ pending.set(key, request);
3737 try {
3838 return (await request).clone();
3939 } finally {
40- if (pending.get(mxc) === request) pending.delete(mxc);
40+ if (pending.get(key) === request) pending.delete(key);
4141 }
4242 };
4343 }
Modified file: web/src/sw.ts+5 additions−4 deletions
@@ -8,7 +8,7 @@ import { createAvatarLoader } from './lib/avatar-cache';
88 const sw = self as unknown as ServiceWorkerGlobalScope & { __MALICE_PRECACHE__: string[] };
99
1010 const CACHE_NAME = 'malice-shell-v1';
11-const AVATAR_CACHE = 'malice-avatars-v2';
11+const AVATAR_CACHE = 'malice-avatars-v3';
1212
1313 sw.addEventListener('install', (event) => {
1414 event.waitUntil(
@@ -434,10 +434,10 @@ async function cacheFirst(request: Request): Promise<Response> {
434434 return response;
435435 }
436436
437-/** Keyed by mxc alone, so the cached bytes outlive the access token and survive restarts. */
437+/** Keyed by the request URL, which names image and size, so cached bytes outlive the access token and survive restarts. */
438438 const cachedAvatar = createAvatarLoader(
439439 async () => caches.open(AVATAR_CACHE),
440- async (mxc) => mediaResponse(await requestMedia('avatar', { mxc }), null, false)
440+ async (query) => mediaResponse(await requestMedia('avatar', Object.fromEntries(new URLSearchParams(query))), null, false)
441441 );
442442
443443 async function networkFirstNavigation(request: Request): Promise<Response> {
@@ -457,7 +457,8 @@ sw.addEventListener('fetch', (event) => {
457457 if (url.pathname === '/api/media') {
458458 const mxc = url.searchParams.get('mxc');
459459 if (mxc) {
460- event.respondWith(cachedAvatar(event.request.url, mxc));
460+ const size = url.searchParams.get('size') ?? '256';
461+ event.respondWith(cachedAvatar(event.request.url, new URLSearchParams({ mxc, size }).toString()));
461462 return;
462463 }
463464 }
Modified file: web/src/worker/worker.ts+2 additions−1 deletions
@@ -298,7 +298,8 @@ async function handleMediaRequest(msg: unknown): Promise<void> {
298298 }
299299 let request: Record<string, unknown>;
300300 if (msg['kind'] === 'avatar' && typeof msg['mxc'] === 'string') {
301- request = { op: 'avatar', mxc: msg['mxc'] };
301+ const size = Math.round(Number(msg['size']));
302+ request = { op: 'avatar', mxc: msg['mxc'], size: Number.isFinite(size) && size > 0 ? size : 256 };
302303 } else if (msg['kind'] === 'media' && typeof msg['media_id'] === 'string' && typeof msg['size'] === 'string') {
303304 request = { op: 'media', media_id: msg['media_id'], size: msg['size'] };
304305 } else {
Modified file: web/test/avatar-cache.test.ts+16 additions
@@ -47,6 +47,22 @@ test('avatar cache coalesces simultaneous misses and persists results', async ()
4747 assert.equal(loads, 1);
4848 });
4949
50+test('avatar cache keeps each size separate', async () => {
51+ const store = memoryCache();
52+ const loaded: string[] = [];
53+ const loader = createAvatarLoader(async () => store, async (query) => {
54+ loaded.push(query);
55+ return image(query);
56+ });
57+ const [small, large] = await Promise.all([
58+ loader('/api/media?mxc=avatar&size=32', 'mxc=avatar&size=32'),
59+ loader('/api/media?mxc=avatar&size=64', 'mxc=avatar&size=64')
60+ ]);
61+ assert.equal(await small.text(), 'mxc=avatar&size=32');
62+ assert.equal(await large.text(), 'mxc=avatar&size=64');
63+ assert.deepEqual(loaded.sort(), ['mxc=avatar&size=32', 'mxc=avatar&size=64']);
64+});
65+
5066 test('avatar cache retries unsuccessful responses', async () => {
5167 const store = memoryCache();
5268 let loads = 0;