fundaia 0.52.3

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::time::Duration;

use anyhow::{bail, Context, Result};
use owo_colors::OwoColorize;

use crate::api::client::ApiClient;
use crate::api::models::{CollectedAuthorization, StartedAuthorization};
use crate::config::{normalise_url, same_host, Config, Profile};
use crate::output::{self, Format};
use crate::render::theme;

pub async fn run(url: &str, profile_name: &str, open_browser: bool) -> Result<()> {
    let base = normalise_url(url);
    let client = ApiClient::new(&base, None)?;

    let started: StartedAuthorization = client
        .post(
            "/api/auth/cli/authorizations",
            &serde_json::json!({
                "clientName": "fundaia",
                "clientHost": machine_name(),
            }),
        )
        .await
        .with_context(|| format!("could not reach {base}"))?;

    println!();
    println!(
        "  {}  {}",
        "Código:".style(theme::muted()),
        started.user_code.style(theme::strong())
    );
    println!(
        "  {}  {}",
        "Página:".style(theme::muted()),
        started.verification_uri.style(theme::accent())
    );
    println!();

    if open_browser && !belongs_to(&started.verification_uri_complete, client.base()) {
        println!(
            "  {}",
            "El servidor ha enviado una página que no es suya; ábrela tú si te fías."
                .style(theme::warning())
        );
    } else if open_browser {
        match open::that_detached(&started.verification_uri_complete) {
            Ok(()) => println!("  {}", "Abriendo el navegador…".style(theme::muted())),
            Err(_) => println!(
                "  {}",
                "Abre esa página para autorizar.".style(theme::muted())
            ),
        }
    } else {
        println!(
            "  {}",
            "Abre esa página para autorizar.".style(theme::muted())
        );
    }

    println!("  {}", "Esperando…".style(theme::muted()));

    let token = wait_for_token(&client, &started).await?;

    let mut config = Config::load()?;
    config.set(
        profile_name,
        Profile {
            url: base.clone(),
            token: token.clone(),
        },
    );
    config.current = Some(profile_name.to_owned());
    let path = config.save()?;

    let identity = client.with_token(token).whoami().await.ok().flatten();
    let who = identity
        .map(|principal| principal.display_name)
        .unwrap_or_else(|| "esta instancia".to_owned());

    println!();
    println!(
        "  {} Conectado a {} como {}",
        theme::CHECK.style(theme::success()),
        base.style(theme::strong()),
        who.style(theme::strong())
    );
    println!(
        "  {} {}",
        "Guardado en".style(theme::muted()),
        path.display().style(theme::muted())
    );
    println!();

    Ok(())
}

fn belongs_to(page: &str, base: &str) -> bool {
    let secure = page.starts_with("https://")
        || (page.starts_with("http://") && base.starts_with("http://"));
    secure && same_host(page, base)
}

const WAIT_LIMIT: Duration = Duration::from_secs(15 * 60);

async fn wait_for_token(client: &ApiClient, started: &StartedAuthorization) -> Result<String> {
    let interval = Duration::from_secs(started.interval_seconds.clamp(1, 60));
    let deadline = tokio::time::Instant::now() + WAIT_LIMIT;

    loop {
        tokio::time::sleep(interval).await;
        if tokio::time::Instant::now() > deadline {
            bail!("nobody authorised the request in time — run `fundaia login` again");
        }

        let collected: CollectedAuthorization = client
            .post(
                "/api/auth/cli/collect",
                &serde_json::json!({ "deviceSecret": started.device_secret }),
            )
            .await?;

        match collected.status.as_str() {
            "pending" => continue,
            "approved" => {
                return collected.token.ok_or_else(|| {
                    anyhow::anyhow!("the server approved the request but sent no token")
                })
            }
            "denied" => bail!("the request was rejected in the browser"),
            "expired" => bail!("the code expired — run `fundaia login` again"),
            other => bail!("the server answered with an unexpected status: {other}"),
        }
    }
}

fn machine_name() -> String {
    hostname::get()
        .ok()
        .and_then(|name| name.into_string().ok())
        .unwrap_or_else(|| "una máquina sin nombre".to_owned())
}

pub async fn whoami(client: &ApiClient, format: Format) -> Result<()> {
    let identity = client.whoami().await?;

    if format.is_json() {
        output::emit(&serde_json::json!({
            "url": client.base(),
            "principal": identity,
        }));
        return Ok(());
    }

    match identity {
        Some(principal) => println!(
            "  {} en {}",
            principal.display_name.style(theme::strong()),
            client.base().style(theme::muted()),
        ),
        None => println!("  No hay sesión."),
    }
    Ok(())
}

pub async fn revoke_session(client: &ApiClient, session_id: &str) -> Result<()> {
    client.revoke_session(session_id).await?;
    println!("Sesión cerrada.");
    Ok(())
}

pub fn logout(profile_name: Option<&str>) -> Result<()> {
    let mut config = Config::load()?;
    let name = profile_name
        .map(str::to_owned)
        .or_else(|| config.current.clone())
        .unwrap_or_else(|| "default".to_owned());

    if config.profiles.remove(&name).is_none() {
        bail!("there is no profile called `{name}`");
    }

    if config.current.as_deref() == Some(name.as_str()) {
        config.current = config.profiles.keys().next().cloned();
    }

    config.save()?;

    println!(
        "  {} Perfil `{name}` olvidado en esta máquina.",
        theme::CHECK.style(theme::success())
    );
    println!(
        "  {}",
        "El token sigue siendo válido: revócalo en la web si quieres retirarlo del todo."
            .style(theme::muted())
    );

    Ok(())
}

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

    #[test]
    fn it_should_open_a_page_on_the_instance_signed_in_to() {
        assert!(belongs_to(
            "https://infra.example/cli/abc",
            "https://infra.example"
        ));
    }

    #[test]
    fn it_should_refuse_a_page_on_another_host() {
        assert!(!belongs_to(
            "https://evil.example/cli/abc",
            "https://infra.example"
        ));
    }

    #[test]
    fn it_should_refuse_a_downgrade_to_plain_http() {
        assert!(!belongs_to(
            "http://infra.example/cli/abc",
            "https://infra.example"
        ));
    }

    #[test]
    fn it_should_refuse_a_scheme_that_is_not_http() {
        assert!(!belongs_to("file:///etc/passwd", "https://infra.example"));
    }
}