From d347b2bd2679aefbeeb2862cb3455d5dc5a8d97d Mon Sep 17 00:00:00 2001 From: miky-rola Date: Wed, 9 Apr 2025 21:22:59 +0000 Subject: [PATCH 1/6] Add cgi feature --- src/hteapot/cgi.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/hteapot/cgi.rs diff --git a/src/hteapot/cgi.rs b/src/hteapot/cgi.rs new file mode 100644 index 0000000..2f27547 --- /dev/null +++ b/src/hteapot/cgi.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use std::process::{Command, Output}; +use std::io; +use std::io::Write; + +/// Executes a CGI script with the given environment variables, arguments, and optional input. +/// - `script_path`: Path to the CGI script to execute. +/// - `env_vars`: A `HashMap` of environment variables to set for the script. +/// - `args`: A vector of arguments to pass to the script. +/// - `input`: Optional input data to provide to the script's standard input. +/// - `io::Result`: The output of the executed script, including stdout, stderr, and exit status. +pub fn execute_cgi(script_path: &str, env_vars: HashMap, args: Vec, input: Option<&[u8]>) -> io::Result { + let mut command = Command::new(script_path); + + for (key, value) in env_vars { + command.env(key, value); + } + + if !args.is_empty() { + command.args(&args); + } + + if let Some(input_data) = input { + command.stdin(std::process::Stdio::piped()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command.spawn()?; + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(input_data)?; + } + + child.wait_with_output() + } else { + command.output() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Test case to verify successful execution of a CGI script. + /// This test uses a platform-specific command (`cmd.exe` on Windows, `sh` on Unix) + /// to simulate a CGI script that outputs "Hello, World!". + #[test] + fn test_execute_cgi_success() { + // Determine the script path and arguments based on the operating system + let script_path = if cfg!(windows) { "cmd.exe" } else { "sh" }; + let mut env_vars = HashMap::new(); // Environment variables (empty in this test) + let args = if cfg!(windows) { + vec!["/C".to_string(), "echo".to_string(), "Hello, World!".to_string()] + } else { + vec!["-c".to_string(), "echo Hello, World!".to_string()] + }; + + let input = None; + + let result = execute_cgi(script_path, env_vars, args, input); + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Hello"), "Expected 'Hello' in output, got: {}", stdout); + } + + /// Test case to verify failure when attempting to execute a non-existent CGI script. + #[test] + fn test_execute_cgi_failure() { + let script_path = "non_existent_script.cgi"; + let env_vars = HashMap::new(); + let args = vec![]; // No arguments + let input = None; // No input data + + let result = execute_cgi(script_path, env_vars, args, input); + assert!(result.is_err()); // Ensure the execution failed + } +} \ No newline at end of file From dc61e88f17918e1467057d76f959b24c80b58997 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Wed, 9 Apr 2025 21:26:12 +0000 Subject: [PATCH 2/6] Add cgi to mod --- src/hteapot/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hteapot/mod.rs b/src/hteapot/mod.rs index 588d832..80be15c 100644 --- a/src/hteapot/mod.rs +++ b/src/hteapot/mod.rs @@ -7,6 +7,7 @@ mod methods; mod request; mod response; mod status; +pub mod cgi; use self::response::EmptyHttpResponse; use self::response::HttpResponseCommon; From b50713f1f10ea829009cf0fbc96e909203168277 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Wed, 9 Apr 2025 21:32:04 +0000 Subject: [PATCH 3/6] Add the cgi to main --- src/main.rs | 296 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 219 insertions(+), 77 deletions(-) diff --git a/src/main.rs b/src/main.rs index ada2202..ef3dcca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,37 +2,34 @@ mod cache; mod config; pub mod hteapot; mod logger; -mod utils; - -use std::{fs, io}; +use std::fs; +use std::io; use std::path::Path; - +use std::process::Command; use std::sync::Mutex; + use cache::Cache; use config::Config; use hteapot::{Hteapot, HttpRequest, HttpResponse, HttpStatus}; -use utils::get_mime_tipe; use logger::Logger; const VERSION: &str = env!("CARGO_PKG_VERSION"); -fn is_proxy(config: &Config, req: HttpRequest) -> Option<(String, HttpRequest)> { +fn is_proxy(config: &Config, req: &HttpRequest) -> Option<(String, HttpRequest)> { for proxy_path in config.proxy_rules.keys() { - let path_match = req.path.strip_prefix(proxy_path); - if path_match.is_some() { - let new_path = path_match.unwrap(); + if let Some(path_match) = req.path.strip_prefix(proxy_path) { let url = config.proxy_rules.get(proxy_path).unwrap().clone(); let mut proxy_req = req.clone(); - proxy_req.path = new_path.to_string(); + proxy_req.path = path_match.to_string(); proxy_req.headers.remove("Host"); let host_parts: Vec<_> = url.split("://").collect(); let host = if host_parts.len() == 1 { host_parts.first().unwrap() } else { - host_parts.last().clone().unwrap() + host_parts.last().unwrap() }; proxy_req.header("Host", host); return Some((url, proxy_req)); @@ -41,98 +38,209 @@ fn is_proxy(config: &Config, req: HttpRequest) -> Option<(String, HttpRequest)> None } +fn serve_proxy(proxy_url: String) -> HttpResponse { + let raw_response = fetch(&proxy_url); + match raw_response { + Ok(raw) => HttpResponse::new_raw(raw), + Err(_) => return *HttpResponse::new(HttpStatus::NotFound, "not found", None), + } +} + +fn get_mime_tipe(path: &String) -> String { + let extension = Path::new(path.as_str()) + .extension() + .unwrap() + .to_str() + .unwrap(); + let mimetipe = match extension { + "js" => "text/javascript", + "json" => "application/json", + "css" => "text/css", + "html" => "text/html", + "ico" => "image/x-icon", + _ => "text/plain", + }; + + mimetipe.to_string() +} + + fn serve_file(path: &String) -> Option> { let r = fs::read(path); if r.is_ok() { Some(r.unwrap()) } else { None } } -fn main() { - let args = std::env::args().collect::>(); - if args.len() == 1 { - println!("Hteapot {}", VERSION); - println!("usage: {} ", args[0]); - return; - } - let config = match args[1].as_str() { - "--help" | "-h" => { - println!("Hteapot {}", VERSION); - println!("usage: {} ", args[0]); - return; - } - "--version" | "-v" => { - println!("Hteapot {}", VERSION); - return; - } - "--serve" | "-s" => { - let mut c = config::Config::new_default(); - let serving_path = Some(args.get(2).unwrap().clone()); - let serving_path_str = serving_path.unwrap(); - let serving_path_str = serving_path_str.as_str(); - let serving_path = Path::new(serving_path_str); - if serving_path.is_dir() { - c.root = serving_path.to_str().unwrap_or_default().to_string(); +#[cfg(feature = "cgi")] + +fn serve_cgi( + program: &String, + path: &String, + request: HttpRequest, +) -> Result, &'static str> { + use std::{env, io::Write, process::Stdio}; + let query = request + .args + .iter() + .map(|(key, value)| format!("{key}={value}")) // Convierte cada par en "key=value" + .collect::>() // Recolecta las cadenas en un Vec + .join("&"); + env::set_var("REDIRECT_STATUS", "hteapot"); + env::set_var("SCRIPT_NAME", path); + env::set_var("SCRIPT_FILENAME", path); + env::set_var("QUERY_STRING", query); + env::set_var("REQUEST_METHOD", request.method.to_str()); // Método HTTP de la petición + let content_type = request.headers.get("CONTENT_TYPE"); + let content_type = match content_type { + Some(s) => s.clone(), + None => "".to_string(), + }; + + env::set_var("CONTENT_TYPE", content_type); // Tipo de contenido + env::set_var("CONTENT_LENGTH", request.body.len().to_string().as_str()); // Longitud del contenido para POST + let mut child = Command::new(program) + .arg(&path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("Failed to spawn child process"); + + let stdin = child.stdin.as_mut().expect("msg"); + stdin.write_all(&request.body).expect("Error writing stdin"); + let output = child.wait_with_output(); + match output { + Ok(output) => { + if output.status.success() { + Ok(output.stdout) } else { - c.index = serving_path - .file_name() - .unwrap() - .to_str() - .unwrap_or_default() - .to_string(); - c.root = serving_path - .parent() - .unwrap_or(Path::new("./")) - .to_str() - .unwrap_or_default() - .to_string(); + Err("Command exit with non-zero status") } - c.host = "0.0.0.0".to_string(); - c } - _ => config::Config::load_config(&args[1]), + Err(_) => Err("Error runing command"), + } +} + +/// Fetches the content of a URL using a simple HTTP client. +/// Returns the response body or an error if the request fails. +fn fetch(url: &str) -> Result, Box> { + use std::net::TcpStream; + use std::io::{Read, Write}; + + // Parse the URL + let url = url.strip_prefix("http://").unwrap_or(url); + let parts: Vec<&str> = url.split('/').collect(); + let host = parts[0]; + let path = if parts.len() > 1 { + "/".to_owned() + &parts[1..].join("/") + } else { + "/".to_string() }; + + let mut stream = TcpStream::connect(host)?; + + let request = format!("GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", path, host); + stream.write_all(request.as_bytes())?; + + // Read the response + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + + // Simple HTTP response parsing (very basic) + // Look for the double CRLF that separates headers from body + let mut headers_end = 0; + for i in 0..response.len() - 3 { + if &response[i..i+4] == b"\r\n\r\n" { + headers_end = i + 4; + break; + } + } + + if headers_end > 0 { + Ok(response[headers_end..].to_vec()) + } else { + Ok(response) + } +} - let proxy_only = config.proxy_rules.get("/").is_some(); - let logger = match config.log_file.clone() { - Some(file_name) => { - let file = fs::File::create(file_name.clone()); - let file = file.unwrap(); - Logger::new(file) +fn main() { + let args = std::env::args().collect::>(); + let mut serving_path = None; + if args.len() >= 2 { + match args[1].as_str() { + "--help" | "-h" => { + println!("Hteapot {}", VERSION); + println!("usage: {} ", args[0]); + return; + } + "--version" | "-v" => { + println!("Hteapot {}", VERSION); + return; + } + "--serve" | "-s" => { + serving_path = Some(args.get(2).unwrap().clone()); + } + _ => (), + }; + } + + let config = if args.len() == 2 { + config::Config::load_config(&args[1]) + } else if serving_path.is_some() { + let serving_path_str = serving_path.unwrap(); + let serving_path_str = serving_path_str.as_str(); + let serving_path = Path::new(serving_path_str); + let mut c = config::Config::new_default(); + c.host = "0.0.0.0".to_string(); + if serving_path.is_dir() { + c.root = serving_path.to_str().unwrap_or_default().to_string(); + } else { + c.index = serving_path + .file_name() + .unwrap() + .to_str() + .unwrap_or_default() + .to_string(); + c.root = serving_path + .parent() + .unwrap_or(Path::new("./")) + .to_str() + .unwrap_or_default() + .to_string(); } - None => Logger::new(io::stdout()), + c + } else { + config::Config::new_default() }; + let proxy_only = config.proxy_rules.get("/").is_some(); + let logger = Mutex::new(Logger::new(io::stdout())); let cache: Mutex = Mutex::new(Cache::new(config.cache_ttl as u64)); let server = Hteapot::new_threaded(config.host.as_str(), config.port, config.threads); - logger.msg(format!( + logger.lock().expect("this doesnt work :C").msg(format!( "Server started at http://{}:{}", config.host, config.port )); if config.cache { - logger.msg("Cache Enabled".to_string()); + logger + .lock() + .expect("this doesnt work :C") + .msg("Cache Enabled".to_string()); } if proxy_only { logger + .lock() + .expect("this doesnt work :C") .msg("WARNING: All requests are proxied to /. Local paths won’t be used.".to_string()); } + server.listen(move |req| { // SERVER CORE // for each request - logger.msg(format!("Request {} {}", req.method.to_str(), req.path)); - let is_proxy = is_proxy(&config, req.clone()); - if proxy_only || is_proxy.is_some() { - let (host, proxy_req) = is_proxy.unwrap(); - let res = proxy_req.brew(host.as_str()); - if res.is_ok() { - return res.unwrap(); - } else { - return HttpResponse::new( - HttpStatus::InternalServerError, - "Internal Server Error", - None, - ); - } - } + logger.lock().expect("this doesnt work :C").msg(format!( + "Request {} {}", + req.method.to_str(), + req.path + )); let mut full_path = format!("{}{}", config.root, req.path.clone()); if Path::new(full_path.as_str()).is_dir() { @@ -140,10 +248,44 @@ fn main() { full_path = format!("{}{}{}", full_path, separator, config.index); } + let is_proxy = is_proxy(&config, &req); + if proxy_only || is_proxy.is_some() { + let (url, proxy_req) = is_proxy.unwrap(); + return Box::new(serve_proxy(url)); + } + if !Path::new(full_path.as_str()).exists() { - logger.msg(format!("path {} does not exist", req.path)); + logger + .lock() + .expect("this doesnt work :C") + .msg(format!("path {} does not exist", req.path)); return HttpResponse::new(HttpStatus::NotFound, "Not found", None); } + + #[cfg(feature = "cgi")] + { + let extension = Path::new(&full_path).extension().unwrap(); + let extension = extension.to_str().unwrap(); + println!("File extension: {}", extension); + let cgi_command = config.cgi_rules.get(extension); + if cgi_command.is_some() { + let cgi_command = cgi_command.unwrap(); + logger + .lock() + .expect("this doesnt work :C") + .msg(format!("Runing {} {}", cgi_command, full_path)); + let cgi_result = serve_cgi(cgi_command, &full_path, req); + return match cgi_result { + Ok(result) => HttpResponse::new(HttpStatus::OK, result, None), + Err(err) => HttpResponse::new( + HttpStatus::InternalServerError, + "Internal server error", + None, + ), + }; + } + } + let mimetype = get_mime_tipe(&full_path); let content: Option> = if config.cache { let mut cachee = cache.lock().expect("Error locking cache"); @@ -163,4 +305,4 @@ fn main() { None => HttpResponse::new(HttpStatus::NotFound, "Not found", None), } }); -} +} \ No newline at end of file From dd9eb6ca6946020f7439de116014962b6cd580f6 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Wed, 9 Apr 2025 21:32:45 +0000 Subject: [PATCH 4/6] Update the config and main.rs --- Cargo.toml | 6 +++++- src/config.rs | 20 ++++++++++++++++++++ src/main.rs | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 495bbc9..3895cdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -edition = "2024" +edition = "2021" name = "hteapot" version = "0.5.0" exclude = ["config.toml", "demo/", "readme.md"] @@ -18,3 +18,7 @@ path = "src/hteapot/mod.rs" [[bin]] name = "hteapot" + +[features] +default = ["cgi"] +cgi = [] \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index fb689a5..3e21705 100644 --- a/src/config.rs +++ b/src/config.rs @@ -108,6 +108,7 @@ pub struct Config { pub index: String, // Index file to serve by default //pub error: String, // Error file to serve when a file is not found pub proxy_rules: HashMap, + pub cgi_rules: HashMap, } impl Config { @@ -134,6 +135,7 @@ impl Config { cache: false, cache_ttl: 0, proxy_rules: HashMap::new(), + cgi_rules: HashMap::new(), } } @@ -159,6 +161,23 @@ impl Config { } } + let mut cgi_rules: HashMap = HashMap::new(); + #[cfg(feature = "cgi")] + { + let cgi_map = map.get("cgi"); + if cgi_map.is_some() { + let cgi_map = cgi_map.unwrap(); + for k in cgi_map.keys() { + let command = cgi_map.get2(k); + if command.is_none() { + continue; + } + let command = command.unwrap(); + cgi_rules.insert(k.clone(), command); + } + } + } + let map = map.get("HTEAPOT").unwrap(); Config { port: map.get2("port").unwrap_or(8080), @@ -171,6 +190,7 @@ impl Config { log_file: map.get2("log_file"), //error: map.get2("error").unwrap_or("error.html".to_string()), proxy_rules, + cgi_rules, } } } diff --git a/src/main.rs b/src/main.rs index ef3dcca..ea0359d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,6 +70,7 @@ fn serve_file(path: &String) -> Option> { if r.is_ok() { Some(r.unwrap()) } else { None } } +// Handle CGI requests if enabled #[cfg(feature = "cgi")] fn serve_cgi( From 69d8de15a5e56c85fd123d215b513872da6851e1 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Wed, 9 Apr 2025 21:33:06 +0000 Subject: [PATCH 5/6] Update the toml with feature cgi --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3895cdd..9fb47ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,4 +21,4 @@ name = "hteapot" [features] default = ["cgi"] -cgi = [] \ No newline at end of file +cgi = [] \ No newline at end of file From 0aa945856016ad048a13284d4a28e0b39293ccd7 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Wed, 9 Apr 2025 21:35:06 +0000 Subject: [PATCH 6/6] Add comment to unused issue --- src/config.rs | 2 +- src/main.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 3e21705..785e5fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,7 +34,7 @@ impl Schema for TOMLSchema { r } } - +// Avoid removing the unused error, they're being used in different part of same file pub fn toml_parser(content: &str) -> HashMap { let mut map = HashMap::new(); let mut submap = HashMap::new(); diff --git a/src/main.rs b/src/main.rs index ea0359d..d12103c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,10 @@ fn serve_cgi( } } + +// Avoid removing the unused error, they're being used in different part of same file + + /// Fetches the content of a URL using a simple HTTP client. /// Returns the response body or an error if the request fails. fn fetch(url: &str) -> Result, Box> {