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