concord 2.3.5

A terminal user interface client for Discord
Documentation
use crate::{
    DiscordClient, Result, config,
    discord::{DiscordAuthSession, validate_token_header},
    error::AppError,
    token_store, tui,
};

pub(super) struct ResolvedToken {
    pub(super) token: String,
    pub(super) warnings: Vec<String>,
}

pub(super) async fn resolve_token(auth_session: DiscordAuthSession) -> Result<ResolvedToken> {
    let mut warnings = Vec::new();

    if let Some(token) = token_store::env_token() {
        validate_token_header(&token)?;
        if let Err(error) = validate_token_with_discord(&token, auth_session.clone()).await {
            match error {
                AppError::DiscordTokenRejected => return Err(AppError::DiscordTokenRejected),
                error => warnings.push(format!(
                    "CONCORD_TOKEN could not be verified: {error}; continuing with it for this session"
                )),
            }
        }
        return Ok(ResolvedToken { token, warnings });
    }

    let credential_store = match config::load_options() {
        Ok(options) => options.credentials.store,
        Err(error) => {
            warnings.push(format!(
                "config could not be loaded for credential settings: {error}; using auto credential storage"
            ));
            config::CredentialStoreMode::default()
        }
    };

    match load_token_from_store(credential_store).await {
        Ok(Some(token)) => {
            if let Err(error) = validate_token_header(&token) {
                warnings.push(format!(
                    "saved Discord token is invalid: {error}; enter a new token"
                ));
                delete_rejected_saved_token(credential_store, &mut warnings).await;
            } else if let Err(error) =
                validate_token_with_discord(&token, auth_session.clone()).await
            {
                match error {
                    AppError::DiscordTokenRejected => {
                        warnings.push(
                            "saved Discord token was rejected by Discord; enter a new token"
                                .to_owned(),
                        );
                        delete_rejected_saved_token(credential_store, &mut warnings).await;
                    }
                    error => warnings.push(format!(
                        "saved Discord token could not be verified: {error}; enter a token to continue"
                    )),
                }
            } else {
                return Ok(ResolvedToken { token, warnings });
            }
        }
        Ok(None) => {}
        Err(error) => warnings.push(format!(
            "credential store unavailable: {error}; enter a token to continue for this session"
        )),
    }

    let token = loop {
        let login_notice = login_notice_for_token_warnings(&warnings);
        let token = tui::prompt_login_with_auth_session(login_notice, auth_session.clone()).await?;
        if let Err(error) = validate_token_header(&token) {
            warnings = vec![format!(
                "entered Discord token is invalid: {error}; enter a new token"
            )];
            continue;
        }
        match validate_token_with_discord(&token, auth_session.clone()).await {
            Ok(()) => break token,
            Err(AppError::DiscordTokenRejected) => {
                warnings = vec![
                    "entered Discord token was rejected by Discord; enter a different token"
                        .to_owned(),
                ];
            }
            Err(error) => {
                warnings = vec![format!(
                    "Discord token could not be verified: {error}; try again"
                )];
            }
        }
    };
    match save_token_to_store(token.clone(), credential_store).await {
        Ok(token_store::TokenSaveLocation::PlaintextFile)
            if credential_store == config::CredentialStoreMode::Auto =>
        {
            warnings.push(
                "system keychain is unavailable; token was saved to the plaintext fallback credential store"
                    .to_owned(),
            );
        }
        Ok(_) => {}
        Err(error) => warnings.push(format!("token was not saved: {error}")),
    }

    Ok(ResolvedToken { token, warnings })
}

async fn validate_token_with_discord(token: &str, auth_session: DiscordAuthSession) -> Result<()> {
    DiscordClient::new_with_auth_session(token.to_owned(), auth_session)?
        .validate_token_authentication()
        .await
}

async fn load_token_from_store(store: config::CredentialStoreMode) -> Result<Option<String>> {
    tokio::task::spawn_blocking(move || token_store::load_token(store))
        .await
        .map_err(|source| AppError::CredentialStoreTask { source })?
}

async fn save_token_to_store(
    token: String,
    store: config::CredentialStoreMode,
) -> Result<token_store::TokenSaveLocation> {
    tokio::task::spawn_blocking(move || token_store::save_token(&token, store))
        .await
        .map_err(|source| AppError::CredentialStoreTask { source })?
}

async fn delete_token_from_store(store: config::CredentialStoreMode) -> Result<()> {
    tokio::task::spawn_blocking(move || token_store::delete_token(store))
        .await
        .map_err(|source| AppError::CredentialStoreTask { source })?
}

async fn delete_rejected_saved_token(
    store: config::CredentialStoreMode,
    warnings: &mut Vec<String>,
) {
    if let Err(error) = delete_token_from_store(store).await {
        warnings.push(format!(
            "rejected saved token could not be deleted: {error}"
        ));
    }
}

fn login_notice_for_token_warnings(warnings: &[String]) -> Option<String> {
    if warnings
        .iter()
        .any(|warning| warning.starts_with("entered Discord token"))
    {
        Some("Discord rejected that token; enter a different token.".to_owned())
    } else if warnings
        .iter()
        .any(|warning| warning.starts_with("saved Discord token was rejected"))
    {
        Some("Saved Discord token was rejected; enter a new token.".to_owned())
    } else if warnings
        .iter()
        .any(|warning| warning.starts_with("Discord token could not be verified"))
    {
        Some("Could not verify the token with Discord; try again.".to_owned())
    } else if warnings
        .iter()
        .any(|warning| warning.starts_with("saved Discord token"))
    {
        Some("Saved Discord token is invalid; enter a new token.".to_owned())
    } else if warnings.is_empty() {
        None
    } else {
        Some("Credential storage is unavailable; token may not be saved.".to_owned())
    }
}

#[cfg(test)]
mod tests {
    use super::login_notice_for_token_warnings;

    #[test]
    fn login_notice_for_token_warnings_reports_user_action() {
        let cases = [
            (
                "saved Discord token is invalid: bad; enter a new token",
                "Saved Discord token is invalid; enter a new token.",
            ),
            (
                "saved Discord token was rejected by Discord; enter a new token",
                "Saved Discord token was rejected; enter a new token.",
            ),
            (
                "entered Discord token was rejected by Discord; enter a different token",
                "Discord rejected that token; enter a different token.",
            ),
            (
                "Discord token could not be verified: network down; try again",
                "Could not verify the token with Discord; try again.",
            ),
            (
                "credential store unavailable: permission denied",
                "Credential storage is unavailable; token may not be saved.",
            ),
        ];

        for (warning, expected) in cases {
            let warnings = vec![warning.to_owned()];
            assert_eq!(
                login_notice_for_token_warnings(&warnings).as_deref(),
                Some(expected)
            );
        }
    }
}