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