#include "ModerationTools.h" #include "GameText.h" #include "ChatFilterWords.h" #include #include #include #include #include "RouteUtils.h" #include "DashboardRoutes.h" #include "ClientAssets.h" #include "GameLabels.h" #include "PlayerActions.h" #include "master/PlayerAction.h" #include "Strikes.h" #include "WSRoutes.h" #include "Database.h" #include "Web.h" #include "GeneralUtils.h" #include "eHTTPMethod.h" #include "eAccountLink.h" #include "ePlayerReportKind.h" #include "ePlayerReportStatus.h" #include "magic_enum.hpp" using namespace RouteUtils; namespace { constexpr uint32_t MAX_REPORTS = 200; // The chat filter's files, as the servers load them: the allowed words from the client's res folder, the blocked // words (only their hashes) next to the servers constexpr const char* ALLOW_FILE = "chatplus_en_us.txt"; constexpr const char* BLOCK_FILE = "blocklist.dcf"; constexpr uint32_t FILE_WORDS_PAGE = 200; // Recent chat searched when checking what a word would change constexpr uint32_t CHECK_MESSAGES = 1000; std::string Trimmed(std::string text, size_t max) { text.erase(0, text.find_first_not_of(" \t\r\n")); text.erase(text.find_last_not_of(" \t\r\n") + 1); return text.substr(0, max); } template nlohmann::json EnumLabels() { nlohmann::json list = nlohmann::json::array(); for (const auto value : magic_enum::enum_values()) { list.push_back({ {"value", static_cast(value)}, {"key", std::string(magic_enum::enum_name(value))}, {"name", GameLabels::Name(value)} }); } return list; } // Names looked up once per request struct Names { std::map characters; std::map accounts; const std::string& Character(LWOOBJID id) { if (!characters.contains(id)) { const auto info = id ? Database::Get()->GetCharacterInfo(id) : std::nullopt; characters[id] = info ? info->name : ""; } return characters[id]; } const std::string& Account(uint32_t id) { if (!accounts.contains(id)) { const auto account = id ? Database::Get()->GetAccountById(id) : nlohmann::json(); accounts[id] = account.is_object() ? account.value("name", "") : ""; } return accounts[id]; } }; nlohmann::json ReportJson(const IModeration::PlayerReport& r, Names& names) { const auto kind = magic_enum::enum_cast(r.kind); const auto status = static_cast(r.status); const auto& zones = GameText::ZoneNames(); const auto zone = std::to_string(r.zoneId); std::string propertyName; if (r.propertyId) { if (const auto property = Database::Get()->GetPropertyInfo(r.propertyId)) propertyName = property->name; } return { {"id", r.id}, {"created_at", r.createdAt}, {"kind", r.kind}, {"kind_name", kind ? GameLabels::Name(*kind) : r.kind}, {"reporter_id", std::to_string(r.reporterId)}, {"reporter_name", names.Character(r.reporterId)}, {"reporter_account_id", r.reporterAccountId}, {"object_id", std::to_string(r.objectId)}, {"object_lot", r.objectLot}, {"object_name", r.objectLot ? ClientAssets::ItemName(r.objectLot) : ""}, {"target_character_id", std::to_string(r.targetCharacterId)}, {"target_name", names.Character(r.targetCharacterId)}, {"target_account_id", r.targetAccountId}, {"target_account_name", names.Account(r.targetAccountId)}, {"property_id", std::to_string(r.propertyId)}, {"property_name", propertyName}, {"zone_id", r.zoneId}, {"zone_name", zones.contains(zone) ? zones[zone] : nlohmann::json("")}, {"instance_id", r.instanceId}, {"clone_id", r.cloneId}, {"body", r.body}, {"status", r.status}, {"status_key", std::string(magic_enum::enum_name(status))}, {"status_name", GameLabels::Name(status)}, {"handled_by", r.handledBy}, {"handled_at", r.handledAt}, {"resolution", r.resolution} }; } // Load a report for staff acting on it; replies with the error when it can't be acted on std::optional OpenReport(const HTTPContext& context, HTTPReply& reply) { const auto id = PathId(context.path, 2); const auto report = id ? Database::Get()->GetPlayerReport(*id) : std::nullopt; if (!report) { JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such report"); return std::nullopt; } if (report->status != static_cast(ePlayerReportStatus::OPEN)) { JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "This report was already handled by " + report->handledBy); return std::nullopt; } return report; } std::string ReportSubject(const IModeration::PlayerReport& report) { return "Report #" + std::to_string(report.id); } // Load the words again in every running world; the reply's request id shows how many did uint32_t ReloadWorlds(uint32_t requester) { PlayerActionRequest request; request.action = ePlayerAction::RELOAD_CHAT_FILTER; return PlayerActions::Request(request, requester, [](const PlayerActionResult& result) { return PlayerActions::Outcome{ true, result.affected ? "The chat filter was updated in " + std::to_string(result.affected) + " world(s)" : "No world is running; they'll use the words when they start" }; }); } void RegisterPlayerReportRoutes() { Route(eHTTPMethod::GET, "/player_reports", Perm("player_reports_view"), "Reports players sent from the game", [](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "player_reports.jinja2", "player_reports"); }); Route(eHTTPMethod::GET, "/api/player_reports", Perm("player_reports_view"), "Player reports, newest first. Query: status (ePlayerReportStatus value; missing: any), account (reported account), limit, offset. " "Returns {reports, total, kinds, statuses}", [](HTTPReply& reply, const HTTPContext& context) { IModeration::PlayerReportQuery query; query.status = GeneralUtils::TryParse(QueryValue(context.queryString, "status")).value_or(-1); query.accountId = GeneralUtils::TryParse(QueryValue(context.queryString, "account")).value_or(0); query.limit = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "limit")).value_or(50), 1, MAX_REPORTS); query.offset = GeneralUtils::TryParse(QueryValue(context.queryString, "offset")).value_or(0); Names names; nlohmann::json reports = nlohmann::json::array(); for (const auto& report : Database::Get()->GetPlayerReports(query)) reports.push_back(ReportJson(report, names)); JsonSuccess(reply, { {"reports", reports}, {"total", Database::Get()->CountPlayerReports(query)}, {"kinds", EnumLabels()}, {"statuses", EnumLabels()}, {"canManage", Can(context, "player_reports_manage")} }); }); Route(eHTTPMethod::POST, "/api/player_reports/:id/action", Perm("player_reports_manage"), "Mark a report acted on. Body: {reason (what was done), strike (true: also a strike on the reported account)}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context).value_or(nlohmann::json::object()); const auto report = OpenReport(context, reply); if (!report) return; const auto strike = Strikes::Requested(context, body, reply); if (!strike) return; if (*strike && report->targetAccountId == 0) { return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The game didn't say whose this is, so there's no account to give a strike to; nothing was done"); } if (*strike && !AuthorizeAccountAction(context, report->targetAccountId, reply, eAccountAction::MODERATION)) return; const auto reason = Trimmed(body.value("reason", ""), 500); Database::Get()->SetPlayerReportStatus(report->id, static_cast(ePlayerReportStatus::ACTIONED), context.authenticatedUser, reason, static_cast(std::time(nullptr))); Audit(context, "action_player_report", ReportSubject(*report) + (reason.empty() ? "" : ": " + reason), report->targetAccountId ? AuditTarget::Account(report->targetAccountId) : AuditTarget{}); nlohmann::json result{ {"message", ReportSubject(*report) + " marked acted on" + std::string(*strike ? ", with a strike" : "")} }; if (*strike) { Strikes::Give(context, report->targetAccountId, report->targetCharacterId, eStrikeSource::PLAYER_REPORT, ReportSubject(*report), reason.empty() ? report->body.substr(0, 200) : reason).Into(result); } BroadcastTableChanged("player_reports", std::to_string(report->id)); JsonSuccess(reply, result); }); Route(eHTTPMethod::POST, "/api/player_reports/:id/dismiss", Perm("player_reports_manage"), "Dismiss a report: nothing to act on. Body: {reason (optional)}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context).value_or(nlohmann::json::object()); const auto report = OpenReport(context, reply); if (!report) return; const auto reason = Trimmed(body.value("reason", ""), 500); Database::Get()->SetPlayerReportStatus(report->id, static_cast(ePlayerReportStatus::DISMISSED), context.authenticatedUser, reason, static_cast(std::time(nullptr))); Audit(context, "dismiss_player_report", ReportSubject(*report) + (reason.empty() ? "" : ": " + reason), report->targetAccountId ? AuditTarget::Account(report->targetAccountId) : AuditTarget{}); BroadcastTableChanged("player_reports", std::to_string(report->id)); JsonSuccess(reply, { {"message", ReportSubject(*report) + " dismissed"} }); }); } void RegisterLinkedAccountRoutes() { Route(eHTTPMethod::GET, "/api/accounts/:id/links", Perm("accounts_links"), "Other accounts sharing a play key, email address or login address with this one: {accounts: [{account_id, name, gm_level, banned, links: [{link, name, shared, last_seen}]}], loginAddresses}. " "Addresses themselves are never returned", [](HTTPReply& reply, const HTTPContext& context) { const auto accountId = PathId(context.path, 2); if (!accountId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid account"); // One entry per other account, with every signal it shares, in the order first found nlohmann::json accounts = nlohmann::json::array(); std::map index; for (const auto& linked : Database::Get()->GetLinkedAccounts(*accountId)) { if (!index.contains(linked.accountId)) { index[linked.accountId] = accounts.size(); accounts.push_back({ {"account_id", linked.accountId}, {"name", linked.name}, {"gm_level", linked.gmLevel}, {"banned", linked.banned}, {"links", nlohmann::json::array()} }); } const auto link = static_cast(linked.link); accounts[index[linked.accountId]]["links"].push_back({ {"link", std::string(magic_enum::enum_name(link))}, {"name", GameLabels::Name(link)}, {"shared", linked.shared}, {"last_seen", linked.lastSeen} }); } JsonSuccess(reply, { {"accounts", accounts}, {"loginAddresses", Database::Get()->CountLoginAddresses(*accountId)}, {"reports", Database::Get()->CountPlayerReports({ .status = -1, .accountId = *accountId })}, {"openReports", Database::Get()->CountPlayerReports({ .status = static_cast(ePlayerReportStatus::OPEN), .accountId = *accountId })}, {"canSeeReports", Can(context, "player_reports_view")} }); }); } std::vector AllowFileWords() { const auto text = ClientAssets::ReadResFile(ALLOW_FILE); return text ? ModerationTools::FileWords(*text) : std::vector{}; } std::optional> BlockFileHashes() { std::ifstream in(BLOCK_FILE, std::ios::binary); if (!in) return std::nullopt; return ModerationTools::DcfHashes(std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator())); } // The dashboard's own lists by word: true allowed, false blocked std::map DashboardWords() { std::map words; for (const auto& word : Database::Get()->GetChatFilterWords()) words[word.word] = word.allowed; return words; } void RegisterChatFilterRoutes() { Route(eHTTPMethod::GET, "/chat_filter", Perm("chat_filter_manage"), "Words the chat filter allows and stops", [](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "chat_filter.jinja2", "chat_filter"); }); Route(eHTTPMethod::GET, "/api/chat_filter", Perm("chat_filter_manage"), "Words staff added to the chat filter: {words: [{word, allowed, added_by, added_at}]}", [](HTTPReply& reply, const HTTPContext& context) { nlohmann::json words = nlohmann::json::array(); for (const auto& word : Database::Get()->GetChatFilterWords()) { words.push_back({ {"word", word.word}, {"allowed", word.allowed}, {"added_by", word.addedBy}, {"added_at", word.addedAt} }); } JsonSuccess(reply, { {"words", words} }); }); Route(eHTTPMethod::GET, "/api/chat_filter/files", Perm("chat_filter_manage"), "The words of the filter's files: the allowed words of chatplus_en_us.txt (from the client) matching ?search=, 200 from ?start=, " "each with what the dashboard's lists say about it, and how many hashed words blocklist.dcf has (its words can't be listed)", [](HTTPReply& reply, const HTTPContext& context) { const auto all = AllowFileWords(); const auto search = ModerationTools::FilterWord(QueryValue(context.queryString, "search")).value_or(""); const auto start = GeneralUtils::TryParse(QueryValue(context.queryString, "start")).value_or(0); const auto dashboard = DashboardWords(); nlohmann::json words = nlohmann::json::array(); uint32_t matched = 0; for (const auto& word : all) { if (!search.empty() && word.find(search) == std::string::npos) continue; if (matched++ < start || words.size() >= FILE_WORDS_PAGE) continue; const auto it = dashboard.find(word); words.push_back({ {"word", word}, {"dashboard", it == dashboard.end() ? nlohmann::json(nullptr) : nlohmann::json(it->second ? "allowed" : "blocked")} }); } const auto blocked = BlockFileHashes(); uint32_t imported = 0; for (const auto& word : all) if (dashboard.contains(word)) imported++; JsonSuccess(reply, { {"allowFile", ALLOW_FILE}, {"allowFileFound", !all.empty()}, {"allowTotal", all.size()}, {"matched", matched}, {"start", start}, {"pageSize", FILE_WORDS_PAGE}, {"words", words}, {"onDashboard", imported}, {"blockFile", BLOCK_FILE}, {"blockFileFound", blocked.has_value()}, {"blockTotal", blocked ? blocked->size() : 0} }); }); Route(eHTTPMethod::GET, "/api/chat_filter/lookup", Perm("chat_filter_manage"), "Where a word stands: in the allowed words file, in the blocked words file (by its hash), and on the dashboard's lists. Query: word", [](HTTPReply& reply, const HTTPContext& context) { const auto word = ModerationTools::FilterWord(QueryValue(context.queryString, "word")); if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type one word (no spaces), up to 64 characters"); const auto all = AllowFileWords(); const auto blocked = BlockFileHashes(); const auto dashboard = DashboardWords(); const auto it = dashboard.find(*word); JsonSuccess(reply, { {"word", *word}, {"inAllowFile", std::binary_search(all.begin(), all.end(), *word)}, {"inBlockFile", blocked && std::find(blocked->begin(), blocked->end(), ModerationTools::WordHash(*word)) != blocked->end()}, {"dashboard", it == dashboard.end() ? nlohmann::json(nullptr) : nlohmann::json(it->second ? "allowed" : "blocked")} }); }); Route(eHTTPMethod::POST, "/api/chat_filter/import", Perm("chat_filter_manage"), "Copy the words of chatplus_en_us.txt into the dashboard's allowed list, so they can be edited here; words already on either of " "the dashboard's lists are left as they are", [](HTTPReply& reply, const HTTPContext& context) { const auto all = AllowFileWords(); if (all.empty()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, std::string("Could not read ") + ALLOW_FILE + " from the client (client_location)"); const auto dashboard = DashboardWords(); const auto now = static_cast(std::time(nullptr)); uint32_t added = 0; DatabaseTransaction transaction(*Database::Get()); for (const auto& word : all) { // Only words the filter could compare (the file's odd lines with spaces or punctuation stay in the file only) const auto filterWord = ModerationTools::FilterWord(word); if (!filterWord || *filterWord != word || dashboard.contains(word)) continue; Database::Get()->SetChatFilterWord({ word, true, context.authenticatedUser, now }); added++; } transaction.Commit(); Audit(context, "chat_filter_import", "Copied " + std::to_string(added) + " words from " + ALLOW_FILE + " into the chat filter's allowed list"); BroadcastTableChanged("chat_filter"); JsonSuccess(reply, { {"message", "Copied " + std::to_string(added) + " words (" + std::to_string(all.size() - added) + " were already here or can't be used)"}, {"added", added}, {"requestId", ReloadWorlds(context.accountId)} }); }); Route(eHTTPMethod::POST, "/api/chat_filter/words", Perm("chat_filter_manage"), "Allow or block a word (or move it to the other list); running worlds pick it up at once. Body: {word, allowed: bool}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); const auto word = ModerationTools::FilterWord(body->value("word", "")); if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type one word (no spaces), up to 64 characters"); const bool allowed = body->value("allowed", false); Database::Get()->SetChatFilterWord({ *word, allowed, context.authenticatedUser, static_cast(std::time(nullptr)) }); Audit(context, allowed ? "chat_filter_allow" : "chat_filter_block", (allowed ? "Allowed \"" : "Blocked \"") + *word + "\" in chat"); BroadcastTableChanged("chat_filter"); JsonSuccess(reply, { {"message", "\"" + *word + "\" is now " + (allowed ? "allowed" : "blocked")}, {"requestId", ReloadWorlds(context.accountId)} }); }); Route(eHTTPMethod::POST, "/api/chat_filter/words/delete", Perm("chat_filter_manage"), "Remove a word added here; the filter's files decide about it again. Body: {word}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); const auto word = body ? ModerationTools::FilterWord(body->value("word", "")) : std::nullopt; if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid word"); if (!Database::Get()->DeleteChatFilterWord(*word)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That word isn't on either list"); Audit(context, "chat_filter_remove", "Removed \"" + *word + "\" from the chat filter's lists"); BroadcastTableChanged("chat_filter"); JsonSuccess(reply, { {"message", "\"" + *word + "\" removed"}, {"requestId", ReloadWorlds(context.accountId)} }); }); Route(eHTTPMethod::POST, "/api/chat_filter/reload", Perm("chat_filter_manage"), "Load the words again in every running world", [](HTTPReply& reply, const HTTPContext& context) { Audit(context, "chat_filter_reload", "Reloaded the chat filter's words in running worlds"); JsonSuccess(reply, { {"requestId", ReloadWorlds(context.accountId)} }); }); Route(eHTTPMethod::GET, "/api/chat_filter/test", Perm("chat_filter_manage"), "Whether the filter would stop a message from a player below GM level 2, and why, word by word. Query: message (up to 300 characters), " "chat (normal, or free: best friends' free chat). Returns {stopped, words: [{text, word, stopped, reason}]}; reason is one of blocked_here, " "allowed_here, allow_file, character_name, not_allowed, block_file, not_in_block_file, no_block_file", [](HTTPReply& reply, const HTTPContext& context) { const auto message = QueryValue(context.queryString, "message"); if (message.empty() || message.size() > 300) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type a message of 1 to 300 characters"); const bool allowList = QueryValue(context.queryString, "chat") != "free"; const auto all = AllowFileWords(); const auto blocked = BlockFileHashes(); const auto dashboard = DashboardWords(); std::set names; for (auto name : Database::Get()->GetApprovedCharacterNames()) { std::transform(name.begin(), name.end(), name.begin(), ::tolower); names.insert(std::move(name)); } ModerationTools::WordSources sources; sources.dashboard = [&dashboard](const std::string& word) -> std::optional { const auto it = dashboard.find(word); return it == dashboard.end() ? std::nullopt : std::optional(it->second); }; sources.allowFile = [&all](const std::string& word) { return std::binary_search(all.begin(), all.end(), word); }; sources.characterName = [&names](const std::string& word) { return names.contains(word); }; sources.blockFile = [&blocked](const std::string& word) { return blocked && std::find(blocked->begin(), blocked->end(), ModerationTools::WordHash(word)) != blocked->end(); }; sources.blockFileLoaded = blocked && !blocked->empty(); nlohmann::json words = nlohmann::json::array(); bool stopped = false; for (const auto& verdict : ModerationTools::ExplainMessage(message, allowList, sources)) { stopped |= verdict.stopped; words.push_back({ {"text", verdict.text}, {"word", verdict.word}, {"stopped", verdict.stopped}, {"reason", verdict.reason} }); } JsonSuccess(reply, { {"message", message}, {"chat", allowList ? "normal" : "free"}, {"stopped", stopped}, {"words", words}, {"allowFileFound", !all.empty()}, {"blockFileFound", blocked.has_value()} }); }); Route(eHTTPMethod::GET, "/api/chat_filter/check", Perm("chat_filter_manage"), "Recent chat a word would change. Query: word, allowed (1: messages the filter stopped that contain it; otherwise messages it let through " "that blocking it would have stopped). Searches the newest 1000 messages containing the text. Also needs chat_view; private chat only with chat_private", [](HTTPReply& reply, const HTTPContext& context) { if (!Can(context, "chat_view")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reading chat needs the chat_view permission"); const auto word = ModerationTools::FilterWord(QueryValue(context.queryString, "word")); if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type one word (no spaces), up to 64 characters"); const bool allowed = QueryValue(context.queryString, "allowed") == "1"; IChatLog::ChatQuery query; query.search = *word; query.includePrivate = Can(context, "chat_private"); query.includeWhispers = Can(context, "chat_dms"); query.blockedOnly = allowed; query.newestFirst = true; query.limit = CHECK_MESSAGES; const auto& zones = GameText::ZoneNames(); nlohmann::json messages = nlohmann::json::array(); uint32_t searched = 0; for (const auto& m : Database::Get()->GetChatMessages(query)) { searched++; // Only messages from players: web messages aren't filtered, and the filter compares whole words if (m.channel == "web" || m.blocked != allowed || !ModerationTools::HasFilterWord(m.message, *word)) continue; const auto zone = std::to_string(m.zoneId); messages.push_back({ {"id", m.id}, {"time", m.time}, {"channel", m.channel}, {"sender_id", std::to_string(m.senderId)}, {"sender_name", m.senderName}, {"account_id", m.accountId}, {"zone_id", m.zoneId}, {"zone_name", zones.contains(zone) ? zones[zone] : nlohmann::json("")}, {"message", m.message}, {"blocked", m.blocked} }); } JsonSuccess(reply, { {"word", *word}, {"allowed", allowed}, {"messages", messages}, {"searched", searched}, {"limit", CHECK_MESSAGES} }); }); } } void RegisterModerationToolRoutes() { RegisterPlayerReportRoutes(); RegisterLinkedAccountRoutes(); RegisterChatFilterRoutes(); }