Skip to main content

wekan_cli/
client.rs

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