diff --git a/.gitignore b/.gitignore index a008caf..dfdee31 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ Cargo.lock example *.diff + diff --git a/Cargo.toml b/Cargo.toml index 0caf2ea..c1fed7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,14 @@ [package] name = "http-rs" version = "0.1.0" -author="rattatwinko" edition = "2024" [dependencies] clap = {version = "4.5.55", features = ["derive"]} logger-rust = "0.2.12" mime_guess = "2.0.5" +mlua = {version="0.11.6", features = ["lua55", "vendored"]} +once_cell = "1.21.4" serde = { version = "1.0.228", features = ["derive"]} threadpool = "1.8.1" tiny_http = "0.12.0" diff --git a/src/examples/plugins/plugin.lua b/src/examples/plugins/plugin.lua new file mode 100644 index 0000000..dff80fe --- /dev/null +++ b/src/examples/plugins/plugin.lua @@ -0,0 +1,18 @@ +-- example plugin for http-rs ; using current api + +print("type(router) = " .. type(router)) +print("router = ", router) + +router:add_file_server("/uploads", "./uploaded_files") + +router:add_static("/api/status", 200, '{"status":"ok","version":"1.0"}', "application/json") + +print("Lua plugin loaded with the following arguments:") +for i, v in ipairs(args) do + print(" arg[" .. i .. "] = " .. tostring(v)) +end + +if args[1] == "debug" then + router:add_static("/debug", 200, "Debug mode active", "text/plain") + print("Debug endpoint added at /debug") +end \ No newline at end of file diff --git a/src/examples/plugins/todo.md b/src/examples/plugins/todo.md new file mode 100644 index 0000000..42f8e8b --- /dev/null +++ b/src/examples/plugins/todo.md @@ -0,0 +1,32 @@ +# mlua-plugin-addition todo list + +## implemented ("working") + +- [x] **cli args** + `--lua-plugin` and `--lua-arg` (multiple) added with `clap` + +- [x] **lua scripting** + `lua_plugin.rs` with `LuaRouter` userdata + +- [x] **file server mounting from lua** + `router:add_file_server(prefix, root)` – paths relative to plugin file + +- [x] **route registration from lua** + `router:add_static(path, status, body, content_type)` + +- [x] **fallback static file server** + Original `resolve_fs` logic still works for non lua routes + +- [x] **pass cli args to lua** + Accessible as `args` table inside Lua scripts. + +## fixing implementing needed + +- [ ] **better error messages from Lua** + log something at all. write a custom logger ; and use logger_rs for it + +- [ ] **hot reload of lua plugins** + i want this + +- [ ] **more lua api functions** + very bare bones right now \ No newline at end of file diff --git a/src/examples/www/index.html b/src/examples/www/index.html new file mode 100644 index 0000000..01d0119 --- /dev/null +++ b/src/examples/www/index.html @@ -0,0 +1,15 @@ + + + + + + TestDoc + + +

If this is there the server worked!

+ + + \ No newline at end of file diff --git a/src/lua_plugin.rs b/src/lua_plugin.rs new file mode 100644 index 0000000..f3dabef --- /dev/null +++ b/src/lua_plugin.rs @@ -0,0 +1,70 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use logger_rust::*; +use mlua::{Lua, Table, UserData, UserDataMethods}; +use crate::router::Router; + +// just use userdata +pub struct LuaRouter(pub Arc); + +impl UserData for LuaRouter { + fn add_methods>(methods: &mut M) { + // lua example -> router:add_file_server(prefix, root) + methods.add_method("add_file_server", |lua, this, (prefix, root): (String, String)| { + // get lua dir ; dont panic and fuck up. + let plugin_dir: String = lua.globals().get("_PLUGIN_DIR")?; + let plugin_dir = PathBuf::from(plugin_dir); + let root_path = PathBuf::from(&root); + let resolved_root = if root_path.is_relative() { + plugin_dir.join(root_path) + } else { + root_path + }; + this.0.add_file_server(prefix, resolved_root) + .map_err(mlua::Error::external) + }); + + // lua method example -> router:add_static(path, status, body, content_type) + methods.add_method("add_static", |_, this, (path, status, body, content_type): (String, u16, String, String)| { + this.0.add_static(path, status, body, content_type) + .map_err(mlua::Error::external) + }); + } +} + +// load a plugin and register it +pub fn load_plugin(plugin_path: &Path, args: Vec, router: Arc) -> Result<(), String> { + let lua = Lua::new(); + + // we need the dir from the plugin so we can make other methods use that instead of just the binary path + // using this we dont fuck up when trying to load a file, cause that panics and aborts xD + let plugin_dir = plugin_path.parent().unwrap_or(Path::new(".")).to_path_buf(); + lua.globals().set("_PLUGIN_DIR", plugin_dir.to_string_lossy().to_string()) + .map_err(|e| e.to_string())?; + + // hacky workaround for getting plugins working in their respective directory; if you like you can see this as a cwd + let package_path = format!("{}/?.lua;{}/?/init.lua", plugin_dir.display(), plugin_dir.display()); + let globals = lua.globals(); + let package: Table = globals.get("package").map_err(|e| e.to_string())?; + let current_path: String = package.get("path").unwrap_or_default(); + let new_path = format!("{};{}", package_path, current_path); + package.set("path", new_path).map_err(|e| e.to_string())?; + + // pass cli args from rust to lua as a Lua table + let lua_args = lua.create_table().map_err(|e| e.to_string())?; + for (i, arg) in args.iter().enumerate() { + lua_args.set(i + 1, arg.as_str()).map_err(|e| e.to_string())?; + } + lua.globals().set("args", lua_args).map_err(|e| e.to_string())?; + + // create and set the router object + let lua_router = LuaRouter(router); + lua.globals().set("router", lua_router).map_err(|e| e.to_string())?; + + // Execute the script + let script = std::fs::read_to_string(plugin_path).map_err(|e| e.to_string())?; + lua.load(&script).exec().map_err(|e| e.to_string())?; + + log_info!("Lua plugin loaded successfully from {:?}", plugin_path); + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 4136c4a..73f5155 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,50 +1,65 @@ mod server; mod router; mod response; +mod lua_plugin; use std::path::PathBuf; use std::sync::Arc; use logger_rust::*; - use clap::Parser; +use crate::router::Router; -// 1CA:b6e8ae4ca8acae66aa3dfd3f0a377e91d041e4d7 ; added clap as a parser, its way cleaner #[derive(Parser, Debug)] #[command(name = "http-rs")] -#[command(about = "http-rs webserver")] +#[command(about = "http-rs webserver with Lua plugin support")] struct Arguments { - /// port where we should serve on + /// Port where the server should listen #[arg(short, long, default_value_t = 8080)] - port : u16, + port: u16, - /// where we should serve from + /// Root directory to serve static files from #[arg(short, long, default_value = ".")] root: PathBuf, - - /// number of threads to run on + + /// Number of worker threads #[arg(short, long)] threads: Option, -} + /// Lua plugin script to load + #[arg(long)] + lua_plugin: Option, + + /// Arguments passed to the Lua plugin (can be used multiple times) + #[arg(long)] + lua_arg: Vec, +} fn main() { let args = Arguments::parse(); let root = Arc::new(args.root); if !root.exists() || !root.is_dir() { - log_error!("Root {} isnt a valid directory", root.display()); + log_error!("Root '{}' is not a valid directory", root.display()); std::process::exit(1); } - - // moved num_cpus from server.rs to main.rs + let threads = args.threads.unwrap_or_else(|| { std::thread::available_parallelism() .map(|n| n.get() * 2) .unwrap_or(8) }); - if let Err(_) = server::run(root, args.port, threads) { - log_error!("port {} in use!", args.port); + let router = Arc::new(Router::new((*root).clone())); + + if let Some(plugin_path) = args.lua_plugin { + if let Err(e) = lua_plugin::load_plugin(&plugin_path, args.lua_arg, router.clone()) { + log_error!("Failed to load Lua plugin: {}", e); + std::process::exit(1); + } } + if let Err(_) = server::run(router, args.port, threads) { + log_error!("Port {} is already in use", args.port); + std::process::exit(1); + } } diff --git a/src/response.rs b/src/response.rs index d171dce..006334e 100644 --- a/src/response.rs +++ b/src/response.rs @@ -12,9 +12,9 @@ use tiny_http::{Header, Response, StatusCode}; use tinytemplate::TinyTemplate; use serde::Serialize; -type BoxedResponse = Response>>; +pub type BoxedResponse = Response>>; -fn content_type(value: &str) -> Header { +pub fn content_type(value: &str) -> Header { Header::from_bytes("Content-Type", value).unwrap() } diff --git a/src/router.rs b/src/router.rs index 5c6708b..5e2f1e2 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; +use std::sync::RwLock; use logger_rust::*; - -// Fixed from version a671182b07da0c22ed29e66f87faa3738fbdca64 -// onwards +use tiny_http::StatusCode; +use crate::response; pub enum ResolveResult { Found(PathBuf), @@ -10,40 +10,127 @@ pub enum ResolveResult { Traversal, } -pub fn resolve(root: &Path, url_path: &str) -> ResolveResult { +pub fn resolve_fs(root: &Path, url_path: &str) -> ResolveResult { let rel = url_path.trim_start_matches('/'); let rel = if rel.is_empty() { "index.html" } else { rel }; - // join once let mut joined = root.join(rel); - // if directory look for index - // this is shitty logic, TODO: Improve this snippet if joined.is_dir() { let html = joined.join("index.html"); - let htm = joined.join("index.htm"); + let htm = joined.join("index.htm"); if html.exists() { joined = html; } else if htm.exists() { joined = htm; } else { - return ResolveResult::NoIndex; // no index found + return ResolveResult::NoIndex; } } - // nono no escape let canonical_root = root.canonicalize().ok(); let canonical_file = joined.canonicalize().ok(); - - // cleanup 1CA:b6e8ae4ca8acae66aa3dfd3f0a377e91d041e4d7 + match (canonical_root, canonical_file) { - (Some(root), Some(file)) - if file.starts_with(&root) => { - ResolveResult::Found(file) - } + (Some(root), Some(file)) if file.starts_with(&root) => ResolveResult::Found(file), _ => { log_warn!("ResolveResult::Traversal"); ResolveResult::Traversal } } } + +#[derive(Clone)] +pub enum LuaRoute { + FileServer { prefix: String, root: PathBuf }, + StaticResponse { prefix: String, status: u16, body: String, content_type: String }, +} + +pub struct Router { + root: PathBuf, + lua_routes: RwLock>, +} + +impl Router { + pub fn new(root: PathBuf) -> Self { + Self { + root, + lua_routes: RwLock::new(Vec::new()), + } + } + + pub fn add_file_server(&self, prefix: String, root: PathBuf) -> Result<(), String> { + if !prefix.starts_with('/') { + return Err("Prefix must start with '/'".to_string()); + } + let canonical = root.canonicalize().map_err(|e| e.to_string())?; + if !canonical.exists() || !canonical.is_dir() { + return Err(format!("Root '{}' is not a valid directory", canonical.display())); + } + let mut routes = self.lua_routes.write().unwrap(); + routes.push(LuaRoute::FileServer { prefix, root: canonical }); + // sort by longest prefix first + routes.sort_by(|a, b| { + let len_a = match a { LuaRoute::FileServer { prefix, .. } => prefix.len(), LuaRoute::StaticResponse { prefix, .. } => prefix.len() }; + let len_b = match b { LuaRoute::FileServer { prefix, .. } => prefix.len(), LuaRoute::StaticResponse { prefix, .. } => prefix.len() }; + len_b.cmp(&len_a) + }); + Ok(()) + } + + pub fn add_static(&self, path: String, status: u16, body: String, content_type: String) -> Result<(), String> { + if !path.starts_with('/') { + return Err("Path must start with '/'".to_string()); + } + let mut routes = self.lua_routes.write().unwrap(); + routes.push(LuaRoute::StaticResponse { prefix: path.clone(), status, body, content_type }); + routes.sort_by(|a, b| { + let len_a = match a { LuaRoute::FileServer { prefix, .. } => prefix.len(), LuaRoute::StaticResponse { prefix, .. } => prefix.len() }; + let len_b = match b { LuaRoute::FileServer { prefix, .. } => prefix.len(), LuaRoute::StaticResponse { prefix, .. } => prefix.len() }; + len_b.cmp(&len_a) + }); + Ok(()) + } + + pub fn resolve(&self, url_path: &str) -> response::BoxedResponse { + let routes = self.lua_routes.read().unwrap(); + for route in routes.iter() { + let (prefix, matched) = match route { + LuaRoute::FileServer { prefix, .. } => (prefix, url_path.starts_with(prefix)), + LuaRoute::StaticResponse { prefix, .. } => (prefix, url_path == prefix), + }; + if matched { + let remaining = &url_path[prefix.len()..]; + return match route { + LuaRoute::FileServer { root, .. } => { + let rel = remaining.trim_start_matches('/'); + let full_path = if rel.is_empty() { + root.join("index.html") + } else { + root.join(rel) + }; + response::serve_file(&full_path) + } + LuaRoute::StaticResponse { status, body, content_type, .. } => { + let mut resp = tiny_http::Response::from_string(body.clone()); + resp = resp.with_status_code(StatusCode(*status)); + resp = resp.with_header(response::content_type(content_type)); + resp + } + }; + } + } + + match resolve_fs(&self.root, url_path) { + ResolveResult::Found(path) => response::serve_file(&path), + ResolveResult::NoIndex => { + log_warn!("No index file for: {}", url_path); + response::not_found("No index file found") + } + ResolveResult::Traversal => { + log_warn!("Rejected path traversal attempt: {}", url_path); + response::forbidden("Access denied") + } + } + } +} diff --git a/src/server.rs b/src/server.rs index 3b83711..fbb7069 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,42 +1,28 @@ -use std::path::PathBuf; use std::sync::Arc; use logger_rust::*; use threadpool::ThreadPool; use tiny_http::Server; -use crate::router; -use crate::response; +use crate::router::Router; pub fn run( - root: Arc, + router: Arc, port: u16, - threads: usize + threads: usize, ) -> Result<(), Box> { - let addr = format!("0.0.0.0:{}", port); let server = Arc::new(Server::http(&addr)?); let pool = ThreadPool::new(threads); - log_info!("Serving '{}' on http://{}", root.display(), addr); + log_info!("Serving on http://{}", addr); for request in server.incoming_requests() { - let root = Arc::clone(&root); - + let router = Arc::clone(&router); pool.execute(move || { let url = request.url().to_owned(); log_info!("{} {}", request.method(), url); - let resp = match router::resolve(&root, &url) { - router::ResolveResult::Found(path) => response::serve_file(&path), - router::ResolveResult::NoIndex => { - log_warn!("No index file for: {}", url); - response::not_found("No index file found") - } - router::ResolveResult::Traversal => { - log_warn!("Rejected path traversal attempt: {}", url); - response::forbidden("Access denied") - } - }; + let resp = router.resolve(&url); if let Err(e) = request.respond(resp) { log_error!("Failed to send response: {}", e); @@ -46,4 +32,3 @@ pub fn run( Ok(()) } -