mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
Better External Link Validation (#7005)
* complain about links we know are incorrect * dont complain about the specific part of the url being incorrect if it wouldn't be correct regardless * UnsavedChangesPopup had a place where it was supposed to disable the save button but the actual impl didn't implement that property so i fixed that and also more work on link checks (including start of license link checks too) * github sponsor note * show the warning directly cuz it's not particularly clear there's a tooltip migrate links page to actually good unsaved changes thing that every other page uses also like self-hosted git support also add 9minecraft to blacklist * this dont look correct warning + oops duplicate id * better/more blacklist * clean up after merge * imports --------- Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
committed by
Prospector
co-authored by
tdgao
parent
56b8cae31c
commit
4b2f8dedec
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="check && check.severity !== 'valid'"
|
||||
class="flex w-full items-center gap-1.5"
|
||||
:class="check.severity === 'error' ? 'text-red' : 'text-orange'"
|
||||
>
|
||||
<component :is="icon" class="my-auto" />
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
check: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const icon = computed(() => (props.check?.severity === 'error' ? XCircleIcon : TriangleAlertIcon))
|
||||
|
||||
const message = computed(() => {
|
||||
if (!props.check?.message) return undefined
|
||||
return formatMessage(props.check.message, props.check.values)
|
||||
})
|
||||
</script>
|
||||
@@ -72,7 +72,7 @@
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="w-1/2">
|
||||
<div class="flex w-1/2 flex-col gap-2">
|
||||
<StyledInput
|
||||
id="license-url"
|
||||
v-model="current.licenseUrl"
|
||||
@@ -84,6 +84,7 @@
|
||||
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<LinkCheckMessage :check="effectiveLicenseCheck" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +146,8 @@
|
||||
!(
|
||||
current.license.friendly === 'Custom' &&
|
||||
(current.license.short === '' || current.licenseUrl === '')
|
||||
)
|
||||
) &&
|
||||
effectiveLicenseCheck?.severity !== 'error'
|
||||
"
|
||||
@reset="reset"
|
||||
@save="save"
|
||||
@@ -154,6 +156,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLinkCheck } from '@modrinth/moderation'
|
||||
import {
|
||||
Checkbox,
|
||||
Combobox,
|
||||
@@ -169,6 +172,8 @@ import {
|
||||
import { builtinLicenses, formatProjectType, TeamMemberPermission } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LinkCheckMessage from '@/components/LinkCheckMessage.vue'
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.license)
|
||||
@@ -227,6 +232,15 @@ const { saved, current, saving, hasChanges, reset, save } = useSavable(
|
||||
},
|
||||
)
|
||||
|
||||
const effectiveLicenseCheck = useLinkCheck(
|
||||
computed(() => ({
|
||||
field: 'license',
|
||||
url: current.value.licenseUrl,
|
||||
expectedLicense: current.value.license.short,
|
||||
isCustom: current.value.license.friendly === 'Custom',
|
||||
})),
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const selectedLicense = computed({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<!-- Server Project Links -->
|
||||
<section v-if="isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
@@ -8,48 +9,30 @@
|
||||
<span class="label__title">Website</span>
|
||||
<span class="label__description">Your server's official website.</span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isServerSiteLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="isServerSiteDiscordUrl"
|
||||
v-tooltip="`Discord invites are not appropriate for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<input
|
||||
id="server-website"
|
||||
v-model="siteUrl"
|
||||
v-model="current.site"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="siteCheck" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-store" title="Your server's store page.">
|
||||
<span class="label__title">Store</span>
|
||||
<span class="label__description">A link to your server's store or shop.</span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isServerStoreLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="isServerStoreDiscordUrl"
|
||||
v-tooltip="`Discord invites are not appropriate for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<input
|
||||
id="server-store"
|
||||
v-model="storeUrl"
|
||||
v-model="current.store"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="storeCheck" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
@@ -61,48 +44,30 @@
|
||||
>A page containing information, documentation, and help for the server.</span
|
||||
>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isServerWikiLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="isServerWikiDiscordUrl"
|
||||
v-tooltip="`Discord invites are not appropriate for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<input
|
||||
id="server-wiki"
|
||||
v-model="serverWikiUrl"
|
||||
v-model="current.wiki"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="wikiCheck" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-discord" title="An invitation link to your Discord server.">
|
||||
<span class="label__title">Discord</span>
|
||||
<span class="label__description">An invitation link to your Discord server.</span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isServerDiscordLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="!isServerDiscordUrlCommon"
|
||||
v-tooltip="`You're using a link which isn't common for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<input
|
||||
id="server-discord"
|
||||
v-model="serverDiscordUrl"
|
||||
v-model="current.discord"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="discordInviteCheck" />
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap justify-start gap-2">
|
||||
<Button
|
||||
@@ -130,29 +95,15 @@
|
||||
A place for users to report bugs, issues, and concerns about your project.
|
||||
</span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isIssuesLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="isIssuesDiscordUrl"
|
||||
v-tooltip="`Discord invites are not appropriate for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="!isIssuesUrlCommon"
|
||||
v-tooltip="`Link includes a domain which isn't common for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<StyledInput
|
||||
id="project-issue-tracker"
|
||||
v-model="issuesUrl"
|
||||
v-model="current.issues"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
:maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="issuesCheck" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
@@ -164,29 +115,15 @@
|
||||
A page/repository containing the source code for your project
|
||||
</span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isSourceLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="isSourceDiscordUrl"
|
||||
v-tooltip="`Discord invites are not appropriate for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="!isSourceUrlCommon"
|
||||
v-tooltip="`Link includes a domain which isn't common for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<StyledInput
|
||||
id="project-source-code"
|
||||
v-model="sourceUrl"
|
||||
v-model="current.source"
|
||||
type="url"
|
||||
:maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="sourceCheck" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
@@ -198,48 +135,30 @@
|
||||
A page containing information, documentation, and help for the project.
|
||||
</span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isWikiLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="isWikiDiscordUrl"
|
||||
v-tooltip="`Discord invites are not appropriate for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<StyledInput
|
||||
id="project-wiki-page"
|
||||
v-model="wikiUrl"
|
||||
v-model="current.wiki"
|
||||
type="url"
|
||||
:maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="wikiCheck" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="project-discord-invite" title="An invitation link to your Discord server.">
|
||||
<span class="label__title">Discord invite </span>
|
||||
<span class="label__description"> An invitation link to your Discord server. </span>
|
||||
</label>
|
||||
<TriangleAlertIcon
|
||||
v-if="isDiscordLinkShortener"
|
||||
v-tooltip="`Use of link shorteners is prohibited.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-else-if="!isDiscordUrlCommon"
|
||||
v-tooltip="`You're using a link which isn't common for this link type.`"
|
||||
class="size-6 animate-pulse text-orange"
|
||||
/>
|
||||
<StyledInput
|
||||
id="project-discord-invite"
|
||||
v-model="discordUrl"
|
||||
v-model="current.discord"
|
||||
type="url"
|
||||
:maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<LinkCheckMessage :check="discordInviteCheck" />
|
||||
</div>
|
||||
<span class="label">
|
||||
<span class="label__title">Donation links</span>
|
||||
@@ -272,6 +191,7 @@
|
||||
@update:model-value="updateDonationLinks"
|
||||
/>
|
||||
</div>
|
||||
<LinkCheckMessage :check="donationCheckState(donationLink, index)" />
|
||||
<div class="mt-3 flex flex-wrap justify-start gap-2">
|
||||
<Button type="colored" color="brand" :disabled="!hasChanges" @click="saveChanges()">
|
||||
<SaveIcon />
|
||||
@@ -279,22 +199,40 @@
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<UnsavedChangesPopup
|
||||
:original="original"
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
:can-save="canSave"
|
||||
@reset="reset"
|
||||
@save="save"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
checkLink,
|
||||
getLinkCheckState,
|
||||
isLinkCheckPending,
|
||||
useLinkCheck,
|
||||
} from '@modrinth/moderation'
|
||||
import {
|
||||
Combobox,
|
||||
ConfirmLeaveModal,
|
||||
defineMessage,
|
||||
commonProjectSettingsMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useSavable,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import LinkCheckMessage from '@/components/LinkCheckMessage.vue'
|
||||
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const donationPlatformOptions = computed(() =>
|
||||
@@ -304,204 +242,252 @@ const donationPlatformOptions = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
currentMember,
|
||||
patchProject,
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
const { projectV3: project, currentMember, invalidate } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.links)
|
||||
|
||||
const issuesUrl = ref(project.value.issues_url)
|
||||
const sourceUrl = ref(project.value.source_url)
|
||||
const wikiUrl = ref(project.value.wiki_url)
|
||||
const discordUrl = ref(project.value.discord_url)
|
||||
const isServerProject = computed(() => project.value?.minecraft_server != null)
|
||||
|
||||
// Server project links
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const siteUrl = ref(projectV3.value?.link_urls?.site?.url ?? '')
|
||||
const storeUrl = ref(projectV3.value?.link_urls?.store?.url ?? '')
|
||||
const serverWikiUrl = ref(projectV3.value?.link_urls?.wiki?.url ?? '')
|
||||
const serverDiscordUrl = ref(projectV3.value?.link_urls?.discord?.url ?? '')
|
||||
|
||||
watch(
|
||||
projectV3,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
siteUrl.value = newVal.link_urls?.site?.url ?? ''
|
||||
storeUrl.value = newVal.link_urls?.store?.url ?? ''
|
||||
serverWikiUrl.value = newVal.link_urls?.wiki?.url ?? ''
|
||||
serverDiscordUrl.value = newVal.link_urls?.discord?.url ?? ''
|
||||
const {
|
||||
saved,
|
||||
current,
|
||||
reset: resetFields,
|
||||
} = useSavable(
|
||||
() => {
|
||||
if (isServerProject.value) {
|
||||
return {
|
||||
site: project.value.link_urls?.site?.url ?? '',
|
||||
store: project.value.link_urls?.store?.url ?? '',
|
||||
wiki: project.value.link_urls?.wiki?.url ?? '',
|
||||
discord: project.value.link_urls?.discord?.url ?? '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
issues: project.value.link_urls?.issues?.url ?? '',
|
||||
source: project.value.link_urls?.source?.url ?? '',
|
||||
wiki: project.value.link_urls?.wiki?.url ?? '',
|
||||
discord: project.value.link_urls?.discord?.url ?? '',
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
() => {},
|
||||
)
|
||||
|
||||
const isIssuesUrlCommon = computed(() => {
|
||||
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
|
||||
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues)
|
||||
})
|
||||
function donationRowsFromLinks(linkUrls) {
|
||||
const rows = (tags.value.donationPlatforms ?? [])
|
||||
.filter((platform) => linkUrls?.[platform.short]?.url)
|
||||
.map((platform) => ({ id: platform.short, url: linkUrls[platform.short].url }))
|
||||
rows.push({ id: undefined, url: undefined })
|
||||
return rows
|
||||
}
|
||||
|
||||
const isSourceUrlCommon = computed(() => {
|
||||
if (!sourceUrl.value || sourceUrl.value.trim().length === 0) return true
|
||||
return isCommonUrl(sourceUrl.value, commonLinkDomains.source)
|
||||
})
|
||||
const donationLinks = ref(donationRowsFromLinks(project.value?.link_urls))
|
||||
|
||||
const isDiscordUrlCommon = computed(() => {
|
||||
if (!discordUrl.value || discordUrl.value.trim().length === 0) return true
|
||||
return isCommonUrl(discordUrl.value, commonLinkDomains.discord)
|
||||
})
|
||||
function resetDonations() {
|
||||
donationLinks.value = donationRowsFromLinks(project.value?.link_urls)
|
||||
}
|
||||
|
||||
const isIssuesDiscordUrl = computed(() => {
|
||||
return isDiscordUrl(issuesUrl.value)
|
||||
})
|
||||
function reset() {
|
||||
resetFields()
|
||||
resetDonations()
|
||||
}
|
||||
|
||||
const isSourceDiscordUrl = computed(() => {
|
||||
return isDiscordUrl(sourceUrl.value)
|
||||
})
|
||||
function fieldContext(field, getUrl, extra) {
|
||||
return computed(() => ({ field, url: getUrl(), ...extra }))
|
||||
}
|
||||
|
||||
const isWikiDiscordUrl = computed(() => {
|
||||
return isDiscordUrl(wikiUrl.value)
|
||||
const discordContext = fieldContext('discord', () => current.value.discord, {
|
||||
platformName: 'Discord',
|
||||
})
|
||||
const issuesContext = fieldContext('issues', () => current.value.issues)
|
||||
const sourceContext = fieldContext('source', () => current.value.source)
|
||||
const wikiContext = fieldContext('wiki', () => current.value.wiki)
|
||||
const siteContext = fieldContext('site', () => current.value.site)
|
||||
const storeContext = fieldContext('store', () => current.value.store)
|
||||
|
||||
const isIssuesLinkShortener = computed(() => {
|
||||
return isLinkShortener(issuesUrl.value)
|
||||
})
|
||||
const isSourceLinkShortener = computed(() => {
|
||||
return isLinkShortener(sourceUrl.value)
|
||||
})
|
||||
const isWikiLinkShortener = computed(() => {
|
||||
return isLinkShortener(wikiUrl.value)
|
||||
})
|
||||
const isDiscordLinkShortener = computed(() => {
|
||||
return isLinkShortener(discordUrl.value)
|
||||
})
|
||||
const discordInviteCheck = useLinkCheck(discordContext)
|
||||
const issuesCheck = useLinkCheck(issuesContext)
|
||||
const sourceCheck = useLinkCheck(sourceContext)
|
||||
const wikiCheck = useLinkCheck(wikiContext)
|
||||
const siteCheck = useLinkCheck(siteContext)
|
||||
const storeCheck = useLinkCheck(storeContext)
|
||||
|
||||
const isServerSiteDiscordUrl = computed(() => {
|
||||
return isDiscordUrl(siteUrl.value)
|
||||
})
|
||||
const isServerStoreDiscordUrl = computed(() => {
|
||||
return isDiscordUrl(storeUrl.value)
|
||||
})
|
||||
const isServerWikiDiscordUrl = computed(() => {
|
||||
return isDiscordUrl(serverWikiUrl.value)
|
||||
})
|
||||
const isServerSiteLinkShortener = computed(() => {
|
||||
return isLinkShortener(siteUrl.value)
|
||||
})
|
||||
const isServerStoreLinkShortener = computed(() => {
|
||||
return isLinkShortener(storeUrl.value)
|
||||
})
|
||||
const isServerWikiLinkShortener = computed(() => {
|
||||
return isLinkShortener(serverWikiUrl.value)
|
||||
})
|
||||
const isServerDiscordLinkShortener = computed(() => {
|
||||
return isLinkShortener(serverDiscordUrl.value)
|
||||
})
|
||||
const isServerDiscordUrlCommon = computed(() => {
|
||||
if (!serverDiscordUrl.value || serverDiscordUrl.value.trim().length === 0) return true
|
||||
return isCommonUrl(serverDiscordUrl.value, commonLinkDomains.discord)
|
||||
})
|
||||
function donationContext(row) {
|
||||
return {
|
||||
field: row.id,
|
||||
url: row.url,
|
||||
isDonation: true,
|
||||
platformName: tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name,
|
||||
}
|
||||
}
|
||||
|
||||
const rawDonationLinks = JSON.parse(JSON.stringify(project.value.donation_urls))
|
||||
rawDonationLinks.push({
|
||||
id: null,
|
||||
platform: null,
|
||||
url: null,
|
||||
})
|
||||
const donationLinks = ref(rawDonationLinks)
|
||||
function donationCheckState(row, index) {
|
||||
if (row.url && !row.id) {
|
||||
return {
|
||||
severity: 'error',
|
||||
message: defineMessage({
|
||||
id: 'project.settings.links.donation.no-type',
|
||||
defaultMessage: 'Please select a platform for this Donation link.',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (row.id) {
|
||||
const firstIndex = donationLinks.value.findIndex((other) => other.id === row.id)
|
||||
if (firstIndex !== index) {
|
||||
return {
|
||||
severity: 'error',
|
||||
message: defineMessage({
|
||||
id: 'project.settings.links.donation.duplicate-type',
|
||||
defaultMessage: 'You already have another {platform} link.',
|
||||
}),
|
||||
values: {
|
||||
platform:
|
||||
tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name ??
|
||||
row.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getLinkCheckState(donationContext(row))
|
||||
}
|
||||
|
||||
const donationCheckTimers = new Map()
|
||||
watch(
|
||||
donationLinks,
|
||||
(rows) => {
|
||||
rows.forEach((row, index) => {
|
||||
if (!row.id || !row.url) return
|
||||
clearTimeout(donationCheckTimers.get(index))
|
||||
donationCheckTimers.set(
|
||||
index,
|
||||
setTimeout(() => checkLink(donationContext(row)), 500),
|
||||
)
|
||||
})
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||
})
|
||||
|
||||
function donationsMapFromLinkUrls(linkUrls) {
|
||||
const donations = {}
|
||||
for (const platform of tags.value.donationPlatforms ?? []) {
|
||||
donations[platform.short] = linkUrls?.[platform.short]?.url ?? ''
|
||||
}
|
||||
return donations
|
||||
}
|
||||
|
||||
const donationsOriginal = computed(() =>
|
||||
isServerProject.value ? {} : donationsMapFromLinkUrls(project.value?.link_urls),
|
||||
)
|
||||
|
||||
const donationsModified = computed(() => {
|
||||
if (isServerProject.value) return {}
|
||||
const donations = {}
|
||||
for (const row of donationLinks.value) {
|
||||
if (row.id && !(row.id in donations)) donations[row.id] = row.url ?? ''
|
||||
}
|
||||
return donations
|
||||
})
|
||||
|
||||
function serializeDonationRow(row) {
|
||||
return `${row.id ?? ''}:${row.url}`
|
||||
}
|
||||
|
||||
function donationRowsToObject(rows) {
|
||||
const entries = {}
|
||||
rows.forEach((row, index) => {
|
||||
if (!row.url) return
|
||||
entries[`donation-row-${index}`] = serializeDonationRow(row)
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
const donationsSavedRows = computed(() => donationRowsFromLinks(project.value?.link_urls))
|
||||
|
||||
const original = computed(() => ({
|
||||
...saved.value,
|
||||
...(isServerProject.value ? {} : donationRowsToObject(donationsSavedRows.value)),
|
||||
}))
|
||||
const modified = computed(() => ({
|
||||
...current.value,
|
||||
...(isServerProject.value ? {} : donationRowsToObject(donationLinks.value)),
|
||||
}))
|
||||
|
||||
const hasChanges = computed(() =>
|
||||
Object.keys(modified.value).some((key) => modified.value[key] !== original.value[key]),
|
||||
)
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const patchData = computed(() => {
|
||||
const data = {}
|
||||
|
||||
if (checkDifference(issuesUrl.value, project.value.issues_url)) {
|
||||
data.issues_url = issuesUrl.value === '' ? null : issuesUrl.value.trim()
|
||||
for (const key of Object.keys(current.value)) {
|
||||
if (current.value[key] == saved.value[key]) continue
|
||||
data[key] = current.value[key] === '' ? null : current.value[key].trim()
|
||||
}
|
||||
if (checkDifference(sourceUrl.value, project.value.source_url)) {
|
||||
data.source_url = sourceUrl.value === '' ? null : sourceUrl.value.trim()
|
||||
}
|
||||
if (checkDifference(wikiUrl.value, project.value.wiki_url)) {
|
||||
data.wiki_url = wikiUrl.value === '' ? null : wikiUrl.value.trim()
|
||||
}
|
||||
if (checkDifference(discordUrl.value, project.value.discord_url)) {
|
||||
data.discord_url = discordUrl.value === '' ? null : discordUrl.value.trim()
|
||||
}
|
||||
|
||||
const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id)
|
||||
|
||||
if (
|
||||
validDonationLinks !== project.value.donation_urls &&
|
||||
!(
|
||||
project.value.donation_urls &&
|
||||
project.value.donation_urls.length === 0 &&
|
||||
validDonationLinks.length === 0
|
||||
)
|
||||
) {
|
||||
data.donation_urls = validDonationLinks
|
||||
}
|
||||
|
||||
if (data.donation_urls) {
|
||||
data.donation_urls.forEach((link) => {
|
||||
const platform = tags.value.donationPlatforms.find((platform) => platform.short === link.id)
|
||||
link.platform = platform.name
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchData.value).length > 0
|
||||
})
|
||||
|
||||
// Server project links
|
||||
const serverPatchData = computed(() => {
|
||||
const data = {}
|
||||
const originalSite = projectV3.value?.link_urls?.site?.url ?? ''
|
||||
const originalStore = projectV3.value?.link_urls?.store?.url ?? ''
|
||||
const originalWiki = projectV3.value?.link_urls?.wiki?.url ?? ''
|
||||
const originalDiscord = projectV3.value?.link_urls?.discord?.url ?? ''
|
||||
|
||||
if (checkDifference(siteUrl.value, originalSite)) {
|
||||
data.site = siteUrl.value === '' ? null : siteUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(storeUrl.value, originalStore)) {
|
||||
data.store = storeUrl.value === '' ? null : storeUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(serverWikiUrl.value, originalWiki)) {
|
||||
data.wiki = serverWikiUrl.value === '' ? null : serverWikiUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(serverDiscordUrl.value, originalDiscord)) {
|
||||
data.discord = serverDiscordUrl.value === '' ? null : serverDiscordUrl.value?.trim()
|
||||
if (!isServerProject.value) {
|
||||
for (const platform of tags.value.donationPlatforms ?? []) {
|
||||
const newUrl = donationsModified.value[platform.short] ?? ''
|
||||
const oldUrl = donationsOriginal.value[platform.short] ?? ''
|
||||
if (newUrl === oldUrl) continue
|
||||
data[platform.short] = newUrl === '' ? null : newUrl.trim()
|
||||
}
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
const hasServerChanges = computed(() => {
|
||||
return Object.keys(serverPatchData.value).length > 0
|
||||
const canSave = computed(() => {
|
||||
const checks = isServerProject.value
|
||||
? [siteCheck, storeCheck, wikiCheck, discordInviteCheck]
|
||||
: [issuesCheck, sourceCheck, wikiCheck, discordInviteCheck]
|
||||
const contexts = isServerProject.value
|
||||
? [siteContext, storeContext, wikiContext, discordContext]
|
||||
: [issuesContext, sourceContext, wikiContext, discordContext]
|
||||
|
||||
const fieldsInvalid = checks.some((check) => check.value?.severity === 'error')
|
||||
const fieldsPending = contexts.some((context) => isLinkCheckPending(context.value))
|
||||
|
||||
const donationsInvalid =
|
||||
!isServerProject.value &&
|
||||
donationLinks.value.some((row, index) => donationCheckState(row, index)?.severity === 'error')
|
||||
const donationsPending =
|
||||
!isServerProject.value &&
|
||||
donationLinks.value.some((row) => isLinkCheckPending(donationContext(row)))
|
||||
|
||||
return (
|
||||
!fieldsInvalid &&
|
||||
!fieldsPending &&
|
||||
!donationsInvalid &&
|
||||
!donationsPending &&
|
||||
Object.keys(patchData.value).length > 0
|
||||
)
|
||||
})
|
||||
|
||||
async function saveServerChanges() {
|
||||
const linkUpdates = serverPatchData.value
|
||||
if (Object.keys(linkUpdates).length === 0) return
|
||||
const saving = ref(false)
|
||||
|
||||
async function save() {
|
||||
const data = patchData.value
|
||||
if (Object.keys(data).length === 0) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await labrinth.projects_v3.edit(project.value.id, {
|
||||
link_urls: linkUpdates,
|
||||
link_urls: data,
|
||||
})
|
||||
await invalidate()
|
||||
reset()
|
||||
addNotification({
|
||||
title: 'Links updated',
|
||||
text: 'Your server links have been updated.',
|
||||
text: isServerProject.value
|
||||
? 'Your server links have been updated.'
|
||||
: 'Your links have been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -510,17 +496,8 @@ async function saveServerChanges() {
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (patchData.value && (await patchProject(patchData.value))) {
|
||||
donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
|
||||
donationLinks.value.push({
|
||||
id: null,
|
||||
platform: null,
|
||||
url: null,
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,17 +521,12 @@ function updateDonationLinks() {
|
||||
})
|
||||
if (!links.find((link) => !(link.url && link.id))) {
|
||||
links.push({
|
||||
id: null,
|
||||
platform: null,
|
||||
url: null,
|
||||
id: undefined,
|
||||
url: undefined,
|
||||
})
|
||||
}
|
||||
donationLinks.value = links
|
||||
}
|
||||
|
||||
function checkDifference(newLink, existingLink) {
|
||||
return newLink != existingLink
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.donation-link-group {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './core'
|
||||
export * from './description'
|
||||
export * from './link-checks'
|
||||
export * from './links'
|
||||
export * from './server-projects'
|
||||
export * from './tags'
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
import {defineMessage, defineMessages, type MessageDescriptor} from "@modrinth/ui"
|
||||
import {computed, reactive, type Ref, watch} from "vue"
|
||||
|
||||
export interface LinkCheckContext {
|
||||
url: string | undefined
|
||||
field: string
|
||||
|
||||
[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[]
|
||||
verify?: 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 blacklist(label: string, ...domains: string[]): LinkCheckBuilder {
|
||||
const pattern = domains.map((domain) => domain.replace(/\./g, "\\.")).join("|")
|
||||
return check(new RegExp(`^(?:${pattern})`, "i"), label)
|
||||
}
|
||||
|
||||
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.verify = 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.verify = async () => warn(message, values)
|
||||
return node
|
||||
}
|
||||
node.error = (message: MessageDescriptor, values?: Record<string, unknown>) => {
|
||||
node.verify = 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)
|
||||
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 is not a valid URL.",
|
||||
})
|
||||
|
||||
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;
|
||||
|
||||
return remaining.indexOf(hostname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const checks = check(validUrlPrefix).message(invalidUrlMessage).transparent()
|
||||
|
||||
const rootNode = checks as unknown as LinkCheckNode
|
||||
|
||||
const cache = reactive(new Map<string, "pending" | LinkCheckResult>())
|
||||
|
||||
function cacheKey(context: LinkCheckContext): string {
|
||||
return JSON.stringify(context)
|
||||
}
|
||||
|
||||
async function checkLink(context: LinkCheckContext) {
|
||||
const url = context.url
|
||||
if (!url) return
|
||||
const key = cacheKey(context)
|
||||
if (cache.has(key)) return
|
||||
|
||||
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, "$1")
|
||||
|
||||
cache.set(key, "pending")
|
||||
|
||||
const found = await matchNode(rootNode, normalizedUrl, context, true)
|
||||
if (!found) {
|
||||
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) {
|
||||
const build = matched.unrecognizedSeverity === "warn" ? warn : error
|
||||
|
||||
if (matched.unrecognizedMessage && isLeaf) {
|
||||
cache.set(key, build(matched.unrecognizedMessage, {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.verify) {
|
||||
cache.set(key, valid)
|
||||
return
|
||||
}
|
||||
|
||||
cache.set(key, "pending")
|
||||
matched.verify(match, context).then(
|
||||
(result) => cache.set(key, result),
|
||||
() => 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
|
||||
return cache.get(cacheKey(context)) === "pending"
|
||||
}
|
||||
|
||||
function useLinkCheck(context: Ref<LinkCheckContext>) {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
watch(
|
||||
context,
|
||||
(value) => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => checkLink(value), 500)
|
||||
},
|
||||
{deep: true, immediate: true},
|
||||
)
|
||||
|
||||
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"),
|
||||
),
|
||||
);
|
||||
|
||||
checks.children(
|
||||
blacklist("URL Shortener", "bit.ly", "adf.ly", "tinyurl.com", "short.io", "is.gd"),
|
||||
|
||||
// Social Media
|
||||
blacklist("Twitter", "twitter.com", "x.com"),
|
||||
blacklist("Instagram", "instagram.com"),
|
||||
blacklist("Facebook", "facebook.com"),
|
||||
blacklist("TikTok", "tiktok.com"),
|
||||
blacklist("Telegram", "telegram.org", "t.me"),
|
||||
blacklist("Bilibili", "bilibili.com"),
|
||||
blacklist("Bluesky", "bsky.app"),
|
||||
blacklist("Twitch", "twitch.tv"),
|
||||
blacklist("Reddit", "reddit.com", "redd.it"),
|
||||
|
||||
// Minecraft
|
||||
blacklist("Modrinth", "modrinth.com"),
|
||||
blacklist("Minecraft", "minecraft.net"),
|
||||
//TODO we should probably setup curseforge/planetminecraft issues for issues but im too lazy to do that rn
|
||||
blacklist("Mod Distribution Platform", "curseforge.com", "planetminecraft.com", "9minecraft.net", "mcmod.cn"),
|
||||
|
||||
blacklist("AI Mod Generation Platform", "creativemode.net", "orcaclient.com", "autoforged.cn")
|
||||
)
|
||||
|
||||
export {checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck}
|
||||
Reference in New Issue
Block a user