mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
feat: change links in summary to be a blocking error and combined with formatting nag, banned links in description are also blocking
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<NagContext, 'project' | 'projectV3'>) {
|
||||
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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = ['', '', '', '']
|
||||
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')
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user