Skip to main content

wekan_cli/
client.rs

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}