1use std::{
2 fmt,
3 sync::atomic::{AtomicU64, Ordering},
4 time::Duration,
5};
6
7use reqwest::{
8 Body, Method, StatusCode, Url,
9 header::{CONTENT_LENGTH, HeaderMap, HeaderValue},
10};
11use secrecy::{ExposeSecret, SecretString};
12use thiserror::Error;
13use tokio::io::AsyncRead;
14use tokio_util::io::ReaderStream;
15
16use super::{ClientError, ServerUrl, WekanClient, WekanClientFactory};
17
18#[derive(Debug)]
19pub(crate) enum RawApiAuthentication<'token> {
20 Bearer(&'token SecretString),
21 QueryToken(&'token SecretString),
22 None,
23}
24
25pub(crate) enum RawApiRequestBody {
26 Text(String),
27 Reader(Box<dyn AsyncRead + Send + Unpin>),
28}
29
30impl RawApiRequestBody {
31 fn into_reqwest_body(self) -> Body {
32 match self {
33 Self::Text(body) => Body::from(body),
34 Self::Reader(reader) => Body::wrap_stream(ReaderStream::new(reader)),
35 }
36 }
37}
38
39pub(crate) struct RawApiRequest<'request> {
40 pub(crate) method: Method,
41 pub(crate) path: &'request str,
42 pub(crate) query: &'request [(String, String)],
43 pub(crate) headers: HeaderMap,
44 pub(crate) body: Option<RawApiRequestBody>,
45 pub(crate) content_length: Option<u64>,
46 pub(crate) authentication: RawApiAuthentication<'request>,
47 pub(crate) timeout: Option<Duration>,
48}
49
50#[derive(Debug, Error)]
51pub(crate) enum RawApiRequestError {
52 #[error("{0}")]
53 InvalidInput(String),
54 #[error(transparent)]
55 Client(#[from] ClientError),
56}
57
58pub struct RawApiResponse {
59 identity: u64,
60 response: reqwest::Response,
61}
62
63impl fmt::Debug for RawApiResponse {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter
66 .debug_struct("RawApiResponse")
67 .field("identity", &self.identity)
68 .field("status", &self.status())
69 .finish_non_exhaustive()
70 }
71}
72
73impl PartialEq for RawApiResponse {
74 fn eq(&self, other: &Self) -> bool {
75 self.identity == other.identity
76 }
77}
78
79impl Eq for RawApiResponse {}
80
81impl RawApiResponse {
82 pub fn status(&self) -> StatusCode {
83 self.response.status()
84 }
85
86 pub fn headers(&self) -> &HeaderMap {
87 self.response.headers()
88 }
89
90 pub(crate) async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>, ClientError> {
91 let status = self.response.status();
92 let retry_after_seconds = self
93 .response
94 .headers()
95 .get(reqwest::header::RETRY_AFTER)
96 .and_then(|value| value.to_str().ok())
97 .and_then(|value| value.parse::<u64>().ok());
98 self.response
99 .chunk()
100 .await
101 .map(|chunk| chunk.map(|bytes| bytes.to_vec()))
102 .map_err(|source| ClientError::ResponseBody {
103 status,
104 retry_after_seconds,
105 source: source.without_url(),
106 })
107 }
108}
109
110impl WekanClientFactory {
111 pub(crate) fn validate_raw_api_request(
112 &self,
113 method: &Method,
114 path: &str,
115 query: &[(String, String)],
116 query_token: bool,
117 ) -> Result<(), RawApiRequestError> {
118 raw_api_url(&self.server, method, path, query, query_token, None).map(|_| ())
119 }
120}
121
122impl WekanClient {
123 pub(crate) async fn execute_raw_api_request(
124 &self,
125 request: RawApiRequest<'_>,
126 ) -> Result<RawApiResponse, RawApiRequestError> {
127 let RawApiRequest {
128 method,
129 path,
130 query,
131 headers,
132 body,
133 content_length,
134 authentication,
135 timeout,
136 } = request;
137 let (query_token, bearer_token, query_token_value) = match authentication {
138 RawApiAuthentication::Bearer(token) => (false, Some(token), None),
139 RawApiAuthentication::QueryToken(token) => (true, None, Some(token.expose_secret())),
140 RawApiAuthentication::None => (false, None, None),
141 };
142 let url = raw_api_url(
143 &self.server,
144 &method,
145 path,
146 query,
147 query_token,
148 query_token_value,
149 )?;
150 let mut headers = headers;
151 if let Some(length) = content_length {
152 headers.insert(CONTENT_LENGTH, HeaderValue::from(length));
153 }
154 let mut request = self.http.request(method, url).headers(headers);
155 if let Some(token) = bearer_token {
156 request = request.bearer_auth(token.expose_secret());
157 }
158 if let Some(body) = body {
159 request = request.body(body.into_reqwest_body());
160 }
161 if let Some(timeout) = timeout {
162 request = request.timeout(timeout);
163 }
164
165 request
166 .send()
167 .await
168 .map(|response| RawApiResponse {
169 identity: next_response_identity(),
170 response,
171 })
172 .map_err(|error| {
173 RawApiRequestError::Client(ClientError::Transport(error.without_url()))
174 })
175 }
176}
177
178fn raw_api_url(
179 server: &ServerUrl,
180 method: &Method,
181 path: &str,
182 query: &[(String, String)],
183 query_token: bool,
184 query_token_value: Option<&str>,
185) -> Result<Url, RawApiRequestError> {
186 if !path.starts_with('/') || path.starts_with("//") {
187 return Err(RawApiRequestError::InvalidInput(
188 "the raw API path must begin with exactly one slash".to_owned(),
189 ));
190 }
191 if path.contains('#') {
192 return Err(RawApiRequestError::InvalidInput(
193 "the raw API path must not contain a URL fragment".to_owned(),
194 ));
195 }
196
197 let path_only = path.split_once('?').map_or(path, |(path, _)| path);
198 if path_only.contains('\\') {
199 return Err(RawApiRequestError::InvalidInput(
200 "the raw API path must use forward slashes and must not contain backslashes".to_owned(),
201 ));
202 }
203 if path_only[1..].split('/').any(is_dot_segment) {
204 return Err(RawApiRequestError::InvalidInput(
205 "the raw API path must not contain dot-segment traversal".to_owned(),
206 ));
207 }
208 if query_token && *method != Method::GET {
209 return Err(RawApiRequestError::InvalidInput(
210 "--auth-token-query is accepted only for GET requests".to_owned(),
211 ));
212 }
213
214 let mut url = server.join(&path[1..]).map_err(|error| {
215 RawApiRequestError::InvalidInput(format!("the raw API path is invalid: {error}"))
216 })?;
217 if url.origin() != server.0.origin() || !url.path().starts_with(server.0.path()) {
218 return Err(RawApiRequestError::InvalidInput(
219 "the raw API path must stay beneath the configured Wekan server base".to_owned(),
220 ));
221 }
222
223 if url.query_pairs().any(|(name, _)| name == "authToken")
224 || query.iter().any(|(name, _)| name == "authToken")
225 {
226 return Err(RawApiRequestError::InvalidInput(
227 "authToken is managed by --auth-token-query and must not be supplied in the path or --query"
228 .to_owned(),
229 ));
230 }
231 if !query.is_empty() || query_token_value.is_some() {
232 let mut pairs = url.query_pairs_mut();
233 for (name, value) in query {
234 pairs.append_pair(name, value);
235 }
236 if let Some(token) = query_token_value {
237 pairs.append_pair("authToken", token);
238 }
239 }
240 Ok(url)
241}
242
243fn is_dot_segment(segment: &str) -> bool {
244 let mut decoded = segment.to_ascii_lowercase();
245 while decoded.contains("%2e") {
246 decoded = decoded.replace("%2e", ".");
247 }
248 matches!(decoded.as_str(), "." | "..")
249}
250
251fn next_response_identity() -> u64 {
252 static NEXT_IDENTITY: AtomicU64 = AtomicU64::new(1);
253 NEXT_IDENTITY.fetch_add(1, Ordering::Relaxed)
254}
255
256#[cfg(test)]
257mod tests {
258 use std::{net::TcpListener, time::Duration};
259
260 use reqwest::{Method, header::HeaderMap};
261 use secrecy::SecretString;
262 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path};
263
264 use super::{RawApiAuthentication, RawApiRequest, RawApiRequestError};
265 use crate::client::{ClientError, WekanClientFactory};
266
267 #[tokio::test]
268 async fn token_bearing_request_urls_are_absent_from_errors_and_response_debug() {
269 let token = "managed+/token";
270 let closed_listener = TcpListener::bind("127.0.0.1:0").unwrap();
271 let closed_address = closed_listener.local_addr().unwrap();
272 drop(closed_listener);
273 let factory = WekanClientFactory::for_profile(
274 format!("http://{closed_address}/"),
275 "default".to_owned(),
276 false,
277 );
278 let client = factory.create().unwrap();
279 let query_token = SecretString::from(token.to_owned());
280 let error = client
281 .execute_raw_api_request(RawApiRequest {
282 method: Method::GET,
283 path: "/api/query-auth-on-an-unmodeled-route",
284 query: &[],
285 headers: HeaderMap::new(),
286 body: None,
287 content_length: None,
288 authentication: RawApiAuthentication::QueryToken(&query_token),
289 timeout: Some(Duration::from_secs(1)),
290 })
291 .await
292 .unwrap_err();
293 assert!(matches!(
294 error,
295 RawApiRequestError::Client(ClientError::Transport(_))
296 ));
297 let rendered = format!("{error:?}");
298 assert!(!rendered.contains(token));
299 assert!(!rendered.contains("managed%2B%2Ftoken"));
300 assert!(!rendered.contains("authToken"));
301
302 let server = MockServer::start().await;
303 Mock::given(path("/api/query-auth-on-an-unmodeled-route"))
304 .respond_with(ResponseTemplate::new(200).insert_header("x-secret", token))
305 .mount(&server)
306 .await;
307 let factory = WekanClientFactory::for_profile(
308 format!("{}/", server.uri()),
309 "default".to_owned(),
310 false,
311 );
312 let client = factory.create().unwrap();
313 let query_token = SecretString::from(token.to_owned());
314 let response = client
315 .execute_raw_api_request(RawApiRequest {
316 method: Method::GET,
317 path: "/api/query-auth-on-an-unmodeled-route",
318 query: &[],
319 headers: HeaderMap::new(),
320 body: None,
321 content_length: None,
322 authentication: RawApiAuthentication::QueryToken(&query_token),
323 timeout: None,
324 })
325 .await
326 .unwrap();
327 let rendered = format!("{response:?}");
328 assert!(!rendered.contains(token));
329 assert!(!rendered.contains("authToken"));
330 assert!(!rendered.contains("x-secret"));
331 }
332}