ordinary 0.5.51

Ordinary CLI
Documentation
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")
    });

/// account management subcommands
#[derive(Subcommand)]
pub enum Accounts {
    /// register a new account
    Register {
        /// url where `orindaryd` is running
        host: String,
        /// name of your account with the host
        account: String,

        /// password for your new account
        #[arg(long)]
        password: Option<String>,

        /// base64 encoded invite token
        #[arg(long)]
        invite: Option<String>,
    },
    /// log in to an existing account
    Login {
        /// url where `orindaryd` is running
        host: String,
        /// name of your account with the host
        account: String,

        /// password for your existing account
        #[arg(long)]
        password: Option<String>,

        /// 6 digit TOTP MFA code
        #[arg(long)]
        mfa: Option<String>,
    },
    /// log out of a logged in account
    Logout,
    Access {
        #[command(subcommand)]
        access: Access,
    },
    /// password management
    Password {
        #[command(subcommand)]
        password: Password,
    },
    /// invite another user to a project
    Invite {
        /// name of the account to be invited & registered
        account: String,
        /// which permissions to include in their set
        permissions: Vec<Permission>,
    },
    /// display info for current account
    ///
    /// format: <host> <account> <project> <permissions> <session exp>
    Current,
    /// list all logged in accounts
    List,
    /// switch to a different logged in account
    Switch {
        /// location where `orindaryd` is running
        host: String,
        /// name of the account you'd like to switch to
        account: String,
    },
}

/// access management subcommands
#[derive(Subcommand)]
pub enum Access {
    /// get access
    Get {
        /// how long the client signature is valid for
        #[arg(short, long)]
        min: Option<u16>,
    },
}

/// password management subcommands
#[derive(Subcommand)]
pub enum Password {
    /// reset your password
    Reset {
        /// existing password
        #[arg(long)]
        password: Option<String>,

        /// new password to be set
        #[arg(long)]
        new_password: Option<String>,

        /// 6 digit TOTP MFA code
        #[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);

                // todo: pull from elsewhere if None
                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);

                // todo: pull from elsewhere if None
                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);

                    // todo: pull from elsewhere if None
                    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()
        ))
    }
}