# NSimpleWebApp
A lightweight Go web framework with Jinja2 templates, Lua back-end scripts, and SQLite3 database support.
## Quick Start
```bash
go build -o nsimplewebapp .
./nsimplewebapp
# Server starts at http://localhost:8080
```
All web content lives in the `web/` directory. Data files live in `data/`.
## Project Structure
```
web/
├── index.jinja2 # Templates (.jinja2 extension)
├── _base.jinja2 # Internal template (underscore = not web-accessible)
├── app.css # Static files served directly
├── api/
│ └── hello.lua # Lua scripts for back-end logic
└── contact/
├── _contact.lua # Internal Lua module (require-only)
├── submit.lua # POST handler
└── messages.lua # GET/DELETE API
data/
└── contact.db # Database files (sandboxed, Lua-only access)
```
**URL Resolution** (in order):
1. `/` → `web/index.jinja2`
2. `.jinja2` extension → render as template
3. `.lua` extension → execute as script
4. Exact file match → serve static
5. Append `.jinja2` → render as template if it exists
6. Append `.lua` → execute as script if it exists
**Hidden files/directories** (underscore prefix): Any path component starting with `_` is inaccessible via URL and returns 404. These are for internal-only modules and base templates that are meant to be reused via `extends` or `require()`.
---
## Jinja2 Templates
Templates use [Gonja](https://github.com/nikolalohinski/gonja), a Jinja2-compatible Go engine.
### Template Context
Each template receives these variables:
| Variable | Description |
|---|---|
| `request.method` | HTTP method (`GET`, `POST`, etc.) |
| `request.path` | Request path |
| `request.query` | Query parameters as a dict |
| `request.form` | POST form fields as a dict |
| `request.headers` | Request headers (lowercase keys) |
| `base_url` | The scheme + host (e.g. `http://localhost:8080`) |
### Template Inheritance
Base templates are stored with an underscore prefix (e.g. `_base.jinja2`) to keep them from being served directly. Child templates extend them by path (without the `web/` prefix):
```jinja2
{# _base.jinja2 #}
{% block title %}App{% endblock %}
{% block content %}{% endblock %}
{# index.jinja2 #}
{% extends "_base.jinja2" %}
{% block title %}Home{% endblock %}
{% block content %}Hello
{% endblock %}
```
---
## Lua Scripts
Lua scripts handle back-end logic. Each script defines one or more HTTP method handlers (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`). The framework calls the matching handler and passes a `req` table.
### Handler Signature
```lua
function GET(req)
-- Return: status_code, body, optional_headers
return 200, "Hello World"
end
function POST(req)
return 201, {id = 1, name = "created"}, {["Location"] = "/items/1"}
end
```
**Return values**:
1. **status** (number) — HTTP status code. Defaults to 200 if omitted.
2. **body** (string, table, or nil) — String returned as-is. Tables are JSON-encoded automatically.
3. **headers** (table, optional) — Key-value pairs of header names to values.
### Request Object
The `req` parameter provides access to the HTTP request:
| Property/Method | Description |
|---|---|
| `req.path` | Request path string |
| `req.method` | HTTP method string |
| `req.body` | Raw request body string |
| `req.query` | Query parameters table |
| `req.headers` | Request headers table (lowercase keys) |
| `req:json()` | Parse body as JSON → `(table, error)` |
| `req:form()` | Parse body as form data → `(table, error)` |
---
## Available Lua Functions
### `json.encode(value)` / `json.decode(string)`
Serialize/deserialize JSON.
```lua
local data, err = json.decode(req.body)
return 200, json.encode({status = "ok", data = data})
```
Returns `(value, error)`.
### `os.date(format)` / `os.time()`
- `os.date(format)` — Format current time. Default: `"%Y-%m-%d %H:%M:%S"`. Supports: `%Y`, `%y`, `%m`, `%d`, `%H`, `%M`, `%S`, `%p`, `%A`, `%a`, `%B`, `%b`, `%T`, `%c`, `%x`, `%X`.
- `os.time()` — Current Unix timestamp.
```lua
local ts = os.date("%Y-%m-%d") -- "2025-01-15"
local now = os.time() -- 1705334400
```
### `fs.read(path)` / `fs.write(path, content)` / `fs.list(path)` / `fs.exists(path)`
File operations restricted to the `data/` directory. All paths are relative to `data/`.
```lua
-- Read a file
local content, err = fs.read("config.txt")
-- Write a file
local ok, err = fs.write("output.txt", "Hello")
-- List directory contents
local files, err = fs.list("") -- empty string = data/ root
-- Check existence
if fs.exists("cache.json") then
local data = fs.read("cache.json")
end
```
**Sandbox**: All file access is confined to `data/`. Absolute paths and `..` traversal are blocked.
### `http.get(url)` / `http.post(url, body, content_type?)`
Make HTTP requests to external services.
```lua
-- GET request
local status, body = http.get("https://api.example.com/data")
-- POST request
local status, body = http.post(
"https://api.example.com/submit",
json.encode({key = "value"}),
"application/json" -- optional, defaults to application/json
)
```
Returns `(status_code, body_string)` or `(nil, error_string)`.
### `db.open(filename)` / `db.openMemory()`
Open a SQLite3 database. File-based databases are stored in `data/`.
```lua
-- File-based database (saved in data/myapp.db)
local mydb, err = db.open("myapp.db")
-- In-memory database (ephemeral)
local mydb, err = db.openMemory()
```
Returns `(database_handle, error)`.
#### Database Handle Methods
| Method | Description | Returns |
|---|---|---|
| `mydb:exec(sql, ...)` | Execute SQL without results (INSERT, UPDATE, CREATE, etc.) | `(true, nil)` or `(nil, error)` |
| `mydb:query(sql, ...)` | Query and return all matching rows | `({rows}, nil)` or `(nil, error)` |
| `mydb:queryRow(sql, ...)` | Query and return the first row only | `({row}, nil)` or `(nil, error)` |
| `mydb:close()` | Close the database connection | `(true, nil)` or `(nil, error)` |
**Query results** are arrays of tables, where each table has column name keys:
```lua
local rows, err = mydb:query("SELECT id, name, email FROM users WHERE active = ?", 1)
-- rows = {{id=1, name="Alice", email="a@x.com"}, {id=2, name="Bob", email="b@x.com"}}
local row, err = mydb:queryRow("SELECT COUNT(*) as total FROM users")
-- row = {total = 42}
local ok, err = mydb:exec("INSERT INTO users (name, email) VALUES (?, ?)", name, email)
```
---
## Modules (`require()`)
Share Lua code between scripts using `require()`. Modules are resolved relative to `web/` using dot notation:
```
require("contact._contact") → web/contact/_contact.lua
require("utils.helpers") → web/utils/helpers.lua
```
**Module convention**: Modules must end with `_M = table` to export their API (standard `return` does not work in this sandbox):
```lua
-- web/contact/_contact.lua (internal module, not web-accessible)
local M = {}
M.dbName = "contact.db"
function M.openDatabase()
local mydb, err = db.open(M.dbName)
return mydb, err
end
function M.ensureTable(mydb)
mydb:exec[[
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
)
]]
end
_M = M -- Export the module (not "return M")
```
Modules are cached after first load — `require()` returns the cached value on subsequent calls.
---
## Full Example: Contact Form
**Template** (`contact.jinja2`):
```jinja2
{% extends "_base.jinja2" %}
{% block content %}
Contact
{% if request.query.message is defined %}
Message sent!
{% endif %}
{% endblock %}
```
**Submit handler** (`contact/submit.lua`):
```lua
local contact = require("contact._contact")
function POST(req)
local form, err = req:form()
if err then return 400, {error = err} end
local mydb, err = contact.openDatabase()
if err then return 500, {error = err} end
contact.ensureTable(mydb)
mydb:exec("INSERT INTO messages (name, email, message, created_at) VALUES (?, ?, ?, ?)",
form.name, form.email, form.message, os.date("%Y-%m-%d %H:%M:%S"))
mydb:close()
return 303, {redirect = "/contact?message=sent"}, {["Location"] = "/contact?message=sent"}
end
```
**Messages API** (`contact/messages.lua`):
```lua
local contact = require("contact._contact")
function GET(req)
local mydb, err = contact.openDatabase()
if err then return 500, {error = err} end
contact.ensureTable(mydb)
local messages = mydb:query("SELECT * FROM messages ORDER BY id DESC LIMIT 50")
mydb:close()
return 200, {messages = messages} -- Tables auto-JSON-encoded
end
```