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::{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	// refetch daily; og metadata drifts
142	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/// Which configured agent a preview request speaks as.
156///
157/// Origins commonly gate a page and the media it references differently, so
158/// the two are configured separately.
159#[derive(Clone, Copy)]
160pub(super) enum Agent {
161	Page,
162	Media,
163}
164
165/// Hosts whose pages carry their `<head>` metadata only for an allowlisted
166/// crawler, and which answer oEmbed for any agent.
167const YOUTUBE_HOSTS: [&str; 5] = [
168	"youtu.be",
169	"youtube.com",
170	"www.youtube.com",
171	"m.youtube.com",
172	"music.youtube.com",
173];
174
175/// Consent state that suppresses the interstitial Google serves in place of
176/// the page in some regions.
177///
178/// `SOCS` is the cookie Google reads today and `CONSENT` the one older
179/// endpoints still honor.
180const YOUTUBE_CONSENT_COOKIE: &str = "SOCS=CAI; CONSENT=PENDING+999";
181
182/// Endpoint answering oEmbed for every host in `YOUTUBE_HOSTS`, including
183/// the short and subdomain forms.
184#[cfg(feature = "url_preview")]
185const YOUTUBE_OEMBED: &str = "https://www.youtube.com/oembed";
186
187/// An oEmbed document runs to a few hundred bytes; the cap bounds only a
188/// hostile origin.
189#[cfg(feature = "url_preview")]
190const OEMBED_MAX_SIZE: usize = 64 * 1024;
191
192/// The oEmbed fields a preview can carry.
193///
194/// Every other field of the document is ignored, and each of these is
195/// optional in the specification.
196#[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	// ensure that only one request is made per URL
213	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	// an upstream error response must not be turned into a cached preview.
232	// origins commonly gate pages and media differently by agent, so when a
233	// distinct media agent is configured, a page-agent rejection is not
234	// final: the URL may be a direct media link acceptable to the media
235	// client (see media_refetch for the successful counterpart).
236	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			// pages are only crawled with the page client; its rejection
265			// stands even when the media client was served a page
266			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/// Build a preview request through the preview client, carrying the headers
312/// `preview_headers` applies.
313#[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/// Apply the configured User-Agent and any origin-specific headers to a
321/// preview request.
322///
323/// Both are read per request rather than baked into the client, so a
324/// configuration reload takes effect without restarting the server. The
325/// configuration is bound once so the two agent options are read through a
326/// single handle.
327#[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	// the consent cookie is scoped to the hosts that gate on it so it never
349	// travels to another origin
350	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/// Screen a preview response's peer address against the CIDR denylist.
363///
364/// A missing peer address cannot be screened, so it fails closed.
365#[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/// Recover a preview from the origin's oEmbed endpoint when the page yielded
381/// nothing usable.
382///
383/// Some origins serve their `<head>` metadata only to an agent they
384/// recognise as a link-preview crawler, while answering oEmbed for anyone.
385/// A page that parsed to nothing is therefore worth one much smaller second
386/// request, and the original preview stands if that request fails too.
387#[cfg(feature = "url_preview")]
388#[implement(Service)]
389async fn oembed_recover(&self, url: &Url, data: UrlPreviewData) -> UrlPreviewData {
390	// an already-staged image would be orphaned by replacing the preview
391	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/// The oEmbed endpoint answering for `url`, when its origin has one.
411#[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/// Fetch an oEmbed document and render it as a preview.
421///
422/// The document names a thumbnail rather than carrying one, so the image is
423/// measured and staged through the same path an `og:image` takes.
424#[cfg(feature = "url_preview")]
425#[implement(Service)]
426async fn oembed_preview(&self, endpoint: &Url, page: &Url) -> Result<UrlPreviewData> {
427	// this host is chosen here rather than named by the page, so the operator's
428	// own allowlist decides whether it may be contacted at all
429	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	// oEmbed carries no description; the author is the only other prose the
460	// document offers and reads as a byline in every client that shows one
461	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/// Translate an oEmbed `video` document into the type a preview can carry.
476///
477/// oEmbed hands back an HTML player rather than a media file, so the player
478/// type is all a preview can report. Clients read that type on its own to
479/// mark the preview playable at the origin.
480#[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/// Translate an oEmbed `type` into the OpenGraph vocabulary the preview
486/// response is defined in.
487///
488/// oEmbed names its own kinds (`video`, `photo`, `link`, `rich`), none of
489/// which is an OpenGraph type; anything without a counterpart takes the
490/// OpenGraph default the page path would have produced.
491#[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/// Measure an oEmbed thumbnail, yielding an empty preview when it is absent
501/// or unusable.
502///
503/// A thumbnail failure must not cost the textual preview the document has
504/// already provided.
505#[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/// Fetch and measure a preview image, keeping the textual preview when the
521/// origin refuses it.
522///
523/// The measurement is a media fetch: it carries the media agent, or the
524/// origin could serve the measurement different content than it serves the
525/// relayed mxc.
526#[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	// a failing preview image must not become a preview mxc the relay is
539	// guaranteed to reject; skip it and keep the textual preview
540	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/// Download an image for URL preview metadata.
554///
555/// When URL previews are enabled, the image is staged for lazy media retrieval;
556/// otherwise this returns the feature-disabled error.
557#[cfg(feature = "url_preview")]
558#[implement(Service)]
559pub async fn download_image(&self, response: reqwest::Response) -> Result<UrlPreviewData> {
560	use image::ImageReader;
561
562	// the image is fetched once here to measure it; the bytes are staged so the
563	// first client download promotes them instead of refetching the origin
564	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/// Download an image for URL preview metadata.
612///
613/// When URL previews are enabled, the image is staged for lazy media retrieval;
614/// otherwise this returns the feature-disabled error.
615#[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/// Fetch a URL with the media client, applying the same address and status
623/// screening as the page fetch. Direct preview media is measured and
624/// registered from the media client's response so it matches what the relay
625/// will serve.
626#[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/// Replace a page-client response with the media client's for a direct media
652/// URL. When no distinct media agent is configured the two clients are
653/// identical and the original response is used as-is, avoiding a second
654/// request.
655#[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
675/// Verify a possibly-refetched preview response still carries the content type
676/// class the page response was dispatched on, so a media-client refetch that
677/// substitutes a different type is not mis-registered.
678fn 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/// Mint a local mxc:// URI that resolves to `url` on first download (see
689/// `Service::fetch_lazy_media`), keeping preview generation independent of the
690/// underlying file size while routing clients through this server.
691#[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/// Parse a direct-file preview's advertised size, refusing one over the cap so
764/// we never register an mxc the relay is guaranteed to reject at fetch time.
765#[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	// the parser needs an owned string, so the read buffer becomes one rather
787	// than being copied into a second buffer of the same size
788	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	// twitter:* card tags mirror og:; some pages emit only the twitter set,
796	// or (fixvx) an empty og: value beside the real twitter: one
797	let twitter = |key| {
798		html.meta
799			.get(key)
800			.map(String::as_str)
801			.filter(|content| !content.is_empty())
802	};
803
804	// `webpage` does not resolve relative URLs in `og:` meta tags; resolve
805	// against the page URL, then keep only the http(s) ones we can fetch
806	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		// the declared type is reported even when the URL cannot be relayed
828		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	// a page whose head metadata sits past the cap parses clean and yields
873	// nothing, which is indistinguishable from a page carrying no tags
874	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/// Read a page body up to `limit`, reporting whether the cap cut it short.
898///
899/// An advertised length seeds the buffer, and growth past that stays
900/// geometric but never exceeds the cap, so a truncated page costs the cap
901/// rather than the next power of two above it.
902#[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/// Reserve `want` more bytes, growing geometrically but never past `limit`.
926///
927/// `want` is expected to be clamped to the remaining budget by the caller; a
928/// larger value is honored rather than dropped, since refusing to reserve it
929/// would only move the allocation into the following `extend_from_slice`.
930#[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/// Mint an `mxc://` URI for a page's declared media, or nothing when it is not
947/// relayable.
948///
949/// The URL is recorded rather than fetched, so a page naming a large video
950/// costs the preview request no bandwidth; it is fetched and checked only once
951/// a client asks for the resulting URI. Screening IP literals here as well
952/// keeps a preview from handing out a URI that the same check at relay time is
953/// guaranteed to refuse.
954#[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/// Whether an OpenGraph media object's declared type belongs to `class`.
966///
967/// A missing `og:*:type` is accepted, since most origins omit it. A type
968/// outside the class means the URL addresses a player page rather than a
969/// file, which the relay cannot serve as media.
970#[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		// check root domain if available and if user has root domain checks
1082		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			// values carrying a 0xFF byte, which sheared fields in the old codec
1141			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		// a host merely ending in the domain is a different origin
1245		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		// geometric growth, not one reallocation per chunk
1310		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}