mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
/api/v1/zones/{zoneId}: send a zone's edited files (multipart), follow the reload,
list, restore and clear the zone's sets, and take, release and look at its lock.
Every request needs an API key that names the zones_publish permission, an owner at
zone_publish_gm_level, and HTTPS or the server's own machine; refusals are counted
per address and every use is in the audit log. A publish is checked at its headers
before any of its body is taken, the files are checked and written on a worker
thread, and the zone's instances are replaced through the world reload, held while
players are there unless told to move them.
The Worlds page lists each zone's editors and lock holder for an admin.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
334 lines
15 KiB
C++
334 lines
15 KiB
C++
#include "RouteUtils.h"
|
|
#include "ApiKeyService.h"
|
|
#include "Permissions.h"
|
|
|
|
#include "Database.h"
|
|
#include "GameLabels.h"
|
|
#include "GameText.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
|
|
inja::Environment env{ TEMPLATE_DIR };
|
|
// Game text from the client's locale in the viewer's language (GameText.h), escaped like the page data:
|
|
// {{ zone_name(1150) }}, {{ phrase("UI_COINS") }}
|
|
env.add_callback("zone_name", 1, [](inja::Arguments& args) {
|
|
return RouteUtils::EscapeHtml(GameText::ZoneName(args.at(0)->get<uint32_t>()));
|
|
});
|
|
env.add_callback("phrase", 1, [](inja::Arguments& args) {
|
|
return RouteUtils::EscapeHtml(GameText::TextOrKey(args.at(0)->get<std::string>()));
|
|
});
|
|
return env;
|
|
}();
|
|
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) {
|
|
// Game text in this request comes in the viewer's language
|
|
const GameText::LanguageScope language(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));
|
|
}
|
|
|
|
void Document(eHTTPMethod method, const std::string& path, const std::string& permission, const std::string& description) {
|
|
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, Permissions::Level(permission), description, permission, method == eHTTPMethod::GET });
|
|
}
|
|
|
|
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();
|
|
// The game's words and zone names in the viewer's language: `game` for templates, <body data-game> for scripts
|
|
data["game"] = GameText::PageJson();
|
|
data["gameJson"] = data["game"].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;
|
|
}
|
|
}
|