Add identity-safe player moderation runtimes for T5 and T6
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "loader/component_loader.hpp"
|
||||
#include "gsc.hpp"
|
||||
#include "scripting.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "command.hpp"
|
||||
#include "game/game.hpp"
|
||||
#include "game/scripting/array.hpp"
|
||||
#include <utils/moderation.hpp>
|
||||
#include <utils/http.hpp>
|
||||
#include <utils/cryptography.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
namespace moderation
|
||||
{
|
||||
namespace
|
||||
{
|
||||
using runtime = utils::moderation::runtime;
|
||||
struct transfer
|
||||
{
|
||||
std::string session, response;
|
||||
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
|
||||
std::atomic<bool> done{false};
|
||||
bool success = false;
|
||||
};
|
||||
std::shared_ptr<runtime> current;
|
||||
std::shared_ptr<transfer> pending;
|
||||
std::string endpoint, credential, configured_server;
|
||||
int configured_generation = 0;
|
||||
std::chrono::steady_clock::time_point next_poll{}, next_log{};
|
||||
bool fault = false;
|
||||
long long now() { return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); }
|
||||
void failure()
|
||||
{
|
||||
if (std::chrono::steady_clock::now() >= next_log)
|
||||
{
|
||||
printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n");
|
||||
next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
}
|
||||
}
|
||||
void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast<int>(slot)); }
|
||||
std::string execute(const std::string& kind, int slot, const std::string& reason)
|
||||
{
|
||||
auto active = current;
|
||||
if (!active) return "failed";
|
||||
// This function is called only by the server-thread GSC pump. No queue/yield
|
||||
// separates runtime target validation from synchronous engine dispatch.
|
||||
if (kind == "announce" || kind == "warn" || kind == "message")
|
||||
{
|
||||
const auto text = std::string("j \"") + reason + "\"";
|
||||
game::SV_GameSendServerCommand(slot, 0, text.c_str());
|
||||
return "confirmed";
|
||||
}
|
||||
if (slot < 0 || slot >= 64) return "failed";
|
||||
if (kind == "mute" || kind == "unmute")
|
||||
{
|
||||
command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true);
|
||||
return "unknown"; // Engine has no mute-state readback in this adapter.
|
||||
}
|
||||
if (kind != "kick") return "failed";
|
||||
const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string();
|
||||
command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true);
|
||||
return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown";
|
||||
}
|
||||
void pump()
|
||||
{
|
||||
if (!current || fault) return;
|
||||
try
|
||||
{
|
||||
if (pending && pending->done.load())
|
||||
{
|
||||
auto completed = pending; pending.reset();
|
||||
if (completed->session == current->session)
|
||||
{
|
||||
if (completed->success)
|
||||
{
|
||||
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - completed->started).count();
|
||||
current->accept(completed->response, now(), age);
|
||||
}
|
||||
else failure();
|
||||
}
|
||||
}
|
||||
auto active = current;
|
||||
active->enforce(now(), [active](const auto& kind, int slot, const auto& reason)
|
||||
{
|
||||
return current == active ? execute(kind, slot, reason) : std::string("failed");
|
||||
});
|
||||
if (current != active) return;
|
||||
if (!pending && std::chrono::steady_clock::now() >= next_poll)
|
||||
{
|
||||
auto request = current->request().dump(); auto target = endpoint; auto token = credential;
|
||||
pending = std::make_shared<transfer>(); pending->session = current->session; auto work = pending;
|
||||
next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
scheduler::thread_pool.push([work, request, target, token]
|
||||
{
|
||||
try
|
||||
{
|
||||
utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000;
|
||||
limits.max_response_bytes = 1024 * 1024;
|
||||
const auto response = utils::http::get_data(target, request,
|
||||
{{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits);
|
||||
if (response && response->code == CURLE_OK && response->status == 200)
|
||||
{ work->response = response->buffer; work->success = true; }
|
||||
}
|
||||
catch (...) { work->success = false; }
|
||||
work->done.store(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (...) { failure(); }
|
||||
}
|
||||
}
|
||||
class component final : public component_interface
|
||||
{
|
||||
public:
|
||||
void on_startup(plugin::plugin* plugin) override
|
||||
{
|
||||
plugin->get_interface()->callbacks()->on_player_connect(disconnected);
|
||||
plugin->get_interface()->callbacks()->on_player_disconnect(disconnected);
|
||||
scripting::on_shutdown([]
|
||||
{
|
||||
// In-flight requests own only native data. Results from the old VM are abandoned.
|
||||
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
|
||||
});
|
||||
gsc::function::add("moderationversion", [] { return std::string("1.0.0"); });
|
||||
gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation)
|
||||
{
|
||||
if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true;
|
||||
// Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects.
|
||||
if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos ||
|
||||
url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 ||
|
||||
token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 ||
|
||||
token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false;
|
||||
try
|
||||
{
|
||||
auto configured = std::make_shared<runtime>("tws-moderation/t6", server, url, generation,
|
||||
utils::cryptography::sha256::compute(token, true));
|
||||
current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation;
|
||||
endpoint = url; credential = token; next_poll = {}; fault = false; return true;
|
||||
}
|
||||
catch (...) { fault = true; failure(); return false; }
|
||||
});
|
||||
gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name)
|
||||
{
|
||||
if (!current || fault) return false;
|
||||
const auto ref = entity.get_entity_reference();
|
||||
if (ref.classnum != 0 || ref.entnum >= 64) return false;
|
||||
try { current->observe(ref.entnum, guid, name); return true; }
|
||||
catch (...) { failure(); return false; }
|
||||
});
|
||||
gsc::function::add("moderationstop", []
|
||||
{
|
||||
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
|
||||
});
|
||||
gsc::function::add("moderationpump", pump);
|
||||
}
|
||||
};
|
||||
}
|
||||
REGISTER_COMPONENT(moderation::component)
|
||||
@@ -14,13 +14,17 @@ namespace utils::http
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct response_buffer { std::string bytes; size_t limit; };
|
||||
|
||||
size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept
|
||||
{
|
||||
const auto buffer = static_cast<std::string*>(userp);
|
||||
const auto buffer = static_cast<response_buffer*>(userp);
|
||||
if (size && nmemb > static_cast<size_t>(-1) / size) return 0;
|
||||
const auto total_size = size * nmemb;
|
||||
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
|
||||
try
|
||||
{
|
||||
buffer->append(static_cast<char*>(contents), total_size);
|
||||
buffer->bytes.append(static_cast<char*>(contents), total_size);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -68,7 +72,7 @@ namespace utils::http
|
||||
header_list.reset(appended);
|
||||
}
|
||||
|
||||
std::string buffer{};
|
||||
response_buffer buffer{{}, limits.max_response_bytes};
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get());
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.data());
|
||||
@@ -106,7 +110,7 @@ namespace utils::http
|
||||
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
|
||||
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
|
||||
if (response.code == CURLE_OK)
|
||||
response.buffer = std::move(buffer);
|
||||
response.buffer = std::move(buffer.bytes);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
|
||||
@@ -25,6 +26,8 @@ namespace utils::http
|
||||
{
|
||||
long connect_timeout_ms = 5000;
|
||||
long timeout_ms = 15000;
|
||||
// Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate.
|
||||
size_t max_response_bytes = 0;
|
||||
};
|
||||
|
||||
using headers = std::unordered_map<std::string, std::string>;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#include <stdinc.hpp>
|
||||
#include "moderation.hpp"
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
namespace utils::moderation
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr size_t max_file = 16 * 1024 * 1024;
|
||||
constexpr size_t max_response = 1024 * 1024;
|
||||
bool identifier(const std::string& value, size_t limit = 128)
|
||||
{
|
||||
return !value.empty() && value.size() <= limit &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char ch) {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':';
|
||||
});
|
||||
}
|
||||
std::string bounded_text(const std::string& value, size_t limit)
|
||||
{
|
||||
// Engine names may contain malformed bytes. Preserve valid UTF-8 and never
|
||||
// split a codepoint or let one bad name poison the complete JSON roster.
|
||||
std::string result;
|
||||
for (size_t i = 0; i < value.size() && result.size() < limit;)
|
||||
{
|
||||
const auto ch = static_cast<unsigned char>(value[i]);
|
||||
size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 :
|
||||
ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0;
|
||||
bool valid = width && i + width <= value.size();
|
||||
for (size_t j = 1; valid && j < width; ++j)
|
||||
valid = (static_cast<unsigned char>(value[i + j]) & 0xc0) == 0x80;
|
||||
if (valid && width >= 3)
|
||||
{
|
||||
const auto second = static_cast<unsigned char>(value[i + 1]);
|
||||
valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) &&
|
||||
!(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90);
|
||||
}
|
||||
if (!valid) { result += '?'; ++i; continue; }
|
||||
if (result.size() + width > limit) break;
|
||||
result.append(value, i, width); i += width;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string player_name(const std::string& value)
|
||||
{
|
||||
auto name = bounded_text(value, 255);
|
||||
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == 127) ch = ' ';
|
||||
auto visible = name;
|
||||
// Match ECMAScript trim whitespace so the controller cannot reject an
|
||||
// otherwise complete roster because one display name is blank.
|
||||
for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81",
|
||||
"\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87",
|
||||
"\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf",
|
||||
"\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"})
|
||||
for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size());
|
||||
return visible.empty() ? "Unknown player" : name;
|
||||
}
|
||||
bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; }
|
||||
std::string hash_scope(const std::string& text)
|
||||
{
|
||||
unsigned long long hash = 14695981039346656037ull;
|
||||
for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull;
|
||||
std::ostringstream out; out << std::hex << hash; return out.str();
|
||||
}
|
||||
void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); }
|
||||
std::string field(const json& value, const char* key, size_t max, bool empty = false)
|
||||
{
|
||||
require(value.contains(key) && value.at(key).is_string());
|
||||
auto result = value.at(key).get<std::string>();
|
||||
require((empty || !result.empty()) && result.size() <= max);
|
||||
return result;
|
||||
}
|
||||
void reason(const json& value) { field(value, "reason", 500, true); }
|
||||
void sanction(const json& value, long long revision)
|
||||
{
|
||||
require(value.is_object());
|
||||
require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128)));
|
||||
require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision);
|
||||
auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value);
|
||||
if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32));
|
||||
}
|
||||
void action(const json& value)
|
||||
{
|
||||
require(value.is_object() && identifier(field(value, "id", 128)));
|
||||
auto kind = field(value, "kind", 16);
|
||||
require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce");
|
||||
if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null());
|
||||
else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128)));
|
||||
reason(value); timestamp(field(value, "expiresAt", 32));
|
||||
}
|
||||
}
|
||||
long long timestamp(const std::string& value)
|
||||
{
|
||||
require(value.size() == 20 || value.size() == 24);
|
||||
require(value.back() == 'Z' && value[10] == 'T');
|
||||
if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22]));
|
||||
std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
|
||||
require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200);
|
||||
const auto original = tm;
|
||||
#ifdef _WIN32
|
||||
auto result = _mkgmtime64(&tm);
|
||||
#else
|
||||
auto result = timegm(&tm);
|
||||
#endif
|
||||
require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday &&
|
||||
original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec);
|
||||
return result;
|
||||
}
|
||||
std::string command_text(const std::string& value)
|
||||
{
|
||||
// Untrusted text is never a console delimiter or a quoted command escape.
|
||||
std::string result;
|
||||
for (unsigned char ch : bounded_text(value, 240))
|
||||
{
|
||||
result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast<char>(ch);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string canonical_guid(const std::string& value)
|
||||
{
|
||||
if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
|
||||
{
|
||||
const auto first = value.find_first_not_of('0');
|
||||
return first == std::string::npos ? "0" : value.substr(first);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
std::string new_session()
|
||||
{
|
||||
std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0');
|
||||
for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast<unsigned>(random());
|
||||
return out.str();
|
||||
}
|
||||
runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope)
|
||||
: server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation)
|
||||
{
|
||||
require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_));
|
||||
file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json");
|
||||
saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_},
|
||||
{"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}};
|
||||
if (std::filesystem::exists(file_))
|
||||
{
|
||||
require(std::filesystem::file_size(file_) < max_file);
|
||||
std::ifstream stream(file_, std::ios::binary); json value; stream >> value;
|
||||
require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ &&
|
||||
value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL &&
|
||||
value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000);
|
||||
for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get<long long>());
|
||||
for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it)
|
||||
{
|
||||
require(identifier(it.key())); action(it.value().at("action"));
|
||||
auto state = field(it.value(), "state", 16);
|
||||
require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired");
|
||||
require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key());
|
||||
}
|
||||
require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000);
|
||||
for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get<std::string>()));
|
||||
saved_ = std::move(value);
|
||||
}
|
||||
require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL);
|
||||
auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get<long long>() + 1;
|
||||
save(next); // Allocate this boot/map sequence durably before exposing its session.
|
||||
}
|
||||
void runtime::save(const json& next)
|
||||
{
|
||||
const auto bytes = next.dump(); require(bytes.size() < max_file);
|
||||
const auto exists = std::filesystem::exists(file_);
|
||||
require(exists || !allocated_);
|
||||
if (exists)
|
||||
{
|
||||
// A synchronous engine command may replace the map/runtime before the
|
||||
// older call frame returns. It must never overwrite the new sequence.
|
||||
require(std::filesystem::file_size(file_) < max_file);
|
||||
std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint;
|
||||
require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ &&
|
||||
checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ &&
|
||||
checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence"));
|
||||
}
|
||||
std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp";
|
||||
std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush();
|
||||
require(stream.good()); stream.close(); require(!stream.fail());
|
||||
#ifdef _WIN32
|
||||
require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0);
|
||||
#else
|
||||
std::filesystem::rename(temp, file_);
|
||||
#endif
|
||||
saved_ = next; allocated_ = true;
|
||||
}
|
||||
void runtime::observe(int slot, const std::string& guid, const std::string& name)
|
||||
{
|
||||
require(slot >= 0 && slot < 64 && guid_identifier(guid));
|
||||
auto found = players.find(slot);
|
||||
if (found == players.end() || found->second.guid != canonical_guid(guid))
|
||||
{
|
||||
disconnect(slot);
|
||||
players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)});
|
||||
}
|
||||
else found->second.name = player_name(name);
|
||||
}
|
||||
void runtime::disconnect(int slot)
|
||||
{
|
||||
auto found = players.find(slot);
|
||||
if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); }
|
||||
}
|
||||
json runtime::request()
|
||||
{
|
||||
json roster = json::array(), receipts = json::array(); sent_.clear();
|
||||
for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}});
|
||||
for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it)
|
||||
if (!it.value().at("reported").get<bool>() && receipts.size() < 256)
|
||||
{
|
||||
receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}});
|
||||
sent_.push_back(it.key());
|
||||
}
|
||||
return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster},
|
||||
{"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}};
|
||||
}
|
||||
void runtime::accept(const std::string& response, long long now, long long transfer_age_ms)
|
||||
{
|
||||
const auto received = std::chrono::steady_clock::now();
|
||||
require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000);
|
||||
auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get<bool>());
|
||||
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
|
||||
auto revision = value.at("revision").get<long long>();
|
||||
auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120);
|
||||
require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 &&
|
||||
value.at("actions").is_array() && value.at("actions").size() <= 100 &&
|
||||
value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000);
|
||||
std::set<std::string> ids;
|
||||
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
|
||||
ids.clear();
|
||||
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); require(ids.emplace(id).second);
|
||||
if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); }
|
||||
if (revision == this->revision() && revision != 0)
|
||||
{
|
||||
auto previous = saved_.at("sanctions");
|
||||
for (auto it = previous.begin(); it != previous.end(); )
|
||||
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = previous.erase(it); else ++it;
|
||||
auto incoming = value.at("sanctions");
|
||||
for (auto it = incoming.begin(); it != incoming.end(); )
|
||||
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
|
||||
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
|
||||
std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id);
|
||||
require(incoming == previous);
|
||||
}
|
||||
auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions");
|
||||
for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true;
|
||||
// Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline.
|
||||
for (auto it = next["journal"].begin(); it != next["journal"].end(); )
|
||||
if (it.value().at("reported").get<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < now - 86400) it = next["journal"].erase(it); else ++it;
|
||||
save(next); pending_ = value.at("actions");
|
||||
const auto accepted = std::chrono::steady_clock::now();
|
||||
const auto processing = std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - received);
|
||||
const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) +
|
||||
std::chrono::milliseconds(transfer_age_ms) + processing;
|
||||
const auto previous = server_anchor_ + std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - clock_anchor_);
|
||||
server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate;
|
||||
clock_anchor_ = accepted;
|
||||
}
|
||||
long long runtime::effective_time(long long local) const
|
||||
{
|
||||
if (!server_anchor_.time_since_epoch().count()) return local;
|
||||
// Never extend deadlines when the host clock lags or jumps backward. The
|
||||
// full request age is conservative because serverTime is generated during it.
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
|
||||
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
|
||||
return std::max(local, static_cast<long long>(estimate));
|
||||
}
|
||||
void runtime::enforce(long long now, const executor& execute)
|
||||
{
|
||||
now = effective_time(now);
|
||||
auto pending = pending_; pending_ = json::array();
|
||||
for (const auto& item : pending)
|
||||
{
|
||||
auto id = item.at("id").get<std::string>();
|
||||
if (saved_["journal"].contains(id))
|
||||
{
|
||||
// A duplicate immutable action is acknowledged, never executed twice.
|
||||
require(saved_["journal"][id].at("action") == item);
|
||||
auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue;
|
||||
}
|
||||
require(saved_["journal"].size() < 10000);
|
||||
auto kind = item.at("kind").get<std::string>(); int slot = -1;
|
||||
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
|
||||
if (timestamp(item.at("expiresAt").get<std::string>()) <= now) { state = "expired"; detail = "Action deadline elapsed."; }
|
||||
else if (kind != "announce")
|
||||
{
|
||||
for (const auto& [number, target] : players)
|
||||
if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number;
|
||||
if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; }
|
||||
}
|
||||
auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next);
|
||||
if (state == "unknown")
|
||||
{
|
||||
// All target validation and dispatch occur on the calling game thread, without yielding.
|
||||
if (timestamp(item.at("expiresAt").get<std::string>()) <= effective_time(now)) state = "expired";
|
||||
else
|
||||
{
|
||||
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
|
||||
catch (...) { state = "unknown"; }
|
||||
if (state != "confirmed" && state != "failed") state = "unknown";
|
||||
}
|
||||
next = saved_; next["journal"][id]["state"] = state;
|
||||
next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next);
|
||||
}
|
||||
}
|
||||
// Snapshot is copied because kicking can synchronously invoke a disconnect callback.
|
||||
const auto roster = players;
|
||||
for (const auto& [slot, target] : roster)
|
||||
{
|
||||
now = effective_time(now);
|
||||
if (!players.contains(slot) || players.at(slot).connection != target.connection) continue;
|
||||
bool ban = false, mute = false; std::string why;
|
||||
for (const auto& policy : saved_.at("sanctions"))
|
||||
{
|
||||
if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get<std::string>()) <= now)) continue;
|
||||
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
|
||||
if (policy.at("kind") == "mute") mute = true;
|
||||
}
|
||||
if (ban)
|
||||
{
|
||||
if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now)
|
||||
{ kicked_[target.connection] = now; execute("kick", slot, command_text(why)); }
|
||||
continue;
|
||||
}
|
||||
bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection);
|
||||
auto owned = saved_.at("ownedMutes");
|
||||
bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end();
|
||||
if (mute != was_muted || (!mute && managed))
|
||||
{
|
||||
if (mute && !managed)
|
||||
{
|
||||
require(owned.size() < 10000); owned.push_back(target.guid);
|
||||
auto next = saved_; next["ownedMutes"] = owned; save(next);
|
||||
}
|
||||
execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute;
|
||||
if (!mute && managed)
|
||||
{
|
||||
owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end());
|
||||
auto next = saved_; next["ownedMutes"] = owned; save(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <json.hpp>
|
||||
|
||||
namespace utils::moderation
|
||||
{
|
||||
using json = nlohmann::json;
|
||||
struct player { int slot; std::string guid, name, connection; };
|
||||
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
|
||||
long long timestamp(const std::string& value);
|
||||
std::string command_text(const std::string& value);
|
||||
std::string new_session();
|
||||
std::string canonical_guid(const std::string& value);
|
||||
class runtime
|
||||
{
|
||||
public:
|
||||
runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope);
|
||||
std::string session = new_session();
|
||||
std::map<int, player> players;
|
||||
void observe(int slot, const std::string& guid, const std::string& name);
|
||||
void disconnect(int slot);
|
||||
json request();
|
||||
void accept(const std::string& response, long long now, long long transfer_age_ms = 0);
|
||||
void enforce(long long now, const executor& execute);
|
||||
long long revision() const { return saved_.at("revision").get<long long>(); }
|
||||
private:
|
||||
std::filesystem::path file_;
|
||||
std::string server_, endpoint_, credential_scope_;
|
||||
int generation_;
|
||||
unsigned long long serial_ = 0;
|
||||
bool allocated_ = false;
|
||||
std::chrono::system_clock::time_point server_anchor_{};
|
||||
std::chrono::steady_clock::time_point clock_anchor_{};
|
||||
json saved_, pending_ = json::array();
|
||||
std::vector<std::string> sent_;
|
||||
std::map<std::string, bool> muted_;
|
||||
std::map<std::string, long long> kicked_;
|
||||
void save(const json& next);
|
||||
long long effective_time(long long local) const;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user