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
|
linkValidation.value = null
|
||||||
|
|
||||||
if (import.meta.server) return
|
if (import.meta.server) return
|
||||||
|
if (validateProjectDescription(text).some(({ code }) => code === 'text-banned-link')) {
|
||||||
|
pending.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const links = extractProjectLinks(text ?? '')
|
const links = extractProjectLinks(text ?? '')
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui
|
|||||||
|
|
||||||
import type { Nag, NagContext } from '../../types/nags'
|
import type { Nag, NagContext } from '../../types/nags'
|
||||||
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
|
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
|
||||||
|
import {
|
||||||
|
getBlockedProjectContentLink,
|
||||||
|
PROJECT_CONTENT_LINK_SHORTENERS,
|
||||||
|
} from '../../validators/project-links'
|
||||||
|
|
||||||
export const commonLinkDomains = {
|
export const commonLinkDomains = {
|
||||||
source: [
|
source: [
|
||||||
@@ -45,10 +49,10 @@ export const commonLinkDomains = {
|
|||||||
'example.com',
|
'example.com',
|
||||||
't.me',
|
'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
|
if (url === null || url === '') return true
|
||||||
try {
|
try {
|
||||||
const domain = new URL(url).hostname.toLowerCase()
|
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
|
if (url === null || url === '') return false
|
||||||
return isCommonUrl(url, commonDomains)
|
return isCommonUrl(url, commonDomains)
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,26 @@ export function isUncommonLicenseUrl(url: string | null): boolean {
|
|||||||
return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist)
|
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[] = [
|
export const linksNags: Nag[] = [
|
||||||
{
|
{
|
||||||
id: 'add-links',
|
id: 'add-links',
|
||||||
@@ -210,36 +234,26 @@ export const linksNags: Nag[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'link-shortener-usage',
|
id: 'banned-link-usage',
|
||||||
title: defineMessage({
|
title: defineMessage({
|
||||||
id: 'nags.link-shortener-usage.title',
|
id: 'nags.banned-link-usage.title',
|
||||||
defaultMessage: "Don't use link shorteners",
|
defaultMessage: 'Remove prohibited links',
|
||||||
}),
|
}),
|
||||||
description: defineMessage({
|
description: (context: NagContext) => {
|
||||||
id: 'nags.link-shortener-usage.description',
|
const blockedLink = findBlockedProjectExternalLink(context)
|
||||||
defaultMessage:
|
if (!blockedLink) return ''
|
||||||
'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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const { formatMessage } = useVIntl()
|
||||||
isLinkShortener(context.project.source_url ?? null) ||
|
return formatMessage(
|
||||||
isLinkShortener(context.project.issues_url ?? null) ||
|
defineMessage({
|
||||||
isLinkShortener(context.project.wiki_url ?? null) ||
|
id: 'nags.banned-link-usage.description',
|
||||||
isLinkShortener(context.project.discord_url ?? null) ||
|
defaultMessage: '“{url}” is not allowed in project links.',
|
||||||
isLinkShortener(context.projectV3?.link_urls?.site?.url ?? null) ||
|
}),
|
||||||
isLinkShortener(context.projectV3?.link_urls?.store?.url ?? null) ||
|
blockedLink,
|
||||||
Boolean(context.project.license.url && isLinkShortener(context.project.license.url ?? null))
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
status: 'required',
|
||||||
|
shouldShow: (context: NagContext) => findBlockedProjectExternalLink(context) !== null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'invalid-license-url',
|
id: 'invalid-license-url',
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ const summaryErrorCodes: readonly ProjectTextValidationCode[] = [
|
|||||||
'text-slur',
|
'text-slur',
|
||||||
'text-profanity',
|
'text-profanity',
|
||||||
'text-non-standard',
|
'text-non-standard',
|
||||||
]
|
'text-banned-link',
|
||||||
const summaryWarningCodes: readonly ProjectTextValidationCode[] = [
|
|
||||||
'summary-link',
|
'summary-link',
|
||||||
'summary-matches-title',
|
|
||||||
]
|
]
|
||||||
|
const summaryWarningCodes: readonly ProjectTextValidationCode[] = ['summary-matches-title']
|
||||||
const descriptionErrorCodes: readonly ProjectTextValidationCode[] = [
|
const descriptionErrorCodes: readonly ProjectTextValidationCode[] = [
|
||||||
'text-slur',
|
'text-slur',
|
||||||
'text-profanity',
|
'text-profanity',
|
||||||
'text-non-standard',
|
'text-non-standard',
|
||||||
|
'text-banned-link',
|
||||||
]
|
]
|
||||||
|
|
||||||
function getFirstFailure(
|
function getFirstFailure(
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ export * from './validators/link-checks'
|
|||||||
export * from './validators/non-standard-text'
|
export * from './validators/non-standard-text'
|
||||||
export * from './validators/profanity'
|
export * from './validators/profanity'
|
||||||
export * from './validators/project-fields'
|
export * from './validators/project-fields'
|
||||||
|
export * from './validators/project-links'
|
||||||
export * from './validators/project-validation'
|
export * from './validators/project-validation'
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
"nags.all-tags-selected.title": {
|
"nags.all-tags-selected.title": {
|
||||||
"defaultMessage": "Select accurate tags"
|
"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": {
|
"nags.check-disclosures.description": {
|
||||||
"defaultMessage": "Make sure users are aware of any important details by filling in content disclosures that apply to your {type}."
|
"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": {
|
"nags.invalid-project-summary.title": {
|
||||||
"defaultMessage": "Fix the project summary"
|
"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": {
|
"nags.link.discord.channel": {
|
||||||
"defaultMessage": "This is a link to a Discord channel, not a server invite."
|
"defaultMessage": "This is a link to a Discord channel, not a server invite."
|
||||||
},
|
},
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
"defaultMessage": "Visit versions settings"
|
"defaultMessage": "Visit versions settings"
|
||||||
},
|
},
|
||||||
"nags.summary-special-formatting.description": {
|
"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": {
|
"nags.summary-special-formatting.title": {
|
||||||
"defaultMessage": "Clean up the summary"
|
"defaultMessage": "Clean up the summary"
|
||||||
@@ -350,6 +350,9 @@
|
|||||||
"nags.visit-links-settings.title": {
|
"nags.visit-links-settings.title": {
|
||||||
"defaultMessage": "Visit links settings"
|
"defaultMessage": "Visit links settings"
|
||||||
},
|
},
|
||||||
|
"project.text-validation.banned-link": {
|
||||||
|
"defaultMessage": "“{url}” is not allowed in project summaries or descriptions."
|
||||||
|
},
|
||||||
"project.text-validation.description-profanity": {
|
"project.text-validation.description-profanity": {
|
||||||
"defaultMessage": "Excessive profanity is not allowed. Detected: {values}"
|
"defaultMessage": "Excessive profanity is not allowed. Detected: {values}"
|
||||||
},
|
},
|
||||||
@@ -362,9 +365,6 @@
|
|||||||
"project.text-validation.slur": {
|
"project.text-validation.slur": {
|
||||||
"defaultMessage": "The detected slur “{value}” is not allowed."
|
"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": {
|
"project.text-validation.summary-matches-title": {
|
||||||
"defaultMessage": "A project summary should not be the same as its 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 { computed, onScopeDispose, reactive, type Ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { PROJECT_CONTENT_LINK_BLOCKLIST } from '../project-links/index.ts'
|
||||||
|
|
||||||
interface MessageDescriptor {
|
interface MessageDescriptor {
|
||||||
id: string
|
id: string
|
||||||
defaultMessage?: string
|
defaultMessage?: string
|
||||||
@@ -104,7 +106,7 @@ function anchored(source: string): RegExp {
|
|||||||
|
|
||||||
function blacklist(label: string, ...domains: string[]): LinkCheckBuilder {
|
function blacklist(label: string, ...domains: string[]): LinkCheckBuilder {
|
||||||
const pattern = domains.map((domain) => domain.replace(/\./g, '\\.')).join('|')
|
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 {
|
function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
|
||||||
@@ -829,32 +831,7 @@ checks.children(
|
|||||||
)
|
)
|
||||||
|
|
||||||
checks.children(
|
checks.children(
|
||||||
blacklist('URL Shortener', 'bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'),
|
...PROJECT_CONTENT_LINK_BLOCKLIST.map(({ label, domains }) => blacklist(label, ...domains)),
|
||||||
|
|
||||||
// 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'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck }
|
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')
|
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 () => {
|
test('compares recognized license URLs with the selected license', async () => {
|
||||||
const matching = {
|
const matching = {
|
||||||
field: 'license',
|
field: 'license',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import LinkifyIt from 'linkify-it'
|
|||||||
|
|
||||||
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
|
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
|
||||||
import { validateProfanity } from '../profanity/index.ts'
|
import { validateProfanity } from '../profanity/index.ts'
|
||||||
|
import { getBlockedProjectContentLink } from '../project-links/index.ts'
|
||||||
|
|
||||||
export interface ProjectFieldMessageDescriptor {
|
export interface ProjectFieldMessageDescriptor {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,6 +27,7 @@ export type ProjectTextValidationCode =
|
|||||||
| 'text-slur'
|
| 'text-slur'
|
||||||
| 'text-profanity'
|
| 'text-profanity'
|
||||||
| 'text-non-standard'
|
| 'text-non-standard'
|
||||||
|
| 'text-banned-link'
|
||||||
| 'title-version-number'
|
| 'title-version-number'
|
||||||
| 'title-minecraft-branding'
|
| 'title-minecraft-branding'
|
||||||
| 'summary-link'
|
| 'summary-link'
|
||||||
@@ -67,6 +69,10 @@ const messages = defineMessages({
|
|||||||
id: 'project.text-validation.non-standard-text',
|
id: 'project.text-validation.non-standard-text',
|
||||||
defaultMessage: 'Non-standard text characters are not allowed.',
|
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: {
|
titleVersionNumber: {
|
||||||
id: 'project.text-validation.title-version-number',
|
id: 'project.text-validation.title-version-number',
|
||||||
defaultMessage: 'Names are not allowed to include version numbers.',
|
defaultMessage: 'Names are not allowed to include version numbers.',
|
||||||
@@ -76,10 +82,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
|
'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: {
|
summaryMatchesTitle: {
|
||||||
id: 'project.text-validation.summary-matches-title',
|
id: 'project.text-validation.summary-matches-title',
|
||||||
defaultMessage: 'A project summary should not be the same as its title.',
|
defaultMessage: 'A project summary should not be the same as its title.',
|
||||||
@@ -92,7 +94,7 @@ const messages = defineMessages({
|
|||||||
summarySpecialFormatting: {
|
summarySpecialFormatting: {
|
||||||
id: 'nags.summary-special-formatting.description',
|
id: 'nags.summary-special-formatting.description',
|
||||||
defaultMessage:
|
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: {
|
descriptionRequired: {
|
||||||
id: 'nags.add-description.description',
|
id: 'nags.add-description.description',
|
||||||
@@ -151,6 +153,22 @@ export function containsProjectLinkOrIp(text: string) {
|
|||||||
return linkify.test(text)
|
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) {
|
export function hasProjectSummaryFormatting(summary: string) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
summary.match(/# .*/g) ||
|
summary.match(/# .*/g) ||
|
||||||
@@ -195,7 +213,7 @@ export function countText(markdown: string): number {
|
|||||||
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
|
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
|
||||||
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
|
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
|
||||||
const withoutMarkdownSyntax = withoutHtml
|
const withoutMarkdownSyntax = withoutHtml
|
||||||
.replace(/^>{1}\s?.*$/gm, ' ')
|
.replace(/^(?:>[ \t]?)+/gm, '')
|
||||||
.replace(/^#{1,6}\s+/gm, ' ')
|
.replace(/^#{1,6}\s+/gm, ' ')
|
||||||
.replace(/[*_~`>-]/g, ' ')
|
.replace(/[*_~`>-]/g, ' ')
|
||||||
.replace(/\|/g, ' ')
|
.replace(/\|/g, ' ')
|
||||||
@@ -317,11 +335,21 @@ export function validateProjectSummary(
|
|||||||
const results = validateProjectText(summary)
|
const results = validateProjectText(summary)
|
||||||
if (results.length > 0 || !summary) return results
|
if (results.length > 0 || !summary) return results
|
||||||
|
|
||||||
if (containsProjectLinkOrIp(summary)) {
|
const blockedLink = findBlockedProjectContentLink(summary)
|
||||||
return [{ code: 'summary-link', severity: 'warn', message: messages.summaryLink }]
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
code: 'summary-matches-title',
|
code: 'summary-matches-title',
|
||||||
@@ -332,7 +360,7 @@ export function validateProjectSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const length = normalizeProjectFieldText(summary).length
|
const length = normalizeProjectFieldText(summary).length
|
||||||
if (length < MIN_SUMMARY_CHARS) {
|
if (!containsExplicitLink && length < MIN_SUMMARY_CHARS) {
|
||||||
results.push({
|
results.push({
|
||||||
code: 'summary-too-short',
|
code: 'summary-too-short',
|
||||||
severity: 'warn',
|
severity: 'warn',
|
||||||
@@ -341,10 +369,10 @@ export function validateProjectSummary(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasProjectSummaryFormatting(summary)) {
|
if (hasProjectSummaryFormatting(summary) || containsExplicitLink) {
|
||||||
results.push({
|
results.push({
|
||||||
code: 'summary-special-formatting',
|
code: containsExplicitLink ? 'summary-link' : 'summary-special-formatting',
|
||||||
severity: 'warn',
|
severity: containsExplicitLink ? 'error' : 'warn',
|
||||||
message: messages.summarySpecialFormatting,
|
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)
|
const readableLength = countText(normalizedDescription)
|
||||||
if (readableLength < MIN_DESCRIPTION_CHARS) {
|
if (readableLength < MIN_DESCRIPTION_CHARS) {
|
||||||
results.push({
|
results.push({
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
containsExplicitHttpProjectLink,
|
||||||
containsProjectLinkOrIp,
|
containsProjectLinkOrIp,
|
||||||
|
countText,
|
||||||
extractProjectLinks,
|
extractProjectLinks,
|
||||||
|
MIN_CHARS_PER_IMAGE,
|
||||||
|
MIN_DESCRIPTION_CHARS,
|
||||||
projectSummaryMatchesTitle,
|
projectSummaryMatchesTitle,
|
||||||
validateProjectDescription,
|
validateProjectDescription,
|
||||||
validateProjectSummary,
|
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)
|
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', () => {
|
test('extracts and deduplicates normalized links', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
extractProjectLinks(
|
extractProjectLinks(
|
||||||
@@ -87,11 +98,31 @@ test('validates project titles', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('validates project summaries', () => {
|
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(
|
assert.equal(
|
||||||
validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.message.id,
|
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.id,
|
||||||
'project.text-validation.summary-link',
|
'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(
|
assert.equal(
|
||||||
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.id,
|
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.id,
|
||||||
'project.text-validation.summary-matches-title',
|
'project.text-validation.summary-matches-title',
|
||||||
@@ -117,6 +148,47 @@ test('validates project summaries', () => {
|
|||||||
validateProjectSummary('# Short summary', 'Project title').map(({ code }) => code),
|
validateProjectSummary('# Short summary', 'Project title').map(({ code }) => code),
|
||||||
['summary-too-short', 'summary-special-formatting'],
|
['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', () => {
|
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')
|
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', () => {
|
test('validates required description content and returns simultaneous recommendations', () => {
|
||||||
assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required')
|
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)
|
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({
|
const project = createProject({
|
||||||
name: 'Tools 1.2.3',
|
name: 'Tools 1.2.3',
|
||||||
summary: 'Visit modrinth.com for more information',
|
summary: 'Visit https://example.dev for more information',
|
||||||
})
|
})
|
||||||
const result = validateProjectFields(project)
|
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 })),
|
result.failures.map(({ code, severity }) => ({ code, severity })),
|
||||||
[
|
[
|
||||||
{ code: 'title-version-number', severity: 'error' },
|
{ code: 'title-version-number', severity: 'error' },
|
||||||
{ code: 'summary-link', severity: 'warn' },
|
{ code: 'summary-link', severity: 'error' },
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert.equal(hasProjectFieldValidationFailures(project), true)
|
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', () => {
|
test('reports summary recommendations without invalidating the project', () => {
|
||||||
const project = createProject({ summary: 'Short summary' })
|
const project = createProject({ summary: 'Short summary' })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user