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