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}[^>]*>.*?" + 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}[^>]*>).*?()" + 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}[^>]*>)(.*?)()" + def replacer(match): + open_tag, content, close_tag = match.groups() + return f"{open_tag}<{wrapper_tag} {attrs}>{content}{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, "", "") +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, "", "") +``` + +**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, "", 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")