Commit 0291516d40b5
tarball14 files changed+606-37
@@ -31,7 +31,7 @@ matrix-sdk-base = { version = "0.18.0", default-features = false }
3131
matrix-sdk-crypto = { version = "0.18.0", default-features = false }3232
matrix-sdk-store-encryption = { version = "0.18.0", optional = true }3333
matrix-sdk-ui = { version = "0.18.0", default-features = false }34
-ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545"] }34
+ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }3535
serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }3636
thiserror = "2.0.16"3737
mime = "0.3.17"@@ -228,7 +228,12 @@ and `SessionTokensChanged` only goes out once it has landed, or
228228
`SessionSaveFailed` if it didn't. natively the sdk's save callback runs inside229229
the refresh, before the new token is used, and `save_blocking` writes there and230230
then. in a browser there's only the async save, so a tab that dies between a231
-refresh and its save wakes up holding a spent token. `without_persistence`231
+refresh and its save wakes up holding a spent token. a fork patch letting the232
+sdk await an async save before it uses the new token would close that, but it233
+changes how a refresh behaves rather than what's visible, and on 2026-10-22 we234
+held off so the fork doesn't drift that far that fast. upstream taking an async235
+save callback, or the fork already carrying behaviour changes, reopens it.236
+`without_persistence`232237
hands the job back to the host. `EncryptedSessionStore` seals the record with233238
the sdk's `StoreCipher` under the store's passphrase and replaces it by rename,234239
so a crash mid save leaves the old one. it keeps the record out of the sdk's@@ -329,6 +334,31 @@ membership until it's forgotten. push rule changes never touch room info, so
329334
`NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in330335
the summaries331336
337
+## link previews338
+339
+a text message can carry its sender's own previews (msc4095), and those come340
+out on `Message::link_previews` without asking anyone, so they work in341
+encrypted rooms and leak nothing. an encrypted preview image is registered like342
+an attachment. a preview whose url isn't in the body is dropped, or a sender343
+could caption any link with any card344
+345
+`url_preview` asks the homeserver, which fetches the page, re-hosts `og:image`346
+and answers with loose `og:` keys. that tells the homeserver every url, so347
+whether encrypted rooms ask is the host's call. answers are kept an hour and348
+misses ten minutes, and asks for one url at once share a request, since synapse349
+rate limits the endpoint and a timeline repeats links. synapse answers a page350
+it couldn't download with a 500, a dns failure with a 502 and its blocklist with351
+a 403, so those, 400 and 404 mean no preview. a genuine 500 hides in there too,352
+but a missing card is cheap. a353
+server that doesn't preview at all says so with `M_UNRECOGNIZED` once and isn't354
+asked again. a 429 anywhere is `ErrorKind::RateLimited`. page text from either355
+source is trimmed, 256 characters for a title or site name and 1024 for a356
+description, and an image that isn't an mxc uri is dropped357
+358
+`url_previews_allowed` follows element, so both agree. the user's own choice359
+for the room, then their account wide default, then room state an admin set.360
+the room state comes in with the rest through the fork's extra required state361
+332362
## image packs333363
334364
msc2545 packs come from the user's account data, from room state, and from the@@ -31,10 +31,10 @@ use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};
3131
use ruma::MxcUri;3232
use tokio::sync::Mutex;3333
34
-use super::executor::EngineBoxFuture;34
+use super::{executor::EngineBoxFuture, url_preview::UrlPreviews};3535
use crate::{36
- AttachmentEncryption, AttachmentUpload, Error, ErrorKind, Media, MediaFile, MediaId, MediaKind,37
- MediaLocation, MediaSize, TimelineId,36
+ AttachmentEncryption, AttachmentUpload, Error, ErrorKind, LinkPreview, Media, MediaFile,37
+ MediaId, MediaKind, MediaLocation, MediaSize, TimelineId,3838
normalise::{MediaRegistration, MediaScope, MediaSourceInfo},3939
upload::prepare_upload,4040
};@@ -54,11 +54,13 @@ pub(crate) struct MediaService {
5454
registry: Mutex<MediaRegistry>,5555
flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,5656
max_bytes: usize,57
+ url_previews: UrlPreviews,5758
}5859
5960
impl MediaService {6061
pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {6162
Self {63
+ url_previews: UrlPreviews::new(client.clone()),6264
client,6365
registry: Mutex::new(MediaRegistry::new(max_sources)),6466
flights: Mutex::new(HashMap::new()),@@ -66,6 +68,10 @@ impl MediaService {
6668
}6769
}6870
71
+ pub(crate) async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {72
+ self.url_previews.preview(url).await73
+ }74
+6975
pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {7076
if !media.is_empty() {7177
self.registry.lock().await.insert_all(media);@@ -6,6 +6,7 @@ mod prepare;
66
mod room;77
mod session;88
mod timeline;9
+mod url_preview;910
mod watchers;1011
1112
use std::{@@ -41,7 +42,7 @@ use crate::{
4142
AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,4243
EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,4344
NotificationCommand, RoomCommand, Termination, TimelineCommand,44
- normalise::ROOM_PACKS,45
+ normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},4546
transport::{DriverPort, Request, Responder, transport_pair},4647
};4748
@@ -310,7 +311,10 @@ impl Engine {
310311
let sync = Arc::new(311312
SyncService::builder(client.clone())312313
.with_offline_mode()313
- .with_room_list_extra_required_state(vec![(ROOM_PACKS.into(), "*".to_owned())])314
+ .with_room_list_extra_required_state(vec![315
+ (ROOM_PACKS.into(), "*".to_owned()),316
+ (ROOM_PREVIEW_URLS.into(), String::new()),317
+ ])314318
.build()315319
.await316320
.map_err(EngineError::SyncService)?,@@ -0,0 +1,166 @@
1
+use std::{2
+ collections::{HashMap, VecDeque},3
+ sync::{4
+ Arc,5
+ atomic::{AtomicBool, Ordering},6
+ },7
+ time::Duration,8
+};9
+10
+use futures_util::{FutureExt, future::WeakShared};11
+use matrix_sdk::{12
+ Client, HttpError,13
+ ruma::api::{14
+ Metadata,15
+ client::{authenticated_media, media},16
+ },17
+};18
+use ruma::{19
+ api::error::ErrorKind as ApiErrorKind,20
+ serde::{JsonObject, Raw},21
+ time::Instant,22
+};23
+use tokio::sync::Mutex;24
+25
+use super::executor::EngineBoxFuture;26
+use crate::{Error, LinkPreview, normalise::server_preview};27
+28
+const HIT_FOR: Duration = Duration::from_secs(60 * 60);29
+const MISS_FOR: Duration = Duration::from_secs(10 * 60);30
+const REMEMBERED: usize = 512;31
+32
+type Lookup = EngineBoxFuture<'static, Result<Option<LinkPreview>, Error>>;33
+34
+/// the homeserver rate limits `preview_url` and a busy timeline repeats links, so answers are35
+/// kept a while, misses too, and asks for one url at once share one request36
+pub(crate) struct UrlPreviews(Arc<Inner>);37
+38
+struct Inner {39
+ client: Client,40
+ /// a server that doesn't preview at all says so once and isn't asked again41
+ unsupported: AtomicBool,42
+ cache: Mutex<Cache>,43
+ lookups: Mutex<HashMap<String, WeakShared<Lookup>>>,44
+}45
+46
+#[derive(Default)]47
+struct Cache {48
+ entries: HashMap<String, (Instant, Option<LinkPreview>)>,49
+ order: VecDeque<String>,50
+}51
+52
+impl UrlPreviews {53
+ pub(crate) fn new(client: Client) -> Self {54
+ Self(Arc::new(Inner {55
+ client,56
+ unsupported: AtomicBool::new(false),57
+ cache: Mutex::new(Cache::default()),58
+ lookups: Mutex::new(HashMap::new()),59
+ }))60
+ }61
+62
+ pub(crate) async fn preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {63
+ if self.0.unsupported.load(Ordering::Relaxed) {64
+ return Ok(None);65
+ }66
+ if let Some(preview) = self.0.cache.lock().await.get(url) {67
+ return Ok(preview);68
+ }69
+ let lookup = {70
+ let mut lookups = self.0.lookups.lock().await;71
+ lookups.retain(|_, lookup| lookup.upgrade().is_some());72
+ match lookups.get(url).and_then(WeakShared::upgrade) {73
+ Some(lookup) => lookup,74
+ None => {75
+ let inner = self.0.clone();76
+ let owned = url.to_owned();77
+ let lookup: Lookup = Box::pin(async move { inner.look_up(owned).await });78
+ let lookup = lookup.shared();79
+ if let Some(weak) = lookup.downgrade() {80
+ lookups.insert(url.to_owned(), weak);81
+ }82
+ lookup83
+ }84
+ }85
+ };86
+ lookup.await87
+ }88
+}89
+90
+impl Inner {91
+ async fn look_up(&self, url: String) -> Result<Option<LinkPreview>, Error> {92
+ let data = match self.fetch(&url).await {93
+ Ok(data) => data,94
+ Err(error) => {95
+ let Some(api) = error.as_client_api_error() else {96
+ return Err(Error::from_sdk(97
+ "asking for a url preview failed",98
+ error.into(),99
+ ));100
+ };101
+ let unrecognised = matches!(api.error_kind(), Some(ApiErrorKind::Unrecognized))102
+ || api.status_code.as_u16() == 405;103
+ if unrecognised {104
+ self.unsupported.store(true, Ordering::Relaxed);105
+ return Ok(None);106
+ }107
+ // synapse answers 500 for a page it can't download, 502 when dns fails and 403 for108
+ // its blocklist (synapse/media/url_previewer.py). a real fault hides in the 500s too,109
+ // but a missing card is cheap and only cached ten minutes110
+ if !matches!(api.status_code.as_u16(), 400 | 403 | 404 | 500 | 502) {111
+ return Err(Error::from_sdk(112
+ "asking for a url preview failed",113
+ error.into(),114
+ ));115
+ }116
+ None117
+ }118
+ };119
+ let preview = data.and_then(|data| server_preview(&url, &data));120
+ self.cache.lock().await.insert(url, preview.clone());121
+ Ok(preview)122
+ }123
+124
+ async fn fetch(&self, url: &str) -> Result<Option<Raw<JsonObject>>, HttpError> {125
+ let versions = self.client.supported_versions().await?;126
+ let data = if authenticated_media::get_media_preview::v1::Request::PATH_BUILDER127
+ .is_supported(&versions)128
+ {129
+ self.client130
+ .send(authenticated_media::get_media_preview::v1::Request::new(131
+ url.to_owned(),132
+ ))133
+ .await?134
+ .data135
+ } else {136
+ #[allow(deprecated)]137
+ let request = media::get_media_preview::v3::Request::new(url.to_owned());138
+ self.client.send(request).await?.data139
+ };140
+ Ok(data.map(Raw::from_json))141
+ }142
+}143
+144
+impl Cache {145
+ fn get(&mut self, url: &str) -> Option<Option<LinkPreview>> {146
+ let (at, preview) = self.entries.get(url)?;147
+ let lasts = if preview.is_some() { HIT_FOR } else { MISS_FOR };148
+ (at.elapsed() < lasts).then(|| preview.clone())149
+ }150
+151
+ fn insert(&mut self, url: String, preview: Option<LinkPreview>) {152
+ if self153
+ .entries154
+ .insert(url.clone(), (Instant::now(), preview))155
+ .is_none()156
+ {157
+ self.order.push_back(url);158
+ }159
+ while self.entries.len() > REMEMBERED {160
+ let Some(oldest) = self.order.pop_front() else {161
+ break;162
+ };163
+ self.entries.remove(&oldest);164
+ }165
+ }166
+}@@ -63,6 +63,8 @@ pub enum ErrorKind {
6363
NoCrossSigningIdentity,6464
RecoveryRejected,6565
Network,66
+ /// the homeserver wants us to slow down. try again later67
+ RateLimited,6668
Server,6769
Store,6870
Timeline,@@ -269,6 +271,13 @@ pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> ErrorKind {
269271
fn classify_http_error(source: &HttpError) -> ErrorKind {270272
match source {271273
HttpError::Reqwest(_) => ErrorKind::Network,274
+ HttpError::Api(_)275
+ if source276
+ .as_client_api_error()277
+ .is_some_and(|api| api.status_code.as_u16() == 429) =>278
+ {279
+ ErrorKind::RateLimited280
+ }272281
HttpError::Api(_) => ErrorKind::Server,273282
HttpError::RefreshToken(source) => classify_refresh_token_error(source),274283
HttpError::Cached(source) => classify_http_error(source),@@ -49,18 +49,19 @@ pub use model::{
4949
AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,5050
ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,5151
EngineFailure, Event, EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks,52
- KeyBackupState, LatestEvent, LifecycleCommand, Media, MediaCommand, MediaDescriptor, MediaFile,53
- MediaId, MediaKind, MediaLocation, MediaSize, MemberFilter, MemberMembership, MemberSummary,54
- MembershipChange, Message, MessageContent, MessageEditContent, MessageKind, MessageMentions,55
- NotificationCommand, OwnDevice, PackImage, PackUsage, PowerLevelChanges, Profile,56
- PushNotification, PushNotificationEvent, PushNotificationRoom, PushNotificationSender,57
- PushNotificationStatus, Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain,58
- RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,59
- RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,60
- RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand,61
- TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,62
- TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,63
- VerificationAction, VerificationFailure, VerificationState, VerificationTarget,52
+ KeyBackupState, LatestEvent, LifecycleCommand, LinkPreview, LinkPreviewImage, Media,53
+ MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,54
+ MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,55
+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackImage,56
+ PackUsage, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,57
+ PushNotificationRoom, PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState,58
+ ReplyDetails, ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero,59
+ RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings,60
+ RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild,61
+ Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,62
+ TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,63
+ UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,64
+ VerificationState, VerificationTarget,6465
};6566
pub use ruma;6667
pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};@@ -1494,6 +1494,36 @@ pub struct Message {
14941494
pub mentions: Option<MessageMentions>,14951495
pub edited: bool,14961496
pub media: Option<MediaDescriptor>,1497
+ /// previews the sender attached (msc4095), so showing them asks no one about the url.1498
+ /// one for a url the body doesn't contain is dropped1499
+ pub link_previews: Vec<LinkPreview>,1500
+}1501
+1502
+/// a link card, from the sender's own message or from `ClientHandle::url_preview`. page1503
+/// text passes through whoever made it unfiltered, so it arrives trimmed to a sane length1504
+#[derive(Clone, Debug, Eq, PartialEq)]1505
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1506
+#[non_exhaustive]1507
+pub struct LinkPreview {1508
+ /// the url as it appears in the message, or as it was asked about1509
+ pub url: String,1510
+ /// where the page says it lives, when that's somewhere else1511
+ pub canonical_url: Option<String>,1512
+ pub title: Option<String>,1513
+ pub description: Option<String>,1514
+ pub site_name: Option<String>,1515
+ pub image: Option<LinkPreviewImage>,1516
+}1517
+1518
+#[derive(Clone, Debug, Eq, PartialEq)]1519
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1520
+#[non_exhaustive]1521
+pub struct LinkPreviewImage {1522
+ pub media_id: MediaId,1523
+ pub mimetype: Option<String>,1524
+ pub size_bytes: Option<u64>,1525
+ pub width: Option<u64>,1526
+ pub height: Option<u64>,14971527
}14981528
14991529
#[derive(Clone, Debug, Eq, PartialEq)]@@ -0,0 +1,217 @@
1
+use matrix_sdk::{Room, deserialized_responses::RawAnySyncOrStrippedState};2
+use ruma::{3
+ MxcUri,4
+ events::{5
+ GlobalAccountDataEventType, RoomAccountDataEventType, StateEventType,6
+ room::{7
+ MediaSource,8
+ message::{MessageType, PreviewImageSource, UrlPreview},9
+ },10
+ },11
+ serde::{JsonObject, Raw},12
+};13
+14
+use super::media::{MediaRegistration, MediaSourceInfo, media_id};15
+use crate::{Error, LinkPreview, LinkPreviewImage, MediaId, MediaKind};16
+17
+/// room state an admin sets, and room account data a user sets, under the same name18
+pub(crate) const ROOM_PREVIEW_URLS: &str = "org.matrix.room.preview_urls";19
+const USER_PREVIEW_URLS: &str = "org.matrix.preview_urls";20
+21
+const SHORT_TEXT: usize = 256;22
+const LONG_TEXT: usize = 1024;23
+24
+/// the previews a text message's sender attached, each with the registration its image needs25
+/// when it's encrypted. embedded reply content has no media identity, so its encrypted images26
+/// go without27
+pub(crate) fn sender_previews(28
+ identity: Option<&str>,29
+ message: &MessageType,30
+) -> Vec<(LinkPreview, Option<MediaRegistration>)> {31
+ let MessageType::Text(text) = message else {32
+ return Vec::new();33
+ };34
+ text.url_previews35
+ .iter()36
+ .flatten()37
+ .enumerate()38
+ .filter_map(|(index, preview)| sender_preview(identity, &text.body, index, preview))39
+ .collect()40
+}41
+42
+/// the matched url has to be in the body, or a sender could caption any link with any card43
+fn sender_preview(44
+ identity: Option<&str>,45
+ body: &str,46
+ index: usize,47
+ preview: &UrlPreview,48
+) -> Option<(LinkPreview, Option<MediaRegistration>)> {49
+ let url = preview.matched_url.as_deref()?;50
+ if url.is_empty() || !body.contains(url) || !preview.contains_preview() {51
+ return None;52
+ }53
+ let (image, registration) = match &preview.image {54
+ None => (None, None),55
+ Some(image) => {56
+ let (media_id, registration) = match &image.source {57
+ PreviewImageSource::Url(uri) => (plain(uri)?, None),58
+ PreviewImageSource::EncryptedImage(file) => {59
+ let id = media_id(identity?, &format!("preview{index}"));60
+ let registration = MediaRegistration {61
+ id: id.clone(),62
+ kind: MediaKind::Image,63
+ full: MediaSourceInfo {64
+ source: MediaSource::Encrypted(Box::new(file.clone())),65
+ mimetype: image.mimetype.clone(),66
+ declared_size: image.size.map(u64::from),67
+ },68
+ thumbnail: None,69
+ };70
+ (id, Some(registration))71
+ }72
+ };73
+ let image = LinkPreviewImage {74
+ media_id,75
+ mimetype: image.mimetype.clone(),76
+ size_bytes: image.size.map(u64::from),77
+ width: image.width.map(u64::from),78
+ height: image.height.map(u64::from),79
+ };80
+ (Some(image), registration)81
+ }82
+ };83
+ let preview = LinkPreview {84
+ url: url.to_owned(),85
+ canonical_url: preview.url.clone().filter(|canonical| canonical != url),86
+ title: clamp(preview.title.as_deref(), SHORT_TEXT),87
+ description: clamp(preview.description.as_deref(), LONG_TEXT),88
+ site_name: None,89
+ image,90
+ };91
+ Some((preview, registration))92
+}93
+94
+/// what the homeserver's `preview_url` answered, a loose map of `og:` keys. `None` when95
+/// nothing in it is worth a card96
+pub(crate) fn server_preview(url: &str, data: &Raw<JsonObject>) -> Option<LinkPreview> {97
+ let text = |key: &str| data.get_field::<String>(key).ok().flatten();98
+ let image = text("og:image")99
+ .as_deref()100
+ .and_then(|uri| plain(<&MxcUri>::from(uri)))101
+ .map(|media_id| LinkPreviewImage {102
+ media_id,103
+ mimetype: text("og:image:type"),104
+ size_bytes: number(data, "matrix:image:size"),105
+ width: number(data, "og:image:width"),106
+ height: number(data, "og:image:height"),107
+ });108
+ let preview = LinkPreview {109
+ url: url.to_owned(),110
+ canonical_url: text("og:url").filter(|canonical| canonical != url),111
+ title: clamp(text("og:title").as_deref(), SHORT_TEXT),112
+ description: clamp(text("og:description").as_deref(), LONG_TEXT),113
+ site_name: clamp(text("og:site_name").as_deref(), SHORT_TEXT),114
+ image,115
+ };116
+ (preview.title.is_some() || preview.description.is_some() || preview.image.is_some())117
+ .then_some(preview)118
+}119
+120
+/// element's order, so both clients agree. the user's choice for this room, then their own121
+/// default, then the room admins'. encrypted rooms are the host's call122
+pub(crate) async fn url_previews_allowed(room: &Room) -> Result<bool, Error> {123
+ let room_choice = room124
+ .account_data(RoomAccountDataEventType::from(ROOM_PREVIEW_URLS))125
+ .await126
+ .map_err(|source| Error::from_sdk("reading url preview settings failed", source))?127
+ .and_then(|event| disabled(&event.get_field::<Raw<JsonObject>>("content").ok()??));128
+ let own_default = || async {129
+ room.client()130
+ .account()131
+ .account_data_raw(GlobalAccountDataEventType::from(USER_PREVIEW_URLS))132
+ .await133
+ .map(|content| content.and_then(|content| disabled(&content.cast_unchecked())))134
+ .map_err(|source| Error::from_sdk("reading url preview settings failed", source))135
+ };136
+ let admins = || async {137
+ room.get_state_event(StateEventType::from(ROOM_PREVIEW_URLS), "")138
+ .await139
+ .map(|event| match event {140
+ Some(RawAnySyncOrStrippedState::Sync(event)) => {141
+ disabled(&event.get_field::<Raw<JsonObject>>("content").ok()??)142
+ }143
+ _ => None,144
+ })145
+ .map_err(|source| Error::from_sdk("reading url preview settings failed", source))146
+ };147
+ let disabled = match room_choice {148
+ Some(disabled) => disabled,149
+ None => match own_default().await? {150
+ Some(disabled) => disabled,151
+ None => admins().await?.unwrap_or(false),152
+ },153
+ };154
+ Ok(!disabled)155
+}156
+157
+fn disabled(content: &Raw<JsonObject>) -> Option<bool> {158
+ content.get_field::<bool>("disable").ok().flatten()159
+}160
+161
+fn plain(uri: &MxcUri) -> Option<MediaId> {162
+ uri.is_valid().then(|| MediaId::plain(uri))163
+}164
+165
+/// servers send dimensions as numbers or as the page's own strings166
+fn number(data: &Raw<JsonObject>, key: &str) -> Option<u64> {167
+ data.get_field::<u64>(key).ok().flatten().or_else(|| {168
+ data.get_field::<String>(key)169
+ .ok()170
+ .flatten()?171
+ .trim()172
+ .parse()173
+ .ok()174
+ })175
+}176
+177
+fn clamp(text: Option<&str>, chars: usize) -> Option<String> {178
+ let text = text?.trim();179
+ (!text.is_empty()).then(|| match text.char_indices().nth(chars) {180
+ Some((end, _)) => text[..end].trim_end().to_owned(),181
+ None => text.to_owned(),182
+ })183
+}184
+185
+#[cfg(test)]186
+mod tests {187
+ use ruma::{events::room::message::MessageType, serde::Raw};188
+ use serde_json::json;189
+190
+ use super::sender_previews;191
+192
+ #[test]193
+ fn a_sender_cannot_caption_a_link_the_message_does_not_contain() {194
+ let message: MessageType = Raw::new(&json!({195
+ "msgtype": "m.text",196
+ "body": "look at https://example.org/real",197
+ "com.beeper.linkpreviews": [198
+ {199
+ "matched_url": "https://example.org/real",200
+ "og:title": "real",201
+ "og:image": "mxc://example.org/image",202
+ },203
+ { "matched_url": "https://bank.example/login", "og:title": "your bank" },204
+ ],205
+ }))206
+ .unwrap()207
+ .cast_unchecked()208
+ .deserialize()209
+ .unwrap();210
+ let previews = sender_previews(Some("1#item"), &message);211
+ assert_eq!(previews.len(), 1);212
+ let (preview, registration) = &previews[0];213
+ assert_eq!(preview.url, "https://example.org/real");214
+ assert_eq!(preview.title.as_deref(), Some("real"));215
+ assert!(preview.image.is_some() && registration.is_none());216
+ }217
+}@@ -10,6 +10,7 @@ use ruma::events::{
1010
sticker::StickerEventContent,1111
};1212
13
+use super::link_preview::sender_previews;1314
use crate::{MediaDescriptor, MediaId, MediaKind};1415
1516
static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);@@ -54,20 +55,30 @@ pub(crate) struct MediaSourceInfo {
5455
pub(crate) declared_size: Option<u64>,5556
}5657
57
-pub(super) fn media_registration_for_item(58
+pub(super) fn media_registrations_for_item(5859
item: &SdkTimelineItem,5960
scope: &MediaScope,60
-) -> Option<MediaRegistration> {61
- let event = item.as_event()?;61
+) -> Vec<MediaRegistration> {62
+ let Some(event) = item.as_event() else {63
+ return Vec::new();64
+ };6265
let identity = media_identity(item, scope);63
- let content = match event.content() {64
- TimelineItemContent::MsgLike(content) => content,65
- _ => return None,66
+ let TimelineItemContent::MsgLike(content) = event.content() else {67
+ return Vec::new();6668
};6769
match &content.kind {68
- MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype()),69
- MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content()),70
- _ => None,70
+ MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype())71
+ .into_iter()72
+ .chain(73
+ sender_previews(Some(&identity), message.msgtype())74
+ .into_iter()75
+ .filter_map(|(_, registration)| registration),76
+ )77
+ .collect(),78
+ MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content())79
+ .into_iter()80
+ .collect(),81
+ _ => Vec::new(),7182
}7283
}7384
@@ -1,10 +1,12 @@
11
mod image_pack;2
+mod link_preview;23
mod media;34
mod member;45
mod room;56
mod timeline;67
78
pub(crate) use image_pack::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, image_packs};9
+pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};810
pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};911
pub(crate) use member::member_summary;1012
pub(crate) use room::notification_mode;@@ -24,9 +24,10 @@ use crate::{
2424
TimelineEvent, TimelineItem, TimelineItemId, UtdCause,2525
};2626
27
+use super::link_preview::sender_previews;2728
use super::media::{2829
MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,29
- media_identity, media_registration_for_item,30
+ media_identity, media_registrations_for_item,3031
};3132
3233
#[derive(Default)]@@ -52,9 +53,8 @@ struct Normaliser<'a> {
5253
5354
impl Normaliser<'_> {5455
fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {55
- if let Some(media) = media_registration_for_item(item, self.scope) {56
- self.media.push(media);57
- }56
+ self.media57
+ .extend(media_registrations_for_item(item, self.scope));5858
match item.kind() {5959
TimelineItemKind::Event(event) => {6060
TimelineItem::Event(Box::new(self.event(item, event)))@@ -282,6 +282,10 @@ fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineC
282282
edited: message.is_edited(),283283
media: media_identity284284
.and_then(|identity| media_from_message(identity, message.msgtype())),285
+ link_previews: sender_previews(media_identity, message.msgtype())286
+ .into_iter()287
+ .map(|(preview, _)| preview)288
+ .collect(),285289
}),286290
MsgLikeKind::Sticker(sticker) => {287291
let sticker = sticker.content();@@ -28,16 +28,16 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828
use crate::{2929
AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,3030
EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePacks,31
- InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId,32
- MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,33
- NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,34
- PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,31
+ InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media, MediaCommand,32
+ MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,33
+ MessageEditContent, NotificationCommand, OwnDevice, PowerLevelChanges, Profile,34
+ PushNotification, PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,3535
RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,3636
TimelineCommand, TimelineId, VerificationAction, VerificationTarget,3737
encryption::encryption_state,3838
engine::{MediaService, StoreLock},3939
model::{PushNotificationRoom, PushNotificationSender},40
- normalise::{image_packs, member_summary, room_summaries, space_child},40
+ normalise::{image_packs, member_summary, room_summaries, space_child, url_previews_allowed},4141
};4242
4343
#[derive(Clone)]@@ -588,6 +588,17 @@ impl ClientHandle {
588588
image_packs(&self.client).await589589
}590590
591
+ /// the homeserver's preview of a page, `None` when it has none. the homeserver sees every592
+ /// url asked about, so a host decides whether encrypted rooms ask at all593
+ pub async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {594
+ self.media.url_preview(url).await595
+ }596
+597
+ /// whether the room and the user's settings let link previews show here598
+ pub async fn url_previews_allowed(&self, room_id: &RoomId) -> Result<bool, Error> {599
+ url_previews_allowed(&self.room(room_id)?).await600
+ }601
+591602
/// browse a space the way the server sees it, rooms you haven't joined included592603
pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {593604
SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)@@ -992,3 +992,81 @@ fn plain_audio() -> RoomMessageEventContent {
992992
mxc_uri!("mxc://localhost/audio").to_owned(),993993
)))994994
}995
+996
+#[tokio::test]997
+async fn url_previews_are_cached_and_say_why_they_failed() {998
+ let mut harness = start(plain_image()).await;999
+ let preview_url = || path_regex(r"/media(/v3)?/preview_url$");1000
+ let answer = |status: u16, body: Value| {1001
+ Mock::given(method("GET"))1002
+ .and(preview_url())1003
+ .and(wiremock::matchers::query_param(1004
+ "url",1005
+ "https://example.org/page",1006
+ ))1007
+ .respond_with(ResponseTemplate::new(status).set_body_json(body))1008
+ };1009
+ answer(1010
+ 200,1011
+ json!({1012
+ "og:title": " a page ",1013
+ "og:image": "mxc://example.org/image",1014
+ "og:image:width": "640",1015
+ "og:description": "x".repeat(5000),1016
+ }),1017
+ )1018
+ .expect(1)1019
+ .mount(harness.server.server())1020
+ .await;1021
+1022
+ for _ in 0..2 {1023
+ let preview = harness1024
+ .handle1025
+ .url_preview("https://example.org/page")1026
+ .await1027
+ .unwrap()1028
+ .expect("a page with a title has a preview");1029
+ assert_eq!(preview.title.as_deref(), Some("a page"));1030
+ assert_eq!(1031
+ preview.description.map(|text| text.chars().count()),1032
+ Some(1024)1033
+ );1034
+ let image = preview.image.expect("an mxc og:image is kept");1035
+ assert_eq!(image.width, Some(640));1036
+ }1037
+1038
+ let failing = |status: u16, errcode: &str| {1039
+ Mock::given(method("GET"))1040
+ .and(preview_url())1041
+ .and(wiremock::matchers::query_param(1042
+ "url",1043
+ format!("https://example.org/{status}"),1044
+ ))1045
+ .respond_with(1046
+ ResponseTemplate::new(status)1047
+ .set_body_json(json!({ "errcode": errcode, "error": "no" })),1048
+ )1049
+ };1050
+ failing(502, "M_UNKNOWN")1051
+ .mount(harness.server.server())1052
+ .await;1053
+ failing(429, "M_LIMIT_EXCEEDED")1054
+ .mount(harness.server.server())1055
+ .await;1056
+ assert_eq!(1057
+ harness1058
+ .handle1059
+ .url_preview("https://example.org/502")1060
+ .await1061
+ .unwrap(),1062
+ None1063
+ );1064
+ let limited = harness1065
+ .handle1066
+ .url_preview("https://example.org/429")1067
+ .await1068
+ .unwrap_err();1069
+ assert_eq!(limited.kind, ErrorKind::RateLimited);1070
+1071
+ support::shut_down(&harness.handle, &mut harness.events, harness.running).await;1072
+}