tuwunel_api/client/rendezvous/
mod.rs1mod 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}