#include #include "moderation.hpp" #include #include #include #include #include #include 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(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(value[i + j]) & 0xc0) == 0x80; if (valid && width >= 3) { const auto second = static_cast(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(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(); 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(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(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()); 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())); 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() + 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() && 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()); require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL); auto revision = value.at("revision").get(); 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 ids; for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get()).second); } ids.clear(); for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get(); 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()) <= 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()) <= server_now) it = incoming.erase(it); else ++it; auto by_id = [](const json& a, const json& b) { return a.at("id").get() < b.at("id").get(); }; 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() && timestamp(it.value().at("action").at("expiresAt").get()) < 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(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(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::steady_clock::now() - clock_anchor_); const auto estimate = std::chrono::duration_cast((server_anchor_ + elapsed).time_since_epoch()).count(); return std::max(local, static_cast(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(); 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(); int slot = -1; auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain."); if (timestamp(item.at("expiresAt").get()) <= 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()) <= effective_time(now)) state = "expired"; else { try { state = execute(kind, slot, command_text(item.at("reason").get())); } 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()) <= now)) continue; if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get(); } 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); } } } } }