feat: add check for html formatting in disclosures

This commit is contained in:
tdgao
2026-09-01 14:58:48 -06:00
parent 860c5029d4
commit 621097bcf3
5 changed files with 175 additions and 13 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { validateProjectDisclosures } from '@modrinth/moderation'
import {
commonMessages,
ConfirmLeaveModal,
@@ -30,6 +31,7 @@ import {
type DisclosureType,
type DisclosureUpdatedByUser,
findDisclosureData,
formToDisclosures,
getDisclosureFormIssues,
getDisclosureFormSnapshot,
PaidFeaturesDisclosureCard,
@@ -251,9 +253,15 @@ watch(
)
const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
const disclosureTextValidation = computed(() =>
validateProjectDisclosures(formToDisclosures(current.value)),
)
const canSave = computed(
() => hasPermission.value && (isAdminUser.value || issues.value.length === 0),
() =>
hasPermission.value &&
disclosureTextValidation.value.length === 0 &&
(isAdminUser.value || issues.value.length === 0),
)
const saveDisabledReason = computed(() => {
@@ -261,7 +269,10 @@ const saveDisabledReason = computed(() => {
// should never come up but y'never know
return formatMessage(messages.noPermission)
}
return issues.value.map((issue) => formatMessage(issueMessages[issue]))
return [
...issues.value.map((issue) => formatMessage(issueMessages[issue])),
...disclosureTextValidation.value.map(({ message, values }) => formatMessage(message, values)),
]
})
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
@@ -1,10 +1,13 @@
import type { Labrinth } from '@modrinth/api-client'
import { defineMessages } from '@modrinth/ui/i18n'
import { formatProjectTypeSentence } from '@modrinth/ui/src/utils/common-messages.ts'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { hasProjectTextHtmlFormatting } from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
@@ -16,8 +19,57 @@ const messages = defineMessages({
defaultMessage:
'Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.',
},
removeFormatting: {
id: 'nags.disclosures-special-formatting.title',
defaultMessage: 'Remove HTML from content disclosures',
},
specialFormatting: {
id: 'nags.disclosures-special-formatting.description',
defaultMessage: 'Content disclosures cannot contain HTML, as they display in plain text.',
},
})
function getDisclosureText(disclosure: Labrinth.Projects.v3.ProjectDisclosure): string[] {
switch (disclosure.type) {
case 'ai_content':
case 'advertisements':
case 'epilepsy_triggers':
case 'archived':
return disclosure.note ? [disclosure.note] : []
case 'system_interactions':
return disclosure.note ? [disclosure.note] : []
case 'telemetry':
return disclosure.data_collected
case 'derivative_work':
return disclosure.sources.flatMap((source) =>
source.note ? [source.label, source.note] : [source.label],
)
case 'paid_features':
return disclosure.features
}
}
export const projectDisclosureTextValidationRules = {
'disclosures-special-formatting': {
severity: 'error',
evaluate: (disclosures) => ({
valid: !disclosures.some((disclosure) =>
getDisclosureText(disclosure).some(hasProjectTextHtmlFormatting),
),
}),
presentation: {
message: messages.specialFormatting,
nag: { title: messages.removeFormatting, destination: 'disclosures' },
},
},
} satisfies ValidationRuleSet<readonly Labrinth.Projects.v3.ProjectDisclosure[]>
export function validateProjectDisclosures(
disclosures: readonly Labrinth.Projects.v3.ProjectDisclosure[],
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(disclosures, projectDisclosureTextValidationRules))
}
export const projectDisclosureValidationRules = {
'check-disclosures': {
severity: 'suggestion',
@@ -1,5 +1,4 @@
import { defineMessages } from '@modrinth/ui/i18n'
import { md } from '@modrinth/utils/parse.ts'
import LinkifyIt from 'linkify-it'
import tlds from 'tlds' with { type: 'json' }
@@ -11,6 +10,7 @@ import {
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
hasProjectTextFormatting,
normalizeProjectFieldText,
projectRequiresEnglishText,
} from '../text.ts'
@@ -101,10 +101,6 @@ const summaryLinkify = new LinkifyIt({
fuzzyLink: true,
}).tlds(tlds)
const summaryMarkdown = md({ linkify: false })
const allowedSummaryBlockTokenTypes = new Set(['paragraph_open', 'inline', 'paragraph_close'])
const allowedSummaryInlineTokenTypes = new Set(['text', 'softbreak', 'hardbreak'])
function containsProjectSummaryLinkOrIp(summary: string): boolean {
return summaryLinkify.test(summary)
}
@@ -117,11 +113,7 @@ export function projectSummaryMatchesName(summary: string, name: string) {
}
export function hasProjectSummaryFormatting(summary: string) {
return summaryMarkdown.parse(summary, {}).some((token) => {
if (!allowedSummaryBlockTokenTypes.has(token.type)) return true
return token.children?.some((child) => !allowedSummaryInlineTokenTypes.has(child.type)) ?? false
})
return hasProjectTextFormatting(summary)
}
const commonNagPresentation = {
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { Labrinth } from '@modrinth/api-client'
import type { ProjectValidationContext } from '../types/nags.ts'
import { evaluateRules } from './evaluate-rules.ts'
import {
@@ -15,6 +17,7 @@ import {
MIN_DESCRIPTION_CHARS,
validateProjectDescription,
} from './rules/description.ts'
import { validateProjectDisclosures } from './rules/disclosures.ts'
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
import {
@@ -205,6 +208,70 @@ test('allows plain-text punctuation in project summaries', () => {
}
})
test('rejects HTML in disclosure text', () => {
const formattedDisclosures = [
{ type: 'ai_content', uses: [], note: '<b>Bold disclosure</b>' },
{ type: 'advertisements', note: '<strong>HTML disclosure</strong>' },
{ type: 'paid_features', features: ['<code>Paid feature</code>'] },
{ type: 'telemetry', consent: 'opt_in', data_collected: ['<h1>Collected data</h1>'] },
{
type: 'derivative_work',
sources: [{ label: 'Original work', note: '<li>Derived feature</li>' }],
},
{ type: 'epilepsy_triggers', note: '<em>Flashing lights</em>' },
{
type: 'system_interactions',
interactions: [],
note: '<strong>Desktop file access</strong>',
},
{ type: 'archived', note: '<span>No longer maintained</span>' },
] satisfies Labrinth.Projects.v3.ProjectDisclosure[]
for (const disclosure of formattedDisclosures) {
assert.deepEqual(
validateProjectDisclosures([disclosure]).map(({ code }) => code),
['disclosures-special-formatting'],
)
}
const markdownDisclosures = [
{ type: 'ai_content', uses: [], note: '**Bold disclosure**' },
{ type: 'paid_features', features: ['`Paid feature`'] },
{ type: 'telemetry', consent: 'opt_in', data_collected: ['# Collected data'] },
{
type: 'derivative_work',
sources: [{ label: 'Original work', note: '- Derived feature' }],
},
] satisfies Labrinth.Projects.v3.ProjectDisclosure[]
assert.deepEqual(validateProjectDisclosures(markdownDisclosures), [])
const unpairedHtmlDisclosures = [
{ type: 'ai_content', uses: [], note: '<b>Bold disclosure' },
{ type: 'advertisements', note: 'HTML disclosure</strong>' },
{ type: 'paid_features', features: ['A line break<br>'] },
{ type: 'epilepsy_triggers', note: 'Visible content <!-- hidden HTML content -->' },
] satisfies Labrinth.Projects.v3.ProjectDisclosure[]
assert.deepEqual(validateProjectDisclosures(unpairedHtmlDisclosures), [])
assert.deepEqual(
validateProjectDisclosures([
{
type: 'derivative_work',
sources: [
{
label: 'Example project',
link: 'https://example.com/path_with_underscores',
note: 'First line\nSecond line',
},
],
},
]),
[],
)
})
test('rejects every link and IP address in project summaries', () => {
for (const summary of [
'Visit https://example.dev for more information about this project',
@@ -1,3 +1,5 @@
import { md } from '@modrinth/utils/parse.ts'
import type { ProjectValidationContext } from '../types/nags.ts'
import {
validateEnglishSummaryText,
@@ -11,6 +13,44 @@ import {
import { validateProfanity } from '../validators/profanity/index.ts'
import type { ValidationRuleEvaluation } from './types.ts'
const projectPlainTextMarkdown = md({ linkify: false })
const allowedPlainTextBlockTokenTypes = new Set(['paragraph_open', 'inline', 'paragraph_close'])
const allowedPlainTextInlineTokenTypes = new Set(['text', 'softbreak', 'hardbreak'])
export function hasProjectTextFormatting(text: string) {
return projectPlainTextMarkdown.parse(text, {}).some((token) => {
if (!allowedPlainTextBlockTokenTypes.has(token.type)) return true
return (
token.children?.some((child) => !allowedPlainTextInlineTokenTypes.has(child.type)) ?? false
)
})
}
const pairedHtmlTagPattern = /<([a-z][\w:-]*)\b[^>]*>[\s\S]*?<\/\1\s*>/i
function hasExplicitlyClosedHtmlElement(html: string) {
return pairedHtmlTagPattern.test(html)
}
export function hasProjectTextHtmlFormatting(text: string) {
const tokens = projectPlainTextMarkdown.parse(text, {})
for (const token of tokens) {
if (token.type === 'html_block' && hasExplicitlyClosedHtmlElement(token.content)) return true
if (!token.children?.some((child) => child.type === 'html_inline')) continue
const inlineHtml = token.children
.filter((child) => child.type !== 'code_inline')
.map((child) => child.content)
.join('')
if (hasExplicitlyClosedHtmlElement(inlineHtml)) return true
}
return false
}
export function normalizeProjectFieldText(value: string) {
return value.trim().normalize('NFC')
}