3 Commits

Author SHA1 Message Date
rattatwinko 938acd506b readme lua additions
Compile to Binary / build-linux (push) Successful in 1m21s
Compile to Binary / build-linux-musl (push) Successful in 1m47s
Compile to Binary / build-windows (push) Successful in 2m0s
Compile to Binary / release (push) Successful in 5s
2026-04-29 19:10:51 +02:00
rattatwinko 7be522de0c testing new workflow in dev branch!
Compile to Binary / build-linux (push) Successful in 1m22s
Compile to Binary / build-linux-musl (push) Successful in 1m39s
Compile to Binary / build-windows (push) Successful in 2m1s
Compile to Binary / release (push) Successful in 17s
2026-04-29 18:45:00 +02:00
rattatwinko 77e80c990c initial implementation of mlua for loading plugins, see todo.md in examples 2026-04-27 14:53:24 +02:00
12 changed files with 410 additions and 67 deletions
+124 -13
View File
@@ -1,25 +1,37 @@
name: Compile to Binary
on:
push:
branches: [ main, master, dev ]
branches: [ "*" ]
pull_request:
branches: [ main, master ]
branches: [ "*" ]
workflow_dispatch:
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check if Rust/Cargo is already installed
id: rust-check-linux
run: |
if command -v cargo &>/dev/null; then
echo "installed=true" >> $GITHUB_OUTPUT
echo "Cargo found: $(cargo --version)"
else
echo "installed=false" >> $GITHUB_OUTPUT
fi
- name: Install Rust
if: steps.rust-check-linux.outputs.installed == 'false'
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Add target (if Rust was pre-installed)
if: steps.rust-check-linux.outputs.installed == 'true'
run: rustup target add x86_64-unknown-linux-gnu
- name: Cache Cargo
uses: actions/cache@v3
with:
@@ -42,21 +54,34 @@ jobs:
name: http-rs-linux-x86_64
path: artifacts/http-rs-linux-x86_64
build-linux-musl:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install musl tools
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Check if Rust/Cargo is already installed
id: rust-check-musl
run: |
if command -v cargo &>/dev/null; then
echo "installed=true" >> $GITHUB_OUTPUT
echo "Cargo found: $(cargo --version)"
else
echo "installed=false" >> $GITHUB_OUTPUT
fi
- name: Install Rust
if: steps.rust-check-musl.outputs.installed == 'false'
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- name: Add target (if Rust was pre-installed)
if: steps.rust-check-musl.outputs.installed == 'true'
run: rustup target add x86_64-unknown-linux-musl
- name: Cache Cargo
uses: actions/cache@v3
with:
@@ -79,22 +104,33 @@ jobs:
name: http-rs-linux-musl-x86_64
path: artifacts/http-rs-linux-musl-x86_64
build-windows:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install MinGW
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64
- name: Check if Rust/Cargo is already installed
id: rust-check-windows
run: |
if command -v cargo &>/dev/null; then
echo "installed=true" >> $GITHUB_OUTPUT
echo "Cargo found: $(cargo --version)"
else
echo "installed=false" >> $GITHUB_OUTPUT
fi
- name: Install Rust
if: steps.rust-check-windows.outputs.installed == 'false'
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-gnu
- name: Install MinGW
run: |
sudo apt-get update
sudo apt-get install -y gcc-mingw-w64-x86-64
- name: Add target (if Rust was pre-installed)
if: steps.rust-check-windows.outputs.installed == 'true'
run: rustup target add x86_64-pc-windows-gnu
- name: Cache Cargo
uses: actions/cache@v3
@@ -117,3 +153,78 @@ jobs:
with:
name: http-rs-windows-x86_64
path: artifacts/http-rs-windows-x86_64.exe
release:
runs-on: ubuntu-latest
needs: [build-linux, build-linux-musl, build-windows]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure jq is installed
run: |
if ! command -v jq &>/dev/null; then
sudo apt-get update && sudo apt-get install -y jq
else
echo "jq already present: $(jq --version)"
fi
- name: Gather commit info
id: info
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
BRANCH="${{ gitea.ref_name }}"
COMMIT_MSG=$(git log -1 --format="%s")
FULL_MSG=$(git log -1 --format="%B")
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
echo "full_msg<<EOF" >> $GITHUB_OUTPUT
echo "$FULL_MSG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: release-artifacts
- name: Create Gitea release and upload assets
env:
GITEA_TOKEN: ${{ secrets.HTTP_RS_RELEASE_PUSH }}
SERVER_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
TAG: ${{ steps.info.outputs.short_sha }}-${{ steps.info.outputs.branch }}
RELEASE_NAME: "${{ steps.info.outputs.short_sha }}:${{ steps.info.outputs.branch }} - ${{ steps.info.outputs.commit_msg }}"
BODY: ${{ steps.info.outputs.full_msg }}
run: |
RELEASE_RESPONSE=$(curl -s -X POST \
"$SERVER_URL/api/v1/repos/$REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg name "$RELEASE_NAME" \
--arg body "$BODY" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}'
)")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo "Created release ID: $RELEASE_ID"
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
exit 1
fi
find release-artifacts -type f | while read FILE; do
FILENAME=$(basename "$FILE")
echo "Uploading $FILENAME..."
curl -s -X POST \
"$SERVER_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$FILENAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$FILE"
done
+1
View File
@@ -2,3 +2,4 @@
Cargo.lock
example
*.diff
+2 -1
View File
@@ -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"
+8
View File
@@ -12,6 +12,14 @@ We now use clap for parsing the cli args, so dont try the old way!
- `--port` : a port where the webserver opens
- `--root` : the directory where the webserver serves files from
- `--threads` : Number of threads to use (default : 2)
- `--lua-plugin` : A lua plugin (path to it)
- `--lua-arg` : example debug
## More on Lua plugins:
Currently its only semi implemented but its on the way. You can load a plugin and depending on where it lives the lua plugin will always choose its own
directory as its current working directory. that is the only thing which i need to mention! Documentation wise its very bad, a wiki of how the Plugin API works
will have to be written!
## Docker
+18
View File
@@ -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
+32
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TestDoc</title>
</head>
<body>
<h1>If this is there the server worked!</h1>
<script>
var msg = "This is js!";
console.log(msg);
</script>
</body>
</html>
+70
View File
@@ -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<Router>);
impl UserData for LuaRouter {
fn add_methods<M: UserDataMethods<Self>>(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<String>, router: Arc<Router>) -> 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(())
}
+29 -14
View File
@@ -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<usize>,
}
/// Lua plugin script to load
#[arg(long)]
lua_plugin: Option<PathBuf>,
/// Arguments passed to the Lua plugin (can be used multiple times)
#[arg(long)]
lua_arg: Vec<String>,
}
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);
}
}
+2 -2
View File
@@ -12,9 +12,9 @@ use tiny_http::{Header, Response, StatusCode};
use tinytemplate::TinyTemplate;
use serde::Serialize;
type BoxedResponse = Response<Cursor<Vec<u8>>>;
pub type BoxedResponse = Response<Cursor<Vec<u8>>>;
fn content_type(value: &str) -> Header {
pub fn content_type(value: &str) -> Header {
Header::from_bytes("Content-Type", value).unwrap()
}
+103 -16
View File
@@ -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<Vec<LuaRoute>>,
}
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")
}
}
}
}
+6 -21
View File
@@ -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<PathBuf>,
router: Arc<Router>,
port: u16,
threads: usize
threads: usize,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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(())
}