use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::{Duration, Instant};
const PAGE: &str = "<!doctype html><meta charset=utf-8><title>Aura</title>\
<body style=\"font-family:system-ui;text-align:center;margin-top:20vh\"><h2>Listo</h2>\
<p>Ya puedes volver a Aura y cerrar esta pestaña.</p>";
#[derive(Debug, PartialEq, Eq)]
pub struct Redirect {
pub code: String,
pub state: String,
}
pub struct CallbackServer {
listener: TcpListener,
pub port: u16,
}
impl CallbackServer {
pub fn bind(preferred: u16) -> Result<Self, String> {
let listener = TcpListener::bind(("127.0.0.1", preferred))
.or_else(|_| TcpListener::bind(("127.0.0.1", 0)))
.map_err(|error| format!("no se pudo abrir el puerto local: {error}"))?;
listener.set_nonblocking(true).map_err(|error| error.to_string())?;
let port = listener.local_addr().map_err(|error| error.to_string())?.port();
Ok(Self { listener, port })
}
pub fn wait(&self, path: &str, timeout: Duration, cancelled: &dyn Fn() -> bool) -> Result<Redirect, String> {
let started = Instant::now();
loop {
if cancelled() {
return Err("inicio de sesión cancelado".into());
}
if started.elapsed() > timeout {
return Err("se agotó el tiempo para iniciar sesión en el navegador".into());
}
match self.listener.accept() {
Ok((stream, _)) => {
if let Some(redirect) = answer(stream, path) {
return Ok(redirect);
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(100));
}
Err(error) => return Err(error.to_string()),
}
}
}
}
fn answer(mut stream: TcpStream, path: &str) -> Option<Redirect> {
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut buffer = [0u8; 4096];
let read = stream.read(&mut buffer).ok()?;
let request = String::from_utf8_lossy(&buffer[..read]);
let target = request.lines().next()?.split_whitespace().nth(1)?;
let redirect = parse_target(target, path);
let (status, body) = if redirect.is_some() { ("200 OK", PAGE) } else { ("404 Not Found", "") };
let _ = write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
redirect
}
pub fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let (mut out, mut index) = (Vec::new(), 0);
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let hex =
std::str::from_utf8(&bytes[index + 1..index + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
match hex {
Some(byte) => {
out.push(byte);
index += 3;
}
None => {
out.push(b'%');
index += 1;
}
}
}
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn parse_target(target: &str, path: &str) -> Option<Redirect> {
let (route, query) = target.split_once('?')?;
if route != path {
return None;
}
let value = |name: &str| {
query
.split('&')
.find_map(|pair| pair.split_once('=').filter(|(key, _)| *key == name).map(|(_, v)| percent_decode(v)))
};
Some(Redirect { code: value("code")?, state: value("state").unwrap_or_default() })
}
pub fn parse_pasted(input: &str) -> Option<Redirect> {
let input = input.trim();
if input.is_empty() {
return None;
}
if let Some((_, query)) = input.split_once('?') {
return parse_target(&format!("/x?{query}"), "/x");
}
if input.contains("code=") {
return parse_target(&format!("/x?{input}"), "/x");
}
let (code, state) = input.split_once('#').unwrap_or((input, ""));
Some(Redirect { code: code.to_owned(), state: state.to_owned() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lee_codigo_y_estado_de_la_redireccion() {
let redirect = parse_target("/callback?code=a%2Fb&state=xyz", "/callback").unwrap();
assert_eq!(redirect, Redirect { code: "a/b".into(), state: "xyz".into() });
assert!(parse_target("/favicon.ico", "/callback").is_none());
assert!(parse_target("/otra?code=1", "/callback").is_none());
}
#[test]
fn acepta_lo_que_el_usuario_pega() {
assert_eq!(parse_pasted("http://localhost:1/callback?code=c&state=s").unwrap().state, "s");
assert_eq!(parse_pasted("c#s").unwrap(), Redirect { code: "c".into(), state: "s".into() });
assert_eq!(parse_pasted("solo").unwrap().code, "solo");
assert!(parse_pasted(" ").is_none());
}
#[test]
fn el_servidor_entrega_la_redireccion_por_un_socket_real() {
let server = CallbackServer::bind(0).unwrap();
let port = server.port;
let client = std::thread::spawn(move || {
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
stream.write_all(b"GET /callback?code=K&state=S HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
let mut reply = String::new();
stream.read_to_string(&mut reply).unwrap();
reply
});
let got = server.wait("/callback", Duration::from_secs(5), &|| false).unwrap();
assert_eq!(got, Redirect { code: "K".into(), state: "S".into() });
assert!(client.join().unwrap().starts_with("HTTP/1.1 200"));
}
#[test]
fn cancelar_y_el_plazo_cortan_la_espera() {
let server = CallbackServer::bind(0).unwrap();
assert!(server.wait("/c", Duration::from_secs(5), &|| true).unwrap_err().contains("cancelado"));
assert!(server.wait("/c", Duration::ZERO, &|| false).unwrap_err().contains("tiempo"));
}
}