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_enabled
37		.then_some(())
38		.ok_or_else(|| err!(Request(Unrecognized("QR login rendezvous is disabled"))))
39}
40
41#[tracing::instrument(level = "debug", skip_all)]
42pub(super) async fn read_plain_body(
43	headers: &HeaderMap,
44	body: Body,
45	max_bytes: usize,
46) -> Result<Bytes> {
47	validate_content_type(headers)?;
48
49	let oversized = headers
50		.get(CONTENT_LENGTH)
51		.and_then(|value| value.to_str().ok())
52		.and_then(|value| value.parse::<usize>().ok())
53		.is_some_and(|length| length > max_bytes);
54
55	if oversized {
56		return Err!(Request(TooLarge("Rendezvous payload is too large")));
57	}
58
59	to_bytes(body, max_bytes)
60		.await
61		.map_err(|e| err!(Request(TooLarge("Rendezvous payload is too large: {e}"))))
62}
63
64fn validate_content_type(headers: &HeaderMap) -> Result {
65	let content_type = headers
66		.get(CONTENT_TYPE)
67		.ok_or_else(|| err!(Request(MissingParam("Missing required header: content-type"))))?
68		.to_str()
69		.map_err(|_| err!(Request(InvalidParam("Content-Type must be text/plain"))))?;
70
71	content_type_is(Some(content_type), TEXT_PLAIN)
72		.then_some(())
73		.ok_or_else(|| err!(Request(InvalidParam("Content-Type must be text/plain"))))
74}
75
76pub(super) fn session_response(
77	status: StatusCode,
78	body: Body,
79	content_type: Option<&'static str>,
80	meta: &Meta,
81) -> Result<Response> {
82	let mut response = Response::builder()
83		.status(status)
84		.body(body)
85		.expect("rendezvous response builds");
86
87	if let Some(content_type) = content_type {
88		response
89			.headers_mut()
90			.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
91	}
92
93	session_headers(response.headers_mut(), meta)?;
94
95	Ok(response)
96}
97
98pub(super) fn session_headers(headers: &mut HeaderMap, meta: &Meta) -> Result {
99	let etag = HeaderValue::from_bytes(meta.etag.as_bytes()).expect("valid rendezvous ETag");
100	let expires = system_time_to_http_date(&meta.expires_at)
101		.map_err(|e| err!("Invalid rendezvous expiry: {e}"))?;
102	let last_modified = system_time_to_http_date(&meta.last_modified)
103		.map_err(|e| err!("Invalid rendezvous modification time: {e}"))?;
104
105	headers.insert(ETAG, etag);
106	headers.insert(EXPIRES, expires);
107	headers.insert(LAST_MODIFIED, last_modified);
108	headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
109	headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store, no-transform"));
110
111	Ok(())
112}