/* * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use std::{ collections::HashMap, fmt::Display, io::{BufRead, Write}, time::Duration, }; use clap::Parser; use console::style; use jmap_client::client::Credentials; use modules::{ cli::{Cli, Client, Commands}, is_localhost, UnwrapResult, }; use reqwest::{header::AUTHORIZATION, Method, StatusCode}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use crate::modules::OAuthResponse; pub mod modules; #[tokio::main] async fn main() -> std::io::Result<()> { let args = Cli::parse(); let url = args .url .or_else(|| std::env::var("URL").ok()) .map(|url| url.trim_end_matches('/').to_string()) .unwrap_or_else(|| { eprintln!("No URL specified. Use --url or set the URL environment variable."); std::process::exit(1); }); let client = Client { credentials: if let Some(credentials) = args.credentials { parse_credentials(&credentials) } else if let Ok(credentials) = std::env::var("CREDENTIALS") { parse_credentials(&credentials) } else { let credentials = rpassword::prompt_password( "\nEnter administrator credentials or press [ENTER] to use OAuth: ", ) .unwrap(); if !credentials.is_empty() { parse_credentials(&credentials) } else { oauth(&url).await } }, timeout: args.timeout, url, }; match args.command { Commands::Import(command) => { command.exec(client).await; } Commands::Export(command) => { command.exec(client).await; } Commands::Server(command) => command.exec(client).await, /*Commands::Account(command) => command.exec(client).await, Commands::Domain(command) => command.exec(client).await, Commands::List(command) => command.exec(client).await, Commands::Group(command) => command.exec(client).await,*/ Commands::Queue(command) => command.exec(client).await, Commands::Report(command) => command.exec(client).await, } Ok(()) } fn parse_credentials(credentials: &str) -> Credentials { if let Some((account, secret)) = credentials.split_once(':') { Credentials::basic(account, secret) } else { Credentials::basic("admin", credentials) } } async fn oauth(url: &str) -> Credentials { let metadata: HashMap = serde_json::from_slice( &reqwest::Client::builder() .danger_accept_invalid_certs(is_localhost(url)) .build() .unwrap_or_default() .get(format!("{}/.well-known/oauth-authorization-server", url)) .send() .await .unwrap_result("send OAuth GET request") .bytes() .await .unwrap_result("fetch bytes"), ) .unwrap_result("deserialize OAuth GET response"); let token_endpoint = metadata.property("token_endpoint"); let mut params: HashMap = HashMap::from_iter([("client_id".to_string(), "Stalwart_CLI".to_string())]); let response: HashMap = serde_json::from_slice( &reqwest::Client::builder() .danger_accept_invalid_certs(is_localhost(url)) .build() .unwrap_or_default() .post(metadata.property("device_authorization_endpoint")) .form(¶ms) .send() .await .unwrap_result("send OAuth POST request") .bytes() .await .unwrap_result("fetch bytes"), ) .unwrap_result("deserialize OAuth POST response"); params.insert( "grant_type".to_string(), "urn:ietf:params:oauth:grant-type:device_code".to_string(), ); params.insert( "device_code".to_string(), response.property("device_code").to_string(), ); print!( "\nAuthenticate this request using code {} at {}. Please ENTER when done.", style(response.property("user_code")).bold(), style(response.property("verification_uri")).bold().dim() ); std::io::stdout().flush().unwrap(); std::io::stdin().lock().lines().next(); let mut response: HashMap = serde_json::from_slice( &reqwest::Client::builder() .danger_accept_invalid_certs(is_localhost(url)) .build() .unwrap_or_default() .post(token_endpoint) .form(¶ms) .send() .await .unwrap_result("send OAuth POST request") .bytes() .await .unwrap_result("fetch bytes"), ) .unwrap_result("deserialize OAuth POST response"); if let Some(serde_json::Value::String(access_token)) = response.remove("access_token") { Credentials::Bearer(access_token) } else { eprintln!( "OAuth failed with code {}.", response .get("error") .and_then(|s| s.as_str()) .unwrap_or("") ); std::process::exit(1); } } #[derive(Deserialize)] #[serde(untagged)] pub enum Response { Error(ManagementApiError), Data { data: T }, } #[derive(Deserialize)] #[serde(tag = "error")] #[serde(rename_all = "camelCase")] pub enum ManagementApiError { FieldAlreadyExists { field: String, value: String }, FieldMissing { field: String }, NotFound { item: String }, Unsupported { details: String }, AssertFailed, Other { details: String }, } impl Client { pub async fn into_jmap_client(self) -> jmap_client::client::Client { jmap_client::client::Client::new() .credentials(self.credentials) .accept_invalid_certs(is_localhost(&self.url)) .timeout(Duration::from_secs(self.timeout.unwrap_or(60))) .connect(&self.url) .await .unwrap_or_else(|err| { eprintln!("Failed to connect to JMAP server {}: {}.", &self.url, err); std::process::exit(1); }) } pub async fn http_request( &self, method: Method, url: &str, body: Option, ) -> R { self.try_http_request(method, url, body) .await .unwrap_or_else(|| { eprintln!("Request failed: No data returned."); std::process::exit(1); }) } pub async fn try_http_request( &self, method: Method, url: &str, body: Option, ) -> Option { let url = format!( "{}{}{}", self.url, if !self.url.ends_with('/') && !url.starts_with('/') { "/" } else { "" }, url ); let mut request = reqwest::Client::builder() .danger_accept_invalid_certs(is_localhost(&url)) .timeout(Duration::from_secs(self.timeout.unwrap_or(60))) .build() .unwrap_or_default() .request(method, url) .header( AUTHORIZATION, match &self.credentials { Credentials::Basic(s) => format!("Basic {s}"), Credentials::Bearer(s) => format!("Bearer {s}"), }, ); if let Some(body) = body { request = request.body(serde_json::to_string(&body).unwrap_result("serialize body")); } let response = request.send().await.unwrap_result("send HTTP request"); match response.status() { StatusCode::OK => (), StatusCode::NOT_FOUND => { return None; } StatusCode::UNAUTHORIZED => { eprintln!("Authentication failed. Make sure the credentials are correct and that the account has administrator rights."); std::process::exit(1); } _ => { eprintln!( "Request failed: {}", response.text().await.unwrap_result("fetch text") ); std::process::exit(1); } } let bytes = response.bytes().await.unwrap_result("fetch bytes"); match serde_json::from_slice::>(&bytes).unwrap_result(&format!( "deserialize response {}", String::from_utf8_lossy(bytes.as_ref()) )) { Response::Data { data } => Some(data), Response::Error(error) => { eprintln!("Request failed: {error})"); std::process::exit(1); } } } } impl Display for ManagementApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ManagementApiError::FieldAlreadyExists { field, value } => { write!(f, "Field {} already exists with value {}.", field, value) } ManagementApiError::FieldMissing { field } => { write!(f, "Field {} is missing.", field) } ManagementApiError::NotFound { item } => { write!(f, "{} not found.", item) } ManagementApiError::Unsupported { details } => { write!(f, "Unsupported: {}", details) } ManagementApiError::AssertFailed => { write!(f, "Assertion failed.") } ManagementApiError::Other { details } => { write!(f, "{}", details) } } } }