@@ -108,6 +109,7 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
+
{{ formatMessage(messages.summaryDescription) }}
@@ -148,6 +150,8 @@ import {
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
+import ValidationMessage from '~/components/ValidationMessage.vue'
+import { validateProjectText } from '~/composables/project-text-validation'
import { generateUrlSlug } from '~/utils/slugs'
import CreateLimitAlert from './CreateLimitAlert.vue'
@@ -303,8 +307,12 @@ const visibilities = ref([
])
const visibility = ref(visibilities.value[0])
+const nameValidation = computed(() => validateProjectText(name.value))
+const summaryValidation = computed(() => validateProjectText(description.value))
+
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
+ if (nameValidation.value || summaryValidation.value) return true
if (!name.value.trim() || !slug.value.trim()) return true
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
@@ -392,6 +400,7 @@ async function fetchOrganizations() {
}
async function createProject() {
+ if (disableCreate.value) return
startLoading()
const formData = new FormData()
diff --git a/apps/frontend/src/composables/project-text-validation.ts b/apps/frontend/src/composables/project-text-validation.ts
new file mode 100644
index 0000000000..6d1cc80223
--- /dev/null
+++ b/apps/frontend/src/composables/project-text-validation.ts
@@ -0,0 +1,42 @@
+import { validateNonStandardText, validateProfanity } from '@modrinth/moderation'
+import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
+
+export interface ProjectTextValidationResult {
+ severity: 'error'
+ message: MessageDescriptor
+}
+
+const messages = defineMessages({
+ slur: {
+ id: 'project.text-validation.slur',
+ defaultMessage: 'Slurs are not allowed.',
+ },
+ profanity: {
+ id: 'project.text-validation.profanity',
+ defaultMessage: 'Profanity is not allowed.',
+ },
+ nonStandardText: {
+ id: 'project.text-validation.non-standard-text',
+ defaultMessage: 'Non-standard text characters are not allowed.',
+ },
+})
+
+export function validateProjectText(
+ text: string | null | undefined,
+): ProjectTextValidationResult | null {
+ if (!text) return null
+
+ const profanity = validateProfanity(text)
+ if (profanity.slurCount > 0) {
+ return { severity: 'error', message: messages.slur }
+ }
+ if (profanity.profanityCount > 0) {
+ return { severity: 'error', message: messages.profanity }
+ }
+
+ if (!validateNonStandardText(text).valid) {
+ return { severity: 'error', message: messages.nonStandardText }
+ }
+
+ return null
+}
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
index 6cd1c44bef..00018f7f33 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
@@ -17,13 +17,10 @@
+
{{ descriptionWarning }}
@@ -34,6 +31,7 @@
:original="saved"
:modified="current"
:saving="saving"
+ :can-save="canSave"
@reset="reset"
@save="save"
/>
@@ -56,7 +54,9 @@ import { TeamMemberPermission } from '@modrinth/utils'
import { computed, useTemplateRef } from 'vue'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
+import ValidationMessage from '~/components/ValidationMessage.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
+import { validateProjectText } from '~/composables/project-text-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
@@ -64,7 +64,14 @@ const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.description)
-const { saved, current, saving, hasChanges, reset, save } = useSavable(
+const {
+ saved,
+ current,
+ saving,
+ hasChanges,
+ reset,
+ save: saveForm,
+} = useSavable(
() => ({ description: project.value.body }),
async ({ description }) => {
await patchProject({ body: description })
@@ -73,6 +80,20 @@ const { saved, current, saving, hasChanges, reset, save } = useSavable(
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
+const hasPermission = computed(
+ () =>
+ !!currentMember.value &&
+ (currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
+ TeamMemberPermission.EDIT_BODY,
+)
+const descriptionValidation = computed(() => validateProjectText(current.value.description))
+const canSave = computed(() => hasPermission.value && !descriptionValidation.value)
+
+async function save() {
+ if (!canSave.value) return
+ await saveForm()
+}
+
const descriptionWarning = computed(() => {
const text = current.value.description?.trim() || ''
const charCount = countText(text)
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/general.vue b/apps/frontend/src/pages/[type]/[project]/settings/general.vue
index 8a4bf18911..b99e84559d 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/general.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/general.vue
@@ -14,13 +14,23 @@ import {
useVIntl,
} from '@modrinth/ui'
+import ValidationMessage from '~/components/ValidationMessage.vue'
+import { validateProjectText } from '~/composables/project-text-validation'
+
const { formatMessage } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
-const { saved, current, saving, hasChanges, reset, save } = useSavable(
+const {
+ saved,
+ current,
+ saving,
+ hasChanges,
+ reset,
+ save: saveForm,
+} = useSavable(
() => ({
title: project.value.title,
tagline: project.value.description,
@@ -38,6 +48,15 @@ const { saved, current, saving, hasChanges, reset, save } = useSavable(
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
+const titleValidation = computed(() => validateProjectText(current.value.title))
+const taglineValidation = computed(() => validateProjectText(current.value.tagline))
+const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
+
+async function save() {
+ if (!canSave.value) return
+ await saveForm()
+}
+
const messages = defineMessages({
nameTitle: {
id: 'project.settings.general.name.title',
@@ -129,6 +148,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
:original="saved"
:modified="current"
:saving="saving"
+ :can-save="canSave"
@reset="reset"
@save="save"
/>
@@ -152,6 +172,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
wrapper-class="flex-grow"
/>
+
placeholders[placeholderIndex.value] ?? place
:maxlength="120"
wrapper-class="w-full"
/>
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
index 94a4b17957..4a8cf25e01 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/index.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
@@ -28,6 +28,7 @@
:maxlength="2048"
:disabled="!hasPermission"
/>
+
@@ -60,6 +61,7 @@
:disabled="!hasPermission"
resize="vertical"
/>
+
{{ summaryWarning }}
@@ -289,6 +291,7 @@
:original="original"
:modified="modified"
:saving="saving"
+ :can-save="canSave"
@reset="resetChanges"
@save="handleSave"
/>
@@ -325,7 +328,9 @@ import {
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
+import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth.js'
+import { validateProjectText } from '~/composables/project-text-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
@@ -397,6 +402,12 @@ const hasPermission = computed(() => {
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
+const nameValidation = computed(() => validateProjectText(name.value))
+const summaryValidation = computed(() => validateProjectText(summary.value))
+const canSave = computed(
+ () => hasPermission.value && !nameValidation.value && !summaryValidation.value,
+)
+
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
const hasDeletePermission = computed(() => {
@@ -529,6 +540,7 @@ async function updateMonetizationStatus(status) {
}
async function handleSave() {
+ if (!canSave.value) return
saving.value = true
try {
const hasPatchChanges = Object.keys(basePatchData.value).length > 0
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/license.vue b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
index 345f38c6c8..645f5366c5 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/license.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
@@ -84,7 +84,7 @@
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
wrapper-class="w-full"
/>
-
+
@@ -165,7 +165,7 @@ import {
import { builtinLicenses, formatProjectType, TeamMemberPermission } from '@modrinth/utils'
import { computed } from 'vue'
-import LinkCheckMessage from '@/components/LinkCheckMessage.vue'
+import ValidationMessage from '@/components/ValidationMessage.vue'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/links.vue b/apps/frontend/src/pages/[type]/[project]/settings/links.vue
index aa4fbdf60e..2d760d1c70 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/links.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/links.vue
@@ -17,7 +17,7 @@
maxlength="2048"
:disabled="!hasPermission"
/>
-
+
-
+
@@ -92,7 +92,7 @@
:maxlength="2048"
:disabled="!hasPermission"
/>
-
+
-
+
-
+
Donation links
@@ -179,7 +179,7 @@
class="platform-selector !w-80"
@update:model-value="updateDonationLinks"
/>
-
+
{
@@ -75,6 +76,10 @@ function isEmojiModifier(codePoint: number) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff
}
+function isEmojiTag(codePoint: number) {
+ return codePoint >= 0xe0020 && codePoint <= 0xe007f
+}
+
function isAscii(character: string) {
return character.codePointAt(0)! <= 0x7f
}
@@ -129,6 +134,24 @@ function isAllowedVariationSelector(
return UNIFIED_IDEOGRAPH_PATTERN.test(previous)
}
+function isAllowedEmojiTagSequence(characters: readonly string[], characterIndex: number) {
+ let start = characterIndex - 1
+ while (start >= 0 && isEmojiTag(characters[start].codePointAt(0)!)) start--
+ if (characters[start]?.codePointAt(0) !== 0x1f3f4) return false
+
+ let end = characterIndex
+ while (end < characters.length && isEmojiTag(characters[end].codePointAt(0)!)) end++
+ return characters[end - 1]?.codePointAt(0) === 0xe007f
+}
+
+function isPresentedAsEmoji(characters: readonly string[], characterIndex: number) {
+ const character = characters[characterIndex]
+ return (
+ EMOJI_PRESENTATION_PATTERN.test(character) ||
+ characters[characterIndex + 1]?.codePointAt(0) === 0xfe0f
+ )
+}
+
function codePointLabel(codePoint: number) {
return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
}
@@ -200,7 +223,8 @@ export function validateNonStandardText(
if (FORMAT_PATTERN.test(character)) {
const allowed =
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
- (codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex))
+ (codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex)) ||
+ (isEmojiTag(codePoint) && isAllowedEmojiTagSequence(characters, characterIndex))
if (!allowed) addIssue('invisible', character, codePoint, currentIndex)
hasBaseCharacter = false
combiningMarkCount = 0
@@ -225,7 +249,7 @@ export function validateNonStandardText(
combiningMarkCount = 0
hasBaseCharacter = !/^\s$/u.test(character)
- if (isInRanges(codePoint, FANCY_RANGES)) {
+ if (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 9352871df1..cc4c7e27e0 100644
--- a/packages/moderation/src/validators/non-standard-text/tests.ts
+++ b/packages/moderation/src/validators/non-standard-text/tests.ts
@@ -57,6 +57,9 @@ test('allows ordinary emoji and valid emoji joiner sequences', () => {
assert.equal(validateNonStandardText('Family: ๐จโ๐ฉโ๐งโ๐ฆ').valid, true)
assert.equal(validateNonStandardText('Developer: ๐ง๐ฝโ๐ป').valid, true)
assert.equal(validateNonStandardText('Heart: โค๏ธ').valid, true)
+ assert.equal(validateNonStandardText('Information: โน๏ธ').valid, true)
+ assert.equal(validateNonStandardText('A button: ๐
ฐ๏ธ').valid, true)
+ assert.equal(validateNonStandardText('Scotland: ๐ด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ').valid, true)
})
test('detects suspicious invisible and directional characters', () => {
diff --git a/packages/moderation/src/validators/profanity/index.ts b/packages/moderation/src/validators/profanity/index.ts
index 8c897dec8c..04b77a34e7 100644
--- a/packages/moderation/src/validators/profanity/index.ts
+++ b/packages/moderation/src/validators/profanity/index.ts
@@ -499,7 +499,7 @@ function findAt(root: TrieNode, text: string, start: number): ProfanityMatch | u
const matchesNegative = current.negatives.some((negative) =>
negativeMatches(negative, text, start, end),
)
- if (matchesNegative && current.children.size === 0) return undefined
+ if (matchesNegative) continue
return {
kind: current.terminal.kind,
diff --git a/packages/moderation/src/validators/profanity/tests.ts b/packages/moderation/src/validators/profanity/tests.ts
index fdfe6101e6..87f8cef493 100644
--- a/packages/moderation/src/validators/profanity/tests.ts
+++ b/packages/moderation/src/validators/profanity/tests.ts
@@ -57,7 +57,7 @@ test('uses the first terminal when one configured term prefixes another', () =>
})
test('does not match configured false-positive substrings', () => {
- assert.equal(validateProfanity('Scunthorpe and peacock').valid, true)
+ assert.equal(validateProfanity('Scunthorpe, Clitheroe, and peacock').valid, true)
assert.equal(validateProfanity('cock and cunt').profanityCount, 2)
})