2976 lines
99 KiB
C++
2976 lines
99 KiB
C++
// rand_s() — a cryptographically strong RNG on Windows — must be requested
|
|
// before <stdlib.h> (pulled in by <cstdlib> below) is first included, so this
|
|
// macro has to live at the very top of the file. On Linux the auth code reads
|
|
// /dev/urandom instead.
|
|
#if defined(_MSC_VER) && !defined(_CRT_RAND_S)
|
|
#define _CRT_RAND_S
|
|
#endif
|
|
|
|
#if defined(_WIN32) || defined(_WIN64)
|
|
#define DLANG_HTTP_WINDOWS
|
|
#elif defined(__linux__)
|
|
#define DLANG_HTTP_LINUX
|
|
#else
|
|
#error "dlang.http only supports Windows and Linux."
|
|
#endif
|
|
|
|
#ifdef DLANG_HTTP_WINDOWS
|
|
#define NOMINMAX
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
|
|
// NOTE: We intentionally do NOT include <dlang.hpp> here. That umbrella header
|
|
// pulls in every standard library module (libs/*.hpp) and registers all of the
|
|
// default globals; the http plugin only needs the VM core plus the helper
|
|
// macros, which we get from <vm.hpp> and <helpers.hpp>.
|
|
#include <vm.hpp>
|
|
#include <helpers.hpp>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <vector>
|
|
#include <sstream>
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <cctype>
|
|
#include <cstdlib>
|
|
#include <cstdio>
|
|
#include <chrono>
|
|
#include <random>
|
|
#include <thread>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <unordered_map>
|
|
|
|
#ifdef DLANG_HTTP_WINDOWS
|
|
#include <winsock2.h>
|
|
#include <ws2tcpip.h>
|
|
|
|
#if defined(_MSC_VER)
|
|
#pragma comment(lib, "Ws2_32.lib")
|
|
#endif
|
|
|
|
using SocketHandle = SOCKET;
|
|
using SockAddr = SOCKADDR;
|
|
#define DLANG_INVALID_SOCKET INVALID_SOCKET
|
|
#define DLANG_SOCKET_ERROR SOCKET_ERROR
|
|
#define DLANG_CLOSE_SOCKET closesocket
|
|
#define DLANG_SHUT_WRITE SD_SEND
|
|
#else
|
|
#include <sys/socket.h>
|
|
#include <netinet/in.h>
|
|
#include <netinet/tcp.h>
|
|
#include <arpa/inet.h>
|
|
#include <unistd.h>
|
|
#include <fcntl.h>
|
|
#include <cerrno>
|
|
|
|
using SocketHandle = int;
|
|
using SockAddr = struct sockaddr;
|
|
#define DLANG_INVALID_SOCKET (-1)
|
|
#define DLANG_SOCKET_ERROR (-1)
|
|
#define DLANG_CLOSE_SOCKET close
|
|
#define DLANG_SHUT_WRITE SHUT_WR
|
|
#endif
|
|
|
|
#if defined(_WIN32) || defined(_WIN64)
|
|
#define DLANG_API __declspec(dllexport)
|
|
#else
|
|
#define DLANG_API __attribute__((visibility("default")))
|
|
#endif
|
|
|
|
struct RouteHandler {
|
|
std::string path;
|
|
std::vector<std::string> methods;
|
|
StackValue functionVal;
|
|
};
|
|
|
|
static SocketHandle g_serverSocket = DLANG_INVALID_SOCKET;
|
|
static std::vector<RouteHandler> g_routes;
|
|
|
|
// A static asset mount. When `isDirectory` is true the mount maps a URL
|
|
// prefix (e.g. "/static") onto a folder on disk; otherwise it maps an exact
|
|
// URL (e.g. "/style.css") onto a single file.
|
|
struct StaticMount {
|
|
std::string url;
|
|
std::string filePath;
|
|
bool isDirectory;
|
|
};
|
|
|
|
// A user supplied error page override. Either a fixed body/contentType or a
|
|
// function that receives the request and returns the page.
|
|
struct ErrorPage {
|
|
bool isFunction = false;
|
|
StackValue functionVal;
|
|
std::string body;
|
|
std::string contentType = "text/html";
|
|
};
|
|
|
|
struct ErrorResponse {
|
|
std::string body;
|
|
std::string contentType;
|
|
};
|
|
|
|
static std::vector<StaticMount> g_staticMounts;
|
|
static std::unordered_map<int, ErrorPage> g_errorPages;
|
|
|
|
// Largest request body we are willing to buffer. Raised/lowered with
|
|
// `server.maxBody(bytes)`; anything bigger is answered with 413.
|
|
static size_t g_maxBodyBytes = 16u * 1024u * 1024u;
|
|
|
|
// Toggle blocking mode on a socket in a cross-platform way.
|
|
static void SetBlocking(SocketHandle sock, bool blocking) {
|
|
#ifdef DLANG_HTTP_WINDOWS
|
|
u_long mode = blocking ? 0 : 1;
|
|
ioctlsocket(sock, FIONBIO, &mode);
|
|
#else
|
|
int flags = fcntl(sock, F_GETFL, 0);
|
|
if (flags == -1) return;
|
|
if (blocking) flags &= ~O_NONBLOCK;
|
|
else flags |= O_NONBLOCK;
|
|
fcntl(sock, F_SETFL, flags);
|
|
#endif
|
|
}
|
|
|
|
// Cross-platform sleep used while waiting for new connections.
|
|
static void SleepMs(int ms) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
|
}
|
|
|
|
// Send the whole buffer, handling partial writes.
|
|
static void SendAll(SocketHandle sock, const std::string& data) {
|
|
size_t total = 0;
|
|
while (total < data.size()) {
|
|
auto sent = send(sock, data.data() + total,
|
|
static_cast<int>(data.size() - total), 0);
|
|
if (sent <= 0) break;
|
|
total += static_cast<size_t>(sent);
|
|
}
|
|
}
|
|
|
|
static const char* ReasonPhrase(int statusCode) {
|
|
switch (statusCode) {
|
|
case 200: return "OK";
|
|
case 201: return "Created";
|
|
case 204: return "No Content";
|
|
case 301: return "Moved Permanently";
|
|
case 302: return "Found";
|
|
case 400: return "Bad Request";
|
|
case 401: return "Unauthorized";
|
|
case 403: return "Forbidden";
|
|
case 404: return "Not Found";
|
|
case 405: return "Method Not Allowed";
|
|
case 413: return "Payload Too Large";
|
|
case 500: return "Internal Server Error";
|
|
case 501: return "Not Implemented";
|
|
default: return "OK";
|
|
}
|
|
}
|
|
|
|
// Read a field out of a table value without touching the VM value stack.
|
|
//
|
|
// The rewritten VM's `DlangVM::GetField(key)` now POPS the table off the stack
|
|
// and RETURNS the field value (it no longer pushes the result for you), so
|
|
// reading several fields in a row through the VM would consume the table after
|
|
// the first lookup. Reading straight from the DlangTable is simpler and keeps
|
|
// the stack perfectly balanced.
|
|
static bool GetTableField(const StackValue& tableValue, const char* key, StackValue& out) {
|
|
if (tableValue.type != StackValueType::Table)
|
|
return false;
|
|
|
|
auto table = std::get<std::shared_ptr<DlangTable>>(tableValue.data);
|
|
return table->Get(key, out);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static files & error pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static std::string ToLower(std::string value) {
|
|
std::transform(value.begin(), value.end(), value.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
return value;
|
|
}
|
|
|
|
// Maps a file extension to a MIME type so CSS/JS/images are served correctly.
|
|
static std::string ContentTypeForPath(const std::string& path) {
|
|
size_t dot = path.find_last_of('.');
|
|
if (dot == std::string::npos)
|
|
return "application/octet-stream";
|
|
|
|
std::string ext = ToLower(path.substr(dot));
|
|
|
|
static const std::unordered_map<std::string, std::string> types = {
|
|
{ ".html", "text/html" },
|
|
{ ".htm", "text/html" },
|
|
{ ".css", "text/css" },
|
|
{ ".js", "application/javascript" },
|
|
{ ".mjs", "application/javascript" },
|
|
{ ".json", "application/json" },
|
|
{ ".map", "application/json" },
|
|
{ ".txt", "text/plain" },
|
|
{ ".xml", "application/xml" },
|
|
{ ".svg", "image/svg+xml" },
|
|
{ ".png", "image/png" },
|
|
{ ".jpg", "image/jpeg" },
|
|
{ ".jpeg", "image/jpeg" },
|
|
{ ".gif", "image/gif" },
|
|
{ ".webp", "image/webp" },
|
|
{ ".ico", "image/x-icon" },
|
|
{ ".bmp", "image/bmp" },
|
|
{ ".woff", "font/woff" },
|
|
{ ".woff2","font/woff2" },
|
|
{ ".ttf", "font/ttf" },
|
|
{ ".otf", "font/otf" },
|
|
{ ".eot", "application/vnd.ms-fontobject" },
|
|
{ ".mp3", "audio/mpeg" },
|
|
{ ".wav", "audio/wav" },
|
|
{ ".mp4", "video/mp4" },
|
|
{ ".webm", "video/webm" },
|
|
{ ".pdf", "application/pdf" },
|
|
{ ".wasm", "application/wasm" },
|
|
};
|
|
|
|
auto it = types.find(ext);
|
|
return it != types.end() ? it->second : "application/octet-stream";
|
|
}
|
|
|
|
static std::string UrlDecode(const std::string& input) {
|
|
std::string output;
|
|
output.reserve(input.size());
|
|
|
|
for (size_t i = 0; i < input.size(); ++i) {
|
|
if (input[i] == '%' && i + 2 < input.size()) {
|
|
std::string hex = input.substr(i + 1, 2);
|
|
output += static_cast<char>(std::strtol(hex.c_str(), nullptr, 16));
|
|
i += 2;
|
|
}
|
|
else if (input[i] == '+') {
|
|
output += ' ';
|
|
}
|
|
else {
|
|
output += input[i];
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
// Reject anything that could escape the mounted folder.
|
|
static bool IsSafePath(const std::string& path) {
|
|
return path.find("..") == std::string::npos &&
|
|
path.find('\\') == std::string::npos;
|
|
}
|
|
|
|
static bool ReadFileBinary(const std::string& path, std::string& out) {
|
|
std::ifstream file(path, std::ios::binary);
|
|
if (!file.is_open())
|
|
return false;
|
|
|
|
std::ostringstream buffer;
|
|
buffer << file.rdbuf();
|
|
out = buffer.str();
|
|
return true;
|
|
}
|
|
|
|
// `extraHeaders` holds additional response headers, each already terminated
|
|
// with CRLF (used for `WWW-Authenticate` on a 401 and `Set-Cookie`).
|
|
static void SendResponse(SocketHandle sock, int statusCode,
|
|
const std::string& contentType, const std::string& body,
|
|
const std::string& extraHeaders = std::string()) {
|
|
std::ostringstream resp;
|
|
resp << "HTTP/1.1 " << statusCode << " " << ReasonPhrase(statusCode) << "\r\n"
|
|
<< extraHeaders
|
|
<< "Content-Type: " << contentType << "\r\n"
|
|
<< "Content-Length: " << body.length() << "\r\n"
|
|
<< "Connection: close\r\n\r\n"
|
|
<< body;
|
|
|
|
SendAll(sock, resp.str());
|
|
}
|
|
|
|
// Restore the value stack after a (possibly failed) call, so that a throwing
|
|
// handler cannot corrupt the following request.
|
|
static void ResetStack(DlangVM* vm, int base) {
|
|
while (vm->stack.top() > base)
|
|
vm->stack.pop();
|
|
}
|
|
|
|
// Built-in error page used whenever the user did not override the status code.
|
|
static std::string DefaultErrorBody(int statusCode) {
|
|
std::string title = ReasonPhrase(statusCode);
|
|
|
|
std::ostringstream html;
|
|
html << "<!DOCTYPE html>\n"
|
|
<< "<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
|
|
<< "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
|
<< "<title>" << statusCode << " " << title << "</title>\n"
|
|
<< "<style>"
|
|
<< "body{margin:0;height:100vh;display:flex;align-items:center;"
|
|
<< "justify-content:center;background:#111;color:#eee;"
|
|
<< "font-family:system-ui,-apple-system,Segoe UI,sans-serif}"
|
|
<< ".box{text-align:center}h1{font-size:5rem;margin:0;line-height:1}"
|
|
<< "p{margin:.5rem 0 0;opacity:.6;letter-spacing:.05em;text-transform:uppercase}"
|
|
<< "</style>\n</head>\n<body>\n<div class=\"box\"><h1>" << statusCode
|
|
<< "</h1><p>" << title << "</p></div>\n</body>\n</html>\n";
|
|
|
|
return html.str();
|
|
}
|
|
|
|
// Trim a mount URL and store it alongside its resolved kind (file or folder).
|
|
static void AddStaticMount(const std::string& url, const std::string& filePath) {
|
|
std::string prefix = url.empty() ? "/" : url;
|
|
if (prefix.size() > 1 && prefix.back() == '/')
|
|
prefix.pop_back();
|
|
|
|
std::error_code ec;
|
|
bool isDirectory = std::filesystem::is_directory(filePath, ec) && !ec;
|
|
|
|
g_staticMounts.push_back({ prefix, filePath, isDirectory });
|
|
}
|
|
|
|
// Resolve a request path against the configured static mounts.
|
|
static bool TryResolveStatic(const std::string& urlPath,
|
|
std::string& outBody,
|
|
std::string& outContentType) {
|
|
std::string decoded = UrlDecode(urlPath);
|
|
if (!IsSafePath(decoded))
|
|
return false;
|
|
|
|
// Exact URL -> file mappings win over folder mounts.
|
|
for (const auto& mount : g_staticMounts) {
|
|
if (mount.isDirectory || mount.url != decoded)
|
|
continue;
|
|
|
|
if (ReadFileBinary(mount.filePath, outBody)) {
|
|
outContentType = ContentTypeForPath(mount.filePath);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Folder mounts: pick the longest matching URL prefix.
|
|
const StaticMount* best = nullptr;
|
|
for (const auto& mount : g_staticMounts) {
|
|
if (!mount.isDirectory)
|
|
continue;
|
|
|
|
if (decoded.compare(0, mount.url.size(), mount.url) != 0)
|
|
continue;
|
|
if (decoded.size() != mount.url.size() && decoded[mount.url.size()] != '/')
|
|
continue;
|
|
|
|
if (best == nullptr || mount.url.size() > best->url.size())
|
|
best = &mount;
|
|
}
|
|
|
|
if (best == nullptr)
|
|
return false;
|
|
|
|
std::string rest = decoded.substr(best->url.size());
|
|
if (rest.empty() || rest == "/")
|
|
rest = "/index.html";
|
|
if (!IsSafePath(rest))
|
|
return false;
|
|
|
|
std::filesystem::path full =
|
|
std::filesystem::path(best->filePath) /
|
|
rest.substr(rest.front() == '/' ? 1 : 0);
|
|
|
|
std::error_code ec;
|
|
if (!std::filesystem::is_regular_file(full, ec) || ec)
|
|
return false;
|
|
|
|
if (!ReadFileBinary(full.string(), outBody))
|
|
return false;
|
|
|
|
outContentType = ContentTypeForPath(full.string());
|
|
return true;
|
|
}
|
|
|
|
// Render an error page, using the user override when present and the built-in
|
|
// default otherwise.
|
|
static ErrorResponse RenderError(DlangVM* vm, int statusCode,
|
|
const std::string& method,
|
|
const std::string& path) {
|
|
ErrorResponse result;
|
|
result.contentType = "text/html";
|
|
|
|
auto it = g_errorPages.find(statusCode);
|
|
if (it == g_errorPages.end()) {
|
|
result.body = DefaultErrorBody(statusCode);
|
|
return result;
|
|
}
|
|
|
|
const ErrorPage& page = it->second;
|
|
|
|
if (!page.isFunction) {
|
|
result.body = page.body.empty() ? DefaultErrorBody(statusCode) : page.body;
|
|
result.contentType = page.contentType;
|
|
return result;
|
|
}
|
|
|
|
int base = vm->stack.top();
|
|
bool produced = false;
|
|
|
|
try {
|
|
dlang_newtable(vm);
|
|
dlang_pushstring(vm, method.c_str());
|
|
dlang_setfield(vm, "method");
|
|
dlang_pushstring(vm, path.c_str());
|
|
dlang_setfield(vm, "path");
|
|
dlang_pushnumber(vm, statusCode);
|
|
dlang_setfield(vm, "statusCode");
|
|
|
|
dlang_push(vm, page.functionVal);
|
|
if (!dlang_call(vm, 1))
|
|
throw std::runtime_error("Error handler could not be called");
|
|
|
|
// CallFunc leaves the handler's return value on top of the stack.
|
|
StackValue returned = dlang_pop(vm);
|
|
|
|
if (returned.type == StackValueType::String) {
|
|
result.body = std::get<std::string>(returned.data);
|
|
produced = true;
|
|
}
|
|
else if (returned.type == StackValueType::Table) {
|
|
StackValue body;
|
|
if (GetTableField(returned, "body", body) &&
|
|
body.type == StackValueType::String) {
|
|
result.body = std::get<std::string>(body.data);
|
|
produced = true;
|
|
}
|
|
|
|
StackValue contentType;
|
|
if (GetTableField(returned, "contentType", contentType) &&
|
|
contentType.type == StackValueType::String) {
|
|
result.contentType = std::get<std::string>(contentType.data);
|
|
}
|
|
}
|
|
}
|
|
catch (...) {
|
|
produced = false;
|
|
}
|
|
|
|
ResetStack(vm, base);
|
|
|
|
if (!produced)
|
|
result.body = DefaultErrorBody(statusCode);
|
|
|
|
return result;
|
|
}
|
|
|
|
void NativeRoute(DlangVM* vm) {
|
|
// server.route(path, methods, fn)
|
|
//
|
|
// The rewritten VM's `GET_FIELD` already consumed the `server` table while
|
|
// resolving the `server.route` callee, so this native only sees the three
|
|
// explicit arguments (last argument on top of the stack).
|
|
StackValue funcVal = dlang_pop(vm);
|
|
StackValue methodsVal = dlang_pop(vm);
|
|
StackValue pathVal = dlang_pop(vm);
|
|
|
|
if (pathVal.type != StackValueType::String)
|
|
return;
|
|
|
|
if (funcVal.type != StackValueType::Function)
|
|
return;
|
|
|
|
std::string path = std::get<std::string>(pathVal.data);
|
|
|
|
std::vector<std::string> methods;
|
|
if (methodsVal.type == StackValueType::List) {
|
|
auto methodsList = std::get<std::shared_ptr<DlangList>>(methodsVal.data);
|
|
for (const auto& item : methodsList->values) {
|
|
if (item.type == StackValueType::String)
|
|
methods.push_back(std::get<std::string>(item.data));
|
|
}
|
|
}
|
|
|
|
g_routes.push_back({ path, methods, funcVal });
|
|
}
|
|
|
|
void NativeStatic(DlangVM* vm) {
|
|
// Supported call shapes (the last argument is on top of the stack):
|
|
// server.static("/assets", "./www") -> prefix -> folder/file
|
|
// server.static({ assets: "./www", js: "./js" }) -> map of prefix -> path
|
|
// server.static([["/a.css", "./a.css"], ...]) -> explicit URL -> file
|
|
//
|
|
// dlang table keys must be identifiers, so the map form uses keys like
|
|
// `assets` (a leading slash is added automatically).
|
|
StackValue last = dlang_pop(vm);
|
|
|
|
// List of [url, file] pairs.
|
|
if (std::holds_alternative<std::shared_ptr<DlangList>>(last.data)) {
|
|
auto list = std::get<std::shared_ptr<DlangList>>(last.data);
|
|
for (const auto& entry : list->values) {
|
|
if (!std::holds_alternative<std::shared_ptr<DlangList>>(entry.data))
|
|
continue;
|
|
|
|
auto pair = std::get<std::shared_ptr<DlangList>>(entry.data);
|
|
if (pair->values.size() < 2)
|
|
continue;
|
|
|
|
const StackValue& urlValue = pair->values[0];
|
|
const StackValue& fileValue = pair->values[1];
|
|
if (std::holds_alternative<std::string>(urlValue.data) &&
|
|
std::holds_alternative<std::string>(fileValue.data)) {
|
|
AddStaticMount(std::get<std::string>(urlValue.data),
|
|
std::get<std::string>(fileValue.data));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Map of identifier-key -> filesystem path.
|
|
if (std::holds_alternative<std::shared_ptr<DlangTable>>(last.data)) {
|
|
auto table = std::get<std::shared_ptr<DlangTable>>(last.data);
|
|
for (const auto& [key, value] : table->fields) {
|
|
if (!std::holds_alternative<std::string>(value.data))
|
|
continue;
|
|
|
|
std::string url = key.empty() ? "/" : key;
|
|
if (url.front() != '/')
|
|
url = "/" + url;
|
|
|
|
AddStaticMount(url, std::get<std::string>(value.data));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!std::holds_alternative<std::string>(last.data))
|
|
return;
|
|
|
|
std::string filePath = std::get<std::string>(last.data);
|
|
|
|
StackValue first = dlang_pop(vm);
|
|
if (!std::holds_alternative<std::string>(first.data))
|
|
return;
|
|
|
|
AddStaticMount(std::get<std::string>(first.data), filePath);
|
|
}
|
|
|
|
void NativeError(DlangVM* vm) {
|
|
// server.error(statusCode, handler)
|
|
// handler can be a body string, a function, or a { body, contentType } table.
|
|
StackValue handler = dlang_pop(vm);
|
|
StackValue codeValue = dlang_pop(vm);
|
|
|
|
if (!std::holds_alternative<double>(codeValue.data))
|
|
return;
|
|
|
|
int statusCode = static_cast<int>(std::get<double>(codeValue.data));
|
|
ErrorPage page;
|
|
|
|
if (std::holds_alternative<std::shared_ptr<DlangFunction>>(handler.data)) {
|
|
page.isFunction = true;
|
|
page.functionVal = handler;
|
|
}
|
|
else if (std::holds_alternative<std::string>(handler.data)) {
|
|
page.body = std::get<std::string>(handler.data);
|
|
}
|
|
else if (std::holds_alternative<std::shared_ptr<DlangTable>>(handler.data)) {
|
|
auto table = std::get<std::shared_ptr<DlangTable>>(handler.data);
|
|
|
|
StackValue body;
|
|
if (table->Get("body", body) && std::holds_alternative<std::string>(body.data))
|
|
page.body = std::get<std::string>(body.data);
|
|
|
|
StackValue contentType;
|
|
if (table->Get("contentType", contentType) && std::holds_alternative<std::string>(contentType.data))
|
|
page.contentType = std::get<std::string>(contentType.data);
|
|
}
|
|
else {
|
|
return; // Unsupported handler; keep the built-in default page.
|
|
}
|
|
|
|
g_errorPages[statusCode] = page;
|
|
}
|
|
|
|
// Encode a value as JSON using the VM's native `table.json_encode`. The
|
|
// function is looked up directly on the `table` library instead of through
|
|
// `GetField`, which would pop the library off the stack and discard the
|
|
// result (breaking the call that follows).
|
|
static bool EncodeTableAsJson(DlangVM* vm, const StackValue& value, std::string& out) {
|
|
StackValue jsonLib = vm->globals.Get("table");
|
|
if (jsonLib.type != StackValueType::Table)
|
|
return false;
|
|
|
|
StackValue encodeFn;
|
|
if (!GetTableField(jsonLib, "json_encode", encodeFn) ||
|
|
encodeFn.type != StackValueType::Function)
|
|
return false;
|
|
|
|
int base = vm->stack.top();
|
|
|
|
dlang_push(vm, value); // argument 1: value to encode
|
|
dlang_pushnumber(vm, 0); // argument 2: indent
|
|
dlang_push(vm, encodeFn); // the function to call
|
|
|
|
if (!dlang_call(vm, 2)) {
|
|
ResetStack(vm, base);
|
|
return false;
|
|
}
|
|
|
|
if (!dlang_isstring(vm)) {
|
|
ResetStack(vm, base);
|
|
return false;
|
|
}
|
|
|
|
out = dlang_tostring(vm);
|
|
ResetStack(vm, base);
|
|
return true;
|
|
}
|
|
|
|
// Turn whatever a route/error handler returned into an HTTP response: a body,
|
|
// a content type and a status code. The handler may return a plain string or a
|
|
// table with `body`, `contentType` and `statusCode` fields.
|
|
static void ExtractResponse(DlangVM* vm, const StackValue& returned,
|
|
std::string& outBody, std::string& outContentType,
|
|
int& outStatusCode) {
|
|
outBody.clear();
|
|
outContentType = "text/html";
|
|
outStatusCode = 200;
|
|
|
|
if (returned.type == StackValueType::String) {
|
|
outBody = std::get<std::string>(returned.data);
|
|
return;
|
|
}
|
|
|
|
if (returned.type != StackValueType::Table)
|
|
return;
|
|
|
|
StackValue body;
|
|
GetTableField(returned, "body", body);
|
|
|
|
if (body.type == StackValueType::String) {
|
|
outBody = std::get<std::string>(body.data);
|
|
}
|
|
else if (body.type == StackValueType::Table) {
|
|
// Tables/objects are serialized to JSON by the VM (table.json_encode).
|
|
if (EncodeTableAsJson(vm, body, outBody))
|
|
outContentType = "application/json";
|
|
}
|
|
|
|
StackValue contentType;
|
|
if (GetTableField(returned, "contentType", contentType) &&
|
|
contentType.type == StackValueType::String) {
|
|
outContentType = std::get<std::string>(contentType.data);
|
|
}
|
|
|
|
StackValue statusCode;
|
|
if (GetTableField(returned, "statusCode", statusCode) &&
|
|
statusCode.type == StackValueType::Int) {
|
|
outStatusCode = static_cast<int>(std::get<double>(statusCode.data));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Views: view.Render("page.html", { name: "World" })
|
|
//
|
|
// A tiny text/HTML template engine:
|
|
//
|
|
// @include("partial.html") -> inlines another template file. Includes are
|
|
// recursive and resolved relative to the file
|
|
// that contains them (falling back to the
|
|
// process working directory).
|
|
// {{ key }} -> replaced with the matching entry from the
|
|
// variables table. Dotted paths ({{ user.name }})
|
|
// walk into nested tables. Unknown keys are left
|
|
// untouched so they are easy to spot.
|
|
//
|
|
// Variable values may be strings, numbers, booleans or tables/lists (tables and
|
|
// lists are serialized to JSON, which is handy for embedding data in <script>).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static const int kMaxViewIncludeDepth = 32;
|
|
|
|
static std::string ViewTrim(const std::string& value) {
|
|
size_t begin = 0;
|
|
size_t end = value.size();
|
|
|
|
while (begin < end && std::isspace(static_cast<unsigned char>(value[begin])))
|
|
++begin;
|
|
while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1])))
|
|
--end;
|
|
|
|
return value.substr(begin, end - begin);
|
|
}
|
|
|
|
// Convert a dlang value into the text that replaces a {{ placeholder }}.
|
|
static bool ViewValueToString(DlangVM* vm, const StackValue& value, std::string& out) {
|
|
switch (value.type) {
|
|
case StackValueType::String:
|
|
out = std::get<std::string>(value.data);
|
|
return true;
|
|
case StackValueType::Int:
|
|
out = DoubleToString(std::get<double>(value.data));
|
|
return true;
|
|
case StackValueType::Bool:
|
|
out = std::get<bool>(value.data) ? "true" : "false";
|
|
return true;
|
|
case StackValueType::NIL:
|
|
out.clear();
|
|
return true;
|
|
case StackValueType::Table:
|
|
case StackValueType::List:
|
|
return EncodeTableAsJson(vm, value, out);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Look up "user.name" style keys, walking nested tables as we go.
|
|
static bool LookupViewVariable(const StackValue& variables,
|
|
const std::string& path,
|
|
StackValue& out) {
|
|
StackValue current = variables;
|
|
size_t start = 0;
|
|
|
|
while (true) {
|
|
size_t dot = path.find('.', start);
|
|
std::string key = ViewTrim(path.substr(
|
|
start, dot == std::string::npos ? std::string::npos : dot - start));
|
|
|
|
if (key.empty() || current.type != StackValueType::Table)
|
|
return false;
|
|
|
|
StackValue next;
|
|
if (!GetTableField(current, key.c_str(), next))
|
|
return false;
|
|
|
|
if (dot == std::string::npos) {
|
|
out = next;
|
|
return true;
|
|
}
|
|
|
|
current = next;
|
|
start = dot + 1;
|
|
}
|
|
}
|
|
|
|
// Replace every {{ key }} placeholder with the matching value.
|
|
static std::string InterpolateViewVariables(DlangVM* vm,
|
|
const std::string& source,
|
|
const StackValue& variables) {
|
|
std::string out;
|
|
out.reserve(source.size());
|
|
|
|
size_t i = 0;
|
|
while (i < source.size()) {
|
|
size_t open = source.find("{{", i);
|
|
if (open == std::string::npos) {
|
|
out += source.substr(i);
|
|
break;
|
|
}
|
|
|
|
out += source.substr(i, open - i);
|
|
|
|
size_t close = source.find("}}", open + 2);
|
|
if (close == std::string::npos) {
|
|
out += source.substr(open);
|
|
break;
|
|
}
|
|
|
|
std::string key = ViewTrim(source.substr(open + 2, close - open - 2));
|
|
|
|
StackValue value;
|
|
std::string text;
|
|
if (key.empty() ||
|
|
!LookupViewVariable(variables, key, value) ||
|
|
!ViewValueToString(vm, value, text)) {
|
|
// Unknown (or unrenderable) variable: keep the placeholder intact.
|
|
out += source.substr(open, close + 2 - open);
|
|
}
|
|
else {
|
|
out += text;
|
|
}
|
|
|
|
i = close + 2;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// Recursively inline every @include("...") directive.
|
|
static std::string ExpandViewIncludes(const std::string& source,
|
|
const std::filesystem::path& baseDir,
|
|
std::vector<std::string>& activeFiles,
|
|
int depth) {
|
|
const std::string directive = "@include";
|
|
|
|
std::string out;
|
|
size_t i = 0;
|
|
|
|
while (i < source.size()) {
|
|
size_t at = source.find(directive, i);
|
|
if (at == std::string::npos) {
|
|
out += source.substr(i);
|
|
break;
|
|
}
|
|
|
|
out += source.substr(i, at - i);
|
|
|
|
// Parse: @include ( "path" ) -- whitespace and ' / " quotes allowed.
|
|
size_t p = at + directive.size();
|
|
while (p < source.size() && std::isspace(static_cast<unsigned char>(source[p])))
|
|
++p;
|
|
|
|
bool parsed = false;
|
|
std::string includePath;
|
|
|
|
if (p < source.size() && source[p] == '(') {
|
|
++p;
|
|
while (p < source.size() && std::isspace(static_cast<unsigned char>(source[p])))
|
|
++p;
|
|
|
|
if (p < source.size() && (source[p] == '"' || source[p] == '\'')) {
|
|
char quote = source[p++];
|
|
size_t quoteEnd = source.find(quote, p);
|
|
|
|
if (quoteEnd != std::string::npos) {
|
|
includePath = source.substr(p, quoteEnd - p);
|
|
|
|
size_t q = quoteEnd + 1;
|
|
while (q < source.size() && std::isspace(static_cast<unsigned char>(source[q])))
|
|
++q;
|
|
|
|
if (q < source.size() && source[q] == ')') {
|
|
i = q + 1;
|
|
parsed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!parsed) {
|
|
// Not a well-formed directive; emit it verbatim and move on.
|
|
out += directive;
|
|
i = at + directive.size();
|
|
continue;
|
|
}
|
|
|
|
if (depth >= kMaxViewIncludeDepth) {
|
|
out += "<!-- view: include depth limit reached -->";
|
|
continue;
|
|
}
|
|
|
|
std::error_code ec;
|
|
std::filesystem::path resolved = baseDir / includePath;
|
|
|
|
if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
|
|
// Fall back to a path relative to the process working directory.
|
|
if (std::filesystem::is_regular_file(includePath, ec) && !ec)
|
|
resolved = includePath;
|
|
else {
|
|
out += "<!-- view: include not found: " + includePath + " -->";
|
|
continue;
|
|
}
|
|
}
|
|
|
|
std::filesystem::path canonical = std::filesystem::weakly_canonical(resolved, ec);
|
|
std::string canonicalStr = ec ? resolved.string() : canonical.string();
|
|
|
|
if (std::find(activeFiles.begin(), activeFiles.end(), canonicalStr) != activeFiles.end()) {
|
|
out += "<!-- view: include cycle skipped: " + includePath + " -->";
|
|
continue;
|
|
}
|
|
|
|
std::string fileContent;
|
|
if (!ReadFileBinary(resolved.string(), fileContent)) {
|
|
out += "<!-- view: include not found: " + includePath + " -->";
|
|
continue;
|
|
}
|
|
|
|
activeFiles.push_back(canonicalStr);
|
|
out += ExpandViewIncludes(fileContent, resolved.parent_path(), activeFiles, depth + 1);
|
|
activeFiles.pop_back();
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
void NativeViewRender(DlangVM* vm) {
|
|
// view.Render(path, variables)
|
|
//
|
|
// `GET_FIELD` already consumed the `view` table while resolving the
|
|
// `view.Render` callee, so only the two explicit arguments are on the stack
|
|
// (the variables table is on top).
|
|
StackValue variables = dlang_pop(vm);
|
|
StackValue pathValue = dlang_pop(vm);
|
|
|
|
if (pathValue.type != StackValueType::String) {
|
|
std::cerr << "view.Render: template path must be a string\n";
|
|
throw std::runtime_error("view.Render: template path must be a string");
|
|
}
|
|
|
|
std::string path = std::get<std::string>(pathValue.data);
|
|
|
|
std::string source;
|
|
if (!ReadFileBinary(path, source)) {
|
|
std::cerr << "view.Render: could not open template '" << path << "'\n";
|
|
throw std::runtime_error("view.Render: could not open template '" + path + "'");
|
|
}
|
|
|
|
std::error_code ec;
|
|
std::vector<std::string> activeFiles;
|
|
std::filesystem::path canonical = std::filesystem::weakly_canonical(path, ec);
|
|
activeFiles.push_back(ec ? path : canonical.string());
|
|
|
|
std::string expanded = ExpandViewIncludes(
|
|
source, std::filesystem::path(path).parent_path(), activeFiles, 0);
|
|
std::string rendered = InterpolateViewVariables(vm, expanded, variables);
|
|
|
|
dlang_pushstring(vm, rendered.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request parsing
|
|
//
|
|
// Every route handler is invoked with four arguments:
|
|
//
|
|
// fn(req, params, cookies, files)
|
|
//
|
|
// req -> { method, path, query, headers, params, cookies, files, body,
|
|
// user }
|
|
// `headers` holds every request header (names lower-cased) and
|
|
// `user` is only present for a request that passed the HTTP Basic
|
|
// gate set up with auth.protect(...) / auth.protectPrefix(...).
|
|
// params -> a single key/value table holding *all* request parameters:
|
|
// the query string (GET) plus the request body (POST/PUT/PATCH/
|
|
// DELETE). Body values are read as url-encoded form data, as a
|
|
// JSON object when Content-Type is application/json, or as the
|
|
// plain fields of a multipart/form-data upload. A key that occurs
|
|
// more than once is collected into a list.
|
|
// cookies -> the parsed `Cookie:` header as a key/value table.
|
|
// files -> the uploaded files of a multipart/form-data body, keyed by form
|
|
// field name (see the "File uploads" section below).
|
|
//
|
|
// `req.body` holds the raw request body (handy for uploads sent as
|
|
// `application/octet-stream`) and is only set when the body is not empty.
|
|
//
|
|
// Values coming from the query string / url-encoded body are stored as strings;
|
|
// JSON bodies keep their native types (numbers, booleans, nested tables/lists).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static std::string HttpTrim(const std::string& value) {
|
|
size_t begin = 0;
|
|
size_t end = value.size();
|
|
|
|
while (begin < end) {
|
|
unsigned char c = static_cast<unsigned char>(value[begin]);
|
|
if (!std::isspace(c) && value[begin] != '\r' && value[begin] != '\n')
|
|
break;
|
|
++begin;
|
|
}
|
|
while (end > begin) {
|
|
unsigned char c = static_cast<unsigned char>(value[end - 1]);
|
|
if (!std::isspace(c) && value[end - 1] != '\r' && value[end - 1] != '\n')
|
|
break;
|
|
--end;
|
|
}
|
|
|
|
return value.substr(begin, end - begin);
|
|
}
|
|
|
|
// Case-insensitive lookup of a single header inside a header block. A request
|
|
// block starts with the request line (`GET / HTTP/1.1`), which is not a header,
|
|
// while the header block of a multipart part has no such line; hence the
|
|
// `skipFirstLine` switch.
|
|
static bool FindHeader(const std::string& headers, const std::string& name,
|
|
std::string& out, bool skipFirstLine) {
|
|
std::string wanted = ToLower(HttpTrim(name));
|
|
|
|
std::istringstream stream(headers);
|
|
std::string line;
|
|
bool firstLine = true;
|
|
|
|
while (std::getline(stream, line)) {
|
|
if (firstLine) {
|
|
firstLine = false;
|
|
if (skipFirstLine) // skip the request line
|
|
continue;
|
|
}
|
|
if (!line.empty() && line.back() == '\r')
|
|
line.pop_back();
|
|
|
|
size_t colon = line.find(':');
|
|
if (colon == std::string::npos)
|
|
continue;
|
|
|
|
if (ToLower(HttpTrim(line.substr(0, colon))) == wanted) {
|
|
out = HttpTrim(line.substr(colon + 1));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool GetHeaderValue(const std::string& headers, const std::string& name,
|
|
std::string& out) {
|
|
return FindHeader(headers, name, out, true);
|
|
}
|
|
|
|
// Insert one key/value pair into a parameter table. When the same key shows up
|
|
// more than once the values are collected into a list.
|
|
static void AddParamValue(DlangTable& table, const std::string& key,
|
|
const std::string& value) {
|
|
if (key.empty())
|
|
return;
|
|
|
|
StackValue existing;
|
|
if (table.Get(key, existing)) {
|
|
if (existing.type == StackValueType::String) {
|
|
auto list = std::make_shared<DlangList>();
|
|
list->Push(existing);
|
|
list->Push(StackValue(StackValueType::String, value));
|
|
table.Set(key, StackValue(StackValueType::List, list));
|
|
}
|
|
else if (existing.type == StackValueType::List) {
|
|
auto list = std::get<std::shared_ptr<DlangList>>(existing.data);
|
|
list->Push(StackValue(StackValueType::String, value));
|
|
}
|
|
return;
|
|
}
|
|
|
|
table.Set(key, StackValue(StackValueType::String, value));
|
|
}
|
|
|
|
// Parse an `a=1&b=2` style string (query string or url-encoded body).
|
|
static void ParseUrlEncoded(const std::string& data, DlangTable& table) {
|
|
size_t start = 0;
|
|
|
|
while (start <= data.size()) {
|
|
size_t amp = data.find('&', start);
|
|
std::string pair = data.substr(
|
|
start, amp == std::string::npos ? std::string::npos : amp - start);
|
|
|
|
if (!pair.empty()) {
|
|
size_t eq = pair.find('=');
|
|
std::string key, value;
|
|
|
|
if (eq == std::string::npos) {
|
|
key = UrlDecode(pair);
|
|
}
|
|
else {
|
|
key = UrlDecode(pair.substr(0, eq));
|
|
value = UrlDecode(pair.substr(eq + 1));
|
|
}
|
|
|
|
AddParamValue(table, key, value);
|
|
}
|
|
|
|
if (amp == std::string::npos)
|
|
break;
|
|
start = amp + 1;
|
|
}
|
|
}
|
|
|
|
// Parse the `Cookie:` header ("a=1; b=2") into a table.
|
|
static void ParseCookies(const std::string& header, DlangTable& table) {
|
|
size_t start = 0;
|
|
|
|
while (start <= header.size()) {
|
|
size_t semi = header.find(';', start);
|
|
std::string pair = HttpTrim(header.substr(
|
|
start, semi == std::string::npos ? std::string::npos : semi - start));
|
|
|
|
if (!pair.empty()) {
|
|
size_t eq = pair.find('=');
|
|
if (eq != std::string::npos) {
|
|
std::string key = HttpTrim(pair.substr(0, eq));
|
|
std::string value = HttpTrim(pair.substr(eq + 1));
|
|
|
|
if (!key.empty())
|
|
table.Set(key, StackValue(StackValueType::String, value));
|
|
}
|
|
}
|
|
|
|
if (semi == std::string::npos)
|
|
break;
|
|
start = semi + 1;
|
|
}
|
|
}
|
|
|
|
// Parse a header block (request line followed by `Name: value` lines) into a
|
|
// table. Header names are lower-cased; a header that occurs more than once is
|
|
// collected into a list, exactly like a repeated query parameter.
|
|
static void ParseHeaders(const std::string& headerBlock, DlangTable& table) {
|
|
std::istringstream stream(headerBlock);
|
|
std::string line;
|
|
bool firstLine = true;
|
|
|
|
while (std::getline(stream, line)) {
|
|
if (firstLine) {
|
|
firstLine = false; // the request line is not a header
|
|
continue;
|
|
}
|
|
|
|
if (!line.empty() && line.back() == '\r')
|
|
line.pop_back();
|
|
if (line.empty())
|
|
continue;
|
|
|
|
size_t colon = line.find(':');
|
|
if (colon == std::string::npos)
|
|
continue;
|
|
|
|
std::string name = ToLower(HttpTrim(line.substr(0, colon)));
|
|
if (!name.empty())
|
|
AddParamValue(table, name, HttpTrim(line.substr(colon + 1)));
|
|
}
|
|
}
|
|
|
|
// Decode a JSON string into a value using the VM's native `table.json_decode`.
|
|
// Returns false when the library/function is unavailable or the text is not
|
|
// valid JSON, in which case the body is simply ignored.
|
|
static bool DecodeJsonValue(DlangVM* vm, const std::string& text, StackValue& out) {
|
|
StackValue jsonLib = vm->globals.Get("table");
|
|
if (jsonLib.type != StackValueType::Table)
|
|
return false;
|
|
|
|
StackValue decodeFn;
|
|
if (!GetTableField(jsonLib, "json_decode", decodeFn) ||
|
|
decodeFn.type != StackValueType::Function)
|
|
return false;
|
|
|
|
int base = vm->stack.top();
|
|
bool ok = false;
|
|
|
|
try {
|
|
dlang_pushstring(vm, text.c_str());
|
|
dlang_push(vm, decodeFn);
|
|
|
|
if (dlang_call(vm, 1)) {
|
|
out = dlang_pop(vm);
|
|
ok = true;
|
|
}
|
|
}
|
|
catch (...) {
|
|
ok = false;
|
|
}
|
|
|
|
ResetStack(vm, base);
|
|
return ok;
|
|
}
|
|
|
|
// Merge the top-level fields of a JSON object body into the parameter table.
|
|
static void MergeJsonBody(DlangVM* vm, const std::string& body, DlangTable& table) {
|
|
StackValue decoded;
|
|
if (!DecodeJsonValue(vm, body, decoded))
|
|
return;
|
|
if (decoded.type != StackValueType::Table)
|
|
return;
|
|
|
|
auto tablePtr = std::get<std::shared_ptr<DlangTable>>(decoded.data);
|
|
for (const auto& [key, value] : tablePtr->fields)
|
|
table.Set(key, value);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File uploads (multipart/form-data)
|
|
//
|
|
// A form posted with `enctype="multipart/form-data"` (what a browser sends for
|
|
// `<input type="file">`, or what `FormData` produces in fetch/XHR) is split into
|
|
// its parts:
|
|
//
|
|
// * plain fields -> merged into the regular parameter table, exactly like a
|
|
// url-encoded body, so `params.name` keeps working,
|
|
// * file fields -> collected in a `files` table, keyed by form field name.
|
|
// A field name that occurs more than once becomes a list.
|
|
//
|
|
// Every file entry is a table:
|
|
//
|
|
// { field = "avatar", filename = "cat.png", contentType = "image/png",
|
|
// size = 1234, content = "<raw bytes>" }
|
|
//
|
|
// `content` holds the bytes exactly as they arrived (binary safe). The table is
|
|
// exposed as `req.files` and passed to the route handler as its 4th argument:
|
|
//
|
|
// fn(req, params, cookies, files)
|
|
//
|
|
// The `http` table writes an upload to disk (the helpers are not a global of
|
|
// their own, so that the `files` parameter above cannot shadow them):
|
|
//
|
|
// http.save(upload, "out/cat.png") -> true on success
|
|
// http.saveTo(upload, "out") -> the written path, or "" on failure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Percent-decode a string. Unlike UrlDecode this leaves '+' alone, which matters
|
|
// for RFC 5987 encoded filenames (`filename*=UTF-8''cat%20pic.png`).
|
|
static std::string PercentDecode(const std::string& input) {
|
|
std::string output;
|
|
output.reserve(input.size());
|
|
|
|
for (size_t i = 0; i < input.size(); ++i) {
|
|
if (input[i] == '%' && i + 2 < input.size()) {
|
|
std::string hex = input.substr(i + 1, 2);
|
|
output += static_cast<char>(std::strtol(hex.c_str(), nullptr, 16));
|
|
i += 2;
|
|
}
|
|
else {
|
|
output += input[i];
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
// Pull the `boundary=...` parameter (quoted or not) out of a Content-Type.
|
|
static bool GetMultipartBoundary(const std::string& contentType, std::string& out) {
|
|
size_t at = ToLower(contentType).find("boundary=");
|
|
if (at == std::string::npos)
|
|
return false;
|
|
|
|
// The boundary ends at the next ';' (other parameters may follow it).
|
|
std::string rest = contentType.substr(at + 9);
|
|
size_t end = rest.find(';');
|
|
|
|
std::string boundary = HttpTrim(end == std::string::npos ? rest : rest.substr(0, end));
|
|
if (boundary.size() >= 2 && boundary.front() == '"' && boundary.back() == '"')
|
|
boundary = boundary.substr(1, boundary.size() - 2);
|
|
|
|
if (boundary.empty())
|
|
return false;
|
|
|
|
out = boundary;
|
|
return true;
|
|
}
|
|
|
|
// Read one `;`-separated parameter (e.g. `name` / `filename`) out of a header
|
|
// value such as `form-data; name="avatar"; filename="cat.png"`.
|
|
static bool GetHeaderParameter(const std::string& headerValue, const std::string& name,
|
|
std::string& out) {
|
|
std::string wanted = ToLower(name);
|
|
size_t start = 0;
|
|
|
|
while (start <= headerValue.size()) {
|
|
size_t semi = headerValue.find(';', start);
|
|
std::string pair = HttpTrim(headerValue.substr(
|
|
start, semi == std::string::npos ? std::string::npos : semi - start));
|
|
|
|
size_t eq = pair.find('=');
|
|
if (eq != std::string::npos && ToLower(HttpTrim(pair.substr(0, eq))) == wanted) {
|
|
std::string value = HttpTrim(pair.substr(eq + 1));
|
|
if (value.size() >= 2 && value.front() == '"' && value.back() == '"')
|
|
value = value.substr(1, value.size() - 2);
|
|
|
|
out = value;
|
|
return true;
|
|
}
|
|
|
|
if (semi == std::string::npos)
|
|
break;
|
|
start = semi + 1;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Filename of a part, honouring both `filename=` and the RFC 5987
|
|
// `filename*=UTF-8''...` form. Empty when the part is a plain form field.
|
|
static std::string PartFilename(const std::string& disposition) {
|
|
std::string filename;
|
|
if (GetHeaderParameter(disposition, "filename", filename))
|
|
return filename;
|
|
|
|
std::string extended;
|
|
if (!GetHeaderParameter(disposition, "filename*", extended))
|
|
return std::string();
|
|
|
|
// Shaped as charset'language'percent-encoded-value.
|
|
size_t firstQuote = extended.find('\'');
|
|
size_t secondQuote = (firstQuote == std::string::npos)
|
|
? std::string::npos
|
|
: extended.find('\'', firstQuote + 1);
|
|
|
|
if (secondQuote != std::string::npos)
|
|
return PercentDecode(extended.substr(secondQuote + 1));
|
|
|
|
return PercentDecode(extended);
|
|
}
|
|
|
|
// Build the table describing one uploaded file.
|
|
static StackValue MakeFileEntry(const std::string& field,
|
|
const std::string& filename,
|
|
const std::string& contentType,
|
|
const std::string& content) {
|
|
auto entry = std::make_shared<DlangTable>();
|
|
entry->Set("field", StackValue(StackValueType::String, field));
|
|
entry->Set("filename", StackValue(StackValueType::String, filename));
|
|
entry->Set("contentType", StackValue(StackValueType::String, contentType));
|
|
entry->Set("size", StackValue(StackValueType::Int, static_cast<double>(content.size())));
|
|
entry->Set("content", StackValue(StackValueType::String, content));
|
|
|
|
return StackValue(StackValueType::Table, entry);
|
|
}
|
|
|
|
// Store a file entry. Repeated form field names are collected into a list.
|
|
static void AddFileEntry(DlangTable& table, const std::string& key, const StackValue& entry) {
|
|
if (key.empty())
|
|
return;
|
|
|
|
StackValue existing;
|
|
if (table.Get(key, existing)) {
|
|
if (existing.type == StackValueType::Table) {
|
|
auto list = std::make_shared<DlangList>();
|
|
list->Push(existing);
|
|
list->Push(entry);
|
|
table.Set(key, StackValue(StackValueType::List, list));
|
|
}
|
|
else if (existing.type == StackValueType::List) {
|
|
std::get<std::shared_ptr<DlangList>>(existing.data)->Push(entry);
|
|
}
|
|
return;
|
|
}
|
|
|
|
table.Set(key, entry);
|
|
}
|
|
|
|
// Split a `multipart/form-data` body into its parts and fill the parameter and
|
|
// file tables.
|
|
static void ParseMultipartBody(const std::string& body, const std::string& boundary,
|
|
DlangTable& params, DlangTable& files) {
|
|
const std::string delimiter = "--" + boundary;
|
|
|
|
size_t position = body.find(delimiter);
|
|
if (position == std::string::npos)
|
|
return;
|
|
|
|
while (position != std::string::npos) {
|
|
size_t afterDelimiter = position + delimiter.size();
|
|
|
|
// "--boundary--" closes the body.
|
|
if (body.compare(afterDelimiter, 2, "--") == 0)
|
|
break;
|
|
|
|
size_t cursor = afterDelimiter;
|
|
if (body.compare(cursor, 2, "\r\n") == 0)
|
|
cursor += 2;
|
|
else if (cursor < body.size() && body[cursor] == '\n')
|
|
cursor += 1;
|
|
|
|
// Part headers run until the blank line; the raw bytes follow it.
|
|
size_t headerEnd = body.find("\r\n\r\n", cursor);
|
|
size_t separatorSize = 4;
|
|
|
|
if (headerEnd == std::string::npos) {
|
|
headerEnd = body.find("\n\n", cursor);
|
|
separatorSize = 2;
|
|
}
|
|
if (headerEnd == std::string::npos)
|
|
break;
|
|
|
|
std::string partHeaders = body.substr(cursor, headerEnd - cursor);
|
|
size_t contentStart = headerEnd + separatorSize;
|
|
|
|
size_t next = body.find(delimiter, contentStart);
|
|
size_t contentEnd = (next == std::string::npos) ? body.size() : next;
|
|
|
|
// The line break in front of the next delimiter is framing, not content.
|
|
if (contentEnd >= 2 && body.compare(contentEnd - 2, 2, "\r\n") == 0)
|
|
contentEnd -= 2;
|
|
else if (contentEnd >= 1 && body[contentEnd - 1] == '\n')
|
|
contentEnd -= 1;
|
|
|
|
if (contentEnd < contentStart)
|
|
contentEnd = contentStart;
|
|
|
|
std::string content = body.substr(contentStart, contentEnd - contentStart);
|
|
|
|
std::string disposition;
|
|
std::string fieldName;
|
|
|
|
if (FindHeader(partHeaders, "Content-Disposition", disposition, false) &&
|
|
GetHeaderParameter(disposition, "name", fieldName)) {
|
|
|
|
std::string filename = PartFilename(disposition);
|
|
|
|
if (filename.empty()) {
|
|
AddParamValue(params, fieldName, content);
|
|
}
|
|
else {
|
|
std::string partContentType;
|
|
if (!FindHeader(partHeaders, "Content-Type", partContentType, false))
|
|
partContentType = "application/octet-stream";
|
|
|
|
AddFileEntry(files, fieldName,
|
|
MakeFileEntry(fieldName, filename, partContentType, content));
|
|
}
|
|
}
|
|
|
|
position = next;
|
|
}
|
|
}
|
|
|
|
// Trim a client supplied filename down to something safe to use on disk.
|
|
static std::string SafeUploadFilename(const std::string& filename) {
|
|
// Some clients (old IE) send a full path; only trust the last component.
|
|
size_t slash = filename.find_last_of("/\\");
|
|
std::string name = (slash == std::string::npos) ? filename : filename.substr(slash + 1);
|
|
|
|
std::string cleaned;
|
|
cleaned.reserve(name.size());
|
|
|
|
for (unsigned char c : name) {
|
|
if (c < 0x20 || c == 0x7f || c == '"' || c == '*' || c == ':' ||
|
|
c == '<' || c == '>' || c == '?' || c == '|')
|
|
cleaned += '_';
|
|
else
|
|
cleaned += static_cast<char>(c);
|
|
}
|
|
|
|
if (cleaned.empty() || cleaned == "." || cleaned == "..")
|
|
return "upload.bin";
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
static bool WriteFileBinary(const std::string& path, const std::string& data) {
|
|
std::error_code ec;
|
|
std::filesystem::path target(path);
|
|
|
|
if (target.has_parent_path() && !target.parent_path().empty()) {
|
|
std::filesystem::create_directories(target.parent_path(), ec);
|
|
if (ec)
|
|
return false;
|
|
}
|
|
|
|
std::ofstream file(path, std::ios::binary | std::ios::trunc);
|
|
if (!file.is_open())
|
|
return false;
|
|
|
|
file.write(data.data(), static_cast<std::streamsize>(data.size()));
|
|
return file.good();
|
|
}
|
|
|
|
// Pull the bytes (and a suggested filename) out of an upload value. A plain
|
|
// string is accepted too, so a raw request body can be written as well.
|
|
static bool ExtractUploadContent(const StackValue& value, std::string& outContent,
|
|
std::string& outFilename) {
|
|
if (value.type == StackValueType::String) {
|
|
outContent = std::get<std::string>(value.data);
|
|
outFilename = "upload.bin";
|
|
return true;
|
|
}
|
|
|
|
if (value.type != StackValueType::Table)
|
|
return false;
|
|
|
|
StackValue content;
|
|
if (!GetTableField(value, "content", content) || content.type != StackValueType::String)
|
|
return false;
|
|
|
|
outContent = std::get<std::string>(content.data);
|
|
|
|
StackValue filename;
|
|
if (GetTableField(value, "filename", filename) &&
|
|
filename.type == StackValueType::String &&
|
|
!std::get<std::string>(filename.data).empty())
|
|
outFilename = std::get<std::string>(filename.data);
|
|
else
|
|
outFilename = "upload.bin";
|
|
|
|
return true;
|
|
}
|
|
|
|
void NativeFilesSave(DlangVM* vm) {
|
|
// http.save(upload, path) -> true when the bytes were written to disk.
|
|
StackValue pathValue = dlang_pop(vm);
|
|
StackValue uploadValue = dlang_pop(vm);
|
|
|
|
std::string content, filename;
|
|
if (pathValue.type != StackValueType::String ||
|
|
!ExtractUploadContent(uploadValue, content, filename)) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, WriteFileBinary(std::get<std::string>(pathValue.data), content));
|
|
}
|
|
|
|
void NativeFilesSaveTo(DlangVM* vm) {
|
|
// http.saveTo(upload, folder) -> the written path, or "" on failure.
|
|
StackValue folderValue = dlang_pop(vm);
|
|
StackValue uploadValue = dlang_pop(vm);
|
|
|
|
std::string content, filename;
|
|
if (folderValue.type != StackValueType::String ||
|
|
!ExtractUploadContent(uploadValue, content, filename)) {
|
|
dlang_pushstring(vm, "");
|
|
return;
|
|
}
|
|
|
|
std::filesystem::path target =
|
|
std::filesystem::path(std::get<std::string>(folderValue.data)) /
|
|
SafeUploadFilename(filename);
|
|
|
|
if (!WriteFileBinary(target.string(), content)) {
|
|
dlang_pushstring(vm, "");
|
|
return;
|
|
}
|
|
|
|
dlang_pushstring(vm, target.string());
|
|
}
|
|
|
|
// Read a full HTTP request: the header block plus the body described by
|
|
// Content-Length. `headerEnd` is set to the offset of the blank line that
|
|
// terminates the headers. Returns false when no complete header block arrived.
|
|
// `outTooLarge` reports a body bigger than `g_maxBodyBytes`, in which case the
|
|
// request is refused before the whole body has been buffered.
|
|
// ---------------------------------------------------------------------------
|
|
// Authentication: HTTP Basic auth with hashed passwords
|
|
//
|
|
// Passwords never reach the disk in the clear. Every account gets a random salt
|
|
// and a PBKDF2-HMAC-SHA256 hash, stored in a JSON file (`users.json` in the
|
|
// process working directory by default):
|
|
//
|
|
// {
|
|
// "algorithm": "pbkdf2-sha256",
|
|
// "users": [
|
|
// { "username": "admin", "salt": "…hex…", "hash": "…hex…",
|
|
// "iterations": 100000 }
|
|
// ]
|
|
// }
|
|
//
|
|
// The dlang side configures everything through the `auth` global:
|
|
//
|
|
// auth.addUser("admin", "geheim"); // hashes the password, writes users.json
|
|
// auth.protectPrefix("/admin"); // HTTP Basic gate for /admin and below
|
|
// auth.realm("My site"); // text in the browser's login popup
|
|
//
|
|
// A request for a protected path must carry `Authorization: Basic base64(user:password)`.
|
|
// Without valid credentials it is answered with 401 + `WWW-Authenticate`, which is
|
|
// what makes the browser show its own username/password dialog. Handlers receive the
|
|
// authenticated name as `req.user`, and can also check credentials themselves with
|
|
// `auth.login(req)` or `auth.verify(name, password)`.
|
|
//
|
|
// CAUTION: Basic auth resends the password (base64 encoded, i.e. readable) with every
|
|
// request, so it is only safe behind HTTPS — put a TLS reverse proxy in front.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// --- SHA-256 (FIPS 180-4) --------------------------------------------------
|
|
|
|
// First 32 bits of the fractional parts of the cube roots of the first 64 primes.
|
|
static const uint32_t kSha256RoundKeys[64] = {
|
|
0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u,
|
|
0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u,
|
|
0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u,
|
|
0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u,
|
|
0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu,
|
|
0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
|
|
0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u,
|
|
0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u,
|
|
0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u,
|
|
0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u,
|
|
0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u,
|
|
0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
|
|
0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u,
|
|
0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u,
|
|
0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u,
|
|
0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u
|
|
};
|
|
|
|
struct Sha256State {
|
|
uint32_t hash[8];
|
|
unsigned char block[64];
|
|
size_t blockLength;
|
|
uint64_t totalBytes;
|
|
};
|
|
|
|
static uint32_t RotateRight(uint32_t value, unsigned int bits) {
|
|
return (value >> bits) | (value << (32u - bits));
|
|
}
|
|
|
|
static void Sha256Init(Sha256State& state) {
|
|
// First 32 bits of the fractional parts of the square roots of the first
|
|
// eight primes.
|
|
state.hash[0] = 0x6a09e667u;
|
|
state.hash[1] = 0xbb67ae85u;
|
|
state.hash[2] = 0x3c6ef372u;
|
|
state.hash[3] = 0xa54ff53au;
|
|
state.hash[4] = 0x510e527fu;
|
|
state.hash[5] = 0x9b05688cu;
|
|
state.hash[6] = 0x1f83d9abu;
|
|
state.hash[7] = 0x5be0cd19u;
|
|
state.blockLength = 0;
|
|
state.totalBytes = 0;
|
|
}
|
|
|
|
static void Sha256Compress(Sha256State& state, const unsigned char* block) {
|
|
uint32_t schedule[64];
|
|
|
|
for (int i = 0; i < 16; ++i) {
|
|
schedule[i] = (static_cast<uint32_t>(block[i * 4]) << 24) |
|
|
(static_cast<uint32_t>(block[i * 4 + 1]) << 16) |
|
|
(static_cast<uint32_t>(block[i * 4 + 2]) << 8) |
|
|
static_cast<uint32_t>(block[i * 4 + 3]);
|
|
}
|
|
|
|
for (int i = 16; i < 64; ++i) {
|
|
uint32_t s0 = RotateRight(schedule[i - 15], 7) ^
|
|
RotateRight(schedule[i - 15], 18) ^
|
|
(schedule[i - 15] >> 3);
|
|
uint32_t s1 = RotateRight(schedule[i - 2], 17) ^
|
|
RotateRight(schedule[i - 2], 19) ^
|
|
(schedule[i - 2] >> 10);
|
|
|
|
schedule[i] = schedule[i - 16] + s0 + schedule[i - 7] + s1;
|
|
}
|
|
|
|
uint32_t a = state.hash[0], b = state.hash[1], c = state.hash[2], d = state.hash[3];
|
|
uint32_t e = state.hash[4], f = state.hash[5], g = state.hash[6], h = state.hash[7];
|
|
|
|
for (int i = 0; i < 64; ++i) {
|
|
uint32_t s1 = RotateRight(e, 6) ^ RotateRight(e, 11) ^ RotateRight(e, 25);
|
|
uint32_t choose = (e & f) ^ (~e & g);
|
|
uint32_t temp1 = h + s1 + choose + kSha256RoundKeys[i] + schedule[i];
|
|
uint32_t s0 = RotateRight(a, 2) ^ RotateRight(a, 13) ^ RotateRight(a, 22);
|
|
uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
|
|
uint32_t temp2 = s0 + majority;
|
|
|
|
h = g;
|
|
g = f;
|
|
f = e;
|
|
e = d + temp1;
|
|
d = c;
|
|
c = b;
|
|
b = a;
|
|
a = temp1 + temp2;
|
|
}
|
|
|
|
state.hash[0] += a;
|
|
state.hash[1] += b;
|
|
state.hash[2] += c;
|
|
state.hash[3] += d;
|
|
state.hash[4] += e;
|
|
state.hash[5] += f;
|
|
state.hash[6] += g;
|
|
state.hash[7] += h;
|
|
}
|
|
|
|
static void Sha256Update(Sha256State& state, const unsigned char* data, size_t length) {
|
|
state.totalBytes += length;
|
|
|
|
for (size_t i = 0; i < length; ++i) {
|
|
state.block[state.blockLength++] = data[i];
|
|
|
|
if (state.blockLength == sizeof(state.block)) {
|
|
Sha256Compress(state, state.block);
|
|
state.blockLength = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
static void Sha256Final(Sha256State& state, unsigned char out[32]) {
|
|
uint64_t bitLength = state.totalBytes * 8;
|
|
|
|
// One 0x80 byte, then zero padding up to 56 bytes, then the bit length.
|
|
state.block[state.blockLength++] = 0x80;
|
|
|
|
if (state.blockLength > 56) {
|
|
while (state.blockLength < sizeof(state.block))
|
|
state.block[state.blockLength++] = 0x00;
|
|
|
|
Sha256Compress(state, state.block);
|
|
state.blockLength = 0;
|
|
}
|
|
|
|
while (state.blockLength < 56)
|
|
state.block[state.blockLength++] = 0x00;
|
|
|
|
for (int i = 7; i >= 0; --i)
|
|
state.block[56 + (7 - i)] = static_cast<unsigned char>((bitLength >> (i * 8)) & 0xff);
|
|
|
|
Sha256Compress(state, state.block);
|
|
state.blockLength = 0;
|
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
out[i * 4] = static_cast<unsigned char>((state.hash[i] >> 24) & 0xff);
|
|
out[i * 4 + 1] = static_cast<unsigned char>((state.hash[i] >> 16) & 0xff);
|
|
out[i * 4 + 2] = static_cast<unsigned char>((state.hash[i] >> 8) & 0xff);
|
|
out[i * 4 + 3] = static_cast<unsigned char>(state.hash[i] & 0xff);
|
|
}
|
|
}
|
|
|
|
static void Sha256(const unsigned char* data, size_t length, unsigned char out[32]) {
|
|
Sha256State state;
|
|
Sha256Init(state);
|
|
Sha256Update(state, data, length);
|
|
Sha256Final(state, out);
|
|
}
|
|
|
|
static void Sha256String(const std::string& text, unsigned char out[32]) {
|
|
Sha256(reinterpret_cast<const unsigned char*>(text.data()), text.size(), out);
|
|
}
|
|
|
|
// --- HMAC-SHA256 (RFC 2104) & PBKDF2 (RFC 8018) ----------------------------
|
|
|
|
// The padded key is derived once so that PBKDF2 does not re-hash it for every
|
|
// one of its (many) rounds.
|
|
struct HmacSha256Key {
|
|
unsigned char innerPad[64];
|
|
unsigned char outerPad[64];
|
|
};
|
|
|
|
static void PrepareHmacSha256Key(const std::string& key, HmacSha256Key& out) {
|
|
unsigned char digested[32];
|
|
const unsigned char* material = reinterpret_cast<const unsigned char*>(key.data());
|
|
size_t materialLength = key.size();
|
|
|
|
if (materialLength > sizeof(out.innerPad)) {
|
|
Sha256(material, materialLength, digested);
|
|
material = digested;
|
|
materialLength = sizeof(digested);
|
|
}
|
|
|
|
for (size_t i = 0; i < sizeof(out.innerPad); ++i) {
|
|
unsigned char byte = (i < materialLength) ? material[i] : 0x00;
|
|
out.innerPad[i] = static_cast<unsigned char>(byte ^ 0x36);
|
|
out.outerPad[i] = static_cast<unsigned char>(byte ^ 0x5c);
|
|
}
|
|
}
|
|
|
|
static void HmacSha256(const HmacSha256Key& key, const unsigned char* message,
|
|
size_t length, unsigned char out[32]) {
|
|
unsigned char inner[32];
|
|
Sha256State state;
|
|
|
|
Sha256Init(state);
|
|
Sha256Update(state, key.innerPad, sizeof(key.innerPad));
|
|
Sha256Update(state, message, length);
|
|
Sha256Final(state, inner);
|
|
|
|
Sha256Init(state);
|
|
Sha256Update(state, key.outerPad, sizeof(key.outerPad));
|
|
Sha256Update(state, inner, sizeof(inner));
|
|
Sha256Final(state, out);
|
|
}
|
|
|
|
// PBKDF2-HMAC-SHA256 with a 32 byte derived key, so exactly one output block.
|
|
static void Pbkdf2Sha256(const std::string& password,
|
|
const std::vector<unsigned char>& salt,
|
|
int iterations, unsigned char out[32]) {
|
|
HmacSha256Key key;
|
|
PrepareHmacSha256Key(password, key);
|
|
|
|
std::vector<unsigned char> firstInput(salt);
|
|
firstInput.push_back(0x00); // INT_32_BE(1)
|
|
firstInput.push_back(0x00);
|
|
firstInput.push_back(0x00);
|
|
firstInput.push_back(0x01);
|
|
|
|
unsigned char current[32];
|
|
HmacSha256(key, firstInput.data(), firstInput.size(), current);
|
|
|
|
unsigned char accumulated[32];
|
|
std::copy(current, current + sizeof(current), accumulated);
|
|
|
|
for (int round = 1; round < iterations; ++round) {
|
|
HmacSha256(key, current, sizeof(current), current);
|
|
|
|
for (size_t i = 0; i < sizeof(accumulated); ++i)
|
|
accumulated[i] = static_cast<unsigned char>(accumulated[i] ^ current[i]);
|
|
}
|
|
|
|
std::copy(accumulated, accumulated + sizeof(accumulated), out);
|
|
}
|
|
|
|
// --- small helpers ---------------------------------------------------------
|
|
|
|
static std::string HexEncode(const unsigned char* data, size_t length) {
|
|
static const char digits[] = "0123456789abcdef";
|
|
std::string out;
|
|
out.reserve(length * 2);
|
|
|
|
for (size_t i = 0; i < length; ++i) {
|
|
out += digits[(data[i] >> 4) & 0x0f];
|
|
out += digits[data[i] & 0x0f];
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
static int HexValue(char c) {
|
|
if (c >= '0' && c <= '9') return c - '0';
|
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
|
return -1;
|
|
}
|
|
|
|
static bool HexDecode(const std::string& hex, std::vector<unsigned char>& out) {
|
|
out.clear();
|
|
|
|
if (hex.empty() || (hex.size() % 2) != 0)
|
|
return false;
|
|
|
|
out.reserve(hex.size() / 2);
|
|
|
|
for (size_t i = 0; i < hex.size(); i += 2) {
|
|
int high = HexValue(hex[i]);
|
|
int low = HexValue(hex[i + 1]);
|
|
|
|
if (high < 0 || low < 0) {
|
|
out.clear();
|
|
return false;
|
|
}
|
|
|
|
out.push_back(static_cast<unsigned char>((high << 4) | low));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Compare two secrets without leaking how many characters matched.
|
|
static bool ConstantTimeEquals(const std::string& left, const std::string& right) {
|
|
if (left.size() != right.size())
|
|
return false;
|
|
|
|
unsigned char difference = 0;
|
|
for (size_t i = 0; i < left.size(); ++i)
|
|
difference |= static_cast<unsigned char>(left[i] ^ right[i]);
|
|
|
|
return difference == 0;
|
|
}
|
|
|
|
// Fill a buffer with random bytes: rand_s() on MSVC, /dev/urandom on Linux and
|
|
// std::random_device only as a last resort.
|
|
static bool RandomBytes(unsigned char* out, size_t count) {
|
|
#if defined(_MSC_VER)
|
|
size_t written = 0;
|
|
while (written < count) {
|
|
unsigned int value = 0;
|
|
if (rand_s(&value) != 0)
|
|
break;
|
|
|
|
for (int byte = 0; byte < 4 && written < count; ++byte)
|
|
out[written++] = static_cast<unsigned char>((value >> (byte * 8)) & 0xff);
|
|
}
|
|
|
|
if (written == count)
|
|
return true;
|
|
#else
|
|
std::ifstream urandom("/dev/urandom", std::ios::binary);
|
|
if (urandom.is_open()) {
|
|
urandom.read(reinterpret_cast<char*>(out), static_cast<std::streamsize>(count));
|
|
if (urandom.gcount() == static_cast<std::streamsize>(count))
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
static std::random_device fallback;
|
|
for (size_t i = 0; i < count; ++i)
|
|
out[i] = static_cast<unsigned char>(fallback() & 0xff);
|
|
|
|
return true;
|
|
}
|
|
|
|
// base64 -> bytes, lenient about padding but strict about the alphabet.
|
|
static bool Base64Decode(const std::string& input, std::string& out) {
|
|
auto value = [](unsigned char c) -> int {
|
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
|
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
|
if (c >= '0' && c <= '9') return c - '0' + 52;
|
|
if (c == '+') return 62;
|
|
if (c == '/') return 63;
|
|
return -1;
|
|
};
|
|
|
|
out.clear();
|
|
|
|
uint32_t buffer = 0;
|
|
int bits = 0;
|
|
|
|
for (unsigned char c : input) {
|
|
if (c == '=')
|
|
break;
|
|
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
|
|
continue;
|
|
|
|
int decoded = value(c);
|
|
if (decoded < 0)
|
|
return false;
|
|
|
|
buffer = (buffer << 6) | static_cast<uint32_t>(decoded);
|
|
bits += 6;
|
|
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out += static_cast<char>((buffer >> bits) & 0xff);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// `Basic dXNlcjpwYXNz` -> the name and password it carries.
|
|
static bool ParseBasicCredentials(const std::string& headerValue, std::string& outName,
|
|
std::string& outPassword) {
|
|
std::string trimmed = HttpTrim(headerValue);
|
|
if (trimmed.size() < 6 || ToLower(trimmed.substr(0, 5)) != "basic")
|
|
return false;
|
|
|
|
std::string decoded;
|
|
if (!Base64Decode(HttpTrim(trimmed.substr(5)), decoded))
|
|
return false;
|
|
|
|
size_t separator = decoded.find(':');
|
|
if (separator == std::string::npos)
|
|
return false;
|
|
|
|
outName = decoded.substr(0, separator);
|
|
outPassword = decoded.substr(separator + 1);
|
|
return true;
|
|
}
|
|
|
|
static bool ParseBasicAuthorization(const std::string& headerBlock, std::string& outName,
|
|
std::string& outPassword) {
|
|
std::string headerValue;
|
|
if (!GetHeaderValue(headerBlock, "Authorization", headerValue))
|
|
return false;
|
|
|
|
return ParseBasicCredentials(headerValue, outName, outPassword);
|
|
}
|
|
|
|
// A realm ends up inside a quoted header value, so quotes and control
|
|
// characters are dropped.
|
|
static std::string SanitizeRealm(const std::string& realm) {
|
|
std::string cleaned;
|
|
|
|
for (unsigned char c : realm) {
|
|
if (c == '"' || c == '\\' || c < 0x20 || c == 0x7f)
|
|
continue;
|
|
|
|
cleaned += static_cast<char>(c);
|
|
}
|
|
|
|
return cleaned.empty() ? std::string("Restricted") : cleaned;
|
|
}
|
|
|
|
// --- user store ------------------------------------------------------------
|
|
|
|
struct AuthUser {
|
|
std::string name;
|
|
std::string salt; // hex encoded random bytes
|
|
std::string hash; // hex encoded PBKDF2-HMAC-SHA256 output
|
|
int iterations = 0;
|
|
};
|
|
|
|
static const int kAuthMinIterations = 1000;
|
|
static const int kAuthMaxIterations = 1000000;
|
|
static const int kAuthSaltBytes = 16;
|
|
|
|
static std::vector<AuthUser> g_authUsers;
|
|
static std::string g_authFile = "users.json";
|
|
static std::string g_authRealm = "Restricted";
|
|
static int g_authIterations = 100000;
|
|
|
|
// Exact request paths and path prefixes that require credentials.
|
|
static std::vector<std::string> g_authExactPaths;
|
|
static std::vector<std::string> g_authPrefixPaths;
|
|
|
|
// Successful logins are remembered as `username -> sha256(password)`, so that
|
|
// the deliberately slow PBKDF2 work is not repeated for every single request of
|
|
// a browsing session. Cleared whenever the user list changes.
|
|
static std::unordered_map<std::string, std::string> g_authVerified;
|
|
|
|
static bool g_authLoaded = false;
|
|
static bool g_authFileBroken = false; // file exists but could not be parsed
|
|
static bool g_authWarnedMissingUsers = false;
|
|
|
|
static const AuthUser* FindAuthUser(const std::string& name) {
|
|
for (const auto& user : g_authUsers) {
|
|
if (ConstantTimeEquals(user.name, name))
|
|
return &user;
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
static std::string JsonEscape(const std::string& value) {
|
|
std::string out;
|
|
out.reserve(value.size() + 8);
|
|
|
|
for (unsigned char c : value) {
|
|
switch (c) {
|
|
case '"': out += "\\\""; break;
|
|
case '\\': out += "\\\\"; break;
|
|
case '\b': out += "\\b"; break;
|
|
case '\f': out += "\\f"; break;
|
|
case '\n': out += "\\n"; break;
|
|
case '\r': out += "\\r"; break;
|
|
case '\t': out += "\\t"; break;
|
|
default:
|
|
if (c < 0x20) {
|
|
char escape[8];
|
|
std::snprintf(escape, sizeof(escape), "\\u%04x", c);
|
|
out += escape;
|
|
}
|
|
else {
|
|
out += static_cast<char>(c);
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
static std::string SerializeAuthUsers() {
|
|
std::ostringstream out;
|
|
out << "{\n \"algorithm\": \"pbkdf2-sha256\",\n \"users\": [";
|
|
|
|
for (size_t i = 0; i < g_authUsers.size(); ++i) {
|
|
const AuthUser& user = g_authUsers[i];
|
|
|
|
out << (i == 0 ? "\n" : ",\n")
|
|
<< " {\n"
|
|
<< " \"username\": \"" << JsonEscape(user.name) << "\",\n"
|
|
<< " \"salt\": \"" << user.salt << "\",\n"
|
|
<< " \"hash\": \"" << user.hash << "\",\n"
|
|
<< " \"iterations\": " << user.iterations << "\n"
|
|
<< " }";
|
|
}
|
|
|
|
if (!g_authUsers.empty())
|
|
out << "\n ";
|
|
|
|
out << "]\n}\n";
|
|
return out.str();
|
|
}
|
|
|
|
// Write the user file through a temporary file, so an interrupted write cannot
|
|
// leave a half-written users.json behind.
|
|
static bool SaveAuthUsers() {
|
|
const std::string temporary = g_authFile + ".tmp";
|
|
|
|
if (!WriteFileBinary(temporary, SerializeAuthUsers()))
|
|
return false;
|
|
|
|
#ifndef DLANG_HTTP_WINDOWS
|
|
// The file holds password hashes: keep it readable for the owner only.
|
|
std::error_code permissionError;
|
|
std::filesystem::permissions(
|
|
temporary,
|
|
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write,
|
|
std::filesystem::perm_options::replace, permissionError);
|
|
#endif
|
|
|
|
std::error_code ec;
|
|
std::filesystem::remove(g_authFile, ec); // rename() won't overwrite on Windows
|
|
|
|
ec.clear();
|
|
std::filesystem::rename(temporary, g_authFile, ec);
|
|
|
|
if (ec) {
|
|
std::cerr << "auth: could not write '" << g_authFile << "': " << ec.message() << "\n";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
static void LoadAuthUsers(DlangVM* vm) {
|
|
g_authUsers.clear();
|
|
g_authFileBroken = false;
|
|
|
|
std::error_code ec;
|
|
if (!std::filesystem::exists(g_authFile, ec) || ec)
|
|
return; // no file yet: simply no accounts
|
|
|
|
std::string contents;
|
|
if (!ReadFileBinary(g_authFile, contents)) {
|
|
std::cerr << "auth: could not read '" << g_authFile << "'\n";
|
|
g_authFileBroken = true;
|
|
return;
|
|
}
|
|
|
|
if (contents.compare(0, 3, "\xEF\xBB\xBF") == 0) // tolerate a UTF-8 BOM
|
|
contents.erase(0, 3);
|
|
|
|
if (HttpTrim(contents).empty())
|
|
return;
|
|
|
|
StackValue decoded;
|
|
if (!DecodeJsonValue(vm, contents, decoded) || decoded.type != StackValueType::Table) {
|
|
std::cerr << "auth: '" << g_authFile << "' is not valid JSON\n";
|
|
g_authFileBroken = true;
|
|
return;
|
|
}
|
|
|
|
StackValue usersField;
|
|
if (!GetTableField(decoded, "users", usersField) || usersField.type != StackValueType::List) {
|
|
std::cerr << "auth: '" << g_authFile << "' has no \"users\" list\n";
|
|
g_authFileBroken = true;
|
|
return;
|
|
}
|
|
|
|
auto list = std::get<std::shared_ptr<DlangList>>(usersField.data);
|
|
|
|
for (const auto& entry : list->values) {
|
|
if (entry.type != StackValueType::Table)
|
|
continue;
|
|
|
|
StackValue nameValue, saltValue, hashValue, iterationsValue;
|
|
GetTableField(entry, "username", nameValue);
|
|
GetTableField(entry, "salt", saltValue);
|
|
GetTableField(entry, "hash", hashValue);
|
|
GetTableField(entry, "iterations", iterationsValue);
|
|
|
|
if (nameValue.type != StackValueType::String ||
|
|
saltValue.type != StackValueType::String ||
|
|
hashValue.type != StackValueType::String)
|
|
continue;
|
|
|
|
std::vector<unsigned char> salt;
|
|
if (!HexDecode(std::get<std::string>(saltValue.data), salt) || salt.empty())
|
|
continue;
|
|
|
|
AuthUser user;
|
|
user.name = std::get<std::string>(nameValue.data);
|
|
user.salt = std::get<std::string>(saltValue.data);
|
|
user.hash = std::get<std::string>(hashValue.data);
|
|
user.iterations = g_authIterations;
|
|
|
|
if (iterationsValue.type == StackValueType::Int) {
|
|
int iterations = static_cast<int>(std::get<double>(iterationsValue.data));
|
|
|
|
if (iterations >= kAuthMinIterations && iterations <= kAuthMaxIterations)
|
|
user.iterations = iterations;
|
|
else
|
|
std::cerr << "auth: ignoring out-of-range iteration count for '"
|
|
<< user.name << "'\n";
|
|
}
|
|
|
|
if (user.name.empty() || user.hash.empty())
|
|
continue;
|
|
|
|
g_authUsers.push_back(user);
|
|
}
|
|
}
|
|
|
|
static void EnsureAuthLoaded(DlangVM* vm) {
|
|
if (g_authLoaded)
|
|
return;
|
|
|
|
g_authLoaded = true;
|
|
LoadAuthUsers(vm);
|
|
}
|
|
|
|
static bool IsValidAuthUserName(const std::string& name) {
|
|
if (name.empty())
|
|
return false;
|
|
|
|
// A Basic credential is "name:password", so the name must not contain a
|
|
// colon, whitespace or control characters.
|
|
for (unsigned char c : name) {
|
|
if (c <= 0x20 || c == 0x7f || c == ':')
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
static bool SetAuthPassword(DlangVM* vm, const std::string& name, const std::string& password) {
|
|
if (!IsValidAuthUserName(name)) {
|
|
std::cerr << "auth.addUser: invalid user name (no ':', whitespace or control"
|
|
" characters, and it cannot be empty)\n";
|
|
return false;
|
|
}
|
|
|
|
if (password.empty()) {
|
|
std::cerr << "auth.addUser: the password cannot be empty\n";
|
|
return false;
|
|
}
|
|
|
|
EnsureAuthLoaded(vm);
|
|
|
|
if (g_authFileBroken) {
|
|
std::cerr << "auth.addUser: refusing to overwrite the unreadable '"
|
|
<< g_authFile << "'\n";
|
|
return false;
|
|
}
|
|
|
|
unsigned char salt[kAuthSaltBytes];
|
|
if (!RandomBytes(salt, sizeof(salt))) {
|
|
std::cerr << "auth.addUser: no random source available for the salt\n";
|
|
return false;
|
|
}
|
|
|
|
AuthUser user;
|
|
user.name = name;
|
|
user.salt = HexEncode(salt, sizeof(salt));
|
|
user.iterations = g_authIterations;
|
|
|
|
std::vector<unsigned char> saltBytes(salt, salt + sizeof(salt));
|
|
unsigned char derived[32];
|
|
Pbkdf2Sha256(password, saltBytes, user.iterations, derived);
|
|
user.hash = HexEncode(derived, sizeof(derived));
|
|
|
|
std::vector<AuthUser> previous = g_authUsers;
|
|
|
|
bool replaced = false;
|
|
for (auto& existing : g_authUsers) {
|
|
if (existing.name == name) {
|
|
existing = user;
|
|
replaced = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!replaced)
|
|
g_authUsers.push_back(user);
|
|
|
|
g_authVerified.clear();
|
|
|
|
if (!SaveAuthUsers()) {
|
|
g_authUsers = previous; // keep memory and disk in sync
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
static bool RemoveAuthUser(DlangVM* vm, const std::string& name) {
|
|
EnsureAuthLoaded(vm);
|
|
|
|
std::vector<AuthUser> previous = g_authUsers;
|
|
|
|
for (auto it = g_authUsers.begin(); it != g_authUsers.end(); ++it) {
|
|
if (it->name != name)
|
|
continue;
|
|
|
|
g_authUsers.erase(it);
|
|
g_authVerified.clear();
|
|
|
|
if (!SaveAuthUsers()) {
|
|
g_authUsers = previous;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool VerifyAuthPassword(DlangVM* vm, const std::string& name,
|
|
const std::string& password) {
|
|
EnsureAuthLoaded(vm);
|
|
|
|
const AuthUser* user = FindAuthUser(name);
|
|
if (user == nullptr)
|
|
return false;
|
|
|
|
// Cheap re-check for requests that already authenticated once.
|
|
unsigned char digest[32];
|
|
Sha256String(password, digest);
|
|
std::string fingerprint = HexEncode(digest, sizeof(digest));
|
|
|
|
auto cached = g_authVerified.find(user->name);
|
|
if (cached != g_authVerified.end() && ConstantTimeEquals(cached->second, fingerprint))
|
|
return true;
|
|
|
|
std::vector<unsigned char> salt;
|
|
if (!HexDecode(user->salt, salt))
|
|
return false;
|
|
|
|
unsigned char derived[32];
|
|
Pbkdf2Sha256(password, salt, user->iterations, derived);
|
|
|
|
if (!ConstantTimeEquals(HexEncode(derived, sizeof(derived)), user->hash))
|
|
return false;
|
|
|
|
g_authVerified[user->name] = fingerprint;
|
|
return true;
|
|
}
|
|
|
|
// --- which paths are protected --------------------------------------------
|
|
|
|
static std::string NormalizeAuthPath(const std::string& path) {
|
|
std::string normalized = HttpTrim(path);
|
|
if (normalized.empty())
|
|
return "/";
|
|
|
|
if (normalized.front() != '/')
|
|
normalized.insert(normalized.begin(), '/');
|
|
|
|
while (normalized.size() > 1 && normalized.back() == '/')
|
|
normalized.pop_back();
|
|
|
|
return normalized;
|
|
}
|
|
|
|
static bool AddAuthPath(std::vector<std::string>& list, const std::string& path) {
|
|
for (const auto& entry : list) {
|
|
if (entry == path)
|
|
return true;
|
|
}
|
|
|
|
list.push_back(path);
|
|
return true;
|
|
}
|
|
|
|
static bool RemoveAuthPath(std::vector<std::string>& list, const std::string& path) {
|
|
for (auto it = list.begin(); it != list.end(); ++it) {
|
|
if (*it == path) {
|
|
list.erase(it);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool AuthProtectsPath(const std::string& path) {
|
|
for (const auto& entry : g_authExactPaths) {
|
|
if (entry == path)
|
|
return true;
|
|
}
|
|
|
|
for (const auto& entry : g_authPrefixPaths) {
|
|
if (entry == "/")
|
|
return true;
|
|
if (path == entry)
|
|
return true;
|
|
if (path.size() > entry.size() &&
|
|
path.compare(0, entry.size(), entry) == 0 &&
|
|
path[entry.size()] == '/')
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// 401 + WWW-Authenticate: the header that makes a browser open its own
|
|
// username/password dialog. The body still honours server.error(401, ...).
|
|
static void SendUnauthorized(DlangVM* vm, SocketHandle clientSocket,
|
|
const std::string& method, const std::string& path) {
|
|
ErrorResponse err = RenderError(vm, 401, method, path);
|
|
|
|
std::string headers = "WWW-Authenticate: Basic realm=\"" +
|
|
SanitizeRealm(g_authRealm) + "\", charset=\"UTF-8\"\r\n";
|
|
|
|
SendResponse(clientSocket, 401, err.contentType, err.body, headers);
|
|
}
|
|
|
|
// --- auth.* natives --------------------------------------------------------
|
|
|
|
void NativeAuthFile(DlangVM* vm) {
|
|
// auth.file(path) -> the users file in use (default "users.json").
|
|
StackValue pathValue = dlang_pop(vm);
|
|
|
|
if (pathValue.type == StackValueType::String) {
|
|
std::string path = HttpTrim(std::get<std::string>(pathValue.data));
|
|
|
|
if (!path.empty() && path != g_authFile) {
|
|
g_authFile = path;
|
|
g_authLoaded = false;
|
|
g_authFileBroken = false;
|
|
g_authWarnedMissingUsers = false;
|
|
}
|
|
}
|
|
|
|
dlang_pushstring(vm, g_authFile);
|
|
}
|
|
|
|
void NativeAuthRealm(DlangVM* vm) {
|
|
// auth.realm(name) -> the realm shown in the browser's login popup.
|
|
StackValue nameValue = dlang_pop(vm);
|
|
|
|
if (nameValue.type == StackValueType::String)
|
|
g_authRealm = SanitizeRealm(std::get<std::string>(nameValue.data));
|
|
|
|
dlang_pushstring(vm, g_authRealm);
|
|
}
|
|
|
|
void NativeAuthIterations(DlangVM* vm) {
|
|
// auth.iterations(count) -> PBKDF2 rounds used for new/updated passwords.
|
|
StackValue value = dlang_pop(vm);
|
|
|
|
if (value.type == StackValueType::Int) {
|
|
double rounds = std::get<double>(value.data);
|
|
|
|
if (rounds >= kAuthMinIterations && rounds <= kAuthMaxIterations)
|
|
g_authIterations = static_cast<int>(rounds);
|
|
else
|
|
std::cerr << "auth.iterations: expected " << kAuthMinIterations << ".."
|
|
<< kAuthMaxIterations << ", keeping " << g_authIterations << "\n";
|
|
}
|
|
|
|
dlang_pushnumber(vm, g_authIterations);
|
|
}
|
|
|
|
void NativeAuthAddUser(DlangVM* vm) {
|
|
// auth.addUser(name, password) -> true when the account was stored.
|
|
StackValue passwordValue = dlang_pop(vm);
|
|
StackValue nameValue = dlang_pop(vm);
|
|
|
|
if (nameValue.type != StackValueType::String || passwordValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, SetAuthPassword(vm, std::get<std::string>(nameValue.data),
|
|
std::get<std::string>(passwordValue.data)));
|
|
}
|
|
|
|
void NativeAuthRemoveUser(DlangVM* vm) {
|
|
// auth.removeUser(name) -> true when the account was removed.
|
|
StackValue nameValue = dlang_pop(vm);
|
|
|
|
if (nameValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, RemoveAuthUser(vm, std::get<std::string>(nameValue.data)));
|
|
}
|
|
|
|
void NativeAuthHasUser(DlangVM* vm) {
|
|
// auth.hasUser(name) -> true when the account exists.
|
|
StackValue nameValue = dlang_pop(vm);
|
|
|
|
if (nameValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
EnsureAuthLoaded(vm);
|
|
dlang_pushboolean(vm, FindAuthUser(std::get<std::string>(nameValue.data)) != nullptr);
|
|
}
|
|
|
|
void NativeAuthUsers(DlangVM* vm) {
|
|
// auth.users() -> list of the known user names.
|
|
EnsureAuthLoaded(vm);
|
|
|
|
auto list = std::make_shared<DlangList>();
|
|
for (const auto& user : g_authUsers)
|
|
list->Push(StackValue(StackValueType::String, user.name));
|
|
|
|
dlang_push(vm, StackValue(StackValueType::List, list));
|
|
}
|
|
|
|
void NativeAuthVerify(DlangVM* vm) {
|
|
// auth.verify(name, password) -> true when the password matches.
|
|
StackValue passwordValue = dlang_pop(vm);
|
|
StackValue nameValue = dlang_pop(vm);
|
|
|
|
if (nameValue.type != StackValueType::String || passwordValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, VerifyAuthPassword(vm, std::get<std::string>(nameValue.data),
|
|
std::get<std::string>(passwordValue.data)));
|
|
}
|
|
|
|
void NativeAuthLogin(DlangVM* vm) {
|
|
// auth.login(req) -> the authenticated name, or "" when there are no valid
|
|
// credentials in the request headers.
|
|
StackValue request = dlang_pop(vm);
|
|
|
|
std::string headerValue;
|
|
StackValue headers;
|
|
if (GetTableField(request, "headers", headers) && headers.type == StackValueType::Table) {
|
|
StackValue authorization;
|
|
if (GetTableField(headers, "authorization", authorization) &&
|
|
authorization.type == StackValueType::String)
|
|
headerValue = std::get<std::string>(authorization.data);
|
|
}
|
|
|
|
std::string name, password;
|
|
if (!headerValue.empty() &&
|
|
ParseBasicCredentials(headerValue, name, password) &&
|
|
VerifyAuthPassword(vm, name, password)) {
|
|
dlang_pushstring(vm, name);
|
|
return;
|
|
}
|
|
|
|
dlang_pushstring(vm, "");
|
|
}
|
|
|
|
void NativeAuthReload(DlangVM* vm) {
|
|
// auth.reload() -> true when the users file was read successfully.
|
|
g_authLoaded = true;
|
|
g_authVerified.clear();
|
|
LoadAuthUsers(vm);
|
|
dlang_pushboolean(vm, !g_authFileBroken);
|
|
}
|
|
|
|
void NativeAuthProtect(DlangVM* vm) {
|
|
// auth.protect(path) -> require credentials for exactly this path.
|
|
StackValue pathValue = dlang_pop(vm);
|
|
|
|
if (pathValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, AddAuthPath(g_authExactPaths,
|
|
NormalizeAuthPath(std::get<std::string>(pathValue.data))));
|
|
}
|
|
|
|
void NativeAuthProtectPrefix(DlangVM* vm) {
|
|
// auth.protectPrefix(path) -> require credentials for this path and below.
|
|
StackValue pathValue = dlang_pop(vm);
|
|
|
|
if (pathValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, AddAuthPath(g_authPrefixPaths,
|
|
NormalizeAuthPath(std::get<std::string>(pathValue.data))));
|
|
}
|
|
|
|
void NativeAuthUnprotect(DlangVM* vm) {
|
|
// auth.unprotect(path) -> drop an exact-path protection again.
|
|
StackValue pathValue = dlang_pop(vm);
|
|
|
|
if (pathValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, RemoveAuthPath(g_authExactPaths,
|
|
NormalizeAuthPath(std::get<std::string>(pathValue.data))));
|
|
}
|
|
|
|
void NativeAuthUnprotectPrefix(DlangVM* vm) {
|
|
// auth.unprotectPrefix(path) -> drop a prefix protection again.
|
|
StackValue pathValue = dlang_pop(vm);
|
|
|
|
if (pathValue.type != StackValueType::String) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
dlang_pushboolean(vm, RemoveAuthPath(g_authPrefixPaths,
|
|
NormalizeAuthPath(std::get<std::string>(pathValue.data))));
|
|
}
|
|
|
|
// Read a full HTTP request: the header block plus the body described by
|
|
// Content-Length. `headerEnd` is set to the offset of the blank line that
|
|
// terminates the headers. Returns false when no complete header block arrived.
|
|
// `outTooLarge` reports a body bigger than `g_maxBodyBytes`, in which case the
|
|
// request is refused before the whole body has been buffered.
|
|
static bool ReadRequest(SocketHandle sock, std::string& raw, size_t& headerEnd,
|
|
bool& outTooLarge) {
|
|
raw.clear();
|
|
headerEnd = std::string::npos;
|
|
outTooLarge = false;
|
|
|
|
char buffer[4096];
|
|
size_t contentLength = 0;
|
|
bool hasContentLength = false;
|
|
|
|
while (true) {
|
|
int bytes = recv(sock, buffer, sizeof(buffer), 0);
|
|
if (bytes <= 0)
|
|
break;
|
|
|
|
raw.append(buffer, static_cast<size_t>(bytes));
|
|
|
|
if (headerEnd == std::string::npos) {
|
|
headerEnd = raw.find("\r\n\r\n");
|
|
if (headerEnd != std::string::npos) {
|
|
std::string lengthValue;
|
|
if (GetHeaderValue(raw.substr(0, headerEnd), "Content-Length", lengthValue)) {
|
|
hasContentLength = true;
|
|
contentLength = static_cast<size_t>(
|
|
std::strtoul(lengthValue.c_str(), nullptr, 10));
|
|
|
|
// An announced oversized body is refused up front instead of
|
|
// being buffered in full.
|
|
if (contentLength > g_maxBodyBytes) {
|
|
outTooLarge = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (headerEnd != std::string::npos) {
|
|
size_t bodyStart = headerEnd + 4;
|
|
if (!hasContentLength || raw.size() >= bodyStart + contentLength)
|
|
break;
|
|
}
|
|
|
|
// Defensive cap so a misbehaving client cannot exhaust memory, for
|
|
// requests that understate their size as well as endless header blocks.
|
|
if (raw.size() > g_maxBodyBytes + 64u * 1024u) {
|
|
outTooLarge = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return headerEnd != std::string::npos;
|
|
}
|
|
|
|
void NativeMaxBody(DlangVM* vm) {
|
|
// server.maxBody(bytes) -> change the request body limit.
|
|
StackValue value = dlang_pop(vm);
|
|
|
|
if (value.type != StackValueType::Int)
|
|
return;
|
|
|
|
double bytes = std::get<double>(value.data);
|
|
if (bytes > 0)
|
|
g_maxBodyBytes = static_cast<size_t>(bytes);
|
|
}
|
|
|
|
// Handle one fully read request: parse the request line, build the parameter
|
|
// tables and dispatch to a route, a static file or an error page.
|
|
static void HandleRequest(DlangVM* vm, SocketHandle clientSocket,
|
|
const std::string& headerBlock, const std::string& body,
|
|
bool bodyTooLarge) {
|
|
std::istringstream stream(headerBlock);
|
|
std::string method, fullPath, version;
|
|
|
|
if (!(stream >> method >> fullPath >> version))
|
|
return;
|
|
|
|
size_t qPos = fullPath.find('?');
|
|
std::string path = (qPos != std::string::npos) ? fullPath.substr(0, qPos) : fullPath;
|
|
std::string query = (qPos != std::string::npos) ? fullPath.substr(qPos + 1) : std::string();
|
|
|
|
// HTTP Basic auth gate: only the paths registered through auth.protect(...)
|
|
// or auth.protectPrefix(...) need credentials, everything else stays open.
|
|
// Without valid credentials the request is refused here, before a route
|
|
// handler or a static file is even looked up.
|
|
std::string authenticatedUser;
|
|
|
|
if (AuthProtectsPath(path)) {
|
|
EnsureAuthLoaded(vm);
|
|
|
|
if (g_authUsers.empty() && !g_authWarnedMissingUsers) {
|
|
g_authWarnedMissingUsers = true;
|
|
std::cerr << "auth: no accounts in '" << g_authFile << "' yet - create one with "
|
|
<< "auth.addUser(\"name\", \"password\") before listen().\n";
|
|
}
|
|
|
|
std::string basicName, basicPassword;
|
|
if (ParseBasicAuthorization(headerBlock, basicName, basicPassword) &&
|
|
VerifyAuthPassword(vm, basicName, basicPassword)) {
|
|
authenticatedUser = basicName;
|
|
}
|
|
else {
|
|
SendUnauthorized(vm, clientSocket, method, path);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// An oversized upload is refused before anything else is parsed.
|
|
if (bodyTooLarge) {
|
|
ErrorResponse err = RenderError(vm, 413, method, path);
|
|
SendResponse(clientSocket, 413, err.contentType, err.body);
|
|
return;
|
|
}
|
|
|
|
// Every request parameter (query string + body) goes into a single key/value
|
|
// table, uploaded files into another, and the Cookie header into a third.
|
|
auto paramsTable = std::make_shared<DlangTable>();
|
|
auto filesTable = std::make_shared<DlangTable>();
|
|
ParseUrlEncoded(query, *paramsTable);
|
|
|
|
if (!body.empty()) {
|
|
std::string contentType;
|
|
bool hasContentType = GetHeaderValue(headerBlock, "Content-Type", contentType);
|
|
|
|
std::string boundary;
|
|
bool isMultipart = hasContentType &&
|
|
ToLower(contentType).find("multipart/form-data") != std::string::npos &&
|
|
GetMultipartBoundary(contentType, boundary);
|
|
|
|
if (isMultipart)
|
|
ParseMultipartBody(body, boundary, *paramsTable, *filesTable);
|
|
else if (hasContentType && ToLower(contentType).find("json") != std::string::npos)
|
|
MergeJsonBody(vm, body, *paramsTable);
|
|
else
|
|
ParseUrlEncoded(body, *paramsTable);
|
|
}
|
|
|
|
auto cookiesTable = std::make_shared<DlangTable>();
|
|
std::string cookieHeader;
|
|
if (GetHeaderValue(headerBlock, "Cookie", cookieHeader))
|
|
ParseCookies(cookieHeader, *cookiesTable);
|
|
|
|
auto headersTable = std::make_shared<DlangTable>();
|
|
ParseHeaders(headerBlock, *headersTable);
|
|
|
|
StackValue paramsValue(StackValueType::Table, paramsTable);
|
|
StackValue cookiesValue(StackValueType::Table, cookiesTable);
|
|
StackValue filesValue(StackValueType::Table, filesTable);
|
|
StackValue headersValue(StackValueType::Table, headersTable);
|
|
|
|
const RouteHandler* matched = nullptr;
|
|
bool pathMatched = false;
|
|
|
|
for (const auto& route : g_routes) {
|
|
if (route.path != path)
|
|
continue;
|
|
|
|
pathMatched = true;
|
|
auto it = std::find(route.methods.begin(), route.methods.end(), method);
|
|
if (it != route.methods.end()) {
|
|
matched = &route;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (matched) {
|
|
int base = vm->stack.top();
|
|
|
|
try {
|
|
// arg 1: req = { method, path, query, params, cookies, files, body }
|
|
dlang_newtable(vm);
|
|
|
|
dlang_pushstring(vm, method.c_str());
|
|
dlang_setfield(vm, "method");
|
|
|
|
dlang_pushstring(vm, path.c_str());
|
|
dlang_setfield(vm, "path");
|
|
|
|
if (!query.empty()) {
|
|
dlang_pushstring(vm, query.c_str());
|
|
dlang_setfield(vm, "query");
|
|
}
|
|
|
|
if (!body.empty()) {
|
|
dlang_pushstring(vm, body);
|
|
dlang_setfield(vm, "body");
|
|
}
|
|
|
|
dlang_push(vm, paramsValue);
|
|
dlang_setfield(vm, "params");
|
|
|
|
dlang_push(vm, cookiesValue);
|
|
dlang_setfield(vm, "cookies");
|
|
|
|
dlang_push(vm, filesValue);
|
|
dlang_setfield(vm, "files");
|
|
|
|
// All request headers, names lower-cased; repeated headers become a list.
|
|
dlang_push(vm, headersValue);
|
|
dlang_setfield(vm, "headers");
|
|
|
|
// Only set for a request that passed the auth gate (or that carried
|
|
// valid credentials for a path protected with auth.protect...).
|
|
if (!authenticatedUser.empty()) {
|
|
dlang_pushstring(vm, authenticatedUser.c_str());
|
|
dlang_setfield(vm, "user");
|
|
}
|
|
|
|
// arg 2: params, arg 3: cookies, arg 4: files
|
|
dlang_push(vm, paramsValue);
|
|
dlang_push(vm, cookiesValue);
|
|
dlang_push(vm, filesValue);
|
|
|
|
dlang_push(vm, matched->functionVal);
|
|
if (!dlang_call(vm, 4))
|
|
throw std::runtime_error("Route handler could not be called");
|
|
|
|
// CallFunc leaves the handler's return value on top of the stack;
|
|
// inspect it directly instead of using the (now stack-consuming)
|
|
// field opcodes.
|
|
StackValue returned = dlang_pop(vm);
|
|
|
|
std::string responseBody;
|
|
std::string contentType;
|
|
int statusCode = 200;
|
|
ExtractResponse(vm, returned, responseBody, contentType, statusCode);
|
|
|
|
ResetStack(vm, base);
|
|
SendResponse(clientSocket, statusCode, contentType, responseBody);
|
|
}
|
|
catch (...) {
|
|
ResetStack(vm, base);
|
|
ErrorResponse err = RenderError(vm, 500, method, path);
|
|
SendResponse(clientSocket, 500, err.contentType, err.body);
|
|
}
|
|
}
|
|
else {
|
|
std::string staticBody, staticType;
|
|
|
|
if (!pathMatched && TryResolveStatic(path, staticBody, staticType)) {
|
|
SendResponse(clientSocket, 200, staticType, staticBody);
|
|
}
|
|
else {
|
|
int errorCode = pathMatched ? 405 : 404;
|
|
ErrorResponse err = RenderError(vm, errorCode, method, path);
|
|
SendResponse(clientSocket, errorCode, err.contentType, err.body);
|
|
}
|
|
}
|
|
}
|
|
|
|
void NativeListen(DlangVM* vm) {
|
|
int portInt = static_cast<int>(dlang_tonumber(vm));
|
|
|
|
if (g_serverSocket != DLANG_INVALID_SOCKET) {
|
|
DLANG_CLOSE_SOCKET(g_serverSocket);
|
|
}
|
|
|
|
g_serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
|
if (g_serverSocket == DLANG_INVALID_SOCKET) {
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
SetBlocking(g_serverSocket, false);
|
|
|
|
sockaddr_in service{};
|
|
service.sin_family = AF_INET;
|
|
service.sin_addr.s_addr = INADDR_ANY;
|
|
service.sin_port = htons(static_cast<uint16_t>(portInt));
|
|
|
|
if (bind(g_serverSocket, (SockAddr*)&service, sizeof(service)) == DLANG_SOCKET_ERROR ||
|
|
listen(g_serverSocket, SOMAXCONN) == DLANG_SOCKET_ERROR) {
|
|
DLANG_CLOSE_SOCKET(g_serverSocket);
|
|
g_serverSocket = DLANG_INVALID_SOCKET;
|
|
dlang_pushboolean(vm, false);
|
|
return;
|
|
}
|
|
|
|
while (true) {
|
|
SocketHandle clientSocket = accept(g_serverSocket, NULL, NULL);
|
|
|
|
if (clientSocket != DLANG_INVALID_SOCKET) {
|
|
SetBlocking(clientSocket, true);
|
|
|
|
int flag = 1;
|
|
setsockopt(clientSocket, IPPROTO_TCP, TCP_NODELAY,
|
|
reinterpret_cast<const char*>(&flag), sizeof(flag));
|
|
|
|
std::string rawRequest;
|
|
size_t headerEnd = std::string::npos;
|
|
bool bodyTooLarge = false;
|
|
|
|
if (ReadRequest(clientSocket, rawRequest, headerEnd, bodyTooLarge)) {
|
|
std::string headerBlock = rawRequest.substr(0, headerEnd);
|
|
std::string body = (headerEnd + 4 < rawRequest.size())
|
|
? rawRequest.substr(headerEnd + 4)
|
|
: std::string();
|
|
|
|
HandleRequest(vm, clientSocket, headerBlock, body, bodyTooLarge);
|
|
}
|
|
|
|
shutdown(clientSocket, DLANG_SHUT_WRITE);
|
|
|
|
char dummy[256];
|
|
while (recv(clientSocket, dummy, sizeof(dummy), 0) > 0) {
|
|
}
|
|
|
|
DLANG_CLOSE_SOCKET(clientSocket);
|
|
} else {
|
|
SleepMs(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
void NativeCreate(DlangVM* vm)
|
|
{
|
|
#ifdef DLANG_HTTP_WINDOWS
|
|
WSADATA wsaData;
|
|
WSAStartup(MAKEWORD(2, 2), &wsaData);
|
|
#endif
|
|
|
|
dlang_newtable(vm);
|
|
|
|
dlang_pushcfunction(vm, NativeListen);
|
|
dlang_setfield(vm, "listen");
|
|
|
|
dlang_pushcfunction(vm, NativeRoute);
|
|
dlang_setfield(vm, "route");
|
|
|
|
dlang_pushcfunction(vm, NativeStatic);
|
|
dlang_setfield(vm, "static");
|
|
|
|
dlang_pushcfunction(vm, NativeError);
|
|
dlang_setfield(vm, "error");
|
|
|
|
dlang_pushcfunction(vm, NativeMaxBody);
|
|
dlang_setfield(vm, "maxBody");
|
|
}
|
|
|
|
extern "C" {
|
|
DLANG_API void initialize(DlangVM* vm) {
|
|
if (!vm) return;
|
|
|
|
dlang_newtable(vm);
|
|
|
|
dlang_pushcfunction(vm, NativeCreate);
|
|
dlang_setfield(vm, "server");
|
|
|
|
// http.save(upload, path) and http.saveTo(upload, folder) write an upload
|
|
// to disk. They hang off the `http` table rather than a separate `files`
|
|
// global: a route handler's 4th parameter is conventionally called
|
|
// `files` and would shadow a global with that name.
|
|
dlang_pushcfunction(vm, NativeFilesSave);
|
|
dlang_setfield(vm, "save");
|
|
|
|
dlang_pushcfunction(vm, NativeFilesSaveTo);
|
|
dlang_setfield(vm, "saveTo");
|
|
|
|
dlang_setglobal(vm, "http");
|
|
|
|
// view.Render(path, variables) -> rendered template string.
|
|
dlang_newtable(vm);
|
|
|
|
dlang_pushcfunction(vm, NativeViewRender);
|
|
dlang_setfield(vm, "Render");
|
|
|
|
dlang_pushcfunction(vm, NativeViewRender);
|
|
dlang_setfield(vm, "render");
|
|
|
|
dlang_setglobal(vm, "view");
|
|
|
|
// auth.* — HTTP Basic authentication: the user store (`users.json` with
|
|
// PBKDF2-hashed passwords), the login realm and the list of protected
|
|
// paths. See the auth module above.
|
|
dlang_newtable(vm);
|
|
|
|
dlang_pushcfunction(vm, NativeAuthFile);
|
|
dlang_setfield(vm, "file");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthRealm);
|
|
dlang_setfield(vm, "realm");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthIterations);
|
|
dlang_setfield(vm, "iterations");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthAddUser);
|
|
dlang_setfield(vm, "addUser");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthRemoveUser);
|
|
dlang_setfield(vm, "removeUser");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthHasUser);
|
|
dlang_setfield(vm, "hasUser");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthUsers);
|
|
dlang_setfield(vm, "users");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthVerify);
|
|
dlang_setfield(vm, "verify");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthLogin);
|
|
dlang_setfield(vm, "login");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthReload);
|
|
dlang_setfield(vm, "reload");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthProtect);
|
|
dlang_setfield(vm, "protect");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthProtectPrefix);
|
|
dlang_setfield(vm, "protectPrefix");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthUnprotect);
|
|
dlang_setfield(vm, "unprotect");
|
|
|
|
dlang_pushcfunction(vm, NativeAuthUnprotectPrefix);
|
|
dlang_setfield(vm, "unprotectPrefix");
|
|
|
|
dlang_setglobal(vm, "auth");
|
|
}
|
|
} |