Skip to main content

tuwunel_api_client/
media.rs

1use std::time::Duration;
2
3use axum::extract::State;
4use reqwest::Url;
5use ruma::{
6	MilliSecondsSinceUnixEpoch, Mxc, UserId,
7	api::client::{
8		authenticated_media::{
9			get_content, get_content_as_filename, get_content_thumbnail, get_media_config,
10			get_media_preview,
11		},
12		media::{create_content, create_content_async, create_mxc_uri},
13	},
14};
15use tuwunel_core::{
16	Err, Result, err,
17	utils::{
18		self, content_disposition::make_content_disposition, math::ruma_from_usize,
19		time::now_millis,
20	},
21};
22use tuwunel_service::{
23	Services,
24	media::{Animate, CACHE_CONTROL_IMMUTABLE, CORP_CROSS_ORIGIN, Dim, MXC_LENGTH, Media},
25};
26
27use crate::{ClientIp, Ruma};
28
29/// # `GET /_matrix/client/v1/media/config`
30pub(crate) async fn get_media_config_route(
31	State(services): State<crate::State>,
32	_body: Ruma<get_media_config::v1::Request>,
33) -> Result<get_media_config::v1::Response> {
34	Ok(get_media_config::v1::Response {
35		upload_size: ruma_from_usize(services.server.config.http.max_request_size),
36	})
37}
38
39/// # `POST /_matrix/media/v3/upload`
40///
41/// Permanently save media in the server.
42///
43/// - Some metadata will be saved in the database
44/// - Media will be saved in the media/ directory
45#[tracing::instrument(
46	name = "media_upload",
47	level = "debug",
48	skip_all,
49	fields(%client),
50)]
51pub(crate) async fn create_content_route(
52	State(services): State<crate::State>,
53	ClientIp(client): ClientIp,
54	body: Ruma<create_content::v3::Request>,
55) -> Result<create_content::v3::Response> {
56	let user = body.sender_user();
57
58	services.media.check_upload_space().await?;
59
60	let filename = body.filename.as_deref();
61	let content_type = body.content_type.as_deref();
62	let content_disposition = make_content_disposition(None, content_type, filename);
63	let ref mxc = Mxc {
64		server_name: services.globals.server_name(),
65		media_id: &utils::random_string(MXC_LENGTH),
66	};
67
68	services
69		.media
70		.create(mxc, Some(user), Some(&content_disposition), content_type, &body.file)
71		.await?;
72
73	Ok(create_content::v3::Response {
74		content_uri: mxc.to_string().into(),
75		blurhash: None,
76	})
77}
78
79/// # `POST /_matrix/media/v1/create`
80///
81/// Create a new MXC URI without content.
82#[tracing::instrument(
83	name = "media_create_mxc",
84	level = "debug",
85	skip_all,
86	fields(%client),
87)]
88pub(crate) async fn create_mxc_uri_route(
89	State(services): State<crate::State>,
90	ClientIp(client): ClientIp,
91	body: Ruma<create_mxc_uri::v1::Request>,
92) -> Result<create_mxc_uri::v1::Response> {
93	let user = body.sender_user();
94	let mxc = Mxc {
95		server_name: services.globals.server_name(),
96		media_id: &utils::random_string(MXC_LENGTH),
97	};
98
99	// safe because even if it overflows, it will be greater than the current time
100	// and the unused media will be deleted anyway
101	let unused_expires_at = now_millis().saturating_add(
102		services
103			.server
104			.config
105			.media
106			.create_unused_expiration_time
107			.saturating_mul(1000),
108	);
109
110	services
111		.media
112		.create_pending(&mxc, user, unused_expires_at)
113		.await?;
114
115	Ok(create_mxc_uri::v1::Response {
116		content_uri: mxc.to_string().into(),
117		unused_expires_at: ruma::UInt::new(unused_expires_at).map(MilliSecondsSinceUnixEpoch),
118	})
119}
120
121/// # `PUT /_matrix/media/v3/upload/{serverName}/{mediaId}`
122///
123/// Upload content to a MXC URI that was created earlier.
124#[tracing::instrument(
125	name = "media_upload_async",
126	level = "debug",
127	skip_all,
128	fields(%client),
129)]
130pub(crate) async fn create_content_async_route(
131	State(services): State<crate::State>,
132	ClientIp(client): ClientIp,
133	body: Ruma<create_content_async::v3::Request>,
134) -> Result<create_content_async::v3::Response> {
135	let user = body.sender_user();
136
137	services.media.check_upload_space().await?;
138
139	let mxc = Mxc {
140		server_name: &body.server_name,
141		media_id: &body.media_id,
142	};
143
144	let filename = body.filename.as_deref();
145	let content_type = body.content_type.as_deref();
146	let content_disposition = make_content_disposition(None, content_type, filename);
147
148	services
149		.media
150		.upload_pending(&mxc, user, Some(&content_disposition), content_type, &body.file)
151		.await?;
152
153	Ok(create_content_async::v3::Response {})
154}
155
156/// # `GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}`
157///
158/// Load media thumbnail from our server or over federation.
159#[tracing::instrument(
160	name = "media_thumbnail_get",
161	level = "debug",
162	skip_all,
163	fields(%client),
164)]
165pub(crate) async fn get_content_thumbnail_route(
166	State(services): State<crate::State>,
167	ClientIp(client): ClientIp,
168	body: Ruma<get_content_thumbnail::v1::Request>,
169) -> Result<get_content_thumbnail::v1::Response> {
170	let user = body.sender_user();
171
172	let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?;
173	let animate = Animate::from(body.animated);
174	let mxc = Mxc {
175		server_name: &body.server_name,
176		media_id: &body.media_id,
177	};
178
179	let Media {
180		content,
181		content_type,
182		content_disposition,
183	} = fetch_thumbnail(&services, &mxc, user, body.timeout_ms, &dim, animate).await?;
184
185	Ok(get_content_thumbnail::v1::Response {
186		file: content,
187		content_type: content_type.map(Into::into),
188		cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()),
189		cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()),
190		content_disposition,
191	})
192}
193
194/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}`
195///
196/// Load media from our server or over federation.
197#[tracing::instrument(
198	name = "media_get",
199	level = "debug",
200	skip_all,
201	fields(%client),
202)]
203pub(crate) async fn get_content_route(
204	State(services): State<crate::State>,
205	ClientIp(client): ClientIp,
206	body: Ruma<get_content::v1::Request>,
207) -> Result<get_content::v1::Response> {
208	let mxc = Mxc {
209		server_name: &body.server_name,
210		media_id: &body.media_id,
211	};
212
213	let Media {
214		content,
215		content_type,
216		content_disposition,
217	} = fetch_file(&services, &mxc, body.timeout_ms, None).await?;
218
219	Ok(get_content::v1::Response {
220		file: content,
221		content_type: content_type.map(Into::into),
222		cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()),
223		cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()),
224		content_disposition,
225	})
226}
227
228/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}`
229///
230/// Load media from our server or over federation as fileName.
231#[tracing::instrument(
232	name = "media_get_af",
233	level = "debug",
234	skip_all,
235	fields(%client),
236)]
237pub(crate) async fn get_content_as_filename_route(
238	State(services): State<crate::State>,
239	ClientIp(client): ClientIp,
240	body: Ruma<get_content_as_filename::v1::Request>,
241) -> Result<get_content_as_filename::v1::Response> {
242	let mxc = Mxc {
243		server_name: &body.server_name,
244		media_id: &body.media_id,
245	};
246
247	let Media {
248		content,
249		content_type,
250		content_disposition,
251	} = fetch_file(&services, &mxc, body.timeout_ms, Some(&body.filename)).await?;
252
253	Ok(get_content_as_filename::v1::Response {
254		file: content,
255		content_type: content_type.map(Into::into),
256		cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()),
257		cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()),
258		content_disposition,
259	})
260}
261
262/// # `GET /_matrix/client/v1/media/preview_url`
263///
264/// Returns URL preview.
265#[tracing::instrument(
266	name = "url_preview",
267	level = "debug",
268	skip_all,
269	fields(%client),
270)]
271pub(crate) async fn get_media_preview_route(
272	State(services): State<crate::State>,
273	ClientIp(client): ClientIp,
274	body: Ruma<get_media_preview::v1::Request>,
275) -> Result<get_media_preview::v1::Response> {
276	let sender_user = body.sender_user();
277
278	let url = &body.url;
279	let url = Url::parse(&body.url).map_err(|e| {
280		err!(Request(InvalidParam(
281			debug_warn!(%sender_user, %url, "Requested URL is not valid: {e}")
282		)))
283	})?;
284
285	if !services.media.url_preview_allowed(&url) {
286		return Err!(Request(Forbidden(
287			debug_warn!(%sender_user, %url, "URL is not allowed to be previewed")
288		)));
289	}
290
291	let preview = services
292		.media
293		.get_url_preview(&url)
294		.await
295		.map_err(|error| {
296			err!(Request(Unknown(
297				debug_error!(%sender_user, %url, "Failed to fetch URL preview: {error}")
298			)))
299		})?;
300
301	serde_json::value::to_raw_value(&preview)
302		.map(get_media_preview::v1::Response::from_raw_value)
303		.map_err(|error| {
304			err!(Request(Unknown(
305				debug_error!(%sender_user, %url, "Failed to parse URL preview: {error}")
306			)))
307		})
308}
309
310async fn fetch_thumbnail(
311	services: &Services,
312	mxc: &Mxc<'_>,
313	user: &UserId,
314	timeout_ms: Duration,
315	dim: &Dim,
316	animate: Animate,
317) -> Result<Media> {
318	let Media {
319		content,
320		content_type,
321		content_disposition,
322	} = services
323		.media
324		.get_or_fetch_thumbnail(mxc, dim, animate, timeout_ms, user)
325		.await?;
326
327	let content_disposition = Some(make_content_disposition(
328		content_disposition.as_ref(),
329		content_type.as_deref(),
330		None,
331	));
332
333	Ok(Media {
334		content,
335		content_type,
336		content_disposition,
337	})
338}
339
340async fn fetch_file(
341	services: &Services,
342	mxc: &Mxc<'_>,
343	timeout_ms: Duration,
344	filename: Option<&str>,
345) -> Result<Media> {
346	let Media {
347		content,
348		content_type,
349		content_disposition,
350	} = services
351		.media
352		.get_or_fetch(mxc, timeout_ms)
353		.await?;
354
355	let content_disposition = Some(make_content_disposition(
356		content_disposition.as_ref(),
357		content_type.as_deref(),
358		filename,
359	));
360
361	Ok(Media {
362		content,
363		content_type,
364		content_disposition,
365	})
366}