use crate::{Permission, add_http};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as b64};
use clap::Subcommand;
use ordinary_api::client::{AccountMeta, OrdinaryApiClient};
use qrcodegen::{QrCode, QrCodeEcc};
use std::error::Error;
use std::path::Path;
use time::format_description::BorrowedFormatItem;
use time::{UtcDateTime, format_description};
pub static GMT_FORMAT: std::sync::LazyLock<Vec<BorrowedFormatItem<'static>>> =
std::sync::LazyLock::new(|| {
format_description::parse(
"[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT",
)
.expect("failed to create format")
});
#[derive(Subcommand)]
pub enum Accounts {
Register {
host: String,
account: String,
#[arg(long)]
password: Option<String>,
#[arg(long)]
invite: Option<String>,
},
Login {
host: String,
account: String,
#[arg(long)]
password: Option<String>,
#[arg(long)]
mfa: Option<String>,
},
Logout,
Access {
#[command(subcommand)]
access: Access,
},
Password {
#[command(subcommand)]
password: Password,
},
Invite {
account: String,
permissions: Vec<Permission>,
},
Current,
List,
Switch {
host: String,
account: String,
},
}
#[derive(Subcommand)]
pub enum Access {
Get {
#[arg(short, long)]
min: Option<u16>,
},
}
#[derive(Subcommand)]
pub enum Password {
Reset {
#[arg(long)]
password: Option<String>,
#[arg(long)]
new_password: Option<String>,
#[arg(long)]
mfa: Option<String>,
},
}
fn switch_account(host: &str, account: &str) -> Result<(), Box<dyn Error>> {
tracing::debug!("switching accounts");
let cli_path = Path::new(".ordinary").join("cli");
if !cli_path.exists() {
std::fs::create_dir_all(&cli_path)?;
}
let host = if let Some(stripped) = host.strip_prefix("https://") {
stripped
} else if let Some(stripped) = host.strip_prefix("http://") {
stripped
} else {
host
};
let account_path = cli_path.join("account");
std::fs::write(account_path, format!("{account}@{host}"))?;
Ok(())
}
pub fn get_current_account(insecure: bool) -> Result<AccountMeta, Box<dyn Error>> {
tracing::debug!("getting current account");
let account_path = Path::new(".ordinary").join("cli").join("account");
let pair = std::fs::read_to_string(account_path)?;
let split_pair = pair.split('@').collect::<Vec<&str>>();
let mut account_meta = OrdinaryApiClient::get_account(split_pair[1], split_pair[0])?;
account_meta.host = add_http(&account_meta.host, insecure);
Ok(account_meta)
}
impl Accounts {
#[allow(clippy::too_many_lines)]
pub async fn handle(
&self,
host_domain: Option<&str>,
accept_invalid_certs: bool,
insecure: bool,
) -> Result<(), Box<dyn Error>> {
match self {
Self::Register {
host,
account,
password,
invite,
} => {
let client =
OrdinaryApiClient::new(host, account, host_domain, accept_invalid_certs);
let password = password.to_owned().unwrap_or_default();
let invite = invite.to_owned().unwrap_or_default();
let (totp, recovery_codes_str) = client.register(&password, &invite).await?;
let mfa_url = totp.get_url();
let qr: QrCode = QrCode::encode_text(&mfa_url, QrCodeEcc::Medium)?;
let border: i32 = 4;
for y in -border..qr.size() + border {
for x in -border..qr.size() + border {
let c: char = if qr.get_module(x, y) { 'â–ˆ' } else { ' ' };
print!("{c}{c}");
}
println!();
}
println!("{mfa_url}");
let mut recovery_code = String::new();
let mut recovery_codes: Vec<String> = vec![];
for (i, c) in recovery_codes_str.chars().enumerate() {
if i > 0 && i % 11 == 0 {
recovery_codes.push(recovery_code.clone());
recovery_code = c.to_string();
} else if i == recovery_codes_str.len() - 1 {
recovery_code.push(c);
recovery_codes.push(recovery_code.clone());
} else {
recovery_code.push(c);
}
}
println!("recovery codes:");
for code in recovery_codes {
println!("- {code}");
}
println!("\nlog in: `ordinary login --help`");
}
Self::Login {
host,
account,
password,
mfa,
} => {
let client =
OrdinaryApiClient::new(host, account, host_domain, accept_invalid_certs);
let password = password.to_owned().unwrap_or_default();
let mfa = mfa.to_owned().unwrap_or_default();
client.login(&password, &mfa).await?;
switch_account(host, account)?;
}
Self::Logout => {
println!("todo: logout");
}
Self::Access { access } => match access {
Access::Get { min } => {
let AccountMeta {
host,
name: account,
..
} = get_current_account(insecure)?;
let client =
OrdinaryApiClient::new(&host, &account, host_domain, accept_invalid_certs);
let access_token = client.get_access(min.map(|m| u32::from(m) * 60)).await?;
let b64_token = b64.encode(access_token);
println!("{b64_token}");
}
},
Self::Password { password } => match password {
Password::Reset {
password,
new_password,
mfa,
} => {
let AccountMeta {
host,
name: account,
..
} = get_current_account(insecure)?;
let client =
OrdinaryApiClient::new(&host, &account, host_domain, accept_invalid_certs);
let password = password.to_owned().unwrap_or_default();
let new_password = new_password.to_owned().unwrap_or_default();
let mfa = mfa.to_owned().unwrap_or_default();
client
.reset_password(&password, &mfa, &new_password)
.await?;
}
},
Self::Invite {
account: their_account,
permissions,
} => {
let AccountMeta {
host,
name: account,
project,
..
} = get_current_account(insecure)?;
let client =
OrdinaryApiClient::new(&host, &account, host_domain, accept_invalid_certs);
client
.invite_api_account(
&project,
their_account,
permissions
.iter()
.map(Permission::as_u8)
.collect::<Vec<u8>>(),
)
.await?;
}
Self::Current => {
let account = get_current_account(insecure)?;
let readable = Self::account_meta_to_readable(account)?;
println!("{readable}");
}
Self::List => {
for account in OrdinaryApiClient::list_accounts()? {
let readable = Self::account_meta_to_readable(account)?;
println!("{readable}");
}
}
Self::Switch { host, account } => {
switch_account(host, account)?;
}
}
Ok(())
}
fn account_meta_to_readable(account: AccountMeta) -> Result<String, Box<dyn Error>> {
let mut perms_str = String::new();
for permission in account.permissions {
perms_str.push_str(Permission::from_u8(permission).as_str());
perms_str.push(' ');
}
perms_str.pop();
let exp = UtcDateTime::from_unix_timestamp(i64::from(account.refresh_exp))?;
let exp_fmt = exp.format(&GMT_FORMAT)?;
Ok(format!(
"{} {} {} {} {}",
account.host,
account.name,
account.project,
perms_str,
exp_fmt.as_str()
))
}
}