Skip to main content

tuwunel_service/media/
preview.rs

1//! URL Previews
2//!
3//! This functionality is gated by 'url_preview', but not at the unit level for
4//! historical and simplicity reasons. Instead the feature gates the inclusion
5//! of dependencies and nulls out results through the existing interface when
6//! not featured.
7
8use 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
37/// A media type as declared by a page, inline for every common spelling.
38type 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/// Which configured agent a preview request speaks as.
151///
152/// Origins commonly gate a page and the media it references differently, so
153/// the two are configured separately.
154#[derive(Clone, Copy)]
155pub(super) enum Agent {
156	Page,
157	Media,
158}
159
160/// Hosts whose pages carry their `<head>` metadata only for an allowlisted
161/// crawler, and which answer oEmbed for any agent.
162const YOUTUBE_HOSTS: [&str; 5] = [
163	"youtu.be",
164	"youtube.com",
165	"www.youtube.com",
166	"m.youtube.com",
167	"music.youtube.com",
168];
169
170/// Consent state that suppresses the interstitial Google serves in place of
171/// the page in some regions.
172///
173/// `SOCS` is the cookie Google reads today and `CONSENT` the one older
174/// endpoints still honor.
175const YOUTUBE_CONSENT_COOKIE: &str = "SOCS=CAI; CONSENT=PENDING+999";
176
177/// Endpoint answering oEmbed for every host in `YOUTUBE_HOSTS`, including
178/// the short and subdomain forms.
179#[cfg(feature = "url_preview")]
180const YOUTUBE_OEMBED: &str = "https://www.youtube.com/oembed";
181
182/// An oEmbed document runs to a few hundred bytes; the cap bounds only a
183/// hostile origin.
184#[cfg(feature = "url_preview")]
185const OEMBED_MAX_SIZE: usize = 64 * 1024;
186
187/// The oEmbed fields a preview can carry.
188///
189/// Every other field of the document is ignored, and each of these is
190/// optional in the specification.
191#[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	// ensure that only one request is made per URL
208	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	// an upstream error response must not be turned into a cached preview.
227	// origins commonly gate pages and media differently by agent, so when a
228	// distinct media agent is configured, a page-agent rejection is not
229	// final: the URL may be a direct media link acceptable to the media
230	// client (see media_refetch for the successful counterpart).
231	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			// pages are only crawled with the page client; its rejection
260			// stands even when the media client was served a page
261			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/// Build a preview request through the preview client, carrying the headers
309/// `preview_headers` applies.
310#[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/// Apply the configured User-Agent, Accept-Language, and any origin-specific
318/// headers to a preview request.
319///
320/// Headers are read per request rather than baked into the client, so a
321/// configuration reload takes effect without restarting the server. The
322/// configuration is bound once so the header options are read through a
323/// single handle.
324#[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	// the consent cookie is scoped to the hosts that gate on it so it never
351	// travels to another origin
352	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/// Screen a preview response's peer address against the CIDR denylist.
365///
366/// A missing peer address cannot be screened, so it fails closed.
367#[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/// Recover a preview from the origin's oEmbed endpoint when the page yielded
383/// nothing usable.
384///
385/// Some origins serve their `<head>` metadata only to an agent they
386/// recognise as a link-preview crawler, while answering oEmbed for anyone.
387/// A page that parsed to nothing is therefore worth one much smaller second
388/// request, and the original preview stands if that request fails too.
389#[cfg(feature = "url_preview")]
390#[implement(Service)]
391async fn oembed_recover(&self, url: &Url, data: UrlPreviewData) -> UrlPreviewData {
392	// an already-staged image would be orphaned by replacing the preview
393	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/// The oEmbed endpoint answering for `url`, when its origin has one.
413#[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/// Fetch an oEmbed document and render it as a preview.
423///
424/// The document names a thumbnail rather than carrying one, so the image is
425/// measured and staged through the same path an `og:image` takes.
426#[cfg(feature = "url_preview")]
427#[implement(Service)]
428async fn oembed_preview(&self, endpoint: &Url, page: &Url) -> Result<UrlPreviewData> {
429	// this host is chosen here rather than named by the page, so the operator's
430	// own allowlist decides whether it may be contacted at all
431	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	// oEmbed carries no description; the author is the only other prose the
462	// document offers and reads as a byline in every client that shows one
463	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/// Translate an oEmbed `video` document into the type a preview can carry.
478///
479/// oEmbed hands back an HTML player rather than a media file, so the player
480/// type is all a preview can report. Clients read that type on its own to
481/// mark the preview playable at the origin.
482#[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/// Translate an oEmbed `type` into the OpenGraph vocabulary the preview
488/// response is defined in.
489///
490/// oEmbed names its own kinds (`video`, `photo`, `link`, `rich`), none of
491/// which is an OpenGraph type; anything without a counterpart takes the
492/// OpenGraph default the page path would have produced.
493#[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/// Measure an oEmbed thumbnail, yielding an empty preview when it is absent
503/// or unusable.
504///
505/// A thumbnail failure must not cost the textual preview the document has
506/// already provided.
507#[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/// Fetch and measure a preview image, keeping the textual preview when the
523/// origin refuses it.
524///
525/// The measurement is a media fetch: it carries the media agent, or the
526/// origin could serve the measurement different content than it serves the
527/// relayed mxc.
528#[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	// a failing preview image must not become a preview mxc the relay is
541	// guaranteed to reject; skip it and keep the textual preview
542	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/// Download an image for URL preview metadata.
556///
557/// When URL previews are enabled, the image is staged for lazy media retrieval;
558/// otherwise this returns the feature-disabled error.
559#[cfg(feature = "url_preview")]
560#[implement(Service)]
561pub async fn download_image(&self, response: reqwest::Response) -> Result<UrlPreviewData> {
562	use image::ImageReader;
563
564	// the image is fetched once here to measure it; the bytes are staged so the
565	// first client download promotes them instead of refetching the origin
566	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/// Download an image for URL preview metadata.
614///
615/// When URL previews are enabled, the image is staged for lazy media retrieval;
616/// otherwise this returns the feature-disabled error.
617#[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/// Fetch a URL with the media client, applying the same address and status
625/// screening as the page fetch. Direct preview media is measured and
626/// registered from the media client's response so it matches what the relay
627/// will serve.
628#[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/// Replace a page-client response with the media client's for a direct media
654/// URL. When no distinct media agent is configured the two clients are
655/// identical and the original response is used as-is, avoiding a second
656/// request.
657#[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
677/// Verify a possibly-refetched preview response still carries the content type
678/// class the page response was dispatched on, so a media-client refetch that
679/// substitutes a different type is not mis-registered.
680fn 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/// Mint a local mxc:// URI that resolves to `url` on first download (see
691/// `Service::fetch_lazy_media`), keeping preview generation independent of the
692/// underlying file size while routing clients through this server.
693#[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/// Parse a direct-file preview's advertised size, refusing one over the cap so
766/// we never register an mxc the relay is guaranteed to reject at fetch time.
767#[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	// the parser needs an owned string, so the read buffer becomes one rather
789	// than being copied into a second buffer of the same size
790	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	// twitter:* card tags mirror og:; some pages emit only the twitter set,
798	// or (fixvx) an empty og: value beside the real twitter: one
799	let twitter = |key| {
800		html.meta
801			.get(key)
802			.map(String::as_str)
803			.filter(|content| !content.is_empty())
804	};
805
806	// `webpage` does not resolve relative URLs in `og:` meta tags; resolve
807	// against the page URL, then keep only the http(s) ones we can fetch
808	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		// the declared type is reported even when the URL cannot be relayed
830		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	// a page whose head metadata sits past the cap parses clean and yields
875	// nothing, which is indistinguishable from a page carrying no tags
876	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/// Read a page body up to `limit`, reporting whether the cap cut it short.
900///
901/// An advertised length seeds the buffer, and growth past that stays
902/// geometric but never exceeds the cap, so a truncated page costs the cap
903/// rather than the next power of two above it.
904#[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/// Reserve `want` more bytes, growing geometrically but never past `limit`.
928///
929/// `want` is expected to be clamped to the remaining budget by the caller; a
930/// larger value is honored rather than dropped, since refusing to reserve it
931/// would only move the allocation into the following `extend_from_slice`.
932#[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/// Mint an `mxc://` URI for a page's declared media, or nothing when it is not
949/// relayable.
950///
951/// The URL is recorded rather than fetched, so a page naming a large video
952/// costs the preview request no bandwidth; it is fetched and checked only once
953/// a client asks for the resulting URI. Screening IP literals here as well
954/// keeps a preview from handing out a URI that the same check at relay time is
955/// guaranteed to refuse.
956#[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/// Whether an OpenGraph media object's declared type belongs to `class`.
968///
969/// A missing `og:*:type` is accepted, since most origins omit it. A type
970/// outside the class means the URL addresses a player page rather than a
971/// file, which the relay cannot serve as media.
972#[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		// check root domain if available and if user has root domain checks
1084		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			// values carrying a 0xFF byte, which sheared fields in the old codec
1146			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		// a host merely ending in the domain is a different origin
1272		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		// geometric growth, not one reallocation per chunk
1337		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}