hackatime 0.2.0

Terminal CLI for viewing Hackatime stats with OAuth login
use std::{collections::HashMap, net::SocketAddr, sync::Arc};

use anyhow::{Context, Result};
use axum::{
    Router,
    extract::{Query, State},
    response::Html,
    routing::get,
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::{RngCore, rngs::OsRng};
use reqwest::Client;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::{Mutex, oneshot};
use url::Url;

use crate::config::AppConfig;

const AUTHORIZE_URL: &str = "https://hackatime.hackclub.com/oauth/authorize";
const TOKEN_URL: &str = "https://hackatime.hackclub.com/oauth/token";
const OAUTH_SUCCESS_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Hackatime Terminal Viewer</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #141617;
      --panel: #1d2021;
      --panel-soft: #282828;
      --line: #3c3836;
      --text: #fbf1c7;
      --muted: #bdae93;
      --primary: #d8a657;
      --primary-strong: #e78a4e;
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      min-height: 100vh;
      display: grid;
      place-items: center;
      background: linear-gradient(180deg, #111314 0%, var(--bg) 100%);
      color: var(--text);
      font-family:
        Inter,
        ui-sans-serif,
        system-ui,
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        sans-serif;
    }

    main {
      width: min(92vw, 46rem);
      padding: 2.5rem;
    }

    .card {
      border: 1px solid var(--line);
      background: linear-gradient(180deg, var(--panel) 0%, #181b1c 100%);
      border-radius: 18px;
      overflow: hidden;
      box-shadow:
        0 14px 48px rgba(0, 0, 0, 0.35),
        0 0 0 1px rgba(255, 255, 255, 0.02) inset;
    }

    .topbar {
      display: flex;
      align-items: center;
      gap: 0.6rem;
      padding: 1rem 1.25rem;
      background: rgba(255, 255, 255, 0.02);
      border-bottom: 1px solid var(--line);
    }

    .dot {
      width: 0.72rem;
      height: 0.72rem;
      border-radius: 999px;
    }

    .dot-primary {
      background: var(--primary);
    }

    .dot-warm {
      background: var(--primary-strong);
    }

    .dot-soft {
      background: #89b482;
    }

    .content {
      padding: 2.25rem 2rem;
    }

    .eyebrow {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      margin: 0 0 1rem;
      padding: 0.42rem 0.72rem;
      border: 1px solid rgba(216, 166, 87, 0.28);
      border-radius: 999px;
      background: rgba(216, 166, 87, 0.08);
      color: var(--primary);
      font-size: 0.78rem;
      font-weight: 700;
      letter-spacing: 0.14em;
      text-transform: uppercase;
    }

    h1 {
      margin: 0 0 0.75rem;
      font-size: clamp(2.1rem, 4vw, 3.6rem);
      line-height: 1.02;
      font-weight: 800;
      letter-spacing: -0.04em;
    }

    p {
      margin: 0;
      max-width: 36rem;
      color: var(--muted);
      font-size: 1.02rem;
      line-height: 1.7;
    }

    .rule {
      width: 100%;
      height: 1px;
      margin: 1.5rem 0;
      background: linear-gradient(90deg, var(--primary), rgba(216, 166, 87, 0.12) 72%, transparent 100%);
    }

    strong {
      color: var(--text);
      font-weight: 700;
    }
  </style>
</head>
<body>
  <main>
    <section class="card">
      <div class="topbar">
        <span class="dot dot-primary"></span>
        <span class="dot dot-warm"></span>
        <span class="dot dot-soft"></span>
      </div>
      <div class="content">
        <p class="eyebrow">OAuth Complete</p>
        <h1>Hackatime Terminal Viewer</h1>
        <div class="rule"></div>
        <p><strong>You're signed in.</strong> You can close this tab and return to the terminal to see your stats.</p>
      </div>
    </section>
  </main>
</body>
</html>
"#;

#[derive(Debug, Clone)]
pub struct PkcePair {
    pub verifier: String,
    pub challenge: String,
}

#[derive(Debug, Clone)]
pub struct OAuthCallback {
    pub code: String,
    pub state: String,
}

#[derive(Clone)]
struct CallbackState {
    sender: Arc<Mutex<Option<oneshot::Sender<Result<OAuthCallback, String>>>>>,
}

#[derive(Debug, Deserialize)]
struct CallbackParams {
    code: Option<String>,
    state: Option<String>,
    error: Option<String>,
    error_description: Option<String>,
}

#[derive(Debug, Deserialize)]
struct TokenResponse {
    access_token: String,
}

pub fn generate_pkce_pair() -> PkcePair {
    let mut random = [0_u8; 32];
    OsRng.fill_bytes(&mut random);

    let verifier = URL_SAFE_NO_PAD.encode(random);
    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));

    PkcePair {
        verifier,
        challenge,
    }
}

pub fn random_state() -> String {
    let mut random = [0_u8; 24];
    OsRng.fill_bytes(&mut random);
    URL_SAFE_NO_PAD.encode(random)
}

pub async fn authorize(config: &AppConfig) -> Result<String> {
    let pkce = generate_pkce_pair();
    let state = random_state();
    let callback = listen_for_callback(&config.redirect_uri);
    let auth_url = build_authorize_url(config, &pkce, &state)?;

    open::that(auth_url.as_str()).context("failed to open browser for OAuth login")?;

    let callback_data = callback.await?.map_err(anyhow::Error::msg)?;
    if callback_data.state != state {
        anyhow::bail!("OAuth state mismatch; refusing to continue");
    }

    exchange_code(config, &callback_data.code, &pkce.verifier).await
}

fn build_authorize_url(config: &AppConfig, pkce: &PkcePair, state: &str) -> Result<Url> {
    let mut url = Url::parse(AUTHORIZE_URL)?;
    url.query_pairs_mut()
        .append_pair("client_id", &config.client_id)
        .append_pair("redirect_uri", &config.redirect_uri)
        .append_pair("response_type", "code")
        .append_pair("scope", &config.scopes)
        .append_pair("state", state)
        .append_pair("code_challenge", &pkce.challenge)
        .append_pair("code_challenge_method", "S256");
    Ok(url)
}

fn listen_for_callback(
    redirect_uri: &str,
) -> impl std::future::Future<Output = Result<Result<OAuthCallback, String>>> {
    let redirect = redirect_uri.to_string();

    async move {
        let parsed = Url::parse(&redirect)?;
        let host = parsed
            .host_str()
            .context("redirect URI is missing a host")?
            .to_string();
        let port = parsed
            .port_or_known_default()
            .context("redirect URI is missing a port")?;
        let path = parsed.path().to_string();
        let addr: SocketAddr = format!("{host}:{port}")
            .parse()
            .with_context(|| format!("invalid redirect socket address: {host}:{port}"))?;

        let (sender, receiver) = oneshot::channel::<Result<OAuthCallback, String>>();
        let state = CallbackState {
            sender: Arc::new(Mutex::new(Some(sender))),
        };

        let app = Router::new()
            .route(&path, get(handle_callback))
            .with_state(state);

        let listener = tokio::net::TcpListener::bind(addr)
            .await
            .with_context(|| format!("failed to bind OAuth callback listener on {addr}"))?;

        let server = tokio::spawn(async move {
            axum::serve(listener, app)
                .await
                .map_err(anyhow::Error::from)
        });

        let result = receiver.await.context("did not receive OAuth callback")?;
        server.abort();
        Ok(result)
    }
}

async fn handle_callback(
    State(state): State<CallbackState>,
    Query(params): Query<HashMap<String, String>>,
) -> Html<&'static str> {
    let callback_params = CallbackParams {
        code: params.get("code").cloned(),
        state: params.get("state").cloned(),
        error: params.get("error").cloned(),
        error_description: params.get("error_description").cloned(),
    };

    let result = if let Some(error) = callback_params.error {
        Err(callback_params
            .error_description
            .unwrap_or_else(|| format!("OAuth error: {error}")))
    } else {
        match (callback_params.code, callback_params.state) {
            (Some(code), Some(state)) => Ok(OAuthCallback { code, state }),
            _ => Err("Missing code or state in OAuth callback".to_string()),
        }
    };

    if let Some(sender) = state.sender.lock().await.take() {
        let _ = sender.send(result);
    }

    Html(OAUTH_SUCCESS_HTML)
}

async fn exchange_code(config: &AppConfig, code: &str, verifier: &str) -> Result<String> {
    let client = Client::new();
    let response = client
        .post(TOKEN_URL)
        .form(&[
            ("grant_type", "authorization_code"),
            ("client_id", config.client_id.as_str()),
            ("redirect_uri", config.redirect_uri.as_str()),
            ("code", code),
            ("code_verifier", verifier),
        ])
        .send()
        .await
        .context("failed to exchange OAuth code for token")?
        .error_for_status()
        .context("Hackatime rejected the OAuth token exchange")?;

    let payload = response
        .json::<TokenResponse>()
        .await
        .context("failed to decode OAuth token response")?;

    Ok(payload.access_token)
}