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