atagen

macltok

public
Star0
Watch0
5a52012c9b1d7ec241e293fb63039a10884d6fdc
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/engine/media.rsRust633 lines21.5 KB
1
use std::{
2
    borrow::Cow,
3
    collections::{HashMap, VecDeque},
4
    io::Read as _,
5
    panic::AssertUnwindSafe,
6
    sync::Arc,
7
    time::Duration,
8
};
9
10
use futures_util::{
11
    FutureExt, StreamExt,
12
    future::{BoxFuture, Shared, WeakShared},
13
};
14
use matrix_sdk::{
15
    Client, Room,
16
    media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},
17
    room::reply::{EnforceThread, Reply as SdkReply},
18
    ruma::{
19
        api::{
20
            Metadata, OutgoingRequest,
21
            auth_scheme::SendAccessToken,
22
            client::{authenticated_media, media},
23
        },
24
        events::room::{
25
            MediaSource,
26
            message::{AddMentions, ReplyWithinThread},
27
        },
28
    },
29
};
30
use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};
31
use ruma::MxcUri;
32
use tokio::sync::Mutex;
33
34
use crate::{
35
    AttachmentUpload, Error, ErrorKind, Media, MediaId, MediaKind, MediaSize, TimelineId,
36
    normalise::{MediaRegistration, MediaScope},
37
    upload::prepare_upload,
38
};
39
40
/// a stalled download gives up after this long without a byte
41
const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);
42
/// the sdk client gives up on any request after thirty seconds, far too soon for a video
43
const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);
44
45
type FlightFuture = BoxFuture<'static, Result<Media, Error>>;
46
type Flight = Shared<FlightFuture>;
47
48
/// owns everything needed to turn a media id or an mxc uri into bytes, so the
49
/// handle can answer media queries without a trip through the engine
50
pub(crate) struct MediaService {
51
    client: Client,
52
    registry: Mutex<MediaRegistry>,
53
    flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,
54
    max_bytes: usize,
55
}
56
57
impl MediaService {
58
    pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {
59
        Self {
60
            client,
61
            registry: Mutex::new(MediaRegistry::new(max_sources)),
62
            flights: Mutex::new(HashMap::new()),
63
            max_bytes,
64
        }
65
    }
66
67
    pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {
68
        if !media.is_empty() {
69
            self.registry.lock().await.insert_all(media);
70
        }
71
    }
72
73
    pub(crate) async fn release(&self, scope: &MediaScope) {
74
        self.registry.lock().await.release(scope);
75
    }
76
77
    pub(crate) async fn clear(&self) {
78
        self.registry.lock().await.clear();
79
    }
80
81
    pub(crate) async fn timeline_media(
82
        &self,
83
        media_id: &MediaId,
84
        size: MediaSize,
85
    ) -> Result<Media, Error> {
86
        let media = self
87
            .registry
88
            .lock()
89
            .await
90
            .get(media_id)
91
            .cloned()
92
            .ok_or_else(|| {
93
                Error::new(
94
                    ErrorKind::MediaUnknown,
95
                    "media is not registered in a retained timeline",
96
                )
97
            })?;
98
        let (source, format, mimetype, declared_size) = match size {
99
            MediaSize::Full => (
100
                media.full.source,
101
                MediaFormat::File,
102
                media.full.mimetype,
103
                media.full.declared_size,
104
            ),
105
            MediaSize::Thumbnail { width, height } => match media.thumbnail {
106
                Some(thumbnail) => (
107
                    thumbnail.source,
108
                    MediaFormat::File,
109
                    thumbnail.mimetype,
110
                    thumbnail.declared_size,
111
                ),
112
                // the server can only resize what it can read
113
                None if matches!(media.kind, MediaKind::Image | MediaKind::Video)
114
                    && matches!(media.full.source, MediaSource::Plain(_)) =>
115
                {
116
                    // the server picks the thumbnail's type, so the original's would be a lie
117
                    let format = if is_local(&media.full.source) {
118
                        MediaFormat::File
119
                    } else {
120
                        thumbnail_format(width, height)
121
                    };
122
                    (media.full.source, format, None, None)
123
                }
124
                None => {
125
                    return Err(Error::new(
126
                        ErrorKind::MediaUnavailable,
127
                        "the requested media size is unavailable",
128
                    ));
129
                }
130
            },
131
        };
132
        if declared_size.is_some_and(|size| size > self.max_bytes as u64) {
133
            return Err(too_large("media exceeds the configured byte limit"));
134
        }
135
        self.fetch_or_file(MediaRequestParameters { source, format }, mimetype)
136
            .await
137
    }
138
139
    pub(crate) async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {
140
        if !uri.is_valid() {
141
            return Err(Error::new(ErrorKind::Invalid, "mxc uri is malformed"));
142
        }
143
        let format = match size {
144
            MediaSize::Full => MediaFormat::File,
145
            MediaSize::Thumbnail { width, height } => thumbnail_format(width, height),
146
        };
147
        self.fetch_or_file(
148
            MediaRequestParameters {
149
                source: MediaSource::Plain(uri.to_owned()),
150
                format,
151
            },
152
            None,
153
        )
154
        .await
155
    }
156
157
    /// plenty of servers won't thumbnail everything, and the file itself is the next best
158
    /// thing. the byte cap still holds either way
159
    async fn fetch_or_file(
160
        &self,
161
        request: MediaRequestParameters,
162
        mimetype: Option<String>,
163
    ) -> Result<Media, Error> {
164
        if !matches!(request.format, MediaFormat::Thumbnail(_)) {
165
            return self.fetch(request, mimetype).await;
166
        }
167
        match self.fetch(request.clone(), mimetype.clone()).await {
168
            Err(error) if error.kind == ErrorKind::MediaUnavailable => {
169
                let file = MediaRequestParameters {
170
                    source: request.source,
171
                    format: MediaFormat::File,
172
                };
173
                self.fetch(file, mimetype).await
174
            }
175
            result => result,
176
        }
177
    }
178
179
    async fn fetch(
180
        &self,
181
        request: MediaRequestParameters,
182
        mimetype: Option<String>,
183
    ) -> Result<Media, Error> {
184
        let key = flight_key(&request);
185
        let flight = {
186
            let mut flights = self.flights.lock().await;
187
            flights.retain(|_, flight| flight.upgrade().is_some());
188
            match flights.get(&key).and_then(WeakShared::upgrade) {
189
                Some(flight) => flight,
190
                None => {
191
                    let flight: Flight = AssertUnwindSafe(fetch(
192
                        self.client.clone(),
193
                        request,
194
                        mimetype,
195
                        self.max_bytes,
196
                    ))
197
                    .catch_unwind()
198
                    .map(|result| {
199
                        result.unwrap_or_else(|_| {
200
                            Err(Error::new(ErrorKind::Other, "media fetch panicked"))
201
                        })
202
                    })
203
                    .boxed()
204
                    .shared();
205
                    if let Some(weak) = flight.downgrade() {
206
                        flights.insert(key, weak);
207
                    }
208
                    flight
209
                }
210
            }
211
        };
212
        flight.await
213
    }
214
}
215
216
fn thumbnail_format(width: u32, height: u32) -> MediaFormat {
217
    MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))
218
}
219
220
fn is_local(source: &MediaSource) -> bool {
221
    let uri = match source {
222
        MediaSource::Plain(uri) => uri,
223
        MediaSource::Encrypted(file) => &file.url,
224
    };
225
    uri.server_name()
226
        .is_ok_and(|server| server.as_str() == "send-queue.localhost")
227
}
228
229
/// plain and encrypted sources share a cache key in the sdk, but must never share a flight
230
fn flight_key(request: &MediaRequestParameters) -> String {
231
    let uri = match &request.source {
232
        MediaSource::Plain(uri) => format!("plain|{uri}"),
233
        MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),
234
    };
235
    match &request.format {
236
        MediaFormat::File => uri.to_owned(),
237
        MediaFormat::Thumbnail(settings) => format!(
238
            "{uri}|{}|{}x{}|{}",
239
            settings.method, settings.width, settings.height, settings.animated
240
        ),
241
    }
242
}
243
244
fn too_large(message: &'static str) -> Error {
245
    Error::new(ErrorKind::MediaTooLarge, message)
246
}
247
248
async fn fetch(
249
    client: Client,
250
    request: MediaRequestParameters,
251
    mimetype: Option<String>,
252
    max_bytes: usize,
253
) -> Result<Media, Error> {
254
    if let Some(cached) = cached_media(&client, &request).await? {
255
        if cached.len() > max_bytes {
256
            if let Ok(store) = client.media_store().lock().await {
257
                let _ = store.remove_media_content(&request).await;
258
            }
259
            return Err(too_large("cached media exceeds the configured byte limit"));
260
        }
261
        // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri
262
        // leaves ciphertext here. its hash gives it away
263
        if is_ciphertext(&cached, &request.source) {
264
            let bytes = maybe_decrypt(cached, &request, max_bytes)?;
265
            cache(&client, &request, &bytes).await;
266
            return Ok(media(bytes, mimetype));
267
        }
268
        return Ok(media(cached, mimetype));
269
    }
270
    let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;
271
    // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything
272
    let bytes = maybe_decrypt(bytes, &request, max_bytes)?;
273
    cache(&client, &request, &bytes).await;
274
    Ok(media(bytes, mimetype.or(served_mimetype)))
275
}
276
277
async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {
278
    if let Ok(store) = client.media_store().lock().await {
279
        let _ = store
280
            .add_media_content(
281
                request,
282
                bytes.to_vec(),
283
                matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,
284
            )
285
            .await;
286
    }
287
}
288
289
fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {
290
    use sha2::{Digest, Sha256};
291
    let MediaSource::Encrypted(file) = source else {
292
        return false;
293
    };
294
    file.hashes
295
        .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)
296
        .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())
297
}
298
299
fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {
300
    let mimetype = mimetype
301
        .or_else(|| sniff_image(&bytes).map(str::to_owned))
302
        .unwrap_or_else(|| "application/octet-stream".to_owned());
303
    Media {
304
        bytes: Arc::from(bytes),
305
        mimetype,
306
    }
307
}
308
309
/// the cache forgets content types, and avatars never declare one. images are the common case
310
fn sniff_image(bytes: &[u8]) -> Option<&'static str> {
311
    match bytes {
312
        [0x89, b'P', b'N', b'G', ..] => Some("image/png"),
313
        [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),
314
        [b'G', b'I', b'F', b'8', ..] => Some("image/gif"),
315
        [
316
            b'R',
317
            b'I',
318
            b'F',
319
            b'F',
320
            _,
321
            _,
322
            _,
323
            _,
324
            b'W',
325
            b'E',
326
            b'B',
327
            b'P',
328
            ..,
329
        ] => Some("image/webp"),
330
        _ => None,
331
    }
332
}
333
334
async fn cached_media(
335
    client: &Client,
336
    request: &MediaRequestParameters,
337
) -> Result<Option<Vec<u8>>, Error> {
338
    fn cache_failed(source: impl std::error::Error + Send + Sync + 'static) -> Error {
339
        Error::with_source(ErrorKind::Store, "reading media cache failed", source)
340
    }
341
    client
342
        .media_store()
343
        .lock()
344
        .await
345
        .map_err(cache_failed)?
346
        .get_media_content(request)
347
        .await
348
        .map_err(cache_failed)
349
}
350
351
fn maybe_decrypt(
352
    bytes: Vec<u8>,
353
    request: &MediaRequestParameters,
354
    max_bytes: usize,
355
) -> Result<Vec<u8>, Error> {
356
    let MediaSource::Encrypted(file) = &request.source else {
357
        return Ok(bytes);
358
    };
359
    fn decrypt_failed(source: impl std::error::Error + Send + Sync + 'static) -> Error {
360
        Error::with_source(
361
            ErrorKind::MediaUnavailable,
362
            "media decryption failed",
363
            source,
364
        )
365
    }
366
    let info: MediaEncryptionInfo = (**file).clone().into();
367
    let mut cursor = std::io::Cursor::new(bytes);
368
    let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(decrypt_failed)?;
369
    let mut decrypted = Vec::new();
370
    let mut chunk = [0_u8; 8192];
371
    loop {
372
        let read = decryptor.read(&mut chunk).map_err(decrypt_failed)?;
373
        if read == 0 {
374
            return Ok(decrypted);
375
        }
376
        if read > max_bytes.saturating_sub(decrypted.len()) {
377
            return Err(too_large(
378
                "decrypted media exceeds the configured byte limit",
379
            ));
380
        }
381
        decrypted.extend_from_slice(&chunk[..read]);
382
    }
383
}
384
385
/// the sdk's own download has no size cap, so we do it by hand
386
async fn download_bounded(
387
    client: &Client,
388
    request: &MediaRequestParameters,
389
    max_bytes: usize,
390
) -> Result<(Vec<u8>, Option<String>), Error> {
391
    if is_local(&request.source) {
392
        return Err(Error::new(
393
            ErrorKind::MediaUnavailable,
394
            "local media is not in the media cache",
395
        ));
396
    }
397
    let network_failed =
398
        |source| Error::with_source(ErrorKind::Network, "media download failed", source);
399
    let mut refreshed = false;
400
    let response = loop {
401
        let http_request = media_http_request(client, request).await?;
402
        let (parts, _) = http_request.into_parts();
403
        let mut builder = client
404
            .http_client()
405
            .get(parts.uri.to_string())
406
            .timeout(DOWNLOAD_TOTAL);
407
        for (name, value) in &parts.headers {
408
            builder = builder.header(name.as_str(), value.as_bytes());
409
        }
410
        let response = builder.send().await.map_err(network_failed)?;
411
        if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {
412
            break response;
413
        }
414
        if refreshed
415
            || client
416
                .session_tokens()
417
                .is_none_or(|tokens| tokens.refresh_token.is_none())
418
        {
419
            return Err(Error::new(
420
                ErrorKind::Unauthenticated,
421
                "media download was not authorized",
422
            ));
423
        }
424
        client.refresh_access_token().await.map_err(|source| {
425
            Error::from_refresh_token("refreshing media access token failed", source)
426
        })?;
427
        refreshed = true;
428
    };
429
    let status = response.status();
430
    if !status.is_success() {
431
        return Err(Error::new(
432
            ErrorKind::MediaUnavailable,
433
            format!("media download failed with status {status}"),
434
        ));
435
    }
436
    if response
437
        .content_length()
438
        .is_some_and(|length| length > max_bytes as u64)
439
    {
440
        return Err(too_large("media exceeds the configured byte limit"));
441
    }
442
    let mimetype = response
443
        .headers()
444
        .get(http::header::CONTENT_TYPE)
445
        .and_then(|value| value.to_str().ok())
446
        .map(str::to_owned);
447
    let mut stream = response.bytes_stream();
448
    let mut bytes = Vec::new();
449
    loop {
450
        let chunk = tokio::time::timeout(DOWNLOAD_IDLE, stream.next())
451
            .await
452
            .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;
453
        let Some(chunk) = chunk else { break };
454
        let chunk = chunk.map_err(network_failed)?;
455
        if chunk.len() > max_bytes.saturating_sub(bytes.len()) {
456
            return Err(too_large(
457
                "downloaded media exceeds the configured byte limit",
458
            ));
459
        }
460
        bytes.extend_from_slice(&chunk);
461
    }
462
    Ok((bytes, mimetype))
463
}
464
465
async fn media_http_request(
466
    client: &Client,
467
    request: &MediaRequestParameters,
468
) -> Result<http::Request<Vec<u8>>, Error> {
469
    let versions = client.supported_versions().await.map_err(|source| {
470
        Error::with_source(
471
            ErrorKind::Network,
472
            "reading supported server versions failed",
473
            source,
474
        )
475
    })?;
476
    let authenticated =
477
        authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);
478
    let base = client.homeserver().to_string();
479
    let token = client.access_token();
480
    let invalid_uri =
481
        |source| Error::with_source(ErrorKind::Invalid, "media URI is invalid", source);
482
    let (uri, thumbnail) = match (&request.source, &request.format) {
483
        (MediaSource::Encrypted(file), _) => (&file.url, None),
484
        (MediaSource::Plain(uri), MediaFormat::File) => (uri, None),
485
        (MediaSource::Plain(uri), MediaFormat::Thumbnail(settings)) => (uri, Some(settings)),
486
    };
487
    // each endpoint has its own auth input type, so no generic helper can take them all
488
    macro_rules! into_http {
489
        ($endpoint:expr, $token:expr) => {
490
            $endpoint
491
                .try_into_http_request::<Vec<u8>>(&base, $token, Cow::Borrowed(&versions))
492
                .map_err(|source| {
493
                    Error::with_source(ErrorKind::Other, "media request is invalid", source)
494
                })?
495
        };
496
    }
497
    let authed = SendAccessToken::IfRequired(token.as_deref().unwrap_or(""));
498
    #[allow(deprecated)]
499
    let mut http_request = match (thumbnail, authenticated) {
500
        (None, true) => into_http!(
501
            authenticated_media::get_content::v1::Request::from_uri(uri).map_err(invalid_uri)?,
502
            authed
503
        ),
504
        (None, false) => into_http!(
505
            media::get_content::v3::Request::from_url(uri).map_err(invalid_uri)?,
506
            SendAccessToken::None
507
        ),
508
        (Some(settings), true) => {
509
            let mut endpoint = authenticated_media::get_content_thumbnail::v1::Request::from_uri(
510
                uri,
511
                settings.width,
512
                settings.height,
513
            )
514
            .map_err(invalid_uri)?;
515
            endpoint.method = Some(settings.method.clone());
516
            endpoint.animated = Some(settings.animated);
517
            into_http!(endpoint, authed)
518
        }
519
        (Some(settings), false) => {
520
            let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(
521
                uri,
522
                settings.width,
523
                settings.height,
524
            )
525
            .map_err(invalid_uri)?;
526
            endpoint.method = Some(settings.method.clone());
527
            endpoint.animated = Some(settings.animated);
528
            into_http!(endpoint, SendAccessToken::None)
529
        }
530
    };
531
    // legacy endpoints don't ask for a token but plenty of servers want one anyway
532
    if let Some(token) = token.as_deref() {
533
        let value = format!("Bearer {token}").parse().map_err(|_| {
534
            Error::new(ErrorKind::Unauthenticated, "stored access token is invalid")
535
        })?;
536
        http_request
537
            .headers_mut()
538
            .insert(http::header::AUTHORIZATION, value);
539
    }
540
    Ok(http_request)
541
}
542
543
pub(crate) async fn queue_upload(
544
    room: Room,
545
    upload: AttachmentUpload,
546
    max_bytes: usize,
547
) -> Result<(), Error> {
548
    let (upload, mime, mut config) = prepare_upload(upload, max_bytes)?;
549
    if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {
550
        config.reply = Some(SdkReply {
551
            event_id: root_event_id.clone(),
552
            enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
553
            add_mentions: AddMentions::No,
554
        });
555
    }
556
    room.send_queue()
557
        .send_attachment(upload.filename, mime, upload.bytes, config)
558
        .await
559
        .map(drop)
560
        .map_err(|source| {
561
            use matrix_sdk::send_queue::RoomSendQueueError;
562
            let kind = match &source {
563
                RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {
564
                    ErrorKind::RoomUnknown
565
                }
566
                RoomSendQueueError::StorageError(_) => ErrorKind::Store,
567
                _ => ErrorKind::MediaUnavailable,
568
            };
569
            Error::with_source(kind, "media upload could not be queued", source)
570
        })
571
}
572
573
/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down
574
struct MediaRegistry {
575
    entries: HashMap<MediaId, (MediaRegistration, u64)>,
576
    order: VecDeque<(MediaId, u64)>,
577
    next_stamp: u64,
578
    limit: usize,
579
}
580
581
impl MediaRegistry {
582
    fn new(limit: usize) -> Self {
583
        Self {
584
            entries: HashMap::new(),
585
            order: VecDeque::new(),
586
            next_stamp: 0,
587
            limit,
588
        }
589
    }
590
591
    fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {
592
        self.entries.get(id).map(|(media, _)| media)
593
    }
594
595
    fn insert_all(&mut self, media: Vec<MediaRegistration>) {
596
        for media in media {
597
            let stamp = self.next_stamp;
598
            self.next_stamp += 1;
599
            self.order.push_back((media.id.clone(), stamp));
600
            self.entries.insert(media.id.clone(), (media, stamp));
601
        }
602
        while self.entries.len() > self.limit {
603
            let Some((id, stamp)) = self.order.pop_front() else {
604
                break;
605
            };
606
            if self.is_current(&id, stamp) {
607
                self.entries.remove(&id);
608
            }
609
        }
610
        if self.order.len() > self.limit.saturating_mul(4) {
611
            let entries = &self.entries;
612
            self.order
613
                .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));
614
        }
615
    }
616
617
    fn is_current(&self, id: &MediaId, stamp: u64) -> bool {
618
        self.entries
619
            .get(id)
620
            .is_some_and(|(_, current)| *current == stamp)
621
    }
622
623
    fn release(&mut self, scope: &MediaScope) {
624
        self.entries.retain(|id, _| !scope.owns(id));
625
        let entries = &self.entries;
626
        self.order.retain(|(id, _)| entries.contains_key(id));
627
    }
628
629
    fn clear(&mut self) {
630
        self.entries.clear();
631
        self.order.clear();
632
    }
633
}