mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 22:10:15 +00:00
refactor: links validator
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
checkLink,
|
|
||||||
extractProjectLinks,
|
extractProjectLinks,
|
||||||
getLinkCheckState,
|
|
||||||
type LinkCheckContext,
|
type LinkCheckContext,
|
||||||
type LinkCheckResult,
|
type LinkCheckResult,
|
||||||
type ProjectTextValidationResult,
|
type ProjectTextValidationResult,
|
||||||
|
validateLink,
|
||||||
validateProjectDescription,
|
validateProjectDescription,
|
||||||
validateProjectSummary,
|
validateProjectSummary,
|
||||||
validateProjectTitle,
|
validateProjectTitle,
|
||||||
@@ -30,6 +29,47 @@ export function useProjectSummaryValidation(
|
|||||||
return computed(() => validateProjectSummary(toValue(summary), toValue(title)))
|
return computed(() => validateProjectSummary(toValue(summary), toValue(title)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLinkValidation(context: MaybeRefOrGetter<LinkCheckContext>) {
|
||||||
|
const result = ref<LinkCheckResult | null>(null)
|
||||||
|
const pending = ref(false)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let requestId = 0
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => toValue(context),
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
const currentRequestId = ++requestId
|
||||||
|
result.value = null
|
||||||
|
|
||||||
|
if (import.meta.server || !value.url) {
|
||||||
|
pending.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.value = true
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const validation = await validateLink(value)
|
||||||
|
if (currentRequestId === requestId) result.value = validation ?? null
|
||||||
|
} catch {
|
||||||
|
if (currentRequestId === requestId) result.value = null
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === requestId) pending.value = false
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
requestId++
|
||||||
|
})
|
||||||
|
|
||||||
|
return { pending, result }
|
||||||
|
}
|
||||||
|
|
||||||
export function useProjectDescriptionValidation(
|
export function useProjectDescriptionValidation(
|
||||||
description: MaybeRefOrGetter<string | null | undefined>,
|
description: MaybeRefOrGetter<string | null | undefined>,
|
||||||
) {
|
) {
|
||||||
@@ -66,12 +106,11 @@ export function useProjectDescriptionValidation(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(contexts.map((context) => checkLink(context)))
|
const checks = (
|
||||||
|
await Promise.all(contexts.map((context) => validateLink(context)))
|
||||||
|
).filter((check): check is LinkCheckResult => check !== undefined)
|
||||||
if (currentRequestId !== requestId) return
|
if (currentRequestId !== requestId) return
|
||||||
|
|
||||||
const checks = contexts
|
|
||||||
.map((context) => getLinkCheckState(context))
|
|
||||||
.filter((check): check is LinkCheckResult => check !== undefined)
|
|
||||||
linkValidation.value =
|
linkValidation.value =
|
||||||
checks.find((check) => check.severity === 'error') ??
|
checks.find((check) => check.severity === 'error') ??
|
||||||
checks.find((check) => check.severity === 'warn') ??
|
checks.find((check) => check.severity === 'warn') ??
|
||||||
|
|||||||
@@ -149,7 +149,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isLinkCheckPending, useLinkCheck } from '@modrinth/moderation'
|
|
||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Combobox,
|
Combobox,
|
||||||
@@ -238,7 +237,8 @@ const licenseContext = computed(() => ({
|
|||||||
expectedLicense: current.value.license.short,
|
expectedLicense: current.value.license.short,
|
||||||
isCustom: current.value.license.friendly === 'Custom',
|
isCustom: current.value.license.friendly === 'Custom',
|
||||||
}))
|
}))
|
||||||
const effectiveLicenseCheck = useLinkCheck(licenseContext)
|
const licenseValidation = useLinkValidation(licenseContext)
|
||||||
|
const effectiveLicenseCheck = licenseValidation.result
|
||||||
|
|
||||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ const canSave = computed(
|
|||||||
(current.value.license.short === '' || current.value.licenseUrl === '')
|
(current.value.license.short === '' || current.value.licenseUrl === '')
|
||||||
) &&
|
) &&
|
||||||
effectiveLicenseCheck.value?.severity !== 'error' &&
|
effectiveLicenseCheck.value?.severity !== 'error' &&
|
||||||
!isLinkCheckPending(licenseContext.value))),
|
!licenseValidation.pending.value)),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|||||||
@@ -193,13 +193,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import {
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
checkLink,
|
import { type LinkCheckContext, type LinkCheckResult, validateLink } from '@modrinth/moderation'
|
||||||
getLinkCheckState,
|
|
||||||
isLinkCheckPending,
|
|
||||||
useLinkCheck,
|
|
||||||
} from '@modrinth/moderation'
|
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
commonProjectSettingsMessages,
|
commonProjectSettingsMessages,
|
||||||
@@ -217,6 +213,15 @@ import { isAdmin } from '@modrinth/utils'
|
|||||||
|
|
||||||
import ValidationMessage from '@/components/ValidationMessage.vue'
|
import ValidationMessage from '@/components/ValidationMessage.vue'
|
||||||
|
|
||||||
|
type EditableLinkField = 'discord' | 'issues' | 'site' | 'source' | 'store' | 'wiki'
|
||||||
|
type EditableLinks = Partial<Record<EditableLinkField, string>>
|
||||||
|
type ProjectLinkUrls = Labrinth.Projects.v3.Project['link_urls']
|
||||||
|
|
||||||
|
interface DonationRow {
|
||||||
|
id?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
const tags = useGeneratedState()
|
const tags = useGeneratedState()
|
||||||
|
|
||||||
const donationPlatformOptions = computed(() =>
|
const donationPlatformOptions = computed(() =>
|
||||||
@@ -238,7 +243,7 @@ const {
|
|||||||
saved,
|
saved,
|
||||||
current,
|
current,
|
||||||
reset: resetFields,
|
reset: resetFields,
|
||||||
} = useSavable(
|
} = useSavable<EditableLinks>(
|
||||||
() => {
|
() => {
|
||||||
if (isServerProject.value) {
|
if (isServerProject.value) {
|
||||||
return {
|
return {
|
||||||
@@ -258,10 +263,11 @@ const {
|
|||||||
() => {},
|
() => {},
|
||||||
)
|
)
|
||||||
|
|
||||||
function donationRowsFromLinks(linkUrls) {
|
function donationRowsFromLinks(linkUrls?: ProjectLinkUrls): DonationRow[] {
|
||||||
const rows = (tags.value.donationPlatforms ?? [])
|
const rows: DonationRow[] = (tags.value.donationPlatforms ?? []).flatMap((platform) => {
|
||||||
.filter((platform) => linkUrls?.[platform.short]?.url)
|
const url = linkUrls?.[platform.short]?.url
|
||||||
.map((platform) => ({ id: platform.short, url: linkUrls[platform.short].url }))
|
return url ? [{ id: platform.short, url }] : []
|
||||||
|
})
|
||||||
rows.push({ id: undefined, url: undefined })
|
rows.push({ id: undefined, url: undefined })
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@@ -277,7 +283,11 @@ function reset() {
|
|||||||
resetDonations()
|
resetDonations()
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldContext(field, getUrl, extra) {
|
function fieldContext(
|
||||||
|
field: string,
|
||||||
|
getUrl: () => string | undefined,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
return computed(() => ({ field, url: getUrl(), ...extra }))
|
return computed(() => ({ field, url: getUrl(), ...extra }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,23 +300,30 @@ const wikiContext = fieldContext('wiki', () => current.value.wiki)
|
|||||||
const siteContext = fieldContext('site', () => current.value.site)
|
const siteContext = fieldContext('site', () => current.value.site)
|
||||||
const storeContext = fieldContext('store', () => current.value.store)
|
const storeContext = fieldContext('store', () => current.value.store)
|
||||||
|
|
||||||
const discordInviteCheck = useLinkCheck(discordContext)
|
const discordInviteValidation = useLinkValidation(discordContext)
|
||||||
const issuesCheck = useLinkCheck(issuesContext)
|
const issuesValidation = useLinkValidation(issuesContext)
|
||||||
const sourceCheck = useLinkCheck(sourceContext)
|
const sourceValidation = useLinkValidation(sourceContext)
|
||||||
const wikiCheck = useLinkCheck(wikiContext)
|
const wikiValidation = useLinkValidation(wikiContext)
|
||||||
const siteCheck = useLinkCheck(siteContext)
|
const siteValidation = useLinkValidation(siteContext)
|
||||||
const storeCheck = useLinkCheck(storeContext)
|
const storeValidation = useLinkValidation(storeContext)
|
||||||
|
|
||||||
function donationContext(row) {
|
const discordInviteCheck = discordInviteValidation.result
|
||||||
|
const issuesCheck = issuesValidation.result
|
||||||
|
const sourceCheck = sourceValidation.result
|
||||||
|
const wikiCheck = wikiValidation.result
|
||||||
|
const siteCheck = siteValidation.result
|
||||||
|
const storeCheck = storeValidation.result
|
||||||
|
|
||||||
|
function donationContext(row: DonationRow): LinkCheckContext {
|
||||||
return {
|
return {
|
||||||
field: row.id,
|
field: row.id ?? '',
|
||||||
url: row.url,
|
url: row.url,
|
||||||
isDonation: true,
|
isDonation: true,
|
||||||
platformName: tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name,
|
platformName: tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function donationCheckState(row, index) {
|
function donationCheckState(row: DonationRow, index: number): LinkCheckResult | undefined {
|
||||||
if (row.url && !row.id) {
|
if (row.url && !row.id) {
|
||||||
return {
|
return {
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -335,12 +352,17 @@ function donationCheckState(row, index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return getLinkCheckState(donationContext(row))
|
return donationCheckResults.get(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const donationCheckTimers = reactive(new Map())
|
const donationCheckTimers = reactive(new Map<number, ReturnType<typeof setTimeout>>())
|
||||||
|
const donationCheckResults = reactive(new Map<number, LinkCheckResult>())
|
||||||
|
const donationChecksPending = reactive(new Set<number>())
|
||||||
|
let donationValidationId = 0
|
||||||
|
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
|
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
|
||||||
|
donationValidationId++
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -348,14 +370,26 @@ watch(
|
|||||||
(rows) => {
|
(rows) => {
|
||||||
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
|
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
|
||||||
donationCheckTimers.clear()
|
donationCheckTimers.clear()
|
||||||
|
donationCheckResults.clear()
|
||||||
|
donationChecksPending.clear()
|
||||||
|
const currentValidationId = ++donationValidationId
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row, index) => {
|
||||||
if (!row.id || !row.url) return
|
if (!row.id || !row.url) return
|
||||||
|
donationChecksPending.add(index)
|
||||||
donationCheckTimers.set(
|
donationCheckTimers.set(
|
||||||
index,
|
index,
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
donationCheckTimers.delete(index)
|
donationCheckTimers.delete(index)
|
||||||
void checkLink(donationContext(row))
|
try {
|
||||||
|
const result = await validateLink(donationContext(row))
|
||||||
|
if (currentValidationId !== donationValidationId) return
|
||||||
|
if (result) donationCheckResults.set(index, result)
|
||||||
|
} finally {
|
||||||
|
if (currentValidationId === donationValidationId) {
|
||||||
|
donationChecksPending.delete(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
}, 500),
|
}, 500),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -370,33 +404,33 @@ const hasPermission = computed(() => {
|
|||||||
return isAdminUser.value || (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
return isAdminUser.value || (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||||
})
|
})
|
||||||
|
|
||||||
function donationsMapFromLinkUrls(linkUrls) {
|
function donationsMapFromLinkUrls(linkUrls?: ProjectLinkUrls): Record<string, string> {
|
||||||
const donations = {}
|
const donations: Record<string, string> = {}
|
||||||
for (const platform of tags.value.donationPlatforms ?? []) {
|
for (const platform of tags.value.donationPlatforms ?? []) {
|
||||||
donations[platform.short] = linkUrls?.[platform.short]?.url ?? ''
|
donations[platform.short] = linkUrls?.[platform.short]?.url ?? ''
|
||||||
}
|
}
|
||||||
return donations
|
return donations
|
||||||
}
|
}
|
||||||
|
|
||||||
const donationsOriginal = computed(() =>
|
const donationsOriginal = computed<Record<string, string>>(() =>
|
||||||
isServerProject.value ? {} : donationsMapFromLinkUrls(project.value?.link_urls),
|
isServerProject.value ? {} : donationsMapFromLinkUrls(project.value?.link_urls),
|
||||||
)
|
)
|
||||||
|
|
||||||
const donationsModified = computed(() => {
|
const donationsModified = computed<Record<string, string>>(() => {
|
||||||
if (isServerProject.value) return {}
|
if (isServerProject.value) return {}
|
||||||
const donations = {}
|
const donations: Record<string, string> = {}
|
||||||
for (const row of donationLinks.value) {
|
for (const row of donationLinks.value) {
|
||||||
if (row.id && !(row.id in donations)) donations[row.id] = row.url ?? ''
|
if (row.id && !(row.id in donations)) donations[row.id] = row.url ?? ''
|
||||||
}
|
}
|
||||||
return donations
|
return donations
|
||||||
})
|
})
|
||||||
|
|
||||||
function serializeDonationRow(row) {
|
function serializeDonationRow(row: DonationRow): string {
|
||||||
return `${row.id ?? ''}:${row.url}`
|
return `${row.id ?? ''}:${row.url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function donationRowsToObject(rows) {
|
function donationRowsToObject(rows: DonationRow[]): Record<string, string> {
|
||||||
const entries = {}
|
const entries: Record<string, string> = {}
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row, index) => {
|
||||||
if (!row.url) return
|
if (!row.url) return
|
||||||
entries[`donation-row-${index}`] = serializeDonationRow(row)
|
entries[`donation-row-${index}`] = serializeDonationRow(row)
|
||||||
@@ -406,16 +440,19 @@ function donationRowsToObject(rows) {
|
|||||||
|
|
||||||
const donationsSavedRows = computed(() => donationRowsFromLinks(project.value?.link_urls))
|
const donationsSavedRows = computed(() => donationRowsFromLinks(project.value?.link_urls))
|
||||||
|
|
||||||
const originalDonationRows = computed(() =>
|
const originalDonationRows = computed<Record<string, string>>(() =>
|
||||||
isServerProject.value ? {} : donationRowsToObject(donationsSavedRows.value),
|
isServerProject.value ? {} : donationRowsToObject(donationsSavedRows.value),
|
||||||
)
|
)
|
||||||
const modifiedDonationRows = computed(() =>
|
const modifiedDonationRows = computed<Record<string, string>>(() =>
|
||||||
isServerProject.value ? {} : donationRowsToObject(donationLinks.value),
|
isServerProject.value ? {} : donationRowsToObject(donationLinks.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const original = computed(() => ({ ...saved.value, ...originalDonationRows.value }))
|
const original = computed<Record<string, string | undefined>>(() => ({
|
||||||
const modified = computed(() => {
|
...saved.value,
|
||||||
const donations = { ...modifiedDonationRows.value }
|
...originalDonationRows.value,
|
||||||
|
}))
|
||||||
|
const modified = computed<Record<string, string | undefined>>(() => {
|
||||||
|
const donations: Record<string, string | undefined> = { ...modifiedDonationRows.value }
|
||||||
for (const key of Object.keys(originalDonationRows.value)) {
|
for (const key of Object.keys(originalDonationRows.value)) {
|
||||||
if (!(key in donations)) donations[key] = undefined
|
if (!(key in donations)) donations[key] = undefined
|
||||||
}
|
}
|
||||||
@@ -428,11 +465,12 @@ const hasChanges = computed(() =>
|
|||||||
|
|
||||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||||
|
|
||||||
const patchData = computed(() => {
|
const patchData = computed<Record<string, string | null>>(() => {
|
||||||
const data = {}
|
const data: Record<string, string | null> = {}
|
||||||
for (const key of Object.keys(current.value)) {
|
for (const key of Object.keys(current.value) as EditableLinkField[]) {
|
||||||
if (current.value[key] == saved.value[key]) continue
|
const value = current.value[key]
|
||||||
data[key] = current.value[key] === '' ? null : current.value[key].trim()
|
if (value == null || value === saved.value[key]) continue
|
||||||
|
data[key] = value === '' ? null : value.trim()
|
||||||
}
|
}
|
||||||
if (!isServerProject.value) {
|
if (!isServerProject.value) {
|
||||||
for (const platform of tags.value.donationPlatforms ?? []) {
|
for (const platform of tags.value.donationPlatforms ?? []) {
|
||||||
@@ -452,20 +490,18 @@ const canSave = computed(() => {
|
|||||||
const checks = isServerProject.value
|
const checks = isServerProject.value
|
||||||
? [siteCheck, storeCheck, wikiCheck, discordInviteCheck]
|
? [siteCheck, storeCheck, wikiCheck, discordInviteCheck]
|
||||||
: [issuesCheck, sourceCheck, wikiCheck, discordInviteCheck]
|
: [issuesCheck, sourceCheck, wikiCheck, discordInviteCheck]
|
||||||
const contexts = isServerProject.value
|
const validations = isServerProject.value
|
||||||
? [siteContext, storeContext, wikiContext, discordContext]
|
? [siteValidation, storeValidation, wikiValidation, discordInviteValidation]
|
||||||
: [issuesContext, sourceContext, wikiContext, discordContext]
|
: [issuesValidation, sourceValidation, wikiValidation, discordInviteValidation]
|
||||||
|
|
||||||
const fieldsInvalid = checks.some((check) => check.value?.severity === 'error')
|
const fieldsInvalid = checks.some((check) => check.value?.severity === 'error')
|
||||||
const fieldsPending = contexts.some((context) => isLinkCheckPending(context.value))
|
const fieldsPending = validations.some((validation) => validation.pending.value)
|
||||||
|
|
||||||
const donationsInvalid =
|
const donationsInvalid =
|
||||||
!isServerProject.value &&
|
!isServerProject.value &&
|
||||||
donationLinks.value.some((row, index) => donationCheckState(row, index)?.severity === 'error')
|
donationLinks.value.some((row, index) => donationCheckState(row, index)?.severity === 'error')
|
||||||
const donationsPending =
|
const donationsPending =
|
||||||
!isServerProject.value &&
|
!isServerProject.value && (donationCheckTimers.size > 0 || donationChecksPending.size > 0)
|
||||||
(donationCheckTimers.size > 0 ||
|
|
||||||
donationLinks.value.some((row) => isLinkCheckPending(donationContext(row))))
|
|
||||||
|
|
||||||
return !fieldsInvalid && !fieldsPending && !donationsInvalid && !donationsPending
|
return !fieldsInvalid && !fieldsPending && !donationsInvalid && !donationsPending
|
||||||
})
|
})
|
||||||
@@ -491,10 +527,10 @@ async function save() {
|
|||||||
: 'Your links have been updated.',
|
: 'Your links have been updated.',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Failed to update links',
|
title: 'Failed to update links',
|
||||||
text: err.data?.description ?? String(err),
|
text: getErrorDescription(err),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -502,6 +538,15 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getErrorDescription(error: unknown): string {
|
||||||
|
if (typeof error === 'object' && error !== null && 'data' in error) {
|
||||||
|
const data = (error as { data?: { description?: string } }).data
|
||||||
|
if (data?.description) return data.description
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
|
||||||
function updateDonationLinks() {
|
function updateDonationLinks() {
|
||||||
const links = donationLinks.value
|
const links = donationLinks.value
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
|
|||||||
@@ -4,80 +4,11 @@ import type { Nag, NagContext } from '../../types/nags'
|
|||||||
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
|
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
|
||||||
import {
|
import {
|
||||||
getBlockedProjectExternalLink,
|
getBlockedProjectExternalLink,
|
||||||
PROJECT_LINK_SHORTENERS,
|
getLinkHostname,
|
||||||
} from '../../validators/project-links'
|
isCommonProjectLink,
|
||||||
|
isDiscordLink,
|
||||||
export const commonLinkDomains = {
|
isInappropriateLicenseLink,
|
||||||
source: [
|
} from '../../validators/links'
|
||||||
'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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findBlockedProjectExternalLink(context: Pick<NagContext, 'project' | 'projectV3'>) {
|
export function findBlockedProjectExternalLink(context: Pick<NagContext, 'project' | 'projectV3'>) {
|
||||||
const urls = [
|
const urls = [
|
||||||
@@ -191,10 +122,13 @@ export const linksNags: Nag[] = [
|
|||||||
}),
|
}),
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
shouldShow: (context: NagContext) => {
|
shouldShow: (context: NagContext) => {
|
||||||
|
const sourceUrl = context.project.source_url
|
||||||
|
const issuesUrl = context.project.issues_url
|
||||||
|
const discordUrl = context.project.discord_url
|
||||||
return (
|
return (
|
||||||
!isCommonUrl(context.project.source_url ?? null, commonLinkDomains.source) ||
|
(!!sourceUrl && !isCommonProjectLink(sourceUrl, 'source')) ||
|
||||||
!isCommonUrl(context.project.issues_url ?? null, commonLinkDomains.issues) ||
|
(!!issuesUrl && !isCommonProjectLink(issuesUrl, 'issues')) ||
|
||||||
!isCommonUrl(context.project.discord_url ?? null, commonLinkDomains.discord)
|
(!!discordUrl && !isCommonProjectLink(discordUrl, 'discord'))
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
link: {
|
link: {
|
||||||
@@ -219,11 +153,11 @@ export const linksNags: Nag[] = [
|
|||||||
}),
|
}),
|
||||||
status: 'required',
|
status: 'required',
|
||||||
shouldShow: (context: NagContext) =>
|
shouldShow: (context: NagContext) =>
|
||||||
isDiscordUrl(context.project.source_url ?? null) ||
|
isDiscordLink(context.project.source_url) ||
|
||||||
isDiscordUrl(context.project.issues_url ?? null) ||
|
isDiscordLink(context.project.issues_url) ||
|
||||||
isDiscordUrl(context.project.wiki_url ?? null) ||
|
isDiscordLink(context.project.wiki_url) ||
|
||||||
isDiscordUrl(context.projectV3?.link_urls?.site?.url ?? null) ||
|
isDiscordLink(context.projectV3?.link_urls?.site?.url) ||
|
||||||
isDiscordUrl(context.projectV3?.link_urls?.store?.url ?? null),
|
isDiscordLink(context.projectV3?.link_urls?.store?.url),
|
||||||
link: {
|
link: {
|
||||||
path: 'settings/links',
|
path: 'settings/links',
|
||||||
title: defineMessage({
|
title: defineMessage({
|
||||||
@@ -274,8 +208,8 @@ export const linksNags: Nag[] = [
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const domain = getLinkHostname(licenseUrl)
|
||||||
const domain = new URL(licenseUrl).hostname.toLowerCase()
|
if (domain) {
|
||||||
return formatMessage(
|
return formatMessage(
|
||||||
defineMessage({
|
defineMessage({
|
||||||
id: 'nags.invalid-license-url.description.domain',
|
id: 'nags.invalid-license-url.description.domain',
|
||||||
@@ -284,29 +218,21 @@ export const linksNags: Nag[] = [
|
|||||||
}),
|
}),
|
||||||
{ domain },
|
{ 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',
|
status: 'required',
|
||||||
shouldShow: (context: NagContext) => {
|
shouldShow: (context: NagContext) => {
|
||||||
const licenseUrl = context.project.license.url
|
const licenseUrl = context.project.license.url
|
||||||
if (!licenseUrl) return false
|
if (!licenseUrl) return false
|
||||||
|
return getLinkHostname(licenseUrl) === null || isInappropriateLicenseLink(licenseUrl)
|
||||||
const isBlocklisted = isUncommonLicenseUrl(licenseUrl)
|
|
||||||
|
|
||||||
try {
|
|
||||||
new URL(licenseUrl)
|
|
||||||
return isBlocklisted
|
|
||||||
} catch {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
link: {
|
link: {
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ export * from './types/quick-reply'
|
|||||||
export * from './types/reports'
|
export * from './types/reports'
|
||||||
export * from './types/settings'
|
export * from './types/settings'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
export * from './validators/link-checks'
|
export * from './validators/links'
|
||||||
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'
|
||||||
|
|||||||
@@ -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 LinkifyIt from 'linkify-it'
|
||||||
|
|
||||||
|
import { getBlockedProjectContentLink } from '../links/index.ts'
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user