refactor: links validator

This commit is contained in:
tdgao
2026-08-27 13:39:50 -06:00
parent b7b276cf51
commit 1f3f37c86c
18 changed files with 1499 additions and 1290 deletions
+27 -101
View File
@@ -4,80 +4,11 @@ import type { Nag, NagContext } from '../../types/nags'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
import {
getBlockedProjectExternalLink,
PROJECT_LINK_SHORTENERS,
} from '../../validators/project-links'
export const commonLinkDomains = {
source: [
'github.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'git.sr.ht',
'tangled.org',
'git.gay',
],
issues: [
'github.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'docs.google.com',
'tangled.org',
'git.gay',
],
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
licenseBlocklist: [
'youtube.com',
'youtu.be',
'modrinth.com',
'curseforge.com',
'twitter.com',
'x.com',
'discord.gg',
'discord.com',
'instagram.com',
'facebook.com',
'tiktok.com',
'reddit.com',
'twitch.tv',
'patreon.com',
'ko-fi.com',
'paypal.com',
'buymeacoffee.com',
'google.com',
'example.com',
't.me',
],
linkShorteners: PROJECT_LINK_SHORTENERS,
}
export function isCommonUrl(url: string | null, commonDomains: readonly string[]): boolean {
if (url === null || url === '') return true
try {
const domain = new URL(url).hostname.toLowerCase()
return commonDomains.some((allowed) => domain.includes(allowed))
} catch {
return false
}
}
export function isCommonUrlOfType(url: string | null, commonDomains: readonly string[]): boolean {
if (url === null || url === '') return false
return isCommonUrl(url, commonDomains)
}
export function isDiscordUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.discord)
}
export function isLinkShortener(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.linkShorteners)
}
export function isUncommonLicenseUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist)
}
getLinkHostname,
isCommonProjectLink,
isDiscordLink,
isInappropriateLicenseLink,
} from '../../validators/links'
export function findBlockedProjectExternalLink(context: Pick<NagContext, 'project' | 'projectV3'>) {
const urls = [
@@ -191,10 +122,13 @@ export const linksNags: Nag[] = [
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const sourceUrl = context.project.source_url
const issuesUrl = context.project.issues_url
const discordUrl = context.project.discord_url
return (
!isCommonUrl(context.project.source_url ?? null, commonLinkDomains.source) ||
!isCommonUrl(context.project.issues_url ?? null, commonLinkDomains.issues) ||
!isCommonUrl(context.project.discord_url ?? null, commonLinkDomains.discord)
(!!sourceUrl && !isCommonProjectLink(sourceUrl, 'source')) ||
(!!issuesUrl && !isCommonProjectLink(issuesUrl, 'issues')) ||
(!!discordUrl && !isCommonProjectLink(discordUrl, 'discord'))
)
},
link: {
@@ -219,11 +153,11 @@ export const linksNags: Nag[] = [
}),
status: 'required',
shouldShow: (context: NagContext) =>
isDiscordUrl(context.project.source_url ?? null) ||
isDiscordUrl(context.project.issues_url ?? null) ||
isDiscordUrl(context.project.wiki_url ?? null) ||
isDiscordUrl(context.projectV3?.link_urls?.site?.url ?? null) ||
isDiscordUrl(context.projectV3?.link_urls?.store?.url ?? null),
isDiscordLink(context.project.source_url) ||
isDiscordLink(context.project.issues_url) ||
isDiscordLink(context.project.wiki_url) ||
isDiscordLink(context.projectV3?.link_urls?.site?.url) ||
isDiscordLink(context.projectV3?.link_urls?.store?.url),
link: {
path: 'settings/links',
title: defineMessage({
@@ -274,8 +208,8 @@ export const linksNags: Nag[] = [
)
}
try {
const domain = new URL(licenseUrl).hostname.toLowerCase()
const domain = getLinkHostname(licenseUrl)
if (domain) {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.domain',
@@ -284,29 +218,21 @@ export const linksNags: Nag[] = [
}),
{ domain },
)
} catch {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
}),
)
}
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
}),
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const licenseUrl = context.project.license.url
if (!licenseUrl) return false
const isBlocklisted = isUncommonLicenseUrl(licenseUrl)
try {
new URL(licenseUrl)
return isBlocklisted
} catch {
return true
}
return getLinkHostname(licenseUrl) === null || isInappropriateLicenseLink(licenseUrl)
},
link: {
path: 'settings',
+1 -2
View File
@@ -19,9 +19,8 @@ export * from './types/quick-reply'
export * from './types/reports'
export * from './types/settings'
export * from './utils'
export * from './validators/link-checks'
export * from './validators/links'
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'
@@ -1,848 +0,0 @@
import { computed, onScopeDispose, reactive, type Ref, watch } from 'vue'
import {
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
} from '../project-links/index.ts'
interface MessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
function defineMessages<T extends Record<string, MessageDescriptor>>(descriptors: T): T {
return descriptors
}
export interface LinkCheckContext {
url: string | undefined
field: string
generalContent?: boolean
[key: string]: unknown
}
export interface LinkCheckResult {
severity: 'valid' | 'warn' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}
const valid: LinkCheckResult = { severity: 'valid' }
function warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'warn', message, values }
}
function error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'error', message, values }
}
type FieldMatcher = string | string[] | ((field: string, context: LinkCheckContext) => boolean)
function matchesField(matcher: FieldMatcher, context: LinkCheckContext): boolean {
if (typeof matcher === 'function') return matcher(context.field, context)
if (Array.isArray(matcher)) return matcher.includes(context.field)
return matcher === context.field
}
type LinkCheckVerify = (
match: RegExpMatchArray,
context: LinkCheckContext,
) => Promise<LinkCheckResult>
type LinkCheckMatcher = RegExp | ((remaining: string) => number | null | Promise<number | null>)
function isAsyncMatcher(when: LinkCheckMatcher): boolean {
return when instanceof RegExp ? false : when.constructor.name === 'AsyncFunction'
}
interface LinkCheckNode {
when: LinkCheckMatcher
label?: string
unrecognizedSeverity?: 'error' | 'warn'
unrecognizedMessage?: MessageDescriptor
forMatchers?: FieldMatcher[]
verifyMatch?: LinkCheckVerify
childNodes?: LinkCheckNode[]
isTransparent?: boolean
isFallback?: boolean
}
interface LinkCheckBuilder {
when: LinkCheckMatcher
label?: string
for(fields: FieldMatcher): LinkCheckBuilder
verify(fn: LinkCheckVerify): LinkCheckBuilder
children(...shapes: LinkCheckChildShape[]): LinkCheckBuilder
severity(value: 'error' | 'warn'): LinkCheckBuilder
message(descriptor: MessageDescriptor): LinkCheckBuilder
transparent(): LinkCheckBuilder
fallback(): LinkCheckBuilder
warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckBuilder
error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckBuilder
}
type LinkCheckChildShape =
| LinkCheckNode
| LinkCheckBuilder
| RegExp
| string
| ((remaining: string) => number | null | Promise<number | null>)
function anchored(source: string): RegExp {
return new RegExp(`^${source}`, 'i')
}
function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
const childNodes: LinkCheckNode[] = []
const forMatchers: FieldMatcher[] = []
const node: Record<string, unknown> = { when, label, childNodes, forMatchers }
node.for = (fields: FieldMatcher) => {
forMatchers.push(fields)
return node
}
node.verify = (fn: LinkCheckVerify) => {
node.verifyMatch = fn
return node
}
node.severity = (value: 'error' | 'warn') => {
node.unrecognizedSeverity = value
return node
}
node.message = (descriptor: MessageDescriptor) => {
node.unrecognizedMessage = descriptor
return node
}
node.transparent = () => {
node.isTransparent = true
return node
}
node.fallback = () => {
node.isFallback = true
return node
}
node.warn = (message: MessageDescriptor, values?: Record<string, unknown>) => {
node.verifyMatch = async () => warn(message, values)
return node
}
node.error = (message: MessageDescriptor, values?: Record<string, unknown>) => {
node.verifyMatch = async () => error(message, values)
return node
}
node.children = (...shapes: LinkCheckChildShape[]) => {
const parentLabel = node.label as string | undefined
const parentForMatchers = node.forMatchers as FieldMatcher[] | undefined
for (const shape of shapes) {
const child = normalizeChild(shape)
const label = child.label ? [parentLabel, child.label].filter(Boolean).join(' ') : parentLabel
const inheritedFor = [...(parentForMatchers ?? []), ...(child.forMatchers ?? [])]
childNodes.push({ ...child, label, forMatchers: inheritedFor })
}
return node
}
return node as unknown as LinkCheckBuilder
}
function check(
when: RegExp | string | ((remaining: string) => number | null | Promise<number | null>),
label?: string,
): LinkCheckBuilder {
const matcher =
typeof when === 'function' ? when : typeof when === 'string' ? new RegExp(when) : when
return buildNode(matcher, label)
}
function fallback(label?: string): LinkCheckBuilder {
return buildNode(() => 0, label).fallback()
}
function normalizeChild(shape: LinkCheckChildShape): LinkCheckNode {
if (shape instanceof RegExp || typeof shape === 'function') return { when: shape }
if (typeof shape === 'string') return { when: new RegExp(shape) }
return shape as unknown as LinkCheckNode
}
function named(label: string, shapes: LinkCheckChildShape[]): LinkCheckNode[] {
return shapes.map((shape) => ({ ...normalizeChild(shape), label }))
}
interface MatchResult {
node: LinkCheckNode
match: RegExpMatchArray
expectedChild?: LinkCheckNode
}
async function matchNode(
node: LinkCheckNode,
remaining: string,
context: LinkCheckContext,
isRoot = false,
): Promise<MatchResult | null> {
let match: RegExpMatchArray | null
if (node.when instanceof RegExp) {
match = node.when.exec(remaining)
} else {
const consumed = await node.when(remaining)
match =
consumed === null
? null
: (Object.assign([remaining.slice(0, consumed)], {
input: remaining,
index: 0,
}) as RegExpMatchArray)
}
if (!match) {
if (!isRoot || !node.unrecognizedMessage) return null
return {
node: {
when: node.when,
label: node.label,
unrecognizedMessage: node.unrecognizedMessage,
unrecognizedSeverity: node.unrecognizedSeverity,
},
match: Object.assign([remaining], { input: remaining, index: 0 }) as RegExpMatchArray,
}
}
if (node.childNodes?.length) {
const rest = remaining.slice(match[0].length)
const syncChildren = node.childNodes.filter(
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
)
const asyncChildren = node.childNodes.filter(
(child) =>
isAsyncMatcher(child.when) &&
!child.isFallback &&
!(context.generalContent && hasFieldSpecificDescendant(child)),
)
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
let expectedChild: LinkCheckNode | undefined
for (const child of [...syncChildren, ...asyncChildren]) {
const found = await matchNode(child, rest, context)
if (found) return found
if (!expectedChild && child.forMatchers?.some((matcher) => matchesField(matcher, context)))
expectedChild = child
}
const matchingFallback = fallbackChildren.find((child) =>
child.forMatchers?.some((matcher) => matchesField(matcher, context)),
)
if (matchingFallback) {
return {
node: matchingFallback,
match: Object.assign([rest], { input: rest, index: 0 }) as RegExpMatchArray,
}
}
if (node.isTransparent) return null
return { node, match, expectedChild }
}
return { node, match }
}
const coreMessages = defineMessages({
wrongField: {
id: 'nags.link.wrong-field',
defaultMessage: "{label} links aren't valid for this field.",
},
neverValid: {
id: 'nags.link.never-valid',
defaultMessage: "{label} links aren't allowed here.",
},
expectedType: {
id: 'nags.link.expected-type',
defaultMessage: "This isn't a valid {label} link.",
},
})
//TODO: we should probably just let you not provide https but backend currently requires it
const invalidUrlMessage = defineMessage({
id: 'nags.link.invalid-url',
defaultMessage: 'This URL is invalid',
})
const invalidDescriptionUrlMessage = defineMessage({
id: 'nags.link.description.invalid-url',
defaultMessage: 'The description has an invalid link',
})
function validUrlPrefix(remaining: string): number | null {
let url: URL
try {
url = new URL(remaining)
const hostname = url.hostname
// https pls
if (url.protocol !== 'https:') return null
// ensure there's a domain and TLD
if (!/[^.]\.[^.]/.test(hostname)) return null
// reserved TLDs
if (/(^|\.)(local|localhost|test|example|invalid|onion|arpa|home)$/i.test(hostname)) return null
// example.com/net/org is reserved (and probably quite likely to be set by AI)
if (/^example\.(com|net|org)$/i.test(hostname)) return null
// No IP addresses
const strippedHost = hostname.replace(/^\[|]$/g, '')
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHost) || strippedHost.includes(':')) return null
const protocolPrefix = /^https:\/\//i.exec(remaining)
return protocolPrefix?.[0].length ?? null
} catch {
return null
}
}
const checks = check(validUrlPrefix).message(invalidUrlMessage).transparent()
const rootNode = checks as unknown as LinkCheckNode
const cache = reactive(new Map<string, 'scheduled' | 'pending' | LinkCheckResult>())
function cacheKey(context: LinkCheckContext): string {
return JSON.stringify(context)
}
function hasFieldSpecificDescendant(node: LinkCheckNode): boolean {
return (
(node.forMatchers?.length ?? 0) > 0 ||
(node.childNodes?.some((child) => hasFieldSpecificDescendant(child)) ?? false)
)
}
async function checkLink(context: LinkCheckContext) {
const url = context.url
if (!url) return
const key = cacheKey(context)
const cached = cache.get(key)
if (cached === 'pending' || typeof cached === 'object') return
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1')
cache.set(key, 'pending')
const blockedLink = context.generalContent
? getBlockedProjectContentLink(url)
: getBlockedProjectExternalLink(url)
if (blockedLink) {
cache.set(key, error(coreMessages.neverValid, { label: blockedLink.label }))
return
}
const found = await matchNode(rootNode, normalizedUrl, context, true)
if (!found) {
if (context.generalContent && validUrlPrefix(normalizedUrl) !== null) {
cache.set(key, valid)
return
}
cache.delete(key)
return
}
const { node: matched, match, expectedChild } = found
const isLeaf = !matched.childNodes?.length
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
if (!applies) {
if (context.generalContent && hasFieldSpecificDescendant(matched)) {
cache.set(key, valid)
return
}
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
if (matched.unrecognizedMessage && isLeaf) {
const message =
context.field === 'description' &&
matched.unrecognizedMessage.id === invalidUrlMessage.id
? invalidDescriptionUrlMessage
: matched.unrecognizedMessage
cache.set(key, build(message, { label: matched.label }))
return
}
if (expectedChild) {
if (matched.unrecognizedMessage) {
cache.set(key, build(matched.unrecognizedMessage, { label: matched.label }))
return
}
cache.set(key, build(coreMessages.expectedType, { label: expectedChild.label }))
return
}
const validElsewhere = matched.forMatchers && matched.forMatchers.length > 0
const message = validElsewhere ? coreMessages.wrongField : coreMessages.neverValid
cache.set(key, build(message, { label: matched.label }))
return
}
if (!matched.verifyMatch) {
cache.set(key, valid)
return
}
cache.set(key, 'pending')
try {
cache.set(key, await matched.verifyMatch(match, context))
} catch {
cache.delete(key)
}
}
function getLinkCheckState(context: LinkCheckContext): LinkCheckResult | undefined {
if (!context.url) return undefined
const state = cache.get(cacheKey(context))
return typeof state === 'object' ? state : undefined
}
function isLinkCheckPending(context: LinkCheckContext): boolean {
if (!context.url) return false
const state = cache.get(cacheKey(context))
return state === 'scheduled' || state === 'pending'
}
function useLinkCheck(context: Ref<LinkCheckContext>) {
let timeout: ReturnType<typeof setTimeout>
let scheduledKey: string | undefined
watch(
context,
(value) => {
clearTimeout(timeout)
if (scheduledKey && cache.get(scheduledKey) === 'scheduled') cache.delete(scheduledKey)
scheduledKey = undefined
if (!value.url) return
const key = cacheKey(value)
if (!cache.has(key)) {
cache.set(key, 'scheduled')
scheduledKey = key
}
timeout = setTimeout(() => checkLink(value), 500)
},
{ deep: true, immediate: true },
)
onScopeDispose(() => {
clearTimeout(timeout)
if (scheduledKey && cache.get(scheduledKey) === 'scheduled') cache.delete(scheduledKey)
})
return computed(() => getLinkCheckState(context.value) ?? null)
}
async function discordInviteVerify(match: RegExpMatchArray): Promise<LinkCheckResult> {
const res = await fetch(`https://discord.com/api/v10/invites/${match[1]}?with_expiration=true`)
if (!res.ok)
return error(
defineMessage({
id: 'nags.link.discord.invite.invalid',
defaultMessage: 'This Discord invite is invalid or has expired.',
}),
)
const invite = await res.json()
if (!invite.guild)
return error(
defineMessage({
id: 'nags.link.discord.invite.not-guild',
defaultMessage: 'This Discord invite does not lead to a server.',
}),
)
if (invite.expires_at)
return warn(
defineMessage({
id: 'nags.link.discord.invite.expires',
defaultMessage: 'This Discord invite is set to expire',
}),
)
//TODO Ideally we could also check if the invite has a max uses and if its temporary but
// we can't without auth which we can't really do in frontend
return valid
}
checks.children(
...named('Discord', [
check(/^discord\.gg/i).children(
check(/^\/([\w-]+)/i)
.for('discord')
.verify(discordInviteVerify),
),
check(/^(?:discord\.com|discordapp\.com)/i).children(
check(/^\/invite\/([\w-]+)/i)
.for('discord')
.verify(discordInviteVerify),
check(/^\/channels\//i).message(
defineMessage({
id: 'nags.link.discord.channel',
defaultMessage: 'This is a link to a Discord channel, not a server invite.',
}),
),
check(/^\/users\//i).message(
defineMessage({
id: 'nags.link.discord.user',
defaultMessage: 'This is a link to a Discord user, not a server invite.',
}),
),
),
]),
)
const gitRepoMessages = defineMessages({
notFound: {
id: 'nags.link.git.not-found',
defaultMessage: 'This repository could not be found (it may be private or deleted).',
},
empty: {
id: 'nags.link.git.empty',
defaultMessage: 'This repository appears to be empty.',
},
archived: {
id: 'nags.link.git.archived',
defaultMessage: 'This repository is archived, which disables issues.',
},
issuesDisabled: {
id: 'nags.link.git.issues-disabled',
defaultMessage: 'Issues are disabled on this repository.',
},
wikiDisabled: {
id: 'nags.link.git.wiki-disabled',
defaultMessage: 'The wiki is disabled on this repository.',
},
})
async function checkRepo(
fetchRepo: (path: string) => Promise<Record<string, boolean> | undefined>,
path: string,
evaluate: (facts: Record<string, boolean>) => LinkCheckResult,
): Promise<LinkCheckResult> {
const facts = await fetchRepo(path)
if (!facts) return error(gitRepoMessages.notFound)
return evaluate(facts)
}
async function giteaFetchRepo(
host: string,
path: string,
): Promise<Record<string, boolean> | undefined> {
const res = await fetch(`https://${host}/api/v1/repos/${path}`)
if (!res.ok) return undefined
const data = await res.json()
return {
empty: data.size === 0,
archived: data.archived,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
function gitHost(
name: string,
domain: string,
fetchRepo: (path: string) => Promise<Record<string, boolean> | undefined>,
options: {
pathPattern?: string
subPageSeparator?: string
wikiPath?: string
} = {},
): LinkCheckBuilder {
const path = options.pathPattern ?? /[^/]+\/[^/]+/.source
const sep = options.subPageSeparator ?? ''
const wikiPath = options.wikiPath ?? 'wiki'
return check(anchored(domain), name)
.severity('warn')
.children(
check(anchored(`/(${path})/?$`), 'repo')
.for('source')
.verify(async (match) =>
checkRepo(fetchRepo, match[1], (facts) =>
facts.empty ? error(gitRepoMessages.empty) : valid,
),
),
check(anchored(`/(${path})${sep}/issues`), 'issues')
.for('issues')
.verify(async (match) =>
checkRepo(fetchRepo, match[1], (facts) => {
if (facts.archived) return error(gitRepoMessages.archived)
if (facts.issues === false) return error(gitRepoMessages.issuesDisabled)
return valid
}),
),
check(anchored(`/(${path})${sep}/${wikiPath}`), 'wiki')
.for('wiki')
.verify(async (match) =>
checkRepo(fetchRepo, match[1], (facts) =>
facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid,
),
),
)
}
// Repo Platforms, includes most source/issues/wiki + Github Sponsor
checks.children(
gitHost('GitHub', 'github\\.com', async (path) => {
const res = await fetch(`https://api.github.com/repos/${path}`)
if (!res.ok) return undefined
const data = await res.json()
return {
empty: data.size === 0,
archived: data.archived,
issues: data.has_issues,
wiki: data.has_wiki,
}
})
// Github sponsor is here
//TODO: we can't actually check if sponsors is setup with auth or cuz of cors im not really sure but regardless it doesn't works from browser
.children(check(/^\/sponsors\/[^/]+/i, 'sponsors').for('github')),
gitHost('Codeberg', 'codeberg\\.org', (path) => giteaFetchRepo('codeberg.org', path)),
gitHost(
'GitLab',
'gitlab\\.com',
async (path) => {
const res = await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`)
if (!res.ok) return undefined
//TODO unauthed gitlab doesn't give us like any info, so... yeah that sucks I guess
return {}
},
{
pathPattern: /[^/]+(?:\/[^/]+)+/.source,
subPageSeparator: '/-',
wikiPath: 'wikis',
},
),
gitHost('Bitbucket', 'bitbucket\\.org', async (path) => {
const res = await fetch(`https://api.bitbucket.org/2.0/repositories/${path}`)
if (!res.ok) return undefined
const data = await res.json()
return {
empty: data.size === 0,
issues: data.has_issues,
wiki: data.has_wiki,
}
}),
gitHost('Gitee', 'gitee\\.com', async (path) => {
const res = await fetch(`https://gitee.com/api/v5/repos/${path}`)
if (!res.ok) return undefined
const data = await res.json()
return {
issues: data.has_issues,
wiki: data.has_wiki,
}
}),
)
const giteaHostCache = new Map<string, boolean>()
async function probeGiteaHost(hostname: string): Promise<boolean> {
if (giteaHostCache.has(hostname)) return giteaHostCache.get(hostname)!
try {
const res = await fetch(`https://${hostname}/api/v1/version`)
giteaHostCache.set(hostname, res.ok)
return res.ok
} catch {
giteaHostCache.set(hostname, false)
return false
}
}
checks.children(
check(async (remaining) => {
const hostMatch = /^[^/]+/.exec(remaining)
if (!hostMatch) return null
return (await probeGiteaHost(hostMatch[0])) ? 0 : null
}, 'Self-hosted Gitea/Forgejo')
.severity('warn')
.children(
check(/^([^/]+)\/([^/]+\/[^/]+)\/?$/i, 'repo')
.for('source')
.verify(async (match) =>
checkRepo(
(path) => giteaFetchRepo(match[1], path),
match[2],
(facts) => (facts.empty ? error(gitRepoMessages.empty) : valid),
),
),
check(/^([^/]+)\/([^/]+\/[^/]+)\/issues/i, 'issues')
.for('issues')
.verify(async (match) =>
checkRepo(
(path) => giteaFetchRepo(match[1], path),
match[2],
(facts) => {
if (facts.archived) return error(gitRepoMessages.archived)
if (facts.issues === false) return error(gitRepoMessages.issuesDisabled)
return valid
},
),
),
check(/^([^/]+)\/([^/]+\/[^/]+)\/wiki/i, 'wiki')
.for('wiki')
.verify(async (match) =>
checkRepo(
(path) => giteaFetchRepo(match[1], path),
match[2],
(facts) => (facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid),
),
),
),
)
// Donation
checks.children(
check(/^patreon\.com/i, 'Patreon').children(check(/^\/(?:user\?u=\d+|[\w.-]+)/i).for('patreon')),
check(/^(?:buymeacoffee\.com|buymeacoff\.ee)/i, 'Buy Me a Coffee').children(
check(/^\/([\w-]+)/i).for('bmac'),
),
check(/^paypal\.[a-z.]{2,}/i, 'PayPal')
.for('paypal')
.children(
check(/^\/paypalme\/[\w.-]+/i),
check(/^\/donate/i),
check(/^\/cgi-bin\/webscr\?cmd=_donations/i),
),
check(/^paypal\.me/i, 'PayPal').children(check(/^\/([\w.-]+)/i).for('paypal')),
// Github sponsor is with the rest of github.
check(/^ko-fi\.com/i, 'Ko-fi').children(check(/^\/([\w-]+)/i).for('ko-fi')),
(() => {
const YOUTUBE_CHANNEL = '(?:@[\\w.-]+|channel/[\\w-]+|c/[\\w-]+|user/[\\w-]+)'
return check(/^(?:youtube\.com|youtu\.be)/i, 'YouTube')
.message(
defineMessage({
id: 'nags.link.youtube.unrecognized',
defaultMessage: "This doesn't look like a YouTube donation link.",
}),
)
.for('other')
.children(
check(anchored(`/${YOUTUBE_CHANNEL}/join`)),
check(anchored(`/${YOUTUBE_CHANNEL}/store`)),
)
})(),
)
//TODO: remove this if/when we move this to the backend as we can know this if its backend
// tho actually we will probably still need it even then if we're fine with non immediate redirects
// we at the very least need to reword it in that case idk man
checks.children(
fallback('Unrecognized redirect link')
.for(['discord', 'github', 'patreon', 'ko-fi', 'paypal', 'bmac'])
.verify(async (_match, context) =>
warn(
defineMessage({
id: 'nags.link.unverifiable-redirect',
defaultMessage: "This doesn't look like a {platform} link.",
}),
{ platform: context.platformName ?? context.field },
),
),
)
const licenseCheckMessages = defineMessages({
urlMismatch: {
id: 'nags.link.license.url-mismatch',
defaultMessage:
'This link points to the {detected} license, but your project is set to {selected}.',
},
urlRedundant: {
id: 'nags.link.license.url-redundant',
defaultMessage:
"You don't need to link to a generic license page for a supported license — consider linking to your repository's own license file instead, or leaving this blank.",
},
})
function licenseVerify(detected: string | null, context: Record<string, unknown>): LinkCheckResult {
const expectedLicense = context.expectedLicense as string | undefined
const isCustom = context.isCustom as boolean | undefined
if (detected && expectedLicense && !isCustom) {
return detected.toLowerCase() === expectedLicense.toLowerCase()
? valid
: warn(licenseCheckMessages.urlMismatch, { detected, selected: expectedLicense })
}
return isCustom ? valid : warn(licenseCheckMessages.urlRedundant)
}
checks.children(
check(anchored('spdx\\.org'), 'SPDX').children(
check(anchored('/licenses/([\\w.-]+)\\.html'))
.for('license')
.verify(async (match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('opensource\\.org'), 'OSI').children(
check(anchored('/licenses?/([\\w.-]+)'))
.for('license')
.verify(async (match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('choosealicense\\.com'), 'choosealicense.com').children(
check(anchored('/licenses/([\\w.-]+)'))
.for('license')
.verify(async (match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('(?:www\\.)?gnu\\.org'), 'GNU').children(
check(anchored('/licenses/[\\w.-]+'))
.for('license')
.verify(async (_match, ctx) => licenseVerify(null, ctx)),
),
check(anchored('(?:www\\.)?apache\\.org'), 'Apache').children(
check(anchored('/licenses/[\\w.-]+'))
.for('license')
.verify(async (_match, ctx) => licenseVerify(null, ctx)),
),
check(anchored('creativecommons\\.org'), 'Creative Commons').children(
check(anchored('/(?:licenses/[\\w-]+|publicdomain/zero)/[\\d.]+/?'))
.for('license')
.verify(async (_match, ctx) => licenseVerify(null, ctx)),
),
)
// Google Forms for issues and Docs for Wiki
checks.children(
check(/^docs\.google\.com/i, 'Google').children(
check(/^\/forms\//i, 'Forms').for('issues'),
check(/^\/document\//i, 'Documents').for('wiki'),
),
)
export { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck }
@@ -1,141 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { effectScope, ref } from 'vue'
import { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck } from './index.ts'
test('rejects invalid and insecure URLs', async () => {
const insecure = { field: 'source', url: 'http://github.com/modrinth/code' }
const reserved = { field: 'source', url: 'https://example.com/project' }
await checkLink(insecure)
await checkLink(reserved)
assert.equal(getLinkCheckState(insecure)?.severity, 'error')
assert.equal(getLinkCheckState(reserved)?.severity, 'error')
})
test('uses a description-specific message for invalid content links', async () => {
const context = {
field: 'description',
url: 'http://example.dev/project',
generalContent: true,
}
await checkLink(context)
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.description.invalid-url')
assert.equal(
getLinkCheckState(context)?.message?.defaultMessage,
'The description has an invalid link',
)
})
test('matches recognized hosts case-insensitively', async () => {
const googleForm = { field: 'issues', url: 'https://DOCS.GOOGLE.COM/forms/d/e/example' }
const shortener = { field: 'source', url: 'https://BIT.LY/example' }
await checkLink(googleForm)
await checkLink(shortener)
assert.equal(getLinkCheckState(googleForm)?.severity, 'valid')
assert.equal(getLinkCheckState(shortener)?.severity, 'error')
})
test('rejects a recognized link used in the wrong field', async () => {
const context = { field: 'wiki', url: 'https://docs.google.com/forms/d/e/example' }
await checkLink(context)
assert.equal(getLinkCheckState(context)?.severity, 'error')
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.wrong-field')
})
test('allows structured link types in general content', async () => {
const context = {
field: 'description',
url: 'https://github.com/modrinth/code',
generalContent: true,
}
await checkLink(context)
assert.equal(getLinkCheckState(context)?.severity, 'valid')
})
test('allows unrecognized valid links but keeps global restrictions in general content', async () => {
const allowed = {
field: 'description',
url: 'https://docs.example.dev/project',
generalContent: true,
}
const blocked = {
field: 'description',
url: 'https://bit.ly/project',
generalContent: true,
}
await checkLink(allowed)
await checkLink(blocked)
assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
assert.equal(getLinkCheckState(blocked)?.severity, 'error')
})
test('applies the external-link blocklist only outside general content', async () => {
const blockedExternalLink = {
field: 'site',
url: 'https://social.modrinth.com/project',
}
const allowedContentLink = {
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(blockedExternalLink)
await checkLink(allowedContentLink)
await checkLink(allowed)
assert.equal(getLinkCheckState(blockedExternalLink)?.severity, 'error')
assert.equal(getLinkCheckState(allowedContentLink)?.severity, 'valid')
assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
})
test('compares recognized license URLs with the selected license', async () => {
const matching = {
field: 'license',
url: 'https://spdx.org/licenses/MIT.html',
expectedLicense: 'MIT',
isCustom: false,
}
const mismatching = {
field: 'license',
url: 'https://spdx.org/licenses/MIT.html',
expectedLicense: 'Apache-2.0',
isCustom: false,
}
await checkLink(matching)
await checkLink(mismatching)
assert.equal(getLinkCheckState(matching)?.severity, 'valid')
assert.equal(getLinkCheckState(mismatching)?.severity, 'warn')
})
test('marks debounced checks as pending immediately', () => {
const context = ref({ field: 'source', url: 'https://bit.ly/example' })
const scope = effectScope()
scope.run(() => useLinkCheck(context))
assert.equal(isLinkCheckPending(context.value), true)
scope.stop()
assert.equal(isLinkCheckPending(context.value), false)
})
@@ -0,0 +1,24 @@
export const PROJECT_LINK_BLOCK_LIST = {
urlShorteners: ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'],
external: [
{ 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'],
},
],
} as const
@@ -0,0 +1,45 @@
export const PROJECT_LINK_DOMAIN_LIST = {
common: {
source: [
'github.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'git.sr.ht',
'tangled.org',
'git.gay',
],
issues: [
'github.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'docs.google.com',
'tangled.org',
'git.gay',
],
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
},
inappropriateLicense: [
'youtube.com',
'youtu.be',
'modrinth.com',
'curseforge.com',
'twitter.com',
'x.com',
'discord.gg',
'discord.com',
'instagram.com',
'facebook.com',
'tiktok.com',
'reddit.com',
'twitch.tv',
'patreon.com',
'ko-fi.com',
'paypal.com',
'buymeacoffee.com',
'google.com',
'example.com',
't.me',
],
} as const
@@ -0,0 +1,19 @@
export { PROJECT_LINK_BLOCK_LIST } from './block-list.ts'
export { PROJECT_LINK_DOMAIN_LIST } from './domain-list.ts'
export {
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
getLinkHostname,
hostnameMatchesDomain,
isCommonProjectLink,
isDiscordLink,
isInappropriateLicenseLink,
isLinkShortener,
} from './syntax-checks.ts'
export type {
BlockedProjectLink,
LinkCheckContext,
LinkCheckResult,
MessageDescriptor,
} from './types.ts'
export { validateLink, validateLinkSyntax } from './validation.ts'
@@ -0,0 +1,119 @@
import type { LinkCheckResult, MessageDescriptor } from './types.ts'
export interface GitRepoFacts {
empty?: boolean
archived?: boolean
issues?: boolean
wiki?: boolean
}
function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
function warn(message: MessageDescriptor): LinkCheckResult {
return { severity: 'warn', message }
}
function error(message: MessageDescriptor): LinkCheckResult {
return { severity: 'error', message }
}
export async function checkDiscordInvite(inviteCode: string): Promise<LinkCheckResult> {
const response = await fetch(
`https://discord.com/api/v10/invites/${inviteCode}?with_expiration=true`,
)
if (!response.ok) {
return error(
defineMessage({
id: 'nags.link.discord.invite.invalid',
defaultMessage: 'This Discord invite is invalid or has expired.',
}),
)
}
const invite = await response.json()
if (!invite.guild) {
return error(
defineMessage({
id: 'nags.link.discord.invite.not-guild',
defaultMessage: 'This Discord invite does not lead to a server.',
}),
)
}
if (invite.expires_at) {
return warn(
defineMessage({
id: 'nags.link.discord.invite.expires',
defaultMessage: 'This Discord invite is set to expire',
}),
)
}
return { severity: 'valid' }
}
export async function fetchGiteaRepo(
host: string,
path: string,
): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://${host}/api/v1/repos/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
empty: data.size === 0,
archived: data.archived,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function fetchGitHubRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://api.github.com/repos/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
empty: data.size === 0,
archived: data.archived,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function fetchGitLabRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`)
if (!response.ok) return undefined
return {}
}
export async function fetchBitbucketRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://api.bitbucket.org/2.0/repositories/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
empty: data.size === 0,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function fetchGiteeRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://gitee.com/api/v5/repos/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function probeGiteaHost(hostname: string): Promise<boolean> {
try {
const response = await fetch(`https://${hostname}/api/v1/version`)
return response.ok
} catch {
return false
}
}
@@ -0,0 +1,352 @@
import { PROJECT_LINK_BLOCK_LIST } from './block-list.ts'
import { PROJECT_LINK_DOMAIN_LIST } from './domain-list.ts'
import type {
BlockedProjectLink,
FieldMatcher,
LinkCheckBuilder,
LinkCheckChildShape,
LinkCheckContext,
LinkCheckMatcher,
LinkCheckNode,
LinkCheckResult,
LinkCheckVerify,
MatchResult,
MessageDescriptor,
RemoteLinkCheckVerify,
} from './types.ts'
function warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'warn', message, values }
}
function error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'error', message, values }
}
export function matchesField(matcher: FieldMatcher, context: LinkCheckContext): boolean {
if (typeof matcher === 'function') return matcher(context.field, context)
if (Array.isArray(matcher)) return matcher.includes(context.field)
return matcher === context.field
}
function isAsyncMatcher(when: LinkCheckMatcher): boolean {
return when instanceof RegExp ? false : when.constructor.name === 'AsyncFunction'
}
function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
const childNodes: LinkCheckNode[] = []
const forMatchers: FieldMatcher[] = []
const node: Record<string, unknown> = { when, label, childNodes, forMatchers }
node.for = (fields: FieldMatcher) => {
forMatchers.push(fields)
return node
}
node.verify = (fn: LinkCheckVerify) => {
node.verifyMatch = fn
return node
}
node.verifyRemotely = (fn: RemoteLinkCheckVerify) => {
node.verifyMatch = fn
node.isRemoteVerification = true
return node
}
node.severity = (value: 'error' | 'warn') => {
node.unrecognizedSeverity = value
return node
}
node.message = (descriptor: MessageDescriptor) => {
node.unrecognizedMessage = descriptor
return node
}
node.transparent = () => {
node.isTransparent = true
return node
}
node.fallback = () => {
node.isFallback = true
return node
}
node.warn = (message: MessageDescriptor, values?: Record<string, unknown>) => {
node.verifyMatch = async () => warn(message, values)
return node
}
node.error = (message: MessageDescriptor, values?: Record<string, unknown>) => {
node.verifyMatch = async () => error(message, values)
return node
}
node.children = (...shapes: LinkCheckChildShape[]) => {
const parentLabel = node.label as string | undefined
const parentForMatchers = node.forMatchers as FieldMatcher[] | undefined
for (const shape of shapes) {
const child = normalizeChild(shape)
const label = child.label ? [parentLabel, child.label].filter(Boolean).join(' ') : parentLabel
const inheritedFor = [...(parentForMatchers ?? []), ...(child.forMatchers ?? [])]
childNodes.push({ ...child, label, forMatchers: inheritedFor })
}
return node
}
return node as unknown as LinkCheckBuilder
}
export function check(
when: RegExp | string | ((remaining: string) => number | null | Promise<number | null>),
label?: string,
): LinkCheckBuilder {
const matcher =
typeof when === 'function' ? when : typeof when === 'string' ? new RegExp(when) : when
return buildNode(matcher, label)
}
export function fallback(label?: string): LinkCheckBuilder {
return buildNode(() => 0, label).fallback()
}
function normalizeChild(shape: LinkCheckChildShape): LinkCheckNode {
if (shape instanceof RegExp || typeof shape === 'function') return { when: shape }
if (typeof shape === 'string') return { when: new RegExp(shape) }
return shape as unknown as LinkCheckNode
}
export function named(label: string, shapes: LinkCheckChildShape[]): LinkCheckNode[] {
return shapes.map((shape) => ({ ...normalizeChild(shape), label }))
}
export function anchored(source: string): RegExp {
return new RegExp(`^${source}`, 'i')
}
export async function matchNode(
node: LinkCheckNode,
remaining: string,
context: LinkCheckContext,
isRoot = false,
): Promise<MatchResult | null> {
let match: RegExpMatchArray | null
if (node.when instanceof RegExp) {
match = node.when.exec(remaining)
} else {
const consumed = await node.when(remaining)
match =
consumed === null
? null
: (Object.assign([remaining.slice(0, consumed)], {
input: remaining,
index: 0,
}) as RegExpMatchArray)
}
if (!match) {
if (!isRoot || !node.unrecognizedMessage) return null
return {
node: {
when: node.when,
label: node.label,
unrecognizedMessage: node.unrecognizedMessage,
unrecognizedSeverity: node.unrecognizedSeverity,
},
match: Object.assign([remaining], { input: remaining, index: 0 }) as RegExpMatchArray,
}
}
if (node.childNodes?.length) {
const rest = remaining.slice(match[0].length)
const syncChildren = node.childNodes.filter(
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
)
const asyncChildren = node.childNodes.filter(
(child) =>
isAsyncMatcher(child.when) &&
!child.isFallback &&
!(context.generalContent && hasFieldSpecificDescendant(child)),
)
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
let expectedChild: LinkCheckNode | undefined
for (const child of [...syncChildren, ...asyncChildren]) {
const found = await matchNode(child, rest, context)
if (found) return found
if (!expectedChild && child.forMatchers?.some((matcher) => matchesField(matcher, context)))
expectedChild = child
}
const matchingFallback = fallbackChildren.find((child) =>
child.forMatchers?.some((matcher) => matchesField(matcher, context)),
)
if (matchingFallback) {
return {
node: matchingFallback,
match: Object.assign([rest], { input: rest, index: 0 }) as RegExpMatchArray,
}
}
if (node.isTransparent) return null
return { node, match, expectedChild }
}
return { node, match }
}
export function matchNodeSyntax(
node: LinkCheckNode,
remaining: string,
context: LinkCheckContext,
isRoot = false,
): MatchResult | null {
let match: RegExpMatchArray | null
if (node.when instanceof RegExp) {
match = node.when.exec(remaining)
} else {
if (isAsyncMatcher(node.when)) return null
const consumed = node.when(remaining)
if (consumed instanceof Promise) return null
match =
consumed === null
? null
: (Object.assign([remaining.slice(0, consumed)], {
input: remaining,
index: 0,
}) as RegExpMatchArray)
}
if (!match) {
if (!isRoot || !node.unrecognizedMessage) return null
return {
node: {
when: node.when,
label: node.label,
unrecognizedMessage: node.unrecognizedMessage,
unrecognizedSeverity: node.unrecognizedSeverity,
},
match: Object.assign([remaining], { input: remaining, index: 0 }) as RegExpMatchArray,
}
}
if (node.childNodes?.length) {
const rest = remaining.slice(match[0].length)
const children = node.childNodes.filter(
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
)
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
let expectedChild: LinkCheckNode | undefined
for (const child of children) {
const found = matchNodeSyntax(child, rest, context)
if (found) return found
if (!expectedChild && child.forMatchers?.some((matcher) => matchesField(matcher, context)))
expectedChild = child
}
const matchingFallback = fallbackChildren.find((child) =>
child.forMatchers?.some((matcher) => matchesField(matcher, context)),
)
if (matchingFallback) {
return {
node: matchingFallback,
match: Object.assign([rest], { input: rest, index: 0 }) as RegExpMatchArray,
}
}
if (node.isTransparent) return null
return { node, match, expectedChild }
}
return { node, match }
}
export function validUrlPrefix(remaining: string): number | null {
let url: URL
try {
url = new URL(remaining)
const hostname = url.hostname
if (url.protocol !== 'https:') return null
if (!/[^.]\.[^.]/.test(hostname)) return null
if (/(^|\.)(local|localhost|test|example|invalid|onion|arpa|home)$/i.test(hostname)) return null
if (/^example\.(com|net|org)$/i.test(hostname)) return null
const strippedHost = hostname.replace(/^\[|]$/g, '')
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHost) || strippedHost.includes(':')) return null
const protocolPrefix = /^https:\/\//i.exec(remaining)
return protocolPrefix?.[0].length ?? null
} catch {
return null
}
}
export function getLinkHostname(url: string | null | undefined): string | null {
if (!url) return null
try {
const hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '')
return hostname || null
} catch {
return null
}
}
export function hostnameMatchesDomain(hostname: string, domain: string): boolean {
return hostname === domain || hostname.endsWith(`.${domain}`)
}
function isLinkFromDomains(url: string | null | undefined, domains: readonly string[]): boolean {
const hostname = getLinkHostname(url)
return hostname !== null && domains.some((domain) => hostnameMatchesDomain(hostname, domain))
}
export function isCommonProjectLink(
url: string | null | undefined,
field: keyof typeof PROJECT_LINK_DOMAIN_LIST.common,
): boolean {
return isLinkFromDomains(url, PROJECT_LINK_DOMAIN_LIST.common[field])
}
export function isDiscordLink(url: string | null | undefined): boolean {
return isCommonProjectLink(url, 'discord')
}
export function isLinkShortener(url: string | null | undefined): boolean {
return isLinkFromDomains(url, PROJECT_LINK_BLOCK_LIST.urlShorteners)
}
export function isInappropriateLicenseLink(url: string | null | undefined): boolean {
return isLinkFromDomains(url, PROJECT_LINK_DOMAIN_LIST.inappropriateLicense)
}
export function hasFieldSpecificDescendant(node: LinkCheckNode): boolean {
return (
(node.forMatchers?.length ?? 0) > 0 ||
(node.childNodes?.some((child) => hasFieldSpecificDescendant(child)) ?? false)
)
}
function isIpAddress(hostname: string): boolean {
const strippedHostname = hostname.replace(/^\[|]$/g, '')
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHostname) || strippedHostname.includes(':')
}
function getBlockedProjectLink(url: string, includeExternal: boolean): BlockedProjectLink | null {
const hostname = getLinkHostname(url)
if (!hostname) return null
if (isIpAddress(hostname)) return { label: 'IP address', url }
if (
PROJECT_LINK_BLOCK_LIST.urlShorteners.some((domain) => hostnameMatchesDomain(hostname, domain))
) {
return { label: 'URL shortener', url }
}
if (!includeExternal) return null
const entry = PROJECT_LINK_BLOCK_LIST.external.find(({ domains }) =>
domains.some((domain) => hostnameMatchesDomain(hostname, domain)),
)
return entry ? { label: entry.label, url } : null
}
export function getBlockedProjectContentLink(url: string): BlockedProjectLink | null {
return getBlockedProjectLink(url, false)
}
export function getBlockedProjectExternalLink(url: string): BlockedProjectLink | null {
return getBlockedProjectLink(url, true)
}
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
getLinkHostname,
isCommonProjectLink,
isDiscordLink,
isInappropriateLicenseLink,
isLinkShortener,
PROJECT_LINK_BLOCK_LIST,
validateLink,
validateLinkSyntax,
} from './index.ts'
test('rejects invalid and insecure URLs', async () => {
const insecure = await validateLink({
field: 'source',
url: 'http://github.com/modrinth/code',
})
const reserved = await validateLink({
field: 'source',
url: 'https://example.com/project',
})
assert.equal(insecure?.severity, 'error')
assert.equal(reserved?.severity, 'error')
})
test('uses a description-specific message for invalid content links', async () => {
const result = await validateLink({
field: 'description',
url: 'http://example.dev/project',
generalContent: true,
})
assert.equal(result?.message?.id, 'nags.link.description.invalid-url')
assert.equal(result?.message?.defaultMessage, 'The description has an invalid link')
})
test('matches recognized hosts case-insensitively', async () => {
const googleForm = await validateLink({
field: 'issues',
url: 'https://DOCS.GOOGLE.COM/forms/d/e/example',
})
const shortener = await validateLink({ field: 'source', url: 'https://BIT.LY/example' })
assert.equal(googleForm?.severity, 'valid')
assert.equal(shortener?.severity, 'error')
})
test('rejects a recognized link used in the wrong field', async () => {
const result = await validateLink({
field: 'wiki',
url: 'https://docs.google.com/forms/d/e/example',
})
assert.equal(result?.severity, 'error')
assert.equal(result?.message?.id, 'nags.link.wrong-field')
})
test('allows structured link types in general content', async () => {
const result = await validateLink({
field: 'description',
url: 'https://github.com/modrinth/code',
generalContent: true,
})
assert.equal(result?.severity, 'valid')
})
test('allows unrecognized valid links but keeps global restrictions in general content', async () => {
const allowed = await validateLink({
field: 'description',
url: 'https://docs.example.dev/project',
generalContent: true,
})
const blocked = await validateLink({
field: 'description',
url: 'https://bit.ly/project',
generalContent: true,
})
assert.equal(allowed?.severity, 'valid')
assert.equal(blocked?.severity, 'error')
})
test('applies the external-link blocklist only outside general content', async () => {
const blockedExternalLink = await validateLink({
field: 'site',
url: 'https://social.modrinth.com/project',
})
const allowedContentLink = await validateLink({
field: 'description',
url: 'https://social.modrinth.com/project',
generalContent: true,
})
const allowed = await validateLink({
field: 'description',
url: 'https://modrinth.com.example.dev/project',
generalContent: true,
})
assert.equal(blockedExternalLink?.severity, 'error')
assert.equal(allowedContentLink?.severity, 'valid')
assert.equal(allowed?.severity, 'valid')
})
test('compares recognized license URLs with the selected license', async () => {
const matching = await validateLink({
field: 'license',
url: 'https://spdx.org/licenses/MIT.html',
expectedLicense: 'MIT',
isCustom: false,
})
const mismatching = await validateLink({
field: 'license',
url: 'https://spdx.org/licenses/MIT.html',
expectedLicense: 'Apache-2.0',
isCustom: false,
})
assert.equal(matching?.severity, 'valid')
assert.equal(mismatching?.severity, 'warn')
})
test('blocks every configured URL shortener and its subdomains', () => {
for (const domain of PROJECT_LINK_BLOCK_LIST.urlShorteners) {
assert.deepEqual(getBlockedProjectContentLink(`https://${domain}/project`), {
label: 'URL shortener',
url: `https://${domain}/project`,
})
assert.equal(
getBlockedProjectExternalLink(`https://subdomain.${domain}/project`)?.label,
'URL shortener',
)
}
})
test('blocks every configured external domain and its subdomains', () => {
for (const { label, domains } of PROJECT_LINK_BLOCK_LIST.external) {
for (const domain of domains) {
assert.deepEqual(getBlockedProjectExternalLink(`https://${domain}/project`), {
label,
url: `https://${domain}/project`,
})
assert.equal(
getBlockedProjectExternalLink(`https://subdomain.${domain}/project`)?.label,
label,
)
}
}
})
test('allows external-only blocklist entries in project content', () => {
assert.equal(getBlockedProjectContentLink('https://social.modrinth.com/project'), null)
assert.equal(
getBlockedProjectExternalLink('https://social.modrinth.com/project')?.label,
'Modrinth',
)
})
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(getBlockedProjectExternalLink('http://127.0.0.1:25565')?.label, 'IP address')
assert.equal(getBlockedProjectContentLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectExternalLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectContentLink('not a URL'), null)
})
test('matches classified domains exactly or by subdomain', () => {
assert.equal(isCommonProjectLink('https://github.com/modrinth/code', 'source'), true)
assert.equal(isCommonProjectLink('https://subdomain.github.com/modrinth/code', 'source'), true)
assert.equal(isCommonProjectLink('https://fakegithub.com/modrinth/code', 'source'), false)
assert.equal(isCommonProjectLink('https://github.com.example.com/modrinth/code', 'source'), false)
assert.equal(isDiscordLink('https://discord.gg/modrinth'), true)
assert.equal(isDiscordLink('https://discord.gg.example.com/modrinth'), false)
assert.equal(isLinkShortener('https://bit.ly/modrinth'), true)
assert.equal(isLinkShortener('https://bit.ly.example.com/modrinth'), false)
assert.equal(isInappropriateLicenseLink('https://youtube.com/watch?v=example'), true)
assert.equal(isInappropriateLicenseLink('https://youtube.com.evil.dev/license'), false)
})
test('extracts normalized hostnames from valid web URLs', () => {
assert.equal(getLinkHostname('https://GITHUB.COM./modrinth/code'), 'github.com')
assert.equal(getLinkHostname('not a URL'), null)
assert.equal(getLinkHostname('mailto:example@example.com'), null)
})
test('validates recognized link syntax without performing remote checks', () => {
const source = validateLinkSyntax({
field: 'source',
url: 'https://github.com/modrinth/code',
})
const wrongField = validateLinkSyntax({
field: 'wiki',
url: 'https://docs.google.com/forms/d/e/example',
})
assert.equal(source?.severity, 'valid')
assert.equal(wrongField?.severity, 'error')
assert.equal(wrongField?.message?.id, 'nags.link.wrong-field')
})
@@ -0,0 +1,93 @@
export interface MessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
export interface LinkCheckContext {
url: string | undefined
field: string
generalContent?: boolean
[key: string]: unknown
}
export interface LinkCheckResult {
severity: 'valid' | 'warn' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}
export interface BlockedProjectLink extends Record<string, unknown> {
label: string
url: string
}
export type FieldMatcher =
| string
| string[]
| ((field: string, context: LinkCheckContext) => boolean)
export type LinkCheckVerify = (
match: RegExpMatchArray,
context: LinkCheckContext,
) => LinkCheckResult
export type RemoteLinkCheckVerify = (
match: RegExpMatchArray,
context: LinkCheckContext,
) => Promise<LinkCheckResult>
export type LinkCheckMatcher =
| RegExp
| ((remaining: string) => number | null | Promise<number | null>)
export interface LinkCheckNode {
when: LinkCheckMatcher
label?: string
unrecognizedSeverity?: 'error' | 'warn'
unrecognizedMessage?: MessageDescriptor
forMatchers?: FieldMatcher[]
verifyMatch?: LinkCheckVerify | RemoteLinkCheckVerify
isRemoteVerification?: boolean
childNodes?: LinkCheckNode[]
isTransparent?: boolean
isFallback?: boolean
}
export interface LinkCheckBuilder {
when: LinkCheckMatcher
label?: string
for(fields: FieldMatcher): LinkCheckBuilder
verify(fn: LinkCheckVerify): LinkCheckBuilder
verifyRemotely(fn: RemoteLinkCheckVerify): LinkCheckBuilder
children(...shapes: LinkCheckChildShape[]): LinkCheckBuilder
severity(value: 'error' | 'warn'): LinkCheckBuilder
message(descriptor: MessageDescriptor): LinkCheckBuilder
transparent(): LinkCheckBuilder
fallback(): LinkCheckBuilder
warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckBuilder
error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckBuilder
}
export type LinkCheckChildShape =
| LinkCheckNode
| LinkCheckBuilder
| RegExp
| string
| ((remaining: string) => number | null | Promise<number | null>)
export interface MatchResult {
node: LinkCheckNode
match: RegExpMatchArray
expectedChild?: LinkCheckNode
}
@@ -0,0 +1,467 @@
import {
checkDiscordInvite,
fetchBitbucketRepo,
fetchGiteaRepo,
fetchGiteeRepo,
fetchGitHubRepo,
fetchGitLabRepo,
type GitRepoFacts,
probeGiteaHost,
} from './remote-checks.ts'
import {
anchored,
check,
fallback,
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
hasFieldSpecificDescendant,
matchesField,
matchNode,
matchNodeSyntax,
named,
validUrlPrefix,
} from './syntax-checks.ts'
import type {
LinkCheckBuilder,
LinkCheckContext,
LinkCheckNode,
LinkCheckResult,
MatchResult,
MessageDescriptor,
} from './types.ts'
function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
function defineMessages<T extends Record<string, MessageDescriptor>>(descriptors: T): T {
return descriptors
}
const valid: LinkCheckResult = { severity: 'valid' }
function warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'warn', message, values }
}
function error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'error', message, values }
}
const coreMessages = defineMessages({
wrongField: {
id: 'nags.link.wrong-field',
defaultMessage: "{label} links aren't valid for this field.",
},
neverValid: {
id: 'nags.link.never-valid',
defaultMessage: "{label} links aren't allowed here.",
},
expectedType: {
id: 'nags.link.expected-type',
defaultMessage: "This isn't a valid {label} link.",
},
})
//TODO: we should probably just let you not provide https but backend currently requires it
const invalidUrlMessage = defineMessage({
id: 'nags.link.invalid-url',
defaultMessage: 'This URL is invalid',
})
const invalidDescriptionUrlMessage = defineMessage({
id: 'nags.link.description.invalid-url',
defaultMessage: 'The description has an invalid link',
})
const checks = check(validUrlPrefix).message(invalidUrlMessage).transparent()
const rootNode = checks as unknown as LinkCheckNode
type PreparedLinkValidation =
| LinkCheckResult
| (() => LinkCheckResult | Promise<LinkCheckResult>)
| undefined
function prepareMatchedLinkValidation(
context: LinkCheckContext,
found: MatchResult,
includeRemoteChecks: boolean,
): PreparedLinkValidation {
const { node: matched, match, expectedChild } = found
const isLeaf = !matched.childNodes?.length
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
if (!applies) {
if (context.generalContent && hasFieldSpecificDescendant(matched)) return valid
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
if (matched.unrecognizedMessage && isLeaf) {
const message =
context.field === 'description' && matched.unrecognizedMessage.id === invalidUrlMessage.id
? invalidDescriptionUrlMessage
: matched.unrecognizedMessage
return build(message, { label: matched.label })
}
if (expectedChild) {
if (matched.unrecognizedMessage) {
return build(matched.unrecognizedMessage, { label: matched.label })
}
return build(coreMessages.expectedType, { label: expectedChild.label })
}
const validElsewhere = matched.forMatchers && matched.forMatchers.length > 0
const message = validElsewhere ? coreMessages.wrongField : coreMessages.neverValid
return build(message, { label: matched.label })
}
if (!matched.verifyMatch || (matched.isRemoteVerification && !includeRemoteChecks)) return valid
return () => matched.verifyMatch!(match, context)
}
function getBlockedLinkResult(context: LinkCheckContext): LinkCheckResult | undefined {
const url = context.url
if (!url) return
const blockedLink = context.generalContent
? getBlockedProjectContentLink(url)
: getBlockedProjectExternalLink(url)
return blockedLink ? error(coreMessages.neverValid, { label: blockedLink.label }) : undefined
}
export function validateLinkSyntax(context: LinkCheckContext): LinkCheckResult | undefined {
const url = context.url
if (!url) return
const blockedResult = getBlockedLinkResult(context)
if (blockedResult) return blockedResult
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1')
const found = matchNodeSyntax(rootNode, normalizedUrl, context, true)
if (!found)
return context.generalContent && validUrlPrefix(normalizedUrl) !== null ? valid : undefined
const prepared = prepareMatchedLinkValidation(context, found, false)
if (typeof prepared !== 'function') return prepared
const result = prepared()
return result instanceof Promise ? undefined : result
}
export async function validateLink(
context: LinkCheckContext,
): Promise<LinkCheckResult | undefined> {
const url = context.url
if (!url) return
const blockedResult = getBlockedLinkResult(context)
if (blockedResult) return blockedResult
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1')
const found = await matchNode(rootNode, normalizedUrl, context, true)
if (!found) {
return context.generalContent && validUrlPrefix(normalizedUrl) !== null ? valid : undefined
}
const prepared = prepareMatchedLinkValidation(context, found, true)
if (typeof prepared !== 'function') return prepared
try {
return await prepared()
} catch {
return undefined
}
}
checks.children(
...named('Discord', [
check(/^discord\.gg/i).children(
check(/^\/([\w-]+)/i)
.for('discord')
.verifyRemotely((match) => checkDiscordInvite(match[1])),
),
check(/^(?:discord\.com|discordapp\.com)/i).children(
check(/^\/invite\/([\w-]+)/i)
.for('discord')
.verifyRemotely((match) => checkDiscordInvite(match[1])),
check(/^\/channels\//i).message(
defineMessage({
id: 'nags.link.discord.channel',
defaultMessage: 'This is a link to a Discord channel, not a server invite.',
}),
),
check(/^\/users\//i).message(
defineMessage({
id: 'nags.link.discord.user',
defaultMessage: 'This is a link to a Discord user, not a server invite.',
}),
),
),
]),
)
const gitRepoMessages = defineMessages({
notFound: {
id: 'nags.link.git.not-found',
defaultMessage: 'This repository could not be found (it may be private or deleted).',
},
empty: {
id: 'nags.link.git.empty',
defaultMessage: 'This repository appears to be empty.',
},
archived: {
id: 'nags.link.git.archived',
defaultMessage: 'This repository is archived, which disables issues.',
},
issuesDisabled: {
id: 'nags.link.git.issues-disabled',
defaultMessage: 'Issues are disabled on this repository.',
},
wikiDisabled: {
id: 'nags.link.git.wiki-disabled',
defaultMessage: 'The wiki is disabled on this repository.',
},
})
async function checkRepo(
fetchRepo: (path: string) => Promise<GitRepoFacts | undefined>,
path: string,
evaluate: (facts: GitRepoFacts) => LinkCheckResult,
): Promise<LinkCheckResult> {
const facts = await fetchRepo(path)
if (!facts) return error(gitRepoMessages.notFound)
return evaluate(facts)
}
function gitHost(
name: string,
domain: string,
fetchRepo: (path: string) => Promise<GitRepoFacts | undefined>,
options: {
pathPattern?: string
subPageSeparator?: string
wikiPath?: string
} = {},
): LinkCheckBuilder {
const path = options.pathPattern ?? /[^/]+\/[^/]+/.source
const sep = options.subPageSeparator ?? ''
const wikiPath = options.wikiPath ?? 'wiki'
return check(anchored(domain), name)
.severity('warn')
.children(
check(anchored(`/(${path})/?$`), 'repo')
.for('source')
.verifyRemotely((match) =>
checkRepo(fetchRepo, match[1], (facts) =>
facts.empty ? error(gitRepoMessages.empty) : valid,
),
),
check(anchored(`/(${path})${sep}/issues`), 'issues')
.for('issues')
.verifyRemotely((match) =>
checkRepo(fetchRepo, match[1], (facts) => {
if (facts.archived) return error(gitRepoMessages.archived)
if (facts.issues === false) return error(gitRepoMessages.issuesDisabled)
return valid
}),
),
check(anchored(`/(${path})${sep}/${wikiPath}`), 'wiki')
.for('wiki')
.verifyRemotely((match) =>
checkRepo(fetchRepo, match[1], (facts) =>
facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid,
),
),
)
}
// Repo Platforms, includes most source/issues/wiki + Github Sponsor
checks.children(
gitHost('GitHub', 'github\\.com', fetchGitHubRepo)
// Github sponsor is here
//TODO: we can't actually check if sponsors is setup with auth or cuz of cors im not really sure but regardless it doesn't works from browser
.children(check(/^\/sponsors\/[^/]+/i, 'sponsors').for('github')),
gitHost('Codeberg', 'codeberg\\.org', (path) => fetchGiteaRepo('codeberg.org', path)),
gitHost('GitLab', 'gitlab\\.com', fetchGitLabRepo, {
pathPattern: /[^/]+(?:\/[^/]+)+/.source,
subPageSeparator: '/-',
wikiPath: 'wikis',
}),
gitHost('Bitbucket', 'bitbucket\\.org', fetchBitbucketRepo),
gitHost('Gitee', 'gitee\\.com', fetchGiteeRepo),
)
checks.children(
check(async (remaining) => {
const hostMatch = /^[^/]+/.exec(remaining)
if (!hostMatch) return null
return (await probeGiteaHost(hostMatch[0])) ? 0 : null
}, 'Self-hosted Gitea/Forgejo')
.severity('warn')
.children(
check(/^([^/]+)\/([^/]+\/[^/]+)\/?$/i, 'repo')
.for('source')
.verifyRemotely((match) =>
checkRepo(
(path) => fetchGiteaRepo(match[1], path),
match[2],
(facts) => (facts.empty ? error(gitRepoMessages.empty) : valid),
),
),
check(/^([^/]+)\/([^/]+\/[^/]+)\/issues/i, 'issues')
.for('issues')
.verifyRemotely((match) =>
checkRepo(
(path) => fetchGiteaRepo(match[1], path),
match[2],
(facts) => {
if (facts.archived) return error(gitRepoMessages.archived)
if (facts.issues === false) return error(gitRepoMessages.issuesDisabled)
return valid
},
),
),
check(/^([^/]+)\/([^/]+\/[^/]+)\/wiki/i, 'wiki')
.for('wiki')
.verifyRemotely((match) =>
checkRepo(
(path) => fetchGiteaRepo(match[1], path),
match[2],
(facts) => (facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid),
),
),
),
)
// Donation
checks.children(
check(/^patreon\.com/i, 'Patreon').children(check(/^\/(?:user\?u=\d+|[\w.-]+)/i).for('patreon')),
check(/^(?:buymeacoffee\.com|buymeacoff\.ee)/i, 'Buy Me a Coffee').children(
check(/^\/([\w-]+)/i).for('bmac'),
),
check(/^paypal\.[a-z.]{2,}/i, 'PayPal')
.for('paypal')
.children(
check(/^\/paypalme\/[\w.-]+/i),
check(/^\/donate/i),
check(/^\/cgi-bin\/webscr\?cmd=_donations/i),
),
check(/^paypal\.me/i, 'PayPal').children(check(/^\/([\w.-]+)/i).for('paypal')),
// Github sponsor is with the rest of github.
check(/^ko-fi\.com/i, 'Ko-fi').children(check(/^\/([\w-]+)/i).for('ko-fi')),
(() => {
const YOUTUBE_CHANNEL = '(?:@[\\w.-]+|channel/[\\w-]+|c/[\\w-]+|user/[\\w-]+)'
return check(/^(?:youtube\.com|youtu\.be)/i, 'YouTube')
.message(
defineMessage({
id: 'nags.link.youtube.unrecognized',
defaultMessage: "This doesn't look like a YouTube donation link.",
}),
)
.for('other')
.children(
check(anchored(`/${YOUTUBE_CHANNEL}/join`)),
check(anchored(`/${YOUTUBE_CHANNEL}/store`)),
)
})(),
)
//TODO: remove this if/when we move this to the backend as we can know this if its backend
// tho actually we will probably still need it even then if we're fine with non immediate redirects
// we at the very least need to reword it in that case idk man
checks.children(
fallback('Unrecognized redirect link')
.for(['discord', 'github', 'patreon', 'ko-fi', 'paypal', 'bmac'])
.verify((_match, context) =>
warn(
defineMessage({
id: 'nags.link.unverifiable-redirect',
defaultMessage: "This doesn't look like a {platform} link.",
}),
{ platform: context.platformName ?? context.field },
),
),
)
const licenseCheckMessages = defineMessages({
urlMismatch: {
id: 'nags.link.license.url-mismatch',
defaultMessage:
'This link points to the {detected} license, but your project is set to {selected}.',
},
urlRedundant: {
id: 'nags.link.license.url-redundant',
defaultMessage:
"You don't need to link to a generic license page for a supported license — consider linking to your repository's own license file instead, or leaving this blank.",
},
})
function licenseVerify(detected: string | null, context: Record<string, unknown>): LinkCheckResult {
const expectedLicense = context.expectedLicense as string | undefined
const isCustom = context.isCustom as boolean | undefined
if (detected && expectedLicense && !isCustom) {
return detected.toLowerCase() === expectedLicense.toLowerCase()
? valid
: warn(licenseCheckMessages.urlMismatch, { detected, selected: expectedLicense })
}
return isCustom ? valid : warn(licenseCheckMessages.urlRedundant)
}
checks.children(
check(anchored('spdx\\.org'), 'SPDX').children(
check(anchored('/licenses/([\\w.-]+)\\.html'))
.for('license')
.verify((match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('opensource\\.org'), 'OSI').children(
check(anchored('/licenses?/([\\w.-]+)'))
.for('license')
.verify((match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('choosealicense\\.com'), 'choosealicense.com').children(
check(anchored('/licenses/([\\w.-]+)'))
.for('license')
.verify((match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('(?:www\\.)?gnu\\.org'), 'GNU').children(
check(anchored('/licenses/[\\w.-]+'))
.for('license')
.verify((_match, ctx) => licenseVerify(null, ctx)),
),
check(anchored('(?:www\\.)?apache\\.org'), 'Apache').children(
check(anchored('/licenses/[\\w.-]+'))
.for('license')
.verify((_match, ctx) => licenseVerify(null, ctx)),
),
check(anchored('creativecommons\\.org'), 'Creative Commons').children(
check(anchored('/(?:licenses/[\\w-]+|publicdomain/zero)/[\\d.]+/?'))
.for('license')
.verify((_match, ctx) => licenseVerify(null, ctx)),
),
)
// Google Forms for issues and Docs for Wiki
checks.children(
check(/^docs\.google\.com/i, 'Google').children(
check(/^\/forms\//i, 'Forms').for('issues'),
check(/^\/document\//i, 'Documents').for('wiki'),
),
)
@@ -1,8 +1,8 @@
import LinkifyIt from 'linkify-it'
import { getBlockedProjectContentLink } from '../links/index.ts'
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
@@ -1,82 +0,0 @@
export interface ProjectLinkBlocklistEntry {
label: string
domains: readonly string[]
}
export const PROJECT_LINK_SHORTENERS = [
'bit.ly',
'adf.ly',
'tinyurl.com',
'short.io',
'is.gd',
] as const
const URL_SHORTENER_BLOCKLIST_ENTRY: ProjectLinkBlocklistEntry = {
label: 'URL shortener',
domains: PROJECT_LINK_SHORTENERS,
}
export const PROJECT_CONTENT_LINK_BLOCKLIST: readonly ProjectLinkBlocklistEntry[] = [
URL_SHORTENER_BLOCKLIST_ENTRY,
]
export const PROJECT_EXTERNAL_LINK_BLOCKLIST: readonly ProjectLinkBlocklistEntry[] = [
URL_SHORTENER_BLOCKLIST_ENTRY,
{ 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 BlockedProjectLink 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(':')
}
function getBlockedProjectLink(
url: string,
blocklist: readonly ProjectLinkBlocklistEntry[],
): BlockedProjectLink | 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 = blocklist.find(({ domains }) =>
domains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)),
)
return entry ? { label: entry.label, url } : null
}
export function getBlockedProjectContentLink(url: string): BlockedProjectLink | null {
return getBlockedProjectLink(url, PROJECT_CONTENT_LINK_BLOCKLIST)
}
export function getBlockedProjectExternalLink(url: string): BlockedProjectLink | null {
return getBlockedProjectLink(url, PROJECT_EXTERNAL_LINK_BLOCKLIST)
}
@@ -1,53 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
PROJECT_CONTENT_LINK_BLOCKLIST,
PROJECT_EXTERNAL_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 every configured external-link domain and its subdomains', () => {
for (const { label, domains } of PROJECT_EXTERNAL_LINK_BLOCKLIST) {
for (const domain of domains) {
assert.deepEqual(getBlockedProjectExternalLink(`https://${domain}/project`), {
label,
url: `https://${domain}/project`,
})
assert.equal(
getBlockedProjectExternalLink(`https://subdomain.${domain}/project`)?.label,
label,
)
}
}
})
test('allows external-only blocklist entries in project content', () => {
assert.equal(getBlockedProjectContentLink('https://social.modrinth.com/project'), null)
assert.equal(getBlockedProjectExternalLink('https://social.modrinth.com/project')?.label, 'Modrinth')
})
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(getBlockedProjectExternalLink('http://127.0.0.1:25565')?.label, 'IP address')
assert.equal(getBlockedProjectContentLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectExternalLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectContentLink('not a URL'), null)
})