mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
feat: implement profanity and non-standard text validators
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
"name": "@modrinth/moderation",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"test": "node --test src/validators/*/tests.ts",
|
||||
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file src/locales/en-US/index.json --preserve-whitespace",
|
||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation"
|
||||
},
|
||||
|
||||
@@ -19,3 +19,5 @@ export * from './types/quick-reply'
|
||||
export * from './types/reports'
|
||||
export * from './types/settings'
|
||||
export * from './utils'
|
||||
export * from './validators/non-standard-text'
|
||||
export * from './validators/profanity'
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// the following are non-stardard text that are detected
|
||||
export type NonStandardTextIssueKind =
|
||||
| 'fancy' // styled characters such as `𝐀`, `Ⓐ`, or `A`.
|
||||
| 'zalgo' // detached or excessive combining marks such as `a̴̵̶`.
|
||||
| 'invisible' // hidden formatting such as a zero-width space or bidi override.
|
||||
| 'control' // disallowed control characters such as a null byte.
|
||||
| 'private-use' // characters from Unicode private-use areas.
|
||||
| 'unassigned' // code points with no assigned Unicode character.
|
||||
| 'surrogate' // malformed standalone UTF-16 surrogate code units.
|
||||
|
||||
export interface NonStandardTextIssue {
|
||||
kind: NonStandardTextIssueKind
|
||||
character: string
|
||||
codePoint: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface NonStandardTextResult {
|
||||
valid: boolean
|
||||
issues: NonStandardTextIssue[]
|
||||
counts: Record<NonStandardTextIssueKind, number>
|
||||
}
|
||||
|
||||
export interface NonStandardTextOptions {
|
||||
allowNewlines?: boolean
|
||||
allowTabs?: boolean
|
||||
maxCombiningMarksPerCharacter?: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER = 2
|
||||
|
||||
const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x02b0, 0x02ff],
|
||||
[0x1d400, 0x1d7ff],
|
||||
[0x2460, 0x24ff],
|
||||
[0x2070, 0x209f],
|
||||
[0x2100, 0x214f],
|
||||
[0xfb00, 0xfb06],
|
||||
[0xff01, 0xff60],
|
||||
[0x1f100, 0x1f1ad],
|
||||
]
|
||||
|
||||
const MARK_PATTERN = /\p{M}/u
|
||||
const CONTROL_PATTERN = /\p{Cc}/u
|
||||
const FORMAT_PATTERN = /\p{Cf}/u
|
||||
const PRIVATE_USE_PATTERN = /\p{Co}/u
|
||||
const UNASSIGNED_PATTERN = /\p{Cn}/u
|
||||
const LETTER_PATTERN = /\p{L}/u
|
||||
const EXTENDED_PICTOGRAPHIC_PATTERN = /\p{Extended_Pictographic}/u
|
||||
const UNIFIED_IDEOGRAPH_PATTERN = /\p{Unified_Ideograph}/u
|
||||
|
||||
function createCounts(): Record<NonStandardTextIssueKind, number> {
|
||||
return {
|
||||
fancy: 0,
|
||||
zalgo: 0,
|
||||
invisible: 0,
|
||||
control: 0,
|
||||
'private-use': 0,
|
||||
unassigned: 0,
|
||||
surrogate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function isInRanges(codePoint: number, ranges: ReadonlyArray<readonly [number, number]>) {
|
||||
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end)
|
||||
}
|
||||
|
||||
function isVariationSelector(codePoint: number) {
|
||||
return (
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||
)
|
||||
}
|
||||
|
||||
function isEmojiModifier(codePoint: number) {
|
||||
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff
|
||||
}
|
||||
|
||||
function isAscii(character: string) {
|
||||
return character.codePointAt(0)! <= 0x7f
|
||||
}
|
||||
|
||||
function isAllowedZeroWidthNonJoiner(
|
||||
characters: readonly string[],
|
||||
characterIndex: number,
|
||||
): boolean {
|
||||
const previous = characters[characterIndex - 1]
|
||||
const next = characters[characterIndex + 1]
|
||||
if (!previous || !next || !LETTER_PATTERN.test(previous) || !LETTER_PATTERN.test(next)) {
|
||||
return false
|
||||
}
|
||||
return !isAscii(previous) || !isAscii(next)
|
||||
}
|
||||
|
||||
function findAdjacentEmojiCharacter(
|
||||
characters: readonly string[],
|
||||
start: number,
|
||||
direction: -1 | 1,
|
||||
): string | undefined {
|
||||
for (let index = start; index >= 0 && index < characters.length; index += direction) {
|
||||
const character = characters[index]
|
||||
const codePoint = character.codePointAt(0)!
|
||||
if (isVariationSelector(codePoint) || isEmojiModifier(codePoint)) continue
|
||||
return character
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isAllowedZeroWidthJoiner(characters: readonly string[], characterIndex: number): boolean {
|
||||
const previous = findAdjacentEmojiCharacter(characters, characterIndex - 1, -1)
|
||||
const next = findAdjacentEmojiCharacter(characters, characterIndex + 1, 1)
|
||||
return (
|
||||
previous !== undefined &&
|
||||
next !== undefined &&
|
||||
EXTENDED_PICTOGRAPHIC_PATTERN.test(previous) &&
|
||||
EXTENDED_PICTOGRAPHIC_PATTERN.test(next)
|
||||
)
|
||||
}
|
||||
|
||||
function isAllowedVariationSelector(
|
||||
characters: readonly string[],
|
||||
characterIndex: number,
|
||||
codePoint: number,
|
||||
): boolean {
|
||||
const previous = characters[characterIndex - 1]
|
||||
if (!previous) return false
|
||||
if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) {
|
||||
return EXTENDED_PICTOGRAPHIC_PATTERN.test(previous) || /^[0-9#*]$/u.test(previous)
|
||||
}
|
||||
return UNIFIED_IDEOGRAPH_PATTERN.test(previous)
|
||||
}
|
||||
|
||||
function codePointLabel(codePoint: number) {
|
||||
return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
|
||||
}
|
||||
|
||||
export function validateNonStandardText(
|
||||
text: string,
|
||||
options: NonStandardTextOptions = {},
|
||||
): NonStandardTextResult {
|
||||
const allowNewlines = options.allowNewlines ?? true
|
||||
const allowTabs = options.allowTabs ?? true
|
||||
const maxCombiningMarks =
|
||||
options.maxCombiningMarksPerCharacter ?? DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER
|
||||
if (!Number.isInteger(maxCombiningMarks) || maxCombiningMarks < 0) {
|
||||
throw new Error('Maximum combining marks must be a non-negative integer')
|
||||
}
|
||||
|
||||
const issues: NonStandardTextIssue[] = []
|
||||
const counts = createCounts()
|
||||
const characters = Array.from(text)
|
||||
let utf16Index = 0
|
||||
let hasBaseCharacter = false
|
||||
let combiningMarkCount = 0
|
||||
|
||||
function addIssue(
|
||||
kind: NonStandardTextIssueKind,
|
||||
character: string,
|
||||
codePoint: number,
|
||||
index: number,
|
||||
) {
|
||||
issues.push({
|
||||
kind,
|
||||
character,
|
||||
codePoint: codePointLabel(codePoint),
|
||||
index,
|
||||
})
|
||||
counts[kind]++
|
||||
}
|
||||
|
||||
for (let characterIndex = 0; characterIndex < characters.length; characterIndex++) {
|
||||
const character = characters[characterIndex]
|
||||
const codePoint = character.codePointAt(0)!
|
||||
const currentIndex = utf16Index
|
||||
utf16Index += character.length
|
||||
|
||||
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
||||
addIssue('surrogate', character, codePoint, currentIndex)
|
||||
hasBaseCharacter = false
|
||||
combiningMarkCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (PRIVATE_USE_PATTERN.test(character)) {
|
||||
addIssue('private-use', character, codePoint, currentIndex)
|
||||
} else if (UNASSIGNED_PATTERN.test(character)) {
|
||||
addIssue('unassigned', character, codePoint, currentIndex)
|
||||
}
|
||||
|
||||
if (CONTROL_PATTERN.test(character)) {
|
||||
const allowedNewline = allowNewlines && (character === '\n' || character === '\r')
|
||||
const allowedTab = allowTabs && character === '\t'
|
||||
if (!allowedNewline && !allowedTab) {
|
||||
addIssue('control', character, codePoint, currentIndex)
|
||||
}
|
||||
hasBaseCharacter = false
|
||||
combiningMarkCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (FORMAT_PATTERN.test(character)) {
|
||||
const allowed =
|
||||
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
|
||||
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex))
|
||||
if (!allowed) addIssue('invisible', character, codePoint, currentIndex)
|
||||
hasBaseCharacter = false
|
||||
combiningMarkCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (MARK_PATTERN.test(character)) {
|
||||
if (isVariationSelector(codePoint)) {
|
||||
if (!isAllowedVariationSelector(characters, characterIndex, codePoint)) {
|
||||
addIssue('invisible', character, codePoint, currentIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
combiningMarkCount++
|
||||
if (!hasBaseCharacter || combiningMarkCount > maxCombiningMarks) {
|
||||
addIssue('zalgo', character, codePoint, currentIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
combiningMarkCount = 0
|
||||
hasBaseCharacter = !/^\s$/u.test(character)
|
||||
|
||||
if (isInRanges(codePoint, FANCY_RANGES)) {
|
||||
addIssue('fancy', character, codePoint, currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
issues,
|
||||
counts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { validateNonStandardText } from './index.ts'
|
||||
|
||||
test('accepts ordinary multilingual text and punctuation', () => {
|
||||
const result = validateNonStandardText(
|
||||
'Hello, “world”! Français — Русский — العربية — 中文 — 日本語',
|
||||
)
|
||||
|
||||
assert.equal(result.valid, true)
|
||||
assert.deepEqual(result.issues, [])
|
||||
})
|
||||
|
||||
test('accepts composed and normally decomposed accents', () => {
|
||||
assert.equal(validateNonStandardText('café').valid, true)
|
||||
assert.equal(validateNonStandardText('cafe\u0301').valid, true)
|
||||
assert.equal(validateNonStandardText('a\u0301\u0327').valid, true)
|
||||
})
|
||||
|
||||
test('detects excessive and leading combining marks as zalgo text', () => {
|
||||
const excessive = validateNonStandardText('a\u0301\u0327\u0308')
|
||||
const leading = validateNonStandardText('\u0301text')
|
||||
|
||||
assert.equal(excessive.valid, false)
|
||||
assert.equal(excessive.counts.zalgo, 1)
|
||||
assert.equal(excessive.issues[0].index, 3)
|
||||
assert.equal(leading.counts.zalgo, 1)
|
||||
})
|
||||
|
||||
test('supports a custom combining-mark threshold', () => {
|
||||
assert.equal(
|
||||
validateNonStandardText('a\u0301\u0327', {
|
||||
maxCombiningMarksPerCharacter: 1,
|
||||
}).counts.zalgo,
|
||||
1,
|
||||
)
|
||||
assert.throws(
|
||||
() => validateNonStandardText('text', { maxCombiningMarksPerCharacter: -1 }),
|
||||
/non-negative integer/,
|
||||
)
|
||||
})
|
||||
|
||||
test('detects common fancy alphabets and presentation forms', () => {
|
||||
const result = validateNonStandardText('𝐇 Ⓗ ʰ ℌ h ff')
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.equal(result.counts.fancy, 6)
|
||||
assert.deepEqual(
|
||||
result.issues.map(({ codePoint }) => codePoint),
|
||||
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FB00'],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows ordinary emoji and valid emoji joiner sequences', () => {
|
||||
assert.equal(validateNonStandardText('Hello 👋🏽').valid, true)
|
||||
assert.equal(validateNonStandardText('Family: 👨👩👧👦').valid, true)
|
||||
assert.equal(validateNonStandardText('Developer: 🧑🏽💻').valid, true)
|
||||
assert.equal(validateNonStandardText('Heart: ❤️').valid, true)
|
||||
})
|
||||
|
||||
test('detects suspicious invisible and directional characters', () => {
|
||||
const result = validateNonStandardText('ab\u200Bcd\u202Eef\u2060gh f\uFE0F')
|
||||
|
||||
assert.equal(result.counts.invisible, 4)
|
||||
assert.deepEqual(
|
||||
result.issues.map(({ codePoint }) => codePoint),
|
||||
['U+200B', 'U+202E', 'U+2060', 'U+FE0F'],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows contextual non-joiners but catches ASCII separator evasion', () => {
|
||||
assert.equal(validateNonStandardText('میخواهم').valid, true)
|
||||
assert.equal(validateNonStandardText('fuck').counts.invisible, 1)
|
||||
assert.equal(validateNonStandardText('ab').counts.invisible, 1)
|
||||
})
|
||||
|
||||
test('allows newlines and tabs by default and can reject them', () => {
|
||||
assert.equal(validateNonStandardText('line one\n\tline two').valid, true)
|
||||
|
||||
const result = validateNonStandardText('line one\n\tline two', {
|
||||
allowNewlines: false,
|
||||
allowTabs: false,
|
||||
})
|
||||
assert.equal(result.counts.control, 2)
|
||||
})
|
||||
|
||||
test('detects other disallowed control characters', () => {
|
||||
const result = validateNonStandardText(`hello\u0000world`)
|
||||
|
||||
assert.equal(result.counts.control, 1)
|
||||
assert.equal(result.issues[0].codePoint, 'U+0000')
|
||||
})
|
||||
|
||||
test('detects private-use, unassigned, and lone surrogate code points', () => {
|
||||
const privateUse = validateNonStandardText('\uE000')
|
||||
const unassigned = validateNonStandardText('\uFDD0')
|
||||
const surrogate = validateNonStandardText('\uD800')
|
||||
|
||||
assert.equal(privateUse.counts['private-use'], 1)
|
||||
assert.equal(unassigned.counts.unassigned, 1)
|
||||
assert.equal(surrogate.counts.surrogate, 1)
|
||||
})
|
||||
|
||||
test('reports UTF-16 indexes consistently around astral characters', () => {
|
||||
const result = validateNonStandardText('🙂\u200Btext')
|
||||
|
||||
assert.equal(result.issues[0].index, 2)
|
||||
})
|
||||
|
||||
test('reports multiple issue categories in source order', () => {
|
||||
const result = validateNonStandardText('𝐀\u200B\u0000')
|
||||
|
||||
assert.deepEqual(
|
||||
result.issues.map(({ kind }) => kind),
|
||||
['fancy', 'invisible', 'control'],
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,572 @@
|
||||
export type ProfanityKind = 'profanity' | 'slur'
|
||||
|
||||
export interface ProfanityPattern {
|
||||
kind: ProfanityKind
|
||||
exceptions: readonly string[]
|
||||
}
|
||||
|
||||
export interface ProfanityConfig {
|
||||
patterns: Readonly<Record<string, ProfanityPattern>>
|
||||
}
|
||||
|
||||
export interface ProfanityMatch {
|
||||
kind: ProfanityKind
|
||||
term: string
|
||||
sanitizedStart: number
|
||||
sanitizedEnd: number
|
||||
}
|
||||
|
||||
export interface ProfanityResult {
|
||||
valid: boolean
|
||||
profanityCount: number
|
||||
slurCount: number
|
||||
firstMatch?: ProfanityMatch
|
||||
matches: ProfanityMatch[]
|
||||
}
|
||||
|
||||
export interface ProfanityValidator {
|
||||
sanitize(text: string): string
|
||||
findFirst(text: string): ProfanityMatch | undefined
|
||||
findAll(text: string): ProfanityMatch[]
|
||||
validate(text: string): ProfanityResult
|
||||
}
|
||||
|
||||
interface NegativeMatch {
|
||||
prefix: string
|
||||
suffix: string
|
||||
}
|
||||
|
||||
interface TrieNode {
|
||||
children: Map<string, TrieNode>
|
||||
negatives: NegativeMatch[]
|
||||
terminal?: {
|
||||
kind: ProfanityKind
|
||||
term: string
|
||||
}
|
||||
}
|
||||
|
||||
const CHARACTER_REPLACEMENTS: Readonly<Record<string, string>> = {
|
||||
'4': 'a',
|
||||
'@': 'a',
|
||||
'3': 'e',
|
||||
'1': 'i',
|
||||
'0': 'o',
|
||||
'5': 's',
|
||||
'7': 't',
|
||||
'8': 'b',
|
||||
'9': 'g',
|
||||
'+': 't',
|
||||
$: 's',
|
||||
'(': 'c',
|
||||
'{': 'c',
|
||||
'[': 'c',
|
||||
'!': 'i',
|
||||
'|': 'i',
|
||||
'£': 'e',
|
||||
'€': 'e',
|
||||
'¥': 'y',
|
||||
'¢': 'c',
|
||||
'<': 'c',
|
||||
}
|
||||
|
||||
const MULTI_CHARACTER_REPLACEMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
||||
'(': { ')': 'o' },
|
||||
'[': { ']': 'o' },
|
||||
'{': { '}': 'o' },
|
||||
'<': { '>': 'o' },
|
||||
}
|
||||
|
||||
const TERM_EXCEPTIONS: Readonly<Record<string, readonly string[]>> = {
|
||||
anuslick: [],
|
||||
arsehol: [],
|
||||
arselick: [],
|
||||
asslick: [],
|
||||
arsch: [],
|
||||
asshol: [],
|
||||
auschwitz: [],
|
||||
beaner: [],
|
||||
bestiality: [],
|
||||
baise: [],
|
||||
bakachon: [],
|
||||
bakatyon: [],
|
||||
bastard: ['bastardized'],
|
||||
bitch: [],
|
||||
btch: [],
|
||||
biatch: [],
|
||||
bussy: [],
|
||||
blowjob: [],
|
||||
blowme: [],
|
||||
bukakke: [],
|
||||
buttplug: [],
|
||||
buttchug: [],
|
||||
butagorosi: [],
|
||||
cagada: [],
|
||||
caralho: [],
|
||||
cameljockey: [],
|
||||
castrate: [],
|
||||
cazzo: [],
|
||||
ceemen: [],
|
||||
chankoro: [],
|
||||
chink: [],
|
||||
chingchong: [],
|
||||
choad: [],
|
||||
chode: [],
|
||||
chlamydia: [],
|
||||
clit: ['clitheroe'],
|
||||
clitoris: [],
|
||||
cock: [
|
||||
'cockade',
|
||||
'cockatiel',
|
||||
'cockatiels',
|
||||
'cockatoo',
|
||||
'cockatoos',
|
||||
'cockatrice',
|
||||
'cockayne',
|
||||
'cockburn',
|
||||
'cockcroft',
|
||||
'cocked',
|
||||
'cocker',
|
||||
'cockerel',
|
||||
'cockers',
|
||||
'cockeyed',
|
||||
'cockiness',
|
||||
'cocking',
|
||||
'cocklebur',
|
||||
'cockney',
|
||||
'cockpit',
|
||||
'cockpits',
|
||||
'cockroach',
|
||||
'cockroaches',
|
||||
'cockscomb',
|
||||
'cockspur',
|
||||
'cocktail',
|
||||
'gamecock',
|
||||
'hancock',
|
||||
'haycock',
|
||||
'hitchcock',
|
||||
'leacock',
|
||||
'peacock',
|
||||
'shuttlecock',
|
||||
'stopcock',
|
||||
'woodcock',
|
||||
],
|
||||
coon: ['cocoon', 'laocoon', 'raccoon', 'racoon', 'tycoon'],
|
||||
cocain: [],
|
||||
coitus: [],
|
||||
cottonpic: [],
|
||||
cottonpik: [],
|
||||
cum: [
|
||||
'acumen',
|
||||
'acuminate',
|
||||
'altocumulus',
|
||||
'cumber',
|
||||
'cumbing',
|
||||
'cumbria',
|
||||
'cumbrian',
|
||||
'cumbrous',
|
||||
'cummerbund',
|
||||
'cumming',
|
||||
'cumulat',
|
||||
'cumuli',
|
||||
'cumulonimbus',
|
||||
'cumulus',
|
||||
'encumber',
|
||||
'encumbrance',
|
||||
'scumbag',
|
||||
'locum',
|
||||
'modicum',
|
||||
'magnacumlaude',
|
||||
'macumba',
|
||||
'practicum',
|
||||
'recumbent',
|
||||
'slocum',
|
||||
'stratocumulus',
|
||||
'succumb',
|
||||
'talcum',
|
||||
'taraxacum',
|
||||
'tecumseh',
|
||||
'tucuman',
|
||||
'capsicum',
|
||||
'cecum',
|
||||
'circum',
|
||||
'colchicum',
|
||||
'document',
|
||||
'ecumeni',
|
||||
'illyricum',
|
||||
'incumben',
|
||||
],
|
||||
cunt: ['scunthorpe'],
|
||||
cvnt: [],
|
||||
cunny: [],
|
||||
cunnie: [],
|
||||
csam: [],
|
||||
cyka: [],
|
||||
darkie: [],
|
||||
dick: [
|
||||
'chappaquiddick',
|
||||
'dickens',
|
||||
'dickensian',
|
||||
'dickerson',
|
||||
'dickey',
|
||||
'dickies',
|
||||
'dickinson',
|
||||
'dickson',
|
||||
'dickvandyke',
|
||||
'dicky',
|
||||
],
|
||||
dildo: [],
|
||||
douchebag: [],
|
||||
dyke: ['vandyke'],
|
||||
downie: [],
|
||||
dumbass: [],
|
||||
ejaculate: [],
|
||||
fag: ['antofagasta', 'serfage', 'wharfage', 'fagin', 'leafage'],
|
||||
feck: [],
|
||||
fellate: [],
|
||||
fellatio: [],
|
||||
felch: [],
|
||||
fuck: [],
|
||||
fvck: [],
|
||||
fxck: [],
|
||||
fack: [],
|
||||
fzck: [],
|
||||
fck: [],
|
||||
fudgepacker: [],
|
||||
flange: ['flanged', 'flanges'],
|
||||
gestapo: [],
|
||||
gook: [],
|
||||
horny: ['thorny'],
|
||||
hooker: [],
|
||||
hitler: [],
|
||||
incest: [],
|
||||
jap: ['japan'],
|
||||
jizz: [],
|
||||
jigabo: [],
|
||||
junglebunny: [],
|
||||
kkk: [],
|
||||
kike: [],
|
||||
klux: [],
|
||||
kluklux: [],
|
||||
klukluxklan: [],
|
||||
koon: [],
|
||||
lickmy: [],
|
||||
masturbat: [],
|
||||
molest: [],
|
||||
muff: [
|
||||
'muffed',
|
||||
'muffin',
|
||||
'muffins',
|
||||
'muffle',
|
||||
'muffled',
|
||||
'muffler',
|
||||
'mufflers',
|
||||
'muffles',
|
||||
'muffling',
|
||||
'muffs',
|
||||
'ragamuffin',
|
||||
'earmuff',
|
||||
'earmuffs',
|
||||
],
|
||||
nazi: ['ashkenazi', 'ashkenazic', 'ashkenazim', 'monazite'],
|
||||
nigg: [],
|
||||
niqa: [],
|
||||
nigga: [],
|
||||
niqqa: [],
|
||||
niggu: [],
|
||||
niqqu: [],
|
||||
niggr: [],
|
||||
nigger: [],
|
||||
niglet: [],
|
||||
nignog: [],
|
||||
paki: ['pakistan'],
|
||||
penis: ['openis', 'penistone'],
|
||||
porn: [],
|
||||
prostitut: [],
|
||||
pube: [],
|
||||
pussie: [],
|
||||
pussy: ['pussycat', 'pussyfoot'],
|
||||
raghead: [],
|
||||
rape: [
|
||||
'grape',
|
||||
'forape',
|
||||
'trapeze',
|
||||
'trapezium',
|
||||
'trapezius',
|
||||
'trapezoid',
|
||||
'therapeutic',
|
||||
'drape',
|
||||
'parapet',
|
||||
'rapeseed',
|
||||
'scrape',
|
||||
'serape',
|
||||
],
|
||||
rapist: ['therapist'],
|
||||
retard: ['retardant', 'retarder', 'retarding'],
|
||||
rimjob: [],
|
||||
shit: [
|
||||
'cushitic',
|
||||
'shitake',
|
||||
'pushit',
|
||||
'peshitta',
|
||||
'libshitz',
|
||||
'shitzu',
|
||||
'wishit',
|
||||
'yamashita',
|
||||
'finishit',
|
||||
'shitbox',
|
||||
'shitmg',
|
||||
'publishit',
|
||||
'englishit',
|
||||
],
|
||||
slut: [],
|
||||
spunk: ['spunky'],
|
||||
suckmy: [],
|
||||
sodom: [],
|
||||
semen: ['sement'],
|
||||
teensex: [],
|
||||
tittie: [],
|
||||
titty: [],
|
||||
trannie: [],
|
||||
tranny: [],
|
||||
vagina: [],
|
||||
wank: ['swank', 'wankel'],
|
||||
wetback: [],
|
||||
whore: ['whores', 'whorev', 'whoreturned'],
|
||||
whitepower: [],
|
||||
fondle: [],
|
||||
minestorm: [],
|
||||
kissmy: [],
|
||||
blowmy: [],
|
||||
jelqing: [],
|
||||
dafuq: [],
|
||||
}
|
||||
|
||||
const SLUR_TERMS = new Set([
|
||||
'beaner',
|
||||
'cameljockey',
|
||||
'chankoro',
|
||||
'chink',
|
||||
'chingchong',
|
||||
'coon',
|
||||
'cottonpic',
|
||||
'cottonpik',
|
||||
'darkie',
|
||||
'downie',
|
||||
'dyke',
|
||||
'fag',
|
||||
'gook',
|
||||
'jap',
|
||||
'jigabo',
|
||||
'junglebunny',
|
||||
'kike',
|
||||
'koon',
|
||||
'nigg',
|
||||
'niqa',
|
||||
'nigga',
|
||||
'niqqa',
|
||||
'niggu',
|
||||
'niqqu',
|
||||
'niggr',
|
||||
'nigger',
|
||||
'niglet',
|
||||
'nignog',
|
||||
'paki',
|
||||
'raghead',
|
||||
'retard',
|
||||
'trannie',
|
||||
'tranny',
|
||||
'wetback',
|
||||
])
|
||||
|
||||
export const DEFAULT_PROFANITY_PATTERNS: Readonly<Record<string, ProfanityPattern>> =
|
||||
Object.fromEntries(
|
||||
Object.entries(TERM_EXCEPTIONS).map(([term, exceptions]) => {
|
||||
const kind: ProfanityKind = SLUR_TERMS.has(term) ? 'slur' : 'profanity'
|
||||
return [term, { kind, exceptions }] as const
|
||||
}),
|
||||
)
|
||||
|
||||
export const DEFAULT_PROFANITY_CONFIG: ProfanityConfig = {
|
||||
patterns: DEFAULT_PROFANITY_PATTERNS,
|
||||
}
|
||||
|
||||
function newTrieNode(): TrieNode {
|
||||
return {
|
||||
children: new Map(),
|
||||
negatives: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeProfanityText(text: string): string {
|
||||
const transformed = text
|
||||
.normalize('NFD')
|
||||
.replaceAll(/\p{Mn}/gu, '')
|
||||
.normalize('NFC')
|
||||
const characters = Array.from(transformed)
|
||||
let sanitized = ''
|
||||
|
||||
for (let index = 0; index < characters.length; index++) {
|
||||
const character = characters[index]
|
||||
const multiReplacement = MULTI_CHARACTER_REPLACEMENTS[character]
|
||||
const nextCharacter = characters[index + 1]
|
||||
|
||||
if (multiReplacement && nextCharacter && multiReplacement[nextCharacter]) {
|
||||
sanitized += multiReplacement[nextCharacter]
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
const replacement = CHARACTER_REPLACEMENTS[character]
|
||||
if (replacement) {
|
||||
sanitized += replacement
|
||||
} else if (character >= 'A' && character <= 'Z') {
|
||||
sanitized += character.toLowerCase()
|
||||
} else if ((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')) {
|
||||
sanitized += character
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function createTrie(patterns: Readonly<Record<string, ProfanityPattern>>): TrieNode {
|
||||
const root = newTrieNode()
|
||||
|
||||
for (const [rawTerm, pattern] of Object.entries(patterns)) {
|
||||
const term = rawTerm.toLowerCase()
|
||||
if (!term || sanitizeProfanityText(term) !== term) {
|
||||
throw new Error(`Profanity term must already be sanitized: ${rawTerm}`)
|
||||
}
|
||||
|
||||
let current = root
|
||||
for (const character of term) {
|
||||
let child = current.children.get(character)
|
||||
if (!child) {
|
||||
child = newTrieNode()
|
||||
current.children.set(character, child)
|
||||
}
|
||||
current = child
|
||||
}
|
||||
|
||||
if (current.terminal) {
|
||||
throw new Error(`Duplicate sanitized profanity term: ${term}`)
|
||||
}
|
||||
current.terminal = { kind: pattern.kind, term }
|
||||
|
||||
for (const rawException of pattern.exceptions) {
|
||||
const exception = rawException.toLowerCase()
|
||||
if (sanitizeProfanityText(exception) !== exception) {
|
||||
throw new Error(`Profanity exception must already be sanitized: ${rawException}`)
|
||||
}
|
||||
|
||||
const termIndex = exception.indexOf(term)
|
||||
if (termIndex < 0) {
|
||||
throw new Error(`Profanity exception must contain ${term}: ${rawException}`)
|
||||
}
|
||||
|
||||
current.negatives.push({
|
||||
prefix: exception.slice(0, termIndex),
|
||||
suffix: exception.slice(termIndex + term.length),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function negativeMatches(negative: NegativeMatch, text: string, start: number, end: number) {
|
||||
const prefixIndex = start - negative.prefix.length
|
||||
const suffixIndex = end + negative.suffix.length
|
||||
|
||||
if (prefixIndex < 0 || suffixIndex > text.length) return false
|
||||
return (
|
||||
text.slice(prefixIndex, start) === negative.prefix &&
|
||||
text.slice(end, suffixIndex) === negative.suffix
|
||||
)
|
||||
}
|
||||
|
||||
function findAt(root: TrieNode, text: string, start: number): ProfanityMatch | undefined {
|
||||
let current = root
|
||||
|
||||
for (let index = start; index < text.length; index++) {
|
||||
const child = current.children.get(text[index])
|
||||
if (!child) return undefined
|
||||
current = child
|
||||
|
||||
if (!current.terminal) continue
|
||||
|
||||
const end = index + 1
|
||||
const matchesNegative = current.negatives.some((negative) =>
|
||||
negativeMatches(negative, text, start, end),
|
||||
)
|
||||
if (matchesNegative && current.children.size === 0) return undefined
|
||||
|
||||
return {
|
||||
kind: current.terminal.kind,
|
||||
term: text.slice(start, end),
|
||||
sanitizedStart: start,
|
||||
sanitizedEnd: end,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function createProfanityValidator(
|
||||
config: ProfanityConfig = DEFAULT_PROFANITY_CONFIG,
|
||||
): ProfanityValidator {
|
||||
const root = createTrie(config.patterns)
|
||||
|
||||
function findFirst(text: string): ProfanityMatch | undefined {
|
||||
const sanitized = sanitizeProfanityText(text)
|
||||
for (let index = 0; index < sanitized.length; index++) {
|
||||
const match = findAt(root, sanitized, index)
|
||||
if (match) return match
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findAll(text: string): ProfanityMatch[] {
|
||||
const sanitized = sanitizeProfanityText(text)
|
||||
const matches: ProfanityMatch[] = []
|
||||
|
||||
for (let index = 0; index < sanitized.length; ) {
|
||||
const match = findAt(root, sanitized, index)
|
||||
if (match) {
|
||||
matches.push(match)
|
||||
index = match.sanitizedEnd
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function validate(text: string): ProfanityResult {
|
||||
const matches = findAll(text)
|
||||
const profanityCount = matches.filter((match) => match.kind === 'profanity').length
|
||||
const slurCount = matches.length - profanityCount
|
||||
|
||||
return {
|
||||
valid: matches.length === 0,
|
||||
profanityCount,
|
||||
slurCount,
|
||||
firstMatch: matches[0],
|
||||
matches,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sanitize: sanitizeProfanityText,
|
||||
findFirst,
|
||||
findAll,
|
||||
validate,
|
||||
}
|
||||
}
|
||||
|
||||
export const profanityValidator = createProfanityValidator()
|
||||
|
||||
export function validateProfanity(text: string): ProfanityResult {
|
||||
return profanityValidator.validate(text)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createProfanityValidator, sanitizeProfanityText, validateProfanity } from './index.ts'
|
||||
|
||||
test('sanitizes text with the configured single-character replacements', () => {
|
||||
assert.equal(sanitizeProfanityText('4@3105789+$([{!|£€¥¢<'), 'aaeiostbgtsccciieeycc')
|
||||
})
|
||||
|
||||
test('sanitizes paired characters before their single-character replacements', () => {
|
||||
assert.equal(sanitizeProfanityText('()[]{}<>'), 'oooo')
|
||||
})
|
||||
|
||||
test('strips accents, separators, emoji, and ASCII casing', () => {
|
||||
assert.equal(sanitizeProfanityText('F Ü.C—K🙂'), 'fuck')
|
||||
})
|
||||
|
||||
test('matches profanity across separators and common substitutions', () => {
|
||||
assert.equal(validateProfanity('f.u c-k').firstMatch?.term, 'fuck')
|
||||
assert.equal(validateProfanity('$h!t').firstMatch?.term, 'shit')
|
||||
assert.equal(validateProfanity('p()rn').firstMatch?.term, 'porn')
|
||||
})
|
||||
|
||||
test('honors exact negative prefix and suffix matches', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: ['notbadword'] },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(validator.findFirst('not bad word'), undefined)
|
||||
assert.equal(validator.findFirst('very bad word')?.term, 'bad')
|
||||
assert.equal(validator.findFirst('not bad phrase')?.term, 'bad')
|
||||
})
|
||||
|
||||
test('matches the first profanity while ignoring a later negative match', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
shit: { kind: 'profanity', exceptions: ['horseshit', 'bullshit'] },
|
||||
fuck: { kind: 'profanity', exceptions: [] },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(validator.findFirst('this horseshit'), undefined)
|
||||
assert.equal(validator.findFirst('fuck this bullshit')?.term, 'fuck')
|
||||
})
|
||||
|
||||
test('uses the first terminal when one configured term prefixes another', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: [] },
|
||||
badword: { kind: 'profanity', exceptions: [] },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(validator.findFirst('badword')?.term, 'bad')
|
||||
})
|
||||
|
||||
test('does not match configured false-positive substrings', () => {
|
||||
assert.equal(validateProfanity('Scunthorpe and peacock').valid, true)
|
||||
assert.equal(validateProfanity('cock and cunt').profanityCount, 2)
|
||||
})
|
||||
|
||||
test('rejects any uncensored configured profanity', () => {
|
||||
assert.equal(validateProfanity('A clean project').valid, true)
|
||||
assert.equal(validateProfanity('This is shit').valid, false)
|
||||
})
|
||||
|
||||
test('allows redacted profanity when the removed letters cannot reconstruct a term', () => {
|
||||
assert.equal(validateProfanity('f**k').valid, true)
|
||||
assert.equal(validateProfanity('f**k works in titles, summaries, and descriptions').valid, true)
|
||||
assert.equal(validateProfanity('f.u.c.k').valid, false)
|
||||
})
|
||||
|
||||
test('rejects slurs', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
forbidden: { kind: 'slur', exceptions: [] },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(validator.validate('forbidden').valid, false)
|
||||
assert.equal(validator.validate('forbidden').slurCount, 1)
|
||||
})
|
||||
|
||||
test('counts matches from left to right without overlaps', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: [] },
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
validator
|
||||
.findAll('bad-bad')
|
||||
.map(({ sanitizedStart, sanitizedEnd }) => [sanitizedStart, sanitizedEnd]),
|
||||
[
|
||||
[0, 3],
|
||||
[3, 6],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects invalid configuration', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createProfanityValidator({
|
||||
patterns: {
|
||||
'not sanitized': { kind: 'profanity', exceptions: [] },
|
||||
},
|
||||
}),
|
||||
/term must already be sanitized/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: ['innocent'] },
|
||||
},
|
||||
}),
|
||||
/exception must contain bad/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user