atagen

macltok

public
Star0
Watch0

Commit 0291516d40b5

tarball

atagen <boss@atagen.co>2026-10-22 09:31 UTC

14 files changed+606-37
link_previews: show senders' msc4095 previews and ask the homeserver for the rest

Sender-attached previews need no request, so they work in encrypted rooms without telling the homeserver every url, but they're the sender's word, so a preview for a url the body doesn't contain is dropped. The homeserver's preview_url is rate limited and timelines repeat links, so answers and misses are cached and concurrent asks share one request. A 429 now classifies as ErrorKind::RateLimited everywhere, not just here.
Modified file: Cargo.toml+1 additions−1 deletions
@@ -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"
Modified file: docs/design-decisions.md+31 additions−1 deletions
@@ -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 inside
229229 the refresh, before the new token is used, and `save_blocking` writes there and
230230 then. in a browser there's only the async save, so a tab that dies between a
231-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 the
232+sdk await an async save before it uses the new token would close that, but it
233+changes how a refresh behaves rather than what's visible, and on 2026-10-22 we
234+held off so the fork doesn't drift that far that fast. upstream taking an async
235+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 with
233238 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 in
330335 the summaries
331336
337+## link previews
338+
339+a text message can carry its sender's own previews (msc4095), and those come
340+out on `Message::link_previews` without asking anyone, so they work in
341+encrypted rooms and leak nothing. an encrypted preview image is registered like
342+an attachment. a preview whose url isn't in the body is dropped, or a sender
343+could caption any link with any card
344+
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, so
347+whether encrypted rooms ask is the host's call. answers are kept an hour and
348+misses ten minutes, and asks for one url at once share a request, since synapse
349+rate limits the endpoint and a timeline repeats links. synapse answers a page
350+it couldn't download with a 500, a dns failure with a 502 and its blocklist with
351+a 403, so those, 400 and 404 mean no preview. a genuine 500 hides in there too,
352+but a missing card is cheap. a
353+server that doesn't preview at all says so with `M_UNRECOGNIZED` once and isn't
354+asked again. a 429 anywhere is `ErrorKind::RateLimited`. page text from either
355+source is trimmed, 256 characters for a title or site name and 1024 for a
356+description, and an image that isn't an mxc uri is dropped
357+
358+`url_previews_allowed` follows element, so both agree. the user's own choice
359+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 state
361+
332362 ## image packs
333363
334364 msc2545 packs come from the user's account data, from room state, and from the
Modified file: src/engine/media.rs+9 additions−3 deletions
@@ -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).await
73+ }
74+
6975 pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {
7076 if !media.is_empty() {
7177 self.registry.lock().await.insert_all(media);
Modified file: src/engine/mod.rs+6 additions−2 deletions
@@ -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 .await
316320 .map_err(EngineError::SyncService)?,
Added file: src/engine/url_preview.rs+166 additions
@@ -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 are
35+/// kept a while, misses too, and asks for one url at once share one request
36+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 again
41+ 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+ lookup
83+ }
84+ }
85+ };
86+ lookup.await
87+ }
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 for
108+ // 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 minutes
110+ 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+ None
117+ }
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_BUILDER
127+ .is_supported(&versions)
128+ {
129+ self.client
130+ .send(authenticated_media::get_media_preview::v1::Request::new(
131+ url.to_owned(),
132+ ))
133+ .await?
134+ .data
135+ } else {
136+ #[allow(deprecated)]
137+ let request = media::get_media_preview::v3::Request::new(url.to_owned());
138+ self.client.send(request).await?.data
139+ };
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 self
153+ .entries
154+ .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+}
Modified file: src/error.rs+9 additions
@@ -63,6 +63,8 @@ pub enum ErrorKind {
6363 NoCrossSigningIdentity,
6464 RecoveryRejected,
6565 Network,
66+ /// the homeserver wants us to slow down. try again later
67+ 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 source
276+ .as_client_api_error()
277+ .is_some_and(|api| api.status_code.as_u16() == 429) =>
278+ {
279+ ErrorKind::RateLimited
280+ }
272281 HttpError::Api(_) => ErrorKind::Server,
273282 HttpError::RefreshToken(source) => classify_refresh_token_error(source),
274283 HttpError::Cached(source) => classify_http_error(source),
Modified file: src/lib.rs+13 additions−12 deletions
@@ -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};
Modified file: src/model.rs+30 additions
@@ -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 dropped
1499+ pub link_previews: Vec<LinkPreview>,
1500+}
1501+
1502+/// a link card, from the sender's own message or from `ClientHandle::url_preview`. page
1503+/// text passes through whoever made it unfiltered, so it arrives trimmed to a sane length
1504+#[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 about
1509+ pub url: String,
1510+ /// where the page says it lives, when that's somewhere else
1511+ 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)]
Added file: src/normalise/link_preview.rs+217 additions
@@ -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 name
18+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 needs
25+/// when it's encrypted. embedded reply content has no media identity, so its encrypted images
26+/// go without
27+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_previews
35+ .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 card
43+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` when
95+/// nothing in it is worth a card
96+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 own
121+/// default, then the room admins'. encrypted rooms are the host's call
122+pub(crate) async fn url_previews_allowed(room: &Room) -> Result<bool, Error> {
123+ let room_choice = room
124+ .account_data(RoomAccountDataEventType::from(ROOM_PREVIEW_URLS))
125+ .await
126+ .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+ .await
133+ .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+ .await
139+ .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 strings
166+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+}
Modified file: src/normalise/media.rs+20 additions−9 deletions
@@ -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
Modified file: src/normalise/mod.rs+2 additions
@@ -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;
Modified file: src/normalise/timeline.rs+8 additions−4 deletions
@@ -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.media
57+ .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_identity
284284 .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();
Modified file: src/transport.rs+16 additions−5 deletions
@@ -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).await
589589 }
590590
591+ /// the homeserver's preview of a page, `None` when it has none. the homeserver sees every
592+ /// url asked about, so a host decides whether encrypted rooms ask at all
593+ pub async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {
594+ self.media.url_preview(url).await
595+ }
596+
597+ /// whether the room and the user's settings let link previews show here
598+ pub async fn url_previews_allowed(&self, room_id: &RoomId) -> Result<bool, Error> {
599+ url_previews_allowed(&self.room(room_id)?).await
600+ }
601+
591602 /// browse a space the way the server sees it, rooms you haven't joined included
592603 pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {
593604 SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)
Modified file: tests/media.rs+78 additions
@@ -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 = harness
1024+ .handle
1025+ .url_preview("https://example.org/page")
1026+ .await
1027+ .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+ harness
1058+ .handle
1059+ .url_preview("https://example.org/502")
1060+ .await
1061+ .unwrap(),
1062+ None
1063+ );
1064+ let limited = harness
1065+ .handle
1066+ .url_preview("https://example.org/429")
1067+ .await
1068+ .unwrap_err();
1069+ assert_eq!(limited.kind, ErrorKind::RateLimited);
1070+
1071+ support::shut_down(&harness.handle, &mut harness.events, harness.running).await;
1072+}