diff --git a/Cargo.lock b/Cargo.lock index eb51ff2..7a4a6e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bytes" version = "1.7.1" @@ -294,7 +300,10 @@ name = "manserve" version = "0.1.0" dependencies = [ "axum", + "base64", + "http-body-util", "serde", + "serde_json", "tokio", "tower 0.5.0", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 1316131..9711844 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,10 @@ edition = "2021" [dependencies] axum = "0.7.5" +base64 = "0.22.1" +http-body-util = "0.1.2" serde = { version = "1.0.209", features = ["derive"] } +serde_json = "1.0.127" tokio = { version = "1.39.3", features = ["rt-multi-thread"] } tower = { version = "0.5.0", features = ["limit", "buffer"] } tracing-subscriber = "0.3.18" diff --git a/src/main.rs b/src/main.rs index b2b199c..07ecdbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,88 +1,191 @@ use std::{ - env, + env, io, process::{exit, Command}, + string::FromUtf8Error, time::Duration, }; use axum::{ - error_handling::HandleErrorLayer, extract::Path, http::StatusCode, response::Html, - routing::get, BoxError, Router, + error_handling::HandleErrorLayer, + extract::Path, + http::{header, StatusCode}, + response::IntoResponse, + routing::get, + BoxError, Router, }; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Deserialize; use tower::{buffer::BufferLayer, limit::rate::RateLimitLayer, ServiceBuilder}; #[tokio::main] async fn main() { - // initialize tracing - tracing_subscriber::fmt::init(); - - // idk how to have one rate limiting block apply to both of these but this works so it's fine - let app = Router::new() - .route( - "/:name", - get(command).layer( - ServiceBuilder::new() - .layer(HandleErrorLayer::new(|err: BoxError| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled error: {err}"), - ) - })) - .layer(BufferLayer::new(1024)) - .layer(RateLimitLayer::new(5, Duration::from_secs(1))), - ), - ) - .route( - "/", - get(StatusCode::BAD_REQUEST).layer( - ServiceBuilder::new() - .layer(HandleErrorLayer::new(|err: BoxError| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled error: {err}"), - ) - })) - .layer(BufferLayer::new(1024)) - .layer(RateLimitLayer::new(5, Duration::from_secs(1))), - ), - ); - let args: Vec = env::args().collect(); if args.len() < 2 { println!("Provide a port with manserve $PORT"); exit(1); } - if args[1].parse::().is_ok() { - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", args[1])) - .await - .unwrap(); - println!("Starting server on port {}", args[1]); - axum::serve(listener, app).await.unwrap(); - } else { + if args[1].parse::().is_err() { println!("Could not parse port {}", args[1]); exit(1); } + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", args[1])) + .await + .unwrap(); + println!("Starting server on port {}", args[1]); + axum::serve(listener, app()).await.unwrap(); } -async fn command(Path(name): Path) -> Result, StatusCode> { - let mut manpage_loc = String::from_utf8( - Command::new("man") - .args(["-w", name.as_str()]) - .output() - .unwrap() - .stdout, - ) - .unwrap(); - manpage_loc.pop(); - let man = Command::new("mandoc") +fn app() -> Router { + Router::new() + .layer( + // Throttle incoming traffic + ServiceBuilder::new() + .layer(HandleErrorLayer::new(|err: BoxError| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled error: {err}"), + ) + })) + .layer(BufferLayer::new(1024)) + .layer(RateLimitLayer::new(5, Duration::from_secs(1))), + ) + .route("/:command", get(command)) +} + +#[derive(Deserialize, Debug)] +struct Manpage { + command: String, + html: String, +} + +#[derive(Debug)] +enum ManserveErr { + NotFound, + IOError, + InvalidUtf, +} + +impl From for ManserveErr { + fn from(_value: io::Error) -> Self { + ManserveErr::IOError + } +} +impl From for ManserveErr { + fn from(_value: FromUtf8Error) -> Self { + ManserveErr::InvalidUtf + } +} + +fn get_man_loc(command: &str) -> Result { + let output = Command::new("man").args(["-w", command]).output()?; + if !output.status.success() { + return Err(ManserveErr::NotFound); + } + let mut loc = String::from_utf8(output.stdout)?; + loc.pop(); + Ok(loc) +} + +fn generate_html(command: &str) -> Result { + let man_page = get_man_loc(command)?; + let output = Command::new("mandoc") .arg("-Thtml") .arg("-O") .arg("fragment") - .arg(manpage_loc) - .output() - .unwrap(); - match man.status.success() { - true => Ok(Html(String::from_utf8(man.stdout).unwrap())), - false => Err(StatusCode::BAD_REQUEST), + .arg(man_page.as_str()) + .output()?; + + let encoded = STANDARD.encode(String::from_utf8(output.stdout)?); + Ok(encoded) +} + +async fn command(Path(command): Path) -> impl IntoResponse { + let command = command.as_str(); + match generate_html(command) { + Ok(html) => { + let json = format!("{{\"command\": \"{command}\", \"html\": \"{html}\"}}"); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/json")], + json.into_response(), + ) + } + Err(error) => match error { + ManserveErr::NotFound => { + let json = format!("{{\"command\": \"{command}\", \"error\": \"Could not find the requested command\"}}"); + ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/json")], + json.into_response(), + ) + } + ManserveErr::IOError => { + let json = format!("{{\"command\": \"{command}\", \"error\": \"The server could not complete the request\"}}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + [(header::CONTENT_TYPE, "text/json")], + json.into_response(), + ) + } + ManserveErr::InvalidUtf => { + let json = format!("{{\"command\": \"{command}\", \"error\": \"There was invalid utf-8 in the request\"}}"); + ( + StatusCode::BAD_REQUEST, + [(header::CONTENT_TYPE, "text/json")], + json.into_response(), + ) + } + }, + } +} + +#[cfg(test)] +mod tests { + use axum::{body::Body, http::Request}; + use http_body_util::BodyExt; + use tower::Service; + + use super::*; + + #[tokio::test] + async fn json() { + let mut app = app(); + let res = app + .call(Request::builder().uri("/ls").body(Body::default()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK) + } + + #[tokio::test] + async fn bad_command() { + let mut app = app(); + let res = app + .call( + Request::builder() + .uri("/lollll") + .body(Body::default()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND) + } + + #[tokio::test] + async fn man_ls() { + let mut app = app(); + let res = app + .call(Request::builder().uri("/ls").body(Body::default()).unwrap()) + .await + .unwrap(); + + let expected = generate_html("ls").unwrap(); + let res_str = + String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().into()).unwrap(); + let manpage: Manpage = serde_json::from_str(&res_str).unwrap(); + + assert_eq!(manpage.html, expected) } }