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	let blurhash = body.generate_blurhash.then(|| {
72		services
73			.media
74			.create_blurhash(&body.file, content_type, filename)
75			.ok()
76			.flatten()
77	});
78
79	Ok(create_content::v3::Response {
80		content_uri: mxc.to_string().into(),
81		blurhash: blurhash.flatten(),
82	})
83}
84
85/// # `POST /_matrix/media/v1/create`
86///
87/// Create a new MXC URI without content.
88#[tracing::instrument(
89	name = "media_create_mxc",
90	level = "debug",
91	skip_all,
92	fields(%client),
93)]
94pub(crate) async fn create_mxc_uri_route(
95	State(services): State<crate::State>,
96	ClientIp(client): ClientIp,
97	body: Ruma<create_mxc_uri::v1::Request>,
98) -> Result<create_mxc_uri::v1::Response> {
99	let user = body.sender_user();
100	let mxc = Mxc {
101		server_name: services.globals.server_name(),
102		media_id: &utils::random_string(MXC_LENGTH),
103	};
104
105	// safe because even if it overflows, it will be greater than the current time
106	// and the unused media will be deleted anyway
107	let unused_expires_at = now_millis().saturating_add(
108		services
109			.server
110			.config
111			.media_create_unused_expiration_time
112			.saturating_mul(1000),
113	);
114	services
115		.media
116		.create_pending(&mxc, user, unused_expires_at)
117		.await?;
118
119	Ok(create_mxc_uri::v1::Response {
120		content_uri: mxc.to_string().into(),
121		unused_expires_at: ruma::UInt::new(unused_expires_at).map(MilliSecondsSinceUnixEpoch),
122	})
123}
124
125/// # `PUT /_matrix/media/v3/upload/{serverName}/{mediaId}`
126///
127/// Upload content to a MXC URI that was created earlier.
128#[tracing::instrument(
129	name = "media_upload_async",
130	level = "debug",
131	skip_all,
132	fields(%client),
133)]
134pub(crate) async fn create_content_async_route(
135	State(services): State<crate::State>,
136	ClientIp(client): ClientIp,
137	body: Ruma<create_content_async::v3::Request>,
138) -> Result<create_content_async::v3::Response> {
139	let user = body.sender_user();
140	let mxc = Mxc {
141		server_name: &body.server_name,
142		media_id: &body.media_id,
143	};
144
145	let filename = body.filename.as_deref();
146	let content_type = body.content_type.as_deref();
147	let content_disposition = make_content_disposition(None, content_type, filename);
148
149	services
150		.media
151		.upload_pending(&mxc, user, Some(&content_disposition), content_type, &body.file)
152		.await?;
153
154	Ok(create_content_async::v3::Response {})
155}
156
157/// # `GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}`
158///
159/// Load media thumbnail from our server or over federation.
160#[tracing::instrument(
161	name = "media_thumbnail_get",
162	level = "debug",
163	skip_all,
164	fields(%client),
165)]
166pub(crate) async fn get_content_thumbnail_route(
167	State(services): State<crate::State>,
168	ClientIp(client): ClientIp,
169	body: Ruma<get_content_thumbnail::v1::Request>,
170) -> Result<get_content_thumbnail::v1::Response> {
171	let user = body.sender_user();
172
173	let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?;
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).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) -> Result<Media> {
317	let Media {
318		content,
319		content_type,
320		content_disposition,
321	} = services
322		.media
323		.get_or_fetch_thumbnail(mxc, dim, timeout_ms, user)
324		.await?;
325
326	let content_disposition = Some(make_content_disposition(
327		content_disposition.as_ref(),
328		content_type.as_deref(),
329		None,
330	));
331
332	Ok(Media {
333		content,
334		content_type,
335		content_disposition,
336	})
337}
338
339async fn fetch_file(
340	services: &Services,
341	mxc: &Mxc<'_>,
342	timeout_ms: Duration,
343	filename: Option<&str>,
344) -> Result<Media> {
345	let Media {
346		content,
347		content_type,
348		content_disposition,
349	} = services
350		.media
351		.get_or_fetch(mxc, timeout_ms)
352		.await?;
353
354	let content_disposition = Some(make_content_disposition(
355		content_disposition.as_ref(),
356		content_type.as_deref(),
357		filename,
358	));
359
360	Ok(Media {
361		content,
362		content_type,
363		content_disposition,
364	})
365}