pub mod admin;
pub mod attachments;
pub mod auth;
pub mod boards;
pub mod cards;
pub mod checklist_items;
pub mod checklists;
pub mod comments;
pub mod custom_fields;
mod datetime;
mod decoding;
pub mod dependencies;
pub mod exports;
pub mod imports;
pub mod integrations;
pub mod rules;
mod strings;
pub use decoding::{ResponseDecodeKind, ResponseDiagnostic};
pub(crate) use strings::trim_javascript_whitespace;
pub mod error;
pub mod lists;
mod raw;
pub mod swimlanes;
mod transport;
pub mod users;
use std::{net::IpAddr, time::Duration};
use reqwest::{Client, redirect::Policy};
use thiserror::Error;
use url::{Host, Url};
pub use auth::{AuthSession, LoginRequest, LogoutRequest, RegisterRequest};
pub use boards::{
BoardColor, BoardCounts, BoardDocument, BoardPermission, BoardSummary, BoardType,
BoardWatchLevel, BoardWatcher, CreateBoardRequest, CreateBoardResult, DeleteBoardResult,
PresentParentTask, RenameBoardResult,
};
pub use cards::{
ArchiveCardResult, CardCustomField, CardCustomFieldValue, CardDependency, CardDocument,
CardLocation, CardPoker, CardSticker, CardStickerHighlight, CardSummary, CardVote,
CreateCardRequest, CreateCardResult, DeleteCardResult, RestoreCardResult,
SetCardCustomFieldRequest, SetCardCustomFieldResponse, UpdateCardRequest, UpdateCardResult,
};
pub use checklist_items::{
ChecklistItemDocument, ChecklistItemFinishedInput, ChecklistItemSortInput,
CreateChecklistItemRequest, CreateChecklistItemResult, DeleteChecklistItemResult,
UpdateChecklistItemRequest, UpdateChecklistItemResult,
};
pub use checklists::{
ChecklistDocument, ChecklistItemSummary, ChecklistItemsInput, ChecklistSummary,
CreateChecklistRequest, CreateChecklistResult, DeleteChecklistResult,
};
pub use comments::{
CommentDocument, CommentSummary, CreateCommentRequest, CreateCommentResult, DeleteCommentResult,
};
pub use custom_fields::{
CreateCustomFieldItemsRequest, CreateCustomFieldRequest, CustomFieldDocument,
CustomFieldDropdownItem, CustomFieldIdResponse, CustomFieldSettings, CustomFieldSummary,
CustomFieldType, UpdateCustomFieldItemRequest, UpdateCustomFieldRequest,
};
pub use dependencies::{
BoardDependencyLine, CardDependencySummary, CreateDependencyRequest, CreateDependencyResponse,
DependencyMetadata, DependencyMutationResponse, DependencyType,
};
pub use error::ClientError;
pub use integrations::{
CreateIntegrationRequest, IntegrationActivitiesRequest, IntegrationActivitiesResponse,
IntegrationDocument, IntegrationIdResponse, UpdateIntegrationRequest,
};
pub use lists::{
CreateListRequest, CreateListResult, DeleteListResult, ListDocument, ListSummary, ListWipLimit,
UpdateListRequest, UpdateListResult,
};
pub use raw::RawApiResponse;
pub(crate) use raw::{RawApiAuthentication, RawApiRequest, RawApiRequestBody, RawApiRequestError};
pub use rules::{
CreateRuleRequest, RuleAction, RuleActionInput, RuleCreateResponse, RuleDocument,
RuleIdResponse, RuleNumber, RuleTrigger, RuleTriggerInput, UpdateRuleRequest,
};
pub use swimlanes::{
CreateSwimlaneRequest, CreateSwimlaneResult, DeleteSwimlaneResult, SwimlaneDocument,
SwimlaneSummary, UpdateSwimlaneRequest, UpdateSwimlaneResult,
};
pub use users::{
CreateUserRequest, CreateUserResult, CurrentUser, CurrentUserBoard, CurrentUserEmail,
CurrentUserProfile, UserAction, UserActionResult, UserCard, UserCardQuery, UserEmail,
UserOrganization, UserProfile, UserRecord, UserSummary, UserTeam, UserWorkspaceNode,
};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServerUrl(Url);
impl ServerUrl {
pub fn parse(raw: &str) -> Result<Self, ServerUrlError> {
let mut url = Url::parse(raw).map_err(ServerUrlError::Invalid)?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ServerUrlError::UnsupportedScheme(url.scheme().to_owned()));
}
if url.host().is_none() || url.cannot_be_a_base() {
return Err(ServerUrlError::MissingHost);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ServerUrlError::EmbeddedCredentials);
}
if url.query().is_some() {
return Err(ServerUrlError::Query);
}
if url.fragment().is_some() {
return Err(ServerUrlError::Fragment);
}
if !url.path().ends_with('/') {
url.path_segments_mut()
.map_err(|_| ServerUrlError::MissingHost)?
.push("");
}
Ok(Self(url))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn join(&self, path: &str) -> Result<Url, url::ParseError> {
self.0.join(path)
}
fn is_http_loopback(&self) -> bool {
self.0.scheme() == "http" && is_loopback(&self.0)
}
fn enforce_network_policy(&self, allow_insecure_http: bool) -> Result<(), ServerUrlError> {
if self.0.scheme() == "http" && !allow_insecure_http && !is_loopback(&self.0) {
return Err(ServerUrlError::InsecureHttp);
}
Ok(())
}
}
fn is_loopback(url: &Url) -> bool {
match url.host() {
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(),
Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(),
None => false,
}
}
#[derive(Debug, Error)]
pub enum ServerUrlError {
#[error("the server URL is invalid: {0}")]
Invalid(#[source] url::ParseError),
#[error("unsupported server URL scheme `{0}`; use http or https")]
UnsupportedScheme(String),
#[error("the server URL must include a host")]
MissingHost,
#[error("the server URL must not contain a username or password")]
EmbeddedCredentials,
#[error("the server URL must not contain a query string")]
Query,
#[error("the server URL must not contain a fragment")]
Fragment,
#[error(
"plaintext HTTP is allowed only for localhost or loopback addresses; use HTTPS or pass --allow-insecure-http"
)]
InsecureHttp,
}
pub struct WekanClientFactory {
server: ServerUrl,
profile: Option<String>,
profile_store_namespace: Option<String>,
allow_insecure_http: bool,
}
impl WekanClientFactory {
pub fn for_server(server: ServerUrl, allow_insecure_http: bool) -> Self {
Self {
server,
profile: None,
profile_store_namespace: None,
allow_insecure_http,
}
}
pub fn for_resolved_profile(
server: ServerUrl,
profile: String,
profile_store_namespace: String,
allow_insecure_http: bool,
) -> Self {
Self {
server,
profile: Some(profile),
profile_store_namespace: Some(profile_store_namespace),
allow_insecure_http,
}
}
#[cfg(test)]
pub(crate) fn new(server: Option<String>, allow_insecure_http: bool) -> Self {
let server = ServerUrl::parse(
server
.as_deref()
.expect("test client factories require a server identity"),
)
.expect("test client factories require a valid server identity");
Self::for_resolved_profile(
server,
"default".to_owned(),
"test-store".to_owned(),
allow_insecure_http,
)
}
#[cfg(test)]
pub(crate) fn for_profile(server: String, profile: String, allow_insecure_http: bool) -> Self {
let server = ServerUrl::parse(&server)
.expect("test client factories require a valid server identity");
Self::for_resolved_profile(
server,
profile,
"test-store".to_owned(),
allow_insecure_http,
)
}
pub fn profile(&self) -> Option<&str> {
self.profile.as_deref()
}
pub(crate) fn profile_store_namespace(&self) -> Option<&str> {
self.profile_store_namespace.as_deref()
}
pub fn create(&self) -> Result<WekanClient, WekanClientFactoryError> {
self.server
.enforce_network_policy(self.allow_insecure_http)?;
WekanClient::new(self.server.clone()).map_err(WekanClientFactoryError::Build)
}
pub(crate) const fn server_identity(&self) -> &ServerUrl {
&self.server
}
}
#[derive(Debug, Error)]
pub enum WekanClientFactoryError {
#[error(transparent)]
ServerUrl(#[from] ServerUrlError),
#[error("the HTTP client could not be initialized")]
Build(#[source] ClientError),
}
#[derive(Clone, Debug)]
pub struct WekanClient {
server: ServerUrl,
http: Client,
}
impl WekanClient {
fn new(server: ServerUrl) -> Result<Self, ClientError> {
Self::from_builder(server, Client::builder())
}
fn from_builder(
server: ServerUrl,
builder: reqwest::ClientBuilder,
) -> Result<Self, ClientError> {
let mut builder = builder
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.redirect(Policy::none())
.retry(reqwest::retry::never())
.user_agent(concat!("wekan-cli/", env!("CARGO_PKG_VERSION")));
if server.is_http_loopback() {
builder = builder.no_proxy();
}
let http = builder.build().map_err(ClientError::Build)?;
Ok(Self { server, http })
}
pub fn server(&self) -> &ServerUrl {
&self.server
}
}
#[cfg(test)]
mod tests {
use reqwest::Client;
use secrecy::SecretString;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
use super::{
RegisterRequest, ServerUrl, ServerUrlError, WekanClient, WekanClientFactory,
WekanClientFactoryError,
};
#[test]
fn client_factory_defers_network_permission_until_creation() {
let server = ServerUrl::parse("http://wekan.example").unwrap();
let factory = WekanClientFactory::for_server(server.clone(), false);
assert_eq!(factory.server_identity(), &server);
assert!(matches!(
factory.create(),
Err(WekanClientFactoryError::ServerUrl(
ServerUrlError::InsecureHttp
))
));
assert!(
WekanClientFactory::for_server(server, true)
.create()
.is_ok()
);
}
#[test]
fn normalizes_server_paths_without_losing_subpaths() {
let server = ServerUrl::parse("https://example.com/wekan").unwrap();
assert_eq!(server.as_str(), "https://example.com/wekan/");
assert_eq!(
server.join("users/register").unwrap().as_str(),
"https://example.com/wekan/users/register"
);
assert_eq!(
server.join("users/login").unwrap().as_str(),
"https://example.com/wekan/users/login"
);
assert_eq!(
server.join("api/user").unwrap().as_str(),
"https://example.com/wekan/api/user"
);
}
#[test]
fn normalization_preserves_percent_encoded_path_segments() {
let server = ServerUrl::parse("https://example.com/tenant%20one/wekan").unwrap();
assert_eq!(server.as_str(), "https://example.com/tenant%20one/wekan/");
}
#[test]
fn client_factory_permits_http_for_loopback_hosts() {
for raw in [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://[::1]:3000",
] {
let server = ServerUrl::parse(raw).unwrap();
assert!(
WekanClientFactory::for_server(server, false)
.create()
.is_ok()
);
}
}
#[test]
fn rejects_url_components_that_make_credentials_or_keys_ambiguous() {
assert!(matches!(
ServerUrl::parse("https://alice:secret@example.com"),
Err(ServerUrlError::EmbeddedCredentials)
));
assert!(matches!(
ServerUrl::parse("https://example.com?tenant=one"),
Err(ServerUrlError::Query)
));
assert!(matches!(
ServerUrl::parse("https://example.com#fragment"),
Err(ServerUrlError::Fragment)
));
}
#[tokio::test]
async fn loopback_http_bypasses_a_configured_proxy() {
let destination = MockServer::start().await;
let proxy = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/users/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "user-1",
"token": "server-token",
"tokenExpires": "2030-01-02T03:04:05Z"
})))
.expect(1)
.mount(&destination)
.await;
let server = ServerUrl::parse(&destination.uri()).unwrap();
let builder = Client::builder().proxy(reqwest::Proxy::all(proxy.uri()).unwrap());
let client = WekanClient::from_builder(server, builder).unwrap();
client
.register(&RegisterRequest {
username: Some("alice".to_owned()),
email: None,
password: SecretString::from("test-password".to_owned()),
})
.await
.expect("the loopback request must go directly to its destination");
destination.verify().await;
assert!(proxy.received_requests().await.unwrap().is_empty());
}
}