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: