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">
|
<script setup lang="ts">
|
||||||
|
import { validateProjectDisclosures } from '@modrinth/moderation'
|
||||||
import {
|
import {
|
||||||
commonMessages,
|
commonMessages,
|
||||||
ConfirmLeaveModal,
|
ConfirmLeaveModal,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
type DisclosureType,
|
type DisclosureType,
|
||||||
type DisclosureUpdatedByUser,
|
type DisclosureUpdatedByUser,
|
||||||
findDisclosureData,
|
findDisclosureData,
|
||||||
|
formToDisclosures,
|
||||||
getDisclosureFormIssues,
|
getDisclosureFormIssues,
|
||||||
getDisclosureFormSnapshot,
|
getDisclosureFormSnapshot,
|
||||||
PaidFeaturesDisclosureCard,
|
PaidFeaturesDisclosureCard,
|
||||||
@@ -251,9 +253,15 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
|
const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
|
||||||
|
const disclosureTextValidation = computed(() =>
|
||||||
|
validateProjectDisclosures(formToDisclosures(current.value)),
|
||||||
|
)
|
||||||
|
|
||||||
const canSave = computed(
|
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(() => {
|
const saveDisabledReason = computed(() => {
|
||||||
@@ -261,7 +269,10 @@ const saveDisabledReason = computed(() => {
|
|||||||
// should never come up but y'never know
|
// should never come up but y'never know
|
||||||
return formatMessage(messages.noPermission)
|
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)
|
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { defineMessages } from '@modrinth/ui/i18n'
|
import { defineMessages } from '@modrinth/ui/i18n'
|
||||||
import { formatProjectTypeSentence } from '@modrinth/ui/src/utils/common-messages.ts'
|
import { formatProjectTypeSentence } from '@modrinth/ui/src/utils/common-messages.ts'
|
||||||
|
|
||||||
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
|
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
|
||||||
import { evaluateRules } from '../evaluate-rules.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 { toNags } from '../to-nags.ts'
|
||||||
import type { ValidationRuleSet } from '../types.ts'
|
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
@@ -16,8 +19,57 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.',
|
'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 = {
|
export const projectDisclosureValidationRules = {
|
||||||
'check-disclosures': {
|
'check-disclosures': {
|
||||||
severity: 'suggestion',
|
severity: 'suggestion',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { defineMessages } from '@modrinth/ui/i18n'
|
import { defineMessages } from '@modrinth/ui/i18n'
|
||||||
import { md } from '@modrinth/utils/parse.ts'
|
|
||||||
import LinkifyIt from 'linkify-it'
|
import LinkifyIt from 'linkify-it'
|
||||||
import tlds from 'tlds' with { type: 'json' }
|
import tlds from 'tlds' with { type: 'json' }
|
||||||
|
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
evaluateNonStandardText,
|
evaluateNonStandardText,
|
||||||
evaluateProfanity,
|
evaluateProfanity,
|
||||||
evaluateSlur,
|
evaluateSlur,
|
||||||
|
hasProjectTextFormatting,
|
||||||
normalizeProjectFieldText,
|
normalizeProjectFieldText,
|
||||||
projectRequiresEnglishText,
|
projectRequiresEnglishText,
|
||||||
} from '../text.ts'
|
} from '../text.ts'
|
||||||
@@ -101,10 +101,6 @@ const summaryLinkify = new LinkifyIt({
|
|||||||
fuzzyLink: true,
|
fuzzyLink: true,
|
||||||
}).tlds(tlds)
|
}).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 {
|
function containsProjectSummaryLinkOrIp(summary: string): boolean {
|
||||||
return summaryLinkify.test(summary)
|
return summaryLinkify.test(summary)
|
||||||
}
|
}
|
||||||
@@ -117,11 +113,7 @@ export function projectSummaryMatchesName(summary: string, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasProjectSummaryFormatting(summary: string) {
|
export function hasProjectSummaryFormatting(summary: string) {
|
||||||
return summaryMarkdown.parse(summary, {}).some((token) => {
|
return hasProjectTextFormatting(summary)
|
||||||
if (!allowedSummaryBlockTokenTypes.has(token.type)) return true
|
|
||||||
|
|
||||||
return token.children?.some((child) => !allowedSummaryInlineTokenTypes.has(child.type)) ?? false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonNagPresentation = {
|
const commonNagPresentation = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
|
||||||
import type { ProjectValidationContext } from '../types/nags.ts'
|
import type { ProjectValidationContext } from '../types/nags.ts'
|
||||||
import { evaluateRules } from './evaluate-rules.ts'
|
import { evaluateRules } from './evaluate-rules.ts'
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
MIN_DESCRIPTION_CHARS,
|
MIN_DESCRIPTION_CHARS,
|
||||||
validateProjectDescription,
|
validateProjectDescription,
|
||||||
} from './rules/description.ts'
|
} from './rules/description.ts'
|
||||||
|
import { validateProjectDisclosures } from './rules/disclosures.ts'
|
||||||
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
|
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
|
||||||
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
|
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
|
||||||
import {
|
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', () => {
|
test('rejects every link and IP address in project summaries', () => {
|
||||||
for (const summary of [
|
for (const summary of [
|
||||||
'Visit https://example.dev for more information about this project',
|
'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 type { ProjectValidationContext } from '../types/nags.ts'
|
||||||
import {
|
import {
|
||||||
validateEnglishSummaryText,
|
validateEnglishSummaryText,
|
||||||
@@ -11,6 +13,44 @@ import {
|
|||||||
import { validateProfanity } from '../validators/profanity/index.ts'
|
import { validateProfanity } from '../validators/profanity/index.ts'
|
||||||
import type { ValidationRuleEvaluation } from './types.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) {
|
export function normalizeProjectFieldText(value: string) {
|
||||||
return value.trim().normalize('NFC')
|
return value.trim().normalize('NFC')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user