Commit 7643577c76bb
tarball17 files changed+444-62
@@ -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
+]@@ -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@@ -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 the17
+/// 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 decoder28
+ 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 avatars57
+ let resized = image.resize_exact(scaled(width), scaled(height), FilterType::Lanczos3);58
+ let mut output = Vec::new();59
+ if resized.color().has_alpha() {60
+ resized61
+ .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") {@@ -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_SIZE25
+}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
+ _ => handle830
+ .media(&media_id, MediaSize::Full)831
+ .await?832
+ .bytes833
+ .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)?;@@ -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 uri225
+ (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 response220237
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 uri231
- (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(@@ -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
+]@@ -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,@@ -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}@@ -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}@@ -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 small15
+ $: src = mediaUrl(mxc, Math.ceil((size * density) / 32) * 32);1416
1517
let failed = false;1618
$: if (mxc) failed = false;@@ -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}@@ -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}@@ -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 {@@ -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
}@@ -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
}@@ -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 {@@ -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;