Skip to main content

wekan_cli/
client.rs

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