patreon 0.2.1

Patreon client
Documentation
use patreon::{OAuthClient, oauth::scopes};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use tokio::sync::oneshot;

fn env(name: &str) -> String {
    std::env::var(name).unwrap_or_else(|_| panic!("{name} is required"))
}

fn env_port() -> u16 {
    match std::env::var("PORT") {
        Ok(v) => v
            .parse::<u16>()
            .unwrap_or_else(|_| panic!("PORT must be a valid u16, got: {v}")),
        Err(_) => 8080,
    }
}

fn open_browser(url: &str) {
    if let Ok(browser) = std::env::var("BROWSER") {
        let _ = std::process::Command::new(browser).arg(url).spawn();
        return;
    }

    if cfg!(target_os = "macos") {
        let _ = std::process::Command::new("open").arg(url).spawn();
    } else if cfg!(target_os = "windows") {
        let _ = std::process::Command::new("cmd")
            .args(["/C", "start", url])
            .spawn();
    } else {
        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
    }
}

fn http_ok(stream: &mut TcpStream, body: &str) -> std::io::Result<()> {
    let body_bytes = body.as_bytes();
    write!(
        stream,
        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        body_bytes.len()
    )?;
    stream.write_all(body_bytes)?;
    Ok(())
}

fn http_redirect(stream: &mut TcpStream, location: &str) -> std::io::Result<()> {
    write!(
        stream,
        "HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
        location
    )?;
    Ok(())
}

fn read_http_request_path(stream: &mut TcpStream) -> std::io::Result<String> {
    let mut buf = [0u8; 8192];
    let n = stream.read(&mut buf)?;
    let req = String::from_utf8_lossy(&buf[..n]);
    let first_line = req.lines().next().unwrap_or_default();
    let mut parts = first_line.split_whitespace();
    let _method = parts.next().unwrap_or_default();
    let path = parts.next().unwrap_or("/");
    Ok(path.to_string())
}

fn parse_query(path: &str) -> (String, Vec<(String, String)>) {
    let mut parts = path.splitn(2, '?');
    let route = parts.next().unwrap_or("/").to_string();
    let query = parts.next().unwrap_or("");
    let mut out = Vec::new();
    for pair in query.split('&').filter(|s| !s.is_empty()) {
        let mut kv = pair.splitn(2, '=');
        let k = kv.next().unwrap_or("").to_string();
        let v = kv.next().unwrap_or("");
        let v = urlencoding::decode(v).unwrap_or_else(|_| v.into()).to_string();
        out.push((k, v));
    }
    (route, out)
}

fn gen_state() -> String {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    format!("state-{nanos}")
}

fn run_local_server(
    listener: TcpListener,
    auth_url: String,
    expected_state: String,
    tx: oneshot::Sender<String>,
) -> std::io::Result<()> {
    let port = listener.local_addr()?.port();
    eprintln!("Listening on http://localhost:{port}");

    for stream in listener.incoming() {
        let mut stream = match stream {
            Ok(s) => s,
            Err(e) => {
                eprintln!("accept failed: {e}");
                continue;
            }
        };

        let path = match read_http_request_path(&mut stream) {
            Ok(p) => p,
            Err(e) => {
                eprintln!("read request failed: {e}");
                continue;
            }
        };

        let (route, query) = parse_query(&path);

        if route == "/favicon.ico" {
            let _ = http_ok(&mut stream, "");
            continue;
        }

        if route == "/" {
            let _ = http_redirect(&mut stream, &auth_url);
            continue;
        }

        if route != "/callback" {
            let _ = http_ok(
                &mut stream,
                "<h1>Not Found</h1><p>Try <a href=\"/\">/</a>.</p>",
            );
            continue;
        }

        let mut code: Option<String> = None;
        let mut state: Option<String> = None;
        for (k, v) in query {
            match k.as_str() {
                "code" => code = Some(v),
                "state" => state = Some(v),
                _ => {}
            }
        }

        if state.as_deref() != Some(&expected_state) {
            let _ = http_ok(
                &mut stream,
                "<h1>Invalid state</h1><p>Please restart the flow.</p>",
            );
            continue;
        }

        let Some(code) = code else {
            let _ = http_ok(
                &mut stream,
                "<h1>Missing code</h1><p>No authorization code found in callback.</p>",
            );
            continue;
        };

        let _ = http_ok(
            &mut stream,
            "<h1>Success</h1><p>You can close this tab and return to the terminal.</p>",
        );

        let _ = tx.send(code);
        break;
    }

    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client_id = env("CLIENT_ID");
    let client_secret = env("CLIENT_SECRET");

    let port = env_port();
    let listener = TcpListener::bind(("127.0.0.1", port))?;
    let port = listener.local_addr()?.port();
    let redirect_url = format!("http://localhost:{port}/callback");

    let oauth = OAuthClient::new(client_id, client_secret, redirect_url);
    let state = gen_state();

    let auth_url = oauth.authorization_url_with_state(
        &[
            scopes::IDENTITY,
            scopes::IDENTITY_EMAIL,
            scopes::IDENTITY_MEMBERSHIPS,
        ],
        &state,
    );

    let (tx, rx) = oneshot::channel::<String>();
    std::thread::spawn(move || {
        if let Err(e) = run_local_server(listener, auth_url, state, tx) {
            eprintln!("local server error: {e}");
        }
    });

    let local_entry = format!("http://localhost:{port}/");
    eprintln!("Opening browser to start login:\n{local_entry}\n");
    open_browser(&local_entry);

    let code = rx.await?;
    let token = oauth.exchange_code(&code).await?;

    println!("access_token: {}", token.access_token);
    println!("refresh_token: {}", token.refresh_token);
    println!("expires_at: {}", token.expires_at);
    println!("token_type: {}", token.token_type);
    println!("scope: {}", token.scope);

    Ok(())
}