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