feat: new modpack permissions system (#6005)

* Begin external projects moderator database frontend

* add copy link button

* begin project page permissions settings

* MEL database backend routes

* include filename in external files

* wip: when uploading a version file, fetch its overrides as a list

* wip: override license checks

* improve FileHost ref counting

* file host read capability

* scan files when inserting version file

* add dependency sha1 field

* clean up version files

* wip: attributions

* update s3 file host

* attribution scanning basic works

* works

* insert attribution info after resolving

* add routes

* remove dep sha1 stuff

* prepr

* wip: override file sources

* add files_missing_attributions to versions

* return extended version info + attributed at/by

* hook up frontend to backend (mostly)

* expose version date published

* withholding version visibility

* frontend work

* prepr

* use api-client for img upload

* moar frontend

* prepr

* Add schema to attribution resolution and Flame project results

* sqlx prepare

* changes

* remove feature flag, fix optional proof images

* fix schema

* fmt

* fix deletion and file fetch

* prepare

* fix admonition

* update frontend stuff to new schema

* prepr

* attribution on dependencies

* fixes

* sqlx prepare

* fixes

* routes

* fix routes

* Version grandfathering

* prepare

* wip: bulk routes

* pushing what i've got rn

* include link in NoPermission

* change hash insert to bulk route

* query flame even if entry in MEL

* delete file with weird name

* Prioritise putting override files in existing groups even with ExternalLicense

* fix how hex bytes are handled in route

* feat: coolbot moderation changes (#6215)

* Update moderator checklist

* move permissions stage order

* Updated nagContext.versions to v3, added nag for permissions

* Update permissions.vue default messages

* prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>

* QA

* prepr

* should group by project

* return attribution resolution correctly

* updated by moderator info

* Track what moderator reviewed an attribution moderation status

* default deser FMA field

* new version page

* clean up fetching + add a couple missing features

* qa items

* prepr

* provide moderation package stuff with DI

* format?

* don't redact moderated_at

* move supplementary resources

* Reorganize moderation messages.

* Quick replies for external content permissions.

* prepare

* QA

* allow exempting projects

* Ignore Flame projects which 404

* fix ci

* fix cross project attribution stuff

* Fix permission error

* change what files get cscanned

* add more logging

* QA Jun 22

* fix

* idempotency

* Expose route for rescanning

* update blog link

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
Prospector
2026-06-23 21:27:51 +02:00
committed by GitHub
co-authored by coolbot100s aecsocket aecsocket
parent a686a93858
commit e7926083fb
315 changed files with 11106 additions and 2209 deletions
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { VersionChannel } from '@modrinth/utils'
import { computed } from 'vue'
import { commonMessages } from '#ui/utils/common-messages.ts'
import { useVIntl } from '../../composables/i18n'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
channel: VersionChannel
}>()
const RELEASE_CHANNELS = {
release: {
message: commonMessages.release,
bgColor: 'var(--color-green-bg)',
color: 'var(--color-green)',
},
beta: {
message: commonMessages.beta,
bgColor: 'var(--color-orange-bg)',
color: 'var(--color-orange)',
},
alpha: {
message: commonMessages.alpha,
bgColor: 'var(--color-red-bg)',
color: 'var(--color-red)',
},
}
const releaseChannel = computed(() => RELEASE_CHANNELS[props.channel])
</script>
<template>
<TagItem
class="py-1.5"
:style="{ '--_bg-color': releaseChannel.bgColor, '--_color': releaseChannel.color }"
>
{{ formatMessage(releaseChannel.message) }}
</TagItem>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '#ui/composables/i18n.ts'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import type { DependencyContext } from './index.ts'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
context: DependencyContext
dependencyLink?: string
linkTabbable?: boolean
}>(),
{
dependencyLink: undefined,
linkTabbable: false,
},
)
const messages = defineMessages({
anyVersion: {
id: 'version.section.dependencies.any-version',
defaultMessage: 'Any version',
},
anyCompatibleVersion: {
id: 'version.section.dependencies.any-compatible-version',
defaultMessage: 'Any compatible version',
},
unavailableVersion: {
id: 'version.section.dependencies.unavailable-version',
defaultMessage: 'Unavailable version',
},
unavailableVersionDescription: {
id: 'version.section.dependencies.unavailable-version.description',
defaultMessage: 'This version may have been deleted or is being reviewed by moderators.',
},
unavailableRequiredVersionDescription: {
id: 'version.section.dependencies.unavailable-version.description.required',
defaultMessage:
'The required version may have been deleted or is being reviewed by moderators.',
},
})
</script>
<template>
<div class="flex gap-3 items-center">
<AutoLink
:to="dependencyLink"
class="flex gap-3 items-center group"
:tabindex="linkTabbable ? undefined : '-1'"
>
<Avatar :src="context.project?.icon_url" alt="" size="48px" no-shadow />
<div class="flex flex-col gap-1">
<span
class="text-contrast font-medium"
:class="{ 'group-hover:underline': !!dependencyLink }"
>{{ context.project?.title ?? context.dependency.file_name }}</span
>
<span class="text-secondary flex items-center gap-1">
<template v-if="!context.dependency.version_id">
{{
formatMessage(
context.dependency.dependency_type === 'incompatible'
? messages.anyVersion
: messages.anyCompatibleVersion,
)
}}
</template>
<template v-else-if="context.version">
{{ context.version.version_number }}
</template>
<template v-else>
{{ formatMessage(messages.unavailableVersion) }}
<InfoIcon
v-tooltip="
formatMessage(
context.dependency.dependency_type === 'required'
? messages.unavailableRequiredVersionDescription
: messages.unavailableVersionDescription,
)
"
class="shrink-0"
/>
</template>
</span>
</div>
</AutoLink>
<div class="flex items-center gap-2 ml-auto">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,491 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, FileIcon, SearchIcon } from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useFormatBytes } from '#ui/composables/format-bytes.ts'
import { useFormatDateTime } from '#ui/composables/format-date-time.ts'
import { useCompactNumber, useFormatNumber } from '#ui/composables/format-number.ts'
import { useRelativeTime } from '#ui/composables/how-ago.ts'
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n.ts'
import { injectModrinthClient } from '#ui/providers/api-client.ts'
import {
commonMessages,
fileTypeMessages,
projectCompatibilityMessages,
} from '#ui/utils/common-messages.ts'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import StyledInput from '../base/StyledInput.vue'
import Table from '../base/Table.vue'
import TagItem from '../base/TagItem.vue'
import TagTagItem from '../base/TagTagItem.vue'
import EnvironmentTags from '../project/EnvironmentTags.vue'
import VersionChannelTag from './VersionChannelTag.vue'
import VersionDependencyItem from './VersionDependencyItem.vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const formatNumber = useFormatNumber()
const { formatCompactNumber } = useCompactNumber()
const props = defineProps<{
version: Labrinth.Versions.v3.Version
enrichment?: Labrinth.Projects.v2.DependencyInfo
dependencyLinkCreator: (context: DependencyContext) => string | undefined
members?: Labrinth.Projects.v3.TeamMember[]
userLinkCreator?: (user: Labrinth.Users.v3.User) => string | undefined
}>()
const api = injectModrinthClient()
const versionNumber = computed(() => props.version.version_number)
const versionSubtitle = computed(() => props.version.name)
const publishDate = computed(() => formatRelativeTime(props.version.date_published))
const publishDateTooltip = computed(() => formatDateTime(props.version.date_published))
const isModpack = computed(() => props.version.loaders.includes('mrpack'))
const platforms = computed(() =>
isModpack.value ? props.version.mrpack_loaders : props.version.loaders,
)
const noModpackLoader = computed(
() =>
(isModpack.value &&
props.version.mrpack_loaders?.length === 1 &&
props.version.mrpack_loaders?.[0] === 'minecraft') ||
props.version.mrpack_loaders?.length === 0,
)
const primaryFile = computed(
() => props.version.files?.find((file) => file.primary) ?? props.version.files?.[0] ?? {},
)
const promotedSupplementaryFiles = computed(() =>
props.version.files.filter(
(file) =>
file.file_type &&
['required-resource-pack', 'optional-resource-pack'].includes(file.file_type),
),
)
type DependencyContext = {
dependency: Labrinth.Versions.v3.Dependency
project?: Labrinth.Projects.v2.Project
version?: Labrinth.Versions.v2.Version
}
const dependencies = computed<DependencyContext[]>(() =>
props.version.dependencies.map(
(dep) =>
({
dependency: dep,
project: props.enrichment?.projects.find((x) => x.id === dep.project_id),
version: props.enrichment?.versions.find((x) => x.id === dep.version_id),
}) satisfies DependencyContext,
),
)
const requiredContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'required'),
)
const optionalContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'optional'),
)
const incompatibleContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'incompatible'),
)
const includedContent = computed(() =>
dependencies.value
.filter((context) => context.dependency.dependency_type === 'embedded')
.map((context) => ({
icon_url: context.project
? context.project.icon_url
: context.dependency.attribution?.icon_url,
name: context.project ? context.project.title : context.dependency.file_name,
version: context.version ? context.version.version_number : undefined,
link: context.project
? props.dependencyLinkCreator(context)
: context.dependency.attribution?.link,
hasProject: !!context.project,
})),
)
const contentTableColumns = computed(() => {
const cols = [
{
key: 'icon',
width: '3.75rem',
},
{
key: 'name',
label: formatMessage(defineMessage({ id: 'version.content.name', defaultMessage: 'Name' })),
},
]
if (includedContent.value.some((x) => x.version)) {
cols.push({
key: 'version',
label: formatMessage(
defineMessage({ id: 'version.content.version', defaultMessage: 'Version' }),
),
})
}
return cols
})
const supplementaryResourcesTableColumns = computed(() => [
{
key: 'file',
label: formatMessage(
defineMessage({ id: 'version.supplementary-resources.file', defaultMessage: 'File' }),
),
width: '20rem',
},
{
key: 'type',
label: formatMessage(
defineMessage({ id: 'version.supplementary-resources.type', defaultMessage: 'Type' }),
),
width: '14rem',
},
{
key: 'size',
label: formatMessage(
defineMessage({ id: 'version.supplementary-resources.size', defaultMessage: 'Size' }),
),
width: '5rem',
},
{
key: 'actions',
label: '',
width: '14rem',
},
])
const supplementaryResources = computed(() =>
props.version.files
.filter((file) => file.primary === false && file !== primaryFile.value)
.map((file) => ({
file: file,
})),
)
const messages = defineMessages({
compatibility: {
id: 'version.section.compatibility',
defaultMessage: 'Compatibility',
},
required: {
id: 'version.section.required-content',
defaultMessage: 'Required content',
},
optionalDeps: {
id: 'version.section.optional-dependencies',
defaultMessage: 'Optional dependencies',
},
knownIncompatibilities: {
id: 'version.section.known-incompatibilities',
defaultMessage: 'Known incompatibilities',
},
content: {
id: 'version.section.content',
defaultMessage: 'Content',
},
includedContent: {
id: 'version.section.included-content',
defaultMessage: 'Included content',
},
noModpackLoader: {
id: 'version.section.no-modpack-mod-loader',
defaultMessage: 'No mod loader',
},
changes: {
id: 'version.section.changes',
defaultMessage: 'Changes',
},
noChanges: {
id: 'version.section.no-changes',
defaultMessage: 'No changelog was provided.',
},
files: {
id: 'version.section.files',
defaultMessage: 'Files',
},
supplementaryResources: {
id: 'version.section.supplementary-resources',
defaultMessage: 'Supplementary resources',
},
searchContent: {
id: 'version.section.content.search-placeholder',
defaultMessage: 'Search content...',
},
})
const contentSearchQuery = ref('')
const formattedDownloads = computed(() => formatNumber(props.version.downloads))
const compactDownloads = computed(() => formatCompactNumber(props.version.downloads))
const authorMember = computed(
() => props.members?.find((member) => member.user.id === props.version.author_id)?.user,
)
const { data: externalAuthor, isLoading: loadingAuthor } = useQuery({
queryKey: ['user', props.version.author_id],
queryFn: () => api.labrinth.users_v3.get(props.version.author_id),
enabled: computed(() => !authorMember.value),
})
const author = computed(() => authorMember.value ?? externalAuthor.value)
const authorLink = computed(() =>
author.value ? props.userLinkCreator?.(author.value) : undefined,
)
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-4 justify-between items-center">
<div class="flex flex-col gap-1.5">
<div class="flex flex-wrap gap-2 items-center">
<h2 class="m-0 leading-tight font-semibold">{{ versionNumber }}</h2>
<div>
<VersionChannelTag :channel="version.version_type" />
</div>
</div>
<div class="flex items-center gap-2 flex-col sm:flex-row">
<span>{{ versionSubtitle }}</span>
<span class="bg-surface-5 size-1.5 rounded-full hidden sm:block" />
<span class="flex items-center gap-2 sm:content">
<span v-tooltip="publishDateTooltip">{{ publishDate }}</span>
<span class="bg-surface-5 size-1.5 rounded-full" />
<span
v-tooltip="
compactDownloads !== formattedDownloads
? capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: props.version.downloads,
}),
)
: undefined
"
class="flex items-center gap-1"
>
<DownloadIcon class="size-5" /> {{ compactDownloads }}
</span>
</span>
</div>
<div
v-if="(author && authorLink) || loadingAuthor"
class="flex items-center gap-1 text-secondary"
>
Uploaded by
<AutoLink
v-if="author && authorLink"
:to="authorLink"
class="flex items-center gap-1 hover:underline"
>
<Avatar :src="author?.avatar_url" size="1.5rem" circle />
{{ author?.username }}
</AutoLink>
<div
v-else-if="loadingAuthor"
class="w-32 h-6 bg-surface-3 rounded-md animate-pulse flex"
></div>
</div>
</div>
<div class="flex gap-2 flex-wrap items-center">
<slot
name="headerActions"
:primary-file="primaryFile"
:promoted-files="promotedSupplementaryFiles"
/>
</div>
</div>
<hr class="w-full border-none h-[1px] bg-surface-4 m-0" />
<section v-if="requiredContent.length > 0" id="dependencies">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.required) }}</h3>
<div class="grid md:grid-cols-2 gap-4">
<VersionDependencyItem
v-for="depContext in requiredContent"
:key="`required-dep-${depContext.dependency.version_id ?? depContext.dependency.project_id ?? depContext.dependency.file_name}`"
:context="depContext"
:dependency-link="dependencyLinkCreator(depContext)"
target="_blank"
class="bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl"
>
<slot name="dependencyActions" :dependency="depContext" />
</VersionDependencyItem>
</div>
</section>
<section id="compatibility">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.compatibility) }}</h3>
<div
class="grid gap-3 md:gap-4 bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl md:p-0 md:border-0 md:bg-transparent"
:class="version.environment ? 'md:grid-cols-3' : 'md:grid-cols-2'"
>
<div
class="md:bg-surface-3 md:border md:border-solid md:border-surface-4 md:p-4 md:rounded-2xl"
>
{{ formatMessage(projectCompatibilityMessages.minecraftJava) }}
<div class="flex gap-1 flex-wrap mt-2">
<TagItem
v-for="gameVersion in version.game_versions"
:key="`version-compat-game-version-${gameVersion}`"
>{{ gameVersion }}</TagItem
>
</div>
</div>
<div
class="md:bg-surface-3 md:border md:border-solid md:border-surface-4 md:p-4 md:rounded-2xl"
>
{{
formatMessage(projectCompatibilityMessages.platformsPlural, {
count: platforms?.length ?? 0,
})
}}
<div class="flex gap-1 flex-wrap mt-2">
<template v-if="isModpack && noModpackLoader">
<TagItem class="border !border-solid border-surface-5 hover:no-underline">
{{ formatMessage(messages.noModpackLoader) }}
</TagItem>
</template>
<template v-else>
<TagTagItem
v-for="platform in platforms"
:key="`version-compat-platform-${platform}`"
:tag="platform"
/>
</template>
</div>
</div>
<div
v-if="version.environment"
class="md:bg-surface-3 md:border md:border-solid md:border-surface-4 md:p-4 md:rounded-2xl"
>
{{ formatMessage(projectCompatibilityMessages.environments) }}
<div class="flex gap-1 flex-wrap mt-2">
<EnvironmentTags :environment="version.environment" />
</div>
</div>
</div>
</section>
<section id="changes">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.changes) }}</h3>
<div class="p-4 bg-surface-3 rounded-2xl flex border-solid border border-surface-4">
<div
v-if="version.changelog"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
<div v-else class="text-secondary">{{ formatMessage(messages.noChanges) }}</div>
</div>
</section>
<section v-if="optionalContent.length > 0" id="optional-dependencies">
<h3 class="mt-0 mb-2 text-lg font-semibold">{{ formatMessage(messages.optionalDeps) }}</h3>
<div class="grid md:grid-cols-2 gap-4">
<VersionDependencyItem
v-for="depContext in optionalContent"
:key="`optional-dep-${depContext.dependency.version_id ?? depContext.dependency.project_id ?? depContext.dependency.file_name}`"
:context="depContext"
:dependency-link="dependencyLinkCreator(depContext)"
target="_blank"
class="bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl"
>
<slot name="dependencyActions" :dependency="depContext" />
</VersionDependencyItem>
</div>
</section>
<section v-if="incompatibleContent.length > 0" id="known-incompatibilities">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{ formatMessage(messages.knownIncompatibilities) }}
</h3>
<div class="grid md:grid-cols-2 gap-4">
<VersionDependencyItem
v-for="depContext in incompatibleContent"
:key="`incompatible-dep-${depContext.dependency.version_id ?? depContext.dependency.project_id ?? depContext.dependency.file_name}`"
:context="depContext"
:dependency-link="dependencyLinkCreator(depContext)"
target="_blank"
class="bg-surface-3 border border-solid border-surface-4 p-4 rounded-2xl"
/>
</div>
</section>
<section v-if="includedContent?.length > 0" id="content">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{
formatMessage(
version.loaders.includes('mrpack') ? messages.content : messages.includedContent,
)
}}
</h3>
<Table :columns="contentTableColumns" :data="includedContent">
<template #header-actions>
<StyledInput
v-model="contentSearchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchContent)"
clearable
wrapper-class="w-full sm:w-64"
/>
</template>
<template #cell-icon="{ row }">
<AutoLink :to="row.link" tabindex="-1" class="flex" target="_blank">
<Avatar v-if="row.icon_url" :src="row.icon_url" alt="" size="2rem" />
<div v-else class="size-[2rem] flex items-center justify-center">
<FileIcon class="size-5 text-secondary" />
</div>
</AutoLink>
</template>
<template #cell-name="{ row }">
<AutoLink
:to="row.link"
class="flex w-fit"
link-class="hover:underline hover:text-contrast"
target="_blank"
>
{{ row.name }}
</AutoLink>
</template>
<template #cell-version="{ row }">
{{ (row.version ?? row.hasProject) ? formatMessage(commonMessages.unknownLabel) : '—' }}
</template>
</Table>
</section>
<section v-if="supplementaryResources?.length > 0" id="supplementary-resources">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{ formatMessage(messages.supplementaryResources) }}
</h3>
<Table :columns="supplementaryResourcesTableColumns" :data="supplementaryResources">
<template #cell-file="{ row }">
{{ row.file.filename }}
</template>
<template #cell-type="{ row }">
{{
formatMessage(
!row.file.file_type || row.file.file_type === 'unknown'
? commonMessages.unknownLabel
: fileTypeMessages[row.file.file_type],
)
}}
</template>
<template #cell-size="{ row }">
{{ formatBytes(row.file.size) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<slot name="supplementaryResourceActions" :file="row.file" />
</div>
</template>
</Table>
</section>
</div>
</template>
<style>
.markdown-body ul {
padding-left: 1.25rem;
}
</style>
@@ -1,3 +1,13 @@
import type { Labrinth } from '@modrinth/api-client'
export { default as VersionChannelIndicator } from './VersionChannelIndicator.vue'
export { default as VersionDependencyItem } from './VersionDependencyItem.vue'
export { default as VersionFilterControl } from './VersionFilterControl.vue'
export { default as VersionPage } from './VersionPage.vue'
export { default as VersionSummary } from './VersionSummary.vue'
export type DependencyContext = {
dependency: Labrinth.Versions.v3.Dependency
project?: Labrinth.Projects.v2.Project
version?: Labrinth.Versions.v2.Version
}