From f1bda77ce23d649c794e269a6f39ea22eb757203 Mon Sep 17 00:00:00 2001
From: rattatwinko
Date: Wed, 1 Oct 2025 12:25:27 +0200
Subject: [PATCH] lua - now we have a plugin manager which works relativley
cool!
---
PyPost.py | 26 +-
lua/__init__.py | 0
lua/plugin_manager.py | 443 +++++++
lua/plugins/demo/generatemd.lua | 1 +
lua/plugins/demo/hello.lua | 6 +
lua/plugins/demo/md_banner.lua | 9 +
lua/plugins/demo/return_available_routes.lua | 41 +
lua/readme.md | 1155 ++++++++++++++++++
webserver.py | 71 +-
9 files changed, 1742 insertions(+), 10 deletions(-)
create mode 100644 lua/__init__.py
create mode 100644 lua/plugin_manager.py
create mode 100644 lua/plugins/demo/generatemd.lua
create mode 100644 lua/plugins/demo/hello.lua
create mode 100644 lua/plugins/demo/md_banner.lua
create mode 100644 lua/plugins/demo/return_available_routes.lua
create mode 100644 lua/readme.md
diff --git a/PyPost.py b/PyPost.py
index 384687e..df1b703 100644
--- a/PyPost.py
+++ b/PyPost.py
@@ -7,14 +7,19 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
import base64
import random
+import time
import marko
from marko.ext.gfm import GFM
from watchdog.observers import Observer
-from log.Logger import *
+from log.Logger import *
from hashes.hashes import hash_list
from htmlhandler import htmlhandler as Handler
+from lua import plugin_manager
+
+plugin_manager = plugin_manager.PluginManager()
+plugin_manager.load_all() # load plugins
# Use absolute paths
ROOT = Path(os.path.abspath("."))
@@ -108,6 +113,15 @@ def render_markdown(md_path: Path):
if line.startswith("# "):
title = line[2:].strip()
break
+
+ # Call pre_template hook properly
+ Logger.log_debug(f"Calling pre_template hook for {md_path}")
+ modified = plugin_manager.run_hook("pre_template", str(md_path), html_body)
+ if modified is not None:
+ html_body = modified
+ Logger.log_debug("pre_template hook modified the content")
+ else:
+ Logger.log_debug("pre_template hook returned None")
# Create clean HTML structure
# Pick two different hashes from hash_list
@@ -120,11 +134,15 @@ def render_markdown(md_path: Path):
title=title,
html_body=html_body,
now=time.asctime(time.localtime()),
- hash1 = base64.b64encode(hash1.encode("utf-8")).decode("utf-8"),
- hash2 = base64.b64encode(hash2.encode("windows-1252")).decode("utf-8"),
+ hash1=base64.b64encode(hash1.encode("utf-8")).decode("utf-8"),
+ hash2=base64.b64encode(hash2.encode("windows-1252")).decode("utf-8"),
timestamp=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
)
+ post_mod = plugin_manager.run_hook("post_render", str(md_path), clean_jinja_html)
+ if post_mod is not None:
+ clean_jinja_html = post_mod
+
# Ensure html directory exists
HTML_DIR.mkdir(exist_ok=True)
@@ -145,13 +163,11 @@ def remove_html(md_path: Path):
out_path.unlink()
Logger.log_debug(f"Removed: {out_path}")
-
def initial_scan(markdown_dir: Path):
Logger.log_info(f"Starting initial scan of markdown files in {markdown_dir}...")
for md in markdown_dir.rglob("*.md"):
render_markdown(md)
-
def build_rust_parser() -> bool:
fastmd_dir = ROOT / "fastmd"
diff --git a/lua/__init__.py b/lua/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lua/plugin_manager.py b/lua/plugin_manager.py
new file mode 100644
index 0000000..c00b289
--- /dev/null
+++ b/lua/plugin_manager.py
@@ -0,0 +1,443 @@
+import os
+import re
+import time
+from pathlib import Path
+from lupa import LuaRuntime, LuaError
+from watchdog.observers import Observer
+from watchdog.events import FileSystemEventHandler
+from log.Logger import Logger
+
+
+PLUGINS_DIR = Path(__file__).parent / "plugins"
+HTML_DIR = Path(__file__).parent / "../html"
+MARKDOWN_DIR = Path(__file__).parent / ".." / "markdown"
+
+class PluginFSHandler(FileSystemEventHandler):
+ def __init__(self, manager):
+ self.manager = manager
+
+ def on_modified(self, event):
+ if event.is_directory:
+ return
+ if event.src_path.endswith(".lua"):
+ Logger.log_info(f"Plugin changed: {event.src_path}, reloading")
+ self.manager.reload_plugin(Path(event.src_path))
+
+ def on_created(self, event):
+ if event.is_directory:
+ return
+ if event.src_path.endswith(".lua"):
+ Logger.log_info(f"New plugin: {event.src_path}, loading")
+ self.manager.load_plugin(Path(event.src_path))
+
+ def on_deleted(self, event):
+ if event.is_directory:
+ return
+ p = Path(event.src_path)
+ if p.suffix == ".lua":
+ Logger.log_info(f"Plugin removed: {p.name}")
+ self.manager.unload_plugin(p.name)
+
+class PluginManager:
+ def __init__(self):
+ self.lua = LuaRuntime(unpack_returned_tuples=True)
+ self.plugins = {} # name -> dict{path, lua_module, hooks, routes}
+ self.routes = {} # path -> (plugin_name, lua_fn)
+ self.hooks = {} # hook_name -> list of (plugin_name, lua_fn)
+
+ # Create directories
+ PLUGINS_DIR.mkdir(exist_ok=True)
+ HTML_DIR.mkdir(exist_ok=True)
+ MARKDOWN_DIR.mkdir(exist_ok=True)
+
+ # Expose helpers to Lua
+ self._setup_lua_globals()
+
+ self._start_watcher()
+
+ def _setup_lua_globals(self):
+ """Set up all Lua global functions and guardrails"""
+ g = self.lua.globals()
+
+ # Route and hook registration
+ g.add_route = self._expose_add_route
+ g.register_hook = self._expose_register_hook
+
+ # Logging - using custom Logger
+ g.log = lambda msg: Logger.log_info(f"[lua] {msg}")
+ g.log_warn = lambda msg: Logger.log_warning(f"[lua] {msg}")
+ g.log_error = lambda msg: Logger.log_error(f"[lua] {msg}")
+
+ # Safe file operations (sandboxed)
+ g.read_file = self._safe_read_file
+ g.write_file = self._safe_write_file
+
+ # HTML manipulation
+ g.read_html = lambda filename: self._read_content(HTML_DIR, filename)
+ g.write_html = lambda filename, content: self._write_content(HTML_DIR, filename, content)
+ g.list_html_files = lambda: self._list_files(HTML_DIR, ".html")
+
+ # Markdown manipulation
+ g.read_markdown = lambda filename: self._read_content(MARKDOWN_DIR, filename)
+ g.write_markdown = lambda filename, content: self._write_content(MARKDOWN_DIR, filename, content)
+ g.list_markdown_files = lambda: self._list_files(MARKDOWN_DIR, ".md")
+
+ # HTML modifier helpers
+ g.html_find_tag = self._html_find_tag
+ g.html_replace_tag = self._html_replace_tag
+ g.html_insert_before = self._html_insert_before
+ g.html_insert_after = self._html_insert_after
+ g.html_wrap_content = self._html_wrap_content
+
+ # Markdown modifier helpers
+ g.md_add_header = self._md_add_header
+ g.md_replace_section = self._md_replace_section
+ g.md_append_content = self._md_append_content
+
+ # Utility functions
+ g.table_to_json = self._table_to_json
+ g.json_to_table = self._json_to_table
+
+ # Guardrails - predefined safe patterns
+ self._setup_lua_guardrails()
+
+ def _setup_lua_guardrails(self):
+ """Set up Lua guardrails and safe patterns"""
+ guardrails_code = """
+-- Guardrails and safe patterns for plugin development
+
+-- Safe string operations
+function safe_concat(...)
+ local result = {}
+ for i, v in ipairs({...}) do
+ if v ~= nil then
+ table.insert(result, tostring(v))
+ end
+ end
+ return table.concat(result)
+end
+
+-- Safe table operations
+function table_contains(tbl, value)
+ for _, v in ipairs(tbl) do
+ if v == value then return true end
+ end
+ return false
+end
+
+function table_keys(tbl)
+ local keys = {}
+ for k, _ in pairs(tbl) do
+ table.insert(keys, k)
+ end
+ return keys
+end
+
+function table_values(tbl)
+ local values = {}
+ for _, v in pairs(tbl) do
+ table.insert(values, v)
+ end
+ return values
+end
+
+-- Safe string escaping
+function escape_html(str)
+ if str == nil then return "" end
+ local s = tostring(str)
+ s = string.gsub(s, "&", "&")
+ s = string.gsub(s, "<", "<")
+ s = string.gsub(s, ">", ">")
+ s = string.gsub(s, '"', """)
+ s = string.gsub(s, "'", "'")
+ return s
+end
+
+-- Pattern validation
+function is_valid_filename(name)
+ if name == nil or name == "" then return false end
+ -- Block directory traversal
+ if string.match(name, "%.%.") then return false end
+ if string.match(name, "/") or string.match(name, "\\\\") then return false end
+ return true
+end
+
+-- Safe error handling wrapper
+function try_catch(fn, catch_fn)
+ local status, err = pcall(fn)
+ if not status and catch_fn then
+ catch_fn(err)
+ end
+ return status
+end
+
+-- Request validation
+function validate_request(req, required_fields)
+ if type(req) ~= "table" then return false, "Request must be a table" end
+ for _, field in ipairs(required_fields) do
+ if req[field] == nil then
+ return false, "Missing required field: " .. field
+ end
+ end
+ return true, nil
+end
+
+-- Rate limiting helper (simple in-memory)
+_rate_limits = _rate_limits or {}
+function check_rate_limit(key, max_calls, window_seconds)
+ local now = os.time()
+ if _rate_limits[key] == nil then
+ _rate_limits[key] = {count = 1, window_start = now}
+ return true
+ end
+
+ local rl = _rate_limits[key]
+ if now - rl.window_start > window_seconds then
+ -- Reset window
+ rl.count = 1
+ rl.window_start = now
+ return true
+ end
+
+ if rl.count >= max_calls then
+ return false
+ end
+
+ rl.count = rl.count + 1
+ return true
+end
+
+log("Lua guardrails initialized")
+"""
+ try:
+ self.lua.execute(guardrails_code)
+ except LuaError as e:
+ Logger.log_error(f"Failed to initialize Lua guardrails: {e}")
+
+ # Safe file operations
+ def _safe_read_file(self, path):
+ """Safe file reading with path validation"""
+ try:
+ p = Path(path)
+ # Prevent directory traversal
+ if ".." in str(p.parts):
+ raise ValueError("Path traversal not allowed")
+ return p.read_text(encoding="utf-8")
+ except Exception as e:
+ Logger.log_error(f"Error reading file {path}: {e}")
+ return None
+
+ def _safe_write_file(self, path, content):
+ """Safe file writing with path validation"""
+ try:
+ p = Path(path)
+ # Prevent directory traversal
+ if ".." in str(p.parts):
+ raise ValueError("Path traversal not allowed")
+ p.write_text(content, encoding="utf-8")
+ return True
+ except Exception as e:
+ Logger.log_error(f"Error writing file {path}: {e}")
+ return False
+
+ # HTML/Markdown content operations
+ def _read_content(self, base_dir, filename):
+ """Read content from HTML or Markdown directory"""
+ try:
+ path = base_dir / filename
+ if not path.is_relative_to(base_dir):
+ raise ValueError("Invalid path")
+ if path.exists():
+ return path.read_text(encoding="utf-8")
+ return None
+ except Exception as e:
+ Logger.log_error(f"Error reading {filename}: {e}")
+ return None
+
+ def _write_content(self, base_dir, filename, content):
+ """Write content to HTML or Markdown directory"""
+ try:
+ path = base_dir / filename
+ if not path.is_relative_to(base_dir):
+ raise ValueError("Invalid path")
+ path.write_text(content, encoding="utf-8")
+ return True
+ except Exception as e:
+ Logger.log_error(f"Error writing {filename}: {e}")
+ return False
+
+ def _list_files(self, base_dir, extension):
+ """List files with given extension"""
+ try:
+ return [f.name for f in base_dir.glob(f"*{extension}")]
+ except Exception as e:
+ Logger.log_error(f"Error listing files: {e}")
+ return []
+
+ # HTML manipulation helpers
+ def _html_find_tag(self, html, tag):
+ """Find first occurrence of HTML tag"""
+ pattern = f"<{tag}[^>]*>.*?{tag}>"
+ match = re.search(pattern, html, re.DOTALL | re.IGNORECASE)
+ return match.group(0) if match else None
+
+ def _html_replace_tag(self, html, tag, new_content):
+ """Replace HTML tag content"""
+ pattern = f"(<{tag}[^>]*>).*?({tag}>)"
+ return re.sub(pattern, f"\\1{new_content}\\2", html, flags=re.DOTALL | re.IGNORECASE)
+
+ def _html_insert_before(self, html, marker, content):
+ """Insert content before a marker"""
+ return html.replace(marker, content + marker)
+
+ def _html_insert_after(self, html, marker, content):
+ """Insert content after a marker"""
+ return html.replace(marker, marker + content)
+
+ def _html_wrap_content(self, html, tag, wrapper_tag, attrs=""):
+ """Wrap tag content with another tag"""
+ pattern = f"(<{tag}[^>]*>)(.*?)({tag}>)"
+ def replacer(match):
+ open_tag, content, close_tag = match.groups()
+ return f"{open_tag}<{wrapper_tag} {attrs}>{content}{wrapper_tag}>{close_tag}"
+ return re.sub(pattern, replacer, html, flags=re.DOTALL | re.IGNORECASE)
+
+ # Markdown manipulation helpers
+ def _md_add_header(self, markdown, level, text):
+ """Add header to markdown"""
+ prefix = "#" * level
+ return f"{prefix} {text}\n\n{markdown}"
+
+ def _md_replace_section(self, markdown, header, new_content):
+ """Replace markdown section"""
+ # Find section starting with header
+ pattern = f"(#{1,6}\\s+{re.escape(header)}.*?)(?=#{1,6}\\s+|$)"
+ return re.sub(pattern, f"## {header}\n\n{new_content}\n\n", markdown, flags=re.DOTALL)
+
+ def _md_append_content(self, markdown, content):
+ """Append content to markdown"""
+ return markdown.rstrip() + "\n\n" + content
+
+ # JSON conversion helpers
+ def _table_to_json(self, lua_table):
+ """Convert Lua table to JSON string"""
+ import json
+ try:
+ # Convert lupa table to Python dict
+ py_dict = dict(lua_table)
+ return json.dumps(py_dict)
+ except Exception as e:
+ Logger.log_error(f"Error converting table to JSON: {e}")
+ return "{}"
+
+ def _json_to_table(self, json_str):
+ """Convert JSON string to Lua table"""
+ import json
+ try:
+ return json.loads(json_str)
+ except Exception as e:
+ Logger.log_error(f"Error parsing JSON: {e}")
+ return {}
+
+ """ Lifecycle of Plugin """
+ def load_all(self):
+ for p in PLUGINS_DIR.glob("*.lua"):
+ self.load_plugin(p)
+
+ def load_plugin(self, path: Path):
+ name = path.name
+ try:
+ code = path.read_text(encoding="utf-8")
+ self._current_plugin = name
+ lua_module = self.lua.execute(code)
+ if lua_module is None:
+ lua_module = {}
+ self.plugins[name] = {"path": path, "module": lua_module}
+ Logger.log_info(f"Loaded plugin: {name}")
+ except LuaError as e:
+ Logger.log_error(f"Lua error while loading {name}: {e}")
+ except Exception as e:
+ Logger.log_error(f"Error loading plugin {name}: {e}")
+ finally:
+ self._current_plugin = None
+
+ def reload_plugin(self, path: Path):
+ name = path.name
+ # remove previous hooks/routes
+ self.unload_plugin(name)
+ time.sleep(0.05)
+ self.load_plugin(path)
+
+ def unload_plugin(self, name: str):
+ # Remove routes/hook registrations from this plugin
+ to_remove_routes = [r for r, v in self.routes.items() if v[0] == name]
+ for r in to_remove_routes:
+ del self.routes[r]
+ for hook, lst in list(self.hooks.items()):
+ self.hooks[hook] = [x for x in lst if x[0] != name]
+ if not self.hooks[hook]:
+ del self.hooks[hook]
+ if name in self.plugins:
+ del self.plugins[name]
+ Logger.log_info(f"Unloaded plugin {name}")
+
+ """ Expose a new API route """
+ def _expose_add_route(self, path, lua_fn):
+ """Called from Lua as add_route(path, function(req) ... end)"""
+ p = str(path)
+ plugin_name = self._current_loading_plugin_name()
+ if not plugin_name:
+ plugin_name = ""
+ self.routes[p] = (plugin_name, lua_fn)
+ Logger.log_info(f"Plugin {plugin_name} registered route {p}")
+
+ def _expose_register_hook(self, hook_name, lua_fn):
+ hook = str(hook_name)
+ plugin_name = self._current_loading_plugin_name() or ""
+ self.hooks.setdefault(hook, []).append((plugin_name, lua_fn))
+ Logger.log_info(f"Plugin {plugin_name} registered hook {hook}")
+
+ def _current_loading_plugin_name(self):
+ return getattr(self, "_current_plugin", None)
+
+ """ Running hooks & handling routes """
+ def handle_request(self, path, request_info=None):
+ """If a plugin registered a route for this path, call it and return (status, headers, body) or raw str."""
+ if path in self.routes:
+ plugin_name, lua_fn = self.routes[path]
+ try:
+ lua_req = request_info or {}
+ res = lua_fn(lua_req)
+ if isinstance(res, tuple) and len(res) == 3:
+ return res
+ return (200, {"Content-Type": "text/html"}, str(res))
+ except LuaError as e:
+ Logger.log_error(f"Lua error in route {path}: {e}")
+ return (500, {"Content-Type": "text/plain"}, f"Plugin error: {e}")
+ return None
+
+ def run_hook(self, hook_name: str, *args):
+ """Run all registered hook functions for hook_name; return last non-None return value."""
+ if hook_name not in self.hooks:
+ return None
+ last = None
+ for plugin_name, fn in list(self.hooks[hook_name]):
+ try:
+ out = fn(*args)
+ if out is not None:
+ last = out
+ except LuaError as e:
+ Logger.log_error(f"Lua error in hook {hook_name} from {plugin_name}: {e}")
+ return last
+
+ """ File watcher """
+ def _start_watcher(self):
+ self.observer = Observer()
+ self.fs_handler = PluginFSHandler(self)
+ self.observer.schedule(self.fs_handler, str(PLUGINS_DIR), recursive=False)
+ self.observer.start()
+ Logger.log_info("Started plugin folder watcher")
+
+ def stop(self):
+ self.observer.stop()
+ self.observer.join()
diff --git a/lua/plugins/demo/generatemd.lua b/lua/plugins/demo/generatemd.lua
new file mode 100644
index 0000000..6c011cb
--- /dev/null
+++ b/lua/plugins/demo/generatemd.lua
@@ -0,0 +1 @@
+write_markdown("output.md", "# Generated Document\n\nContent here...")
\ No newline at end of file
diff --git a/lua/plugins/demo/hello.lua b/lua/plugins/demo/hello.lua
new file mode 100644
index 0000000..7f4c200
--- /dev/null
+++ b/lua/plugins/demo/hello.lua
@@ -0,0 +1,6 @@
+-- hello.lua
+add_route("/lua/hello", function(req)
+ log("hello.lua handling request for " .. (req.path or "unknown"))
+ -- return (status, headers_table, body_string)
+ return 200, {["Content-Type"] = "text/html"}, "Hello from Lua plugin!
"
+end)
diff --git a/lua/plugins/demo/md_banner.lua b/lua/plugins/demo/md_banner.lua
new file mode 100644
index 0000000..369ab64
--- /dev/null
+++ b/lua/plugins/demo/md_banner.lua
@@ -0,0 +1,9 @@
+register_hook("pre_template", function(md_path, html_body)
+ -- Check if the current markdown path is for test.md
+ if md_path and md_path:match("test%.md$") then
+ local banner = "Note: served via Lua plugin banner
"
+ return banner .. html_body
+ end
+ -- For other pages, return the original HTML body unchanged
+ return html_body
+end)
\ No newline at end of file
diff --git a/lua/plugins/demo/return_available_routes.lua b/lua/plugins/demo/return_available_routes.lua
new file mode 100644
index 0000000..e1ed336
--- /dev/null
+++ b/lua/plugins/demo/return_available_routes.lua
@@ -0,0 +1,41 @@
+-- Simple working endpoint using existing utilities
+add_route("/admin/html-files", function(req)
+ -- Get files and convert to proper Lua table
+ local files = list_html_files()
+
+ local html = [[
+
+
+
+ HTML Files
+
+
+
+ HTML Files
+
+]]
+
+ -- Safe iteration using a counter
+ local count = 0
+ for i = 1, 100 do -- Safe upper limit
+ local success, file = pcall(function() return files[i] end)
+ if not success or file == nil then
+ break
+ end
+ html = html .. "
" .. file .. "
"
+ count = count + 1
+ end
+
+ html = html .. [[
+
+ Total files: ]] .. count .. [[
+ Back
+
+
+]]
+
+ return html
+end)
\ No newline at end of file
diff --git a/lua/readme.md b/lua/readme.md
new file mode 100644
index 0000000..a938d78
--- /dev/null
+++ b/lua/readme.md
@@ -0,0 +1,1155 @@
+# Plugin Manager Documentation
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Architecture](#architecture)
+3. [Getting Started](#getting-started)
+4. [Core Concepts](#core-concepts)
+ - [Plugins](#plugins)
+ - [Routes](#routes)
+ - [Hooks](#hooks)
+5. [API Reference](#api-reference)
+ - [Route Registration](#route-registration)
+ - [Hook Registration](#hook-registration)
+ - [File Operations](#file-operations)
+ - [HTML Manipulation](#html-manipulation)
+ - [Markdown Manipulation](#markdown-manipulation)
+ - [Logging Functions](#logging-functions)
+ - [Utility Functions](#utility-functions)
+6. [Guardrails & Security](#guardrails--security)
+7. [Examples](#examples)
+
+---
+
+## Overview
+
+The Plugin Manager is a Python-based system that enables dynamic Lua plugin loading for extending application functionality. It provides a secure, sandboxed environment for plugins to register HTTP routes, hook into application events, and manipulate HTML/Markdown content.
+
+**Key Features:**
+- Hot-reload plugins without restarting the application
+- Secure file operations with path traversal protection
+- Built-in HTML and Markdown manipulation tools
+- Event-based hook system
+- HTTP route registration
+- Comprehensive logging
+- Rate limiting and validation helpers
+
+---
+
+## Architecture
+
+### Directory Structure
+
+```
+project/
+├── plugin_manager.py # Main plugin manager
+├── plugins/ # Lua plugin files (*.lua)
+├── html/ # HTML files for manipulation
+└── markdown/ # Markdown files for manipulation
+```
+
+### Component Overview
+
+**PluginManager**: Core class that manages plugin lifecycle, Lua runtime, and API exposure
+
+**PluginFSHandler**: Watchdog-based file system handler for hot-reloading
+
+**LuaRuntime**: Embedded Lua interpreter with Python bindings via `lupa`
+
+---
+
+## Getting Started
+
+### Installation Requirements
+
+```bash
+pip install lupa watchdog
+```
+
+### Creating Your First Plugin
+
+1. Create a file `plugins/hello.lua`:
+
+```lua
+-- Register a simple route
+add_route("/hello", function(req)
+ return "Hello from Lua Plugin!
"
+end)
+
+log("Hello plugin loaded")
+```
+
+2. Start the plugin manager:
+
+```python
+from plugin_manager import PluginManager
+
+manager = PluginManager()
+manager.load_all()
+
+# Handle a request
+result = manager.handle_request("/hello")
+print(result) # (200, {"Content-Type": "text/html"}, "Hello from Lua Plugin!
")
+```
+
+3. The plugin will automatically reload when you modify `hello.lua`
+
+---
+
+## Core Concepts
+
+### Plugins
+
+Plugins are Lua scripts (`.lua` files) placed in the `plugins/` directory. Each plugin can:
+- Register HTTP routes via [`add_route()`](#route-registration)
+- Hook into events via [`register_hook()`](#hook-registration)
+- Access [file operations](#file-operations)
+- Use [HTML](#html-manipulation) and [Markdown manipulation](#markdown-manipulation) tools
+
+**Plugin Lifecycle:**
+1. **Load**: Plugin file is read and executed in Lua runtime
+2. **Active**: Plugin routes/hooks are registered and callable
+3. **Reload**: File change detected → unload → load
+4. **Unload**: Plugin deleted → routes/hooks removed
+
+### Routes
+
+Routes map URL paths to Lua handler functions. When a request matches a registered route, the corresponding Lua function is called.
+
+**See:** [Route Registration](#route-registration), [Examples - Route Handler](#example-1-simple-route-handler)
+
+### Hooks
+
+Hooks are named events where plugins can register callback functions. Multiple plugins can hook into the same event, and they'll be called in registration order.
+
+**Common Use Cases:**
+- Pre/post-processing request data
+- Modifying responses before they're sent
+- Content transformation pipelines
+- Event notifications
+
+**See:** [Hook Registration](#hook-registration), [Examples - Hook System](#example-2-hook-system)
+
+---
+
+## API Reference
+
+### Route Registration
+
+#### `add_route(path, handler_function)`
+
+Register a URL path handler.
+
+**Parameters:**
+- `path` (string): URL path to handle (e.g., `/api/users`)
+- `handler_function` (function): Lua function to handle requests
+
+**Handler Function Signature:**
+```lua
+function(request) -> response
+```
+
+**Request Object:**
+```lua
+{
+ method = "GET", -- HTTP method
+ path = "/api/users", -- Request path
+ query = {...}, -- Query parameters
+ body = "...", -- Request body
+ headers = {...} -- Request headers
+}
+```
+
+**Response Format:**
+
+Option 1 - Simple string (returns 200 with text/html):
+```lua
+return "..."
+```
+
+Option 2 - Full response tuple:
+```lua
+return 200, {["Content-Type"] = "application/json"}, '{"status":"ok"}'
+```
+
+**Example:**
+```lua
+add_route("/api/status", function(req)
+ return 200, {["Content-Type"] = "application/json"}, '{"status": "online"}'
+end)
+```
+
+**See also:** [Hooks](#hook-registration), [Examples](#examples)
+
+---
+
+### Hook Registration
+
+#### `register_hook(hook_name, handler_function)`
+
+Register a callback for a named hook event.
+
+**Parameters:**
+- `hook_name` (string): Name of the hook to register for
+- `handler_function` (function): Callback function
+
+**Handler Function Signature:**
+```lua
+function(...args) -> result
+```
+
+**Behavior:**
+- All registered hooks for an event are called in order
+- Return values can modify data (last non-nil value is used)
+- Hooks receive arguments passed by `run_hook()` from Python
+
+**Example:**
+```lua
+-- Content transformation hook
+register_hook("transform_html", function(html)
+ -- Add analytics script
+ return html_insert_before(html, "
", "")
+```
+
+**See also:** [`html_insert_before()`](#html_insert_beforehtml-marker-content)
+
+#### `html_wrap_content(html, tag, wrapper_tag, attrs)`
+
+Wrap the content of a tag with another tag.
+
+**Parameters:**
+- `html` (string): HTML content
+- `tag` (string): Tag whose content to wrap
+- `wrapper_tag` (string): Tag to wrap with
+- `attrs` (string): Attributes for wrapper tag (optional)
+
+**Returns:** Modified HTML
+
+**Example:**
+```lua
+local html = "Hello World
"
+local modified = html_wrap_content(html, "div", "span", 'class="highlight"')
+-- Result: "Hello World
"
+```
+
+**See also:** [`html_replace_tag()`](#html_replace_taghtml-tag-new_content)
+
+---
+
+### Markdown Manipulation
+
+#### `read_markdown(filename)`
+
+Read Markdown file from the `markdown/` directory.
+
+**Parameters:**
+- `filename` (string): Name of Markdown file (e.g., `"README.md"`)
+
+**Returns:** Markdown content as string, or `nil` if not found
+
+**Example:**
+```lua
+local md = read_markdown("README.md")
+```
+
+#### `write_markdown(filename, content)`
+
+Write Markdown content to the `markdown/` directory.
+
+**Parameters:**
+- `filename` (string): Name of Markdown file
+- `content` (string): Markdown content to write
+
+**Returns:** `true` on success, `false` on error
+
+**Example:**
+```lua
+write_markdown("output.md", "# Generated Document\n\nContent here...")
+```
+
+#### `list_markdown_files()`
+
+Get list of all Markdown files in the `markdown/` directory.
+
+**Returns:** Array of filenames
+
+**Example:**
+```lua
+local files = list_markdown_files()
+for _, file in ipairs(files) do
+ local content = read_markdown(file)
+ -- Process each file
+end
+```
+
+#### `md_add_header(markdown, level, text)`
+
+Add a header at the beginning of Markdown content.
+
+**Parameters:**
+- `markdown` (string): Existing Markdown content
+- `level` (number): Header level (1-6, where 1 is `#` and 6 is `######`)
+- `text` (string): Header text
+
+**Returns:** Modified Markdown with header prepended
+
+**Example:**
+```lua
+local md = "Some content here"
+local modified = md_add_header(md, 1, "Introduction")
+-- Result:
+-- # Introduction
+--
+-- Some content here
+```
+
+**See also:** [`md_replace_section()`](#md_replace_sectionmarkdown-header-new_content)
+
+#### `md_replace_section(markdown, header, new_content)`
+
+Replace an entire Markdown section (from header to next header or end).
+
+**Parameters:**
+- `markdown` (string): Markdown content
+- `header` (string): Header text to find (without `#` symbols)
+- `new_content` (string): New content for the section
+
+**Returns:** Modified Markdown
+
+**Example:**
+```lua
+local md = [[
+# Introduction
+Old intro text
+
+# Features
+Feature list here
+]]
+
+local modified = md_replace_section(md, "Introduction", "New introduction paragraph")
+-- Result:
+-- ## Introduction
+--
+-- New introduction paragraph
+--
+-- # Features
+-- Feature list here
+```
+
+**See also:** [`md_add_header()`](#md_add_headermarkdown-level-text), [`md_append_content()`](#md_append_contentmarkdown-content)
+
+#### `md_append_content(markdown, content)`
+
+Append content to the end of Markdown document.
+
+**Parameters:**
+- `markdown` (string): Existing Markdown content
+- `content` (string): Content to append
+
+**Returns:** Modified Markdown
+
+**Example:**
+```lua
+local md = "# Document\n\nExisting content"
+local modified = md_append_content(md, "## Appendix\n\nAdditional information")
+```
+
+**See also:** [`md_add_header()`](#md_add_headermarkdown-level-text)
+
+---
+
+### Logging Functions
+
+#### `log(message)`
+
+Log informational message.
+
+**Parameters:**
+- `message` (string): Message to log
+
+**Example:**
+```lua
+log("Plugin initialized successfully")
+```
+
+**See also:** [`log_warn()`](#log_warnmessage), [`log_error()`](#log_errormessage)
+
+#### `log_warn(message)`
+
+Log warning message.
+
+**Parameters:**
+- `message` (string): Warning message
+
+**Example:**
+```lua
+if not config then
+ log_warn("Configuration file not found, using defaults")
+end
+```
+
+**See also:** [`log()`](#logmessage), [`log_error()`](#log_errormessage)
+
+#### `log_error(message)`
+
+Log error message.
+
+**Parameters:**
+- `message` (string): Error message
+
+**Example:**
+```lua
+local html = read_html("template.html")
+if not html then
+ log_error("Failed to load template.html")
+ return
+end
+```
+
+**See also:** [`log()`](#logmessage), [`log_warn()`](#log_warnmessage)
+
+---
+
+### Utility Functions
+
+#### `table_to_json(lua_table)`
+
+Convert Lua table to JSON string.
+
+**Parameters:**
+- `lua_table` (table): Lua table to convert
+
+**Returns:** JSON string
+
+**Example:**
+```lua
+local data = {name = "John", age = 30}
+local json = table_to_json(data)
+-- Result: '{"name":"John","age":30}'
+```
+
+**See also:** [`json_to_table()`](#json_to_tablejson_str)
+
+#### `json_to_table(json_str)`
+
+Parse JSON string into Lua table.
+
+**Parameters:**
+- `json_str` (string): JSON string
+
+**Returns:** Lua table, or empty table on error
+
+**Example:**
+```lua
+local json = '{"status":"ok","count":5}'
+local data = json_to_table(json)
+log("Status: " .. data.status) -- Status: ok
+```
+
+**See also:** [`table_to_json()`](#table_to_jsonlua_table)
+
+---
+
+## Guardrails & Security
+
+The plugin system includes built-in security features and helper functions to prevent common vulnerabilities.
+
+### Path Traversal Protection
+
+All file operations automatically block path traversal attempts:
+
+```lua
+-- BLOCKED - Will fail safely
+read_file("../../../etc/passwd")
+read_html("../../config.php")
+
+-- ALLOWED - Sandboxed to designated directories
+read_html("index.html")
+read_markdown("docs.md")
+```
+
+**Implementation:** Any path containing `..` is rejected before file access.
+
+### Safe String Operations
+
+#### `safe_concat(...)`
+
+Safely concatenate multiple values, converting to strings and handling `nil`.
+
+**Example:**
+```lua
+local msg = safe_concat("User ", nil, " logged in at ", os.time())
+-- Result: "User logged in at 1609459200"
+```
+
+**See also:** [`escape_html()`](#escape_htmlstr)
+
+#### `escape_html(str)`
+
+Escape HTML special characters to prevent XSS attacks.
+
+**Escapes:**
+- `&` → `&`
+- `<` → `<`
+- `>` → `>`
+- `"` → `"`
+- `'` → `'`
+
+**Example:**
+```lua
+local user_input = ''
+local safe = escape_html(user_input)
+-- Result: "<script>alert("XSS")</script>"
+
+return "" .. safe .. "
"
+```
+
+**Use Case:** Always escape user input before including in HTML responses.
+
+**See also:** [Security Best Practices](#security)
+
+### Filename Validation
+
+#### `is_valid_filename(name)`
+
+Validate filename for safety.
+
+**Checks:**
+- Not nil or empty
+- No `..` (path traversal)
+- No `/` or `\` (directory separators)
+
+**Returns:** `true` if valid, `false` otherwise
+
+**Example:**
+```lua
+local filename = req.query.file
+
+if not is_valid_filename(filename) then
+ return 400, {}, "Invalid filename"
+end
+
+local content = read_html(filename)
+```
+
+**See also:** [Path Traversal Protection](#path-traversal-protection)
+
+### Error Handling
+
+#### `try_catch(fn, catch_fn)`
+
+Safe error handling wrapper using `pcall`.
+
+**Parameters:**
+- `fn` (function): Function to execute
+- `catch_fn` (function): Error handler (receives error message)
+
+**Returns:** `true` if successful, `false` if error occurred
+
+**Example:**
+```lua
+local success = try_catch(
+ function()
+ local html = read_html("template.html")
+ local modified = html_replace_tag(html, "title", "New Title")
+ write_html("output.html", modified)
+ end,
+ function(err)
+ log_error("HTML processing failed: " .. tostring(err))
+ end
+)
+
+if not success then
+ return 500, {}, "Internal error"
+end
+```
+
+**See also:** [`validate_request()`](#validate_requestreq-required_fields)
+
+### Request Validation
+
+#### `validate_request(req, required_fields)`
+
+Validate request object has required fields.
+
+**Parameters:**
+- `req` (table): Request object to validate
+- `required_fields` (array): List of required field names
+
+**Returns:** `(true, nil)` if valid, or `(false, error_message)` if invalid
+
+**Example:**
+```lua
+add_route("/api/create", function(req)
+ local valid, err = validate_request(req, {"name", "email", "body"})
+ if not valid then
+ return 400, {}, "Bad request: " .. err
+ end
+
+ -- Process valid request
+ return 200, {}, "Created"
+end)
+```
+
+**See also:** [`try_catch()`](#try_catchfn-catch_fn)
+
+### Rate Limiting
+
+#### `check_rate_limit(key, max_calls, window_seconds)`
+
+Simple in-memory rate limiting.
+
+**Parameters:**
+- `key` (string): Unique identifier for rate limit (e.g., IP address, user ID)
+- `max_calls` (number): Maximum calls allowed in window
+- `window_seconds` (number): Time window in seconds
+
+**Returns:** `true` if within limit, `false` if exceeded
+
+**Example:**
+```lua
+add_route("/api/search", function(req)
+ local client_ip = req.headers["X-Forwarded-For"] or "unknown"
+
+ if not check_rate_limit(client_ip, 10, 60) then
+ return 429, {}, "Rate limit exceeded. Try again later."
+ end
+
+ -- Process search
+ return 200, {}, "Search results"
+end)
+```
+
+**Note:** Rate limits are stored in memory and reset when plugin reloads.
+
+**See also:** [Security Best Practices](#security)
+
+### Table Utilities
+
+#### `table_contains(tbl, value)`
+
+Check if table contains a value.
+
+**Example:**
+```lua
+local allowed = {"admin", "moderator", "user"}
+if table_contains(allowed, req.role) then
+ -- Process request
+end
+```
+
+#### `table_keys(tbl)`
+
+Get all keys from a table.
+
+**Example:**
+```lua
+local data = {name = "John", age = 30}
+local keys = table_keys(data)
+-- Result: {"name", "age"}
+```
+
+#### `table_values(tbl)`
+
+Get all values from a table.
+
+**Example:**
+```lua
+local data = {a = 1, b = 2, c = 3}
+local values = table_values(data)
+-- Result: {1, 2, 3}
+```
+
+---
+
+## Examples
+
+### Example 1: Simple Route Handler
+
+Create `plugins/api.lua`:
+
+```lua
+-- Simple API route
+add_route("/api/hello", function(req)
+ local name = req.query.name or "World"
+ local safe_name = escape_html(name)
+
+ return 200,
+ {["Content-Type"] = "application/json"},
+ '{"message": "Hello ' .. safe_name .. '"}'
+end)
+
+log("API plugin loaded")
+```
+
+**Usage:**
+```python
+result = manager.handle_request("/api/hello", {
+ "query": {"name": "Alice"}
+})
+# Returns: (200, {...}, '{"message": "Hello Alice"}')
+```
+
+**See also:** [Route Registration](#route-registration)
+
+---
+
+### Example 2: Hook System
+
+Create `plugins/analytics.lua`:
+
+```lua
+-- Hook into HTML transformation
+register_hook("transform_html", function(html, page_name)
+ log("Adding analytics to " .. page_name)
+
+ local analytics_script = [[
+
+ ]]
+
+ return html_insert_before(html, "", analytics_script)
+end)
+```
+
+**Python Side:**
+```python
+# Trigger hook
+original_html = "..."
+modified_html = manager.run_hook("transform_html", original_html, "home")
+```
+
+**See also:** [Hook Registration](#hook-registration)
+
+---
+
+### Example 3: HTML Template Processor
+
+Create `plugins/templates.lua`:
+
+```lua
+-- Process HTML templates with variable substitution
+add_route("/render/:template", function(req)
+ local template_name = req.params.template .. ".html"
+
+ -- Validate filename
+ if not is_valid_filename(template_name) then
+ return 400, {}, "Invalid template name"
+ end
+
+ -- Read template
+ local html = read_html(template_name)
+ if not html then
+ return 404, {}, "Template not found"
+ end
+
+ -- Replace variables
+ local title = req.query.title or "Untitled"
+ local content = req.query.content or "No content"
+
+ html = html_replace_tag(html, "title", escape_html(title))
+ html = html_replace_tag(html, "main", escape_html(content))
+
+ return html
+end)
+```
+
+**See also:** [HTML Manipulation](#html-manipulation), [Route Registration](#route-registration)
+
+---
+
+### Example 4: Markdown Documentation Generator
+
+Create `plugins/docs_generator.lua`:
+
+```lua
+-- Generate documentation from code files
+add_route("/docs/generate", function(req)
+ local doc_content = [[
+# API Documentation
+
+Generated on: ]] .. os.date() .. [[
+
+## Overview
+
+This documentation is auto-generated from source code.
+ ]]
+
+ -- Add sections for each endpoint
+ local endpoints = {
+ {path = "/api/users", method = "GET", desc = "Get all users"},
+ {path = "/api/users/:id", method = "GET", desc = "Get user by ID"},
+ {path = "/api/users", method = "POST", desc = "Create new user"}
+ }
+
+ for _, endpoint in ipairs(endpoints) do
+ local section = string.format([[
+
+## %s %s
+
+%s
+
+**Example:**
+```
+curl -X %s http://localhost%s
+```
+ ]], endpoint.method, endpoint.path, endpoint.desc, endpoint.method, endpoint.path)
+
+ doc_content = md_append_content(doc_content, section)
+ end
+
+ -- Save to file
+ write_markdown("api_docs.md", doc_content)
+
+ return 200, {}, "Documentation generated successfully"
+end)
+```
+
+**See also:** [Markdown Manipulation](#markdown-manipulation)
+
+---
+
+### Example 5: Content Security System
+
+Create `plugins/security.lua`:
+
+```lua
+-- Rate-limited API with validation
+add_route("/api/submit", function(req)
+ -- Rate limiting
+ local client_ip = req.headers["X-Real-IP"] or "unknown"
+ if not check_rate_limit(client_ip, 5, 60) then
+ log_warn("Rate limit exceeded for " .. client_ip)
+ return 429, {}, "Too many requests"
+ end
+
+ -- Request validation
+ local valid, err = validate_request(req, {"title", "content", "author"})
+ if not valid then
+ return 400, {}, err
+ end
+
+ -- Content sanitization
+ local title = escape_html(req.body.title)
+ local content = escape_html(req.body.content)
+ local author = escape_html(req.body.author)
+
+ -- Save as HTML
+ local html = string.format([[
+
+
+
+ %s
+
+
+ %s
+ By: %s
+ %s
+
+
+ ]], title, title, author, content)
+
+ local filename = "post_" .. os.time() .. ".html"
+ write_html(filename, html)
+
+ log("Created post: " .. filename)
+ return 200, {}, "Post created successfully"
+end)
+```
+
+**See also:** [Guardrails & Security](#guardrails--security)
+
+---
+
+### Example 6: Multi-File HTML Processor
+
+Create `plugins/html_batch.lua`:
+
+```lua
+-- Process all HTML files in a directory
+add_route("/admin/add_footer", function(req)
+ local files = list_html_files()
+ local processed = 0
+
+ local footer = [[
+
+ ]]
+
+ for _, filename in ipairs(files) do
+ try_catch(
+ function()
+ local html = read_html(filename)
+ if html then
+ -- Add footer before closing body tag
+ local modified = html_insert_before(html, "", "")
+end)
+
+-- Logging hook
+register_hook("request_complete", function(path, status)
+ log("Request to " .. path .. " returned " .. status)
+end)
+```
+
+**Python Side - Triggering Hooks:**
+```python
+# Transform HTML through all registered hooks
+modified_html = manager.run_hook("transform_html", original_html)
+
+# Notify all hooks of event
+manager.run_hook("request_complete", "/api/users", 200)
+```
+
+**See also:** [Routes](#route-registration), [Examples - Hooks](#example-2-hook-system)
+
+---
+
+### File Operations
+
+#### `read_file(path)`
+
+Read any file with path validation.
+
+**Parameters:**
+- `path` (string): File path to read
+
+**Returns:** File content as string, or `nil` on error
+
+**Security:** Path traversal (`..`) is blocked
+
+**Example:**
+```lua
+local config = read_file("config.json")
+if config then
+ log("Config loaded")
+end
+```
+
+#### `write_file(path, content)`
+
+Write content to file with path validation.
+
+**Parameters:**
+- `path` (string): File path to write
+- `content` (string): Content to write
+
+**Returns:** `true` on success, `false` on error
+
+**Example:**
+```lua
+write_file("output.txt", "Generated content")
+```
+
+**See also:** [HTML Operations](#html-manipulation), [Markdown Operations](#markdown-manipulation)
+
+---
+
+### HTML Manipulation
+
+#### `read_html(filename)`
+
+Read HTML file from the `html/` directory.
+
+**Parameters:**
+- `filename` (string): Name of HTML file (e.g., `"index.html"`)
+
+**Returns:** HTML content as string, or `nil` if not found
+
+**Example:**
+```lua
+local html = read_html("index.html")
+if html then
+ -- Process HTML
+end
+```
+
+#### `write_html(filename, content)`
+
+Write HTML content to the `html/` directory.
+
+**Parameters:**
+- `filename` (string): Name of HTML file
+- `content` (string): HTML content to write
+
+**Returns:** `true` on success, `false` on error
+
+**Example:**
+```lua
+local modified = html_insert_after(html, "
", "")
+write_html("index.html", modified)
+```
+
+#### `list_html_files()`
+
+Get list of all HTML files in the `html/` directory.
+
+**Returns:** Array of filenames
+
+**Example:**
+```lua
+local files = list_html_files()
+for _, file in ipairs(files) do
+ log("Found HTML file: " .. file)
+end
+```
+
+#### `html_find_tag(html, tag)`
+
+Find the first occurrence of an HTML tag.
+
+**Parameters:**
+- `html` (string): HTML content to search
+- `tag` (string): Tag name (e.g., `"div"`, `"title"`)
+
+**Returns:** Matched tag with content, or `nil` if not found
+
+**Example:**
+```lua
+local title_tag = html_find_tag(html, "title")
+-- Result: "
My Page"
+```
+
+#### `html_replace_tag(html, tag, new_content)`
+
+Replace the content inside an HTML tag.
+
+**Parameters:**
+- `html` (string): HTML content
+- `tag` (string): Tag name to replace content within
+- `new_content` (string): New content (tag itself is preserved)
+
+**Returns:** Modified HTML
+
+**Example:**
+```lua
+-- Replace title content
+local html = "
Old Title"
+local modified = html_replace_tag(html, "title", "New Title")
+-- Result: "
New Title"
+```
+
+**See also:** [`html_wrap_content()`](#html_wrap_contenthtml-tag-wrapper_tag-attrs)
+
+#### `html_insert_before(html, marker, content)`
+
+Insert content before a marker string.
+
+**Parameters:**
+- `html` (string): HTML content
+- `marker` (string): String to insert before
+- `content` (string): Content to insert
+
+**Returns:** Modified HTML
+
+**Example:**
+```lua
+-- Insert meta tag before closing head
+local html = "
"
+local modified = html_insert_before(html, "", "")
+-- Result: "
"
+```
+
+**See also:** [`html_insert_after()`](#html_insert_afterhtml-marker-content)
+
+#### `html_insert_after(html, marker, content)`
+
+Insert content after a marker string.
+
+**Parameters:**
+- `html` (string): HTML content
+- `marker` (string): String to insert after
+- `content` (string): Content to insert
+
+**Returns:** Modified HTML
+
+**Example:**
+```lua
+-- Insert script before closing body
+local modified = html_insert_after(html, "
", footer)
+ write_html(filename, modified)
+ processed = processed + 1
+ end
+ end,
+ function(err)
+ log_error("Failed to process " .. filename .. ": " .. tostring(err))
+ end
+ )
+ end
+
+ return 200, {}, "Processed " .. processed .. " files"
+end)
+```
+
+**See also:** [HTML Manipulation](#html-manipulation), [Error Handling](#error-handling)
+
+---
+
+## Best Practices
+
+### Plugin Design
+
+1. **Single Responsibility**: Each plugin should have one clear purpose
+2. **Error Handling**: Always use [`try_catch()`](#try_catchfn-catch_fn) for operations that might fail
+3. **Logging**: Use appropriate log levels ([`log()`](#logmessage), [`log_warn()`](#log_warnmessage), [`log_error()`](#log_errormessage))
+4. **Documentation**: Add comments explaining what your plugin does
+
+```lua
+-- Good: Clear purpose, error handling, logging
+add_route("/api/config", function(req)
+ try_catch(
+ function()
+ local config = read_file("config.json")
+ return 200, {}, config
+ end,
+ function(err)
+ log_error("Config read failed: " .. tostring(err))
+ return 500, {}, "Internal error"
+ end
+ )
+end)
+```
+
+**See also:** [Examples](#examples)
+
+---
+
+### Security
+
+1. **Always Escape User Input**: Use [`escape_html()`](#escape_htmlstr) for any user-provided content
+2. **Validate Filenames**: Use [`is_valid_filename()`](#is_valid_filenamename) before file operations
+3. **Validate Requests**: Use [`validate_request()`](#validate_requestreq-required_fields) for required fields
+4. **Implement Rate Limiting**: Use [`check_rate_limit()`](#check_rate_limitkey-max_calls-window_seconds) for public APIs
+5. **Never Trust Input**: Sanitize and validate all external data
+
+```lua
+-- Good: Comprehensive security
+add_route("/api/read", function(req)
+ local filename = req.query.file
+
+ -- Validate filename
+ if not is_valid_filename(filename) then
+ return 400, {}, "Invalid filename"
+ end
+
+ -- Rate limit
+ if not check_rate_limit(req.ip, 10, 60) then
+ return 429, {}, "Rate limited"
+ end
+
+ -- Safe read
+ local content = read_html(filename)
+ if content then
+ return escape_html(content)
+ end
+ return 404, {}, "Not found"
+end)
+```
+
+**See also:** [Guardrails & Security](#guardrails--security), [Path Traversal Protection](#path-traversal-protection)
+
diff --git a/webserver.py b/webserver.py
index acc4c2c..16632fe 100644
--- a/webserver.py
+++ b/webserver.py
@@ -5,14 +5,19 @@ import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
import mimetypes
from jsmin import jsmin # pip install jsmin
+from pathlib import Path
from log.Logger import *
+from lua import plugin_manager
logger = Logger()
+plugin_manager = plugin_manager.PluginManager()
+plugin_manager.load_all() # load all plugins
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
HTML_DIR = os.path.join(PROJECT_ROOT, "html")
MARKDOWN_DIR = os.path.join(PROJECT_ROOT, "markdown")
BASE_FILE = os.path.join(HTML_DIR, "base", "index.html")
+LUA_DIR = Path(PROJECT_ROOT) / "lua" / "plugins"
def get_html_files(directory=HTML_DIR):
html_files = []
@@ -87,8 +92,8 @@ def index_footer():
class MyHandler(BaseHTTPRequestHandler):
def do_GET(self):
- req_path = self.path.lstrip("/")
-
+ req_path = self.path.lstrip("/") # normalize leading /
+
# Handle root/index
if req_path == "" or req_path == "index.html":
content = build_index_page()
@@ -98,11 +103,22 @@ class MyHandler(BaseHTTPRequestHandler):
self.wfile.write(content.encode("utf-8"))
return
+ # CHECK PLUGIN ROUTES FIRST
+ plugin_result = plugin_manager.handle_request("/" + req_path, {"path": self.path})
+ if plugin_result is not None:
+ status, headers, body = plugin_result
+ self.send_response(status)
+ for key, value in headers.items():
+ self.send_header(key, value)
+ self.end_headers()
+ self.wfile.write(body.encode("utf-8") if isinstance(body, str) else body)
+ return
+
# Handle markdown file downloads
if req_path.startswith("markdown/"):
markdown_filename = req_path[9:] # Remove "markdown/" prefix
- # Security check: only allow .md files and prevent directory traversal
+ # Security check
if not markdown_filename.endswith(".md") or ".." in markdown_filename or "/" in markdown_filename:
self.send_response(403)
self.end_headers()
@@ -111,14 +127,12 @@ class MyHandler(BaseHTTPRequestHandler):
markdown_file_path = os.path.join(MARKDOWN_DIR, markdown_filename)
- # Check if file exists and is within markdown directory
if not os.path.exists(markdown_file_path) or not os.path.isfile(markdown_file_path):
self.send_response(404)
self.end_headers()
self.wfile.write(b"404 - Markdown file not found")
return
- # Verify the resolved path is still within the markdown directory (extra security)
resolved_path = os.path.realpath(markdown_file_path)
resolved_markdown_dir = os.path.realpath(MARKDOWN_DIR)
if not resolved_path.startswith(resolved_markdown_dir):
@@ -146,6 +160,52 @@ class MyHandler(BaseHTTPRequestHandler):
self.wfile.write(b"500 - Internal Server Error")
return
+ # Handle Lua file downloads
+ if req_path.startswith("lua/"):
+ lua_filename = req_path[4:] # Remove "lua/" prefix
+
+ # Security check
+ if not lua_filename.endswith(".lua") or ".." in lua_filename or "/" in lua_filename:
+ self.send_response(403)
+ self.end_headers()
+ self.wfile.write(b"403 - Forbidden: Only .lua files allowed")
+ return
+
+ lua_file_path = os.path.join(LUA_DIR, lua_filename)
+
+ if not os.path.exists(lua_file_path) or not os.path.isfile(lua_file_path):
+ self.send_response(404)
+ self.end_headers()
+ self.wfile.write(b"404 - Lua file not found")
+ return
+
+ resolved_path = os.path.realpath(lua_file_path)
+ resolved_lua_dir = os.path.realpath(LUA_DIR)
+ if not resolved_path.startswith(resolved_lua_dir):
+ self.send_response(403)
+ self.end_headers()
+ self.wfile.write(b"403 - Forbidden")
+ return
+
+ try:
+ with open(lua_file_path, "rb") as f:
+ content = f.read()
+
+ self.send_response(200)
+ self.send_header("Content-type", "text/x-lua")
+ self.send_header("Content-Disposition", f'attachment; filename="{lua_filename}"')
+ self.end_headers()
+ self.wfile.write(content)
+ logger.log_info(f"Served Lua file: {lua_filename}")
+ return
+
+ except Exception as err:
+ logger.log_error(f"Error serving Lua file {lua_filename}: {err}")
+ self.send_response(500)
+ self.end_headers()
+ self.wfile.write(b"500 - Internal Server Error")
+ return
+
# Handle other files (existing functionality)
file_path = os.path.normpath(os.path.join(PROJECT_ROOT, req_path))
if not file_path.startswith(PROJECT_ROOT):
@@ -179,6 +239,7 @@ class MyHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(b"404 - Not Found")
+
def run_pypost():
"""Run PyPost.py in a separate process."""
script = os.path.join(PROJECT_ROOT, "PyPost.py")