semora-cli 0.4.1

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `semora login` (browser approval, PKCE) and `semora logout`.

use std::io::{BufRead, Write};
use std::time::Duration;

use anyhow::{Context, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rand::RngCore;
use semora_proto::{CliTokenRequest, CliTokenResponse};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

use crate::client::{Client, check, http_client, no_server_error};
use crate::config::origin;
use crate::credentials::Stored;
use crate::{Ctx, exit, output};

const LOGIN_TIMEOUT: Duration = Duration::from_secs(600);

fn random_b64(n: usize) -> String {
    let mut buf = vec![0u8; n];
    rand::rngs::OsRng.fill_bytes(&mut buf);
    URL_SAFE_NO_PAD.encode(buf)
}

pub(crate) fn hostname() -> String {
    for var in ["COMPUTERNAME", "HOSTNAME"] {
        if let Ok(h) = std::env::var(var)
            && !h.trim().is_empty()
        {
            return h.trim().to_lowercase();
        }
    }
    std::process::Command::new("hostname")
        .output()
        .ok()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase())
        .filter(|h| !h.is_empty())
        .unwrap_or_else(|| "this-machine".into())
}

const DONE_PAGE: &str = "<!doctype html><meta charset=utf-8><title>Semora</title>\
<body style=\"font:16px system-ui;margin:4rem auto;max-width:32rem\"><h1>Signed in</h1>\
<p>The Semora CLI has its key. You can close this tab.</p>";
const DENIED_PAGE: &str = "<!doctype html><meta charset=utf-8><title>Semora</title>\
<body style=\"font:16px system-ui;margin:4rem auto;max-width:32rem\"><h1>Not signed in</h1>\
<p>The request was denied. You can close this tab.</p>";

/// Serves one `/callback` request on the loopback listener; returns its query pairs.
async fn wait_for_callback(listener: TcpListener) -> anyhow::Result<Vec<(String, String)>> {
    loop {
        let (mut sock, _) = listener.accept().await?;
        let mut buf = Vec::with_capacity(2048);
        let mut chunk = [0u8; 1024];
        while !buf.windows(4).any(|w| w == b"\r\n\r\n") && buf.len() < 16 * 1024 {
            let n = sock.read(&mut chunk).await?;
            if n == 0 {
                break;
            }
            buf.extend_from_slice(&chunk[..n]);
        }
        let head = String::from_utf8_lossy(&buf);
        let target = head
            .lines()
            .next()
            .and_then(|l| l.split_whitespace().nth(1))
            .unwrap_or("")
            .to_string();
        let Some(query) = target.strip_prefix("/callback?") else {
            let _ = sock
                .write_all(
                    b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
                )
                .await;
            continue;
        };
        let pairs: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes())
            .into_owned()
            .collect();
        let body = if pairs.iter().any(|(k, _)| k == "error") {
            DENIED_PAGE
        } else {
            DONE_PAGE
        };
        let resp = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            body.len()
        );
        let _ = sock.write_all(resp.as_bytes()).await;
        let _ = sock.shutdown().await;
        return Ok(pairs);
    }
}

pub async fn login(ctx: &Ctx, no_browser: bool, name: Option<String>) -> anyhow::Result<i32> {
    let server = ctx.settings.server.clone().ok_or_else(no_server_error)?;
    let base = origin(&server);
    let name = name.unwrap_or_else(|| format!("cli@{}", hostname()));
    let verifier = random_b64(32);
    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
    let state = random_b64(16);

    let listener = if no_browser {
        None
    } else {
        Some(
            TcpListener::bind("127.0.0.1:0")
                .await
                .context("opening a local port for the sign-in callback")?,
        )
    };
    let port = match &listener {
        Some(l) => l.local_addr()?.port(),
        None => 0,
    };
    let query = url::form_urlencoded::Serializer::new(String::new())
        .append_pair("port", &port.to_string())
        .append_pair("state", &state)
        .append_pair("code_challenge", &challenge)
        .append_pair("name", &name)
        .finish();
    let url = format!("{base}/cli/authorize?{query}");

    eprintln!("Sign in to {base} in your browser and allow the CLI:");
    println!("{url}");
    std::io::stdout().flush()?;

    let code = match listener {
        Some(listener) => {
            if ctx.settings.open_browser && open::that(&url).is_err() {
                output::warn("could not open a browser; open the URL above yourself");
            }
            let pairs = tokio::time::timeout(LOGIN_TIMEOUT, wait_for_callback(listener))
                .await
                .map_err(|_| exit::timeout("no answer from the browser within 10 minutes"))??;
            let get = |k: &str| {
                pairs
                    .iter()
                    .find(|(key, _)| key == k)
                    .map(|(_, v)| v.clone())
            };
            if get("state").as_deref() != Some(state.as_str()) {
                bail!("the sign-in callback's state did not match; start `semora login` again");
            }
            if let Some(err) = get("error") {
                return Err(exit::auth(format!("sign-in was not allowed ({err})")));
            }
            get("code").context("the sign-in callback had no code")?
        }
        None => {
            eprint!("After you allow it, paste the code shown in the browser: ");
            std::io::stderr().flush()?;
            let line = tokio::task::spawn_blocking(|| {
                let mut s = String::new();
                std::io::stdin().lock().read_line(&mut s).map(|_| s)
            })
            .await??;
            let code = line.trim().to_string();
            if code.is_empty() {
                bail!("no code entered");
            }
            code
        }
    };

    let resp = http_client()?
        .post(format!("{base}/api/v1/cli/token"))
        .json(&CliTokenRequest {
            code,
            code_verifier: verifier,
        })
        .send()
        .await
        .with_context(|| format!("could not reach {base}"))?;
    let token: CliTokenResponse = check(resp, "sign-in").await?.json().await?;
    let stored = ctx.store.set(&base, &token.api_key, &token.name)?;
    let place = match stored {
        Stored::Keyring => "the system keyring".to_string(),
        Stored::File => ctx.store.path().display().to_string(),
    };
    eprintln!(
        "Signed in to {base} as {}. Key \"{}\" saved in {place}.",
        token.user.email, token.name
    );
    if ctx.settings.key_source == crate::config::KeySource::Env {
        output::warn("SEMORA_API_KEY is set, so it wins over this login until you unset it");
    }
    Ok(exit::OK)
}

pub async fn logout(ctx: &Ctx) -> anyhow::Result<i32> {
    let server = ctx.settings.server.clone().ok_or_else(no_server_error)?;
    let base = origin(&server);
    let Some(key) = ctx.store.get(&base) else {
        eprintln!("No stored login for {base}.");
        if ctx.settings.key_source == crate::config::KeySource::Env {
            eprintln!("SEMORA_API_KEY comes from the environment or a .env file; remove it there.");
        }
        return Ok(exit::OK);
    };
    let mut settings = ctx.settings.clone();
    settings.api_key = Some(key);
    let client = Client::new(&settings)?;
    match client.whoami().await {
        Ok(w) => {
            if let Some(k) = w.key
                && let Err(e) = client.revoke_key(k.id).await
            {
                output::warn(format!("could not revoke the key on the server: {e:#}"));
            }
        }
        Err(e) if exit::code_of(&e) == exit::AUTH => {} // already revoked or expired
        Err(e) => output::warn(format!(
            "could not reach the server to revoke the key: {e:#}"
        )),
    }
    ctx.store.remove(&base)?;
    eprintln!("Signed out of {base}.");
    Ok(exit::OK)
}