162 lines
8.2 KiB
C++
162 lines
8.2 KiB
C++
#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)
|