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.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 animate = Animate::from(body.animated);
167	let mxc = Mxc {
168		server_name: &body.server_name,
169		media_id: &body.media_id,
170	};
171
172	let Media {
173		content,
174		content_type,
175		content_disposition,
176	} = fetch_thumbnail(&services, &mxc, user, body.timeout_ms, &dim, animate).await?;
177
178	Ok(get_content_thumbnail::v1::Response {
179		file: content,
180		content_type: content_type.map(Into::into),
181		cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()),
182		cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()),
183		content_disposition,
184	})
185}
186
187/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}`
188///
189/// Load media from our server or over federation.
190#[tracing::instrument(
191	name = "media_get",
192	level = "debug",
193	skip_all,
194	fields(%client),
195)]
196pub(crate) async fn get_content_route(
197	State(services): State<crate::State>,
198	ClientIp(client): ClientIp,
199	body: Ruma<get_content::v1::Request>,
200) -> Result<get_content::v1::Response> {
201	let mxc = Mxc {
202		server_name: &body.server_name,
203		media_id: &body.media_id,
204	};
205
206	let Media {
207		content,
208		content_type,
209		content_disposition,
210	} = fetch_file(&services, &mxc, body.timeout_ms, None).await?;
211
212	Ok(get_content::v1::Response {
213		file: content,
214		content_type: content_type.map(Into::into),
215		cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()),
216		cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()),
217		content_disposition,
218	})
219}
220
221/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}`
222///
223/// Load media from our server or over federation as fileName.
224#[tracing::instrument(
225	name = "media_get_af",
226	level = "debug",
227	skip_all,
228	fields(%client),
229)]
230pub(crate) async fn get_content_as_filename_route(
231	State(services): State<crate::State>,
232	ClientIp(client): ClientIp,
233	body: Ruma<get_content_as_filename::v1::Request>,
234) -> Result<get_content_as_filename::v1::Response> {
235	let mxc = Mxc {
236		server_name: &body.server_name,
237		media_id: &body.media_id,
238	};
239
240	let Media {
241		content,
242		content_type,
243		content_disposition,
244	} = fetch_file(&services, &mxc, body.timeout_ms, Some(&body.filename)).await?;
245
246	Ok(get_content_as_filename::v1::Response {
247		file: content,
248		content_type: content_type.map(Into::into),
249		cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()),
250		cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()),
251		content_disposition,
252	})
253}
254
255/// # `GET /_matrix/client/v1/media/preview_url`
256///
257/// Returns URL preview.
258#[tracing::instrument(
259	name = "url_preview",
260	level = "debug",
261	skip_all,
262	fields(%client),
263)]
264pub(crate) async fn get_media_preview_route(
265	State(services): State<crate::State>,
266	ClientIp(client): ClientIp,
267	body: Ruma<get_media_preview::v1::Request>,
268) -> Result<get_media_preview::v1::Response> {
269	let sender_user = body.sender_user();
270
271	let url = &body.url;
272	let url = Url::parse(&body.url).map_err(|e| {
273		err!(Request(InvalidParam(
274			debug_warn!(%sender_user, %url, "Requested URL is not valid: {e}")
275		)))
276	})?;
277
278	if !services.media.url_preview_allowed(&url) {
279		return Err!(Request(Forbidden(
280			debug_warn!(%sender_user, %url, "URL is not allowed to be previewed")
281		)));
282	}
283
284	let preview = services
285		.media
286		.get_url_preview(&url)
287		.await
288		.map_err(|error| {
289			err!(Request(Unknown(
290				debug_error!(%sender_user, %url, "Failed to fetch URL preview: {error}")
291			)))
292		})?;
293
294	serde_json::value::to_raw_value(&preview)
295		.map(get_media_preview::v1::Response::from_raw_value)
296		.map_err(|error| {
297			err!(Request(Unknown(
298				debug_error!(%sender_user, %url, "Failed to parse URL preview: {error}")
299			)))
300		})
301}
302
303async fn fetch_thumbnail(
304	services: &Services,
305	mxc: &Mxc<'_>,
306	user: &UserId,
307	timeout_ms: Duration,
308	dim: &Dim,
309	animate: Animate,
310) -> Result<Media> {
311	let Media {
312		content,
313		content_type,
314		content_disposition,
315	} = services
316		.media
317		.get_or_fetch_thumbnail(mxc, dim, animate, timeout_ms, user)
318		.await?;
319
320	let content_disposition = Some(make_content_disposition(
321		content_disposition.as_ref(),
322		content_type.as_deref(),
323		None,
324	));
325
326	Ok(Media {
327		content,
328		content_type,
329		content_disposition,
330	})
331}
332
333async fn fetch_file(
334	services: &Services,
335	mxc: &Mxc<'_>,
336	timeout_ms: Duration,
337	filename: Option<&str>,
338) -> Result<Media> {
339	let Media {
340		content,
341		content_type,
342		content_disposition,
343	} = services
344		.media
345		.get_or_fetch(mxc, timeout_ms)
346		.await?;
347
348	let content_disposition = Some(make_content_disposition(
349		content_disposition.as_ref(),
350		content_type.as_deref(),
351		filename,
352	));
353
354	Ok(Media {
355		content,
356		content_type,
357		content_disposition,
358	})
359}