Compare commits
4
Commits
fbdf3889b5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962f20e56d | ||
|
|
3397609c12 | ||
|
|
45129fd3d1 | ||
|
|
9b2e1692d4 |
No files matched your search
@@ -12,3 +12,7 @@ CTestTestfile.cmake
|
|||||||
_deps
|
_deps
|
||||||
CMakeUserPresets.json
|
CMakeUserPresets.json
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
# Auth user store (holds password hashes) and its temporary file
|
||||||
|
users.json
|
||||||
|
users.json.tmp
|
||||||
@@ -33,6 +33,7 @@ file uploads (`multipart/form-data`).
|
|||||||
- [`server.maxBody(bytes)`](#servermaxbodybytes)
|
- [`server.maxBody(bytes)`](#servermaxbodybytes)
|
||||||
- [File uploads](#file-uploads)
|
- [File uploads](#file-uploads)
|
||||||
- [`http.save` / `http.saveTo`](#httpsave--httpsaveto)
|
- [`http.save` / `http.saveTo`](#httpsave--httpsaveto)
|
||||||
|
- [Authentication (`auth.*`)](#authentication-auth)
|
||||||
- [`view.Render(path, vars)`](#viewrenderpath-vars)
|
- [`view.Render(path, vars)`](#viewrenderpath-vars)
|
||||||
- [Status codes](#status-codes)
|
- [Status codes](#status-codes)
|
||||||
- [Request and response flow](#request-and-response-flow)
|
- [Request and response flow](#request-and-response-flow)
|
||||||
@@ -51,6 +52,7 @@ file uploads (`multipart/form-data`).
|
|||||||
| Request parsing | Query string, url-encoded bodies, JSON bodies and `multipart/form-data` |
|
| Request parsing | Query string, url-encoded bodies, JSON bodies and `multipart/form-data` |
|
||||||
| File uploads | Uploaded files as tables (`filename`, `contentType`, `size`, raw `content`) plus `http.save` / `http.saveTo` helpers to write them to disk |
|
| File uploads | Uploaded files as tables (`filename`, `contentType`, `size`, raw `content`) plus `http.save` / `http.saveTo` helpers to write them to disk |
|
||||||
| Cookies | Parsed `Cookie:` header as a table |
|
| Cookies | Parsed `Cookie:` header as a table |
|
||||||
|
| Authentication | HTTP Basic auth for the paths you choose, with PBKDF2-HMAC-SHA256 hashed passwords in `users.json` |
|
||||||
| Error pages | Custom handlers or bodies per status code, with a built-in HTML fallback |
|
| Error pages | Custom handlers or bodies per status code, with a built-in HTML fallback |
|
||||||
| Templates | `view.Render("page.html", { ... })` with recursive `@include` and `{{ key }}` interpolation |
|
| Templates | `view.Render("page.html", { ... })` with recursive `@include` and `{{ key }}` interpolation |
|
||||||
| Body limit | Configurable request size limit (`server.maxBody`), oversized uploads get `413` |
|
| Body limit | Configurable request size limit (`server.maxBody`), oversized uploads get `413` |
|
||||||
@@ -118,7 +120,7 @@ Load it from a script with `require` (the returned string is a status message su
|
|||||||
require("dlang.http");
|
require("dlang.http");
|
||||||
```
|
```
|
||||||
|
|
||||||
Once loaded, the plugin registers two globals: `http` and `view`.
|
Once loaded, the plugin registers three globals: `http`, `view` and `auth`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,6 +131,10 @@ Once loaded, the plugin registers two globals: `http` and `view`.
|
|||||||
```dlang
|
```dlang
|
||||||
require("dlang.http");
|
require("dlang.http");
|
||||||
|
|
||||||
|
// Basic auth: hash the password into ./users.json and gate /admin/**
|
||||||
|
auth.addUser("admin", "geheim");
|
||||||
|
auth.protectPrefix("/admin");
|
||||||
|
|
||||||
var server = http.server();
|
var server = http.server();
|
||||||
|
|
||||||
// Serve everything in ./public under /assets (e.g. /assets/css/site.css)
|
// Serve everything in ./public under /assets (e.g. /assets/css/site.css)
|
||||||
@@ -217,7 +223,7 @@ func(req, params, cookies, files)
|
|||||||
|
|
||||||
| Argument | Contents |
|
| Argument | Contents |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `req` | Table with `method`, `path`, `query` (only when the URL had one), `params`, `cookies`, `files` and `body` (only when the request body was not empty) |
|
| `req` | Table with `method`, `path`, `query` (only when the URL had one), `headers`, `params`, `cookies`, `files`, `body` (only when the request body was not empty) and `user` (only when the request passed an [`auth`](#authentication-auth) gate) |
|
||||||
| `params` | All request parameters merged into one table: query string + body |
|
| `params` | All request parameters merged into one table: query string + body |
|
||||||
| `cookies` | The parsed `Cookie:` header |
|
| `cookies` | The parsed `Cookie:` header |
|
||||||
| `files` | Uploaded files from a `multipart/form-data` body, keyed by form field name |
|
| `files` | Uploaded files from a `multipart/form-data` body, keyed by form field name |
|
||||||
@@ -233,6 +239,10 @@ func(req, params, cookies, files)
|
|||||||
|
|
||||||
A key that occurs more than once (for example `?tag=a&tag=b`) becomes a list.
|
A key that occurs more than once (for example `?tag=a&tag=b`) becomes a list.
|
||||||
|
|
||||||
|
`req.headers` holds every request header with its name lower-cased, so
|
||||||
|
`req.headers.authorization` or `req.headers.content-type` can be read directly; a header
|
||||||
|
that occurs more than once becomes a list too.
|
||||||
|
|
||||||
The four-argument signature is backward compatible: older three-parameter handlers keep
|
The four-argument signature is backward compatible: older three-parameter handlers keep
|
||||||
working, they simply never see `files`.
|
working, they simply never see `files`.
|
||||||
|
|
||||||
@@ -352,6 +362,95 @@ the same name.
|
|||||||
`../../etc/passwd`) is stripped and unsafe characters are replaced, so a client can never
|
`../../etc/passwd`) is stripped and unsafe characters are replaced, so a client can never
|
||||||
choose the target directory.
|
choose the target directory.
|
||||||
|
|
||||||
|
### Authentication (`auth.*`)
|
||||||
|
|
||||||
|
Optional HTTP Basic auth. You choose which paths are protected; everything else stays open.
|
||||||
|
|
||||||
|
Passwords are **hashed, not encrypted**: the plugin generates a random salt per account and
|
||||||
|
stores a PBKDF2-HMAC-SHA256 hash of the password, so the file never holds anything that can
|
||||||
|
be turned back into the original password.
|
||||||
|
|
||||||
|
```dlang
|
||||||
|
require("dlang.http");
|
||||||
|
|
||||||
|
auth.addUser("admin", "geheim"); // hashes the password and writes ./users.json
|
||||||
|
auth.realm("My site"); // the text shown in the browser's login popup
|
||||||
|
auth.protectPrefix("/admin"); // /admin and everything below need credentials
|
||||||
|
|
||||||
|
var server = http.server();
|
||||||
|
|
||||||
|
server.route("/admin", ["GET"], func(req, params, cookies, files)
|
||||||
|
return "<h1>Welcome back, " + req.user + "</h1>";
|
||||||
|
end);
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
The first request for a protected path is answered with `401 Unauthorized` plus a
|
||||||
|
`WWW-Authenticate: Basic realm="..."` header. A browser reacts to that header by showing its
|
||||||
|
own username/password dialog; once the visitor fills it in, the request is repeated with an
|
||||||
|
`Authorization: Basic ...` header and the page is served. Inside the handler `req.user` holds
|
||||||
|
the authenticated name.
|
||||||
|
|
||||||
|
The user file lives in the working directory (`users.json` by default) — add it to your
|
||||||
|
`.gitignore`, since it holds password hashes.
|
||||||
|
|
||||||
|
#### The user store
|
||||||
|
|
||||||
|
| Call | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `auth.file(path)` | Use another user file; returns the path in use. Default: `users.json`. |
|
||||||
|
| `auth.reload()` | Re-read the file from disk (after editing it by hand). `true` on success. |
|
||||||
|
| `auth.addUser(name, password)` | Create the account or replace its password. Writes the file and returns `true`, or `false` when the name/password is rejected or the file cannot be written. |
|
||||||
|
| `auth.removeUser(name)` | Delete the account. `true` when it existed and the file was written. |
|
||||||
|
| `auth.hasUser(name)` | `true` when the account exists. |
|
||||||
|
| `auth.users()` | List of user names. |
|
||||||
|
| `auth.verify(name, password)` | `true` when the password matches — to check credentials inside your own handler. |
|
||||||
|
| `auth.login(req)` | The name from the request's `Authorization` header, or `""`. |
|
||||||
|
| `auth.iterations(count)` | PBKDF2 rounds for new or updated passwords (default `100000`, clamped to `1000`–`1000000`); returns the value in use. |
|
||||||
|
| `auth.realm(name)` | The realm in the login popup (default `Restricted`); returns the value in use. |
|
||||||
|
|
||||||
|
A name may not be empty or contain `:`, whitespace or control characters (it is sent as
|
||||||
|
`name:password`), and the password may not be empty. `addUser` never overwrites a
|
||||||
|
`users.json` it cannot parse — it reports the problem on `stderr` instead, so a hand-edited
|
||||||
|
file with a typo cannot silently wipe your accounts.
|
||||||
|
|
||||||
|
The file looks like this (`salt` and `hash` are hex; `iterations` is stored per account so
|
||||||
|
the cost can be raised later without invalidating existing passwords):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"algorithm": "pbkdf2-sha256",
|
||||||
|
"users": [
|
||||||
|
{ "username": "admin", "salt": "ca63c036f511d4006c93ad68353677d7",
|
||||||
|
"hash": "d4b7284ab9668ecbb548f8fd8048b44200368b258cd2b87d00807737c9aec472",
|
||||||
|
"iterations": 100000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accounts are read lazily, so `auth.addUser(...)` can simply run just before
|
||||||
|
`server.listen(...)`. A password that verified once is remembered in memory (as a hash of the
|
||||||
|
password, never the password itself), so the deliberately slow PBKDF2 work is paid for the
|
||||||
|
first request of a session instead of every request. Editing the file by hand? Call
|
||||||
|
`auth.reload()`.
|
||||||
|
|
||||||
|
#### Protecting paths
|
||||||
|
|
||||||
|
| Call | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `auth.protect(path)` | Require credentials for exactly this path. |
|
||||||
|
| `auth.protectPrefix(path)` | Require credentials for this path and everything below it. |
|
||||||
|
| `auth.unprotect(path)` | Remove an exact-path protection again. |
|
||||||
|
| `auth.unprotectPrefix(path)` | Remove a prefix protection again. |
|
||||||
|
|
||||||
|
Paths are normalised (`admin/panel/` becomes `/admin/panel`) and `auth.protectPrefix("/")`
|
||||||
|
gates the whole site. The check happens before routing, static files and error pages, so a
|
||||||
|
protected path never leaks whether it exists: `/secret/nope` answers `401` without
|
||||||
|
credentials and `404` with them. The `401` body comes from
|
||||||
|
[`server.error(401, ...)`](#servererrorcode-handler) like any other status code; only the
|
||||||
|
`WWW-Authenticate` header is added by the plugin.
|
||||||
|
|
||||||
### `view.Render(path, vars)`
|
### `view.Render(path, vars)`
|
||||||
|
|
||||||
Renders a small template file and returns the result as a string. `view.render` is an alias.
|
Renders a small template file and returns the result as a string. `view.render` is an alias.
|
||||||
@@ -403,7 +502,9 @@ flowchart TD
|
|||||||
A[Accept connection] --> B[Read headers + Content-Length body]
|
A[Accept connection] --> B[Read headers + Content-Length body]
|
||||||
B --> C{Body within limit?}
|
B --> C{Body within limit?}
|
||||||
C -- no --> D[413]
|
C -- no --> D[413]
|
||||||
C -- yes --> E[Parse query, cookies and body<br/>urlencoded / JSON / multipart]
|
C -- yes --> N{Protected path?}
|
||||||
|
N -- "wrong or missing credentials" --> O[401 + WWW-Authenticate]
|
||||||
|
N -- "not protected, or valid credentials" --> E[Parse query, headers, cookies and body<br/>urlencoded / JSON / multipart]
|
||||||
E --> F{Route matches path?}
|
E --> F{Route matches path?}
|
||||||
F -- yes, method allowed --> G[Call handler with req, params, cookies, files]
|
F -- yes, method allowed --> G[Call handler with req, params, cookies, files]
|
||||||
F -- yes, other method --> H[405]
|
F -- yes, other method --> H[405]
|
||||||
@@ -427,8 +528,14 @@ flowchart TD
|
|||||||
- **In-memory bodies.** The whole body (up to `server.maxBody`) is buffered before a handler
|
- **In-memory bodies.** The whole body (up to `server.maxBody`) is buffered before a handler
|
||||||
runs, and static files are read fully into memory before being sent.
|
runs, and static files are read fully into memory before being sent.
|
||||||
- **HTTP/1.1 only**, no TLS. Put a reverse proxy in front if you need HTTPS.
|
- **HTTP/1.1 only**, no TLS. Put a reverse proxy in front if you need HTTPS.
|
||||||
- **Global server state.** Routes, static mounts, error pages and the socket are
|
- **Basic auth is only as safe as the transport.** The browser resends the password with
|
||||||
process-wide, so one `listen` call serves one server per process.
|
every request (base64 encoded, not encrypted), so use it behind HTTPS — or keep it on a
|
||||||
|
trusted network.
|
||||||
|
- **A wrong password costs a full PBKDF2 run.** `auth.iterations` defaults to 100 000, and
|
||||||
|
the server handles one request at a time, so an attacker can keep it busy with bad
|
||||||
|
credentials. Lower the iteration count or add rate limiting if that matters to you.
|
||||||
|
- **Global server state.** Routes, static mounts, error pages, the protected paths and the
|
||||||
|
loaded accounts are process-wide, so one `listen` call serves one server per process.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -441,7 +548,7 @@ dlang-http/
|
|||||||
└─ dlang.http/
|
└─ dlang.http/
|
||||||
├─ CMakeLists.txt # builds the `dlang.http` shared library
|
├─ CMakeLists.txt # builds the `dlang.http` shared library
|
||||||
├─ entry.cpp # the entire plugin: server, routing, static files,
|
├─ entry.cpp # the entire plugin: server, routing, static files,
|
||||||
│ # uploads, error pages and the view engine
|
│ # uploads, error pages, Basic auth and the view engine
|
||||||
├─ json.hpp # nlohmann/json (used by the VM's table library)
|
├─ json.hpp # nlohmann/json (used by the VM's table library)
|
||||||
└─ dlang/ # upstream dlang VM copy — do not edit
|
└─ dlang/ # upstream dlang VM copy — do not edit
|
||||||
├─ vm.hpp, types/, libs/, parser/
|
├─ vm.hpp, types/, libs/, parser/
|
||||||
@@ -460,8 +567,9 @@ This repository and this README were produced with the help of an AI assistant
|
|||||||
(GitHub Copilot). That means:
|
(GitHub Copilot). That means:
|
||||||
|
|
||||||
- The code was **generated, then verified against its own test harnesses** (request parsing,
|
- The code was **generated, then verified against its own test harnesses** (request parsing,
|
||||||
multipart uploads, socket-level request handling, `http.save`/`saveTo`), but it has not
|
multipart uploads, socket-level request handling, `http.save`/`saveTo`, and the auth
|
||||||
been reviewed by a human maintainer.
|
module including its SHA-256/HMAC/PBKDF2 test vectors), but it has not been reviewed by a
|
||||||
|
human maintainer.
|
||||||
- Comments and documentation may describe behaviour that has not been audited in every
|
- Comments and documentation may describe behaviour that has not been audited in every
|
||||||
edge case, and the API is still evolving.
|
edge case, and the API is still evolving.
|
||||||
- Treat it as a starting point: review the source in `dlang.http/entry.cpp`, test it with
|
- Treat it as a starting point: review the source in `dlang.http/entry.cpp`, test it with
|
||||||
|
|||||||
@@ -200,13 +200,25 @@ void DlangVM::Execute(ByteCodeArray bytecode)
|
|||||||
auto b = stack.pop();
|
auto b = stack.pop();
|
||||||
auto a = stack.pop();
|
auto a = stack.pop();
|
||||||
|
|
||||||
if (a.type != StackValueType::Int || b.type != StackValueType::Int)
|
if (a.type == StackValueType::Int && b.type == StackValueType::Int)
|
||||||
throw std::runtime_error("LESS_THAN Operation error, not all variables are integer!");
|
{
|
||||||
|
|
||||||
stack.push(StackValue(StackValueType::Bool,
|
stack.push(StackValue(StackValueType::Bool,
|
||||||
std::get<double>(a.data) ==
|
std::get<double>(a.data) ==
|
||||||
std::get<double>(b.data)
|
std::get<double>(b.data)
|
||||||
));
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.type == StackValueType::String && b.type == StackValueType::String)
|
||||||
|
{
|
||||||
|
stack.push(StackValue(StackValueType::Bool,
|
||||||
|
std::get<std::string>(a.data) ==
|
||||||
|
std::get<std::string>(b.data)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw std::runtime_error("COMPARE Operation error, in compatible variables!");
|
||||||
} break;
|
} break;
|
||||||
|
|
||||||
case Opcodes::GTE: {
|
case Opcodes::GTE: {
|
||||||
@@ -284,6 +296,8 @@ void DlangVM::Execute(ByteCodeArray bytecode)
|
|||||||
|
|
||||||
if (a.type == StackValueType::Int && b.type == StackValueType::Int) {
|
if (a.type == StackValueType::Int && b.type == StackValueType::Int) {
|
||||||
result.data = (std::get<double>(a.data) != std::get<double>(b.data));
|
result.data = (std::get<double>(a.data) != std::get<double>(b.data));
|
||||||
|
} else if(a.type == StackValueType::String && b.type == StackValueType::String) {
|
||||||
|
result.data = (std::get<std::string>(a.data) != std::get<std::string>(b.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.push(result);
|
stack.push(result);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
inline ModuleManager* pluginManager = nullptr;
|
inline ModuleManager* pluginManager = nullptr;
|
||||||
|
|
||||||
#define DEBUG_MODE 0
|
#define DEBUG_MODE 0
|
||||||
#define VERSION "v1.0.37"
|
#define VERSION "v1.0.39"
|
||||||
|
|
||||||
using ByteCodeArray = std::vector<double>;
|
using ByteCodeArray = std::vector<double>;
|
||||||
|
|
||||||
|
|||||||
+1176
-3
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user