From 5dcd66f27f3e094ebea8d733709db5b4e0074ecf Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 26 Aug 2026 18:16:55 -0600 Subject: [PATCH] feat: change links in summary to be a blocking error and combined with formatting nag, banned links in description are also blocking --- .../composables/project-field-validation.ts | 4 + packages/moderation/src/data/nags/links.ts | 70 +++++++----- .../src/data/nags/project-validation.ts | 6 +- packages/moderation/src/index.ts | 1 + .../moderation/src/locales/en-US/index.json | 20 ++-- .../src/validators/link-checks/index.ts | 31 +---- .../src/validators/link-checks/tests.ts | 19 ++++ .../src/validators/project-fields/index.ts | 66 ++++++++--- .../src/validators/project-fields/tests.ts | 106 +++++++++++++++++- .../src/validators/project-links/index.ts | 65 +++++++++++ .../src/validators/project-links/tests.ts | 26 +++++ .../validators/project-validation/tests.ts | 22 +++- 12 files changed, 349 insertions(+), 87 deletions(-) create mode 100644 packages/moderation/src/validators/project-links/index.ts create mode 100644 packages/moderation/src/validators/project-links/tests.ts diff --git a/apps/frontend/src/composables/project-field-validation.ts b/apps/frontend/src/composables/project-field-validation.ts index d82bea3bfa..d317331889 100644 --- a/apps/frontend/src/composables/project-field-validation.ts +++ b/apps/frontend/src/composables/project-field-validation.ts @@ -46,6 +46,10 @@ export function useProjectDescriptionValidation( linkValidation.value = null if (import.meta.server) return + if (validateProjectDescription(text).some(({ code }) => code === 'text-banned-link')) { + pending.value = false + return + } const links = extractProjectLinks(text ?? '') if (links.length === 0) { diff --git a/packages/moderation/src/data/nags/links.ts b/packages/moderation/src/data/nags/links.ts index e83557824a..0a31d08125 100644 --- a/packages/moderation/src/data/nags/links.ts +++ b/packages/moderation/src/data/nags/links.ts @@ -2,6 +2,10 @@ import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui import type { Nag, NagContext } from '../../types/nags' import { licenseRequiresSource, notSourceAsDistributed } from '../../utils' +import { + getBlockedProjectContentLink, + PROJECT_CONTENT_LINK_SHORTENERS, +} from '../../validators/project-links' export const commonLinkDomains = { source: [ @@ -45,10 +49,10 @@ export const commonLinkDomains = { 'example.com', 't.me', ], - linkShorteners: ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'], + linkShorteners: PROJECT_CONTENT_LINK_SHORTENERS, } -export function isCommonUrl(url: string | null, commonDomains: string[]): boolean { +export function isCommonUrl(url: string | null, commonDomains: readonly string[]): boolean { if (url === null || url === '') return true try { const domain = new URL(url).hostname.toLowerCase() @@ -58,7 +62,7 @@ export function isCommonUrl(url: string | null, commonDomains: string[]): boolea } } -export function isCommonUrlOfType(url: string | null, commonDomains: string[]): boolean { +export function isCommonUrlOfType(url: string | null, commonDomains: readonly string[]): boolean { if (url === null || url === '') return false return isCommonUrl(url, commonDomains) } @@ -75,6 +79,26 @@ export function isUncommonLicenseUrl(url: string | null): boolean { return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist) } +export function findBlockedProjectExternalLink(context: Pick) { + const urls = [ + context.project.source_url, + context.project.issues_url, + context.project.wiki_url, + context.project.discord_url, + context.project.license.url, + ...(context.project.donation_urls ?? []).map(({ url }) => url), + ...Object.values(context.projectV3?.link_urls ?? {}).map(({ url }) => url), + ] + + for (const url of urls) { + if (!url) continue + const blockedLink = getBlockedProjectContentLink(url) + if (blockedLink) return blockedLink + } + + return null +} + export const linksNags: Nag[] = [ { id: 'add-links', @@ -210,36 +234,26 @@ export const linksNags: Nag[] = [ }, }, { - id: 'link-shortener-usage', + id: 'banned-link-usage', title: defineMessage({ - id: 'nags.link-shortener-usage.title', - defaultMessage: "Don't use link shorteners", + id: 'nags.banned-link-usage.title', + defaultMessage: 'Remove prohibited links', }), - description: defineMessage({ - id: 'nags.link-shortener-usage.description', - defaultMessage: - 'Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links.', - }), - status: 'required', - shouldShow: (context: NagContext) => { - if (context.project.donation_urls) { - for (const donation of context.project.donation_urls) { - if (isLinkShortener(donation.url ?? null)) { - return true - } - } - } + description: (context: NagContext) => { + const blockedLink = findBlockedProjectExternalLink(context) + if (!blockedLink) return '' - return ( - isLinkShortener(context.project.source_url ?? null) || - isLinkShortener(context.project.issues_url ?? null) || - isLinkShortener(context.project.wiki_url ?? null) || - isLinkShortener(context.project.discord_url ?? null) || - isLinkShortener(context.projectV3?.link_urls?.site?.url ?? null) || - isLinkShortener(context.projectV3?.link_urls?.store?.url ?? null) || - Boolean(context.project.license.url && isLinkShortener(context.project.license.url ?? null)) + const { formatMessage } = useVIntl() + return formatMessage( + defineMessage({ + id: 'nags.banned-link-usage.description', + defaultMessage: '“{url}” is not allowed in project links.', + }), + blockedLink, ) }, + status: 'required', + shouldShow: (context: NagContext) => findBlockedProjectExternalLink(context) !== null, }, { id: 'invalid-license-url', diff --git a/packages/moderation/src/data/nags/project-validation.ts b/packages/moderation/src/data/nags/project-validation.ts index 963094cecf..4c0ff39877 100644 --- a/packages/moderation/src/data/nags/project-validation.ts +++ b/packages/moderation/src/data/nags/project-validation.ts @@ -18,15 +18,15 @@ const summaryErrorCodes: readonly ProjectTextValidationCode[] = [ 'text-slur', 'text-profanity', 'text-non-standard', -] -const summaryWarningCodes: readonly ProjectTextValidationCode[] = [ + 'text-banned-link', 'summary-link', - 'summary-matches-title', ] +const summaryWarningCodes: readonly ProjectTextValidationCode[] = ['summary-matches-title'] const descriptionErrorCodes: readonly ProjectTextValidationCode[] = [ 'text-slur', 'text-profanity', 'text-non-standard', + 'text-banned-link', ] function getFirstFailure( diff --git a/packages/moderation/src/index.ts b/packages/moderation/src/index.ts index fee752654f..4d8ddcafce 100644 --- a/packages/moderation/src/index.ts +++ b/packages/moderation/src/index.ts @@ -23,4 +23,5 @@ export * from './validators/link-checks' export * from './validators/non-standard-text' export * from './validators/profanity' export * from './validators/project-fields' +export * from './validators/project-links' export * from './validators/project-validation' diff --git a/packages/moderation/src/locales/en-US/index.json b/packages/moderation/src/locales/en-US/index.json index 924224cdab..ee164a750c 100644 --- a/packages/moderation/src/locales/en-US/index.json +++ b/packages/moderation/src/locales/en-US/index.json @@ -47,6 +47,12 @@ "nags.all-tags-selected.title": { "defaultMessage": "Select accurate tags" }, + "nags.banned-link-usage.description": { + "defaultMessage": "“{url}” is not allowed in project links." + }, + "nags.banned-link-usage.title": { + "defaultMessage": "Remove prohibited links" + }, "nags.check-disclosures.description": { "defaultMessage": "Make sure users are aware of any important details by filling in content disclosures that apply to your {type}." }, @@ -128,12 +134,6 @@ "nags.invalid-project-summary.title": { "defaultMessage": "Fix the project summary" }, - "nags.link-shortener-usage.description": { - "defaultMessage": "Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links." - }, - "nags.link-shortener-usage.title": { - "defaultMessage": "Don't use link shorteners" - }, "nags.link.discord.channel": { "defaultMessage": "This is a link to a Discord channel, not a server invite." }, @@ -297,7 +297,7 @@ "defaultMessage": "Visit versions settings" }, "nags.summary-special-formatting.description": { - "defaultMessage": "Your summary should not contain formatting, line breaks, or special characters, since the summary will only display plain text." + "defaultMessage": "Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text." }, "nags.summary-special-formatting.title": { "defaultMessage": "Clean up the summary" @@ -350,6 +350,9 @@ "nags.visit-links-settings.title": { "defaultMessage": "Visit links settings" }, + "project.text-validation.banned-link": { + "defaultMessage": "“{url}” is not allowed in project summaries or descriptions." + }, "project.text-validation.description-profanity": { "defaultMessage": "Excessive profanity is not allowed. Detected: {values}" }, @@ -362,9 +365,6 @@ "project.text-validation.slur": { "defaultMessage": "The detected slur “{value}” is not allowed." }, - "project.text-validation.summary-link": { - "defaultMessage": "Links should not be included in project summaries." - }, "project.text-validation.summary-matches-title": { "defaultMessage": "A project summary should not be the same as its title." }, diff --git a/packages/moderation/src/validators/link-checks/index.ts b/packages/moderation/src/validators/link-checks/index.ts index b482fa161e..2c4323d68d 100644 --- a/packages/moderation/src/validators/link-checks/index.ts +++ b/packages/moderation/src/validators/link-checks/index.ts @@ -1,5 +1,7 @@ import { computed, onScopeDispose, reactive, type Ref, watch } from 'vue' +import { PROJECT_CONTENT_LINK_BLOCKLIST } from '../project-links/index.ts' + interface MessageDescriptor { id: string defaultMessage?: string @@ -104,7 +106,7 @@ function anchored(source: string): RegExp { function blacklist(label: string, ...domains: string[]): LinkCheckBuilder { const pattern = domains.map((domain) => domain.replace(/\./g, '\\.')).join('|') - return check(new RegExp(`^(?:${pattern})`, 'i'), label) + return check(new RegExp(`^(?:[^./:?#]+\\.)*(?:${pattern})(?=[:/?#]|$)`, 'i'), label) } function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder { @@ -829,32 +831,7 @@ checks.children( ) checks.children( - blacklist('URL Shortener', 'bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'), - - // Social Media - blacklist('Twitter', 'twitter.com', 'x.com'), - blacklist('Instagram', 'instagram.com'), - blacklist('Facebook', 'facebook.com'), - blacklist('TikTok', 'tiktok.com'), - blacklist('Telegram', 'telegram.org', 't.me'), - blacklist('Bilibili', 'bilibili.com'), - blacklist('Bluesky', 'bsky.app'), - blacklist('Twitch', 'twitch.tv'), - blacklist('Reddit', 'reddit.com', 'redd.it'), - - // Minecraft - blacklist('Modrinth', 'modrinth.com'), - blacklist('Minecraft', 'minecraft.net'), - //TODO we should probably setup curseforge/planetminecraft issues for issues but im too lazy to do that rn - blacklist( - 'Mod Distribution Platform', - 'curseforge.com', - 'planetminecraft.com', - '9minecraft.net', - 'mcmod.cn', - ), - - blacklist('AI Mod Generation Platform', 'creativemode.net', 'orcaclient.com', 'autoforged.cn'), + ...PROJECT_CONTENT_LINK_BLOCKLIST.map(({ label, domains }) => blacklist(label, ...domains)), ) export { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck } diff --git a/packages/moderation/src/validators/link-checks/tests.ts b/packages/moderation/src/validators/link-checks/tests.ts index 67234388f8..da2394c043 100644 --- a/packages/moderation/src/validators/link-checks/tests.ts +++ b/packages/moderation/src/validators/link-checks/tests.ts @@ -67,6 +67,25 @@ test('allows unrecognized valid links but keeps global restrictions in general c assert.equal(getLinkCheckState(blocked)?.severity, 'error') }) +test('blocks subdomains of blocklisted hosts without blocking lookalike domains', async () => { + const blocked = { + field: 'description', + url: 'https://social.modrinth.com/project', + generalContent: true, + } + const allowed = { + field: 'description', + url: 'https://modrinth.com.example.dev/project', + generalContent: true, + } + + await checkLink(blocked) + await checkLink(allowed) + + assert.equal(getLinkCheckState(blocked)?.severity, 'error') + assert.equal(getLinkCheckState(allowed)?.severity, 'valid') +}) + test('compares recognized license URLs with the selected license', async () => { const matching = { field: 'license', diff --git a/packages/moderation/src/validators/project-fields/index.ts b/packages/moderation/src/validators/project-fields/index.ts index 7cb4e52256..8dd05714c0 100644 --- a/packages/moderation/src/validators/project-fields/index.ts +++ b/packages/moderation/src/validators/project-fields/index.ts @@ -2,6 +2,7 @@ import LinkifyIt from 'linkify-it' import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts' import { validateProfanity } from '../profanity/index.ts' +import { getBlockedProjectContentLink } from '../project-links/index.ts' export interface ProjectFieldMessageDescriptor { id: string @@ -26,6 +27,7 @@ export type ProjectTextValidationCode = | 'text-slur' | 'text-profanity' | 'text-non-standard' + | 'text-banned-link' | 'title-version-number' | 'title-minecraft-branding' | 'summary-link' @@ -67,6 +69,10 @@ const messages = defineMessages({ id: 'project.text-validation.non-standard-text', defaultMessage: 'Non-standard text characters are not allowed.', }, + bannedLink: { + id: 'project.text-validation.banned-link', + defaultMessage: '“{url}” is not allowed in project summaries or descriptions.', + }, titleVersionNumber: { id: 'project.text-validation.title-version-number', defaultMessage: 'Names are not allowed to include version numbers.', @@ -76,10 +82,6 @@ const messages = defineMessages({ defaultMessage: 'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.', }, - summaryLink: { - id: 'project.text-validation.summary-link', - defaultMessage: 'Links should not be included in project summaries.', - }, summaryMatchesTitle: { id: 'project.text-validation.summary-matches-title', defaultMessage: 'A project summary should not be the same as its title.', @@ -92,7 +94,7 @@ const messages = defineMessages({ summarySpecialFormatting: { id: 'nags.summary-special-formatting.description', defaultMessage: - 'Your summary should not contain formatting, line breaks, or special characters, since the summary will only display plain text.', + 'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.', }, descriptionRequired: { id: 'nags.add-description.description', @@ -151,6 +153,22 @@ export function containsProjectLinkOrIp(text: string) { return linkify.test(text) } +export function containsExplicitHttpProjectLink(text: string) { + return (linkify.match(text) ?? []).some((match) => { + const schema = match.schema.toLowerCase() + return schema === 'http:' || schema === 'https:' + }) +} + +export function findBlockedProjectContentLink(text: string) { + for (const url of extractProjectLinks(text)) { + const blockedLink = getBlockedProjectContentLink(url) + if (blockedLink) return blockedLink + } + + return null +} + export function hasProjectSummaryFormatting(summary: string) { return Boolean( summary.match(/# .*/g) || @@ -195,7 +213,7 @@ export function countText(markdown: string): number { .replace(/\[[^\]]*]\([^)]+\)/g, ' ') const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ') const withoutMarkdownSyntax = withoutHtml - .replace(/^>{1}\s?.*$/gm, ' ') + .replace(/^(?:>[ \t]?)+/gm, '') .replace(/^#{1,6}\s+/gm, ' ') .replace(/[*_~`>-]/g, ' ') .replace(/\|/g, ' ') @@ -317,11 +335,21 @@ export function validateProjectSummary( const results = validateProjectText(summary) if (results.length > 0 || !summary) return results - if (containsProjectLinkOrIp(summary)) { - return [{ code: 'summary-link', severity: 'warn', message: messages.summaryLink }] + const blockedLink = findBlockedProjectContentLink(summary) + if (blockedLink) { + return [ + { + code: 'text-banned-link', + severity: 'error', + message: messages.bannedLink, + values: blockedLink, + }, + ] } - if (title && projectSummaryMatchesTitle(summary, title)) { + const containsExplicitLink = containsExplicitHttpProjectLink(summary) + + if (!containsExplicitLink && title && projectSummaryMatchesTitle(summary, title)) { return [ { code: 'summary-matches-title', @@ -332,7 +360,7 @@ export function validateProjectSummary( } const length = normalizeProjectFieldText(summary).length - if (length < MIN_SUMMARY_CHARS) { + if (!containsExplicitLink && length < MIN_SUMMARY_CHARS) { results.push({ code: 'summary-too-short', severity: 'warn', @@ -341,10 +369,10 @@ export function validateProjectSummary( }) } - if (hasProjectSummaryFormatting(summary)) { + if (hasProjectSummaryFormatting(summary) || containsExplicitLink) { results.push({ - code: 'summary-special-formatting', - severity: 'warn', + code: containsExplicitLink ? 'summary-link' : 'summary-special-formatting', + severity: containsExplicitLink ? 'error' : 'warn', message: messages.summarySpecialFormatting, }) } @@ -386,6 +414,18 @@ export function validateProjectDescription( ] } + const blockedLink = findBlockedProjectContentLink(normalizedDescription) + if (blockedLink) { + return [ + { + code: 'text-banned-link', + severity: 'error', + message: messages.bannedLink, + values: blockedLink, + }, + ] + } + const readableLength = countText(normalizedDescription) if (readableLength < MIN_DESCRIPTION_CHARS) { results.push({ diff --git a/packages/moderation/src/validators/project-fields/tests.ts b/packages/moderation/src/validators/project-fields/tests.ts index 6437ba9fbb..5dee5d7bad 100644 --- a/packages/moderation/src/validators/project-fields/tests.ts +++ b/packages/moderation/src/validators/project-fields/tests.ts @@ -2,8 +2,12 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + containsExplicitHttpProjectLink, containsProjectLinkOrIp, + countText, extractProjectLinks, + MIN_CHARS_PER_IMAGE, + MIN_DESCRIPTION_CHARS, projectSummaryMatchesTitle, validateProjectDescription, validateProjectSummary, @@ -25,6 +29,13 @@ test('detects links and IP addresses but not email addresses or game versions', assert.equal(containsProjectLinkOrIp('Contact hello@example.com'), false) }) +test('distinguishes explicit HTTP links from bare domains', () => { + assert.equal(containsExplicitHttpProjectLink('Visit https://myserver.com'), true) + assert.equal(containsExplicitHttpProjectLink('Visit HTTP://myserver.com'), true) + assert.equal(containsExplicitHttpProjectLink('Visit myserver.com'), false) + assert.equal(containsExplicitHttpProjectLink('The protocol is https://'), false) +}) + test('extracts and deduplicates normalized links', () => { assert.deepEqual( extractProjectLinks( @@ -87,11 +98,31 @@ test('validates project titles', () => { }) test('validates project summaries', () => { + const summaryContentMessage = + 'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.' assert.equal( - validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.message.id, - 'project.text-validation.summary-link', + validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.id, + 'nags.summary-special-formatting.description', + ) + assert.equal( + validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.defaultMessage, + summaryContentMessage, + ) + assert.equal( + validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.severity, + 'error', + ) + assert.equal( + validateProjectSummary('Visit http://example.dev', 'Project title')[0]?.code, + 'summary-link', + ) + assert.deepEqual( + validateProjectSummary( + 'Connect at myserver.com to join our friendly community', + 'Project title', + ), + [], ) - assert.equal(validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.severity, 'warn') assert.equal( validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.id, 'project.text-validation.summary-matches-title', @@ -117,6 +148,47 @@ test('validates project summaries', () => { validateProjectSummary('# Short summary', 'Project title').map(({ code }) => code), ['summary-too-short', 'summary-special-formatting'], ) + assert.equal( + validateProjectSummary('# Short summary', 'Project title')[1]?.message.defaultMessage, + summaryContentMessage, + ) +}) + +test('rejects blocklisted links and IP addresses in summaries and descriptions', () => { + const blockedSummary = validateProjectSummary( + 'Visit https://social.modrinth.com/project', + 'Title', + ) + assert.deepEqual(blockedSummary[0], { + code: 'text-banned-link', + severity: 'error', + message: { + id: 'project.text-validation.banned-link', + defaultMessage: '“{url}” is not allowed in project summaries or descriptions.', + }, + values: { + label: 'Modrinth', + url: 'https://social.modrinth.com/project', + }, + }) + + const blockedDescription = validateProjectDescription( + `A detailed project description with https://bit.ly/project. ${'More details. '.repeat(20)}`, + ) + assert.equal(blockedDescription[0]?.code, 'text-banned-link') + assert.equal(blockedDescription[0]?.values?.label, 'URL shortener') + + const blockedIp = validateProjectSummary('Join 127.0.0.1:25565 to play', 'Title') + assert.equal(blockedIp[0]?.code, 'text-banned-link') + assert.equal(blockedIp[0]?.values?.label, 'IP address') + + const allowedDescription = validateProjectDescription( + `Read more at https://example.dev/project. ${'More details. '.repeat(20)}`, + ) + assert.equal( + allowedDescription.some(({ code }) => code === 'text-banned-link'), + false, + ) }) test('allows sparse non-standard text in descriptions but rejects it at the threshold', () => { @@ -162,6 +234,34 @@ test('allows one profanity match in descriptions but rejects a second match or a assert.equal(validateProjectDescription(`${description} nigger`)[0]?.code, 'text-slur') }) +test('counts blockquote content as readable description text', () => { + assert.equal(countText('> Quoted text'), 'Quoted text'.length) + assert.equal( + countText('> First line\n> > Nested line\n>\n> - Quoted list item'), + 'First line Nested line Quoted list item'.length, + ) + + const quotedDescription = `> ${'A'.repeat(MIN_DESCRIPTION_CHARS)}` + assert.equal( + validateProjectDescription(quotedDescription).some( + ({ code }) => code === 'description-too-short', + ), + false, + ) + + const images = ['![One](one.png)', '![Two](two.png)', '![Three](three.png)', '![Four](four.png)'] + const quotedImageDescription = [ + `> ${'A'.repeat(MIN_CHARS_PER_IMAGE * images.length)}`, + ...images, + ].join('\n') + assert.equal( + validateProjectDescription(quotedImageDescription).some( + ({ code }) => code === 'description-image-heavy', + ), + false, + ) +}) + test('validates required description content and returns simultaneous recommendations', () => { assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required') diff --git a/packages/moderation/src/validators/project-links/index.ts b/packages/moderation/src/validators/project-links/index.ts new file mode 100644 index 0000000000..ac63188b9e --- /dev/null +++ b/packages/moderation/src/validators/project-links/index.ts @@ -0,0 +1,65 @@ +export interface ProjectContentLinkBlocklistEntry { + label: string + domains: readonly string[] +} + +export const PROJECT_CONTENT_LINK_SHORTENERS = [ + 'bit.ly', + 'adf.ly', + 'tinyurl.com', + 'short.io', + 'is.gd', +] as const + +export const PROJECT_CONTENT_LINK_BLOCKLIST: readonly ProjectContentLinkBlocklistEntry[] = [ + { + label: 'URL shortener', + domains: PROJECT_CONTENT_LINK_SHORTENERS, + }, + { label: 'Twitter', domains: ['twitter.com', 'x.com'] }, + { label: 'Instagram', domains: ['instagram.com'] }, + { label: 'Facebook', domains: ['facebook.com'] }, + { label: 'TikTok', domains: ['tiktok.com'] }, + { label: 'Telegram', domains: ['telegram.org', 't.me'] }, + { label: 'Bilibili', domains: ['bilibili.com'] }, + { label: 'Bluesky', domains: ['bsky.app'] }, + { label: 'Twitch', domains: ['twitch.tv'] }, + { label: 'Reddit', domains: ['reddit.com', 'redd.it'] }, + { label: 'Modrinth', domains: ['modrinth.com'] }, + { label: 'Minecraft', domains: ['minecraft.net'] }, + { + label: 'Mod distribution platform', + domains: ['curseforge.com', 'planetminecraft.com', '9minecraft.net', 'mcmod.cn'], + }, + { + label: 'AI mod generation platform', + domains: ['creativemode.net', 'orcaclient.com', 'autoforged.cn'], + }, +] + +export interface BlockedProjectContentLink extends Record { + label: string + url: string +} + +function isIpAddress(hostname: string) { + const strippedHostname = hostname.replace(/^\[|]$/g, '') + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHostname) || strippedHostname.includes(':') +} + +export function getBlockedProjectContentLink(url: string): BlockedProjectContentLink | null { + let hostname: string + try { + hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '') + } catch { + return null + } + + if (isIpAddress(hostname)) return { label: 'IP address', url } + + const entry = PROJECT_CONTENT_LINK_BLOCKLIST.find(({ domains }) => + domains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)), + ) + + return entry ? { label: entry.label, url } : null +} diff --git a/packages/moderation/src/validators/project-links/tests.ts b/packages/moderation/src/validators/project-links/tests.ts new file mode 100644 index 0000000000..f1ac61c53c --- /dev/null +++ b/packages/moderation/src/validators/project-links/tests.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { getBlockedProjectContentLink, PROJECT_CONTENT_LINK_BLOCKLIST } from './index.ts' + +test('blocks every configured project-content domain and its subdomains', () => { + for (const { label, domains } of PROJECT_CONTENT_LINK_BLOCKLIST) { + for (const domain of domains) { + assert.deepEqual(getBlockedProjectContentLink(`https://${domain}/project`), { + label, + url: `https://${domain}/project`, + }) + assert.equal( + getBlockedProjectContentLink(`https://subdomain.${domain}/project`)?.label, + label, + ) + } + } +}) + +test('blocks IP-address URLs without blocking domain lookalikes', () => { + assert.equal(getBlockedProjectContentLink('http://127.0.0.1:25565')?.label, 'IP address') + assert.equal(getBlockedProjectContentLink('https://[2001:db8::1]')?.label, 'IP address') + assert.equal(getBlockedProjectContentLink('https://modrinth.com.example.dev'), null) + assert.equal(getBlockedProjectContentLink('not a URL'), null) +}) diff --git a/packages/moderation/src/validators/project-validation/tests.ts b/packages/moderation/src/validators/project-validation/tests.ts index 1a603bc18b..6a20e0dad2 100644 --- a/packages/moderation/src/validators/project-validation/tests.ts +++ b/packages/moderation/src/validators/project-validation/tests.ts @@ -89,10 +89,10 @@ test('reports whether a project has field validation failures', () => { assert.equal(hasProjectFieldValidationFailures(invalidProject), true) }) -test('treats version numbers as errors and summary content recommendations as warnings', () => { +test('treats version numbers and explicit summary links as errors', () => { const project = createProject({ name: 'Tools 1.2.3', - summary: 'Visit modrinth.com for more information', + summary: 'Visit https://example.dev for more information', }) const result = validateProjectFields(project) @@ -101,7 +101,7 @@ test('treats version numbers as errors and summary content recommendations as wa result.failures.map(({ code, severity }) => ({ code, severity })), [ { code: 'title-version-number', severity: 'error' }, - { code: 'summary-link', severity: 'warn' }, + { code: 'summary-link', severity: 'error' }, ], ) assert.equal(hasProjectFieldValidationFailures(project), true) @@ -111,6 +111,22 @@ test('treats version numbers as errors and summary content recommendations as wa ) }) +test('rejects blocklisted links in summaries and descriptions', () => { + const summaryResult = validateProjectFields( + createProject({ summary: 'Visit modrinth.com for more information' }), + ) + assert.equal(summaryResult.valid, false) + assert.equal(summaryResult.failures[0]?.field, 'summary') + assert.equal(summaryResult.failures[0]?.code, 'text-banned-link') + + const descriptionResult = validateProjectFields( + createProject({ description: `Visit https://bit.ly/project. ${'More details. '.repeat(20)}` }), + ) + assert.equal(descriptionResult.valid, false) + assert.equal(descriptionResult.failures[0]?.field, 'description') + assert.equal(descriptionResult.failures[0]?.code, 'text-banned-link') +}) + test('reports summary recommendations without invalidating the project', () => { const project = createProject({ summary: 'Short summary' })