the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,308 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Campus_live_rewrite.api.server.Classes
{
public class Sanitize
{
public string Context { get; set; } = "RoomChat";
public int Intent { get; set; } = 1;
public bool PreRemoveBlockedCharacters { get; set; } = true;
public string ReplacementChar { get; set; } = "*";
public string Value { get; set; } = "";
public int ruleset { get; set; } = 0;
private const char MaskChar = '*';
// Words that should never be censored
private static readonly HashSet<string> Whitelist = new(StringComparer.OrdinalIgnoreCase)
{
"password",
"pass",
"class",
"assistant",
"account"
};
// Bad words in NORMALIZED form (see Normalize): lowercase letters only, leet
// characters folded, runs of 3+ identical letters collapsed to 2. Both sides of
// the comparison go through the same normaliser, so a plain set lookup catches
// "F.U.C.K" and "b1tch" with no pattern matching at all.
//
// This replaced 1749 compiled regexes of the form ^f.{0,1}u.{0,1}c.{0,1}k$, run
// against every token of every message. The .{0,1} gaps were meant to jump
// separator characters - but Normalize already strips every non-letter before the
// match, so all they did was let a 3-letter entry match a 4-or-5-letter innocent
// word: "fag" matched FLAG, "gay" matched GRAY, "cum" matched CHUM, "hoe" matched
// HOSE. Exact comparison on the normalised token is both correct and far cheaper.
private static readonly HashSet<string> Swears = new(StringComparer.Ordinal);
static Sanitize()
{
// Absolute path. This runs in a static constructor, so a miss here throws
// TypeInitializationException on EVERY caller - room chat, DM threads, room
// names, usernames, display names, bios. A missing word list must degrade to
// "nothing is filtered", never take those endpoints down.
string path = Path.Combine(Environment.CurrentDirectory, "Data", "badwords.txt");
try
{
if (!File.Exists(path))
{
Console.WriteLine($"[Sanitize] word list not found at {path} - nothing will be filtered");
return;
}
int masked = 0;
foreach (var line in File.ReadAllLines(path))
{
// A masked entry ("f***", "****..just", "h*ll") is somebody's already-censored
// OUTPUT, not a word: normalised it becomes "just", "hll", "f" - which is how
// "just" got censored in chat. Skipped, never learnt.
if (line.Contains('*')) { masked++; continue; }
var normalized = Normalize(line);
if (normalized.Length >= 3) Swears.Add(normalized);
}
if (masked > 0) Console.WriteLine($"[Sanitize] skipped {masked} masked (*) entries in the word list");
Console.WriteLine($"[Sanitize] loaded {Swears.Count} words from {path}");
}
catch (Exception ex)
{
Console.WriteLine($"[Sanitize] could not load word list ({ex.Message}) - nothing will be filtered");
}
}
/* ----------------------------------------------------
* PUBLIC API
* --------------------------------------------------*/
public static bool ContainsSwears(string message)
{
if (string.IsNullOrWhiteSpace(message))
return false;
if (ConfigCampus.AllowSwears)
return false;
if (SplitKeepingWhitespace(message).Any(chunk => !IsWhitespace(chunk) && ChunkIsSwear(chunk))) return true;
return FindConcealed(message).Count > 0;
}
public static string SanitizeMessage(string message, char mask = MaskChar)
{
if (string.IsNullOrWhiteSpace(message))
return message;
var sb = new StringBuilder(message.Length);
// Two passes, because they catch different evasions.
//
// 1. The whole whitespace-delimited chunk, punctuation stripped. This is what
// catches "f.u.c.k", "f-u-c-k", "f*ck" and zero-width characters wedged
// between letters - splitting on \W+ first would turn those into
// single-letter tokens that are too short to test, which is exactly how
// they used to get through.
//
// 2. Failing that, the individual words inside the chunk, so "hey,bitch"
// (one chunk, normalising to the harmless "heybitch") still gets caught on
// the word "bitch".
// 3. The CONCEALED forms neither of those see, run first over the whole message so
// the masked characters are already in place when the token passes look:
// letters spread over several tokens ("s h i t", "sh it") and a swear
// repeated or lightly suffixed inside one token ("shitshit", "shitty").
// See FindConcealed for the rules that keep "this hit" and "cockpit" alone.
var concealed = FindConcealed(message);
if (concealed.Count > 0)
{
var chars = message.ToCharArray();
foreach (var (start, end) in concealed)
for (int i = start; i < end; i++)
if (char.IsLetterOrDigit(chars[i])) chars[i] = mask;
Console.WriteLine($"[Sanitize] Censored concealed word(s) in message: '{message}'");
message = new string(chars);
}
foreach (var chunk in SplitKeepingWhitespace(message))
{
if (IsWhitespace(chunk))
{
sb.Append(chunk);
continue;
}
if (ChunkIsSwear(chunk))
{
sb.Append(new string(mask, chunk.Length));
Console.WriteLine($"[Sanitize] Censored word in message: '{chunk}'");
continue;
}
foreach (var token in Regex.Split(chunk, @"(\W+)"))
{
if (IsWord(token) && ChunkIsSwear(token))
{
sb.Append(new string(mask, token.Length));
Console.WriteLine($"[Sanitize] Censored word in message: '{token}'");
}
else
{
sb.Append(token);
}
}
}
return sb.ToString();
}
/* ----------------------------------------------------
* INTERNAL HELPERS
* --------------------------------------------------*/
private static string[] SplitKeepingWhitespace(string input) =>
Regex.Split(input, @"(\s+)");
// Swears of 4+ letters, the only ones worth hunting inside other text: the 3-letter
// entries ("ass", "cum") are exactly the ones that live inside innocent words.
private static readonly Lazy<string[]> LongSwears = new(() =>
Swears.Where(w => w.Length >= 4).OrderByDescending(w => w.Length).ToArray());
/// <summary>
/// Character spans [start, end) of swears that the token passes cannot see:
///
/// spread - the letters of one swear spread across several whitespace-separated
/// tokens ("s h i t", "sh it", "s.h.i t"). A match counts only when it
/// starts at the start of a token and ends at the end of one, covering
/// whole tokens - so "this hit" (thi|shit spans two partial tokens) is
/// not a match, "s h i t" is.
/// compound - one token that IS a swear repeated or lightly suffixed: "shitshit",
/// "shitty", "fucked". The rest of the token after the swear(s) must be
/// at most 2 letters, and the swear must start the token - so "cockpit"
/// (3 letters after) and "scunthorpe" (swear in the middle) are left alone.
///
/// Whole-token exact matches are the caller's job (ChunkIsSwear); this only adds the
/// forms it misses.
/// </summary>
private static List<(int start, int end)> FindConcealed(string message)
{
var spans = new List<(int, int)>();
if (string.IsNullOrWhiteSpace(message) || ConfigCampus.AllowSwears || Swears.Count == 0) return spans;
// Normalised letter stream with, for every letter, its index in the original.
var letters = new StringBuilder(message.Length);
var origIdx = new List<int>(message.Length);
var tokenStarts = new HashSet<int>(); // stream positions that begin a token
var tokenEnds = new HashSet<int>(); // stream positions just past a token's last letter
bool inToken = false;
for (int i = 0; i < message.Length; i++)
{
char c = message[i];
if (char.IsWhiteSpace(c))
{
if (inToken) { tokenEnds.Add(letters.Length); inToken = false; }
continue;
}
string n = Normalize(c.ToString());
if (n.Length == 0) continue; // punctuation / digit that folds to nothing
if (!inToken) { tokenStarts.Add(letters.Length); inToken = true; }
letters.Append(n[0]);
origIdx.Add(i);
}
if (inToken) tokenEnds.Add(letters.Length);
string stream = letters.ToString();
if (stream.Length < 4) return spans;
// Per-token bounds in stream coordinates, for the compound rule.
var tokens = tokenStarts.OrderBy(x => x).Zip(tokenEnds.OrderBy(x => x), (a, b) => (a, b)).ToList();
foreach (var swear in LongSwears.Value)
{
int from = 0;
while ((from = stream.IndexOf(swear, from, StringComparison.Ordinal)) >= 0)
{
int end = from + swear.Length;
bool spread = tokenStarts.Contains(from) && tokenEnds.Contains(end)
&& tokens.Count(t => t.a >= from && t.b <= end) >= 2;
bool compound = false;
if (!spread)
{
var tok = tokens.FirstOrDefault(t => t.a <= from && end <= t.b);
if (tok != default && tok.a == from)
{
// Strip every leading repeat of the swear, then allow a tiny tail.
int p = from;
while (p + swear.Length <= tok.b && string.CompareOrdinal(stream, p, swear, 0, swear.Length) == 0) p += swear.Length;
int tail = tok.b - p;
// The tail has to be an actual suffix ("s", "y", "ed", "er"...). Any
// two letters used to count, so "hello" was hell+o and censored.
string tailText = tail > 0 ? stream.Substring(p, tail) : "";
bool suffix = tail == 0 || CompoundSuffixes.Contains(tailText);
if (tail <= 2 && suffix && (p > from + swear.Length || tail > 0)) // repeated, or one swear + short suffix
{
compound = true; end = tok.b;
string whole = stream.Substring(tok.a, tok.b - tok.a);
if (Whitelist.Contains(whole) || Swears.Contains(whole)) compound = false; // exact forms are the token pass's
}
}
}
if (spread || compound)
{
if (!spans.Any(x => x.Item1 <= origIdx[from] && origIdx[end - 1] < x.Item2))
spans.Add((origIdx[from], origIdx[end - 1] + 1));
from = end;
}
else from++;
}
}
return spans;
}
private static readonly HashSet<string> CompoundSuffixes = new(StringComparer.Ordinal)
{ "s", "y", "z", "ed", "er", "es", "in", "ie", "ty", "rs", "ah" };
private static bool IsWhitespace(string s) =>
s.Length > 0 && char.IsWhiteSpace(s[0]);
private static bool IsWord(string input) =>
input.Any(char.IsLetterOrDigit);
private static bool ChunkIsSwear(string raw)
{
var normalized = Normalize(raw);
if (normalized.Length < 3) return false;
if (Whitelist.Contains(normalized)) return false;
if (Swears.Contains(normalized)) return true;
// "fuuuuck". Normalize leaves runs at 2 ("fuuck") so that "ass" does not
// become "as", but that means a stretched word no longer matches its entry.
// Squeezing every run to a single letter gives "fuck", which does. Only the
// TOKEN is squeezed - the word list keeps its 2-letter runs, so this cannot
// make the innocent "as" match the entry "ass".
var squeezed = Regex.Replace(normalized, @"(.)\1+", "$1");
return squeezed.Length >= 3
&& !Whitelist.Contains(squeezed)
&& Swears.Contains(squeezed);
}
private static string Normalize(string input)
{
input = input.ToLowerInvariant();
input = Regex.Replace(input, @"[0@]", "o");
input = Regex.Replace(input, @"[1!|]", "i");
input = Regex.Replace(input, @"[$5]", "s");
input = Regex.Replace(input, @"3", "e");
input = Regex.Replace(input, @"7", "t");
// Remove non letters
input = Regex.Replace(input, @"[^a-z]", "");
// Collapse repeats (fuuuuuck → fuuck)
input = Regex.Replace(input, @"(.)\1{2,}", "$1$1");
return input;
}
}
}