1use std::{
9 net::IpAddr,
10 time::{Duration, SystemTime},
11};
12
13#[cfg(feature = "url_preview")]
14use reqwest::header::CONTENT_DISPOSITION;
15use reqwest::header::{CONTENT_TYPE, COOKIE, HeaderValue, USER_AGENT};
16#[cfg(feature = "url_preview")]
17use ruma::Mxc;
18use serde::{Deserialize, Serialize};
19use tuwunel_core::{
20 Config, Err, Result, debug, err, implement, smallstr::SmallString,
21 utils::time::timepoint_from_now,
22};
23#[cfg(feature = "url_preview")]
24use tuwunel_core::{debug_warn, utils::random_string};
25#[cfg(feature = "url_preview")]
26use tuwunel_database::Txn;
27use url::{Host, Url};
28#[cfg(feature = "url_preview")]
29use webpage::OpengraphObject;
30
31#[cfg(feature = "url_preview")]
32use super::MXC_LENGTH;
33use super::Service;
34#[cfg(feature = "url_preview")]
35use crate::client::read_response_capped;
36
37type MediaType = SmallString<[u8; 32]>;
39
40#[derive(Debug, Default, Deserialize, Serialize)]
41pub struct UrlPreviewData {
42 #[serde(
43 default,
44 skip_serializing_if = "Option::is_none",
45 rename = "og:title"
46 )]
47 pub title: Option<String>,
48 #[serde(
49 default,
50 skip_serializing_if = "Option::is_none",
51 rename = "og:description"
52 )]
53 pub description: Option<String>,
54 #[serde(
55 default,
56 skip_serializing_if = "Option::is_none",
57 rename = "og:image"
58 )]
59 pub image: Option<String>,
60 #[serde(
61 default,
62 skip_serializing_if = "Option::is_none",
63 rename = "matrix:image:size"
64 )]
65 pub image_size: Option<usize>,
66 #[serde(
67 default,
68 skip_serializing_if = "Option::is_none",
69 rename = "og:image:width"
70 )]
71 pub image_width: Option<u32>,
72 #[serde(
73 default,
74 skip_serializing_if = "Option::is_none",
75 rename = "og:image:height"
76 )]
77 pub image_height: Option<u32>,
78 #[serde(
79 default,
80 skip_serializing_if = "Option::is_none",
81 rename = "og:video"
82 )]
83 pub video: Option<String>,
84 #[serde(
85 default,
86 skip_serializing_if = "Option::is_none",
87 rename = "og:video:type"
88 )]
89 pub video_type: Option<MediaType>,
90 #[serde(
91 default,
92 skip_serializing_if = "Option::is_none",
93 rename = "matrix:video:size"
94 )]
95 pub video_size: Option<usize>,
96 #[serde(
97 default,
98 skip_serializing_if = "Option::is_none",
99 rename = "og:video:width"
100 )]
101 pub video_width: Option<u32>,
102 #[serde(
103 default,
104 skip_serializing_if = "Option::is_none",
105 rename = "og:video:height"
106 )]
107 pub video_height: Option<u32>,
108 #[serde(
109 default,
110 skip_serializing_if = "Option::is_none",
111 rename = "og:audio"
112 )]
113 pub audio: Option<String>,
114 #[serde(
115 default,
116 skip_serializing_if = "Option::is_none",
117 rename = "matrix:audio:size"
118 )]
119 pub audio_size: Option<usize>,
120 #[serde(
121 default,
122 skip_serializing_if = "Option::is_none",
123 rename = "og:type"
124 )]
125 pub og_type: Option<String>,
126 #[serde(
127 default,
128 skip_serializing_if = "Option::is_none",
129 rename = "og:url"
130 )]
131 pub og_url: Option<String>,
132}
133
134#[derive(Debug, Deserialize, Serialize)]
135pub(super) struct CachedPreview {
136 pub(super) preview: UrlPreviewData,
137 pub(super) expire: SystemTime,
138}
139
140impl CachedPreview {
141 const EXPIRE: Duration = Duration::from_hours(24);
143
144 fn new(preview: UrlPreviewData) -> Self {
145 let expire = timepoint_from_now(Self::EXPIRE).expect("1 day from now is representable");
146
147 Self { preview, expire }
148 }
149
150 #[inline]
151 #[must_use]
152 pub(super) fn valid(&self) -> bool { self.expire > SystemTime::now() }
153}
154
155#[derive(Clone, Copy)]
160pub(super) enum Agent {
161 Page,
162 Media,
163}
164
165const YOUTUBE_HOSTS: [&str; 5] = [
168 "youtu.be",
169 "youtube.com",
170 "www.youtube.com",
171 "m.youtube.com",
172 "music.youtube.com",
173];
174
175const YOUTUBE_CONSENT_COOKIE: &str = "SOCS=CAI; CONSENT=PENDING+999";
181
182#[cfg(feature = "url_preview")]
185const YOUTUBE_OEMBED: &str = "https://www.youtube.com/oembed";
186
187#[cfg(feature = "url_preview")]
190const OEMBED_MAX_SIZE: usize = 64 * 1024;
191
192#[cfg(feature = "url_preview")]
197#[derive(Deserialize)]
198struct Oembed {
199 #[serde(rename = "type")]
200 kind: Option<String>,
201 title: Option<String>,
202 author_name: Option<String>,
203 thumbnail_url: Option<String>,
204}
205
206#[implement(Service)]
207pub async fn get_url_preview(&self, url: &Url) -> Result<UrlPreviewData> {
208 if let Ok(cached) = self.db.get_url_preview(url.as_str()).await {
209 return Ok(cached.preview);
210 }
211
212 let _request_lock = self.url_preview_mutex.lock(url.as_str()).await;
214
215 match self.db.get_url_preview(url.as_str()).await {
216 | Ok(cached) => Ok(cached.preview),
217 | Err(_) => self.request_url_preview(url).await,
218 }
219}
220
221#[implement(Service)]
222pub async fn request_url_preview(&self, url: &Url) -> Result<UrlPreviewData> {
223 self.check_url_host(url)?;
224
225 let response = self.preview_get(url, Agent::Page).send().await?;
226
227 debug!(?url, "URL preview response headers: {:?}", response.headers());
228
229 self.check_remote_addr(&response)?;
230
231 let status = response.status();
237 let (response, via_media_client) = if status.is_success() {
238 (response, false)
239 } else if self
240 .services
241 .config
242 .url_preview_media_user_agent
243 .is_some()
244 {
245 (self.media_response(url).await?, true)
246 } else {
247 return Err!(Request(NotFound(debug_warn!(
248 ?status,
249 %url,
250 "URL preview request failed"
251 ))));
252 };
253
254 let content_type = response
255 .headers()
256 .get(CONTENT_TYPE)
257 .ok_or_else(|| err!(Request(Unknown("Missing Content-Type header"))))?
258 .to_str()
259 .map_err(|e| err!(Request(Unknown("Invalid Content-Type header: {e}"))))?
260 .to_owned();
261
262 let data = match content_type.as_str() {
263 | html if html.starts_with("text/html") => {
264 if via_media_client {
267 return Err!(Request(NotFound(debug_warn!(
268 ?status,
269 %url,
270 "URL preview request failed"
271 ))));
272 }
273
274 let data = self.download_html(url, response).await?;
275
276 self.oembed_recover(url, data).await
277 },
278 | img if img.starts_with("image/") => {
279 let response = self
280 .media_refetch(url, response, via_media_client)
281 .await?;
282
283 require_media_type(&response, "image/")?;
284 self.download_image(response).await?
285 },
286 | video if video.starts_with("video/") => {
287 let response = self
288 .media_refetch(url, response, via_media_client)
289 .await?;
290
291 require_media_type(&response, "video/")?;
292 self.download_video(response).await?
293 },
294 | audio if audio.starts_with("audio/") => {
295 let response = self
296 .media_refetch(url, response, via_media_client)
297 .await?;
298
299 require_media_type(&response, "audio/")?;
300 self.download_audio(response).await?
301 },
302 | _ => return Err!(Request(Unknown("Unsupported Content-Type"))),
303 };
304
305 let cached = CachedPreview::new(data);
306 self.db.set_url_preview(url.as_str(), &cached)?;
307
308 Ok(cached.preview)
309}
310
311#[implement(Service)]
314fn preview_get(&self, url: &Url, agent: Agent) -> reqwest::RequestBuilder {
315 let request = self.services.client.url_preview.get(url.as_str());
316
317 self.preview_headers(request, url, agent)
318}
319
320#[implement(Service)]
328pub(super) fn preview_headers(
329 &self,
330 request: reqwest::RequestBuilder,
331 url: &Url,
332 agent: Agent,
333) -> reqwest::RequestBuilder {
334 let config: &Config = &self.services.config;
335 let user_agent = match agent {
336 | Agent::Page => config.url_preview_user_agent.as_deref(),
337 | Agent::Media => config
338 .url_preview_media_user_agent
339 .as_deref()
340 .or(config.url_preview_user_agent.as_deref()),
341 };
342
343 let request = match user_agent {
344 | Some(user_agent) => request.header(USER_AGENT, user_agent),
345 | None => request,
346 };
347
348 match is_youtube(url) {
351 | true => request.header(COOKIE, HeaderValue::from_static(YOUTUBE_CONSENT_COOKIE)),
352 | false => request,
353 }
354}
355
356#[must_use]
357fn is_youtube(url: &Url) -> bool {
358 url.host_str()
359 .is_some_and(|host| YOUTUBE_HOSTS.contains(&host))
360}
361
362#[implement(Service)]
366fn check_remote_addr(&self, response: &reqwest::Response) -> Result {
367 let Some(remote_addr) = response.remote_addr() else {
368 return Err!(Request(Forbidden("URL preview response has no peer address")));
369 };
370
371 debug!(url = %response.url(), ?remote_addr, "URL preview response remote address");
372
373 self.services
374 .client
375 .valid_cidr_range_remote_addr(response.url(), remote_addr)
376 .then_some(())
377 .ok_or_else(|| err!(Request(Forbidden("Requesting from this address is forbidden"))))
378}
379
380#[cfg(feature = "url_preview")]
388#[implement(Service)]
389async fn oembed_recover(&self, url: &Url, data: UrlPreviewData) -> UrlPreviewData {
390 if data.title.is_some() || data.image.is_some() {
392 return data;
393 }
394
395 let Some(endpoint) = oembed_endpoint(url) else {
396 return data;
397 };
398
399 self.oembed_preview(&endpoint, url)
400 .await
401 .inspect_err(|e| debug!(%url, %e, "oEmbed recovery failed"))
402 .unwrap_or(data)
403}
404
405#[cfg(not(feature = "url_preview"))]
406#[implement(Service)]
407#[expect(clippy::unused_async)]
408async fn oembed_recover(&self, _url: &Url, data: UrlPreviewData) -> UrlPreviewData { data }
409
410#[cfg(feature = "url_preview")]
412fn oembed_endpoint(url: &Url) -> Option<Url> {
413 is_youtube(url)
414 .then(|| {
415 Url::parse_with_params(YOUTUBE_OEMBED, [("url", url.as_str()), ("format", "json")])
416 })
417 .and_then(Result::ok)
418}
419
420#[cfg(feature = "url_preview")]
425#[implement(Service)]
426async fn oembed_preview(&self, endpoint: &Url, page: &Url) -> Result<UrlPreviewData> {
427 if !self.url_preview_allowed(endpoint) {
430 return Err!(Request(Forbidden(debug_warn!(
431 %endpoint,
432 "oEmbed endpoint is not allowed for previewing"
433 ))));
434 }
435
436 self.check_url_host(endpoint)?;
437
438 let response = self
439 .preview_get(endpoint, Agent::Page)
440 .send()
441 .await?;
442
443 self.check_remote_addr(&response)?;
444
445 let status = response.status();
446
447 if !status.is_success() {
448 return Err!(Request(NotFound(debug_warn!(
449 ?status,
450 %endpoint,
451 "oEmbed request failed"
452 ))));
453 }
454
455 let body = read_response_capped(response, OEMBED_MAX_SIZE).await?;
456 let oembed: Oembed = serde_json::from_slice(&body)
457 .map_err(|e| err!(Request(Unknown("Invalid oEmbed document: {e}"))))?;
458
459 let image = self
462 .oembed_image(oembed.thumbnail_url.as_deref())
463 .await;
464
465 Ok(UrlPreviewData {
466 title: oembed.title,
467 description: oembed.author_name,
468 video_type: video_type(oembed.kind.as_deref()).map(Into::into),
469 og_type: og_type(oembed.kind.as_deref()),
470 og_url: Some(page.as_str().to_owned()),
471 ..image
472 })
473}
474
475#[cfg(feature = "url_preview")]
481fn video_type(kind: Option<&str>) -> Option<&'static str> {
482 kind.eq(&Some("video")).then_some("text/html")
483}
484
485#[cfg(feature = "url_preview")]
492fn og_type(kind: Option<&str>) -> Option<String> {
493 kind.map(|kind| match kind {
494 | "video" => "video.other",
495 | _ => "website",
496 })
497 .map(ToOwned::to_owned)
498}
499
500#[cfg(feature = "url_preview")]
506#[implement(Service)]
507async fn oembed_image(&self, thumbnail_url: Option<&str>) -> UrlPreviewData {
508 let Some(thumbnail) = thumbnail_url
509 .and_then(|thumbnail| Url::parse(thumbnail).ok())
510 .filter(|thumbnail| ["http", "https"].contains(&thumbnail.scheme()))
511 else {
512 return UrlPreviewData::default();
513 };
514
515 self.preview_image(&thumbnail)
516 .await
517 .unwrap_or_default()
518}
519
520#[cfg(feature = "url_preview")]
527#[implement(Service)]
528async fn preview_image(&self, image_url: &Url) -> Result<UrlPreviewData> {
529 self.check_url_host(image_url)?;
530
531 let response = self
532 .preview_get(image_url, Agent::Media)
533 .send()
534 .await?;
535
536 self.check_remote_addr(&response)?;
537
538 if !response.status().is_success() {
541 debug!(
542 %image_url,
543 status = ?response.status(),
544 "Skipping preview image with unsuccessful response"
545 );
546
547 return Ok(UrlPreviewData::default());
548 }
549
550 self.download_image(response).await
551}
552
553#[cfg(feature = "url_preview")]
558#[implement(Service)]
559pub async fn download_image(&self, response: reqwest::Response) -> Result<UrlPreviewData> {
560 use image::ImageReader;
561
562 let url = response.url().clone();
565 let content_type = response
566 .headers()
567 .get(CONTENT_TYPE)
568 .and_then(|value| value.to_str().ok())
569 .map(ToOwned::to_owned);
570
571 let content_disposition = response
572 .headers()
573 .get(CONTENT_DISPOSITION)
574 .and_then(|value| value.to_str().ok())
575 .map(ToOwned::to_owned);
576
577 let limit = self.services.config.url_preview_max_media_size;
578 let image = read_response_capped(response, limit).await?;
579
580 let cursor = std::io::Cursor::new(&image);
581 let (width, height) = match ImageReader::new(cursor).with_guessed_format() {
582 | Err(_) => (None, None),
583 | Ok(reader) => match reader.into_dimensions() {
584 | Err(_) => (None, None),
585 | Ok((width, height)) => (Some(width), Some(height)),
586 },
587 };
588
589 let mut txn = self.services.db.txn();
590 let mxc = self.queue_lazy_media(&mut txn, url.as_str());
591
592 self.db.set_lazy_content(
593 &mut txn,
594 &mxc,
595 content_type.as_deref(),
596 content_disposition.as_deref(),
597 &image,
598 );
599
600 txn.execute();
601
602 Ok(UrlPreviewData {
603 image: Some(mxc),
604 image_size: Some(image.len()),
605 image_width: width,
606 image_height: height,
607 ..Default::default()
608 })
609}
610
611#[cfg(not(feature = "url_preview"))]
616#[implement(Service)]
617#[expect(clippy::unused_async)]
618pub async fn download_image(&self, _response: reqwest::Response) -> Result<UrlPreviewData> {
619 Err!(FeatureDisabled("url_preview"))
620}
621
622#[cfg(feature = "url_preview")]
627#[implement(Service)]
628async fn media_response(&self, url: &Url) -> Result<reqwest::Response> {
629 let response = self.preview_get(url, Agent::Media).send().await?;
630
631 self.check_remote_addr(&response)?;
632
633 if !response.status().is_success() {
634 return Err!(Request(NotFound(debug_warn!(
635 status = ?response.status(),
636 %url,
637 "URL preview media request failed"
638 ))));
639 }
640
641 Ok(response)
642}
643
644#[cfg(not(feature = "url_preview"))]
645#[implement(Service)]
646#[expect(clippy::unused_async)]
647async fn media_response(&self, _url: &Url) -> Result<reqwest::Response> {
648 Err!(FeatureDisabled("url_preview"))
649}
650
651#[implement(Service)]
656async fn media_refetch(
657 &self,
658 url: &Url,
659 response: reqwest::Response,
660 via_media_client: bool,
661) -> Result<reqwest::Response> {
662 if via_media_client
663 || self
664 .services
665 .config
666 .url_preview_media_user_agent
667 .is_none()
668 {
669 return Ok(response);
670 }
671
672 self.media_response(url).await
673}
674
675fn require_media_type(response: &reqwest::Response, class: &str) -> Result {
679 response
680 .headers()
681 .get(CONTENT_TYPE)
682 .and_then(|value| value.to_str().ok())
683 .is_some_and(|content_type| content_type.starts_with(class))
684 .then_some(())
685 .ok_or_else(|| err!(Request(Unknown("Unsupported Content-Type"))))
686}
687
688#[cfg(feature = "url_preview")]
692#[implement(Service)]
693fn register_lazy_media(&self, url: &str) -> String {
694 let mxc = self.mint_lazy_media();
695
696 self.db.insert_lazy_media(&mxc, url);
697
698 mxc
699}
700
701#[cfg(feature = "url_preview")]
702#[implement(Service)]
703fn queue_lazy_media(&self, txn: &mut Txn, url: &str) -> String {
704 let mxc = self.mint_lazy_media();
705
706 self.db.queue_lazy_media(txn, &mxc, url);
707
708 mxc
709}
710
711#[cfg(feature = "url_preview")]
712#[implement(Service)]
713fn mint_lazy_media(&self) -> String {
714 Mxc {
715 server_name: self.services.globals.server_name(),
716 media_id: &random_string(MXC_LENGTH),
717 }
718 .to_string()
719}
720
721#[cfg(feature = "url_preview")]
722#[implement(Service)]
723#[expect(clippy::unused_async)]
724pub async fn download_video(&self, response: reqwest::Response) -> Result<UrlPreviewData> {
725 let video_size =
726 checked_media_size(&response, self.services.config.url_preview_max_media_size)?;
727
728 Ok(UrlPreviewData {
729 video: Some(self.register_lazy_media(response.url().as_str())),
730 video_size,
731 ..Default::default()
732 })
733}
734
735#[cfg(not(feature = "url_preview"))]
736#[implement(Service)]
737#[expect(clippy::unused_async)]
738pub async fn download_video(&self, _response: reqwest::Response) -> Result<UrlPreviewData> {
739 Err!(FeatureDisabled("url_preview"))
740}
741
742#[cfg(feature = "url_preview")]
743#[implement(Service)]
744#[expect(clippy::unused_async)]
745pub async fn download_audio(&self, response: reqwest::Response) -> Result<UrlPreviewData> {
746 let audio_size =
747 checked_media_size(&response, self.services.config.url_preview_max_media_size)?;
748
749 Ok(UrlPreviewData {
750 audio: Some(self.register_lazy_media(response.url().as_str())),
751 audio_size,
752 ..Default::default()
753 })
754}
755
756#[cfg(not(feature = "url_preview"))]
757#[implement(Service)]
758#[expect(clippy::unused_async)]
759pub async fn download_audio(&self, _response: reqwest::Response) -> Result<UrlPreviewData> {
760 Err!(FeatureDisabled("url_preview"))
761}
762
763#[cfg(feature = "url_preview")]
766fn checked_media_size(response: &reqwest::Response, limit: usize) -> Result<Option<usize>> {
767 let size = response
768 .content_length()
769 .and_then(|len| usize::try_from(len).ok());
770
771 if size.is_some_and(|size| size > limit) {
772 return Err!(Request(TooLarge("Media exceeds url_preview_max_media_size")));
773 }
774
775 Ok(size)
776}
777
778#[cfg(feature = "url_preview")]
779#[implement(Service)]
780async fn download_html(&self, url: &Url, response: reqwest::Response) -> Result<UrlPreviewData> {
781 use webpage::HTML;
782
783 let limit = self.services.config.url_preview_max_spider_size;
784 let (bytes, truncated) = spider_body(response, limit).await?;
785
786 let body = String::from_utf8(bytes)
789 .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
790
791 let Ok(html) = HTML::from_string(body, Some(url.as_str().to_owned())) else {
792 return Err!(Request(Unknown("Failed to parse HTML")));
793 };
794
795 let twitter = |key| {
798 html.meta
799 .get(key)
800 .map(String::as_str)
801 .filter(|content| !content.is_empty())
802 };
803
804 let image_url = html
807 .opengraph
808 .images
809 .first()
810 .map(|obj| obj.url.as_str())
811 .filter(|image| !image.is_empty())
812 .or_else(|| twitter("twitter:image"))
813 .or_else(|| twitter("twitter:image:src"))
814 .map(|image| url.join(image))
815 .transpose()
816 .map_err(|e| err!(Request(Unknown("Invalid preview image URL: {e}"))))?
817 .filter(|image_url| ["http", "https"].contains(&image_url.scheme()));
818
819 let mut data = match image_url {
820 | None => UrlPreviewData::default(),
821 | Some(image_url) => self.preview_image(&image_url).await?,
822 };
823
824 if let Some(obj) = html.opengraph.videos.first()
825 && !obj.url.is_empty()
826 {
827 data.video_type = obj
829 .properties
830 .get("type")
831 .map(String::as_str)
832 .map(Into::into);
833
834 data.video_width = obj
835 .properties
836 .get("width")
837 .and_then(|w| w.parse().ok());
838
839 data.video_height = obj
840 .properties
841 .get("height")
842 .and_then(|h| h.parse().ok());
843
844 data.video = self.lazy_media(url, obj, "video/");
845 }
846
847 if let Some(obj) = html.opengraph.audios.first()
848 && !obj.url.is_empty()
849 {
850 data.audio = self.lazy_media(url, obj, "audio/");
851 }
852
853 let props = html.opengraph.properties;
854
855 data.title = props
856 .get("title")
857 .cloned()
858 .filter(|title| !title.is_empty())
859 .or_else(|| twitter("twitter:title").map(ToOwned::to_owned))
860 .or(html.title);
861
862 data.description = props
863 .get("description")
864 .cloned()
865 .filter(|description| !description.is_empty())
866 .or_else(|| twitter("twitter:description").map(ToOwned::to_owned))
867 .or(html.description);
868
869 data.og_type = Some(html.opengraph.og_type);
870 data.og_url = props.get("url").cloned();
871
872 if truncated && data.title.is_none() && data.description.is_none() && data.image.is_none() {
875 debug_warn!(
876 %url,
877 %limit,
878 "Preview page was truncated before any metadata was found; a larger \
879 url_preview_max_spider_size or a different url_preview_user_agent may be needed"
880 );
881 }
882
883 Ok(data)
884}
885
886#[cfg(not(feature = "url_preview"))]
887#[implement(Service)]
888#[expect(clippy::unused_async)]
889async fn download_html(
890 &self,
891 _url: &Url,
892 _response: reqwest::Response,
893) -> Result<UrlPreviewData> {
894 Err!(FeatureDisabled("url_preview"))
895}
896
897#[cfg(feature = "url_preview")]
903async fn spider_body(mut response: reqwest::Response, limit: usize) -> Result<(Vec<u8>, bool)> {
904 let hint = response
905 .content_length()
906 .and_then(|len| usize::try_from(len).ok())
907 .map_or(0, |len| len.min(limit));
908
909 let mut bytes: Vec<u8> = Vec::with_capacity(hint);
910
911 while let Some(chunk) = response.chunk().await? {
912 let want = chunk.len().min(limit.saturating_sub(bytes.len()));
913
914 reserve_capped(&mut bytes, want, limit);
915 bytes.extend_from_slice(&chunk[..want]);
916
917 if want < chunk.len() {
918 return Ok((bytes, true));
919 }
920 }
921
922 Ok((bytes, false))
923}
924
925#[cfg(feature = "url_preview")]
931fn reserve_capped(bytes: &mut Vec<u8>, want: usize, limit: usize) {
932 let need = bytes.len().saturating_add(want);
933
934 if need <= bytes.capacity() {
935 return;
936 }
937
938 let target = bytes
939 .capacity()
940 .saturating_mul(2)
941 .clamp(need, limit.max(need));
942
943 bytes.reserve_exact(target.saturating_sub(bytes.len()));
944}
945
946#[cfg(feature = "url_preview")]
955#[implement(Service)]
956fn lazy_media(&self, page: &Url, obj: &OpengraphObject, class: &str) -> Option<String> {
957 declares_media_type(obj, class)
958 .then(|| page.join(&obj.url).ok())
959 .flatten()
960 .filter(|url| ["http", "https"].contains(&url.scheme()))
961 .filter(|url| self.check_url_host(url).is_ok())
962 .map(|url| self.register_lazy_media(url.as_str()))
963}
964
965#[cfg(feature = "url_preview")]
971fn declares_media_type(obj: &OpengraphObject, class: &str) -> bool {
972 obj.properties
973 .get("type")
974 .is_none_or(|kind| kind.starts_with(class))
975}
976
977#[implement(Service)]
978pub(super) fn check_url_host(&self, url: &Url) -> Result {
979 if self.services.client.proxy.resolver_alias(url) {
980 return Err!(Request(Forbidden(
981 "Requesting a locally resolved proxy endpoint is forbidden"
982 )));
983 }
984
985 let host = url
986 .host()
987 .ok_or_else(|| err!(Request(Unknown("URL has no host"))))?;
988
989 let ip = match host {
990 | Host::Domain(_) => return Ok(()),
991 | Host::Ipv4(v4) => IpAddr::V4(v4),
992 | Host::Ipv6(v6) => IpAddr::V6(v6),
993 };
994
995 if !self.services.client.valid_cidr_range_ip(ip) {
996 return Err!(Request(Forbidden("Requesting from this address is forbidden")));
997 }
998
999 Ok(())
1000}
1001
1002#[implement(Service)]
1003pub fn url_preview_allowed(&self, url: &Url) -> bool {
1004 if ["http", "https"]
1005 .iter()
1006 .all(|&scheme| !scheme.eq_ignore_ascii_case(url.scheme()))
1007 {
1008 debug!("Ignoring non-HTTP/HTTPS URL to preview: {}", url);
1009 return false;
1010 }
1011
1012 let host = match url.host_str() {
1013 | None => {
1014 debug!("Ignoring URL preview for a URL that does not have a host (?): {}", url);
1015 return false;
1016 },
1017 | Some(h) => h.to_owned(),
1018 };
1019
1020 let allowlist_domain_contains = &self
1021 .services
1022 .config
1023 .url_preview_domain_contains_allowlist;
1024 let allowlist_domain_explicit = &self
1025 .services
1026 .config
1027 .url_preview_domain_explicit_allowlist;
1028 let denylist_domain_explicit = &self
1029 .services
1030 .config
1031 .url_preview_domain_explicit_denylist;
1032 let allowlist_url_contains = &self
1033 .services
1034 .config
1035 .url_preview_url_contains_allowlist;
1036
1037 if allowlist_domain_contains.contains(&"*".to_owned())
1038 || allowlist_domain_explicit.contains(&"*".to_owned())
1039 || allowlist_url_contains.contains(&"*".to_owned())
1040 {
1041 debug!("Config key contains * which is allowing all URL previews. Allowing URL {}", url);
1042 return true;
1043 }
1044
1045 if !host.is_empty() {
1046 if denylist_domain_explicit.contains(&host) {
1047 debug!(
1048 "Host {} is not allowed by url_preview_domain_explicit_denylist (check 1/4)",
1049 &host
1050 );
1051 return false;
1052 }
1053
1054 if allowlist_domain_explicit.contains(&host) {
1055 debug!(
1056 "Host {} is allowed by url_preview_domain_explicit_allowlist (check 2/4)",
1057 &host
1058 );
1059 return true;
1060 }
1061
1062 if allowlist_domain_contains
1063 .iter()
1064 .any(|domain_s| domain_s.contains(&host.clone()))
1065 {
1066 debug!(
1067 "Host {} is allowed by url_preview_domain_contains_allowlist (check 3/4)",
1068 &host
1069 );
1070 return true;
1071 }
1072
1073 if allowlist_url_contains
1074 .iter()
1075 .any(|url_s| url.to_string().contains(url_s))
1076 {
1077 debug!("URL {} is allowed by url_preview_url_contains_allowlist (check 4/4)", &host);
1078 return true;
1079 }
1080
1081 if self.services.config.url_preview_check_root_domain {
1083 debug!("Checking root domain");
1084 match host.split_once('.') {
1085 | None => return false,
1086 | Some((_, root_domain)) => {
1087 if denylist_domain_explicit.contains(&root_domain.to_owned()) {
1088 debug!(
1089 "Root domain {} is not allowed by \
1090 url_preview_domain_explicit_denylist (check 1/3)",
1091 &root_domain
1092 );
1093 return false;
1094 }
1095
1096 if allowlist_domain_explicit.contains(&root_domain.to_owned()) {
1097 debug!(
1098 "Root domain {} is allowed by url_preview_domain_explicit_allowlist \
1099 (check 2/3)",
1100 &root_domain
1101 );
1102 return true;
1103 }
1104
1105 if allowlist_domain_contains
1106 .iter()
1107 .any(|domain_s| domain_s.contains(&root_domain.to_owned()))
1108 {
1109 debug!(
1110 "Root domain {} is allowed by url_preview_domain_contains_allowlist \
1111 (check 3/3)",
1112 &root_domain
1113 );
1114 return true;
1115 }
1116 },
1117 }
1118 }
1119 }
1120
1121 false
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use std::time::{Duration, SystemTime};
1127
1128 use minicbor_serde::{from_slice, to_vec};
1129 use url::Url;
1130
1131 use super::{CachedPreview, UrlPreviewData, is_youtube};
1132 #[cfg(feature = "url_preview")]
1133 use super::{oembed_endpoint, reserve_capped, video_type};
1134
1135 fn sample() -> UrlPreviewData {
1136 UrlPreviewData {
1137 title: Some("Title".to_owned()),
1138 description: Some("Description".to_owned()),
1139 image: Some("mxc://example.org/image".to_owned()),
1140 image_size: Some(0xFF01),
1142 image_width: Some(640),
1143 image_height: Some(0xFF),
1144 video: Some("mxc://example.org/video".to_owned()),
1145 video_type: Some("video/mp4".into()),
1146 video_size: Some(123_456),
1147 video_width: Some(1920),
1148 video_height: Some(1080),
1149 audio: Some("mxc://example.org/audio".to_owned()),
1150 audio_size: Some(4096),
1151 og_type: Some("website".to_owned()),
1152 og_url: Some("https://example.org/".to_owned()),
1153 }
1154 }
1155
1156 #[test]
1157 fn cached_preview_roundtrip() {
1158 let cached = CachedPreview::new(sample());
1159 let bytes = to_vec(&cached).expect("encodes");
1160 let decoded: CachedPreview = from_slice(&bytes).expect("decodes");
1161
1162 assert_eq!(
1163 serde_json::to_value(&decoded.preview).expect("json"),
1164 serde_json::to_value(&cached.preview).expect("json"),
1165 );
1166 assert_eq!(decoded.preview.image_size, Some(0xFF01));
1167 assert_eq!(decoded.preview.image_height, Some(0xFF));
1168 assert_eq!(decoded.expire, cached.expire);
1169 }
1170
1171 #[test]
1172 fn preview_wire_keys_unchanged() {
1173 let value = serde_json::to_value(sample()).expect("json");
1174 let object = value.as_object().expect("object");
1175
1176 assert!(object.contains_key("og:title"));
1177 assert!(object.contains_key("matrix:image:size"));
1178 assert!(object.contains_key("og:video:width"));
1179 assert!(object.contains_key("og:video:type"));
1180 assert!(object.contains_key("og:url"));
1181 assert!(!object.contains_key("title"));
1182
1183 let empty = serde_json::to_value(UrlPreviewData::default()).expect("json");
1184 assert!(empty.as_object().expect("object").is_empty());
1185 }
1186
1187 #[test]
1188 fn preview_cbor_missing_fields_default() {
1189 let sparse = UrlPreviewData {
1190 title: Some("Only a title".to_owned()),
1191 ..Default::default()
1192 };
1193
1194 let bytes = to_vec(&sparse).expect("encodes");
1195 let decoded: UrlPreviewData = from_slice(&bytes).expect("decodes");
1196
1197 assert_eq!(decoded.title.as_deref(), Some("Only a title"));
1198 assert!(decoded.description.is_none());
1199 assert!(decoded.image.is_none());
1200 assert!(decoded.og_url.is_none());
1201 }
1202
1203 #[test]
1204 fn preview_cbor_unknown_key_skipped() {
1205 #[derive(serde::Serialize)]
1206 struct Superset {
1207 #[serde(rename = "og:title")]
1208 title: &'static str,
1209 #[serde(rename = "og:unknown")]
1210 unknown: &'static str,
1211 }
1212
1213 let bytes = to_vec(Superset { title: "Kept", unknown: "Discarded" }).expect("encodes");
1214 let decoded: UrlPreviewData = from_slice(&bytes).expect("decodes");
1215
1216 assert_eq!(decoded.title.as_deref(), Some("Kept"));
1217 assert!(decoded.description.is_none());
1218 }
1219
1220 #[test]
1221 fn cached_preview_expiry() {
1222 let mut cached = CachedPreview::new(UrlPreviewData::default());
1223 assert!(cached.valid());
1224
1225 cached.expire = SystemTime::now() - Duration::from_secs(1);
1226 assert!(!cached.valid());
1227 }
1228
1229 #[test]
1230 fn youtube_hosts_matched() {
1231 let youtube = [
1232 "https://www.youtube.com/watch?v=abc",
1233 "https://youtu.be/abc",
1234 "https://music.youtube.com/watch?v=abc",
1235 "https://m.youtube.com/watch?v=abc",
1236 "https://youtube.com/watch?v=abc",
1237 "https://WWW.YOUTUBE.COM/watch?v=abc",
1238 ];
1239
1240 for url in youtube {
1241 assert!(is_youtube(&Url::parse(url).expect("parses")), "{url}");
1242 }
1243
1244 let other = [
1246 "https://youtube.com.evil.example/watch?v=abc",
1247 "https://notyoutube.com/watch?v=abc",
1248 "https://i.ytimg.com/vi/abc/hqdefault.jpg",
1249 "https://example.org/",
1250 ];
1251
1252 for url in other {
1253 assert!(!is_youtube(&Url::parse(url).expect("parses")), "{url}");
1254 }
1255 }
1256
1257 #[cfg(feature = "url_preview")]
1258 #[test]
1259 fn oembed_endpoint_carries_the_page_url() {
1260 let url = Url::parse("https://www.youtube.com/watch?v=a&b=c").expect("parses");
1261 let endpoint = oembed_endpoint(&url).expect("youtube has an endpoint");
1262
1263 assert_eq!(endpoint.path(), "/oembed");
1264
1265 let params: Vec<_> = endpoint.query_pairs().collect();
1266 assert_eq!(params, [
1267 ("url".into(), url.as_str().into()),
1268 ("format".into(), "json".into())
1269 ]);
1270
1271 assert!(oembed_endpoint(&Url::parse("https://example.org/").expect("parses")).is_none());
1272 }
1273
1274 #[cfg(feature = "url_preview")]
1275 #[test]
1276 fn oembed_video_declares_a_player() {
1277 assert_eq!(video_type(Some("video")), Some("text/html"));
1278
1279 for kind in [Some("photo"), Some("rich"), Some("link"), None] {
1280 assert!(video_type(kind).is_none(), "{kind:?}");
1281 }
1282 }
1283
1284 #[cfg(feature = "url_preview")]
1285 #[test]
1286 fn reserve_capped_never_exceeds_the_cap() {
1287 const LIMIT: usize = 768 * 1024;
1288
1289 let mut bytes: Vec<u8> = Vec::new();
1290 let chunk = vec![0_u8; 16 * 1024];
1291 let mut reallocs = 0;
1292
1293 while bytes.len() < LIMIT {
1294 let want = chunk.len().min(LIMIT.saturating_sub(bytes.len()));
1295 let before = bytes.capacity();
1296
1297 reserve_capped(&mut bytes, want, LIMIT);
1298 bytes.extend_from_slice(&chunk[..want]);
1299
1300 if bytes.capacity() != before {
1301 reallocs += 1;
1302 }
1303
1304 assert!(bytes.capacity() <= LIMIT, "capacity {} past cap", bytes.capacity());
1305 }
1306
1307 assert_eq!(bytes.len(), LIMIT);
1308
1309 assert!(reallocs < 12, "{reallocs} reallocations");
1311 }
1312
1313 #[cfg(feature = "url_preview")]
1314 #[test]
1315 fn reserve_capped_honors_an_unclamped_request() {
1316 let mut bytes: Vec<u8> = Vec::new();
1317
1318 reserve_capped(&mut bytes, 64, 16);
1319
1320 assert!(bytes.capacity() >= 64);
1321 }
1322}