mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
73
dWeb/HTTPContext.h
Normal file
73
dWeb/HTTPContext.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <algorithm>
|
||||
#include "eHTTPStatusCode.h"
|
||||
#include "json.hpp"
|
||||
|
||||
/**
|
||||
* HTTP Request Context
|
||||
*
|
||||
* Carries all request metadata through the middleware chain.
|
||||
* Populated by the Web framework before middleware/handlers are called.
|
||||
*/
|
||||
struct HTTPContext {
|
||||
// Request metadata
|
||||
std::string method{};
|
||||
std::string path{}; // lowercased: routes match without regard to case
|
||||
std::string originalPath{}; // as sent, for parameters whose case matters (file names)
|
||||
std::string queryString{};
|
||||
std::string body{};
|
||||
|
||||
// Request headers (header name -> value)
|
||||
// Header names are lowercase for case-insensitive lookup
|
||||
std::map<std::string, std::string> headers{};
|
||||
|
||||
// Client information
|
||||
std::string clientIP{};
|
||||
|
||||
// Authentication information (populated by auth middleware)
|
||||
bool isAuthenticated = false;
|
||||
std::string authenticatedUser{};
|
||||
uint32_t accountId = 0;
|
||||
uint8_t gmLevel = 0;
|
||||
|
||||
// Custom data for middleware to communicate
|
||||
std::map<std::string, std::string> userData{};
|
||||
|
||||
/**
|
||||
* Get header value (case-insensitive)
|
||||
*/
|
||||
const std::string& GetHeader(const std::string& headerName) const {
|
||||
static const std::string empty{};
|
||||
|
||||
// Convert to lowercase for comparison
|
||||
std::string lowerName = headerName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
|
||||
|
||||
const auto it = headers.find(lowerName);
|
||||
return it != headers.end() ? it->second : empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set header value (automatically lowercased)
|
||||
*/
|
||||
void SetHeader(const std::string& headerName, const std::string& value) {
|
||||
std::string lowerName = headerName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
|
||||
headers[lowerName] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data as JSON for template rendering
|
||||
*/
|
||||
nlohmann::json GetUserDataJson() const {
|
||||
nlohmann::json userData;
|
||||
userData["username"] = authenticatedUser;
|
||||
userData["gmLevel"] = gmLevel;
|
||||
userData["accountId"] = accountId;
|
||||
return userData;
|
||||
}
|
||||
};
|
||||
38
dWeb/IHTTPMiddleware.h
Normal file
38
dWeb/IHTTPMiddleware.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include "HTTPContext.h"
|
||||
|
||||
// Forward declaration
|
||||
struct HTTPReply;
|
||||
|
||||
/**
|
||||
* Middleware Interface
|
||||
*
|
||||
* All middleware implements this interface and is called in order during request processing.
|
||||
* Middleware can:
|
||||
* - Inspect and modify the request (HTTPContext)
|
||||
* - Populate authentication/authorization info
|
||||
* - Short-circuit the chain by setting a reply and returning false
|
||||
* - Pass to the next middleware by returning true
|
||||
*/
|
||||
class IHTTPMiddleware {
|
||||
public:
|
||||
virtual ~IHTTPMiddleware() = default;
|
||||
|
||||
/**
|
||||
* Process the request through this middleware
|
||||
*
|
||||
* @param context The HTTP request context
|
||||
* @param reply The HTTP reply (can be populated to short-circuit)
|
||||
* @return true to continue to next middleware, false to stop processing
|
||||
*/
|
||||
virtual bool Process(HTTPContext& context, HTTPReply& reply) = 0;
|
||||
|
||||
/**
|
||||
* Get a friendly name for this middleware
|
||||
*/
|
||||
virtual std::string GetName() const = 0;
|
||||
};
|
||||
|
||||
using MiddlewarePtr = std::shared_ptr<IHTTPMiddleware>;
|
||||
489
dWeb/Web.cpp
489
dWeb/Web.cpp
@@ -6,28 +6,178 @@
|
||||
#include "eHTTPMethod.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "JSONUtils.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "IHTTPMiddleware.h"
|
||||
#include <ranges>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
|
||||
namespace Game {
|
||||
Web web;
|
||||
}
|
||||
|
||||
namespace {
|
||||
const char* jsonContentType = "Content-Type: application/json\r\n";
|
||||
const std::string wsSubscribed = "{\"status\":\"subscribed\"}";
|
||||
const std::string wsUnsubscribed = "{\"status\":\"unsubscribed\"}";
|
||||
std::map<std::pair<eHTTPMethod, std::string>, HTTPRoute> g_HTTPRoutes;
|
||||
std::map<std::string, WSEvent> g_WSEvents;
|
||||
std::vector<std::string> g_WSSubscriptions;
|
||||
// Minimum permission level per subscription, parallel to g_WSSubscriptions
|
||||
std::vector<std::function<uint8_t()>> g_WSSubscriptionLevels;
|
||||
// Authenticated WebSocket connections: their permission level, account and the token they connected with.
|
||||
// Entries are removed on MG_EV_CLOSE so a reused connection address is never treated as authenticated.
|
||||
struct WSClient {
|
||||
uint8_t level{};
|
||||
uint32_t accountId{};
|
||||
std::string token; // empty for trusted internal connections, which are never rechecked
|
||||
bool apiToken{}; // connected with Authorization: Bearer (subject to the API access rule)
|
||||
std::chrono::steady_clock::time_point nextCheck;
|
||||
};
|
||||
std::map<mg_connection*, WSClient> g_AuthenticatedWSConnections;
|
||||
constexpr uint8_t INTERNAL_WS_LEVEL = UINT8_MAX;
|
||||
constexpr auto WS_RECHECK_INTERVAL = std::chrono::seconds(60);
|
||||
|
||||
// Close a WebSocket whose session is no longer valid (logged out everywhere, banned, demoted below dashboard access)
|
||||
void CloseWebSocket(mg_connection* connection) {
|
||||
static const std::string ended = "{\"event\":\"session_ended\"}";
|
||||
mg_ws_send(connection, ended.c_str(), ended.size(), WEBSOCKET_OP_TEXT);
|
||||
mg_ws_send(connection, "", 0, WEBSOCKET_OP_CLOSE);
|
||||
connection->is_draining = 1;
|
||||
g_AuthenticatedWSConnections.erase(connection);
|
||||
}
|
||||
|
||||
void RecheckDueWebSockets() {
|
||||
const auto& callback = Game::web.GetWSAuthCallback();
|
||||
if (!callback) return;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::vector<mg_connection*> expired;
|
||||
for (auto& [connection, client] : g_AuthenticatedWSConnections) {
|
||||
if (client.token.empty() || client.nextCheck > now) continue;
|
||||
client.nextCheck = now + WS_RECHECK_INTERVAL;
|
||||
auto auth = callback(client.token);
|
||||
if (auth && client.apiToken && Game::web.GetWSApiAccessCallback() && !Game::web.GetWSApiAccessCallback()(auth->level)) auth.reset();
|
||||
if (!auth || auth->accountId != client.accountId) {
|
||||
expired.push_back(connection);
|
||||
continue;
|
||||
}
|
||||
client.level = auth->level;
|
||||
}
|
||||
for (auto* connection : expired) {
|
||||
LOG_DEBUG("Closing a WebSocket whose session is no longer valid");
|
||||
CloseWebSocket(connection);
|
||||
}
|
||||
}
|
||||
|
||||
// Global middleware applied to all routes
|
||||
std::vector<MiddlewarePtr> g_GlobalMiddleware;
|
||||
|
||||
// Helper to extract client IP from mongoose connection
|
||||
static std::string GetClientIP(mg_connection* connection) {
|
||||
if (!connection) return "unknown";
|
||||
|
||||
const uint8_t* ip = connection->rem.ip;
|
||||
|
||||
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
|
||||
if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 &&
|
||||
ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 &&
|
||||
ip[8] == 0 && ip[9] == 0 && ip[10] == 0xff && ip[11] == 0xff) {
|
||||
// IPv4 address is in bytes 12-15
|
||||
char buffer[32]{};
|
||||
snprintf(buffer, sizeof(buffer), "%d.%d.%d.%d",
|
||||
ip[12], ip[13], ip[14], ip[15]);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Direct IPv4
|
||||
char buffer[32]{};
|
||||
snprintf(buffer, sizeof(buffer), "%d.%d.%d.%d",
|
||||
ip[0], ip[1], ip[2], ip[3]);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Helper to populate HTTPContext from mg_http_message
|
||||
static void PopulateHTTPContext(HTTPContext& context,
|
||||
const mg_http_message* http_msg,
|
||||
mg_connection* connection) {
|
||||
// Parse method
|
||||
context.method = std::string(http_msg->method.buf, http_msg->method.len);
|
||||
|
||||
// Paths are matched case-insensitively. mongoose keeps the query string separate from the URI;
|
||||
// it is not lowercased because parameter values (search terms) are case-sensitive.
|
||||
std::string uri(http_msg->uri.buf, http_msg->uri.len);
|
||||
context.originalPath = uri;
|
||||
std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower);
|
||||
context.path = uri;
|
||||
context.queryString = std::string(http_msg->query.buf, http_msg->query.len);
|
||||
|
||||
// Parse body
|
||||
context.body = std::string(http_msg->body.buf, http_msg->body.len);
|
||||
|
||||
// Copy every request header; HTTPContext lowercases names for case-insensitive lookup
|
||||
for (const auto& header : http_msg->headers) {
|
||||
if (header.name.len == 0) break;
|
||||
context.SetHeader(std::string(header.name.buf, header.name.len), std::string(header.value.buf, header.value.len));
|
||||
}
|
||||
|
||||
|
||||
// Get client IP
|
||||
context.clientIP = GetClientIP(connection);
|
||||
}
|
||||
|
||||
const char* ContentTypeToString(eContentType contentType) {
|
||||
switch (contentType) {
|
||||
case eContentType::APPLICATION_JSON:
|
||||
return "application/json";
|
||||
case eContentType::TEXT_HTML:
|
||||
return "text/html; charset=utf-8";
|
||||
case eContentType::TEXT_CSS:
|
||||
return "text/css; charset=utf-8";
|
||||
case eContentType::TEXT_JAVASCRIPT:
|
||||
return "application/javascript; charset=utf-8";
|
||||
case eContentType::TEXT_PLAIN:
|
||||
return "text/plain; charset=utf-8";
|
||||
case eContentType::TEXT_CSV:
|
||||
return "text/csv; charset=utf-8";
|
||||
case eContentType::IMAGE_PNG:
|
||||
return "image/png";
|
||||
case eContentType::IMAGE_JPEG:
|
||||
return "image/jpeg";
|
||||
case eContentType::APPLICATION_OCTET_STREAM:
|
||||
return "application/octet-stream";
|
||||
case eContentType::TEXT_PROMETHEUS:
|
||||
return "text/plain; version=0.0.4; charset=utf-8";
|
||||
default:
|
||||
return "application/json";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
bool ValidateAuthentication(const mg_http_message* http_msg) {
|
||||
// TO DO: This is just a placeholder for now
|
||||
// use tokens or something at a later point if we want to implement authentication
|
||||
// bit using the listen bind address to limit external access is good enough to start with
|
||||
return true;
|
||||
namespace {
|
||||
const char* ReasonPhrase(int status) {
|
||||
switch (status) {
|
||||
case 200: return "OK";
|
||||
case 201: return "Created";
|
||||
case 204: return "No Content";
|
||||
case 301: return "Moved Permanently";
|
||||
case 302: return "Found";
|
||||
case 304: return "Not Modified";
|
||||
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 409: return "Conflict";
|
||||
case 413: return "Payload Too Large";
|
||||
case 429: return "Too Many Requests";
|
||||
case 500: return "Internal Server Error";
|
||||
case 503: return "Service Unavailable";
|
||||
default: return status < 400 ? "OK" : "Error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_msg) {
|
||||
@@ -38,46 +188,265 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
|
||||
if (!http_msg) {
|
||||
reply.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
reply.message = "{\"error\":\"Invalid Request\"}";
|
||||
} else if (ValidateAuthentication(http_msg)) {
|
||||
|
||||
// convert method from cstring to std string
|
||||
} else {
|
||||
// All authentication is now handled by middleware chain
|
||||
// Convert method from cstring to enum
|
||||
std::string method_string(http_msg->method.buf, http_msg->method.len);
|
||||
// get method from mg to enum
|
||||
const eHTTPMethod method = magic_enum::enum_cast<eHTTPMethod>(method_string).value_or(eHTTPMethod::INVALID);
|
||||
|
||||
// convert uri from cstring to std string
|
||||
// Extract URI and convert to lowercase
|
||||
std::string uri(http_msg->uri.buf, http_msg->uri.len);
|
||||
std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower);
|
||||
|
||||
// convert body from cstring to std string
|
||||
std::string body(http_msg->body.buf, http_msg->body.len);
|
||||
|
||||
// Special case for websocket
|
||||
if (uri == "/ws" && method == eHTTPMethod::GET) {
|
||||
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
|
||||
LOG_DEBUG("Upgraded connection to websocket: %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
|
||||
// return cause they are now a websocket
|
||||
// Check if connection is from localhost/internal network
|
||||
bool isInternal = false;
|
||||
const uint8_t* ip = connection->rem.ip;
|
||||
|
||||
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
|
||||
if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 &&
|
||||
ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 &&
|
||||
ip[8] == 0 && ip[9] == 0 && ip[10] == 0xff && ip[11] == 0xff) {
|
||||
// IPv4 address is in bytes 12-15
|
||||
uint8_t b1 = ip[12];
|
||||
uint8_t b2 = ip[13];
|
||||
|
||||
// Check for 127.x.x.x (localhost)
|
||||
if (b1 == 127) {
|
||||
isInternal = true;
|
||||
}
|
||||
// Check for 192.168.x.x
|
||||
else if (b1 == 192 && b2 == 168) {
|
||||
isInternal = true;
|
||||
}
|
||||
// Check for 10.x.x.x
|
||||
else if (b1 == 10) {
|
||||
isInternal = true;
|
||||
}
|
||||
// Check for 172.16.x.x to 172.31.x.x
|
||||
else if (b1 == 172 && b2 >= 16 && b2 <= 31) {
|
||||
isInternal = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Internal connections are only trusted when the server has no token authentication.
|
||||
// With authentication configured a reverse proxy would make every client look internal.
|
||||
std::optional<WSAuth> level;
|
||||
std::string connectToken;
|
||||
bool apiToken = false;
|
||||
if (!Game::web.GetWSAuthCallback()) {
|
||||
if (isInternal) level = WSAuth{ INTERNAL_WS_LEVEL, 0 };
|
||||
} else if (const auto* authHeader = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Authorization");
|
||||
authHeader && std::string_view(authHeader->buf, authHeader->len).starts_with("Bearer ")) {
|
||||
// Bots and scripts: an API token, like the REST API takes it (and subject to the same API access rule)
|
||||
const std::string token(authHeader->buf + 7, authHeader->len - 7);
|
||||
level = Game::web.GetWSAuthCallback()(token);
|
||||
if (level && Game::web.GetWSApiAccessCallback() && !Game::web.GetWSApiAccessCallback()(level->level)) level.reset();
|
||||
connectToken = token;
|
||||
apiToken = true;
|
||||
} else {
|
||||
const auto* cookieHeader = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Cookie");
|
||||
if (cookieHeader) {
|
||||
std::string cookieStr = std::string(cookieHeader->buf, cookieHeader->len);
|
||||
|
||||
// Extract token from cookie
|
||||
const std::string tokenPrefix = "dashboardToken=";
|
||||
const size_t tokenPos = cookieStr.find(tokenPrefix);
|
||||
|
||||
if (tokenPos != std::string::npos) {
|
||||
size_t valueStart = tokenPos + tokenPrefix.length();
|
||||
size_t valueEnd = cookieStr.find(";", valueStart);
|
||||
|
||||
if (valueEnd == std::string::npos) {
|
||||
valueEnd = cookieStr.length();
|
||||
}
|
||||
|
||||
std::string token = cookieStr.substr(valueStart, valueEnd - valueStart);
|
||||
|
||||
level = Game::web.GetWSAuthCallback()(token);
|
||||
connectToken = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (level) {
|
||||
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
|
||||
g_AuthenticatedWSConnections[connection] = { level->level, level->accountId, connectToken, apiToken,
|
||||
std::chrono::steady_clock::now() + WS_RECHECK_INTERVAL };
|
||||
const char* connType = isInternal ? "internal" : "external";
|
||||
LOG_DEBUG("Upgraded %s connection to websocket: %d.%d.%d.%d:%i", connType, MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
|
||||
} else {
|
||||
LOG_DEBUG("Rejected WebSocket connection - no valid authentication from %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"error\":\"Unauthorized\"}";
|
||||
std::string headers = std::string("Content-Type: ") + ContentTypeToString(reply.contentType) + "\r\n";
|
||||
if (!reply.location.empty()) {
|
||||
headers += "Location: " + reply.location + "\r\n";
|
||||
}
|
||||
mg_http_reply(connection, static_cast<int>(reply.status), headers.c_str(), reply.message.c_str());
|
||||
}
|
||||
// return cause they are now a websocket or connection closed
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle HTTP request
|
||||
const auto routeItr = g_HTTPRoutes.find({method, uri});
|
||||
auto routeItr = g_HTTPRoutes.find({method, uri});
|
||||
|
||||
// If exact match not found, try pattern matching with :param syntax
|
||||
if (routeItr == g_HTTPRoutes.end()) {
|
||||
for (const auto& [key, route] : g_HTTPRoutes) {
|
||||
if (key.first != method) continue;
|
||||
|
||||
const std::string& pattern = key.second;
|
||||
// Simple pattern matching for :param syntax
|
||||
if (pattern.find(':') != std::string::npos) {
|
||||
// Split by '/' and compare segments
|
||||
std::vector<std::string> patternSegments;
|
||||
std::vector<std::string> uriSegments;
|
||||
|
||||
size_t pos = 0;
|
||||
const std::string& str = pattern;
|
||||
while (pos < str.length()) {
|
||||
size_t slash = str.find('/', pos);
|
||||
if (slash == std::string::npos) slash = str.length();
|
||||
if (slash > pos) { // Skip empty segments
|
||||
patternSegments.push_back(str.substr(pos, slash - pos));
|
||||
}
|
||||
pos = slash + 1;
|
||||
}
|
||||
|
||||
pos = 0;
|
||||
while (pos < uri.length()) {
|
||||
size_t slash = uri.find('/', pos);
|
||||
if (slash == std::string::npos) slash = uri.length();
|
||||
if (slash > pos) { // Skip empty segments
|
||||
uriSegments.push_back(uri.substr(pos, slash - pos));
|
||||
}
|
||||
pos = slash + 1;
|
||||
}
|
||||
|
||||
// Check if segment counts match
|
||||
if (patternSegments.size() == uriSegments.size()) {
|
||||
bool matches = true;
|
||||
for (size_t i = 0; i < patternSegments.size(); ++i) {
|
||||
const auto& patternSeg = patternSegments[i];
|
||||
const auto& uriSeg = uriSegments[i];
|
||||
|
||||
// If pattern segment is a parameter (starts with :), it always matches
|
||||
// Otherwise it must be an exact match
|
||||
if (!patternSeg.empty() && patternSeg[0] != ':' && patternSeg != uriSeg) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
routeItr = g_HTTPRoutes.find({method, pattern});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (routeItr != g_HTTPRoutes.end()) {
|
||||
const auto& [_, route] = *routeItr;
|
||||
route.handle(reply, body);
|
||||
const auto& route = routeItr->second;
|
||||
|
||||
// Create HTTP context from request
|
||||
HTTPContext context;
|
||||
PopulateHTTPContext(context, http_msg, connection);
|
||||
|
||||
// Build complete middleware chain
|
||||
std::vector<MiddlewarePtr> middlewareChain = g_GlobalMiddleware;
|
||||
middlewareChain.insert(middlewareChain.end(),
|
||||
route.middleware.begin(),
|
||||
route.middleware.end());
|
||||
|
||||
// Execute middleware chain
|
||||
bool chainPassed = true;
|
||||
for (const auto& middleware : middlewareChain) {
|
||||
if (!middleware->Process(context, reply)) {
|
||||
chainPassed = false;
|
||||
LOG_DEBUG("Middleware %s rejected request to %s %s",
|
||||
middleware->GetName().c_str(),
|
||||
context.method.c_str(),
|
||||
context.path.c_str());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Call handler only if all middleware passed. A failing handler (e.g. a database error) answers 500
|
||||
// instead of taking the whole server down.
|
||||
if (chainPassed) {
|
||||
try {
|
||||
route.handle(reply, context);
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Error handling %s %s: %s", context.method.c_str(), context.path.c_str(), ex.what());
|
||||
reply = HTTPReply{};
|
||||
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
||||
reply.message = "{\"success\":false,\"error\":\"Internal server error\"}";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::NOT_FOUND;
|
||||
reply.message = "{\"error\":\"Not Found\"}";
|
||||
}
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"error\":\"Unauthorized\"}";
|
||||
}
|
||||
mg_http_reply(connection, static_cast<int>(reply.status), jsonContentType, reply.message.c_str());
|
||||
|
||||
// Build headers
|
||||
std::string headers = std::string("Content-Type: ") + ContentTypeToString(reply.contentType) + "\r\n";
|
||||
if (!reply.location.empty()) {
|
||||
headers += "Location: " + reply.location + "\r\n";
|
||||
}
|
||||
// A route's own header replaces a default header of the same name
|
||||
const auto headerName = [](const std::string& header) {
|
||||
std::string name = header.substr(0, header.find(':'));
|
||||
std::transform(name.begin(), name.end(), name.begin(), ::tolower);
|
||||
return name;
|
||||
};
|
||||
for (const auto& header : Game::web.GetDefaultHeaders()) {
|
||||
const auto name = headerName(header);
|
||||
if (std::ranges::none_of(reply.headers, [&](const std::string& h) { return headerName(h) == name; })) headers += header + "\r\n";
|
||||
}
|
||||
for (const auto& header : reply.headers) headers += header + "\r\n";
|
||||
|
||||
if (!reply.file.empty() && reply.status == eHTTPStatusCode::OK && http_msg) {
|
||||
// Streamed in chunks by mongoose. Content-Type comes from the mime override (it adds its own header).
|
||||
std::string extraHeaders = headers.substr(headers.find("\r\n") + 2);
|
||||
const std::string mimeTypes = std::string("*=") + ContentTypeToString(reply.contentType);
|
||||
mg_http_serve_opts opts{};
|
||||
opts.extra_headers = extraHeaders.c_str();
|
||||
opts.mime_types = mimeTypes.c_str();
|
||||
// Without Accept-Encoding, so a stale "<file>.gz" next to the file is never served instead
|
||||
mg_http_message request = *http_msg;
|
||||
for (auto& header : request.headers) {
|
||||
if (header.name.len && mg_strcasecmp(header.name, mg_str("Accept-Encoding")) == 0) header.name = mg_str("X-Ignored");
|
||||
}
|
||||
mg_http_serve_file(connection, &request, reply.file.c_str(), &opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// Written by hand rather than with mg_http_reply: that pads Content-Length with spaces (it fills the number in
|
||||
// afterwards), which strict clients such as Node's fetch reject, and it can't send binary bodies
|
||||
headers += "Content-Length: " + std::to_string(reply.message.size()) + "\r\n";
|
||||
const auto status = static_cast<int>(reply.status);
|
||||
std::string resp = "HTTP/1.1 " + std::to_string(status) + " " + ReasonPhrase(status) + "\r\n" + headers + "\r\n";
|
||||
mg_send(connection, resp.data(), resp.size());
|
||||
mg_send(connection, reply.message.data(), reply.message.size());
|
||||
connection->is_resp = 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void HandleWSMessage(mg_connection* connection, const mg_ws_message* ws_msg) {
|
||||
// Check if connection is authenticated
|
||||
if (g_AuthenticatedWSConnections.find(connection) == g_AuthenticatedWSConnections.end()) {
|
||||
LOG_DEBUG("Received websocket message from unauthenticated connection");
|
||||
mg_ws_send(connection, "{\"error\":\"Unauthorized\"}", 23, WEBSOCKET_OP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws_msg) {
|
||||
LOG_DEBUG("Received invalid websocket message");
|
||||
return;
|
||||
@@ -94,7 +463,11 @@ void HandleWSMessage(mg_connection* connection, const mg_ws_message* ws_msg) {
|
||||
const auto eventItr = g_WSEvents.find(event);
|
||||
if (eventItr != g_WSEvents.end()) {
|
||||
const auto& [_, event] = *eventItr;
|
||||
event.handle(connection, good_data);
|
||||
try {
|
||||
event.handle(connection, good_data);
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Error handling websocket event %s: %s", event.name.c_str(), ex.what());
|
||||
}
|
||||
} else {
|
||||
LOG_DEBUG("Received invalid websocket event: %s", event.c_str());
|
||||
}
|
||||
@@ -117,6 +490,12 @@ void HandleWSSubscribe(mg_connection* connection, json data) {
|
||||
if (subItr != g_WSSubscriptions.end()) {
|
||||
// get index of subscription
|
||||
auto index = std::distance(g_WSSubscriptions.begin(), subItr);
|
||||
const auto connItr = g_AuthenticatedWSConnections.find(connection);
|
||||
if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.level < g_WSSubscriptionLevels[index]()) {
|
||||
const std::string forbidden = "{\"error\":\"Forbidden\",\"subscription\":\"" + subscription + "\"}";
|
||||
mg_ws_send(connection, forbidden.c_str(), forbidden.size(), WEBSOCKET_OP_TEXT);
|
||||
return;
|
||||
}
|
||||
connection->data[index] = SubscriptionStatus::SUBSCRIBED;
|
||||
// send subscribe message
|
||||
mg_ws_send(connection, wsSubscribed.c_str(), wsSubscribed.size(), WEBSOCKET_OP_TEXT);
|
||||
@@ -173,6 +552,9 @@ void HandleMessages(mg_connection* connection, int message, void* message_data)
|
||||
case MG_EV_WS_MSG:
|
||||
HandleWSMessage(connection, static_cast<mg_ws_message*>(message_data));
|
||||
break;
|
||||
case MG_EV_CLOSE:
|
||||
g_AuthenticatedWSConnections.erase(connection);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -217,7 +599,11 @@ void Web::RegisterWSEvent(WSEvent event) {
|
||||
}
|
||||
}
|
||||
|
||||
void Web::RegisterWSSubscription(const std::string& subscription) {
|
||||
void Web::RegisterWSSubscription(const std::string& subscription, uint8_t minLevel) {
|
||||
RegisterWSSubscription(subscription, [minLevel] { return minLevel; });
|
||||
}
|
||||
|
||||
void Web::RegisterWSSubscription(const std::string& subscription, std::function<uint8_t()> minLevel) {
|
||||
if (!Game::web.enabled) {
|
||||
LOG_DEBUG("Failed to register WS subscription %s: web server not enabled", subscription.c_str());
|
||||
return;
|
||||
@@ -227,12 +613,24 @@ void Web::RegisterWSSubscription(const std::string& subscription) {
|
||||
auto subItr = std::ranges::find(g_WSSubscriptions, subscription);
|
||||
if (subItr != g_WSSubscriptions.end()) {
|
||||
LOG_DEBUG("Failed to register WS subscription %s: duplicate", subscription.c_str());
|
||||
} else if (g_WSSubscriptions.size() >= MG_DATA_SIZE) {
|
||||
LOG("Failed to register WS subscription %s: limit of %d subscriptions reached", subscription.c_str(), MG_DATA_SIZE);
|
||||
} else {
|
||||
LOG_DEBUG("Registered WS subscription %s", subscription.c_str());
|
||||
g_WSSubscriptions.push_back(subscription);
|
||||
g_WSSubscriptionLevels.push_back(std::move(minLevel));
|
||||
}
|
||||
}
|
||||
|
||||
void Web::AddGlobalMiddleware(MiddlewarePtr middleware) {
|
||||
if (!middleware) {
|
||||
LOG_DEBUG("Attempted to add null middleware");
|
||||
return;
|
||||
}
|
||||
g_GlobalMiddleware.push_back(middleware);
|
||||
LOG_DEBUG("Registered global middleware: %s", middleware->GetName().c_str());
|
||||
}
|
||||
|
||||
Web::Web() {
|
||||
mg_log_set_fn(DLOG, NULL); // Redirect logs to our logger
|
||||
mg_log_set(MG_LL_DEBUG);
|
||||
@@ -277,8 +675,31 @@ bool Web::Startup(const std::string& listen_ip, const uint32_t listen_port) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void Web::ReceiveRequests() {
|
||||
mg_mgr_poll(&mgr, 15);
|
||||
void Web::ReceiveRequests(int timeoutMs) {
|
||||
mg_mgr_poll(&mgr, timeoutMs);
|
||||
RecheckDueWebSockets();
|
||||
}
|
||||
|
||||
void Web::RecheckWebSockets(uint32_t accountId) {
|
||||
for (auto& [connection, client] : g_AuthenticatedWSConnections) {
|
||||
if (accountId == 0 || client.accountId == accountId) client.nextCheck = {};
|
||||
}
|
||||
}
|
||||
|
||||
void Web::SendWSMessageToAccount(const std::string subscription, json& data, uint32_t accountId) {
|
||||
if (!Game::web.enabled || accountId == 0) return;
|
||||
auto subItr = std::ranges::find(g_WSSubscriptions, subscription);
|
||||
if (subItr == g_WSSubscriptions.end()) return;
|
||||
data["event"] = subscription;
|
||||
const auto index = std::distance(g_WSSubscriptions.begin(), subItr);
|
||||
const auto payload = data.dump();
|
||||
const auto minLevel = g_WSSubscriptionLevels[index]();
|
||||
for (auto* wc = Game::web.GetManager().conns; wc != NULL; wc = wc->next) {
|
||||
if (!wc->is_websocket || wc->is_closing || wc->data[index] != SubscriptionStatus::SUBSCRIBED) continue;
|
||||
const auto connItr = g_AuthenticatedWSConnections.find(wc);
|
||||
if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.accountId != accountId || connItr->second.level < minLevel) continue;
|
||||
mg_ws_send(wc, payload.c_str(), payload.size(), WEBSOCKET_OP_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
void Web::SendWSMessage(const std::string subscription, json& data) {
|
||||
@@ -293,9 +714,13 @@ void Web::SendWSMessage(const std::string subscription, json& data) {
|
||||
// tell it the event type
|
||||
data["event"] = subscription;
|
||||
auto index = std::distance(g_WSSubscriptions.begin(), subItr);
|
||||
for (auto *wc = Game::web.mgr.conns; wc != NULL; wc = wc->next) {
|
||||
if (wc->is_websocket && wc->data[index] == SubscriptionStatus::SUBSCRIBED) {
|
||||
mg_ws_send(wc, data.dump().c_str(), data.dump().size(), WEBSOCKET_OP_TEXT);
|
||||
}
|
||||
|
||||
const auto payload = data.dump();
|
||||
const auto minLevel = g_WSSubscriptionLevels[index]();
|
||||
for (auto *wc = Game::web.GetManager().conns; wc != NULL; wc = wc->next) {
|
||||
if (!wc->is_websocket || wc->is_closing || wc->data[index] != SubscriptionStatus::SUBSCRIBED) continue;
|
||||
const auto connItr = g_AuthenticatedWSConnections.find(wc);
|
||||
if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.level < minLevel) continue;
|
||||
mg_ws_send(wc, payload.c_str(), payload.size(), WEBSOCKET_OP_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
76
dWeb/Web.h
76
dWeb/Web.h
@@ -4,9 +4,13 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include "mongoose.h"
|
||||
#include "json_fwd.hpp"
|
||||
#include "eHTTPStatusCode.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "IHTTPMiddleware.h"
|
||||
|
||||
// Forward declarations for game namespace
|
||||
// so that we can access the data anywhere
|
||||
@@ -20,20 +24,41 @@ enum class eHTTPMethod;
|
||||
// Forward declaration for mongoose manager
|
||||
typedef struct mg_mgr mg_mgr;
|
||||
|
||||
// Content type enum for HTTP responses
|
||||
enum class eContentType {
|
||||
APPLICATION_JSON,
|
||||
TEXT_HTML,
|
||||
TEXT_CSS,
|
||||
TEXT_JAVASCRIPT,
|
||||
TEXT_PLAIN,
|
||||
TEXT_CSV,
|
||||
IMAGE_PNG,
|
||||
IMAGE_JPEG,
|
||||
APPLICATION_OCTET_STREAM,
|
||||
TEXT_PROMETHEUS // the Prometheus text exposition format
|
||||
};
|
||||
|
||||
// For passing HTTP messages between functions
|
||||
struct HTTPReply {
|
||||
eHTTPStatusCode status = eHTTPStatusCode::NOT_FOUND;
|
||||
std::string message = "{\"error\":\"Not Found\"}";
|
||||
eContentType contentType = eContentType::APPLICATION_JSON;
|
||||
std::string location = ""; // For redirect responses (Location header)
|
||||
std::vector<std::string> headers{}; // Extra raw headers, e.g. "Set-Cookie: a=b"
|
||||
// When set on a 200 reply, this file is streamed from disk as the body (with contentType and headers) instead of
|
||||
// message, so large downloads never sit in memory
|
||||
std::string file{};
|
||||
};
|
||||
|
||||
// HTTP route structure
|
||||
// This structure is used to register HTTP routes
|
||||
// with the server. Each route has a path, method, and a handler function
|
||||
// that will be called when the route is matched.
|
||||
// with the server. Each route has a path, method, optional middleware,
|
||||
// and a handler function that will be called when the route is matched.
|
||||
struct HTTPRoute {
|
||||
std::string path;
|
||||
eHTTPMethod method;
|
||||
std::function<void(HTTPReply&, const std::string&)> handle;
|
||||
std::vector<MiddlewarePtr> middleware;
|
||||
std::function<void(HTTPReply&, const HTTPContext&)> handle;
|
||||
};
|
||||
|
||||
// WebSocket event structure
|
||||
@@ -51,6 +76,18 @@ enum SubscriptionStatus {
|
||||
SUBSCRIBED = 1
|
||||
};
|
||||
|
||||
// Who a WebSocket connection belongs to
|
||||
struct WSAuth {
|
||||
uint8_t level{};
|
||||
uint32_t accountId{};
|
||||
};
|
||||
|
||||
// WebSocket authentication callback function type
|
||||
// Returns the permission level and account of the token's owner, or nullopt if the token is invalid.
|
||||
// It is called again for open connections (every minute, and on RecheckWebSockets), so revoked sessions, bans and
|
||||
// demotions reach live sockets too.
|
||||
using WSAuthCallback = std::function<std::optional<WSAuth>(const std::string&)>;
|
||||
|
||||
class Web {
|
||||
public:
|
||||
// Constructor
|
||||
@@ -58,7 +95,8 @@ public:
|
||||
// Destructor
|
||||
~Web();
|
||||
// Handle incoming messages
|
||||
void ReceiveRequests();
|
||||
// Handle pending HTTP and WebSocket traffic, waiting up to timeoutMs for some to arrive
|
||||
void ReceiveRequests(int timeoutMs = 15);
|
||||
// Start the web server
|
||||
// Returns true if the server started successfully
|
||||
bool Startup(const std::string& listen_ip, const uint32_t listen_port);
|
||||
@@ -66,17 +104,43 @@ public:
|
||||
void RegisterHTTPRoute(HTTPRoute route);
|
||||
// Register WebSocket event to be handled by the server
|
||||
void RegisterWSEvent(WSEvent event);
|
||||
// Register WebSocket subscription to be handled by the server
|
||||
void RegisterWSSubscription(const std::string& subscription);
|
||||
// Register WebSocket subscription to be handled by the server.
|
||||
// Only connections whose permission level is at least minLevel may subscribe or receive it.
|
||||
void RegisterWSSubscription(const std::string& subscription, uint8_t minLevel = 0);
|
||||
// The level is looked up each time (for permissions that can change while running)
|
||||
void RegisterWSSubscription(const std::string& subscription, std::function<uint8_t()> minLevel);
|
||||
// Add global middleware that applies to all routes
|
||||
void AddGlobalMiddleware(MiddlewarePtr middleware);
|
||||
// Set WebSocket authentication callback for token validation
|
||||
void SetWSAuthCallback(WSAuthCallback callback) { wsAuthCallback = callback; }
|
||||
// Whether a GM level may connect with an API token (Authorization: Bearer) rather than the browser's cookie
|
||||
void SetWSApiAccessCallback(std::function<bool(uint8_t)> callback) { wsApiAccessCallback = std::move(callback); }
|
||||
// Returns if the web server is enabled
|
||||
bool IsEnabled() const { return enabled; };
|
||||
// Send a message to all connected WebSocket clients that are subscribed to the given topic
|
||||
void static SendWSMessage(std::string sub, nlohmann::json& message);
|
||||
// Send a message on a topic only to the subscribed connections of one account
|
||||
void static SendWSMessageToAccount(std::string sub, nlohmann::json& message, uint32_t accountId);
|
||||
// Check the token of open WebSocket connections again on the next poll (one account's, or all when 0).
|
||||
// Connections that no longer verify are closed; the others get their current level.
|
||||
void RecheckWebSockets(uint32_t accountId = 0);
|
||||
// Security headers added to every HTTP response
|
||||
void SetDefaultHeaders(std::vector<std::string> headers) { defaultHeaders = std::move(headers); }
|
||||
const std::vector<std::string>& GetDefaultHeaders() const { return defaultHeaders; }
|
||||
// Get mongoose manager for direct access
|
||||
mg_mgr& GetManager() { return mgr; };
|
||||
// Get WebSocket auth callback (used during WebSocket upgrade)
|
||||
WSAuthCallback GetWSAuthCallback() const { return wsAuthCallback; }
|
||||
const std::function<bool(uint8_t)>& GetWSApiAccessCallback() const { return wsApiAccessCallback; }
|
||||
private:
|
||||
// mongoose manager
|
||||
mg_mgr mgr;
|
||||
// If the web server is enabled
|
||||
bool enabled = false;
|
||||
// WebSocket authentication callback
|
||||
WSAuthCallback wsAuthCallback = nullptr;
|
||||
std::function<bool(uint8_t)> wsApiAccessCallback = nullptr;
|
||||
std::vector<std::string> defaultHeaders{};
|
||||
};
|
||||
|
||||
#endif // !__WEB_H__
|
||||
|
||||
Reference in New Issue
Block a user