Commit 5b21b919d1e6
tarball7 files changed+350-65
@@ -47,6 +47,12 @@ with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported
4747
as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it4848
lands. stop applying, call `resync`, and pick up from the next `TimelineReset`4949
50
+`media` answers with the whole file, decrypted and checked, which is wrong for a51
+long video. `media_source` says where the same bytes live instead, the url and,52
+for an encrypted file, its AES-CTR key, iv and hash. `access_token` is what to53
+send with it. a host can then fetch and decrypt ranges itself, but the hash54
+covers the whole ciphertext, so a range read plays bytes nobody checked55
+5056
with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its5157
store. `EncryptedSessionStore` seals the record with the store's passphrase, in a5258
file you pick natively or an IndexedDB database beside the store in a browser.@@ -175,6 +175,13 @@ checks the cached bytes against the file's hash and decrypts them if they match
175175
media ids are registered as timelines render and forgotten when their timeline176176
closes, least recently seen first once the cap is hit177177
178
+streaming decryption inside macltok was declined. in a browser the service179
+worker answering range requests is a separate context from the worker running180
+macltok, and streaming through macltok would carry every chunk through wasm181
+memory, the very thing the host streams to avoid. `media_source` hands over the182
+url and keys `media` would have used, picked the same way, and `access_token`183
+the rest. the byte cap doesn't apply, nothing is buffered184
+178185
uploads are commands that answer once the send queue has them. progress only179186
goes out on a room's own send queue channel, so each room that uploads gets a180187
forwarder, subscribed before the upload is queued and restarted for uploads the@@ -22,7 +22,7 @@ use matrix_sdk::{
2222
client::{authenticated_media, media},2323
},2424
events::room::{25
- MediaSource,25
+ EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,2626
message::{AddMentions, ReplyWithinThread},2727
},2828
},@@ -33,7 +33,8 @@ use tokio::sync::Mutex;
3333
3434
use super::executor::EngineBoxFuture;3535
use crate::{36
- AttachmentUpload, Error, ErrorKind, Media, MediaId, MediaKind, MediaSize, TimelineId,36
+ AttachmentEncryption, AttachmentUpload, Error, ErrorKind, Media, MediaFile, MediaId, MediaKind,37
+ MediaLocation, MediaSize, TimelineId,3738
normalise::{MediaRegistration, MediaScope},3839
upload::prepare_upload,3940
};@@ -84,8 +85,59 @@ impl MediaService {
8485
media_id: &MediaId,8586
size: MediaSize,8687
) -> Result<Media, Error> {87
- let media = self88
- .registry88
+ let chosen = choose_source(self.registered(media_id).await?, size)?;89
+ if chosen90
+ .declared_size91
+ .is_some_and(|size| size > self.max_bytes as u64)92
+ {93
+ return Err(too_large("media exceeds the configured byte limit"));94
+ }95
+ self.fetch_or_file(chosen.request, chosen.mimetype).await96
+ }97
+98
+ pub(crate) async fn media_location(99
+ &self,100
+ media_id: &MediaId,101
+ size: MediaSize,102
+ ) -> Result<MediaLocation, Error> {103
+ let media = self.registered(media_id).await?;104
+ let chosen = choose_source(media.clone(), size)?;105
+ if is_local(&chosen.request.source) {106
+ return Ok(MediaLocation::Local);107
+ }108
+ if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {109
+ return Ok(MediaLocation::File(110
+ media_file(111
+ &self.client,112
+ chosen.request,113
+ chosen.mimetype,114
+ chosen.declared_size,115
+ )116
+ .await?,117
+ ));118
+ }119
+ let url = media_http_request(&self.client, &chosen.request)120
+ .await?121
+ .uri()122
+ .to_string();123
+ let fallback = MediaRequestParameters {124
+ source: media.full.source,125
+ format: MediaFormat::File,126
+ };127
+ Ok(MediaLocation::ServerThumbnail {128
+ url,129
+ fallback: media_file(130
+ &self.client,131
+ fallback,132
+ media.full.mimetype,133
+ media.full.declared_size,134
+ )135
+ .await?,136
+ })137
+ }138
+139
+ async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {140
+ self.registry89141
.lock()90142
.await91143
.get(media_id)@@ -95,46 +147,7 @@ impl MediaService {
95147
ErrorKind::MediaUnknown,96148
"media is not registered in a retained timeline",97149
)98
- })?;99
- let (source, format, mimetype, declared_size) = match size {100
- MediaSize::Full => (101
- media.full.source,102
- MediaFormat::File,103
- media.full.mimetype,104
- media.full.declared_size,105
- ),106
- MediaSize::Thumbnail { width, height } => match media.thumbnail {107
- Some(thumbnail) => (108
- thumbnail.source,109
- MediaFormat::File,110
- thumbnail.mimetype,111
- thumbnail.declared_size,112
- ),113
- // the server can only resize what it can read114
- None if matches!(media.kind, MediaKind::Image | MediaKind::Video)115
- && matches!(media.full.source, MediaSource::Plain(_)) =>116
- {117
- // the server picks the thumbnail's type, so the original's would be a lie118
- let format = if is_local(&media.full.source) {119
- MediaFormat::File120
- } else {121
- thumbnail_format(width, height)122
- };123
- (media.full.source, format, None, None)124
- }125
- None => {126
- return Err(Error::new(127
- ErrorKind::MediaUnavailable,128
- "the requested media size is unavailable",129
- ));130
- }131
- },132
- };133
- if declared_size.is_some_and(|size| size > self.max_bytes as u64) {134
- return Err(too_large("media exceeds the configured byte limit"));135
- }136
- self.fetch_or_file(MediaRequestParameters { source, format }, mimetype)137
- .await150
+ })138151
}139152
140153
pub(crate) async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {@@ -214,6 +227,91 @@ impl MediaService {
214227
}215228
}216229
230
+struct ChosenSource {231
+ request: MediaRequestParameters,232
+ mimetype: Option<String>,233
+ declared_size: Option<u64>,234
+}235
+236
+fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {237
+ let (source, format, mimetype, declared_size) = match size {238
+ MediaSize::Full => (239
+ media.full.source,240
+ MediaFormat::File,241
+ media.full.mimetype,242
+ media.full.declared_size,243
+ ),244
+ MediaSize::Thumbnail { width, height } => match media.thumbnail {245
+ Some(thumbnail) => (246
+ thumbnail.source,247
+ MediaFormat::File,248
+ thumbnail.mimetype,249
+ thumbnail.declared_size,250
+ ),251
+ // the server can only resize what it can read252
+ None if matches!(media.kind, MediaKind::Image | MediaKind::Video)253
+ && matches!(media.full.source, MediaSource::Plain(_)) =>254
+ {255
+ // the server picks the thumbnail's type, so the original's would be a lie256
+ let format = if is_local(&media.full.source) {257
+ MediaFormat::File258
+ } else {259
+ thumbnail_format(width, height)260
+ };261
+ (media.full.source, format, None, None)262
+ }263
+ None => {264
+ return Err(Error::new(265
+ ErrorKind::MediaUnavailable,266
+ "the requested media size is unavailable",267
+ ));268
+ }269
+ },270
+ };271
+ Ok(ChosenSource {272
+ request: MediaRequestParameters { source, format },273
+ mimetype,274
+ declared_size,275
+ })276
+}277
+278
+async fn media_file(279
+ client: &Client,280
+ request: MediaRequestParameters,281
+ mimetype: Option<String>,282
+ declared_size: Option<u64>,283
+) -> Result<MediaFile, Error> {284
+ let url = media_http_request(client, &request)285
+ .await?286
+ .uri()287
+ .to_string();288
+ let encryption = match &request.source {289
+ MediaSource::Plain(_) => None,290
+ MediaSource::Encrypted(file) => {291
+ let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (292
+ &file.info,293
+ file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),294
+ ) else {295
+ return Err(Error::new(296
+ ErrorKind::MediaUnavailable,297
+ "encrypted media is not v2 with a sha256 hash",298
+ ));299
+ };300
+ Some(AttachmentEncryption {301
+ key: info.k.encode(),302
+ iv: info.iv.encode(),303
+ sha256: sha256.encode(),304
+ })305
+ }306
+ };307
+ Ok(MediaFile {308
+ url,309
+ mimetype,310
+ declared_size,311
+ encryption,312
+ })313
+}314
+217315
fn thumbnail_format(width: u32, height: u32) -> MediaFormat {218316
MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))219317
}@@ -42,20 +42,21 @@ pub use mirror::{
4242
TimelineState, Update, UploadFailure, UploadState,4343
};4444
pub use model::{45
- AccountCommand, AttachmentUpload, Change, Command, CommandOutput, ConnectionState,46
- CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind, EngineFailure, Event,47
- EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand, Media, MediaCommand,48
- MediaDescriptor, MediaId, MediaKind, MediaSize, MemberFilter, MemberMembership, MemberSummary,49
- MembershipChange, Message, MessageContent, MessageEditContent, MessageKind, MessageMentions,50
- NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,51
- PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,52
- Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText, RoomCategory,53
- RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,54
- RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,55
- Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,56
- TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,57
- UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,58
- VerificationFailure, VerificationState, VerificationTarget,45
+ AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,46
+ ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,47
+ EngineFailure, Event, EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand,48
+ Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,49
+ MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,50
+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice,51
+ PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationRoom,52
+ PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyDetails,53
+ ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter,54
+ RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,55
+ RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild, Termination,56
+ ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,57
+ TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,58
+ UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,59
+ VerificationState, VerificationTarget,5960
};6061
pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};6162
pub use uiaa::{@@ -1681,6 +1681,45 @@ pub struct Media {
16811681
pub mimetype: String,16821682
}16831683
1684
+/// where media lives, for a host that fetches and decrypts it itself1685
+#[derive(Clone, Debug, Eq, PartialEq)]1686
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1687
+#[non_exhaustive]1688
+pub enum MediaLocation {1689
+ File(MediaFile),1690
+ /// a resize of plaintext, so never encrypted. plenty of servers refuse some, and `media`1691
+ /// then fetches `fallback` instead, so should you1692
+ ServerThumbnail {1693
+ url: String,1694
+ fallback: MediaFile,1695
+ },1696
+ /// still in the send queue with no url yet. `media` has the bytes1697
+ Local,1698
+}1699
+1700
+#[derive(Clone, Debug, Eq, PartialEq)]1701
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1702
+#[non_exhaustive]1703
+pub struct MediaFile {1704
+ pub url: String,1705
+ pub mimetype: Option<String>,1706
+ pub declared_size: Option<u64>,1707
+ pub encryption: Option<AttachmentEncryption>,1708
+}1709
+1710
+/// AES-256-CTR, the only attachment cipher matrix has1711
+#[derive(Clone, Debug, Eq, PartialEq)]1712
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1713
+#[non_exhaustive]1714
+pub struct AttachmentEncryption {1715
+ /// the JWK `k`, unpadded url-safe base641716
+ pub key: String,1717
+ /// unpadded base64, the initial counter block1718
+ pub iv: String,1719
+ /// unpadded base64 SHA-256 of the whole ciphertext, so a partial read goes unchecked1720
+ pub sha256: String,1721
+}1722
+16841723
#[derive(Clone, Copy, Debug, Eq, PartialEq)]16851724
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]16861725
#[non_exhaustive]@@ -28,12 +28,12 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828
use crate::{2929
AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,3030
EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,31
- InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaSize,32
- MemberFilter, MemberSummary, MessageContent, MessageEditContent, NotificationCommand,33
- OwnDevice, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,34
- PushNotificationStatus, RoomCommand, RoomListFilter, RoomNotificationMode,35
- RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild, TimelineCommand,36
- TimelineId, VerificationAction, VerificationTarget,31
+ InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaLocation,32
+ MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,33
+ NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,34
+ PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,35
+ RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,36
+ TimelineCommand, TimelineId, VerificationAction, VerificationTarget,3737
encryption::encryption_state,3838
engine::{MediaService, StoreLock},3939
model::{PushNotificationRoom, PushNotificationSender},@@ -83,6 +83,12 @@ impl ClientHandle {
8383
crate::account::session_record(&self.client)8484
}8585
86
+ /// the bearer token. it rotates on every refresh so ask again after a 401 rather than87
+ /// keeping it, and keep it in memory and away from all but the homeserver's media endpoints88
+ pub fn access_token(&self) -> Option<String> {89
+ self.client.access_token()90
+ }91
+8692
/// queue a command, waiting for room in the queue if there isn't any8793
pub async fn send(&self, command: Command) -> Result<Pending, Error> {8894
let (request, pending) = Request::new(command);@@ -554,6 +560,16 @@ impl ClientHandle {
554560
self.media.timeline_media(media_id, size).await555561
}556562
563
+ /// where `media` would fetch from, for a host streaming big files itself. the byte cap564
+ /// doesn't apply since nothing is buffered565
+ pub async fn media_source(566
+ &self,567
+ media_id: &MediaId,568
+ size: MediaSize,569
+ ) -> Result<MediaLocation, Error> {570
+ self.media.media_location(media_id, size).await571
+ }572
+557573
/// bytes for any plain mxc uri, avatars mostly. same byte cap as everything else558574
pub async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {559575
self.media.mxc_media(uri, size).await@@ -4,7 +4,8 @@ use std::{sync::Arc, time::Duration};
44
55
use macltok::{66
AttachmentUpload, ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, Event,7
- EventStream, MediaId, MediaSize, UploadDetails, UploadOutcome, UploadThumbnail,7
+ EventStream, MediaFile, MediaId, MediaLocation, MediaSize, UploadDetails, UploadOutcome,8
+ UploadThumbnail,89
};910
use matrix_sdk::{1011
media::{MediaFormat, MediaRequestParameters},@@ -16,6 +17,7 @@ use matrix_sdk::{
1617
message::{1718
AudioMessageEventContent, FileInfo, FileMessageEventContent,1819
ImageMessageEventContent, MessageType, RoomMessageEventContent,20
+ VideoMessageEventContent,1921
},2022
},2123
media::Method,@@ -282,6 +284,89 @@ async fn audio_thumbnail_fetch_fails_with_media_unavailable() {
282284
}283285
284286
#[tokio::test]287
+async fn media_source_locates_what_media_would_fetch() {288
+ let keys =289
+ matrix_sdk_crypto::AttachmentEncryptor::new(&mut std::io::Cursor::new(b"secret")).finish();290
+ let file = EncryptedFile::new(291
+ mxc_uri!("mxc://localhost/secret").to_owned(),292
+ keys.encryption_info,293
+ keys.hashes,294
+ );295
+ let wire = serde_json::to_value(&file).unwrap();296
+ let mut harness = start(RoomMessageEventContent::new(MessageType::File(297
+ FileMessageEventContent::encrypted("secret.bin".to_owned(), file),298
+ )))299
+ .await;300
+301
+ let MediaLocation::File(MediaFile {302
+ url,303
+ encryption: Some(encryption),304
+ ..305
+ }) = harness306
+ .handle307
+ .media_source(&harness.media_id, MediaSize::Full)308
+ .await309
+ .unwrap()310
+ else {311
+ panic!("an encrypted file should locate as one");312
+ };313
+ assert_eq!(314
+ url,315
+ format!(316
+ "{}/_matrix/client/v1/media/download/localhost/secret",317
+ harness.server.uri()318
+ )319
+ );320
+ assert_eq!(encryption.key, wire["key"]["k"]);321
+ assert_eq!(encryption.iv, wire["iv"]);322
+ assert_eq!(encryption.sha256, wire["hashes"]["sha256"]);323
+324
+ let video = sync_media(325
+ &mut harness,326
+ "video.mp4",327
+ RoomMessageEventContent::new(MessageType::Video(VideoMessageEventContent::plain(328
+ "video.mp4".to_owned(),329
+ mxc_uri!("mxc://localhost/video").to_owned(),330
+ ))),331
+ )332
+ .await;333
+ let MediaLocation::ServerThumbnail { url, fallback } = harness334
+ .handle335
+ .media_source(&video, thumbnail_size())336
+ .await337
+ .unwrap()338
+ else {339
+ panic!("a video with no thumbnail of its own should ask the server for one");340
+ };341
+ let url = matrix_sdk::reqwest::Url::parse(&url).unwrap();342
+ assert_eq!(343
+ url.path(),344
+ "/_matrix/client/v1/media/thumbnail/localhost/video"345
+ );346
+ let query: Vec<_> = url.query_pairs().into_owned().collect();347
+ for pair in [("width", "800"), ("height", "800"), ("method", "scale")] {348
+ assert!(query.contains(&(pair.0.to_owned(), pair.1.to_owned())));349
+ }350
+ assert_eq!(351
+ MediaLocation::File(fallback),352
+ harness353
+ .handle354
+ .media_source(&video, MediaSize::Full)355
+ .await356
+ .unwrap()357
+ );358
+359
+ let error = harness360
+ .handle361
+ .media_source(&MediaId::from("made up".to_owned()), MediaSize::Full)362
+ .await363
+ .unwrap_err();364
+ assert_eq!(error.kind, ErrorKind::MediaUnknown);365
+366
+ support::shut_down(&harness.handle, &mut harness.events, harness.running).await;367
+}368
+369
+#[tokio::test]285370
async fn media_upload_is_queued_and_sent() {286371
let mut harness = start(plain_image()).await;287372
harness@@ -373,6 +458,14 @@ async fn queued_offline_attachment_exposes_fetchable_local_media() {
373458
.await374459
.unwrap();375460
assert_eq!(&*fetched.bytes, &original[..]);461
+ assert_eq!(462
+ harness463
+ .handle464
+ .media_source(&media_id, MediaSize::Full)465
+ .await466
+ .unwrap(),467
+ MediaLocation::Local468
+ );376469
377470
support::shut_down(&harness.handle, &mut harness.events, harness.running).await;378471
}@@ -796,6 +889,31 @@ async fn start_with_config(content: RoomMessageEventContent, config: EngineConfi
796889
}797890
}798891
892
+async fn sync_media(893
+ harness: &mut MediaHarness,894
+ filename: &str,895
+ content: RoomMessageEventContent,896
+) -> MediaId {897
+ harness898
+ .server899
+ .sync_room(900
+ &harness.client,901
+ JoinedRoomBuilder::new(room_id!("!media:localhost"))902
+ .add_timeline_event(EventFactory::new().event(content).sender(&ALICE)),903
+ )904
+ .await;905
+ let media_id = |event: &Event| {906
+ support::event_messages(event)907
+ .into_iter()908
+ .filter_map(support::media_of)909
+ .find(|media| media.filename == filename)910
+ .map(|media| media.id.clone())911
+ };912
+ let event =913
+ support::receive_until(&mut harness.events, |event| media_id(event).is_some()).await;914
+ media_id(&event).unwrap()915
+}916
+799917
async fn mount_downloads(server: &MatrixMockServer) {800918
server.mock_media_download().ok_image().mount().await;801919
server.mock_authed_media_download().ok_image().mount().await;