diff --git a/Cargo.lock b/Cargo.lock index 28231e36b7..a3ffa8f646 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1595,6 +1595,18 @@ dependencies = [ "piper", ] +[[package]] +name = "blocklist" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e82d7baf42162b8cbb0040770d863ba1be61408a58d6273ed5238e49e8debb5" +dependencies = [ + "fst", + "once_cell", + "reqwest 0.13.2", + "tokio", +] + [[package]] name = "bollard" version = "0.19.4" @@ -5751,6 +5763,7 @@ dependencies = [ "aws-sdk-s3", "base64 0.22.1", "bitflags 2.9.4", + "blocklist", "bytes", "cel", "censor", @@ -9177,6 +9190,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.3", "lru-slab", @@ -9712,6 +9726,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls 0.23.32", "rustls-pki-types", "rustls-platform-verifier", @@ -12935,13 +12950,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.9.4", "bytes", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index ca056543e7..744c6d7969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [ ] } base64 = "0.22.1" bitflags = "2.9.4" +blocklist = { version = "1.0.0" } bon = "3.9.3" bytemuck = "1.24.0" bytes = "1.10.1" diff --git a/_typos.toml b/_typos.toml index 33c5b1a27d..eb3c35ff04 100644 --- a/_typos.toml +++ b/_typos.toml @@ -30,6 +30,8 @@ gam = "gam" consts = "consts" # short for "Copy" Cpy = "Cpy" +# NoDerivatives in SPDX license identifiers +ND = "ND" [default.extend-identifiers] # Constant from the `zip` crate diff --git a/apps/frontend/src/components/ValidationMessage.vue b/apps/frontend/src/components/ValidationMessage.vue index d54779a913..6cbd368e1c 100644 --- a/apps/frontend/src/components/ValidationMessage.vue +++ b/apps/frontend/src/components/ValidationMessage.vue @@ -110,7 +110,7 @@ watch(() => props.check, updateDisplayedCheck) onScopeDispose(() => clearTimeout(debounceTimer)) const validations = computed(() => { - if (validationIsStale.value || projectValidationLoading.value) return [] + if (validationIsStale.value) return [] return Array.isArray(displayedCheck.value) ? displayedCheck.value diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts index c44b2bf657..cd1d58a745 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts @@ -3,6 +3,7 @@ import { type Nag, nagDefinitions, toProjectNag } from '@modrinth/moderation' import type { Meta, StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' +import { DEFAULT_FEATURE_FLAGS } from '../../../composables/featureFlags' import ModerationProjectNags from './ModerationProjectNags.vue' const categories = [ @@ -92,7 +93,6 @@ const tags = { const previewValues = { count: 3, - domain: 'example.com', fullUrl: 'https://example.com/prohibited-link', languageCount: 12, length: 12, @@ -103,7 +103,6 @@ const previewValues = { tagCount: 9, tags: '16x|32x', totalAvailableTags: 20, - type: 'mod', url: 'https://example.com/prohibited-link', value: 'example', } @@ -120,47 +119,122 @@ const suggestionKinds = new Set([ const warningKinds = new Set([ 'missing-alt-text', - 'verify-external-links', 'too-many-languages', 'too-many-tags', 'multiple-resolution-tags', 'moderator-feedback', ]) -const previewNags = Object.keys(nagDefinitions).map((kind) => { +function createValidationNag( + kind: Labrinth.Projects.v3.NormalizedProjectNagKind, + details: Labrinth.Projects.v3.ProjectNag['details'] = {}, +): Labrinth.Projects.v3.ProjectNag { + return { + kind: kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind, + severity: suggestionKinds.has(kind) + ? 'suggestion' + : warningKinds.has(kind) + ? 'warning' + : 'required', + details: { ...previewValues, ...details }, + } +} + +interface NagPreviewVariant { + details?: Labrinth.Projects.v3.ProjectNag['details'] + projectType?: string +} + +const linkFields = [ + 'issues', + 'source', + 'wiki', + 'discord', + 'site', + 'store', + 'license', + 'description', + 'patreon', + 'bmac', + 'paypal', + 'github', + 'ko-fi', + 'other', +] + +const fieldLinkReasons = [ + 'global_blocklist_match', + 'external_blocklist_match', + 'wrong_field', + 'ip_address', + 'malformed', + 'not_in_allowlist', + 'duplicate', + 'unverifiable', +] + +const nagVariants: Partial< + Record +> = { + 'link-validation': [ + {}, + ...fieldLinkReasons.flatMap((reason) => + linkFields.map((field, index) => ({ + details: { + reason, + field, + other_field: linkFields[(index + 1) % linkFields.length], + }, + })), + ), + { details: { reason: 'download', field: 'description' } }, + { details: { reason: 'discord_invite', field: 'discord' } }, + { details: { reason: 'source_repository', field: 'source' } }, + ], + 'upload-gallery-image': [{}, { projectType: 'resourcepack' }, { projectType: 'shader' }], + 'long-headers': [{}, { details: { count: 1 } }], + 'all-tags-selected': [{}, { details: { totalAvailableTags: 1 } }], + 'multiple-resolution-tags': [{}, { details: { count: 1, tags: ['16x'] } }], + 'too-many-tags': [{}, { details: { tagCount: 1 } }], + 'too-many-tags-server': [{}, { details: { tagCount: 1 } }], + 'too-many-languages': [{}, { details: { languageCount: 1 } }], +} + +const everyNag: Nag[] = Object.keys(nagDefinitions).flatMap((kind) => { const normalizedKind = kind as Labrinth.Projects.v3.NormalizedProjectNagKind - const projectNagKind = kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind - const severity: Labrinth.Projects.v3.ProjectNagSeverity = suggestionKinds.has(normalizedKind) - ? 'suggestion' - : warningKinds.has(normalizedKind) - ? 'warning' - : 'required' - return toProjectNag( - { kind: projectNagKind, severity, details: previewValues }, - previewValues.projectType, - ) + return (nagVariants[normalizedKind] ?? [{}]).map((variant, index) => { + const nag = toProjectNag( + createValidationNag(normalizedKind, variant.details), + variant.projectType ?? previewValues.projectType, + ) + return { ...nag, id: `${nag.id}:preview:${index}` } + }) }) -const everyNag: Nag[] = [ - ...previewNags, - { - id: 'resubmit-for-review-preview', - title: 'Resubmit for review', - description: () => - "Your project has been rejected by Modrinth's staff. Address the moderation team's feedback before resubmitting.", - status: 'special-submit-action', - shouldShow: () => true, - link: { - path: 'moderation', - title: 'Visit moderation page', - shouldShow: () => true, - }, - }, -] +const draftNags = [ + 'add-icon', + 'add-description', + 'upload-version', + 'select-environment', + 'add-links', + 'too-many-tags', + 'check-disclosures', +] satisfies Labrinth.Projects.v3.NormalizedProjectNagKind[] const meta = { title: 'Website/Moderation/PublishingChecklist', component: ModerationProjectNags, + beforeEach: () => { + const previousFlags = Object.getOwnPropertyDescriptor(globalThis, 'useFeatureFlags') + Object.defineProperty(globalThis, 'useFeatureFlags', { + configurable: true, + value: () => ref({ ...DEFAULT_FEATURE_FLAGS }), + }) + return () => { + if (previousFlags) Object.defineProperty(globalThis, 'useFeatureFlags', previousFlags) + else Reflect.deleteProperty(globalThis, 'useFeatureFlags') + } + }, decorators: [ (story) => ({ components: { story }, @@ -199,6 +273,9 @@ export default meta type Story = StoryObj export const EntirePublishingChecklist: Story = { + args: { + validationNags: draftNags.map((kind) => createValidationNag(kind)), + }, parameters: { docs: { description: { @@ -211,13 +288,30 @@ export const EntirePublishingChecklist: Story = { export const EveryNag: Story = { args: { nags: everyNag, + validationNags: draftNags.map((kind) => createValidationNag(kind)), }, parameters: { docs: { description: { story: - 'Every publishing-checklist validation nag plus the submit and resubmit actions, including combinations that cannot normally appear together.', + 'Every registered nag and its message variants, including link reasons and fields, license errors, gallery project types, and singular/plural copy.', }, }, }, } + +export const RejectedProject: Story = { + args: { + project: createProject('rejected'), + projectV3: createProjectV3('rejected'), + validationNags: [createValidationNag('moderator-feedback')], + }, +} + +export const WithheldProject: Story = { + args: { + project: createProject('withheld'), + projectV3: createProjectV3('withheld'), + validationNags: [createValidationNag('moderator-feedback')], + }, +} diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue index 26ff89cf7d..94adf0b4e0 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue @@ -73,7 +73,7 @@ nag.status === 'suggestion' && 'text-purple', ]" /> - {{ getFormattedMessage(nag.title) }} + {{ getFormattedMessage(nag.title, nag.values) }} Promise + submitProject: () => Promise currentMember?: Labrinth.Projects.v3.TeamMember | null collapsed?: boolean disableHorizontalScroll?: boolean @@ -238,7 +238,6 @@ const props = withDefaults(defineProps(), { const emit = defineEmits<{ toggleCollapsed: [] - setProcessing: [processing: boolean] }>() const isProcessing = computed(() => props.project.status === 'processing') @@ -384,10 +383,8 @@ const canSubmitForReview = computed(() => { async function submitForReview() { if (!canSubmitForReview.value) return - const validation = await props.refreshValidation?.() - if (!validation || validation.nags.some((nag) => nag.severity === 'required')) return + if (!(await props.submitProject())) return if (!props.collapsed) emit('toggleCollapsed') - emit('setProcessing', true) await navigateTo( `/${props.project.project_type}/${props.project.slug ?? props.project.id}/${nagDestinations.moderation.path}`, ) @@ -401,16 +398,15 @@ async function submitForReview() { const applicableNags = computed(() => { if (props.nags) return props.nags - const nagsByKind = new Map< - Labrinth.Projects.v3.NormalizedProjectNagKind, - Labrinth.Projects.v3.ProjectNag - >() + const nagsById = new Map() for (const nag of props.validationNags) { const kind = normalizeProjectNagKind(nag.kind) - if (kind && !nagsByKind.has(kind)) nagsByKind.set(kind, nag) + if (!kind) continue + const mapped = toProjectNag(nag, props.project.project_type) + if (!nagsById.has(mapped.id)) nagsById.set(mapped.id, mapped) } - return [...nagsByKind.values()].map((nag) => toProjectNag(nag, props.project.project_type)) + return [...nagsById.values()] }) function isNagComplete(nag: Nag): boolean { @@ -490,7 +486,7 @@ watch( const actionableNagKeys = new Set( validationNags .filter((nag) => nag.severity === 'required' || nag.severity === 'warning') - .map((nag) => `${nag.severity}:${nag.kind}`), + .map((nag) => `${nag.severity}:${nag.kind}:${JSON.stringify(nag.details)}`), ) const previousNagKeys = previousActionableNagKeys const hasNewActionableNag = @@ -551,11 +547,11 @@ function getNagDescriptionSegments(nag: Nag): { text: string; isUrl: boolean }[] .map((text) => ({ text, isUrl: /^https?:\/\//i.test(text) })) } -function getFormattedMessage(message: string | MessageDescriptor): string { +function getFormattedMessage(message: string | MessageDescriptor, values?: Nag['values']): string { if (typeof message === 'string') { return message } - return formatMessage(message) + return formatMessage(message, values) } diff --git a/apps/frontend/src/components/ui/thread/ConversationThread.vue b/apps/frontend/src/components/ui/thread/ConversationThread.vue index 4204c23409..7b15a0f459 100644 --- a/apps/frontend/src/components/ui/thread/ConversationThread.vue +++ b/apps/frontend/src/components/ui/thread/ConversationThread.vue @@ -41,7 +41,7 @@ - @@ -309,7 +313,10 @@ runBlockingAction('send-to-review-reply', () => sendReply('processing', true), ), - disabled: project.status === 'processing' || isLoading, + disabled: + project.status === 'processing' || + isLoading || + reviewSubmissionDisabled, }, ] : [ @@ -338,7 +345,10 @@ hoverFilled: true, action: () => runBlockingAction('send-to-review', () => setStatus('processing')), - disabled: project.status === 'processing' || isLoading, + disabled: + project.status === 'processing' || + isLoading || + reviewSubmissionDisabled, }, ] " @@ -584,6 +594,10 @@ const messages = defineMessages({ }) const props = defineProps({ + reviewSubmissionDisabled: { + type: Boolean, + default: false, + }, thread: { type: Object, required: true, @@ -689,6 +703,7 @@ async function sendReplyFromModal(status = null, privateMessage = false) { } async function sendReply(status = null, privateMessage = false) { + if (status === 'processing' && props.reviewSubmissionDisabled) return try { const body = { body: { @@ -781,6 +796,7 @@ function openReplyModal() { } async function resubmit() { + if (props.reviewSubmissionDisabled) return if (replyWithSubmission.value) { await sendReply('processing') } else { diff --git a/apps/frontend/src/composables/link-network-validation/discord.ts b/apps/frontend/src/composables/link-network-validation/discord.ts new file mode 100644 index 0000000000..41b50b7e42 --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/discord.ts @@ -0,0 +1,54 @@ +import { type AbstractModrinthClient, ModrinthApiError } from '@modrinth/api-client' + +import { normalizeProjectUrl } from '../../helpers/project-url.ts' + +const DISCORD_API = 'https://discord.com/api' +const DISCORD_INVITE_DOMAINS = ['discord.com', 'discordapp.com'] +const DISCORD_SHORT_INVITE_DOMAIN = 'discord.gg' + +export function discordInviteCode(value: string): string | undefined { + try { + const url = new URL(normalizeProjectUrl(value)) + if (url.protocol !== 'https:' || url.username || url.password) return + const host = url.hostname.replace(/\.$/, '') + const matchesDomain = (domain: string) => host === domain || host.endsWith(`.${domain}`) + const parts = url.pathname.split('/').filter(Boolean) + const code = + matchesDomain(DISCORD_SHORT_INVITE_DOMAIN) && parts.length === 1 + ? parts[0] + : DISCORD_INVITE_DOMAINS.some(matchesDomain) && parts.length === 2 && parts[0] === 'invite' + ? parts[1] + : undefined + return code && /^[a-zA-Z0-9_-]+$/.test(code) ? code : undefined + } catch { + return undefined + } +} + +export async function checkDiscordInvite( + client: AbstractModrinthClient, + code: string, + signal: AbortSignal, +): Promise { + try { + const invite = await client.request>( + `/invites/${encodeURIComponent(code)}`, + { + api: DISCORD_API, + version: 10, + skipAuth: true, + headers: { 'Content-Type': '', Accept: 'application/json' }, + retry: false, + timeout: 5000, + signal, + }, + ) + return ( + !invite.guild || + (typeof invite.expires_at === 'string' && Date.parse(invite.expires_at) < Date.now()) + ) + } catch (error) { + if (error instanceof ModrinthApiError && error.statusCode === 404) return true + throw error + } +} diff --git a/apps/frontend/src/composables/link-network-validation/github.ts b/apps/frontend/src/composables/link-network-validation/github.ts new file mode 100644 index 0000000000..4254aef6ef --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/github.ts @@ -0,0 +1,96 @@ +import { type AbstractModrinthClient, ModrinthApiError } from '@modrinth/api-client' + +export function githubRepositoryPath(value: string, allowIssueTracker = false): string | null { + try { + const url = new URL(value) + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + !['github.com', 'www.github.com'].includes(url.hostname) + ) + return null + const parts = url.pathname.split('/').filter(Boolean) + if (parts.length !== 2 && !(allowIssueTracker && parts.length === 3 && parts[2] === 'issues')) + return null + const [owner, name] = parts + if ( + [ + 'sponsors', + 'settings', + 'orgs', + 'users', + 'topics', + 'collections', + 'marketplace', + 'features', + 'enterprise', + 'login', + 'join', + 'explore', + 'search', + 'organizations', + ].includes(owner.toLowerCase()) + ) + return null + const repository = name.replace(/\.git$/, '') + if (!/^[a-z\d-]+$/i.test(owner) || !/^[a-z\d_.-]+$/i.test(repository)) return null + return `/${owner}/${repository}` + } catch { + return null + } +} + +/** Verifies repositories and issue trackers through GitHub's public API, resolving renames. */ +export async function probeGithubRepository( + client: AbstractModrinthClient, + url: string, + path: string, + signal: AbortSignal, +): Promise<{ url: string; accessible: boolean | null }> { + const unavailable = { url, accessible: false } + try { + const originalUrl = new URL(url) + const issueTracker = originalUrl.pathname.split('/').filter(Boolean)[2] === 'issues' + const repository = await client.request | null>(path, { + api: 'https://api.github.com', + version: 'repos', + skipAuth: true, + headers: { 'Content-Type': '', Accept: 'application/vnd.github+json' }, + retry: false, + timeout: 5000, + signal, + }) + if ( + repository?.private !== false || + repository.disabled !== false || + typeof repository.html_url !== 'string' || + !githubRepositoryPath(repository.html_url) || + new URL(repository.html_url).hostname !== 'github.com' + ) + return unavailable + if (issueTracker) { + if (repository.has_issues === false) return unavailable + if (repository.has_issues !== true) return { url, accessible: null } + const destination = new URL(repository.html_url) + destination.pathname = `${destination.pathname.replace(/\/$/, '')}/issues` + destination.search = originalUrl.search + destination.hash = originalUrl.hash + return { url: destination.href, accessible: true } + } + return { url: repository.html_url, accessible: true } + } catch (error) { + signal.throwIfAborted() + return { + url, + accessible: + error instanceof ModrinthApiError && + error.statusCode !== undefined && + error.statusCode >= 400 && + error.statusCode < 500 + ? false + : null, + } + } +} diff --git a/apps/frontend/src/composables/link-network-validation/index.ts b/apps/frontend/src/composables/link-network-validation/index.ts new file mode 100644 index 0000000000..729430b0f5 --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/index.ts @@ -0,0 +1,81 @@ +import { type Labrinth, ModrinthApiError } from '@modrinth/api-client' +import { injectModrinthClient } from '@modrinth/ui' +import { useQuery, useQueryClient } from '@tanstack/vue-query' +import { useMounted } from '@vueuse/core' +import { computed, type MaybeRefOrGetter, onScopeDispose, toValue } from 'vue' + +import { getCachedLinkNags, validateCachedLinkNetwork } from './queries' +import { PROJECT_REVIEW_VALIDATION_ERROR } from './submission' +import { projectLinkTargets } from './targets' + +export function useProjectLinkValidation( + projectId: MaybeRefOrGetter, + project: MaybeRefOrGetter, + enabled: MaybeRefOrGetter, +) { + const client = injectModrinthClient() + const mounted = useMounted() + const queryClient = useQueryClient() + const saveController = new AbortController() + onScopeDispose(() => saveController.abort()) + const targets = computed(() => { + const value = toValue(project) + return value ? projectLinkTargets(value) : [] + }) + let checkedProjectId: string | undefined + let refreshNetwork = false + const query = useQuery({ + queryKey: computed(() => ['project', toValue(projectId), 'link-validation', targets.value]), + enabled: computed(() => mounted.value && toValue(enabled) && !!toValue(project)), + placeholderData: () => getCachedLinkNags(queryClient, toValue(projectId), targets.value), + queryFn: async ({ signal }) => { + const fresh = refreshNetwork || checkedProjectId !== toValue(projectId) + checkedProjectId = toValue(projectId) + refreshNetwork = false + return validateCachedLinkNetwork( + queryClient, + client, + toValue(projectId), + targets.value, + signal, + fresh, + ) + }, + staleTime: 0, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + + return { + validateSave: async (patch: Labrinth.Projects.v3.EditProjectRequest = {}) => { + const value = toValue(project) + if (!value) throw new Error(PROJECT_REVIEW_VALIDATION_ERROR) + if (value.status !== 'processing') return + const id = toValue(projectId) + const nags = await validateCachedLinkNetwork( + queryClient, + client, + id, + projectLinkTargets(value, patch), + saveController.signal, + true, + ) + if (id !== toValue(projectId)) throw new Error(PROJECT_REVIEW_VALIDATION_ERROR) + if (nags.some((nag) => nag.severity === 'required')) { + throw new ModrinthApiError(PROJECT_REVIEW_VALIDATION_ERROR, { + responseData: { details: { nags } }, + }) + } + }, + nags: computed(() => query.data.value ?? []), + isChecking: computed( + () => toValue(enabled) && (!mounted.value || query.isPending.value || query.isFetching.value), + ), + isError: query.isError, + refresh: () => { + refreshNetwork = true + return query.refetch({ cancelRefetch: false }) + }, + } +} diff --git a/apps/frontend/src/composables/link-network-validation/network.ts b/apps/frontend/src/composables/link-network-validation/network.ts new file mode 100644 index 0000000000..de5310ac6d --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/network.ts @@ -0,0 +1,76 @@ +import { type AbstractModrinthClient, type Labrinth, ModrinthApiError } from '@modrinth/api-client' + +import { checkDiscordInvite, discordInviteCode } from './discord.ts' +import { githubRepositoryPath, probeGithubRepository } from './github.ts' +import type { LinkTarget } from './targets.ts' + +type ProjectNag = Labrinth.Projects.v3.ProjectNag + +export function linkNag(target: LinkTarget, reason: string): ProjectNag { + return { + kind: 'link_validation', + severity: 'required', + details: { field: target.field, url: target.url, reason }, + } +} + +async function checkInvite( + client: AbstractModrinthClient, + target: LinkTarget, + signal: AbortSignal, +): Promise { + const code = discordInviteCode(target.url) + if (!code) return [] + try { + return (await checkDiscordInvite(client, code, signal)) + ? [linkNag(target, 'discord_invite')] + : [] + } catch (error) { + signal.throwIfAborted() + return error instanceof ModrinthApiError && + error.statusCode !== undefined && + error.statusCode >= 400 && + error.statusCode < 500 + ? [linkNag(target, 'unverifiable')] + : [] + } +} + +export async function mapConcurrent(items: T[], run: (item: T) => Promise): Promise { + let next = 0 + const results = new Array(items.length) + await Promise.all( + Array.from({ length: Math.min(8, items.length) }, async () => { + while (next < items.length) { + const index = next++ + results[index] = await run(items[index]) + } + }), + ) + return results +} + +export async function validateLinkNetwork( + client: AbstractModrinthClient, + targets: LinkTarget[], + signal: AbortSignal, +): Promise { + const deadline = AbortSignal.any([signal, AbortSignal.timeout(25_000)]) + return ( + await mapConcurrent(targets, async (target) => { + try { + deadline.throwIfAborted() + if (target.field === 'discord') { + return await checkInvite(client, target, deadline) + } + const githubRepository = !target.image && githubRepositoryPath(target.url, true) + if (!githubRepository) return [] + const observed = await probeGithubRepository(client, target.url, githubRepository, deadline) + return observed.accessible === false ? [linkNag(target, 'unverifiable')] : [] + } catch { + signal.throwIfAborted() + return [] + } + }) + ).flat() +} diff --git a/apps/frontend/src/composables/link-network-validation/queries.ts b/apps/frontend/src/composables/link-network-validation/queries.ts new file mode 100644 index 0000000000..406a5530a7 --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/queries.ts @@ -0,0 +1,56 @@ +import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client' +import type { QueryClient } from '@tanstack/vue-query' + +import { normalizeProjectUrl } from '../../helpers/project-url.ts' +import { discordInviteCode } from './discord.ts' +import { mapConcurrent, validateLinkNetwork } from './network.ts' +import type { LinkTarget } from './targets.ts' + +export function linkValue(field: string, url: string): string { + return (field === 'discord' && discordInviteCode(url)) || normalizeProjectUrl(url) +} + +function linkQueryKey(projectId: string, target: LinkTarget) { + return [ + 'project-link-validation', + projectId, + { ...target, url: linkValue(target.field, target.url) }, + ] as const +} + +export function getCachedLinkNags( + queryClient: QueryClient, + projectId: string, + targets: LinkTarget[], +): Labrinth.Projects.v3.ProjectNag[] { + return targets.flatMap((target) => { + const nags = queryClient.getQueryData( + linkQueryKey(projectId, target), + ) + return (nags ?? []).map((nag) => ({ ...nag, details: { ...nag.details, url: target.url } })) + }) +} + +export async function validateCachedLinkNetwork( + queryClient: QueryClient, + client: AbstractModrinthClient, + projectId: string, + checks: LinkTarget[], + signal: AbortSignal, + fresh = false, +): Promise { + const deadline = AbortSignal.any([signal, AbortSignal.timeout(25_000)]) + const results = await mapConcurrent(checks, async (check) => { + deadline.throwIfAborted() + const nags = await queryClient.fetchQuery({ + queryKey: linkQueryKey(projectId, check), + queryFn: ({ signal: querySignal }) => + validateLinkNetwork(client, [check], AbortSignal.any([deadline, querySignal])), + staleTime: fresh ? 0 : Infinity, + retry: false, + }) + deadline.throwIfAborted() + return nags.map((nag) => ({ ...nag, details: { ...nag.details, url: check.url } })) + }) + return results.flat() +} diff --git a/apps/frontend/src/composables/link-network-validation/submission.ts b/apps/frontend/src/composables/link-network-validation/submission.ts new file mode 100644 index 0000000000..89912785a8 --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/submission.ts @@ -0,0 +1,11 @@ +import type { Labrinth } from '@modrinth/api-client' + +export const PROJECT_REVIEW_VALIDATION_ERROR = + 'project must have no required validation nags before or while under review' + +export function canSubmitProjectForReview( + validation: Pick | null | undefined, + loading: boolean, +): boolean { + return !loading && !!validation && !validation.nags.some((nag) => nag.severity === 'required') +} diff --git a/apps/frontend/src/composables/link-network-validation/targets.ts b/apps/frontend/src/composables/link-network-validation/targets.ts new file mode 100644 index 0000000000..9772bfd275 --- /dev/null +++ b/apps/frontend/src/composables/link-network-validation/targets.ts @@ -0,0 +1,65 @@ +import type { Labrinth } from '@modrinth/api-client' +import MarkdownIt from 'markdown-it' + +export interface LinkTarget { + field: string + url: string + image: boolean +} + +const markdown = new MarkdownIt({ html: true, linkify: true }) +markdown.linkify.set({ fuzzyLink: false, fuzzyEmail: false }) + +export function projectLinkTargets( + project: Pick, + patch: Labrinth.Projects.v3.EditProjectRequest = {}, +): LinkTarget[] { + const links = { + ...Object.fromEntries( + Object.entries(project.link_urls).map(([field, link]) => [field, link.url]), + ), + ...patch.link_urls, + } + const targets: LinkTarget[] = Object.entries(links).flatMap(([field, url]) => + url ? [{ field, url, image: false }] : [], + ) + const licenseUrl = patch.license_url !== undefined ? patch.license_url : project.license.url + if (licenseUrl) { + targets.push({ field: 'license', url: licenseUrl, image: false }) + } + targets.sort((a, b) => a.field.localeCompare(b.field)) + const seen = new Set() + const addDescription = (value: string, image: boolean) => { + if (!value || value.startsWith('#')) return + try { + const url = new URL(value, 'https://modrinth.com/') + if (!['http:', 'https:'].includes(url.protocol)) return + const key = `${image}:${url.href}` + if (seen.has(key)) return + seen.add(key) + targets.push({ field: 'description', url: url.href, image }) + } catch { + return + } + } + const html = markdown.render(patch.description ?? project.description) + for (const tag of html.matchAll(/<(a|img|source|video|audio|iframe)\b[^>]*>/gi)) { + for (const attribute of tag[0].matchAll( + /\s(href|src|srcset|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, + )) { + const name = attribute[1].toLowerCase() + const value = markdown.utils.unescapeAll(attribute[2] ?? attribute[3] ?? attribute[4]) + const image = tag[1].toLowerCase() === 'img' || ['poster', 'srcset'].includes(name) + if (name === 'srcset') { + if (!value.startsWith('data:')) { + for (const candidate of value.split(',')) { + addDescription(candidate.trim().split(/\s+/)[0], image) + } + } + } else { + addDescription(value, image) + } + } + } + return targets +} diff --git a/apps/frontend/src/composables/project-nag-validation.ts b/apps/frontend/src/composables/project-nag-validation.ts index 2bf81e6edf..578d3221fb 100644 --- a/apps/frontend/src/composables/project-nag-validation.ts +++ b/apps/frontend/src/composables/project-nag-validation.ts @@ -14,8 +14,7 @@ export type ProjectSettingsField = | 'custom-license' | 'license-url' | 'external-links' - | 'source-issues-discord-links' - | 'non-discord-link-fields' + | 'link-field' | 'source-availability' | 'permissions' | 'server-region' @@ -50,14 +49,15 @@ export const projectNagFields = { ], icon: ['add-icon'], description: [ + 'link-validation', 'project-description-slur', 'project-description-profanity', 'project-description-non-standard-text', 'project-description-non-english', + 'project-description-matches-summary', 'add-description', 'description-too-short', 'project-description-spam', - 'project-description-banned-link', 'long-headers', 'description-ends-with-header', 'adjacent-headers', @@ -67,10 +67,9 @@ export const projectNagFields = { 'gallery-images': ['upload-gallery-image', 'feature-gallery-image'], license: ['select-license'], 'custom-license': ['add-custom-license-details'], - 'license-url': ['invalid-license-url'], - 'external-links': ['add-links', 'add-links-server', 'identical-links', 'banned-link-usage'], - 'source-issues-discord-links': ['verify-external-links'], - 'non-discord-link-fields': ['misused-discord-link'], + 'license-url': ['link-validation'], + 'link-field': ['link-validation'], + 'external-links': ['add-links', 'add-links-server'], 'source-availability': ['gpl-license-source-required'], permissions: ['review-permissions'], 'server-region': ['select-country'], diff --git a/apps/frontend/src/composables/project-save-validation.ts b/apps/frontend/src/composables/project-save-validation.ts new file mode 100644 index 0000000000..66ecca9716 --- /dev/null +++ b/apps/frontend/src/composables/project-save-validation.ts @@ -0,0 +1,92 @@ +import type { Labrinth } from '@modrinth/api-client' +import { normalizeProjectNagKind, toProjectFieldMessage } from '@modrinth/moderation' +import { injectProjectPageContext } from '@modrinth/ui' +import { computed, ref } from 'vue' + +import { projectNagFields, type ProjectSettingsField } from './project-nag-validation' + +function matchesField(nag: Labrinth.Projects.v3.ProjectNag, field: string, detailField = field) { + const kinds: readonly string[] | undefined = Object.hasOwn(projectNagFields, field) + ? projectNagFields[field as ProjectSettingsField] + : undefined + const kind = normalizeProjectNagKind(nag.kind) + if (kinds && (!kind || !kinds.includes(kind))) return false + if (typeof nag.details.field === 'string') return nag.details.field === detailField + if (Array.isArray(nag.details.fields)) return nag.details.fields.includes(detailField) + return kinds !== undefined +} + +/** Keeps rejected-save messages attached to the exact values that were submitted. */ +export function useProjectSaveValidation(state: () => unknown) { + const { projectV2 } = injectProjectPageContext() + const rejected = ref([]) + const rejectedState = ref('') + const showMessages = computed( + () => + projectV2.value.status === 'processing' && rejectedState.value === JSON.stringify(state()), + ) + const messages = computed(() => + showMessages.value ? rejected.value.map((nag) => toProjectFieldMessage(nag)) : [], + ) + + const hasErrors = computed(() => messages.value.some((message) => message.severity === 'error')) + + function snapshot() { + return JSON.stringify(state()) ?? '' + } + + function capture(error: unknown, submittedState: string): boolean { + if (projectV2.value.status !== 'processing') return false + let value = error + for (let depth = 0; depth < 5; depth++) { + if (typeof value !== 'object' || value === null) return false + const data = value as Record + const details = data.details + if (typeof details === 'object' && details !== null && 'nags' in details) { + const nags = details.nags + if (!Array.isArray(nags)) return false + const recognized = nags.filter( + (nag): nag is Labrinth.Projects.v3.ProjectNag => + typeof nag === 'object' && + nag !== null && + typeof nag.kind === 'string' && + normalizeProjectNagKind(nag.kind) !== null && + ['required', 'warning', 'suggestion'].includes(nag.severity) && + typeof nag.details === 'object' && + nag.details !== null, + ) + rejected.value = recognized.filter((nag) => nag.severity !== 'suggestion') + rejectedState.value = submittedState + return recognized.length > 0 + } + value = data.responseData ?? data.data ?? data.originalError ?? data.cause + } + return false + } + + function forField(field: string, detailField = field) { + if (!showMessages.value) return [] + return rejected.value + .filter((nag) => matchesField(nag, field, detailField)) + .map((nag) => toProjectFieldMessage(nag)) + } + + function withoutFields(fields: (string | [field: string, detailField: string])[]) { + if (!showMessages.value) return [] + return rejected.value + .filter( + (nag) => + !fields.some((field) => + Array.isArray(field) ? matchesField(nag, ...field) : matchesField(nag, field), + ), + ) + .map((nag) => toProjectFieldMessage(nag)) + } + + function clear() { + rejected.value = [] + rejectedState.value = '' + } + + return { capture, clear, messages, hasErrors, forField, withoutFields, snapshot } +} diff --git a/apps/frontend/src/helpers/donation-links.ts b/apps/frontend/src/helpers/donation-links.ts new file mode 100644 index 0000000000..15788a5cb7 --- /dev/null +++ b/apps/frontend/src/helpers/donation-links.ts @@ -0,0 +1,55 @@ +export const donationUsernamePrefixes: Record = { + patreon: 'https://www.patreon.com/', + bmac: 'https://buymeacoffee.com/', + paypal: 'https://www.paypal.me/', + github: 'https://github.com/sponsors/', + 'ko-fi': 'https://ko-fi.com/', +} + +export interface DonationInput { + id?: string + url: string + input: string + mode: 'username' | 'url' +} + +export function donationUsernameFromUrl( + platform: string | undefined, + raw: string, +): string | undefined { + const prefix = platform ? donationUsernamePrefixes[platform] : undefined + if (!prefix || !raw.startsWith(prefix)) return undefined + const username = raw.slice(prefix.length) + if (!username || /[/?#\s]/.test(username)) return undefined + try { + return decodeURIComponent(username) + } catch { + return undefined + } +} + +export function donationInput(id?: string, url = ''): DonationInput { + const username = donationUsernameFromUrl(id, url) + return { + id, + url, + input: username ?? url, + mode: + username !== undefined || (!url && id && donationUsernamePrefixes[id]) ? 'username' : 'url', + } +} + +export function setDonationInput(row: DonationInput, value: string | number, detectUrl = true) { + row.input = String(value) + if (detectUrl && /^https?:\/\//i.test(row.input.trim())) row.mode = 'url' + const prefix = row.id ? donationUsernamePrefixes[row.id] : undefined + row.url = + row.mode === 'username' && prefix && row.input + ? prefix + encodeURIComponent(row.input) + : row.input +} + +export function toggleDonationInput(row: DonationInput) { + row.mode = row.mode === 'username' ? 'url' : 'username' + setDonationInput(row, row.input, false) +} diff --git a/apps/frontend/src/helpers/project-url.ts b/apps/frontend/src/helpers/project-url.ts new file mode 100644 index 0000000000..c4d87e6a8c --- /dev/null +++ b/apps/frontend/src/helpers/project-url.ts @@ -0,0 +1,5 @@ +export function normalizeProjectUrl(value: string): string { + const url = value.trim() + if (!url || /^[a-z][a-z\d+.-]*:/i.test(url)) return url + return `https://${url}` +} diff --git a/apps/frontend/src/locales/de-CH/index.json b/apps/frontend/src/locales/de-CH/index.json index fd3911ebe5..29a6c93a28 100644 --- a/apps/frontend/src/locales/de-CH/index.json +++ b/apps/frontend/src/locales/de-CH/index.json @@ -4115,12 +4115,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Du hast bereits einen anderen {platform}-Link." - }, - "project.settings.links.donation.no-type": { - "message": "Bitte wählen eine Plattform für diesen Spenden-Link aus." - }, "project.settings.monetization.description": { "message": "Projekte auf Modrinth nehmen automatisch am Belohnungsprogramm teil. Falls du mit diesem Projekt keine Einnahmen erzielen möchtest (oder aus rechtlichen Gründen nicht darfst), kannst du dies hier deaktivieren." }, diff --git a/apps/frontend/src/locales/de-DE/index.json b/apps/frontend/src/locales/de-DE/index.json index 7be56beef9..36abfdcc9a 100644 --- a/apps/frontend/src/locales/de-DE/index.json +++ b/apps/frontend/src/locales/de-DE/index.json @@ -4115,12 +4115,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Du hast bereits einen anderen {platform}-Link." - }, - "project.settings.links.donation.no-type": { - "message": "Bitte wählen eine Plattform für diesen Spenden-Link aus." - }, "project.settings.monetization.description": { "message": "Projekte auf Modrinth nehmen automatisch am Belohnungsprogramm teil. Falls du mit diesem Projekt keine Einnahmen erzielen möchtest (oder aus rechtlichen Gründen nicht darfst), kannst du dies hier deaktivieren." }, diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index e87cc6c93c..c8a43b6247 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -3884,6 +3884,18 @@ "project.settings.delete-project.title": { "message": "Delete project" }, + "project.settings.description.intro": { + "message": "You can type an extended description of your project here. The description must clearly and honestly describe the purpose and function of the project. See section 2.1 of the Content Rules for the full requirements." + }, + "project.settings.description.title": { + "message": "Description" + }, + "project.settings.description.updated": { + "message": "Description updated" + }, + "project.settings.description.updated-text": { + "message": "Your description has been updated." + }, "project.settings.disclosures.advertising.description.1": { "message": "Must be enabled if this project contains advertisements, sponsorships, or promotions of other works." }, @@ -4115,11 +4127,170 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "You already have another {platform} link." + "project.settings.license.all-rights": { + "message": "All Rights Reserved/No License" + }, + "project.settings.license.allow-later": { + "message": "Allow later editions" + }, + "project.settings.license.custom": { + "message": "Custom" + }, + "project.settings.license.custom-url-description": { + "message": "The web location of the full license text. You have to provide a link since this is a custom license." + }, + "project.settings.license.has-spdx": { + "message": "Use SPDX identifier" + }, + "project.settings.license.intro": { + "message": "It is important to choose a proper license for your {type}. You may choose one from our list or provide a custom license. You may also provide a custom URL to your chosen license; otherwise, the license text will be displayed. See our licensing guide for more information." + }, + "project.settings.license.later": { + "message": "Later editions" + }, + "project.settings.license.later-description": { + "message": "The license you selected has an \"or later\" clause. If you check this box, users may use your project under later editions of the license." + }, + "project.settings.license.missing-name": { + "message": "Enter a name or SPDX identifier for your custom license." + }, + "project.settings.license.missing-url": { + "message": "Enter a URL to the full text of your custom license." + }, + "project.settings.license.name": { + "message": "License name" + }, + "project.settings.license.name-description": { + "message": "The full name of the license. If the license has a SPDX identifier, please check the checkbox and use the identifier instead." + }, + "project.settings.license.name-placeholder": { + "message": "License name" + }, + "project.settings.license.optional-url": { + "message": "License URL (optional)" + }, + "project.settings.license.select": { + "message": "Select a license" + }, + "project.settings.license.select-description": { + "message": "How users are and aren't allowed to use your project." + }, + "project.settings.license.select-placeholder": { + "message": "Select license..." + }, + "project.settings.license.spdx": { + "message": "SPDX identifier" + }, + "project.settings.license.spdx-description": { + "message": "If your license does not have an official SPDX license identifier, uncheck the box and enter the name of the license instead." + }, + "project.settings.license.spdx-placeholder": { + "message": "SPDX identifier" + }, + "project.settings.license.title": { + "message": "License" + }, + "project.settings.license.updated": { + "message": "License updated" + }, + "project.settings.license.updated-text": { + "message": "Your license has been updated." + }, + "project.settings.license.url": { + "message": "License URL" + }, + "project.settings.license.url-description": { + "message": "The web location of the full license text. If you don't provide a link, the license text will be displayed instead." + }, + "project.settings.links.add-link": { + "message": "Add link" + }, + "project.settings.links.discord": { + "message": "Discord invite" + }, + "project.settings.links.discord-description": { + "message": "An invitation link to your Discord server." + }, + "project.settings.links.donation-link": { + "message": "Link" + }, + "project.settings.links.donation-platform": { + "message": "Platform" + }, + "project.settings.links.donation-url": { + "message": "URL" + }, + "project.settings.links.donation-username": { + "message": "Username" + }, + "project.settings.links.donation-username-placeholder": { + "message": "Enter your {platform} username" + }, + "project.settings.links.donations": { + "message": "Donation links" + }, + "project.settings.links.donations-description": { + "message": "Add donation links for users to support you directly." + }, + "project.settings.links.issues": { + "message": "Issue tracker" + }, + "project.settings.links.issues-description": { + "message": "A place for users to report bugs, issues, and concerns about your project." + }, + "project.settings.links.link-type": { + "message": "Link type" + }, + "project.settings.links.remove-donation-link": { + "message": "Remove donation link" + }, + "project.settings.links.server-discord": { + "message": "Discord" + }, + "project.settings.links.server-updated": { + "message": "Your server links have been updated." + }, + "project.settings.links.server-wiki-description": { + "message": "A page containing information, documentation, and help for the server." + }, + "project.settings.links.site": { + "message": "Website" + }, + "project.settings.links.site-description": { + "message": "Your server's official website." + }, + "project.settings.links.source": { + "message": "Source code" + }, + "project.settings.links.source-description": { + "message": "A page/repository containing the source code for your project" + }, + "project.settings.links.store": { + "message": "Store" + }, + "project.settings.links.store-description": { + "message": "A link to your server's store or shop." + }, + "project.settings.links.title": { + "message": "Links" + }, + "project.settings.links.updated": { + "message": "Your links have been updated." + }, + "project.settings.links.updated-title": { + "message": "Links updated" + }, + "project.settings.links.url-placeholder": { + "message": "Enter a valid URL" + }, + "project.settings.links.visit-link": { + "message": "Visit {url}" + }, + "project.settings.links.wiki": { + "message": "Wiki page" }, - "project.settings.links.donation.no-type": { - "message": "Please select a platform for this Donation link." + "project.settings.links.wiki-description": { + "message": "A page containing information, documentation, and help for the project." }, "project.settings.monetization.description": { "message": "Projects on Modrinth are automatically enrolled in the Rewards Program. If you don't want to (or can't for legal reasons) earn revenue from this project, you can turn it off here." diff --git a/apps/frontend/src/locales/es-419/index.json b/apps/frontend/src/locales/es-419/index.json index 6f6d8a6aa8..ee483f7397 100644 --- a/apps/frontend/src/locales/es-419/index.json +++ b/apps/frontend/src/locales/es-419/index.json @@ -4115,12 +4115,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Ya tienes otro enlace de {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Por favor, seleccione una plataforma para este enlace de donación." - }, "project.settings.monetization.description": { "message": "Los proyectos en Modrinth están inscritos automáticamente en el Rewards Program. Si usted no quiere (o no puede por razones legales) obtener ingresos de este proyecto, puede desactivarlo aquí." }, diff --git a/apps/frontend/src/locales/es-ES/index.json b/apps/frontend/src/locales/es-ES/index.json index 53fd869504..8bbd2479c7 100644 --- a/apps/frontend/src/locales/es-ES/index.json +++ b/apps/frontend/src/locales/es-ES/index.json @@ -4109,12 +4109,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Ya tienes otro enlace de {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Por favor, seleccione una plataforma para este enlace de donación." - }, "project.settings.monetization.description": { "message": "Los proyectos en Modrinth están inscritos automáticamente en el Rewards Program (Programa de Recompensas). Si usted no quiere (o no puede por razones legales) obtener ingresos de este proyecto, puede desactivarlo aquí." }, diff --git a/apps/frontend/src/locales/fr-FR/index.json b/apps/frontend/src/locales/fr-FR/index.json index 3ead5573b4..f272700a21 100644 --- a/apps/frontend/src/locales/fr-FR/index.json +++ b/apps/frontend/src/locales/fr-FR/index.json @@ -4091,12 +4091,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Vous avez déjà un autre lien {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Veuillez sélectionner une plateforme pour ce lien de donation." - }, "project.settings.monetization.description": { "message": "Les projets sur Modrinth sont automatiquement inscrits au programme de récompenses. Si vous ne souhaitez pas (ou ne pouvez pas, pour des raisons légales) générer des revenus avec ce projet, vous pouvez désactiver cette option ici." }, diff --git a/apps/frontend/src/locales/hu-HU/index.json b/apps/frontend/src/locales/hu-HU/index.json index 10d4fec792..7675e9d2be 100644 --- a/apps/frontend/src/locales/hu-HU/index.json +++ b/apps/frontend/src/locales/hu-HU/index.json @@ -4103,12 +4103,6 @@ "project.settings.general.url.title": { "message": "Link" }, - "project.settings.links.donation.duplicate-type": { - "message": "Már rendelkezel egy másik {platform} hivatkozással." - }, - "project.settings.links.donation.no-type": { - "message": "Válassz egy platformot ehhez az adományozási hivatkozáshoz." - }, "project.settings.monetization.description": { "message": "A Modrinthon található projektek automatikusan részt vesznek a Jutalmazási programban. Ha nem szeretnél (vagy jogi okokból nem tudsz) bevételt szerezni ebből a projektből, itt kikapcsolhatod." }, diff --git a/apps/frontend/src/locales/it-IT/index.json b/apps/frontend/src/locales/it-IT/index.json index 2be753b97e..8e43504934 100644 --- a/apps/frontend/src/locales/it-IT/index.json +++ b/apps/frontend/src/locales/it-IT/index.json @@ -4106,12 +4106,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Hai già un link per {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Seleziona una piattaforma per il link di donazione." - }, "project.settings.monetization.description": { "message": "I progetti su Modrinth vengono automaticamente iscritti al Programma Premi. Se non vuoi guadagnare da questo progetto (o non puoi per motivi legali), puoi disattivarlo qui." }, diff --git a/apps/frontend/src/locales/nl-NL/index.json b/apps/frontend/src/locales/nl-NL/index.json index 42f3a27cb6..87a6e8da3a 100644 --- a/apps/frontend/src/locales/nl-NL/index.json +++ b/apps/frontend/src/locales/nl-NL/index.json @@ -4040,12 +4040,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Je hebt al een andere {platform} link." - }, - "project.settings.links.donation.no-type": { - "message": "Selecteer een platform voor deze donatie link." - }, "project.settings.monetization.description": { "message": "Projecten op Modrinth worden automatisch ingeschreven in het Beloningen Programma. Als je geen inkomsten wilt (of niet kunt verdienen om juridische redenen) van dit project, kun je het hier uitschakelen." }, diff --git a/apps/frontend/src/locales/pl-PL/index.json b/apps/frontend/src/locales/pl-PL/index.json index 8031abc964..d4588b0998 100644 --- a/apps/frontend/src/locales/pl-PL/index.json +++ b/apps/frontend/src/locales/pl-PL/index.json @@ -4058,12 +4058,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Masz już inny link do platformy {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Wybierz platformę dla tego linku do darowizn." - }, "project.settings.monetization.description": { "message": "Projekty na Modrinth są automatycznie przyjęte w Programie Nagród. Jeśli ty nie chcesz (lub nie możesz z powodów prawnych) zarabiać z tego projektu, możesz wyłączyć to tutaj." }, diff --git a/apps/frontend/src/locales/pt-BR/index.json b/apps/frontend/src/locales/pt-BR/index.json index eaba876186..0d180bc1a8 100644 --- a/apps/frontend/src/locales/pt-BR/index.json +++ b/apps/frontend/src/locales/pt-BR/index.json @@ -4115,12 +4115,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Você já possui outro link de {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Por favor, selecione uma plataforma para este link de Doação." - }, "project.settings.monetization.description": { "message": "Projetos do Modrinth são automaticamente inscritos no Programa de Recompensas. Se você não quer (ou não pode por motivos legais) lucrar com o projeto, pode desativar aqui." }, diff --git a/apps/frontend/src/locales/ru-RU/index.json b/apps/frontend/src/locales/ru-RU/index.json index 57cea928cf..3ef8c879fb 100644 --- a/apps/frontend/src/locales/ru-RU/index.json +++ b/apps/frontend/src/locales/ru-RU/index.json @@ -4106,12 +4106,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Ссылка на {platform} повторяется." - }, - "project.settings.links.donation.no-type": { - "message": "Выберите платформу для этой ссылки." - }, "project.settings.monetization.description": { "message": "Проекты на Modrinth автоматически зачисляются в программу наград. Если вы не хотите (или не можете по юридическим причинам) получать доход от этого проекта, вы можете отключить его здесь." }, diff --git a/apps/frontend/src/locales/tr-TR/index.json b/apps/frontend/src/locales/tr-TR/index.json index 4c73767f8c..c84fe1d7ea 100644 --- a/apps/frontend/src/locales/tr-TR/index.json +++ b/apps/frontend/src/locales/tr-TR/index.json @@ -4043,12 +4043,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Zaten başka bir {platform} bağlantınız var." - }, - "project.settings.links.donation.no-type": { - "message": "Lütfen bu bağış bağlantısı için bir platform seçin." - }, "project.settings.monetization.description": { "message": "Modrinth’teki projeler otomatik olarak Ödül Programı’na kaydedilir. Bu projeden gelir elde etmek istemiyorsanız (veya yasal nedenlerle edemiyorsanız), buradan kapatabilirsiniz." }, diff --git a/apps/frontend/src/locales/uk-UA/index.json b/apps/frontend/src/locales/uk-UA/index.json index 07683debfd..c60e09aeb7 100644 --- a/apps/frontend/src/locales/uk-UA/index.json +++ b/apps/frontend/src/locales/uk-UA/index.json @@ -4112,12 +4112,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "Ви вже маєте ще одне посилання {platform}." - }, - "project.settings.links.donation.no-type": { - "message": "Будь ласка, оберіть платформу для цього посилання для пожертви." - }, "project.settings.monetization.description": { "message": "Проєкти на Modrinth автоматично реєструються в Програмі винагород. Якщо ви не хочете (або не можете з юридичних причин) отримувати дохід від цього проєкту, ви можете вимкнути його тут." }, diff --git a/apps/frontend/src/locales/zh-CN/index.json b/apps/frontend/src/locales/zh-CN/index.json index b75ed71c4f..28c15bbf80 100644 --- a/apps/frontend/src/locales/zh-CN/index.json +++ b/apps/frontend/src/locales/zh-CN/index.json @@ -4115,12 +4115,6 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "你已经有了一个{platform}链接了。" - }, - "project.settings.links.donation.no-type": { - "message": "请为此捐赠链接选择一个平台。" - }, "project.settings.monetization.description": { "message": "Modrinth上的项目会自动加入激励计划。如果你不想(或由于法律原因不能)从这个项目赚取收入,你可以在这里关闭它。" }, diff --git a/apps/frontend/src/locales/zh-TW/index.json b/apps/frontend/src/locales/zh-TW/index.json index 44496a4464..1f88c74435 100644 --- a/apps/frontend/src/locales/zh-TW/index.json +++ b/apps/frontend/src/locales/zh-TW/index.json @@ -4115,12 +4115,6 @@ "project.settings.general.url.title": { "message": "網址" }, - "project.settings.links.donation.duplicate-type": { - "message": "你已經有一個 {platform} 連結了。" - }, - "project.settings.links.donation.no-type": { - "message": "請為這個贊助連結選擇平台。" - }, "project.settings.monetization.description": { "message": "Modrinth 上的專案會自動加入獎勵計畫。如果你不想(或因法律原因無法)從專案獲取收益,可在這裡關閉營利功能。" }, diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue index 6d83e1152b..cced125756 100644 --- a/apps/frontend/src/pages/[type]/[project].vue +++ b/apps/frontend/src/pages/[type]/[project].vue @@ -155,11 +155,10 @@ :route-name="route.name" :tags="tags" :validation-nags="projectValidation?.nags ?? []" - :validation-loading="projectValidationLoading" + :validation-loading="reviewSubmissionLoading" :validation-available="projectValidation !== null" - :refresh-validation="refreshProjectValidation" + :submit-project="setProcessing" @toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)" - @set-processing="setProcessing" /> { + onMutate: async ({ projectId, data, optimistic = true }) => { + await linkValidation.validateSave({ + description: data.body, + license_url: data.license_url, + link_urls: Object.fromEntries( + ['issues', 'source', 'wiki', 'discord'] + .filter((field) => data[`${field}_url`] !== undefined) + .map((field) => [field, data[`${field}_url`]]), + ), + }) + if (!optimistic) return await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] }) @@ -1489,7 +1502,9 @@ const patchProjectV3Mutation = useMutation({ return data }, - onMutate: async ({ projectId, data }) => { + onMutate: async ({ projectId, data, optimistic = true }) => { + await linkValidation.validateSave(data) + if (!optimistic) return await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] }) await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) @@ -1516,8 +1531,8 @@ const patchProjectV3Mutation = useMutation({ addProjectMutationErrorNotification(err) }, - onSettled: () => { - void invalidateProject() + onSettled: async () => { + await invalidateProject() }, }) @@ -1737,6 +1752,7 @@ const currentMember = computed(() => { const { data: projectValidationResponse, isFetching: projectValidationLoading, + isError: backendValidationError, refetch: refetchProjectValidation, } = useQuery({ queryKey: computed(() => ['project', projectId.value, 'validation', 'v3']), @@ -1745,11 +1761,42 @@ const { enabled: computed(() => !!projectId.value && !!currentMember.value?.accepted), }) -const projectValidation = computed(() => projectValidationResponse.value ?? null) +const linkValidation = useProjectLinkValidation( + projectId, + projectV3, + () => !!currentMember.value?.accepted, +) +const projectLinksNetworkValidationLoading = linkValidation.isChecking +const reviewSubmissionPending = ref(false) +const reviewSubmissionLoading = computed( + () => + projectValidationLoading.value || + projectLinksNetworkValidationLoading.value || + reviewSubmissionPending.value, +) +const projectValidation = computed(() => { + const validation = projectValidationResponse.value + if (!validation || backendValidationError.value || linkValidation.isError.value) return null + return { ...validation, nags: [...validation.nags, ...linkValidation.nags.value] } +}) async function refreshProjectValidation() { - const result = await refetchProjectValidation() - return result.data ?? null + const projectIdAtStart = projectId.value + const [result, network] = await Promise.all([ + refetchProjectValidation({ cancelRefetch: false }), + linkValidation.refresh(), + ]) + await nextTick() + if ( + !result.isSuccess || + !network.isSuccess || + projectId.value !== projectIdAtStart || + projectValidationLoading.value || + projectLinksNetworkValidationLoading.value + ) { + return null + } + return projectValidation.value } const canAccessSettings = computed(() => !!currentMember.value?.accepted) @@ -2154,26 +2201,37 @@ watch( ) async function setProcessing() { - // Guard against multiple submissions while mutation is pending - if (patchStatusMutation.isPending.value) return - + if ( + patchStatusMutation.isPending.value || + !canSubmitProjectForReview(projectValidation.value, reviewSubmissionLoading.value) + ) { + return false + } + reviewSubmissionPending.value = true startLoading() - patchStatusMutation.mutate( - { + try { + const validation = await refreshProjectValidation() + if (!canSubmitProjectForReview(validation, false)) return false + await patchStatusMutation.mutateAsync({ projectId: project.value.id, status: 'processing', threadId: project.value.thread_id, - }, - { onSettled: () => stopLoading() }, - ) + }) + return true + } catch { + return false + } finally { + reviewSubmissionPending.value = false + stopLoading() + } } -async function patchProject(resData, quiet = false) { +async function patchProject(resData, quiet = false, throwOnError = false) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { patchProjectMutation.mutate( - { projectId: project.value.id, data: resData }, + { projectId: project.value.id, data: resData, optimistic: !throwOnError }, { onSuccess: async () => { if (!quiet) { @@ -2185,19 +2243,19 @@ async function patchProject(resData, quiet = false) { } resolve(true) }, - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) }) } -async function patchProjectV3(resData, quiet = false) { +async function patchProjectV3(resData, quiet = false, throwOnError = false) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { patchProjectV3Mutation.mutate( - { projectId: project.value.id, data: resData }, + { projectId: project.value.id, data: resData, optimistic: !throwOnError }, { onSuccess: async () => { if (!quiet) { @@ -2209,7 +2267,7 @@ async function patchProjectV3(resData, quiet = false) { } resolve(true) }, - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) @@ -2231,30 +2289,58 @@ async function patchIcon(icon) { }) } -async function createGalleryItem(file, title, description, featured, ordering) { +async function createGalleryItem( + file, + title, + description, + featured, + ordering, + throwOnError = false, +) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { createGalleryItemMutation.mutate( - { projectId: project.value.id, file, title, description, featured, ordering }, + { + projectId: project.value.id, + file, + title, + description, + featured, + ordering, + }, { onSuccess: () => resolve(true), - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) }) } -async function editGalleryItem(imageUrl, title, description, featured, ordering) { +async function editGalleryItem( + imageUrl, + title, + description, + featured, + ordering, + throwOnError = false, +) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { editGalleryItemMutation.mutate( - { projectId: project.value.id, imageUrl, title, description, featured, ordering }, + { + projectId: project.value.id, + imageUrl, + title, + description, + featured, + ordering, + }, { onSuccess: () => resolve(true), - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) @@ -2466,6 +2552,7 @@ provideProjectPageContext({ organization, projectValidation, projectValidationLoading, + projectLinksNetworkValidationLoading, // Lazy version loading versions, versionsLoading, diff --git a/apps/frontend/src/pages/[type]/[project]/moderation.vue b/apps/frontend/src/pages/[type]/[project]/moderation.vue index ed80d87a33..8ab9a23e78 100644 --- a/apps/frontend/src/pages/[type]/[project]/moderation.vue +++ b/apps/frontend/src/pages/[type]/[project]/moderation.vue @@ -113,6 +113,7 @@ :thread="prefixedThread" :project="project" :set-status="setStatus" + :review-submission-disabled="reviewSubmissionDisabled" :current-member="currentMember ?? undefined" :auth="auth" class="overflow-clip rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2" @@ -155,6 +156,7 @@ import dayjs from 'dayjs' import { computed, watch } from 'vue' import ConversationThread from '~/components/ui/thread/ConversationThread.vue' +import { canSubmitProjectForReview } from '~/composables/link-network-validation/submission' import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js' defineEmits(['on-download', 'delete-version']) @@ -212,12 +214,24 @@ const messages = defineMessages({ const { addNotification } = injectNotificationManager() const { projectV2: project, + projectValidation, + projectValidationLoading, + projectLinksNetworkValidationLoading, + setProcessing, currentMember, invalidate, allMembers, thread, } = injectProjectPageContext() +const reviewSubmissionDisabled = computed( + () => + !canSubmitProjectForReview( + projectValidation.value, + projectValidationLoading.value || projectLinksNetworkValidationLoading.value, + ), +) + const THREADS_RELEASE_DATE = '2023-08-05T12:00:00-07:00' const prefixedThread = computed(() => { @@ -477,6 +491,10 @@ function updateThread(newThread: Labrinth.Threads.v3.Thread | null | undefined) } async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) { + if (status === 'processing') { + await setProcessing() + return + } startLoading() try { diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue index 9f5c4bafba..0f684160da 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings.vue @@ -38,7 +38,7 @@ const { setProcessing, projectValidation, projectValidationLoading, - refreshProjectValidation, + projectLinksNetworkValidationLoading, } = injectProjectPageContext() const flags = useFeatureFlags() @@ -180,11 +180,10 @@ const moderatorSeeUserUi = computed({ :route-name="route.name as string" :tags="tags" :validation-nags="projectValidation?.nags ?? []" - :validation-loading="projectValidationLoading" + :validation-loading="projectValidationLoading || projectLinksNetworkValidationLoading" :validation-available="projectValidation !== null" - :refresh-validation="refreshProjectValidation" + :submit-project="setProcessing" @toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)" - @set-processing="setProcessing" />
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue index 147d7c6ded..36e912d523 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue @@ -4,20 +4,20 @@
-

Description

+

{{ formatMessage(messages.title) }}

- You can type an extended description of your project here. - - The description must clearly and honestly describe the purpose and function of the - project. See section 2.1 of the - Content Rules - for the full requirements. - + + +
+
Content Rules for the full requirements.', + }, + updated: { id: 'project.settings.description.updated', defaultMessage: 'Description updated' }, + updatedText: { + id: 'project.settings.description.updated-text', + defaultMessage: 'Your description has been updated.', + }, +}) const aiImageWarningModal = useTemplateRef('aiImageWarningModal') useProjectSettingsHeadTitle(commonProjectSettingsMessages.description) @@ -72,7 +93,7 @@ const { } = useSavable( () => ({ description: project.value.body }), async ({ description }) => { - await patchProject({ body: description }) + await patchProjectV3({ description }, true, true) }, ) @@ -86,12 +107,28 @@ const hasPermission = computed( (currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) === TeamMemberPermission.EDIT_BODY), ) -const descriptionValidation = useProjectNagMessages('description') -const canSave = computed(() => hasPermission.value) +const descriptionValidation = useProjectNagMessages('description', 'description') +const saveValidation = useProjectSaveValidation(() => current.value) +const canSave = computed( + () => + hasPermission.value && + !saveValidation.messages.value.some((message) => message.severity === 'error'), +) async function save() { - if (!canSave.value) return - await saveForm() + if (!canSave.value || saving.value) return + const submittedState = saveValidation.snapshot() + try { + await saveForm() + saveValidation.clear() + addNotification({ + title: formatMessage(messages.updated), + text: formatMessage(messages.updatedText), + type: 'success', + }) + } catch (error) { + saveValidation.capture(error, submittedState) + } } async function onUploadHandler(file: File) { diff --git a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue index 86853fa80d..cf0e3d20ba 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue @@ -42,6 +42,7 @@ import { import ValidationMessage from '~/components/ValidationMessage.vue' import { useAuth } from '~/composables/auth' import { useProjectNagMessages } from '~/composables/project-nag-validation' +import { useProjectSaveValidation } from '~/composables/project-save-validation' const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5 @@ -195,7 +196,7 @@ const { saved, current, saving, - reset, + reset: resetForm, save: saveForm, } = useSavable( () => disclosuresToForm(disclosuresResponse.value?.disclosures ?? []), @@ -221,10 +222,23 @@ const hasChanges = computed( () => JSON.stringify(savedSnapshot.value) !== JSON.stringify(currentSnapshot.value), ) +const saveValidation = useProjectSaveValidation(() => currentSnapshot.value) + async function save() { - if (!hasChanges.value) return - await saveForm() - await refreshProjectValidation() + if (!hasChanges.value || !canSave.value || saving.value) return + const submittedState = saveValidation.snapshot() + try { + await saveForm() + saveValidation.clear() + await refreshProjectValidation() + } catch (error) { + if (!saveValidation.capture(error, submittedState)) throw error + } +} + +function reset() { + resetForm() + saveValidation.clear() } function disclosureUpdateProps(type: DisclosureType) { @@ -268,7 +282,10 @@ const disclosureTextValidation = useProjectNagMessages('disclosure-text') const disclosureValidation = useProjectNagMessages('disclosures') const canSave = computed( - () => hasPermission.value && (isAdminUser.value || issues.value.length === 0), + () => + !saveValidation.hasErrors.value && + hasPermission.value && + (isAdminUser.value || issues.value.length === 0), ) const saveDisabledReason = computed(() => { @@ -398,6 +415,7 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges) " />
+
-