atagen

macltok

public
Star0
Watch0
aced11e80b47de7a887296b10eedff310df5360f
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/engine/media.rsRust931 lines31.0 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::{Shared, WeakShared},
13
};
14
use matrix_sdk::{
15
    Client, Room, SendOutsideWasm, SyncOutsideWasm,
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
            EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,
26
            message::{
27
                AddMentions, Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation,
28
            },
29
        },
30
    },
31
};
32
use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};
33
use ruma::{MxcUri, OwnedEventId};
34
use tokio::sync::Mutex;
35
36
use super::{executor::EngineBoxFuture, url_preview::UrlPreviews};
37
use crate::{
38
    AttachmentEncryption, AttachmentUpload, Error, ErrorKind, LinkPreview, Media, MediaFile,
39
    MediaId, MediaKind, MediaLocation, MediaSize,
40
    normalise::{MediaRegistration, MediaScope, MediaSourceInfo},
41
    upload::prepare_upload,
42
};
43
44
/// a stalled download gives up after this long without a byte
45
const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);
46
/// the sdk client gives up on any request after thirty seconds, far too soon for a video
47
const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);
48
49
type FlightFuture = EngineBoxFuture<'static, Result<Media, Error>>;
50
type Flight = Shared<FlightFuture>;
51
52
/// owns everything needed to turn a media id or an mxc uri into bytes, so the
53
/// handle can answer media queries without a trip through the engine
54
pub(crate) struct MediaService {
55
    client: Client,
56
    registry: Mutex<MediaRegistry>,
57
    flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,
58
    max_bytes: usize,
59
    context_limit: usize,
60
    url_previews: UrlPreviews,
61
}
62
63
impl MediaService {
64
    pub(crate) fn new(client: Client, max_bytes: usize, context_limit: usize) -> Self {
65
        Self {
66
            url_previews: UrlPreviews::new(client.clone()),
67
            client,
68
            registry: Mutex::new(MediaRegistry::default()),
69
            flights: Mutex::new(HashMap::new()),
70
            max_bytes,
71
            context_limit,
72
        }
73
    }
74
75
    /// one event context session's media, bounded by `EngineConfig::max_context_media`
76
    pub(crate) fn context_scope(&self) -> MediaScope {
77
        MediaScope::bounded(self.context_limit)
78
    }
79
80
    pub(crate) async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {
81
        self.url_previews.preview(url).await
82
    }
83
84
    pub(crate) async fn register(&self, scope: &MediaScope, media: Vec<MediaRegistration>) {
85
        if !media.is_empty() {
86
            self.registry.lock().await.insert_all(scope, media);
87
        }
88
    }
89
90
    pub(crate) async fn release(&self, scope: &MediaScope) {
91
        self.registry.lock().await.release(scope);
92
    }
93
94
    pub(crate) async fn clear(&self) {
95
        self.registry.lock().await.clear();
96
    }
97
98
    pub(crate) async fn timeline_media(
99
        &self,
100
        media_id: &MediaId,
101
        size: MediaSize,
102
    ) -> Result<Media, Error> {
103
        let chosen = choose_source(self.registered(media_id).await?, size)?;
104
        if chosen
105
            .declared_size
106
            .is_some_and(|size| size > self.max_bytes as u64)
107
        {
108
            return Err(too_large("media exceeds the configured byte limit"));
109
        }
110
        self.fetch_or_file(chosen.request, chosen.mimetype).await
111
    }
112
113
    pub(crate) async fn media_location(
114
        &self,
115
        media_id: &MediaId,
116
        size: MediaSize,
117
    ) -> Result<MediaLocation, Error> {
118
        let media = self.registered(media_id).await?;
119
        let chosen = choose_source(media.clone(), size)?;
120
        if is_local(&chosen.request.source) {
121
            return Ok(MediaLocation::Local);
122
        }
123
        if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {
124
            return Ok(MediaLocation::File(
125
                media_file(
126
                    &self.client,
127
                    chosen.request,
128
                    chosen.mimetype,
129
                    chosen.declared_size,
130
                )
131
                .await?,
132
            ));
133
        }
134
        let url = media_http_request(&self.client, &chosen.request)
135
            .await?
136
            .uri()
137
            .to_string();
138
        let fallback = MediaRequestParameters {
139
            source: media.full.source,
140
            format: MediaFormat::File,
141
        };
142
        Ok(MediaLocation::ServerThumbnail {
143
            url,
144
            fallback: media_file(
145
                &self.client,
146
                fallback,
147
                media.full.mimetype,
148
                media.full.declared_size,
149
            )
150
            .await?,
151
        })
152
    }
153
154
    async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {
155
        if let Some(uri) = media_id.as_plain() {
156
            return plain_registration(media_id, uri);
157
        }
158
        self.registry
159
            .lock()
160
            .await
161
            .get(media_id)
162
            .cloned()
163
            .ok_or_else(|| {
164
                Error::new(
165
                    ErrorKind::MediaUnknown,
166
                    "media is not registered in a retained timeline",
167
                )
168
            })
169
    }
170
171
    /// plenty of servers won't thumbnail everything, and the file itself is the next best
172
    /// thing. the byte cap still holds either way
173
    async fn fetch_or_file(
174
        &self,
175
        request: MediaRequestParameters,
176
        mimetype: Option<String>,
177
    ) -> Result<Media, Error> {
178
        if !matches!(request.format, MediaFormat::Thumbnail(_)) {
179
            return self.fetch(request, mimetype).await;
180
        }
181
        match self.fetch(request.clone(), mimetype.clone()).await {
182
            Err(error) if error.kind == ErrorKind::MediaUnavailable => {
183
                let file = MediaRequestParameters {
184
                    source: request.source,
185
                    format: MediaFormat::File,
186
                };
187
                self.fetch(file, mimetype).await
188
            }
189
            result => result,
190
        }
191
    }
192
193
    async fn fetch(
194
        &self,
195
        request: MediaRequestParameters,
196
        mimetype: Option<String>,
197
    ) -> Result<Media, Error> {
198
        let key = flight_key(&request);
199
        let flight = {
200
            let mut flights = self.flights.lock().await;
201
            flights.retain(|_, flight| flight.upgrade().is_some());
202
            match flights.get(&key).and_then(WeakShared::upgrade) {
203
                Some(flight) => flight,
204
                None => {
205
                    let prepared = AssertUnwindSafe(fetch(
206
                        self.client.clone(),
207
                        request,
208
                        mimetype,
209
                        self.max_bytes,
210
                    ))
211
                    .catch_unwind()
212
                    .map(|result| {
213
                        result.unwrap_or_else(|_| {
214
                            Err(Error::new(ErrorKind::Other, "media fetch panicked"))
215
                        })
216
                    });
217
                    let boxed: FlightFuture = Box::pin(prepared);
218
                    let flight: Flight = boxed.shared();
219
                    if let Some(weak) = flight.downgrade() {
220
                        flights.insert(key, weak);
221
                    }
222
                    flight
223
                }
224
            }
225
        };
226
        flight.await
227
    }
228
}
229
230
struct ChosenSource {
231
    request: MediaRequestParameters,
232
    mimetype: Option<String>,
233
    declared_size: Option<u64>,
234
}
235
236
fn plain_registration(media_id: &MediaId, uri: &MxcUri) -> Result<MediaRegistration, Error> {
237
    if !uri.is_valid() {
238
        return Err(Error::new(ErrorKind::Invalid, "mxc uri is malformed"));
239
    }
240
    Ok(MediaRegistration {
241
        id: media_id.clone(),
242
        kind: MediaKind::Image,
243
        full: MediaSourceInfo {
244
            source: MediaSource::Plain(uri.to_owned()),
245
            mimetype: None,
246
            declared_size: None,
247
        },
248
        thumbnail: None,
249
    })
250
}
251
252
fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {
253
    let (source, format, mimetype, declared_size) = match size {
254
        MediaSize::Full => (
255
            media.full.source,
256
            MediaFormat::File,
257
            media.full.mimetype,
258
            media.full.declared_size,
259
        ),
260
        MediaSize::Thumbnail { width, height } => match media.thumbnail {
261
            Some(thumbnail) => (
262
                thumbnail.source,
263
                MediaFormat::File,
264
                thumbnail.mimetype,
265
                thumbnail.declared_size,
266
            ),
267
            // the server can only resize what it can read
268
            None if matches!(media.kind, MediaKind::Image | MediaKind::Video)
269
                && matches!(media.full.source, MediaSource::Plain(_)) =>
270
            {
271
                // the server picks the thumbnail's type, so the original's would be a lie
272
                let format = if is_local(&media.full.source) {
273
                    MediaFormat::File
274
                } else {
275
                    thumbnail_format(width, height)
276
                };
277
                (media.full.source, format, None, None)
278
            }
279
            None => {
280
                return Err(Error::new(
281
                    ErrorKind::MediaUnavailable,
282
                    "the requested media size is unavailable",
283
                ));
284
            }
285
        },
286
    };
287
    Ok(ChosenSource {
288
        request: MediaRequestParameters { source, format },
289
        mimetype,
290
        declared_size,
291
    })
292
}
293
294
async fn media_file(
295
    client: &Client,
296
    request: MediaRequestParameters,
297
    mimetype: Option<String>,
298
    declared_size: Option<u64>,
299
) -> Result<MediaFile, Error> {
300
    let url = media_http_request(client, &request)
301
        .await?
302
        .uri()
303
        .to_string();
304
    let encryption = match &request.source {
305
        MediaSource::Plain(_) => None,
306
        MediaSource::Encrypted(file) => {
307
            let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (
308
                &file.info,
309
                file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),
310
            ) else {
311
                return Err(Error::new(
312
                    ErrorKind::MediaUnavailable,
313
                    "encrypted media is not v2 with a sha256 hash",
314
                ));
315
            };
316
            Some(AttachmentEncryption {
317
                key: info.k.encode(),
318
                iv: info.iv.encode(),
319
                sha256: sha256.encode(),
320
            })
321
        }
322
    };
323
    Ok(MediaFile {
324
        url,
325
        mimetype,
326
        declared_size,
327
        encryption,
328
    })
329
}
330
331
/// servers without spec v1.11 animated thumbnails ignore the flag and send a still frame
332
fn thumbnail_format(width: u32, height: u32) -> MediaFormat {
333
    let mut settings = MediaThumbnailSettings::new(width.into(), height.into());
334
    settings.animated = true;
335
    MediaFormat::Thumbnail(settings)
336
}
337
338
fn is_local(source: &MediaSource) -> bool {
339
    let uri = match source {
340
        MediaSource::Plain(uri) => uri,
341
        MediaSource::Encrypted(file) => &file.url,
342
    };
343
    uri.server_name()
344
        .is_ok_and(|server| server.as_str() == "send-queue.localhost")
345
}
346
347
/// plain and encrypted sources share a cache key in the sdk, but must never share a flight
348
fn flight_key(request: &MediaRequestParameters) -> String {
349
    let uri = match &request.source {
350
        MediaSource::Plain(uri) => format!("plain|{uri}"),
351
        MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),
352
    };
353
    match &request.format {
354
        MediaFormat::File => uri.to_owned(),
355
        MediaFormat::Thumbnail(settings) => format!(
356
            "{uri}|{}|{}x{}|{}",
357
            settings.method, settings.width, settings.height, settings.animated
358
        ),
359
    }
360
}
361
362
fn too_large(message: &'static str) -> Error {
363
    Error::new(ErrorKind::MediaTooLarge, message)
364
}
365
366
pub(crate) async fn download_file(
367
    client: &Client,
368
    source: MediaSource,
369
    mimetype: Option<String>,
370
    max_bytes: usize,
371
) -> Result<Media, Error> {
372
    let request = MediaRequestParameters {
373
        source,
374
        format: MediaFormat::File,
375
    };
376
    fetch(client.clone(), request, mimetype, max_bytes).await
377
}
378
379
async fn fetch(
380
    client: Client,
381
    request: MediaRequestParameters,
382
    mimetype: Option<String>,
383
    max_bytes: usize,
384
) -> Result<Media, Error> {
385
    if let Some(cached) = cached_media(&client, &request).await? {
386
        if cached.len() > max_bytes {
387
            if let Ok(store) = client.media_store().lock().await {
388
                let _ = store.remove_media_content(&request).await;
389
            }
390
            return Err(too_large("cached media exceeds the configured byte limit"));
391
        }
392
        // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri
393
        // leaves ciphertext here. its hash gives it away
394
        if is_ciphertext(&cached, &request.source) {
395
            let bytes = maybe_decrypt(cached, &request, max_bytes)?;
396
            cache(&client, &request, &bytes).await;
397
            return Ok(media(bytes, mimetype));
398
        }
399
        return Ok(media(cached, mimetype));
400
    }
401
    let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;
402
    // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything
403
    let bytes = maybe_decrypt(bytes, &request, max_bytes)?;
404
    cache(&client, &request, &bytes).await;
405
    Ok(media(bytes, mimetype.or(served_mimetype)))
406
}
407
408
async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {
409
    if let Ok(store) = client.media_store().lock().await {
410
        let _ = store
411
            .add_media_content(
412
                request,
413
                bytes.to_vec(),
414
                matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,
415
            )
416
            .await;
417
    }
418
}
419
420
fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {
421
    use sha2::{Digest, Sha256};
422
    let MediaSource::Encrypted(file) = source else {
423
        return false;
424
    };
425
    file.hashes
426
        .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)
427
        .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())
428
}
429
430
fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {
431
    let mimetype = mimetype
432
        .or_else(|| sniff_image(&bytes).map(str::to_owned))
433
        .unwrap_or_else(|| "application/octet-stream".to_owned());
434
    Media {
435
        bytes: Arc::from(bytes),
436
        mimetype,
437
    }
438
}
439
440
/// the cache forgets content types, and avatars never declare one. images are the common case
441
fn sniff_image(bytes: &[u8]) -> Option<&'static str> {
442
    match bytes {
443
        [0x89, b'P', b'N', b'G', ..] => Some("image/png"),
444
        [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),
445
        [b'G', b'I', b'F', b'8', ..] => Some("image/gif"),
446
        [
447
            b'R',
448
            b'I',
449
            b'F',
450
            b'F',
451
            _,
452
            _,
453
            _,
454
            _,
455
            b'W',
456
            b'E',
457
            b'B',
458
            b'P',
459
            ..,
460
        ] => Some("image/webp"),
461
        _ => None,
462
    }
463
}
464
465
async fn cached_media(
466
    client: &Client,
467
    request: &MediaRequestParameters,
468
) -> Result<Option<Vec<u8>>, Error> {
469
    fn cache_failed(
470
        source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
471
    ) -> Error {
472
        Error::with_source(ErrorKind::Store, "reading media cache failed", source)
473
    }
474
    client
475
        .media_store()
476
        .lock()
477
        .await
478
        .map_err(cache_failed)?
479
        .get_media_content(request)
480
        .await
481
        .map_err(cache_failed)
482
}
483
484
fn maybe_decrypt(
485
    bytes: Vec<u8>,
486
    request: &MediaRequestParameters,
487
    max_bytes: usize,
488
) -> Result<Vec<u8>, Error> {
489
    let MediaSource::Encrypted(file) = &request.source else {
490
        return Ok(bytes);
491
    };
492
    fn decrypt_failed(
493
        source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
494
    ) -> Error {
495
        Error::with_source(
496
            ErrorKind::MediaUnavailable,
497
            "media decryption failed",
498
            source,
499
        )
500
    }
501
    let info: MediaEncryptionInfo = (**file).clone().into();
502
    let mut cursor = std::io::Cursor::new(bytes);
503
    let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(decrypt_failed)?;
504
    let mut decrypted = Vec::new();
505
    let mut chunk = [0_u8; 8192];
506
    loop {
507
        let read = decryptor.read(&mut chunk).map_err(decrypt_failed)?;
508
        if read == 0 {
509
            return Ok(decrypted);
510
        }
511
        if read > max_bytes.saturating_sub(decrypted.len()) {
512
            return Err(too_large(
513
                "decrypted media exceeds the configured byte limit",
514
            ));
515
        }
516
        decrypted.extend_from_slice(&chunk[..read]);
517
    }
518
}
519
520
/// the sdk's own download has no size cap, so we do it by hand
521
async fn download_bounded(
522
    client: &Client,
523
    request: &MediaRequestParameters,
524
    max_bytes: usize,
525
) -> Result<(Vec<u8>, Option<String>), Error> {
526
    if is_local(&request.source) {
527
        return Err(Error::new(
528
            ErrorKind::MediaUnavailable,
529
            "local media is not in the media cache",
530
        ));
531
    }
532
    let network_failed =
533
        |source| Error::with_source(ErrorKind::Network, "media download failed", source);
534
    let mut refreshed = false;
535
    let response = loop {
536
        let http_request = media_http_request(client, request).await?;
537
        let (parts, _) = http_request.into_parts();
538
        let mut builder = client
539
            .http_client()
540
            .get(parts.uri.to_string())
541
            .timeout(DOWNLOAD_TOTAL);
542
        for (name, value) in &parts.headers {
543
            builder = builder.header(name.as_str(), value.as_bytes());
544
        }
545
        let response = builder.send().await.map_err(network_failed)?;
546
        if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {
547
            break response;
548
        }
549
        if refreshed
550
            || client
551
                .session_tokens()
552
                .is_none_or(|tokens| tokens.refresh_token.is_none())
553
        {
554
            return Err(Error::new(
555
                ErrorKind::Unauthenticated,
556
                "media download was not authorized",
557
            ));
558
        }
559
        client.refresh_access_token().await.map_err(|source| {
560
            Error::from_refresh_token("refreshing media access token failed", source)
561
        })?;
562
        refreshed = true;
563
    };
564
    let status = response.status();
565
    if !status.is_success() {
566
        return Err(Error::new(
567
            ErrorKind::MediaUnavailable,
568
            format!("media download failed with status {status}"),
569
        ));
570
    }
571
    if response
572
        .content_length()
573
        .is_some_and(|length| length > max_bytes as u64)
574
    {
575
        return Err(too_large("media exceeds the configured byte limit"));
576
    }
577
    let mimetype = response
578
        .headers()
579
        .get(http::header::CONTENT_TYPE)
580
        .and_then(|value| value.to_str().ok())
581
        .map(str::to_owned);
582
    let mut stream = response.bytes_stream();
583
    let mut bytes = Vec::new();
584
    loop {
585
        let chunk = matrix_sdk::timeout::timeout(stream.next(), DOWNLOAD_IDLE)
586
            .await
587
            .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;
588
        let Some(chunk) = chunk else { break };
589
        let chunk = chunk.map_err(network_failed)?;
590
        if chunk.len() > max_bytes.saturating_sub(bytes.len()) {
591
            return Err(too_large(
592
                "downloaded media exceeds the configured byte limit",
593
            ));
594
        }
595
        bytes.extend_from_slice(&chunk);
596
    }
597
    Ok((bytes, mimetype))
598
}
599
600
async fn media_http_request(
601
    client: &Client,
602
    request: &MediaRequestParameters,
603
) -> Result<http::Request<Vec<u8>>, Error> {
604
    let versions = client.supported_versions().await.map_err(|source| {
605
        Error::with_source(
606
            ErrorKind::Network,
607
            "reading supported server versions failed",
608
            source,
609
        )
610
    })?;
611
    let authenticated =
612
        authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);
613
    let base = client.homeserver().to_string();
614
    let token = client.access_token();
615
    let invalid_uri =
616
        |source| Error::with_source(ErrorKind::Invalid, "media URI is invalid", source);
617
    let (uri, thumbnail) = match (&request.source, &request.format) {
618
        (MediaSource::Encrypted(file), _) => (&file.url, None),
619
        (MediaSource::Plain(uri), MediaFormat::File) => (uri, None),
620
        (MediaSource::Plain(uri), MediaFormat::Thumbnail(settings)) => (uri, Some(settings)),
621
    };
622
    // each endpoint has its own auth input type, so no generic helper can take them all
623
    macro_rules! into_http {
624
        ($endpoint:expr, $token:expr) => {
625
            $endpoint
626
                .try_into_http_request::<Vec<u8>>(&base, $token, Cow::Borrowed(&versions))
627
                .map_err(|source| {
628
                    Error::with_source(ErrorKind::Other, "media request is invalid", source)
629
                })?
630
        };
631
    }
632
    let authed = SendAccessToken::IfRequired(token.as_deref().unwrap_or(""));
633
    #[allow(deprecated)]
634
    let mut http_request = match (thumbnail, authenticated) {
635
        (None, true) => into_http!(
636
            authenticated_media::get_content::v1::Request::from_uri(uri).map_err(invalid_uri)?,
637
            authed
638
        ),
639
        (None, false) => into_http!(
640
            media::get_content::v3::Request::from_url(uri).map_err(invalid_uri)?,
641
            SendAccessToken::None
642
        ),
643
        (Some(settings), true) => {
644
            let mut endpoint = authenticated_media::get_content_thumbnail::v1::Request::from_uri(
645
                uri,
646
                settings.width,
647
                settings.height,
648
            )
649
            .map_err(invalid_uri)?;
650
            endpoint.method = Some(settings.method.clone());
651
            endpoint.animated = Some(settings.animated);
652
            into_http!(endpoint, authed)
653
        }
654
        (Some(settings), false) => {
655
            let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(
656
                uri,
657
                settings.width,
658
                settings.height,
659
            )
660
            .map_err(invalid_uri)?;
661
            endpoint.method = Some(settings.method.clone());
662
            endpoint.animated = Some(settings.animated);
663
            into_http!(endpoint, SendAccessToken::None)
664
        }
665
    };
666
    // legacy endpoints don't ask for a token but plenty of servers want one anyway
667
    if let Some(token) = token.as_deref() {
668
        let value = format!("Bearer {token}").parse().map_err(|_| {
669
            Error::new(ErrorKind::Unauthenticated, "stored access token is invalid")
670
        })?;
671
        http_request
672
            .headers_mut()
673
            .insert(http::header::AUTHORIZATION, value);
674
    }
675
    Ok(http_request)
676
}
677
678
pub(crate) async fn queue_upload(
679
    room: Room,
680
    upload: AttachmentUpload,
681
    max_bytes: usize,
682
) -> Result<(), Error> {
683
    let (mut upload, mime, mut config) = prepare_upload(upload, max_bytes)?;
684
    let thread_root = upload.timeline_id.thread_root().cloned();
685
    config.reply = match (upload.reply_to.take(), thread_root) {
686
        (Some(event_id), thread_root) => {
687
            let reply = || SdkReply {
688
                event_id: event_id.clone(),
689
                enforce_thread: if thread_root.is_some() {
690
                    EnforceThread::Threaded(ReplyWithinThread::Yes)
691
                } else {
692
                    EnforceThread::MaybeThreaded
693
                },
694
                add_mentions: if upload.notify_replied_to {
695
                    AddMentions::Yes
696
                } else {
697
                    AddMentions::No
698
                },
699
            };
700
            if let Some(thread_root) = &thread_root {
701
                check_reply_thread(&room, reply(), thread_root).await?;
702
            }
703
            Some(reply())
704
        }
705
        (None, Some(root_event_id)) => Some(SdkReply {
706
            event_id: root_event_id,
707
            enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
708
            add_mentions: AddMentions::No,
709
        }),
710
        (None, None) => None,
711
    };
712
    room.send_queue()
713
        .send_attachment(upload.filename, mime, upload.bytes, config)
714
        .await
715
        .map(drop)
716
        .map_err(|source| {
717
            use matrix_sdk::send_queue::RoomSendQueueError;
718
            let kind = match &source {
719
                RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {
720
                    ErrorKind::RoomUnknown
721
                }
722
                RoomSendQueueError::StorageError(_) => ErrorKind::Store,
723
                _ => ErrorKind::MediaUnavailable,
724
            };
725
            Error::with_source(kind, "media upload could not be queued", source)
726
        })
727
}
728
729
/// the send queue builds the relation itself, so a reply that would land in another thread has to be caught before it is queued
730
async fn check_reply_thread(
731
    room: &Room,
732
    reply: SdkReply,
733
    thread_root: &OwnedEventId,
734
) -> Result<(), Error> {
735
    let content = room
736
        .make_reply_event(
737
            RoomMessageEventContentWithoutRelation::text_plain(""),
738
            reply,
739
        )
740
        .await
741
        .map_err(|source| {
742
            Error::with_source(ErrorKind::Timeline, "reply operation failed", source)
743
        })?;
744
    if matches!(
745
        content.relates_to.as_ref(),
746
        Some(Relation::Thread(thread)) if &thread.event_id == thread_root
747
    ) {
748
        Ok(())
749
    } else {
750
        Err(Error::new(
751
            ErrorKind::Invalid,
752
            "reply target does not belong to the selected thread",
753
        ))
754
    }
755
}
756
757
/// every entry belongs to the scopes that registered it and lives until the last of them
758
/// lets go. there is no global cap, a bounded scope only ever trims itself
759
#[derive(Default)]
760
struct MediaRegistry {
761
    entries: HashMap<MediaId, Entry>,
762
    scopes: HashMap<u64, ScopeEntries>,
763
}
764
765
struct Entry {
766
    media: MediaRegistration,
767
    owners: Vec<u64>,
768
}
769
770
/// least recently used first. stale order slots are skipped by stamp rather than hunted down
771
#[derive(Default)]
772
struct ScopeEntries {
773
    stamps: HashMap<MediaId, u64>,
774
    order: VecDeque<(MediaId, u64)>,
775
    next_stamp: u64,
776
    limit: Option<usize>,
777
}
778
779
impl ScopeEntries {
780
    fn touch(&mut self, id: &MediaId) {
781
        let Some(limit) = self.limit else {
782
            self.stamps.insert(id.clone(), 0);
783
            return;
784
        };
785
        let stamp = self.next_stamp;
786
        self.next_stamp += 1;
787
        self.stamps.insert(id.clone(), stamp);
788
        self.order.push_back((id.clone(), stamp));
789
        if self.order.len() > limit.saturating_mul(4) {
790
            let stamps = &self.stamps;
791
            self.order
792
                .retain(|(id, stamp)| stamps.get(id) == Some(stamp));
793
        }
794
    }
795
796
    /// what fell out past the limit
797
    fn trim(&mut self) -> Vec<MediaId> {
798
        let Some(limit) = self.limit else {
799
            return Vec::new();
800
        };
801
        let mut dropped = Vec::new();
802
        while self.stamps.len() > limit {
803
            let Some((id, stamp)) = self.order.pop_front() else {
804
                break;
805
            };
806
            if self.stamps.get(&id) == Some(&stamp) {
807
                self.stamps.remove(&id);
808
                dropped.push(id);
809
            }
810
        }
811
        dropped
812
    }
813
}
814
815
impl MediaRegistry {
816
    fn get(&mut self, id: &MediaId) -> Option<&MediaRegistration> {
817
        let entry = self.entries.get(id)?;
818
        for owner in &entry.owners {
819
            if let Some(scope) = self.scopes.get_mut(owner) {
820
                scope.touch(id);
821
            }
822
        }
823
        Some(&entry.media)
824
    }
825
826
    fn insert_all(&mut self, scope: &MediaScope, media: Vec<MediaRegistration>) {
827
        let owner = scope.nonce();
828
        let entries = self.scopes.entry(owner).or_insert_with(|| ScopeEntries {
829
            limit: scope.limit(),
830
            ..ScopeEntries::default()
831
        });
832
        for media in media {
833
            let id = media.id.clone();
834
            entries.touch(&id);
835
            let entry = self.entries.entry(id).or_insert_with(|| Entry {
836
                media: media.clone(),
837
                owners: Vec::new(),
838
            });
839
            entry.media = media;
840
            if !entry.owners.contains(&owner) {
841
                entry.owners.push(owner);
842
            }
843
        }
844
        for id in entries.trim() {
845
            Self::disown(&mut self.entries, &id, owner);
846
        }
847
    }
848
849
    fn disown(entries: &mut HashMap<MediaId, Entry>, id: &MediaId, owner: u64) {
850
        let Some(entry) = entries.get_mut(id) else {
851
            return;
852
        };
853
        entry.owners.retain(|current| *current != owner);
854
        if entry.owners.is_empty() {
855
            entries.remove(id);
856
        }
857
    }
858
859
    fn release(&mut self, scope: &MediaScope) {
860
        let Some(released) = self.scopes.remove(&scope.nonce()) else {
861
            return;
862
        };
863
        for id in released.stamps.keys() {
864
            Self::disown(&mut self.entries, id, scope.nonce());
865
        }
866
    }
867
868
    fn clear(&mut self) {
869
        self.entries.clear();
870
        self.scopes.clear();
871
    }
872
}
873
874
#[cfg(test)]
875
mod tests {
876
    use super::*;
877
    use crate::MediaKind;
878
879
    fn media(name: &str) -> MediaRegistration {
880
        let uri = ruma::OwnedMxcUri::from(format!("mxc://example.org/{name}"));
881
        MediaRegistration {
882
            id: MediaId::new(format!("test#{name}")),
883
            kind: MediaKind::Image,
884
            full: MediaSourceInfo {
885
                source: MediaSource::Plain(uri),
886
                mimetype: None,
887
                declared_size: None,
888
            },
889
            thumbnail: None,
890
        }
891
    }
892
893
    fn has(registry: &mut MediaRegistry, name: &str) -> bool {
894
        registry.get(&media(name).id).is_some()
895
    }
896
897
    #[test]
898
    fn shared_media_lives_until_every_owner_lets_go() {
899
        let mut registry = MediaRegistry::default();
900
        let (timeline, context) = (MediaScope::new(), MediaScope::bounded(8));
901
        registry.insert_all(&timeline, vec![media("shared"), media("row")]);
902
        registry.insert_all(&context, vec![media("shared"), media("page")]);
903
        registry.release(&context);
904
        assert!(has(&mut registry, "shared"));
905
        assert!(has(&mut registry, "row"));
906
        assert!(!has(&mut registry, "page"));
907
        registry.release(&timeline);
908
        assert!(!has(&mut registry, "shared"));
909
        assert!(registry.entries.is_empty());
910
    }
911
912
    #[test]
913
    fn heavy_context_paging_trims_only_its_own_least_recent_entries() {
914
        let mut registry = MediaRegistry::default();
915
        let (timeline, context) = (MediaScope::new(), MediaScope::bounded(4));
916
        registry.insert_all(&timeline, vec![media("on-screen"), media("both")]);
917
        registry.insert_all(&context, vec![media("both"), media("kept")]);
918
        for page in 0..50 {
919
            assert!(has(&mut registry, "kept"));
920
            registry.insert_all(&context, vec![media(&format!("page-{page}"))]);
921
        }
922
        assert!(has(&mut registry, "on-screen"));
923
        assert!(has(&mut registry, "both"));
924
        assert!(has(&mut registry, "kept"));
925
        assert!(!has(&mut registry, "page-0"));
926
        assert!(registry.scopes[&context.nonce()].stamps.len() <= 4);
927
        registry.release(&context);
928
        assert!(has(&mut registry, "both"));
929
        assert!(!has(&mut registry, "kept"));
930
    }
931
}