From ae4519cc2615ce2a9d39e04778f088515cc1976a Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 26 Aug 2026 16:20:35 -0600 Subject: [PATCH] feat: polish profanity validation --- .../ui/moderation/ModerationProjectNags.vue | 2 +- apps/frontend/src/pages/moderation/index.vue | 114 +++- .../services/moderation/validation-filter.ts | 9 +- packages/moderation/package.json | 1 + .../moderation/src/locales/en-US/index.json | 4 +- .../src/validators/non-standard-text/index.ts | 13 +- .../src/validators/non-standard-text/tests.ts | 34 +- .../src/validators/profanity/index.ts | 642 ++++++------------ .../src/validators/profanity/tests.ts | 100 +-- .../src/validators/project-fields/index.ts | 38 +- .../src/validators/project-fields/tests.ts | 40 +- .../validators/project-validation/tests.ts | 5 +- pnpm-lock.yaml | 9 + 13 files changed, 435 insertions(+), 576 deletions(-) diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue index e8bf95a645..20723d3046 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue @@ -56,7 +56,7 @@
import type { Labrinth } from '@modrinth/api-client' import { + CopyIcon, EditIcon, ListFilterIcon, ScaleIcon, @@ -207,14 +208,15 @@ import { useModerationQueue } from '~/services/moderation/queue.ts' import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts' import { fetchAllModerationQueueProjects, - scanProjectsWithValidationErrors, + scanProjectsWithValidationIssues, type ValidationFilterRequest, } from '~/services/moderation/validation-filter.ts' useHead({ title: 'Projects queue - Modrinth' }) const { formatMessage } = useVIntl() -const { addNotification } = injectNotificationManager() +const notificationManager = injectNotificationManager() +const { addNotification } = notificationManager const moderationQueue = useModerationQueue() const route = useRoute() const router = useRouter() @@ -306,6 +308,7 @@ const filterTypes: ComboboxOption[] = [ { value: 'Servers', label: 'Servers' }, { value: 'Project IDs', label: 'Project IDs' }, { value: 'Validation errors', label: 'Validation errors' }, + { value: 'Validation errors + warnings', label: 'Validation errors + warnings' }, { value: 'Fucked up', label: 'Fucked up' }, ] const filterTypeValues = filterTypes.map((option) => option.value) @@ -314,6 +317,7 @@ const DEFAULT_FILTER_TYPE = filterTypeValues[0] const MODPACK_FILTER_TYPE = 'Modpacks' const PROJECT_IDS_FILTER_TYPE = 'Project IDs' const VALIDATION_ERROR_FILTER_TYPE = 'Validation errors' +const VALIDATION_ERROR_AND_WARNING_FILTER_TYPE = 'Validation errors + warnings' const VALIDATION_FILTER_STALE_TIME_MS = 1000 * 60 * 5 const baseSortTypes: ComboboxOption[] = [ @@ -516,6 +520,17 @@ const moderationProjectsQueryKey = computed( () => ['moderation-projects', moderationProjectsRequest.value] as const, ) +const isValidationErrorFilter = computed( + () => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE, +) +const isValidationErrorAndWarningFilter = computed( + () => currentFilterType.value === VALIDATION_ERROR_AND_WARNING_FILTER_TYPE, +) +const isValidationFilter = computed( + () => isValidationErrorFilter.value || isValidationErrorAndWarningFilter.value, +) +const isProjectIdsFilter = computed(() => currentFilterType.value === PROJECT_IDS_FILTER_TYPE) + const { data: standardProjectsResponse, isPending: standardProjectsPending, @@ -525,11 +540,7 @@ const { queryKey: moderationProjectsQueryKey, queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]), placeholderData: (previousData) => previousData, - enabled: computed( - () => - currentFilterType.value !== VALIDATION_ERROR_FILTER_TYPE && - currentFilterType.value !== PROJECT_IDS_FILTER_TYPE, - ), + enabled: computed(() => !isValidationFilter.value && !isProjectIdsFilter.value), }) const validationFilterRequest = computed(() => ({ @@ -539,45 +550,80 @@ const validationFilterRequest = computed(() => ({ })) const validationProjectsQueryKey = computed( - () => ['moderation-projects', 'validation-errors', validationFilterRequest.value] as const, + () => + [ + 'moderation-projects', + 'validation', + isValidationErrorAndWarningFilter.value, + validationFilterRequest.value, + ] as const, ) +let validationScanNotificationId: string | number | undefined + +function showValidationScanCompleteNotification( + response: Labrinth.Moderation.Internal.ProjectsResponse, + includeWarnings: boolean, +) { + if (validationScanNotificationId !== undefined) { + notificationManager.removeNotification(validationScanNotificationId) + } + + const projectIds = response.projects.map((project) => project.id) + const notification = addNotification({ + title: 'Validation scan complete', + text: `Found ${response.total} projects with validation ${includeWarnings ? 'errors or warnings' : 'errors'}.`, + type: 'success', + autoCloseMs: null, + copyable: false, + buttons: [ + { + label: 'Copy all IDs', + icon: CopyIcon, + keepOpen: true, + action: () => navigator.clipboard.writeText(projectIds.join('\n')), + }, + ], + }) + validationScanNotificationId = notification.id +} + const { data: validationProjectsResponse, isPending: validationProjectsPending, error: validationProjectsError, } = useQuery({ queryKey: validationProjectsQueryKey, - queryFn: ({ queryKey, signal }) => - scanProjectsWithValidationErrors({ + queryFn: async ({ queryKey, signal }) => { + const response = await scanProjectsWithValidationIssues({ client, - request: queryKey[2], + request: queryKey[3], titleMetadata: { gameVersions: generatedState.value.gameVersions.map(({ version }) => version), loaders: generatedState.value.loaders.map(({ name }) => name), }, + includeWarnings: queryKey[2], signal, log: debugValidationFilter, - }), - enabled: computed( - () => import.meta.client && currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE, - ), + }) + showValidationScanCompleteNotification(response, queryKey[2]) + return response + }, + enabled: computed(() => import.meta.client && isValidationFilter.value), staleTime: VALIDATION_FILTER_STALE_TIME_MS, retry: false, }) -watch( - () => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE, - (isValidationFilter) => { - if (!isValidationFilter) return - const cached = queryClient.getQueryData( - validationProjectsQueryKey.value, - ) - if (cached) { - debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`) - } - }, -) +watch([isValidationFilter, validationProjectsQueryKey], ([isActive]) => { + if (!isActive) return + const cached = queryClient.getQueryData( + validationProjectsQueryKey.value, + ) + if (cached) { + debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`) + showValidationScanCompleteNotification(cached, isValidationErrorAndWarningFilter.value) + } +}) const projectIdsFilterRequest = computed(() => ({ exclude_technical_review: excludeTechnicalReview.value, @@ -640,29 +686,23 @@ const { retry: false, }) -const isValidationErrorFilter = computed( - () => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE, -) -const isProjectIdsFilter = computed(() => currentFilterType.value === PROJECT_IDS_FILTER_TYPE) -const usesLocalPagination = computed( - () => isValidationErrorFilter.value || isProjectIdsFilter.value, -) +const usesLocalPagination = computed(() => isValidationFilter.value || isProjectIdsFilter.value) const moderationProjectsResponse = computed(() => - isValidationErrorFilter.value + isValidationFilter.value ? validationProjectsResponse.value : isProjectIdsFilter.value ? projectIdsProjectsResponse.value : standardProjectsResponse.value, ) const pending = computed(() => - isValidationErrorFilter.value + isValidationFilter.value ? validationProjectsPending.value : isProjectIdsFilter.value ? projectIds.value.length > 0 && projectIdsProjectsPending.value : standardProjectsPending.value || standardProjectsPlaceholder.value, ) const loadError = computed(() => - isValidationErrorFilter.value + isValidationFilter.value ? validationProjectsError.value : isProjectIdsFilter.value ? projectIdsProjectsError.value diff --git a/apps/frontend/src/services/moderation/validation-filter.ts b/apps/frontend/src/services/moderation/validation-filter.ts index c381126254..93bfca9f22 100644 --- a/apps/frontend/src/services/moderation/validation-filter.ts +++ b/apps/frontend/src/services/moderation/validation-filter.ts @@ -1,5 +1,5 @@ import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client' -import { hasProjectFieldValidationFailures, type ProjectTitleMetadata } from '@modrinth/moderation' +import { type ProjectTitleMetadata, validateProjectFields } from '@modrinth/moderation' export type ValidationFilterRequest = Omit< Labrinth.Moderation.Internal.ProjectsRequest, @@ -17,6 +17,7 @@ interface ValidationFilterScanOptions { client: AbstractModrinthClient request: ValidationFilterRequest titleMetadata: ProjectTitleMetadata + includeWarnings: boolean signal: AbortSignal log: (message: string) => void } @@ -126,10 +127,11 @@ export async function fetchAllModerationQueueProjects( } } -export async function scanProjectsWithValidationErrors({ +export async function scanProjectsWithValidationIssues({ client, request, titleMetadata, + includeWarnings, signal, log, }: ValidationFilterScanOptions): Promise { @@ -168,7 +170,8 @@ export async function scanProjectsWithValidationErrors({ if (!project) { throw new Error(`V3 projects response omitted queued project ${projectId}`) } - if (hasProjectFieldValidationFailures(project, titleMetadata)) { + const validation = validateProjectFields(project, titleMetadata) + if (includeWarnings ? validation.failures.length > 0 : !validation.valid) { matchingProjectIds.add(projectId) } } diff --git a/packages/moderation/package.json b/packages/moderation/package.json index d023fa5eba..fd231a29f9 100644 --- a/packages/moderation/package.json +++ b/packages/moderation/package.json @@ -16,6 +16,7 @@ "@modrinth/utils": "workspace:*", "@modrinth/api-client": "workspace:*", "linkify-it": "^5.0.0", + "obscenity": "^0.4.6", "vue": "^3.5.13" }, "devDependencies": { diff --git a/packages/moderation/src/locales/en-US/index.json b/packages/moderation/src/locales/en-US/index.json index a5345fa743..c4b84aa774 100644 --- a/packages/moderation/src/locales/en-US/index.json +++ b/packages/moderation/src/locales/en-US/index.json @@ -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." diff --git a/packages/moderation/src/validators/non-standard-text/index.ts b/packages/moderation/src/validators/non-standard-text/index.ts index 3ab8713279..fdc9ca028f 100644 --- a/packages/moderation/src/validators/non-standard-text/index.ts +++ b/packages/moderation/src/validators/non-standard-text/index.ts @@ -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 = [ [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) } } diff --git a/packages/moderation/src/validators/non-standard-text/tests.ts b/packages/moderation/src/validators/non-standard-text/tests.ts index 49165aae65..d5fdb3b064 100644 --- a/packages/moderation/src/validators/non-standard-text/tests.ts +++ b/packages/moderation/src/validators/non-standard-text/tests.ts @@ -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), diff --git a/packages/moderation/src/validators/profanity/index.ts b/packages/moderation/src/validators/profanity/index.ts index 04b77a34e7..cf0e6bf417 100644 --- a/packages/moderation/src/validators/profanity/index.ts +++ b/packages/moderation/src/validators/profanity/index.ts @@ -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 - negatives: NegativeMatch[] - terminal?: { - kind: ProfanityKind - term: string - } -} - -const CHARACTER_REPLACEMENTS: Readonly> = { - '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>>> = { - '(': { ')': 'o' }, - '[': { ']': 'o' }, - '{': { '}': 'o' }, - '<': { '>': 'o' }, -} - -const TERM_EXCEPTIONS: Readonly> = { - 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> = 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 { + const thresholds = new Map() -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>): 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, diff --git a/packages/moderation/src/validators/profanity/tests.ts b/packages/moderation/src/validators/profanity/tests.ts index 87f8cef493..9204f7b1eb 100644 --- a/packages/moderation/src/validators/profanity/tests.ts +++ b/packages/moderation/src/validators/profanity/tests.ts @@ -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/, ) }) diff --git a/packages/moderation/src/validators/project-fields/index.ts b/packages/moderation/src/validators/project-fields/index.ts index d88a5e2da9..64e9bfa344 100644 --- a/packages/moderation/src/validators/project-fields/index.ts +++ b/packages/moderation/src/validators/project-fields/index.ts @@ -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 diff --git a/packages/moderation/src/validators/project-fields/tests.ts b/packages/moderation/src/validators/project-fields/tests.ts index 87ef84eab9..92108d6d5d 100644 --- a/packages/moderation/src/validators/project-fields/tests.ts +++ b/packages/moderation/src/validators/project-fields/tests.ts @@ -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') diff --git a/packages/moderation/src/validators/project-validation/tests.ts b/packages/moderation/src/validators/project-validation/tests.ts index 4d89aa0f66..af165e51ef 100644 --- a/packages/moderation/src/validators/project-validation/tests.ts +++ b/packages/moderation/src/validators/project-validation/tests.ts @@ -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', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 541b2bc76f..cca25cd36c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,6 +560,9 @@ importers: linkify-it: specifier: ^5.0.0 version: 5.0.0 + obscenity: + specifier: ^0.4.6 + version: 0.4.6 vue: specifier: ^3.5.13 version: 3.5.27(typescript@5.9.3) @@ -8005,6 +8008,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obscenity@0.4.6: + resolution: {integrity: sha512-pHk7kNN7j3L3zGhhGnwxjvXIGsPpLrcZl2r58fqWh/V/rH6b/dafscj2sMmAY+A/9/wPsocLmimgGk2DKeKsFQ==} + engines: {node: '>=18.0.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -18670,6 +18677,8 @@ snapshots: object-inspect@1.13.4: {} + obscenity@0.4.6: {} + obug@2.1.1: {} ofetch@1.5.1: