Skip to main content

wekan_cli/
client.rs

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