Skip to main content

tuwunel_api_client/rendezvous/
mod.rs

1mod create;
2mod delete;
3mod get;
4mod msc4388;
5mod put;
6
7use axum::{
8	body::{Body, to_bytes},
9	response::Response,
10};
11use bytes::Bytes;
12use http::{
13	HeaderMap, HeaderValue, StatusCode,
14	header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, ETAG, EXPIRES, LAST_MODIFIED, PRAGMA},
15};
16use ruma::http_headers::system_time_to_http_date;
17use tuwunel_core::{Err, Result, err, utils::content_disposition::content_type_is};
18use tuwunel_service::{Services, rendezvous::Meta};
19
20pub(crate) use self::{
21	create::create_rendezvous_route,
22	delete::delete_rendezvous_route,
23	get::get_rendezvous_route,
24	msc4388::{
25		create_msc4388_route, delete_msc4388_route, discover_msc4388_route, get_msc4388_route,
26		put_msc4388_route,
27	},
28	put::put_rendezvous_route,
29};
30
31pub(super) const TEXT_PLAIN: &str = "text/plain";
32
33pub(super) fn ensure_enabled(services: &Services) -> Result {
34	services
35		.config
36		.rendezvous
37		.enable
38		.then_some(())
39		.ok_or_else(|| err!(Request(Unrecognized("QR login rendezvous is disabled"))))
40}
41
42#[tracing::instrument(level = "debug", skip_all)]
43pub(super) async fn read_plain_body(
44	headers: &HeaderMap,
45	body: Body,
46	max_bytes: usize,
47) -> Result<Bytes> {
48	validate_content_type(headers)?;
49
50	let oversized = headers
51		.get(CONTENT_LENGTH)
52		.and_then(|value| value.to_str().ok())
53		.and_then(|value| value.parse::<usize>().ok())
54		.is_some_and(|length| length > max_bytes);
55
56	if oversized {
57		return Err!(Request(TooLarge("Rendezvous payload is too large")));
58	}
59
60	to_bytes(body, max_bytes)
61		.await
62		.map_err(|e| err!(Request(TooLarge("Rendezvous payload is too large: {e}"))))
63}
64
65fn validate_content_type(headers: &HeaderMap) -> Result {
66	let content_type = headers
67		.get(CONTENT_TYPE)
68		.ok_or_else(|| err!(Request(MissingParam("Missing required header: content-type"))))?
69		.to_str()
70		.map_err(|_| err!(Request(InvalidParam("Content-Type must be text/plain"))))?;
71
72	content_type_is(Some(content_type), TEXT_PLAIN)
73		.then_some(())
74		.ok_or_else(|| err!(Request(InvalidParam("Content-Type must be text/plain"))))
75}
76
77pub(super) fn session_response(
78	status: StatusCode,
79	body: Body,
80	content_type: Option<&'static str>,
81	meta: &Meta,
82) -> Result<Response> {
83	let mut response = Response::builder()
84		.status(status)
85		.body(body)
86		.expect("rendezvous response builds");
87
88	if let Some(content_type) = content_type {
89		response
90			.headers_mut()
91			.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
92	}
93
94	session_headers(response.headers_mut(), meta)?;
95
96	Ok(response)
97}
98
99pub(super) fn session_headers(headers: &mut HeaderMap, meta: &Meta) -> Result {
100	let etag = HeaderValue::from_bytes(meta.etag.as_bytes()).expect("valid rendezvous ETag");
101	let expires = system_time_to_http_date(&meta.expires_at)
102		.map_err(|e| err!("Invalid rendezvous expiry: {e}"))?;
103	let last_modified = system_time_to_http_date(&meta.last_modified)
104		.map_err(|e| err!("Invalid rendezvous modification time: {e}"))?;
105
106	headers.insert(ETAG, etag);
107	headers.insert(EXPIRES, expires);
108	headers.insert(LAST_MODIFIED, last_modified);
109	headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
110	headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store, no-transform"));
111
112	Ok(())
113}