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
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}