mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
feat: polish profanity validation
This commit is contained in:
@@ -357,10 +357,10 @@
|
||||
"defaultMessage": "Non-standard text characters are not allowed."
|
||||
},
|
||||
"project.text-validation.profanity": {
|
||||
"defaultMessage": "Profanity is not allowed."
|
||||
"defaultMessage": "The detected profanity “{value}” is not allowed."
|
||||
},
|
||||
"project.text-validation.slur": {
|
||||
"defaultMessage": "Slurs are not allowed."
|
||||
"defaultMessage": "The detected slur “{value}” is not allowed."
|
||||
},
|
||||
"project.text-validation.summary-link": {
|
||||
"defaultMessage": "Links should not be included in project summaries."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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.
|
||||
| 'invisible' // hidden formatting such as a word joiner 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.
|
||||
@@ -44,7 +44,9 @@ const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x2070, 0x209f],
|
||||
[0x2100, 0x214f],
|
||||
[0xfb00, 0xfb06],
|
||||
[0xff01, 0xff60],
|
||||
[0xff10, 0xff19],
|
||||
[0xff21, 0xff3a],
|
||||
[0xff41, 0xff5a],
|
||||
[0x1f100, 0x1f1ad],
|
||||
]
|
||||
|
||||
@@ -230,6 +232,7 @@ export function validateNonStandardText(
|
||||
|
||||
if (FORMAT_PATTERN.test(character)) {
|
||||
const allowed =
|
||||
codePoint === 0x200b ||
|
||||
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
|
||||
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex)) ||
|
||||
(isEmojiTag(codePoint) && isAllowedEmojiTagSequence(characters, characterIndex))
|
||||
@@ -257,7 +260,11 @@ export function validateNonStandardText(
|
||||
combiningMarkCount = 0
|
||||
hasBaseCharacter = !/^\s$/u.test(character)
|
||||
|
||||
if (isInRanges(codePoint, FANCY_RANGES) && !isPresentedAsEmoji(characters, characterIndex)) {
|
||||
if (
|
||||
codePoint !== 0x2122 &&
|
||||
isInRanges(codePoint, FANCY_RANGES) &&
|
||||
!isPresentedAsEmoji(characters, characterIndex)
|
||||
) {
|
||||
addIssue('fancy', character, codePoint, currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
|
||||
|
||||
test('accepts ordinary multilingual text and punctuation', () => {
|
||||
const result = validateNonStandardText(
|
||||
'Hello, “world”! Français — Русский — العربية — 中文 — 日本語',
|
||||
'Hello™, “world”! Français — Русский — العربية — 中文 — 日本語',
|
||||
)
|
||||
|
||||
assert.equal(result.valid, true)
|
||||
assert.deepEqual(result.issues, [])
|
||||
})
|
||||
|
||||
test('accepts Chinese text with fullwidth punctuation', () => {
|
||||
const result = validateNonStandardText(
|
||||
'这是一个中文项目,支持简体和繁體中文!请查看说明:性能、兼容性(支持 1.21)。',
|
||||
)
|
||||
|
||||
assert.equal(result.valid, true)
|
||||
@@ -42,13 +51,13 @@ test('supports a custom combining-mark threshold', () => {
|
||||
})
|
||||
|
||||
test('detects common fancy alphabets and presentation forms', () => {
|
||||
const result = validateNonStandardText('𝐇 Ⓗ ʰ ℌ h ff')
|
||||
const result = validateNonStandardText('𝐇 Ⓗ ʰ ℌ h 1 ff')
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.equal(result.counts.fancy, 6)
|
||||
assert.equal(result.counts.fancy, 7)
|
||||
assert.deepEqual(
|
||||
result.issues.map(({ codePoint }) => codePoint),
|
||||
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FB00'],
|
||||
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FF11', 'U+FB00'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -62,13 +71,22 @@ test('allows ordinary emoji and valid emoji joiner sequences', () => {
|
||||
assert.equal(validateNonStandardText('Scotland: 🏴').valid, true)
|
||||
})
|
||||
|
||||
test('allows zero-width spaces used as formatting residue', () => {
|
||||
const result = validateNonStandardText(
|
||||
'Clan System: Bank - Ranks - Languages \u200B\u200B- Vault - Management',
|
||||
)
|
||||
|
||||
assert.equal(result.valid, true)
|
||||
assert.deepEqual(result.issues, [])
|
||||
})
|
||||
|
||||
test('detects suspicious invisible and directional characters', () => {
|
||||
const result = validateNonStandardText('ab\u200Bcd\u202Eef\u2060gh f\uFE0F')
|
||||
const result = validateNonStandardText('ab\u2060cd\u202Eef\u2063gh f\uFE0F')
|
||||
|
||||
assert.equal(result.counts.invisible, 4)
|
||||
assert.deepEqual(
|
||||
result.issues.map(({ codePoint }) => codePoint),
|
||||
['U+200B', 'U+202E', 'U+2060', 'U+FE0F'],
|
||||
['U+2060', 'U+202E', 'U+2063', 'U+FE0F'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -106,13 +124,13 @@ test('detects private-use, unassigned, and lone surrogate code points', () => {
|
||||
})
|
||||
|
||||
test('reports UTF-16 indexes consistently around astral characters', () => {
|
||||
const result = validateNonStandardText('🙂\u200Btext')
|
||||
const result = validateNonStandardText('🙂\u2060text')
|
||||
|
||||
assert.equal(result.issues[0].index, 2)
|
||||
})
|
||||
|
||||
test('reports multiple issue categories in source order', () => {
|
||||
const result = validateNonStandardText('𝐀\u200B\u0000')
|
||||
const result = validateNonStandardText('𝐀\u2060\u0000')
|
||||
|
||||
assert.deepEqual(
|
||||
result.issues.map(({ kind }) => kind),
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import {
|
||||
collapseDuplicatesTransformer,
|
||||
parseRawPattern,
|
||||
RegExpMatcher,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from 'obscenity'
|
||||
|
||||
export type ProfanityKind = 'profanity' | 'slur'
|
||||
|
||||
export interface ProfanityPattern {
|
||||
kind: ProfanityKind
|
||||
exceptions: readonly string[]
|
||||
}
|
||||
|
||||
export interface ProfanityConfig {
|
||||
@@ -12,8 +21,9 @@ export interface ProfanityConfig {
|
||||
export interface ProfanityMatch {
|
||||
kind: ProfanityKind
|
||||
term: string
|
||||
sanitizedStart: number
|
||||
sanitizedEnd: number
|
||||
rawText: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface ProfanityResult {
|
||||
@@ -25,321 +35,148 @@ export interface ProfanityResult {
|
||||
}
|
||||
|
||||
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 TERMS = [
|
||||
'anuslick',
|
||||
'arsehol',
|
||||
'arselick',
|
||||
'asslick',
|
||||
'arsch',
|
||||
'asshol',
|
||||
'auschwitz',
|
||||
'beaner',
|
||||
'bestiality',
|
||||
'baise',
|
||||
'bakachon',
|
||||
'bakatyon',
|
||||
'bastard',
|
||||
'bitch',
|
||||
'btch',
|
||||
'biatch',
|
||||
'bussy',
|
||||
'blowjob',
|
||||
'blowme',
|
||||
'bukakke',
|
||||
'buttplug',
|
||||
'buttchug',
|
||||
'butagorosi',
|
||||
'cagada',
|
||||
'caralho',
|
||||
'cameljockey',
|
||||
'castrate',
|
||||
'cazzo',
|
||||
'ceemen',
|
||||
'chankoro',
|
||||
'chink',
|
||||
'chingchong',
|
||||
'choad',
|
||||
'chode',
|
||||
'chlamydia',
|
||||
'clit',
|
||||
'clitoris',
|
||||
'cock',
|
||||
'coon',
|
||||
'cocain',
|
||||
'coitus',
|
||||
'cottonpic',
|
||||
'cottonpik',
|
||||
'cum',
|
||||
'cunt',
|
||||
'cvnt',
|
||||
'cunny',
|
||||
'cunnie',
|
||||
'csam',
|
||||
'cyka',
|
||||
'darkie',
|
||||
'dick',
|
||||
'dildo',
|
||||
'douchebag',
|
||||
'dyke',
|
||||
'downie',
|
||||
'dumbass',
|
||||
'ejaculate',
|
||||
'fag',
|
||||
'feck',
|
||||
'fellate',
|
||||
'fellatio',
|
||||
'felch',
|
||||
'fuck',
|
||||
'fvck',
|
||||
'fxck',
|
||||
'fack',
|
||||
'fzck',
|
||||
'fck',
|
||||
'fudgepacker',
|
||||
'flange',
|
||||
'gestapo',
|
||||
'gook',
|
||||
'horny',
|
||||
'hooker',
|
||||
'hitler',
|
||||
'incest',
|
||||
'jap',
|
||||
'jizz',
|
||||
'jigabo',
|
||||
'junglebunny',
|
||||
'kkk',
|
||||
'kike',
|
||||
'klux',
|
||||
'kluklux',
|
||||
'klukluxklan',
|
||||
'koon',
|
||||
'lickmy',
|
||||
'masturbat',
|
||||
'molest',
|
||||
'muff',
|
||||
'nazi',
|
||||
'nigger',
|
||||
'nigga',
|
||||
'niqa',
|
||||
'niqqa',
|
||||
'niggu',
|
||||
'niqqu',
|
||||
'niggr',
|
||||
'niglet',
|
||||
'nignog',
|
||||
'paki',
|
||||
'penis',
|
||||
'porn',
|
||||
'prostitut',
|
||||
'pube',
|
||||
'pussie',
|
||||
'pussy',
|
||||
'raghead',
|
||||
'rape',
|
||||
'rapist',
|
||||
'retard',
|
||||
'rimjob',
|
||||
'shit',
|
||||
'slut',
|
||||
'spunk',
|
||||
'suckmy',
|
||||
'sodom',
|
||||
'semen',
|
||||
'teensex',
|
||||
'tittie',
|
||||
'titty',
|
||||
'trannie',
|
||||
'tranny',
|
||||
'vagina',
|
||||
'wank',
|
||||
'wetback',
|
||||
'whore',
|
||||
'whitepower',
|
||||
'fondle',
|
||||
'minestorm',
|
||||
'kissmy',
|
||||
'blowmy',
|
||||
'jelqing',
|
||||
'dafuq',
|
||||
] as const
|
||||
|
||||
const SLUR_TERMS = new Set([
|
||||
'beaner',
|
||||
@@ -380,9 +217,9 @@ const SLUR_TERMS = new Set([
|
||||
|
||||
export const DEFAULT_PROFANITY_PATTERNS: Readonly<Record<string, ProfanityPattern>> =
|
||||
Object.fromEntries(
|
||||
Object.entries(TERM_EXCEPTIONS).map(([term, exceptions]) => {
|
||||
TERMS.map((term) => {
|
||||
const kind: ProfanityKind = SLUR_TERMS.has(term) ? 'slur' : 'profanity'
|
||||
return [term, { kind, exceptions }] as const
|
||||
return [term, { kind }] as const
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -390,159 +227,69 @@ export const DEFAULT_PROFANITY_CONFIG: ProfanityConfig = {
|
||||
patterns: DEFAULT_PROFANITY_PATTERNS,
|
||||
}
|
||||
|
||||
function newTrieNode(): TrieNode {
|
||||
return {
|
||||
children: new Map(),
|
||||
negatives: [],
|
||||
}
|
||||
}
|
||||
function getDuplicateThresholds(terms: readonly string[]): Map<string, number> {
|
||||
const thresholds = new Map<string, number>()
|
||||
|
||||
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 (const term of terms) {
|
||||
let runLength = 0
|
||||
let previousCharacter = ''
|
||||
|
||||
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),
|
||||
})
|
||||
runLength = character === previousCharacter ? runLength + 1 : 1
|
||||
previousCharacter = character
|
||||
thresholds.set(character, Math.max(thresholds.get(character) ?? 1, runLength))
|
||||
}
|
||||
}
|
||||
|
||||
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) continue
|
||||
|
||||
return {
|
||||
kind: current.terminal.kind,
|
||||
term: text.slice(start, end),
|
||||
sanitizedStart: start,
|
||||
sanitizedEnd: end,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return thresholds
|
||||
}
|
||||
|
||||
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
|
||||
const entries = Object.entries(config.patterns).map(([rawTerm, pattern]) => {
|
||||
const term = rawTerm.toLowerCase()
|
||||
if (!term || !/^[a-z]+$/.test(term)) {
|
||||
throw new Error(`Profanity term must contain only ASCII letters: ${rawTerm}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { kind: pattern.kind, term }
|
||||
})
|
||||
const matcher = new RegExpMatcher({
|
||||
blacklistedTerms: entries.map(({ term }, id) => ({ id, pattern: parseRawPattern(term) })),
|
||||
blacklistMatcherTransformers: [
|
||||
resolveConfusablesTransformer(),
|
||||
resolveLeetSpeakTransformer(),
|
||||
toAsciiLowerCaseTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
collapseDuplicatesTransformer({
|
||||
customThresholds: getDuplicateThresholds(entries.map(({ term }) => term)),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
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++
|
||||
}
|
||||
for (const match of matcher.getAllMatches(text, true)) {
|
||||
const profanityPattern = entries[match.termId]
|
||||
if (!profanityPattern || match.startIndex < (matches.at(-1)?.end ?? 0)) continue
|
||||
|
||||
matches.push({
|
||||
...profanityPattern,
|
||||
rawText: text.slice(match.startIndex, match.endIndex + 1),
|
||||
start: match.startIndex,
|
||||
end: match.endIndex + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function findFirst(text: string): ProfanityMatch | undefined {
|
||||
return findAll(text)[0]
|
||||
}
|
||||
|
||||
function validate(text: string): ProfanityResult {
|
||||
const matches = findAll(text)
|
||||
const profanityCount = matches.filter((match) => match.kind === 'profanity').length
|
||||
@@ -558,7 +305,6 @@ export function createProfanityValidator(
|
||||
}
|
||||
|
||||
return {
|
||||
sanitize: sanitizeProfanityText,
|
||||
findFirst,
|
||||
findAll,
|
||||
validate,
|
||||
|
||||
@@ -1,66 +1,49 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createProfanityValidator, sanitizeProfanityText, validateProfanity } from './index.ts'
|
||||
import { createProfanityValidator, validateProfanity } from './index.ts'
|
||||
|
||||
test('sanitizes text with the configured single-character replacements', () => {
|
||||
assert.equal(sanitizeProfanityText('4@3105789+$([{!|£€¥¢<'), 'aaeiostbgtsccciieeycc')
|
||||
})
|
||||
const blockedForms = [
|
||||
['normal form', 'fuck'],
|
||||
['capitalization', 'FUCK'],
|
||||
['Unicode variants', 'fuck'],
|
||||
['zero-width characters', 'f\u200Buck'],
|
||||
['leetspeak', '$h!t'],
|
||||
['period separators', 'f.u.c.k'],
|
||||
['space separators', 'f u c k'],
|
||||
['repeated letters', 'fuuuuuck'],
|
||||
] as const
|
||||
|
||||
test('sanitizes paired characters before their single-character replacements', () => {
|
||||
assert.equal(sanitizeProfanityText('()[]{}<>'), 'oooo')
|
||||
})
|
||||
for (const [form, input] of blockedForms) {
|
||||
test(`blocks ${form}`, () => {
|
||||
const result = validateProfanity(input)
|
||||
|
||||
test('strips accents, separators, emoji, and ASCII casing', () => {
|
||||
assert.equal(sanitizeProfanityText('F Ü.C—K🙂'), 'fuck')
|
||||
})
|
||||
assert.equal(result.valid, false)
|
||||
assert.equal(result.firstMatch?.rawText, input)
|
||||
})
|
||||
}
|
||||
|
||||
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', () => {
|
||||
test('does not use term exceptions', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: ['notbadword'] },
|
||||
bad: { kind: 'profanity' },
|
||||
},
|
||||
})
|
||||
|
||||
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')
|
||||
assert.equal(validator.findFirst('not bad word')?.term, 'bad')
|
||||
})
|
||||
|
||||
test('matches the first profanity while ignoring a later negative match', () => {
|
||||
test('uses the first match when one configured term prefixes another', () => {
|
||||
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: [] },
|
||||
bad: { kind: 'profanity' },
|
||||
badword: { kind: 'profanity' },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(validator.findFirst('badword')?.term, 'bad')
|
||||
})
|
||||
|
||||
test('does not match configured false-positive substrings', () => {
|
||||
assert.equal(validateProfanity('Scunthorpe, Clitheroe, 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)
|
||||
@@ -72,31 +55,31 @@ test('allows redacted profanity when the removed letters cannot reconstruct a te
|
||||
assert.equal(validateProfanity('f.u.c.k').valid, false)
|
||||
})
|
||||
|
||||
test('rejects slurs', () => {
|
||||
test('classifies slurs separately from other profanity', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
forbidden: { kind: 'slur', exceptions: [] },
|
||||
forbidden: { kind: 'slur' },
|
||||
},
|
||||
})
|
||||
const result = validator.validate('FORBIDDEN')
|
||||
|
||||
assert.equal(validator.validate('forbidden').valid, false)
|
||||
assert.equal(validator.validate('forbidden').slurCount, 1)
|
||||
assert.equal(result.valid, false)
|
||||
assert.equal(result.profanityCount, 0)
|
||||
assert.equal(result.slurCount, 1)
|
||||
})
|
||||
|
||||
test('counts matches from left to right without overlaps', () => {
|
||||
test('returns non-overlapping matches and original input offsets', () => {
|
||||
const validator = createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: [] },
|
||||
bad: { kind: 'profanity' },
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
validator
|
||||
.findAll('bad-bad')
|
||||
.map(({ sanitizedStart, sanitizedEnd }) => [sanitizedStart, sanitizedEnd]),
|
||||
validator.findAll('b.a.d bad').map(({ start, end }) => [start, end]),
|
||||
[
|
||||
[0, 3],
|
||||
[3, 6],
|
||||
[0, 5],
|
||||
[6, 9],
|
||||
],
|
||||
)
|
||||
})
|
||||
@@ -106,18 +89,9 @@ test('rejects invalid configuration', () => {
|
||||
() =>
|
||||
createProfanityValidator({
|
||||
patterns: {
|
||||
'not sanitized': { kind: 'profanity', exceptions: [] },
|
||||
'not sanitized': { kind: 'profanity' },
|
||||
},
|
||||
}),
|
||||
/term must already be sanitized/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
createProfanityValidator({
|
||||
patterns: {
|
||||
bad: { kind: 'profanity', exceptions: ['innocent'] },
|
||||
},
|
||||
}),
|
||||
/exception must contain bad/,
|
||||
/term must contain only ASCII letters/,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -40,9 +40,11 @@ export type ProjectTextValidationCode =
|
||||
| 'description-missing-alt-text'
|
||||
|
||||
export interface ProjectTextValidationOptions {
|
||||
maxProfanityCount?: number
|
||||
nonStandardTextFailureThreshold?: number
|
||||
}
|
||||
|
||||
export const DESCRIPTION_MAX_PROFANITY_COUNT = 1
|
||||
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
|
||||
export const MIN_DESCRIPTION_CHARS = 200
|
||||
export const MAX_HEADER_LENGTH = 80
|
||||
@@ -52,11 +54,11 @@ export const MIN_SUMMARY_CHARS = 30
|
||||
const messages = defineMessages({
|
||||
slur: {
|
||||
id: 'project.text-validation.slur',
|
||||
defaultMessage: 'Slurs are not allowed.',
|
||||
defaultMessage: 'The detected slur “{value}” is not allowed.',
|
||||
},
|
||||
profanity: {
|
||||
id: 'project.text-validation.profanity',
|
||||
defaultMessage: 'Profanity is not allowed.',
|
||||
defaultMessage: 'The detected profanity “{value}” is not allowed.',
|
||||
},
|
||||
nonStandardText: {
|
||||
id: 'project.text-validation.non-standard-text',
|
||||
@@ -275,11 +277,34 @@ export function validateProjectText(
|
||||
if (!text) return []
|
||||
|
||||
const profanity = validateProfanity(text)
|
||||
if (profanity.slurCount > 0) {
|
||||
return [{ code: 'text-slur', severity: 'error', message: messages.slur }]
|
||||
const slurMatch = profanity.matches.find((match) => match.kind === 'slur')
|
||||
if (slurMatch) {
|
||||
return [
|
||||
{
|
||||
code: 'text-slur',
|
||||
severity: 'error',
|
||||
message: messages.slur,
|
||||
values: { value: slurMatch.rawText },
|
||||
},
|
||||
]
|
||||
}
|
||||
if (profanity.profanityCount > 0) {
|
||||
return [{ code: 'text-profanity', severity: 'error', message: messages.profanity }]
|
||||
|
||||
const maxProfanityCount = options.maxProfanityCount ?? 0
|
||||
if (!Number.isInteger(maxProfanityCount) || maxProfanityCount < 0) {
|
||||
throw new Error('Maximum profanity count must be a non-negative integer')
|
||||
}
|
||||
const profanityMatch = profanity.matches.filter((match) => match.kind === 'profanity')[
|
||||
maxProfanityCount
|
||||
]
|
||||
if (profanityMatch) {
|
||||
return [
|
||||
{
|
||||
code: 'text-profanity',
|
||||
severity: 'error',
|
||||
message: messages.profanity,
|
||||
values: { value: profanityMatch.rawText },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const nonStandardText = validateNonStandardText(text)
|
||||
@@ -370,6 +395,7 @@ export function validateProjectDescription(
|
||||
description: string | null | undefined,
|
||||
): ProjectTextValidationResult[] {
|
||||
const results = validateProjectText(description, {
|
||||
maxProfanityCount: DESCRIPTION_MAX_PROFANITY_COUNT,
|
||||
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
|
||||
})
|
||||
if (results.length > 0) return results
|
||||
|
||||
@@ -55,10 +55,16 @@ test('extracts and deduplicates normalized links', () => {
|
||||
|
||||
test('validates shared project text', () => {
|
||||
assert.deepEqual(validateProjectText('An ordinary project'), [])
|
||||
assert.equal(
|
||||
validateProjectText('This project is shit')[0]?.message.id,
|
||||
'project.text-validation.profanity',
|
||||
)
|
||||
assert.deepEqual(validateProjectText('This project is SHIT')[0], {
|
||||
code: 'text-profanity',
|
||||
severity: 'error',
|
||||
message: {
|
||||
id: 'project.text-validation.profanity',
|
||||
defaultMessage: 'The detected profanity “{value}” is not allowed.',
|
||||
},
|
||||
values: { value: 'SHIT' },
|
||||
})
|
||||
assert.deepEqual(validateProjectText('F.A.G')[0]?.values, { value: 'F.A.G' })
|
||||
assert.equal(
|
||||
validateProjectText('𝐅ancy project')[0]?.message.id,
|
||||
'project.text-validation.non-standard-text',
|
||||
@@ -135,6 +141,32 @@ test('allows sparse non-standard text in descriptions but rejects it at the thre
|
||||
)
|
||||
})
|
||||
|
||||
test('allows one profanity match in descriptions but rejects a second match or any slur', () => {
|
||||
const description = 'A detailed project description '.repeat(10)
|
||||
|
||||
assert.equal(
|
||||
validateProjectDescription(`${description} shit`).some(({ code }) => code === 'text-profanity'),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
validateProjectDescription(`${description} f.u.c.k`).some(
|
||||
({ code }) => code === 'text-profanity',
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.deepEqual(validateProjectDescription(`${description} shit FUCK`)[0], {
|
||||
code: 'text-profanity',
|
||||
severity: 'error',
|
||||
message: {
|
||||
id: 'project.text-validation.profanity',
|
||||
defaultMessage: 'The detected profanity “{value}” is not allowed.',
|
||||
},
|
||||
values: { value: 'FUCK' },
|
||||
})
|
||||
assert.equal(validateProjectDescription(`${description} nigga`)[0]?.code, 'text-slur')
|
||||
assert.equal(validateProjectDescription(`${description} nigger`)[0]?.code, 'text-slur')
|
||||
})
|
||||
|
||||
test('validates required description content and returns simultaneous recommendations', () => {
|
||||
assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required')
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ test('validates project fields and gallery text', () => {
|
||||
url: 'https://cdn.modrinth.com/gallery.png',
|
||||
raw_url: 'https://cdn.modrinth.com/gallery.png',
|
||||
featured: false,
|
||||
name: 'This is shit',
|
||||
name: 'This is $h!t',
|
||||
description: '𝐁',
|
||||
created: '2026-01-01T00:00:00Z',
|
||||
ordering: 0,
|
||||
@@ -84,6 +84,9 @@ test('validates project fields and gallery text', () => {
|
||||
},
|
||||
],
|
||||
)
|
||||
assert.deepEqual(result.failures.find(({ field }) => field === 'gallery-name')?.values, {
|
||||
value: '$h!t',
|
||||
})
|
||||
})
|
||||
|
||||
test('reports whether a project has field validation failures', () => {
|
||||
|
||||
Reference in New Issue
Block a user