diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d738d17..1859886c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,6 +104,10 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) target_include_directories(dingosdk_global_bans_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_global_bans_tests PRIVATE dingosdk_json dingosdk_word_filter $<$:dingosdk_https>) add_test(NAME server_global_bans COMMAND dingosdk_global_bans_tests) + add_executable(dingosdk_server_words_tests Server/Test/server_words_tests.cpp Server/server_words.cpp) + target_include_directories(dingosdk_server_words_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_server_words_tests PRIVATE dingosdk_word_filter) + add_test(NAME server_words COMMAND dingosdk_server_words_tests) add_executable(dingosdk_word_filter_tests Engine/Core/Text/Test/word_filter_tests.cpp) target_link_libraries(dingosdk_word_filter_tests PRIVATE dingosdk_word_filter) add_test(NAME word_filter COMMAND dingosdk_word_filter_tests) @@ -231,7 +235,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_executable(dingosdk_server_load_tests Server/Test/server_load_tests.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp Launcher/updater.cpp - Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp + Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp diff --git a/Engine/Core/Text/Test/word_filter_tests.cpp b/Engine/Core/Text/Test/word_filter_tests.cpp index 5ab8a8e4..2c764493 100644 --- a/Engine/Core/Text/Test/word_filter_tests.cpp +++ b/Engine/Core/Text/Test/word_filter_tests.cpp @@ -24,6 +24,15 @@ void masks(std::string_view text, std::string_view expected) { } // namespace int main() { + // The filter has no words of its own: until a list arrives nothing is filtered. These stand + // in for the backend's. + clean("what the fuck"); + { + const std::vector listed{"fuck", "fucking", "motherfucker", "shit", "ass", "bitch", "rape", "raped", + "raping", "rapist", "gangrape", "dick", "cock", "cunt", "hell", "arse", + "perse", "tit", "cum", "sex", "paki", "crap", "turd", "puta"}; + dingosdk::text::set_word_lists(listed, {}); + } // Listed words, any case, whole or inside longer words. bad("fuck"); bad("FUCK this server"); @@ -41,6 +50,13 @@ int main() { bad("evil ass rape server"); bad("Rapist Crew"); bad("gangrape lobby"); + bad("bullshit"); + bad("dumbfucks"); + bad("shitty park"); + bad("fuckkkk"); + bad("xxfuckxx"); + bad("Dickhead Crew"); + bad("absofuckinglutely"); // Ordinary words that contain a listed word, and server names people use. clean("Hello World"); clean("Shell Shock Skatepark"); @@ -74,8 +90,8 @@ int main() { masks("f u c k", "* * * *"); masks("Hello friends", "Hello friends"); masks("fuckface", "****face"); - // The backend's lists take the built-in one's place: its words are no longer filtered, - // theirs are, and the ones not allowed at all are both filtered and told apart. + // New lists take the place of the ones before: those words are no longer filtered, the new + // ones are, and the ones not allowed at all are both filtered and told apart. { using namespace dingosdk::text; const auto expect = [&](bool ok, const char* what) { @@ -84,18 +100,32 @@ int main() { expect(!contains_forbidden_words("what the fuck"), "a word was forbidden before any list said so"); const std::vector filtered{"Darn", "heck"}, forbidden{"Zorblat", "@$$hat"}; set_word_lists(filtered, forbidden); - expect(!contains_bad_words("what the fuck") && contains_bad_words("oh D4RN it") && contains_bad_words("check"), + expect(!contains_bad_words("what the fuck") && contains_bad_words("oh D4RN it") && contains_bad_words("heckin") && + !contains_bad_words("check"), "the given filtered words are not the ones filtered"); expect(contains_forbidden_words("you z0rblat") && contains_forbidden_words("megazorblatter") && contains_forbidden_words("a s s h a t") && !contains_forbidden_words("darn heck") && !contains_forbidden_words("zorb lat"), "the words not allowed at all are not matched as the filter matches"); expect(mask_bad_words("darn you zorblat") == "**** you *******", "a forbidden word is not masked like a filtered one"); + // Short entries, and fragments of words, as the backend's lists have them: they are in + // many ordinary words, and count only as the word being said. + const std::vector fragments{"gger", "igger", "spik", "nega", "negar", "crip", "coon", "coons", "raper", + "nige", "niger", "nicker", "nickers", "hom0", "nigger", "the rapist"}; + set_word_lists({}, fragments); + for (const char* ordinary : + {"a bigger spike", "Spikes and spiked rails", "spiky", "trigger", "logger", "swagger", "debugger", "negative", + "vinegar", "renegade", "script", "description", "raccoon", "raccoons", "tycoon", "cocoon", "snicker", "snickers", "knickers", "triggers", + "scraper", "Nigeria", "Nigel", "homosexual", "my therapist", "therapists"}) + if (contains_forbidden_words(ordinary)) std::printf("FAIL: ordinary word forbidden: %s\n", ordinary), ++failures; + for (const char* said : {"coon", "c00ns", "spiks", "the spik", "bigcoon", "xxcoonxx", "crips", "niggers", "bniggerz", "sandnigger", + "homo", "homos", "rapers"}) + if (!contains_forbidden_words(said)) std::printf("FAIL: not forbidden: %s\n", said), ++failures; + expect(mask_bad_words("bigger coons") == "bigger *****", "a short word is not masked only where it is said"); set_word_lists({}, {}); expect(!contains_bad_words("darn zorblat fuck") && !contains_forbidden_words("zorblat") && mask_bad_words("darn") == "darn", "empty lists still filter"); reset_word_lists(); - expect(contains_bad_words("what the fuck") && !contains_bad_words("darn") && !contains_forbidden_words("zorblat"), - "the built-in list did not come back"); + expect(!contains_bad_words("what the fuck darn") && !contains_forbidden_words("zorblat"), "words are filtered without a list"); } if (failures == 0) std::printf("word filter: all checks passed\n"); return failures == 0 ? 0 : 1; diff --git a/Engine/Core/Text/bad_words.txt b/Engine/Core/Text/bad_words.txt deleted file mode 100644 index 07859417..00000000 --- a/Engine/Core/Text/bad_words.txt +++ /dev/null @@ -1,426 +0,0 @@ -@$$ -AssMonkey -Assface -Biatch -BlowJob -CarpetMuncher -Clit -Cock -CockSucker -Ekrem -Ekto -Felcher -Flikker -Fotze -Fu -FudgePacker -Fukah -Fuken -Fukin -Fukk -Fukkah -Fukken -Fukker -Fukkin -Goddamned -Huevon -Kurac -Lesbian -Lezzian -Lipshits -Lipshitz -MothaFucker -MothaFuker -MothaFukkah -MothaFukker -MotherFucker -MotherFukah -MotherFuker -MotherFukkah -MotherFukker -MuthaFucker -MuthaFukah -MuthaFuker -MuthaFukkah -MuthaFukker -Phuc -Phuck -Phuk -Phuker -Phukker -Poonani -Shitty -Shity -Sht -Shyt -Shyte -Shytty -Skanky -Slutty -ahole -amcik -andskota -anus -arschloch -arse -ash0le -ash0les -asholes -ass -assh0le -assh0lez -asshole -assholes -assholz -assrammer -asswipe -ayir -azzhole -b00bs -b17ch -b1tch -bassterds -bastard -bastards -bastardz -basterds -basterdz -bch -bi7ch -bich -bitch -bitches -blowjob -boffing -boiolas -bollock -boobs -breasts -btch -buceta -bullshit -butthole -buttpirate -buttwipe -c0ck -c0cks -c0k -cabron -cawk -cawks -cazzo -chink -chraa -chuj -cipa -clit -clits -cnts -cntz -cock -cockhead -cocks -cocksucker -crap -cum -cunt -cunts -cuntz -d4mn -damn -daygo -dego -dick -dike -dild0 -dild0s -dildo -dildos -dilld0 -dilld0s -dirsa -dominatricks -dominatrics -dominatrix -dupa -dyke -dziwka -ejackulate -ejakulate -enculer -enema -faen -fag -fag1t -faget -fagg1t -faggit -faggot -fagit -fags -fagz -faig -faigs -fanculo -fanny -fart -fatass -fcuk -feces -feg -ficken -fitt -flipping -foreskin -fuchah -fuck -fucka -fucker -fuckin -fucking -fucks -fuk -fukah -fuker -fukka -fukkah -fukker -futkretzn -fux0r -g00k -gay -gaybor -gayboy -gaygirl -gays -gayz -gangrape -gangraped -gook -guiena -h00r -h0ar -h0r -h0re -h4x0r -hell -hells -helvete -hoar -hoer -honkey -hoor -hoore -hore -hui -injun -jackoff -jap -japs -jerkoff -jisim -jism -jiss -jizm -jizz -kanker -kawk -kike -klootzak -knob -knobs -knobz -knulle -kraut -kuk -kuksuger -kunt -kunts -kuntz -kurwa -kusi -kyrpa -l3i+ch -l3itch -lesbian -lesbo -mamhoon -masochist -masokist -massterbait -masstrbait -masstrbate -masterbaiter -masterbat -masterbat3 -masterbate -masterbates -masturbat -masturbate -merd -mibun -mofo -monkleigh -motha -motherfucker -mouliewop -muie -mulkku -muschi -mutha -n1gr -nastt -nasty -nazi -nazis -nepesaurio -nigga -niggas -nigger -nigur -niiger -niigr -nutsack -orafis -orgasim -orgasm -orgasum -oriface -orifice -orifiss -orospu -p0rn -packi -packie -packy -paki -pakie -paky -paska -pecker -peeenus -peeenusss -peenus -peinus -pen1s -penas -penis -penisbreath -penus -penuus -perse -phuck -picka -pierdol -pillu -pimmel -pimpis -piss -pizda -polac -polack -polak -poontsee -poop -porn -pr0n -pr1c -pr1ck -pr1k -preteen -pula -pule -pusse -pussee -pussy -puta -puto -puuke -puuker -qahbeh -queef -queer -queers -queerz -qweers -qweerz -qweir -rape -raped -rapes -raping -rapist -rapists -rautenberg -recktum -rectum -retard -s.o.b. -sadist -scank -schaffer -scheiss -schlampe -schlong -schmuck -screw -screwing -scrotum -semen -sex -sexx -sexxx -sexy -sh1t -sh1ter -sh1ts -sh1tter -sh1tz -sharmuta -sharmute -shemale -shi+ -shipal -shit -shits -shitt -shitter -shitz -shiz -skanck -skank -skankee -skankey -skanks -skrib -slut -sluts -slutz -smut -sonofabitch -sx -teets -teez -testical -testicle -tit -tits -titt -turd -va1jina -vag1na -vagiina -vagina -vaj1na -vajina -vullva -vulva -w00se -w0p -wank -wh00r -wh0re -whoar -whore -xrated -xxx diff --git a/Engine/Core/Text/word_filter.cpp b/Engine/Core/Text/word_filter.cpp index 09da7949..0989948b 100644 --- a/Engine/Core/Text/word_filter.cpp +++ b/Engine/Core/Text/word_filter.cpp @@ -1,13 +1,14 @@ #include "word_filter.h" -#include "embedded_bad_words.h" - #include #include #include #include #include #include +#include +#include +#include #include #include @@ -47,13 +48,38 @@ constexpr std::string_view whole_word_only[]{ "rapist", "rapists", "rautenberg", "schaffer", "screw", "screwing", "semen", "shiz", "smut", "teets", "teez", "tits", "titt", "turd", "woose"}; -// Ordinary words with a bad word inside; a match that one of these covers does not count. +// Ordinary words with a bad word inside; a match that one of these covers does not count. One +// that is a listed entry with its spaces taken out ("the rapist") does not count either. constexpr std::string_view allowed_words[]{ "scunthorpe", "dickens", "dickinson", "dickson", "dickies", "cocktail", "cockpit", "peacock", "cockroach", "cockatoo", "cockatiel", "cockney", "cockle", "cockerel", "cockburn", "cocky", "hancock", "hitchcock", "woodcock", "babcock", "shuttlecock", "ashkenazi", "shiitake", "shitake", "matsushita", "yamashita", "kinoshita", "morishita", "takeshita", "swank", "swanky", "woodpecker", - "clitheroe", "snigger", "pussycat", "pissarro", "retardant"}; + "clitheroe", "snigger", "pussycat", "pissarro", "retardant", "cocker", "spike", "spiky", + "spiking", "snicker", "snickers", "knicker", "knickers", "triggers", "diggers", "riggers", + "therapist", "specialist", "cumin", "christmas", "christian", + "christopher", "christina", "christine"}; + +// A listed word this long is not found inside an ordinary word by accident ("nigger", +// "faggot"; allowed_words has the few exceptions), so it counts anywhere in a longer one. A +// shorter one ("fuck", "shit", but also a fragment like "spik" or "coon") is inside too many +// ordinary words: it counts only when the rest of the word is what people put around a swear +// word, so "motherfuckers", "shithead" and "bullshit" do, and "spike", "raccoon", "script" and +// "bigger" do not. +constexpr std::size_t counts_anywhere = 6; +// What may come before a short listed word, and after it. +constexpr std::string_view before_words[]{ + "a", "an", "the", "my", "ur", "your", "you", "u", "i", "im", "yo", "no", + "big", "lil", "little", "fat", "dumb", "old", "hot", "mega", "super", "ultra", "hella", "total", + "anti", "un", "non", "pro", "ex", "mc", "mr", "sir", "king", "lord", "god", "mother", + "mutha", "motha", "bull", "horse", "dog", "ape", "bat", "dip", "jack", "chicken", "cluster", "mind", + "brain", "butt", "pig", "skull", "stupid", "ugly", "dirty", "lazy", "crazy"}; +constexpr std::string_view after_words[]{ + "s", "es", "z", "ed", "er", "ers", "erz", "ing", "ings", "in", "y", "ie", + "ies", "ier", "ish", "a", "ah", "as", "az", "o", "os", "ism", "ist", "ists", + "head", "heads", "face", "faces", "hole", "holes", "wad", "tard", "bag", "boy", "boi", "girl", + "man", "stick", "wit", "wits", "lord", "king", "nut", "less", "ness", "ery", "fest", "show", + "storm", "load", "stain", "off", "up", "it", "me", "you", "u", "ya"}; struct Hash { using is_transparent = void; @@ -67,10 +93,15 @@ struct Lists { std::size_t shortest_inside{~std::size_t{}}, longest_inside{}; }; -void add(Lists& l, std::string_view line) { +std::string folded(std::string_view line) { std::string word; for (const char c : line) if (const char letter = fold(c)) word += letter; + return word; +} + +void add(Lists& l, std::string_view line) { + auto word = folded(line); if (word.empty()) return; l.whole.insert(word); if (word.size() > 3 && std::ranges::find(whole_word_only, word) == std::ranges::end(whole_word_only)) { @@ -84,23 +115,37 @@ void add(Lists& l, std::string_view line) { struct Rules { Lists filtered, forbidden; }; -std::shared_ptr built_in() { - static const auto rules = [] { - auto r = std::make_shared(); - std::string_view text = embedded::bad_words_list; - while (!text.empty()) { - const auto end = text.find('\n'); - add(r->filtered, text.substr(0, end)); - text = end == std::string_view::npos ? std::string_view{} : text.substr(end + 1); - } - return std::shared_ptr(std::move(r)); - }(); +// Until a list is given there are no words: nothing is filtered. +std::shared_ptr no_words() { + static const std::shared_ptr rules = std::make_shared(); return rules; } -std::atomic> given; + +// What the rules in use are made from: the backend's lists once they arrive, and a server's own +// words. Changed under the mutex; readers take the rules made from them. +struct Sources { + std::optional, std::vector>> given; + std::vector own_filtered, own_forbidden; + bool use_given = true; +}; +std::mutex sources_mutex; +Sources sources; +std::atomic> made; std::shared_ptr rules() { - auto current = given.load(); - return current ? current : built_in(); + auto current = made.load(); + return current ? current : no_words(); +} +void remake() { + const bool backend = sources.given && sources.use_given; + if (!backend && sources.own_filtered.empty() && sources.own_forbidden.empty()) return made.store({}); + auto r = std::make_shared(); + if (backend) { + for (const auto& word : sources.given->first) add(r->filtered, word); + for (const auto& word : sources.given->second) add(r->filtered, word), add(r->forbidden, word); + } + for (const auto& word : sources.own_filtered) add(r->filtered, word); + for (const auto& word : sources.own_forbidden) add(r->filtered, word), add(r->forbidden, word); + made.store(std::shared_ptr(std::move(r))); } // A word as folded letters, with where each letter sits in the text. @@ -148,10 +193,28 @@ bool covered(std::string_view letters, std::size_t start, std::size_t length) { return false; } +// Letters added only to get a word past the filter: "xx", "zz". +bool padding(std::string_view letters) { + return std::ranges::all_of(letters, [](char c) { return c == 'x' || c == 'z'; }); +} +// Whether a short listed word at letters[start, start + length) is the word being said, going by +// what is around it: nothing, another listed word, or one of the usual beginnings and endings. +// A last letter said again counts as part of it ("shitty", "fuckkk"). +bool said(const Lists& l, std::string_view letters, std::size_t start, std::size_t length) { + const auto before = letters.substr(0, start); + auto after = letters.substr(start + length); + while (!after.empty() && after.front() == letters[start + length - 1]) after.remove_prefix(1); + const auto fits = [&](std::string_view part, std::span usual) { + return part.empty() || padding(part) || l.whole.contains(part) || std::ranges::find(usual, part) != usual.end(); + }; + return fits(before, before_words) && fits(after, after_words); +} + // Whether `word` has a bad word in it; with `marked`, flags each letter that belongs to one. bool match(const Lists& l, const Word& word, std::vector* marked) { const std::string_view letters = word.letters; if (l.whole.contains(letters)) { + if (std::ranges::find(allowed_words, letters) != std::ranges::end(allowed_words)) return false; if (marked) marked->assign(letters.size(), true); return true; } @@ -160,6 +223,7 @@ bool match(const Lists& l, const Word& word, std::vector* marked) { for (std::size_t start = 0; start + l.shortest_inside <= letters.size(); ++start) for (auto length = l.shortest_inside; length <= l.longest_inside && start + length <= letters.size(); ++length) { if (!l.inside.contains(letters.substr(start, length)) || covered(letters, start, length)) continue; + if (length < counts_anywhere && !said(l, letters, start, length)) continue; if (!marked) return true; found = true; std::fill(marked->begin() + static_cast(start), @@ -185,13 +249,25 @@ bool contains_forbidden_words(std::string_view text) { } void set_word_lists(std::span filtered, std::span forbidden) { - auto r = std::make_shared(); - for (const auto& word : filtered) add(r->filtered, word); - for (const auto& word : forbidden) add(r->filtered, word), add(r->forbidden, word); - given.store(std::shared_ptr(std::move(r))); + std::lock_guard lock(sources_mutex); + sources.given.emplace(std::vector(filtered.begin(), filtered.end()), + std::vector(forbidden.begin(), forbidden.end())); + remake(); } -void reset_word_lists() { given.store({}); } +void reset_word_lists() { + std::lock_guard lock(sources_mutex); + sources.given.reset(); + remake(); +} + +void set_own_words(std::span filtered, std::span forbidden, bool use_given) { + std::lock_guard lock(sources_mutex); + sources.own_filtered.assign(filtered.begin(), filtered.end()); + sources.own_forbidden.assign(forbidden.begin(), forbidden.end()); + sources.use_given = use_given; + remake(); +} std::string mask_bad_words(std::string_view text) { const auto r = rules(); diff --git a/Engine/Core/Text/word_filter.h b/Engine/Core/Text/word_filter.h index df111424..f506e4f6 100644 --- a/Engine/Core/Text/word_filter.h +++ b/Engine/Core/Text/word_filter.h @@ -4,14 +4,17 @@ #include // The bad-word filter: a list of words matched against text after folding case and look-alike -// characters, so "Sh1t", "@$$" and "f u c k" count too. The list is the one in bad_words.txt -// (built in) until the ReSkate backend's lists arrive (set_word_lists), which are two: the -// filtered words, and the ones not allowed in chat at all. Both are filtered. +// characters, so "Sh1t", "@$$" and "f u c k" count too. The words are the ReSkate backend's +// (set_word_lists), in two lists: the filtered words, and the ones not allowed in chat at all. +// Both are filtered. Until they arrive there are none, and nothing is filtered. // // - Words of three letters or fewer, and a few longer ones that are common inside ordinary // words ("hell" in hello, "arse" in parse), only match a whole word. -// - Every other word also matches inside a longer one ("fuckface"), unless an ordinary word -// covers it there ("Scunthorpe", "cocktail"). +// - A word of six letters or more also matches anywhere inside a longer one, unless an ordinary +// word covers it there ("snigger", "retardant"). +// - A word of four or five letters matches inside a longer one only when the rest of it is what +// goes around a swear word ("fuckface", "motherfuckers", "bullshit"), so not in "spike", +// "raccoon" or "script". // // Used for dedicated server names (the server refuses to list one, the client hides it) and, // as a player option, to mask chat. A dedicated server and a lobby's host do not pass on a chat @@ -20,10 +23,17 @@ namespace dingosdk::text { bool contains_bad_words(std::string_view text); // One of the words not allowed at all. There are none until set_word_lists gives some. bool contains_forbidden_words(std::string_view text); -// The lists in use from now on (any thread), in place of the built-in one. +// The lists in use from now on (any thread). void set_word_lists(std::span filtered, std::span forbidden); -// Back to the built-in list, with nothing forbidden. +// Back to no lists: nothing filtered or forbidden but a server's own words. void reset_word_lists(); +// A dedicated server's own words, on top of the lists above: more filtered words, and more +// that are not allowed at all (Server/server_words.h). The server sends its filtered ones to +// its players (Packet words), whose games give them here while they are on it, and none again +// when they leave. +// - `use_given` false (a server's "use_global_word_filter") leaves the backend's lists out: +// only the server's own words are filtered and forbidden. +void set_own_words(std::span filtered, std::span forbidden, bool use_given = true); // `text` with each letter of every bad word replaced by '*'; same length, other text untouched. std::string mask_bad_words(std::string_view text); diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 1499a6f8..2532c860 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -435,6 +435,10 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 (p.role_permissions & ~role_permission_bits) || std::any_of(p.role_commands.begin(), p.role_commands.end(), [](const auto &command) { return !valid_role_word(command, max_role_command); }))) throw std::invalid_argument("Invalid permissions"); + if (p.kind == PacketKind::words && + (!p.source || p.words.size() > max_server_words || + std::any_of(p.words.begin(), p.words.end(), [](const auto &word) { return !valid_server_word(word); }))) + throw std::invalid_argument("Invalid word list"); if (p.kind == PacketKind::hello && !p.text.empty() && !valid_member_name(p.text)) throw std::invalid_argument("Invalid player name"); if (p.kind == PacketKind::roster && !valid_voice_range(p.voice_range)) @@ -464,7 +468,7 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 p.kind != PacketKind::admin && p.kind != PacketKind::bans && p.kind != PacketKind::maps && p.kind != PacketKind::throwdown && p.kind != PacketKind::teleport && p.kind != PacketKind::physics_tuning && p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras && - p.kind != PacketKind::effects && p.kind != PacketKind::permissions) + p.kind != PacketKind::effects && p.kind != PacketKind::permissions && p.kind != PacketKind::words) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away @@ -623,6 +627,13 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.bytes.insert(w.bytes.end(), command.begin(), command.end()); } } + if (p.kind == PacketKind::words) { + w.integer(p.words.size(), 2); + for (const auto &word : p.words) { + w.integer(word.size(), 1); + w.bytes.insert(w.bytes.end(), word.begin(), word.end()); + } + } if (p.kind == PacketKind::maps) { w.integer(p.maps.size(), 2); for (const auto &asset : p.maps) { @@ -1006,6 +1017,17 @@ std::optional decode(std::span bytes) noexcept { if (!text(max_role_command, command) || !valid_role_word(command, max_role_command)) return {}; p.role_commands.push_back(std::move(command)); } + } else if (p.kind == PacketKind::words) { + const auto count = r.integer(2); + if (!p.source || count > max_server_words) return {}; + for (std::uint64_t i = 0; i < count; ++i) { + const auto length = r.integer(1); + if (length > max_server_word || length > bytes.size() - r.at) return {}; + std::string word(reinterpret_cast(bytes.data() + r.at), static_cast(length)); + r.at += static_cast(length); + if (!valid_server_word(word)) return {}; + p.words.push_back(std::move(word)); + } } else if (p.kind == PacketKind::maps) { const auto count = r.integer(2); if (!p.source || count > max_server_maps) return {}; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 95166c3b..6c38e43c 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -26,7 +26,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 48; +constexpr std::uint16_t protocol_version = 49; // A dedicated server's chat lines unless its owner says otherwise: violet (#8E5CFF) and lavender (#D9C8FF). inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U; constexpr std::size_t max_throwdown_message = 4096; @@ -122,7 +122,10 @@ enum class PacketKind : std::uint16_t { effects = 31, // A dedicated server telling one player what their role lets them do (Server/roles.h): // its name, the server commands they may use and their permissions beyond commands. - permissions = 32 + permissions = 32, + // A dedicated server's own filtered chat words (Server/server_words.h), for the chat filter + // of each player on it: sent on joining and whenever its owner changes them. + words = 33 }; // Packet::party_action. Requests go from a player to whoever hosts, a dedicated server or a // lobby's host (party_player = the other player involved, 0 for leave/open/close); invited @@ -167,6 +170,14 @@ inline constexpr std::uint32_t role_reserved_slot = 1, role_objects = 2, role_mo constexpr std::size_t max_admin_text = 320; constexpr std::size_t max_ban_rows = 256; constexpr std::size_t max_server_maps = 128, max_map_asset = 128; +// Packet::words: as many of a server's own filtered words as it sends, and how long one may be. +constexpr std::size_t max_server_words = 256, max_server_word = 32; +inline bool valid_server_word(std::string_view word) noexcept { + if (word.empty() || word.size() > max_server_word) return false; + for (const unsigned char c : word) + if (c < 32 || c == 127) return false; + return true; +} // A level asset as a server's map list carries it: printable ASCII, no '|'. bool valid_map_asset(std::string_view asset) noexcept; bool valid_map_pool(std::span pool, std::size_t maps) noexcept; // distinct indices below `maps` @@ -299,6 +310,7 @@ struct Packet { std::string role; bool role_all_commands{}; std::vector role_commands; + std::vector words; // words: a dedicated server's own filtered chat words std::uint32_t role_permissions{}; PartyAction party_action = PartyAction::leave; // party: what is asked or told std::uint64_t party_player{}; // party: the other player (see PartyAction) diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index da343965..a18ee2ec 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -88,6 +88,8 @@ void stop(Session &s, std::string reason) { s.server_bans.clear(); s.server_ban_total = 0; s.server_maps.clear(); + text::set_own_words({}, {}); // the server's own filtered words go with it + s.chat_masked.clear(); s.server_map_pool.clear(); s.server_map_rotation = 0; set_lobby_object_placement_allowed(true); diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 958931d7..356cfb2c 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -931,6 +931,15 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } continue; } + // The server's own filtered chat words: filtered here too while on it (reset_session clears them). + if (p.kind == PacketKind::words) { + if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { + text::set_own_words(p.words, {}); + s.chat_masked.clear(); + publish_chat(s); + } + continue; + } if (p.kind == PacketKind::maps) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { s.server_maps = p.maps; diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 95f06b08..bcd98e7c 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -1913,8 +1913,9 @@ void world_layer_sync_checks() { check(decode(bytes) && decode(bytes)->force_world_layers && unpack_world_layers(decode(bytes)->layers) == host.layers, "Roster lost world layer settings"); // The voice policy (allowed byte, 4-byte revision), voice range (2 bytes) and the guest - // noclip / No Bail byte follow the layer modes. - const auto layers_end = bytes.size() - 5 - 2 - 1; + // noclip / No Bail byte follow the layer modes, then what a dedicated server adds, here + // none: polls (1 byte), custom votes (1) and an announcement (4). + const auto layers_end = bytes.size() - 5 - 2 - 1 - 6; for (std::size_t n = layers_end - world_layers().size() - 1; n < bytes.size(); ++n) check(!decode(std::span(bytes).first(n)), "Truncated world layer settings accepted"); auto malformed = bytes; malformed[layers_end - 1] = 3; diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 33be021d..c3f8ca8e 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -1014,6 +1014,16 @@ void dedicated_server_codec() { const auto given = decode(encode(rights)); check(given && given->role == "moderator" && !given->role_all_commands && given->role_commands == rights.role_commands && given->role_permissions == rights.role_permissions, "A player's permissions failed to round-trip"); + Packet filter; + filter.kind = PacketKind::words; filter.session = 9; filter.epoch = 10; filter.map = 11; filter.source = server; + filter.words = {"florp", "gl0rp", "two words"}; + const auto sent = decode(encode(filter)); + check(sent && sent->kind == PacketKind::words && sent->words == filter.words, "A server's filtered words failed to round-trip"); + auto long_word = filter; + long_word.words = {std::string(max_server_word + 1, 'a')}; + check(reject(long_word), "A filtered word past its length was accepted"); + long_word.words.assign(max_server_words + 1, "a"); + check(reject(long_word), "More filtered words than a server may send were accepted"); auto none = rights; none.role.clear(); none.role_commands.clear(); none.role_permissions = 0; none.role_all_commands = true; const auto bare = decode(encode(none)); diff --git a/Extension/Multiplayer/Test/voice_chat_tests.cpp b/Extension/Multiplayer/Test/voice_chat_tests.cpp index 03e7b1d4..e714db86 100644 --- a/Extension/Multiplayer/Test/voice_chat_tests.cpp +++ b/Extension/Multiplayer/Test/voice_chat_tests.cpp @@ -130,17 +130,19 @@ int main() try { const auto decoded = decode(encoded); check(decoded && decoded->voice_policy == packet.voice_policy, "Host voice policy lost in roster"); check(decoded && decoded->voice_range == packet.voice_range, "Host voice range lost in roster"); - // The roster ends: voice allowed (1), policy revision (4), voice range (2), guest noclip / No Bail (1). - for (std::size_t i = encoded.size() - 8; i < encoded.size(); ++i) + // The roster ends: voice allowed (1), policy revision (4), voice range (2), guest noclip / No Bail (1), + // then what a dedicated server adds, here none: polls (1), custom votes (1), announcement (4). + const auto end = encoded.size() - 6; + for (std::size_t i = end - 8; i < encoded.size(); ++i) check(!decode(std::span(encoded).first(i)), "Truncated voice policy accepted"); - auto invalid = encoded; invalid[invalid.size() - 8] = 2; + auto invalid = encoded; invalid[end - 8] = 2; check(!decode(invalid), "Invalid host voice toggle accepted"); - invalid = encoded; invalid[invalid.size() - 1] = 128; + invalid = encoded; invalid[end - 1] = 128; check(!decode(invalid), "Invalid guest noclip / No Bail byte accepted"); invalid = encoded; - for (std::size_t i = invalid.size() - 7; i < invalid.size() - 3; ++i) invalid[i] = 0; + for (std::size_t i = end - 7; i < end - 3; ++i) invalid[i] = 0; check(!decode(invalid), "Zero host voice revision accepted"); - invalid = encoded; invalid[invalid.size() - 2] = 10; invalid[invalid.size() - 1] = 0; + invalid = encoded; invalid[end - 3] = 10; invalid[end - 2] = 0; check(!decode(invalid), "Voice range below the minimum accepted"); } const std::array origin{}; diff --git a/Server/README.txt b/Server/README.txt index 199765d0..3bdd7d3b 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -141,6 +141,11 @@ word_warnings The ReSkate team keeps a list of words that are not allowed they can join again, and are kicked again at the next. Warnings last until the server restarts. 0: nobody is warned or kicked; the message is still not passed on. +use_global_word_filter + Use the ReSkate team's chat word lists (default true). Set + false to leave them out: then only the words in this + server's own data/words_blocked.txt are refused in chat. + Console: words global on|off. See "Chat words" below. allow_voice_chat Allow voice chat. voice_range How far proximity voice reaches, 50-1000 m. object_placement everyone, admins (only admins can build), or nobody. @@ -351,6 +356,30 @@ them moved there the first time the server starts. Roles ----- +Chat words +---------- +Two files in data/, beside the bans, hold this server's own chat words. Both are +written with an explanation the first time the server starts. One word to a +line; a line starting with # is a comment. Edit them and type: words reload + + data/words_filtered.txt Words this server filters, on top of the team's. + Messages with them are still passed on; the server + sends the list to its players' games, which show + the word as stars to players who have their chat + filter on. The first 256 words of up to 32 letters + are sent. + data/words_blocked.txt Words not allowed in this server's chat at all, on + top of the team's. A message with one is not passed + on and its player is warned (word_warnings). + +"use_global_word_filter": false leaves the team's lists out altogether, so only +words_blocked.txt is refused. + +What this does not change: each player's game still stars out the team's +filtered words for a player who has their chat filter on, and hides a server +whose name has one of the team's words from the server browser. Those are the +team's lists in the player's game, whatever a server sets. + Who may do what is in data/roles.json, beside the bans. README-roles.txt has the whole of it: every permission and every command a role can be given, with examples. In short, the server writes the file the first time it starts, with @@ -485,6 +514,10 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. announcements [list | add | remove | clear | interval |off] The messages posted on a timer. activity-log on|off Log player activity (see activity_log). + words [reload | global on|off] + This server's chat words: what is in use, read + the two files again, or use the ReSkate team's + lists or not (see Chat words). announce-throwdowns on|off Chat message when a throwdown is placed. parties [on|off] List the parties, or allow them (off ends them all). party-size <2-8> Most players in one party. diff --git a/Server/Test/global_bans_tests.cpp b/Server/Test/global_bans_tests.cpp index f61a19f4..9caf33d8 100644 --- a/Server/Test/global_bans_tests.cpp +++ b/Server/Test/global_bans_tests.cpp @@ -40,9 +40,9 @@ void lists() { // A backend from before it had bans bans nobody. read = use_ban_list(R"({"categories":{"dev":[]}})"); check(read.ok && read.changed && read.banned == 0 && !reskate_banned(cheater), "A list without bans kept one"); - // The chat word lists ride in the same answer: none changes nothing, some replace the - // built-in list, and the same again is not a change. - check(!read.words_changed && !dingosdk::text::contains_forbidden_words("zorblat") && dingosdk::text::contains_bad_words("shit"), + // The chat word lists ride in the same answer: none changes nothing (no words are filtered + // yet), some are put in use, and the same again is not a change. + check(!read.words_changed && !dingosdk::text::contains_forbidden_words("zorblat") && !dingosdk::text::contains_bad_words("shit"), "An answer without word lists changed the words"); const auto with_words = R"({"categories":{},"filtered_words":["darn"],"forbidden_words":["zorblat",""]})"; read = use_ban_list(with_words); diff --git a/Server/Test/server_words_tests.cpp b/Server/Test/server_words_tests.cpp new file mode 100644 index 00000000..b987976d --- /dev/null +++ b/Server/Test/server_words_tests.cpp @@ -0,0 +1,69 @@ +#include "Server/server_words.h" +#include "Engine/Core/Text/word_filter.h" +#include +#include +#include +#include +#include + +using namespace dingosdk::server; +using namespace dingosdk::text; +namespace { +void check(bool ok, const std::string &message) { + if (ok) return; + std::cerr << message << '\n'; + std::exit(1); +} +} // namespace + +int main() { + const auto folder = std::filesystem::temp_directory_path() / "reskate-server-words-test"; + std::filesystem::remove_all(folder); + ServerConfig config; + config.file = folder / "ReSkateServer.json"; + + // The ReSkate team's lists, as a server has them once the backend answers. + const std::vector filtered{"darn"}, forbidden{"zorblat", "snarf", "snarfing"}; + set_word_lists(filtered, forbidden); + + // A new server: both files are written with their explanation, and hold no words. + auto words = use_own_words(config); + check(words.problem.empty() && !words.filtered && !words.blocked && words.sent.empty() && + std::filesystem::exists(filtered_words_file(config)) && std::filesystem::exists(blocked_words_file(config)), + "The word files were not written, or their explanation was read as words"); + check(contains_forbidden_words("you zorblat") && contains_forbidden_words("snarfing") && !contains_forbidden_words("florp"), + "A server without words of its own does not hold the team's lists"); + + // Its own words: filtered ones are starred and still allowed, blocked ones are refused. + std::ofstream(filtered_words_file(config), std::ios::binary | std::ios::app) + << "\xEF\xBB\xBFSmeg # a comment\r\n\r\nflibber\n" << std::string(40, 'a') << "\n"; + std::ofstream(blocked_words_file(config), std::ios::binary | std::ios::app) << "florp\n # nothing\nGl0rp\n"; + words = use_own_words(config); + check(words.filtered == 3 && words.blocked == 2, "The word files were not read a word to a line"); + check(words.sent == std::vector{"Smeg", "flibber"}, "The words sent to players are not the filtered ones that fit a packet"); + check(contains_bad_words("you smeg") && !contains_forbidden_words("you smeg") && mask_bad_words("flibber off") == "******* off", + "A filtered word of the server's own is not filtered, or is refused outright"); + check(contains_forbidden_words("florp") && contains_forbidden_words("you glorp") && contains_bad_words("florp") && + contains_bad_words("darn") && contains_forbidden_words("zorblat"), + "The server's own blocked words are not refused beside the team's"); + check(own_words_text(config, words).find("filters 3") != std::string::npos, "The log line does not count the words"); + + // Without the team's lists only its own words are filtered and refused. + config.global_words = false; + words = use_own_words(config); + check(!contains_forbidden_words("zorblat") && !contains_bad_words("darn") && contains_forbidden_words("florp") && + contains_bad_words("smeg"), + "use_global_word_filter off still holds the team's lists, or dropped the server's own"); + // The team's lists arriving again (every ten minutes) do not bring them back. + const std::vector later{"zorblat", "blarg"}; + set_word_lists(filtered, later); + check(!contains_forbidden_words("blarg") && contains_forbidden_words("florp"), "A later read of the team's lists turned them back on"); + config.global_words = true; + use_own_words(config); + check(contains_forbidden_words("blarg") && contains_forbidden_words("florp"), "use_global_word_filter on did not bring the team's lists back"); + + set_own_words({}, {}); + reset_word_lists(); + std::filesystem::remove_all(folder); + std::cout << "server words: ok\n"; +} diff --git a/Server/main.cpp b/Server/main.cpp index e2f9388b..0b3e580c 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -507,6 +507,7 @@ int run(int argc, char **argv, bool skip_update) { auto next_ban_check = next_advertise; bool bans_unread{}; if (!config.global_bans) write_log("Global bans are off (\"use_global_bans\": false): only this server's own bans apply."); + write_log(host.command("words")); // reads this server's own chat words // Signed in to Steam, as last logged, and when it is looked at again. Players already on stay // connected through a lost sign-in, but nobody new can join: Steam carries the first messages // of a connection, and theirs time out ("negotiate rendezvous"). diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 15199bce..8996ea42 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -1,4 +1,5 @@ #include "server_host.h" +#include "server_words.h" #include "server_text.h" #include "Engine/Core/Text/word_filter.h" #include "Engine/Game/World/park_randomization.h" @@ -28,7 +29,7 @@ constexpr std::string_view help_text = "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick |off | speed-check off|warn|kick | board-check off|warn|kick\n" - "score-check [off|warn|kick] | score-allow [|remove ]\n" + "score-check [off|warn|kick] | score-allow [|remove ] | words [reload | global on|off]\n" "reserved [slots | add|remove ] | roles [reload] | role |none | update | quit"; } // namespace @@ -421,6 +422,21 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { parties_.set_limit(config_.party_size); return changed("Parties hold up to " + std::to_string(config_.party_size) + " players. Larger ones stay until members leave."); } + if (name == "words") { + // This server's own chat words (server_words.h): the files are read again each time. + const auto [what, setting] = split(argument); + const auto global = what == "global" ? on_off(setting) : std::nullopt; + if (!what.empty() && what != "reload" && !global) return std::string("words | words reload | words global on|off"); + if (global) config_.global_words = *global; + auto read = use_own_words(config_); + if (read.sent != filtered_words_) { + filtered_words_ = std::move(read.sent); + ++words_revision_; // every player is sent them again (tick) + } + read.sent = filtered_words_; + const auto now = own_words_text(config_, read); + return global ? changed(now) : now; + } if (name == "activity-log") { const auto value = on_off(argument); if (!value) return std::string("activity-log on|off (now ") + (config_.activity_log ? "on" : "off") + ")"; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 5b1effc0..2c2b7e38 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -101,6 +101,7 @@ Layout layout(const ServerConfig &c) { players.set("party_size", c.party_size); players.set("afk_kick_minutes", c.afk_kick); players.set("word_warnings", c.word_warnings); + players.set("use_global_word_filter", c.global_words); players.set("allow_voice_chat", c.voice_chat); players.set("voice_range", static_cast(c.voice_range)); players.set("object_placement", placement_text(c.object_placement)); @@ -325,6 +326,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectorcommand("words")); // reads this server's own chat words (server_words.h) log(config_.name + " is up on " + host_->map_name() + " for " + std::to_string(config_.max_players) + " players.", LogLevel::success); log("Steam ID " + std::to_string(steam_->steam_id()) + diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 18943621..da544f74 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -115,6 +115,11 @@ void Host::send_permissions(Guest &guest) { } if (send_packet(guest, p, true, false)) guest.permissions_sent = roles_revision_; } +void Host::send_words(Guest &guest) { + auto p = packet(PacketKind::words, now_); + p.words = filtered_words_; + if (send_packet(guest, p, true, false)) guest.words_sent = words_revision_; +} void Host::roles_changed() { ++roles_revision_; ++bans_revision_; @@ -1830,6 +1835,7 @@ void Host::tick(std::uint64_t now) { // Whoever may change the map picks from it; with map votes on, everyone completes /vote map from it. if (!guest->maps_sent && (can(id, "map") || config_.votes.map.enabled)) send_maps(*guest); if (guest->permissions_sent != roles_revision_) send_permissions(*guest); + if (guest->words_sent != words_revision_) send_words(*guest); } const bool loading = std::any_of(guests_.begin(), guests_.end(), [](const auto &g) { return g.second->handshaken && !g.second->world_ready; }); diff --git a/Server/server_host.h b/Server/server_host.h index a3b59fc8..1155a37c 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -202,6 +202,7 @@ class Host { std::uint64_t next_introduction{}; bool maps_sent{}; // this player has the server's map list (send_maps) std::uint64_t permissions_sent{}; // the roles revision their permissions were sent at + std::uint64_t words_sent{}; // the words revision the server's filtered words were sent at // The owner's own upload, and what the server shares of it. ObjectState objects, shared; std::uint64_t shared_from{}; @@ -281,6 +282,10 @@ class Host { bool roster_dirty_ = true, running_{}; std::uint64_t bans_revision_ = 1; // bumped whenever the ban list or the roles change std::uint64_t roles_revision_ = 1; // bumped whenever the roles or who has them change + // The server's own filtered words as its players have them, and their revision: 0 until + // there are any, so a server without the file sends nothing. + std::vector filtered_words_; + std::uint64_t words_revision_{}; bool role_tags_checked_{}; std::uint64_t now_{}, last_roster_{}, last_world_state_{}, next_object_update_{}, travel_started_{}; std::string relay_status_; // Steam's relay network as last logged @@ -365,6 +370,7 @@ class Host { return role ? role->rank : -1; } void send_permissions(Guest &); // what their role lets them do + void send_words(Guest &); // the server's own filtered chat words (server_words.h) void roles_changed(); // after any change to the roles or who has them bool is_banned(std::uint64_t id) const; void save(); diff --git a/Server/server_words.cpp b/Server/server_words.cpp new file mode 100644 index 00000000..8be8962e --- /dev/null +++ b/Server/server_words.cpp @@ -0,0 +1,74 @@ +#include "server_words.h" +#include "Engine/Core/Text/word_filter.h" +#include "Extension/Multiplayer/Net/protocol.h" +#include +#include +#include +#include + +namespace dingosdk::server { +namespace { +// As many words of as many bytes as one of the team's lists holds. +constexpr std::size_t most_words = 5000, longest_word = 64; + +constexpr std::string_view filtered_help = + "# Words this server filters in chat, beside the ReSkate team's list.\n" + "# One word to a line. Messages with them are still passed on: players who have their chat filter on\n" + "# see the word as stars. The first 256 words of up to 32 letters are sent to players' games.\n" + "# Read when the server starts and by the console command: words reload\n"; +constexpr std::string_view blocked_help = + "# Words that are not allowed in this server's chat at all, beside the ReSkate team's list.\n" + "# One word to a line. A message with one is not passed on and its player is warned\n" + "# (see word_warnings in the README).\n" + "# Read when the server starts and by the console command: words reload\n"; + +std::vector read(const std::filesystem::path &file, std::string_view help, std::string &problem) { + std::vector words; + std::error_code error; + if (!std::filesystem::exists(file, error)) { + std::filesystem::create_directories(file.parent_path(), error); + std::ofstream out(file, std::ios::binary); + if (!(out << help)) problem = "could not write " + file.filename().string(); + return words; + } + std::ifstream in(file, std::ios::binary); + if (!in) { + problem = "could not read " + file.filename().string(); + return words; + } + for (std::string line; std::getline(in, line) && words.size() < most_words;) { + if (const auto comment = line.find('#'); comment != std::string::npos) line.erase(comment); + const auto first = line.find_first_not_of(" \t\r\xEF\xBB\xBF"), last = line.find_last_not_of(" \t\r"); + if (first == std::string::npos) continue; + line = line.substr(first, last - first + 1); + if (line.size() <= longest_word) words.push_back(std::move(line)); + } + return words; +} +} // namespace + +std::filesystem::path filtered_words_file(const ServerConfig &c) { return c.file.parent_path() / "data" / "words_filtered.txt"; } +std::filesystem::path blocked_words_file(const ServerConfig &c) { return c.file.parent_path() / "data" / "words_blocked.txt"; } + +OwnWords use_own_words(const ServerConfig &config) { + OwnWords result; + const auto filtered = read(filtered_words_file(config), filtered_help, result.problem); + const auto blocked = read(blocked_words_file(config), blocked_help, result.problem); + result.filtered = filtered.size(); + result.blocked = blocked.size(); + for (const auto &word : filtered) + if (result.sent.size() < multiplayer::max_server_words && multiplayer::valid_server_word(word)) result.sent.push_back(word); + text::set_own_words(filtered, blocked, config.global_words); + return result; +} + +std::string own_words_text(const ServerConfig &config, const OwnWords &words) { + std::ostringstream out; + out << "Chat words: " << (config.global_words ? "the ReSkate team's lists are used" + : "the ReSkate team's lists are off (\"use_global_word_filter\": false)") + << "; this server filters " << words.filtered << " word(s) of its own (data/words_filtered.txt, " << words.sent.size() + << " sent to players) and blocks " << words.blocked << " (data/words_blocked.txt)."; + if (!words.problem.empty()) out << " Problem: " << words.problem << "."; + return out.str(); +} +} // namespace dingosdk::server diff --git a/Server/server_words.h b/Server/server_words.h new file mode 100644 index 00000000..3aae4e71 --- /dev/null +++ b/Server/server_words.h @@ -0,0 +1,29 @@ +#pragma once +#include "server_config.h" +#include +#include +#include +#include + +namespace dingosdk::server { +// A server's own chat words, beside the ReSkate team's lists (Extension/Multiplayer/word_lists.h): +// data/words_filtered.txt and data/words_blocked.txt, one word to a line, with "#" starting a +// comment. A filtered word shows as stars to players who have their chat filter on (the server +// sends them its list); a blocked one is not allowed at all, as the team's are (config +// word_warnings). With "use_global_word_filter": false the team's lists are left out here, and +// only the blocked file is refused. +std::filesystem::path filtered_words_file(const ServerConfig &config); +std::filesystem::path blocked_words_file(const ServerConfig &config); +struct OwnWords { + std::size_t filtered{}, blocked{}; + // The filtered words that go to players: as many as a packet carries (max_server_words), + // without the ones too long for it. + std::vector sent; + std::string problem; // a file that could not be read or written; the words read still apply +}; +// Reads both files (writing each with its explanation first when it is not there) and puts +// them, and the config's use_global_word_filter, in use. +OwnWords use_own_words(const ServerConfig &config); +// What that did, for the log. +std::string own_words_text(const ServerConfig &config, const OwnWords &words); +} // namespace dingosdk::server diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 8a892c54..575205b0 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -180,7 +180,7 @@ if(WIN32) Server/server_engine.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp Server/server_update.cpp Server/server_release.cpp Launcher/updater.cpp - Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp + Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp @@ -197,7 +197,7 @@ else() add_executable(dingosdk_server Server/main.cpp Server/server_engine.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp Server/server_update.cpp Server/server_release.cpp - Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp + Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp diff --git a/cmake/Core.cmake b/cmake/Core.cmake index 8453fd7e..5b1df298 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -30,13 +30,7 @@ add_library(dingosdk_console_core STATIC add_library(dingosdk_json STATIC Engine/Core/Json/json.cpp) -# The bad-word filter (Engine/Core/Text/word_filter.h) with its list built in: editing -# bad_words.txt re-runs the configure step. -set(bad_words_path "${PROJECT_SOURCE_DIR}/Engine/Core/Text/bad_words.txt") -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${bad_words_path}") -file(READ "${bad_words_path}" bad_words_source) -configure_file(cmake/templates/bad_words.h.in - "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_bad_words.h" @ONLY) +# The bad-word filter (Engine/Core/Text/word_filter.h); its words come from the backend. add_library(dingosdk_word_filter STATIC Engine/Core/Text/word_filter.cpp) target_include_directories(dingosdk_json SYSTEM PRIVATE External) diff --git a/cmake/templates/bad_words.h.in b/cmake/templates/bad_words.h.in deleted file mode 100644 index b6724d49..00000000 --- a/cmake/templates/bad_words.h.in +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -// Generated from Engine/Core/Text/bad_words.txt (one word per line); see word_filter.h. -namespace dingosdk::text::embedded { -inline constexpr char bad_words_list[] = R"bad_words(@bad_words_source@)bad_words"; -}