1pub mod auth;
2pub mod boards;
3pub mod cards;
4pub mod comments;
5mod datetime;
6mod decoding;
7pub use decoding::{ResponseDecodeKind, ResponseDiagnostic};
8pub mod error;
9pub mod lists;
10mod raw;
11pub mod swimlanes;
12mod transport;
13pub mod users;
14
15use std::{net::IpAddr, time::Duration};
16
17use reqwest::{Client, redirect::Policy};
18use thiserror::Error;
19use url::{Host, Url};
20
21pub use auth::{AuthSession, LoginRequest, LogoutRequest, RegisterRequest};
22pub use boards::{
23 BoardColor, BoardCounts, BoardDocument, BoardPermission, BoardSummary, BoardType,
24 BoardWatchLevel, BoardWatcher, CreateBoardRequest, CreateBoardResult, DeleteBoardResult,
25 PresentParentTask, RenameBoardResult,
26};
27pub use cards::{
28 ArchiveCardResult, CardCustomField, CardCustomFieldValue, CardDependency, CardDocument,
29 CardLocation, CardPoker, CardSticker, CardStickerHighlight, CardSummary, CardVote,
30 CreateCardRequest, CreateCardResult, DeleteCardResult, RestoreCardResult, UpdateCardRequest,
31 UpdateCardResult,
32};
33pub use comments::{
34 CommentDocument, CommentSummary, CreateCommentRequest, CreateCommentResult, DeleteCommentResult,
35};
36pub use error::ClientError;
37pub use lists::{
38 CreateListRequest, CreateListResult, DeleteListResult, ListDocument, ListSummary, ListWipLimit,
39 UpdateListRequest, UpdateListResult,
40};
41pub use raw::RawApiResponse;
42pub(crate) use raw::{RawApiAuthentication, RawApiRequest, RawApiRequestBody, RawApiRequestError};
43pub use swimlanes::{
44 CreateSwimlaneRequest, CreateSwimlaneResult, DeleteSwimlaneResult, SwimlaneDocument,
45 SwimlaneSummary, UpdateSwimlaneRequest, UpdateSwimlaneResult,
46};
47pub use users::{
48 CreateUserRequest, CreateUserResult, CurrentUser, CurrentUserBoard, CurrentUserEmail,
49 CurrentUserProfile, UserAction, UserActionResult, UserCard, UserCardQuery, UserEmail,
50 UserOrganization, UserProfile, UserRecord, UserSummary, UserTeam, UserWorkspaceNode,
51};
52
53const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
54const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct ServerUrl(Url);
58
59impl ServerUrl {
60 pub fn parse(raw: &str) -> Result<Self, ServerUrlError> {
61 let mut url = Url::parse(raw).map_err(ServerUrlError::Invalid)?;
62
63 if !matches!(url.scheme(), "http" | "https") {
64 return Err(ServerUrlError::UnsupportedScheme(url.scheme().to_owned()));
65 }
66 if url.host().is_none() || url.cannot_be_a_base() {
67 return Err(ServerUrlError::MissingHost);
68 }
69 if !url.username().is_empty() || url.password().is_some() {
70 return Err(ServerUrlError::EmbeddedCredentials);
71 }
72 if url.query().is_some() {
73 return Err(ServerUrlError::Query);
74 }
75 if url.fragment().is_some() {
76 return Err(ServerUrlError::Fragment);
77 }
78 if !url.path().ends_with('/') {
79 url.path_segments_mut()
80 .map_err(|_| ServerUrlError::MissingHost)?
81 .push("");
82 }
83
84 Ok(Self(url))
85 }
86
87 pub fn as_str(&self) -> &str {
88 self.0.as_str()
89 }
90
91 pub fn join(&self, path: &str) -> Result<Url, url::ParseError> {
92 self.0.join(path)
93 }
94
95 fn is_http_loopback(&self) -> bool {
96 self.0.scheme() == "http" && is_loopback(&self.0)
97 }
98
99 fn enforce_network_policy(&self, allow_insecure_http: bool) -> Result<(), ServerUrlError> {
100 if self.0.scheme() == "http" && !allow_insecure_http && !is_loopback(&self.0) {
101 return Err(ServerUrlError::InsecureHttp);
102 }
103 Ok(())
104 }
105}
106
107fn is_loopback(url: &Url) -> bool {
108 match url.host() {
109 Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
110 Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(),
111 Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(),
112 None => false,
113 }
114}
115
116#[derive(Debug, Error)]
117pub enum ServerUrlError {
118 #[error("the server URL is invalid: {0}")]
119 Invalid(#[source] url::ParseError),
120 #[error("unsupported server URL scheme `{0}`; use http or https")]
121 UnsupportedScheme(String),
122 #[error("the server URL must include a host")]
123 MissingHost,
124 #[error("the server URL must not contain a username or password")]
125 EmbeddedCredentials,
126 #[error("the server URL must not contain a query string")]
127 Query,
128 #[error("the server URL must not contain a fragment")]
129 Fragment,
130 #[error(
131 "plaintext HTTP is allowed only for localhost or loopback addresses; use HTTPS or pass --allow-insecure-http"
132 )]
133 InsecureHttp,
134}
135
136pub struct WekanClientFactory {
137 server: ServerUrl,
138 profile: Option<String>,
139 profile_store_namespace: Option<String>,
140 allow_insecure_http: bool,
141}
142
143impl WekanClientFactory {
144 pub fn for_server(server: ServerUrl, allow_insecure_http: bool) -> Self {
145 Self {
146 server,
147 profile: None,
148 profile_store_namespace: None,
149 allow_insecure_http,
150 }
151 }
152
153 pub fn for_resolved_profile(
154 server: ServerUrl,
155 profile: String,
156 profile_store_namespace: String,
157 allow_insecure_http: bool,
158 ) -> Self {
159 Self {
160 server,
161 profile: Some(profile),
162 profile_store_namespace: Some(profile_store_namespace),
163 allow_insecure_http,
164 }
165 }
166
167 #[cfg(test)]
168 pub(crate) fn new(server: Option<String>, allow_insecure_http: bool) -> Self {
169 let server = ServerUrl::parse(
170 server
171 .as_deref()
172 .expect("test client factories require a server identity"),
173 )
174 .expect("test client factories require a valid server identity");
175 Self::for_resolved_profile(
176 server,
177 "default".to_owned(),
178 "test-store".to_owned(),
179 allow_insecure_http,
180 )
181 }
182
183 #[cfg(test)]
184 pub(crate) fn for_profile(server: String, profile: String, allow_insecure_http: bool) -> Self {
185 let server = ServerUrl::parse(&server)
186 .expect("test client factories require a valid server identity");
187 Self::for_resolved_profile(
188 server,
189 profile,
190 "test-store".to_owned(),
191 allow_insecure_http,
192 )
193 }
194
195 pub fn profile(&self) -> Option<&str> {
196 self.profile.as_deref()
197 }
198
199 pub(crate) fn profile_store_namespace(&self) -> Option<&str> {
200 self.profile_store_namespace.as_deref()
201 }
202
203 pub fn create(&self) -> Result<WekanClient, WekanClientFactoryError> {
204 self.server
205 .enforce_network_policy(self.allow_insecure_http)?;
206 WekanClient::new(self.server.clone()).map_err(WekanClientFactoryError::Build)
207 }
208
209 pub(crate) const fn server_identity(&self) -> &ServerUrl {
210 &self.server
211 }
212}
213
214#[derive(Debug, Error)]
215pub enum WekanClientFactoryError {
216 #[error(transparent)]
217 ServerUrl(#[from] ServerUrlError),
218 #[error("the HTTP client could not be initialized")]
219 Build(#[source] ClientError),
220}
221
222#[derive(Clone, Debug)]
223pub struct WekanClient {
224 server: ServerUrl,
225 http: Client,
226}
227
228impl WekanClient {
229 fn new(server: ServerUrl) -> Result<Self, ClientError> {
230 Self::from_builder(server, Client::builder())
231 }
232
233 fn from_builder(
234 server: ServerUrl,
235 builder: reqwest::ClientBuilder,
236 ) -> Result<Self, ClientError> {
237 let mut builder = builder
238 .connect_timeout(CONNECT_TIMEOUT)
239 .timeout(REQUEST_TIMEOUT)
240 .redirect(Policy::none())
241 .retry(reqwest::retry::never())
242 .user_agent(concat!("wekan-cli/", env!("CARGO_PKG_VERSION")));
243 if server.is_http_loopback() {
244 builder = builder.no_proxy();
245 }
246 let http = builder.build().map_err(ClientError::Build)?;
247 Ok(Self { server, http })
248 }
249
250 pub fn server(&self) -> &ServerUrl {
251 &self.server
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use reqwest::Client;
258 use secrecy::SecretString;
259 use serde_json::json;
260 use wiremock::{
261 Mock, MockServer, ResponseTemplate,
262 matchers::{method, path},
263 };
264
265 use super::{
266 RegisterRequest, ServerUrl, ServerUrlError, WekanClient, WekanClientFactory,
267 WekanClientFactoryError,
268 };
269
270 #[test]
271 fn client_factory_defers_network_permission_until_creation() {
272 let server = ServerUrl::parse("http://wekan.example").unwrap();
273 let factory = WekanClientFactory::for_server(server.clone(), false);
274
275 assert_eq!(factory.server_identity(), &server);
276 assert!(matches!(
277 factory.create(),
278 Err(WekanClientFactoryError::ServerUrl(
279 ServerUrlError::InsecureHttp
280 ))
281 ));
282
283 assert!(
284 WekanClientFactory::for_server(server, true)
285 .create()
286 .is_ok()
287 );
288 }
289
290 #[test]
291 fn normalizes_server_paths_without_losing_subpaths() {
292 let server = ServerUrl::parse("https://example.com/wekan").unwrap();
293 assert_eq!(server.as_str(), "https://example.com/wekan/");
294 assert_eq!(
295 server.join("users/register").unwrap().as_str(),
296 "https://example.com/wekan/users/register"
297 );
298 assert_eq!(
299 server.join("users/login").unwrap().as_str(),
300 "https://example.com/wekan/users/login"
301 );
302 assert_eq!(
303 server.join("api/user").unwrap().as_str(),
304 "https://example.com/wekan/api/user"
305 );
306 }
307
308 #[test]
309 fn normalization_preserves_percent_encoded_path_segments() {
310 let server = ServerUrl::parse("https://example.com/tenant%20one/wekan").unwrap();
311 assert_eq!(server.as_str(), "https://example.com/tenant%20one/wekan/");
312 }
313
314 #[test]
315 fn client_factory_permits_http_for_loopback_hosts() {
316 for raw in [
317 "http://localhost:3000",
318 "http://127.0.0.1:3000",
319 "http://[::1]:3000",
320 ] {
321 let server = ServerUrl::parse(raw).unwrap();
322 assert!(
323 WekanClientFactory::for_server(server, false)
324 .create()
325 .is_ok()
326 );
327 }
328 }
329
330 #[test]
331 fn rejects_url_components_that_make_credentials_or_keys_ambiguous() {
332 assert!(matches!(
333 ServerUrl::parse("https://alice:secret@example.com"),
334 Err(ServerUrlError::EmbeddedCredentials)
335 ));
336 assert!(matches!(
337 ServerUrl::parse("https://example.com?tenant=one"),
338 Err(ServerUrlError::Query)
339 ));
340 assert!(matches!(
341 ServerUrl::parse("https://example.com#fragment"),
342 Err(ServerUrlError::Fragment)
343 ));
344 }
345
346 #[tokio::test]
347 async fn loopback_http_bypasses_a_configured_proxy() {
348 let destination = MockServer::start().await;
349 let proxy = MockServer::start().await;
350 Mock::given(method("POST"))
351 .and(path("/users/register"))
352 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
353 "id": "user-1",
354 "token": "server-token",
355 "tokenExpires": "2030-01-02T03:04:05Z"
356 })))
357 .expect(1)
358 .mount(&destination)
359 .await;
360 let server = ServerUrl::parse(&destination.uri()).unwrap();
361 let builder = Client::builder().proxy(reqwest::Proxy::all(proxy.uri()).unwrap());
362 let client = WekanClient::from_builder(server, builder).unwrap();
363
364 client
365 .register(&RegisterRequest {
366 username: Some("alice".to_owned()),
367 email: None,
368 password: SecretString::from("test-password".to_owned()),
369 })
370 .await
371 .expect("the loopback request must go directly to its destination");
372
373 destination.verify().await;
374 assert!(proxy.received_requests().await.unwrap().is_empty());
375 }
376}