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