mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
What someone may do on the dashboard is now their GM level's permissions plus the grants on their account, minus its denies (PermissionGrants.h). A deny beats a grant; denies never apply to GM 9, and settings and permissions_manage stay GM 9 only. The account's grants are read with every request (like its GM level), so a change applies at once, and they are passed through every check: RouteUtils::Can, CanViewCharacter, the rank rules (self_* and manage_equal_rank), routes guarded by a permission, the templates' `can`, the API documentation, API access, API key scopes (a key never does more than its owner may now) and WebSocket subscriptions. New permission grants_manage (GM 9 by default) and the API to manage grants: GET /api/grants/catalog, GET /api/grants, POST /api/grants, POST /api/grants/:id/remove. Nobody grants or takes away what they don't hold themselves (a permission, every permission of a group, a command they may use, every command up to their own GM level), and only on accounts the rank rules let them manage (their own with self_moderation). Commands with a fixed level or a floor above GM 1 (/execute) can't be granted. Every change goes in the audit log (grant_permission, deny_permission, remove_grant). Also: the Showcase gate and the traffic subscription now check their permission by name. Check: grant a GM 2 account accounts_ban (it can ban, and the Ban button shows); deny a GM 8 account accounts_view (the accounts list is refused); give an expiry a minute ahead and see it stop; try to grant a permission your account doesn't have (refused); dWebTests PermissionGrantsTests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
14 KiB
C++
315 lines
14 KiB
C++
#include "RouteUtils.h"
|
|
#include "ApiKeyService.h"
|
|
#include "Permissions.h"
|
|
|
|
#include "Database.h"
|
|
#include "GameLabels.h"
|
|
#include "Game.h"
|
|
#include "Logger.h"
|
|
#include "dConfig.h"
|
|
#include "inja.hpp"
|
|
#include <bcrypt/BCrypt.hpp>
|
|
#include <openssl/rand.h>
|
|
#include <openssl/sha.h>
|
|
#include "eHTTPMethod.h"
|
|
#include "RequireAuthMiddleware.h"
|
|
#include "magic_enum.hpp"
|
|
#include "Alerts.h"
|
|
#include "mongoose.h"
|
|
|
|
namespace {
|
|
constexpr const char* TEMPLATE_DIR = "dDashboardServer/templates/";
|
|
|
|
inja::Environment& GetEnvironment() {
|
|
static inja::Environment env = [] {
|
|
// No trim_blocks/lstrip_blocks: inja's versions also eat the spaces around a tag on the same line
|
|
// ('class="a{% if x %} b{% endif %}"' would render "ab"), unlike Jinja2's
|
|
return inja::Environment{ TEMPLATE_DIR };
|
|
}();
|
|
return env;
|
|
}
|
|
}
|
|
|
|
namespace RouteUtils {
|
|
namespace {
|
|
std::vector<RouteDoc> g_RouteDocs;
|
|
int g_ReadRoutes = 0;
|
|
|
|
bool Reads(eHTTPMethod method, const std::string& path) {
|
|
return method == eHTTPMethod::GET || g_ReadRoutes > 0 || (method == eHTTPMethod::POST && path.starts_with("/api/tables/"));
|
|
}
|
|
|
|
std::shared_ptr<RequireAuthMiddleware> MakeRequireAuth(std::shared_ptr<RequireAuthMiddleware> middleware, eHTTPMethod method, const std::string& path) {
|
|
if (Reads(method, path)) middleware->SetReadsOnly();
|
|
return middleware;
|
|
}
|
|
}
|
|
|
|
ReadRoutes::ReadRoutes() { g_ReadRoutes++; }
|
|
ReadRoutes::~ReadRoutes() { g_ReadRoutes--; }
|
|
|
|
void Register(eHTTPMethod method, const std::string& path, std::vector<MiddlewarePtr> middleware, Handler handler) {
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = path,
|
|
.method = method,
|
|
.middleware = std::move(middleware),
|
|
.handle = [path, handler = std::move(handler)](HTTPReply& reply, const HTTPContext& context) {
|
|
try {
|
|
handler(reply, context);
|
|
} catch (const std::exception& ex) {
|
|
LOG("Error handling %s %s: %s", context.method.c_str(), context.path.c_str(), ex.what());
|
|
if (path.starts_with("/api/")) JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Internal server error");
|
|
else RenderError(reply, context, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Something went wrong loading this page.");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void Route(eHTTPMethod method, const std::string& path, int16_t minGmLevel, const std::string& description, Handler handler) {
|
|
std::vector<MiddlewarePtr> middleware;
|
|
if (minGmLevel >= 0) middleware.push_back(MakeRequireAuth(std::make_shared<RequireAuthMiddleware>(static_cast<uint8_t>(minGmLevel)), method, path));
|
|
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, minGmLevel, description, "", Reads(method, path) });
|
|
Register(method, path, std::move(middleware), std::move(handler));
|
|
}
|
|
|
|
void Route(eHTTPMethod method, const std::string& path, const Perm& permission, const std::string& description, Handler handler) {
|
|
if (!Permissions::Find(permission.key)) LOG("Route %s uses unknown permission %s; nobody can use it", path.c_str(), permission.key.c_str());
|
|
std::vector<MiddlewarePtr> middleware;
|
|
middleware.push_back(MakeRequireAuth(std::make_shared<RequireAuthMiddleware>(std::function<uint8_t()>([key = permission.key] { return Permissions::Level(key); }), permission.key), method, path));
|
|
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, Permissions::Level(permission.key), description, permission.key, Reads(method, path) });
|
|
Register(method, path, std::move(middleware), std::move(handler));
|
|
}
|
|
|
|
bool Can(const HTTPContext& context, const std::string& permission) {
|
|
return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get(), context.grants.get());
|
|
}
|
|
|
|
std::optional<LWOOBJID> ResolveCharacter(std::string_view text) {
|
|
std::string trimmed(text);
|
|
trimmed.erase(0, trimmed.find_first_not_of(" \t"));
|
|
trimmed.erase(trimmed.find_last_not_of(" \t") + 1);
|
|
if (trimmed.empty()) return std::nullopt;
|
|
if (const auto id = GeneralUtils::TryParse<LWOOBJID>(trimmed)) return id;
|
|
const auto info = Database::Get()->GetCharacterInfo(trimmed);
|
|
return info ? std::optional<LWOOBJID>(info->id) : std::nullopt;
|
|
}
|
|
|
|
bool CanViewCharacter(const HTTPContext& context, uint32_t ownerAccountId) {
|
|
return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId, context.apiKey.get(), context.grants.get());
|
|
}
|
|
|
|
const std::vector<RouteDoc>& GetRouteDocs() {
|
|
return g_RouteDocs;
|
|
}
|
|
|
|
void JsonReply(HTTPReply& reply, eHTTPStatusCode status, const nlohmann::json& body) {
|
|
reply.status = status;
|
|
reply.message = body.dump();
|
|
reply.contentType = eContentType::APPLICATION_JSON;
|
|
}
|
|
|
|
void JsonError(HTTPReply& reply, eHTTPStatusCode status, const std::string& message) {
|
|
JsonReply(reply, status, { {"success", false}, {"error", message} });
|
|
}
|
|
|
|
void JsonSuccess(HTTPReply& reply, nlohmann::json extra) {
|
|
extra["success"] = true;
|
|
JsonReply(reply, eHTTPStatusCode::OK, extra);
|
|
}
|
|
|
|
std::optional<nlohmann::json> ParseBody(const HTTPContext& context) {
|
|
if (context.body.empty()) return nlohmann::json::object();
|
|
auto json = nlohmann::json::parse(context.body, nullptr, false);
|
|
if (json.is_discarded() || !json.is_object()) return std::nullopt;
|
|
return json;
|
|
}
|
|
|
|
AuditTarget AuditTarget::Character(LWOOBJID characterId) {
|
|
const auto info = Database::Get()->GetCharacterInfo(characterId);
|
|
return { info ? info->accountId : 0, characterId };
|
|
}
|
|
|
|
HTTPContext SystemContext() {
|
|
HTTPContext context;
|
|
context.isAuthenticated = true;
|
|
context.authenticatedUser = "[system]";
|
|
return context;
|
|
}
|
|
|
|
void Audit(const HTTPContext& context, const std::string& action, const std::string& description, const AuditTarget& target) {
|
|
// Staff acting on their own account or characters is called out, so it stands out in the log and in alerts
|
|
const auto text = description + OwnAccountNote(context.accountId, target.accountId);
|
|
// What was done with an API key says which key: "user (key name)"
|
|
auto actor = context.authenticatedUser;
|
|
if (context.apiKey) {
|
|
// The audit log's name column holds 64 characters; shorten the key's name rather than the account's
|
|
const auto room = actor.size() + 3 < 64 ? 64 - actor.size() - 3 : 0;
|
|
actor += " (" + context.apiKey->name.substr(0, room) + ")";
|
|
}
|
|
try {
|
|
Database::Get()->InsertAuditLog(context.accountId, actor, action, text, target.accountId, target.characterId);
|
|
} catch (const std::exception& ex) {
|
|
LOG("Failed to write audit log entry %s: %s", action.c_str(), ex.what());
|
|
}
|
|
LOG("[audit] %s: %s %s", actor.c_str(), action.c_str(), text.c_str());
|
|
Alerts::FromAudit(actor, action, text);
|
|
}
|
|
|
|
std::string HashPassword(const std::string& password) {
|
|
char salt[BCRYPT_HASHSIZE];
|
|
char hash[BCRYPT_HASHSIZE];
|
|
bcrypt_gensalt(12, salt);
|
|
bcrypt_hashpw(password.c_str(), salt, hash);
|
|
return hash;
|
|
}
|
|
|
|
std::string ClientAddress(const HTTPContext& context) {
|
|
if (ConfigFlag("behind_proxy", false)) {
|
|
// Use the last address: the one our proxy appended. Earlier ones come from the client and can be forged,
|
|
// which would let anyone dodge the per-address rate limits.
|
|
const auto& forwarded = context.GetHeader("X-Forwarded-For");
|
|
if (!forwarded.empty()) {
|
|
const auto comma = forwarded.rfind(',');
|
|
auto last = comma == std::string::npos ? forwarded : forwarded.substr(comma + 1);
|
|
last.erase(0, last.find_first_not_of(' '));
|
|
last.erase(last.find_last_not_of(' ') + 1);
|
|
if (!last.empty()) return last;
|
|
}
|
|
}
|
|
return context.clientIP;
|
|
}
|
|
|
|
namespace {
|
|
std::string Hex(const unsigned char* data, size_t size) {
|
|
static constexpr char digits[] = "0123456789abcdef";
|
|
std::string out;
|
|
out.reserve(size * 2);
|
|
for (size_t i = 0; i < size; i++) {
|
|
out += digits[data[i] >> 4];
|
|
out += digits[data[i] & 0xf];
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
std::string GenerateUrlToken() {
|
|
unsigned char bytes[32];
|
|
if (RAND_bytes(bytes, sizeof(bytes)) != 1) throw std::runtime_error("RAND_bytes failed");
|
|
return Hex(bytes, sizeof(bytes));
|
|
}
|
|
|
|
std::string HashToken(const std::string& token) {
|
|
unsigned char digest[SHA256_DIGEST_LENGTH];
|
|
SHA256(reinterpret_cast<const unsigned char*>(token.data()), token.size(), digest);
|
|
return Hex(digest, sizeof(digest));
|
|
}
|
|
|
|
void CsvReply(HTTPReply& reply, std::string filename, const std::string& csv) {
|
|
std::erase_if(filename, [](char c) { return !(std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' || c == '.'); });
|
|
if (filename.empty()) filename = "export.csv";
|
|
reply.status = eHTTPStatusCode::OK;
|
|
reply.contentType = eContentType::TEXT_CSV;
|
|
// A byte order mark so Excel reads UTF-8 names correctly
|
|
reply.message = "\xEF\xBB\xBF" + csv;
|
|
reply.headers.push_back("Content-Disposition: attachment; filename=\"" + filename + "\"");
|
|
}
|
|
|
|
std::string QueryValue(const std::string& query, const std::string& name) {
|
|
const auto key = name + "=";
|
|
size_t pos = 0;
|
|
while ((pos = query.find(key, pos)) != std::string::npos) {
|
|
if (pos == 0 || query[pos - 1] == '&' || query[pos - 1] == '?') {
|
|
const auto end = query.find('&', pos);
|
|
std::string raw = query.substr(pos + key.size(), end == std::string::npos ? std::string::npos : end - pos - key.size());
|
|
std::replace(raw.begin(), raw.end(), '+', ' ');
|
|
std::string decoded(raw.size() + 1, '\0');
|
|
const int length = mg_url_decode(raw.c_str(), raw.size(), decoded.data(), decoded.size(), 1);
|
|
decoded.resize(length > 0 ? static_cast<size_t>(length) : 0);
|
|
return decoded;
|
|
}
|
|
pos += key.size();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
bool ConfigFlag(const std::string& key, bool fallback) {
|
|
if (!Game::config) return fallback;
|
|
const auto value = Game::config->GetValue(key);
|
|
return value.empty() ? fallback : value == "1";
|
|
}
|
|
|
|
bool UseSecureCookies() {
|
|
return Game::config && Game::config->GetValue("secure_cookies") == "1";
|
|
}
|
|
|
|
bool CanManageAccount(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action) {
|
|
return context.isAuthenticated && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get(), context.grants.get()) == eManageDenial::NONE;
|
|
}
|
|
|
|
nlohmann::json ManageJson(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId) {
|
|
return {
|
|
{"tools", CanManageAccount(context, targetLevel, targetAccountId, eAccountAction::TOOLS)},
|
|
{"items", CanManageAccount(context, targetLevel, targetAccountId, eAccountAction::ITEMS)},
|
|
{"moderation", CanManageAccount(context, targetLevel, targetAccountId, eAccountAction::MODERATION)},
|
|
};
|
|
}
|
|
|
|
std::optional<uint8_t> AuthorizeAccountAction(const HTTPContext& context, uint32_t targetAccountId, HTTPReply& reply, eAccountAction action) {
|
|
const auto target = Database::Get()->GetAccountById(targetAccountId);
|
|
if (target.contains("error")) {
|
|
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account not found");
|
|
return std::nullopt;
|
|
}
|
|
const uint8_t targetLevel = target.value("gm_level", 0);
|
|
const auto denial = AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get(), context.grants.get());
|
|
if (denial == eManageDenial::NONE) return targetLevel;
|
|
// The owner may do it, but the key's scope doesn't let it
|
|
if (context.apiKey && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, nullptr, context.grants.get()) == eManageDenial::NONE) {
|
|
ApiKeyService::NoteDenied(context, AccountRules::DenialMessage(denial, action));
|
|
JsonError(reply, eHTTPStatusCode::FORBIDDEN, "This API key may not do this: " + AccountRules::DenialMessage(denial, action));
|
|
return std::nullopt;
|
|
}
|
|
JsonError(reply, eHTTPStatusCode::FORBIDDEN, AccountRules::DenialMessage(denial, action));
|
|
return std::nullopt;
|
|
}
|
|
|
|
bool RefuseLastOperator(uint32_t targetAccountId, uint8_t targetLevel, HTTPReply& reply, const std::string& what) {
|
|
if (targetLevel < OPERATOR_LEVEL) return false;
|
|
if (!RemovesLastOperator(targetLevel, Database::Get()->CountActiveAccountsAtGmLevel(OPERATOR_LEVEL, targetAccountId))) return false;
|
|
JsonError(reply, eHTTPStatusCode::CONFLICT, AccountRules::LastOperatorMessage(what));
|
|
return true;
|
|
}
|
|
|
|
std::string OwnAccountNote(uint32_t actorAccountId, uint32_t targetAccountId) {
|
|
return actorAccountId != 0 && actorAccountId == targetAccountId ? " (on their own account)" : "";
|
|
}
|
|
|
|
void RenderPage(HTTPReply& reply, const HTTPContext& context, const std::string& templateName, const std::string& page, nlohmann::json data) {
|
|
try {
|
|
data.merge_patch(context.GetUserDataJson());
|
|
data["current_page"] = page;
|
|
data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0, context.apiKey.get(), context.isAuthenticated ? context.grants.get() : nullptr);
|
|
// The account's view choices, on <body> so each page's toggles start as they were left (static/js/common.js)
|
|
// Names for the game's numbered values, from the server's enums (GameLabels.h)
|
|
data["labels"] = GameLabels::Json();
|
|
data["labelsJson"] = GameLabels::Json().dump();
|
|
data["prefs"] = context.isAuthenticated && context.accountId ? Database::Get()->GetDashboardPreferences(context.accountId) : "{}";
|
|
EscapeHtmlStrings(data);
|
|
|
|
reply.status = eHTTPStatusCode::OK;
|
|
reply.message = GetEnvironment().render_file(templateName, data);
|
|
reply.contentType = eContentType::TEXT_HTML;
|
|
} catch (const std::exception& ex) {
|
|
LOG("Error rendering template %s: %s", templateName.c_str(), ex.what());
|
|
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
|
reply.message = "<h1>500 - Server Error</h1>";
|
|
reply.contentType = eContentType::TEXT_HTML;
|
|
}
|
|
}
|
|
|
|
void RenderError(HTTPReply& reply, const HTTPContext& context, eHTTPStatusCode status, const std::string& message) {
|
|
RenderPage(reply, context, "error.jinja2", "", { {"status_code", static_cast<int>(status)}, {"error_message", message} });
|
|
if (reply.status == eHTTPStatusCode::OK) reply.status = status;
|
|
}
|
|
}
|