mirror of
https://github.com/modrinth/code.git
synced 2026-09-05 06:19:11 +00:00
feat: add check for html formatting in disclosures
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user