Compare commits

...
Author SHA1 Message Date
aecsocket c1e6f5c06a update scan file filtering 2026-06-24 22:28:36 +01:00
aecsocket 0d7839404b fix ci 2026-06-24 20:12:21 +01:00
aecsocket 671e3535a9 🦀 automod attribution is dead, long live automod 2026-06-24 18:36:55 +01:00
aecsocket e4f02c399d prepr 2026-06-24 17:45:33 +01:00
aecsocket c3968e3d0b scan concurrency 2026-06-24 17:36:15 +01:00
aecsocket f140d0c212 move some stuff out of the txn 2026-06-24 17:26:48 +01:00
aecsocket fa6c80767c prepare 2026-06-24 17:01:00 +01:00
aecsocket 13a6fafed0 Batch pending file scanning ops 2026-06-24 17:00:00 +01:00
aecsocketandGitHub c392c85917 perf: Attribution route perf improvements (#6489)
* Attribution route perf improvements

* fix clippy

* clear exemptions when user attempts to scan their file

* Increase Redis TTL/refresh time
2026-06-24 17:48:35 +02:00
Prospector 29110890e1 changelog 2026-06-24 00:22:41 -07:00
Prospector a18f67bf0c Merge branch 'main' into prospector/modpack-proj-fixes 2026-06-24 00:21:19 -07:00
Prospector 623e01dbff bunch more fixes lol 2026-06-24 00:20:02 -07:00
Prospector 14c728c8e3 Copy ID button 2026-06-23 23:03:16 -07:00
ThatGravyBoatandGitHub a1b2073f57 feat: add moderation status action button to tech review (#6484)
* feat: add moderation status action button to tech review

* chore: run prepr
2026-06-24 05:54:39 +00:00
François-Xavier TalbotandGitHub 45c417e406 fix query cache (#6488) 2026-06-24 06:42:14 +02:00
Prospector 3c60cc6f08 project page gap 2026-06-23 15:20:19 -07:00
Prospector 06353bbc14 move deps after compat 2026-06-23 13:50:35 -07:00
38 changed files with 989 additions and 655 deletions
+2 -2
View File
@@ -50,7 +50,7 @@ Run these from the **root** folder before opening a pull request - do not run th
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/CLAUDE.md`
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
The website and app `prepr` commands
@@ -62,7 +62,7 @@ The website and app `prepr` commands
## Project-Specific Instructions
Each project may have its own `CLAUDE.md` with detailed instructions:
Each project may have its own file with detailed instructions:
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
@@ -11,11 +11,15 @@ import {
CopyIcon,
DownloadIcon,
EllipsisVerticalIcon,
EyeOffIcon,
LinkIcon,
LoaderCircleIcon,
ScaleIcon,
ShieldCheckIcon,
SpinnerIcon,
TimerIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
import {
@@ -23,6 +27,7 @@ import {
ButtonStyled,
Collapsible,
CollapsibleRegion,
commonMessages,
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
@@ -30,6 +35,7 @@ import {
type OverflowMenuOption,
useFormatBytes,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { NavTabs } from '@modrinth/ui'
import {
@@ -47,6 +53,7 @@ import ThreadView from '~/components/ui/thread/ThreadView.vue'
const auth = await useAuth()
const featureFlags = useFeatureFlags()
const { formatMessage } = useVIntl()
const formatDateTimeUtc = useFormatDateTime({
year: 'numeric',
@@ -96,6 +103,16 @@ const emit = defineEmits<{
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
}>()
const projectStatus = ref<Labrinth.Projects.v2.ProjectStatus>(props.item.project.status)
const isProjectApproved = computed(() => {
return (
projectStatus.value === 'approved' ||
projectStatus.value === 'archived' ||
projectStatus.value === 'unlisted' ||
projectStatus.value === 'private'
)
})
const quickActions = computed<OverflowMenuOption[]>(() => {
const actions: OverflowMenuOption[] = []
@@ -141,6 +158,53 @@ const quickActions = computed<OverflowMenuOption[]>(() => {
return actions
})
const isLoadingStatusAction = ref(false)
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
{
id: 'approve',
color: 'green',
action: () => setStatus('approved'),
hoverFilled: true,
disabled: isProjectApproved.value || isLoadingStatusAction.value,
},
{
id: 'withhold',
color: 'orange',
action: () => setStatus('withheld'),
hoverFilled: true,
disabled: projectStatus.value === 'withheld' || isLoadingStatusAction.value,
},
{
id: 'send-to-review',
action: () => setStatus('processing'),
hoverFilled: true,
disabled: projectStatus.value === 'processing' || isLoadingStatusAction.value,
},
{
id: 'reject',
color: 'red',
action: () => setStatus('rejected'),
hoverFilled: true,
disabled: projectStatus.value === 'rejected' || isLoadingStatusAction.value,
},
])
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
isLoadingStatusAction.value = true
try {
await client.labrinth.projects_v2.edit(props.item.project.id, { status })
projectStatus.value = status
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: (err as any)?.data?.description ? (err as any).data.description : String(err),
type: 'error',
})
}
isLoadingStatusAction.value = false
}
type Tab = 'Thread' | 'Files' | 'File'
const tabs: readonly ('Thread' | 'Files')[] = ['Thread', 'Files']
const currentTab = ref<Tab>('Thread')
@@ -347,13 +411,6 @@ const severityColor = computed(() => {
}
})
const isProjectApproved = computed(() => {
const status = props.item.project.status
return (
status === 'approved' || status === 'archived' || status === 'unlisted' || status === 'private'
)
})
const formattedDate = computed(() => {
const dates = props.item.reports.map((r) => new Date(r.created))
const earliest = new Date(Math.min(...dates.map((d) => d.getTime())))
@@ -1117,6 +1174,37 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<BugIcon /> Fail
</button>
</ButtonStyled>
<ButtonStyled color="standard">
<OverflowMenu
class="btn-dropdown-animation"
:disabled="isLoadingStatusAction"
:options="projectStatusActions"
>
<SpinnerIcon
v-if="isLoadingStatusAction"
class="animate-spin"
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
Set Status
<template #approve>
<CheckIcon aria-hidden="true" />
Approve
</template>
<template #withhold>
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #send-to-review>
<ScaleIcon aria-hidden="true" />
Send to review
</template>
<template #reject>
<XIcon aria-hidden="true" />
Reject
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-if="featureFlags.developerMode" type="outlined">
<button @click="emit('showMaliciousSummary', unsafeFiles)">Debug Summary</button>
</ButtonStyled>
@@ -2933,6 +2933,10 @@ provideProjectPageContext({
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
.new-page {
column-gap: 1.5rem;
}
</style>
<style lang="scss">
@@ -433,7 +433,7 @@ function dismissInfoBanner() {
</nuxt-link>
</template>
</IntlFormatted>
<template #actions>
<template v-if="false" #actions>
<div class="flex">
<ButtonStyled color="blue">
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
@@ -82,6 +82,7 @@
<VersionPage
:version="version"
:enrichment="enrichment"
:enrichment-loading="dependenciesLoading"
:members="members"
:user-link-creator="(user) => (moderator ? `/user/${user.id}` : undefined)"
:dependency-link-creator="createDependencyLink"
@@ -191,7 +192,7 @@
</OverflowMenu>
</ButtonStyled>
</template>
<ButtonStyled v-else type="outlined" circular>
<ButtonStyled type="outlined" circular>
<OverflowMenu
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
@@ -201,6 +202,20 @@
action: () =>
auth.user ? reportVersion(version!.id) : navigateTo(signInRouteObj),
},
{ divider: true, shown: flags.developerMode },
{
id: 'copy-id',
action: () => copyToClipboard(version!.id),
shown: flags.developerMode,
},
{
id: 'copy-permalink',
action: () =>
copyToClipboard(
`https://modrinth.com/project/${project.id}/version/${version!.id}`,
),
shown: flags.developerMode,
},
]"
>
<MoreVerticalIcon />
@@ -208,6 +223,18 @@
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-link>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
@@ -374,7 +401,7 @@
</pre
>
</template>
<template v-else>
<template v-else-if="showVersionSkeleton">
<div class="flex flex-col gap-4 pb-[30rem]">
<div
class="mt-4 flex h-[8rem] w-full animate-pulse items-center justify-center rounded-2xl bg-surface-3"
@@ -401,6 +428,7 @@ import type { Labrinth } from '@modrinth/api-client'
import {
BoxIcon,
ChevronLeftIcon,
ClipboardCopyIcon,
CopyIcon,
DownloadIcon,
DropdownIcon,
@@ -439,10 +467,12 @@ import {
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { STALE_TIME } from '~/composables/queries/project'
import { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { createDataPackVersion } from '~/helpers/package.js'
import { reportVersion } from '~/utils/report-helpers.ts'
@@ -472,6 +502,7 @@ const {
versionsLoading,
loadVersions,
dependencies: contextDependencies,
dependenciesLoading,
loadDependencies,
invalidate,
cdnDownloadReason,
@@ -495,26 +526,31 @@ const signInRouteObj = computed(() => getSignInRouteObj(route))
const versionRouteParam = computed(() => route.params.version as string)
const isLatestRoute = computed(() => versionRouteParam.value === 'latest')
function filterVersionsForLatestRoute(allVersions: Labrinth.Versions.v3.Version[]) {
let filtered = allVersions
const loaderFilter = route.query.loader
if (typeof loaderFilter === 'string') {
filtered = filtered.filter((x) => x.loaders.includes(loaderFilter))
}
const gameVersionFilter = route.query.version
if (typeof gameVersionFilter === 'string') {
filtered = filtered.filter((x) => x.game_versions.includes(gameVersionFilter))
}
return filtered
}
const latestVersionId = computed(() => {
if (!isLatestRoute.value) {
return null
}
let allVersions = versions.value ?? []
const filtered = filterVersionsForLatestRoute(versions.value ?? [])
if (filtered.length === 0) return null
const loaderFilter = route.query.loader
if (typeof loaderFilter === 'string') {
allVersions = allVersions.filter((x) => x.loaders.includes(loaderFilter))
}
const gameVersionFilter = route.query.version
if (typeof gameVersionFilter === 'string') {
allVersions = allVersions.filter((x) => x.game_versions.includes(gameVersionFilter))
}
if (allVersions.length === 0) return null
return allVersions.reduce((a, b) => (a.date_published > b.date_published ? a : b)).id
return filtered.reduce((a, b) => (a.date_published > b.date_published ? a : b)).id
})
const versionLookupKey = computed(() =>
@@ -525,6 +561,7 @@ const {
data: version,
refetch: refetchVersion,
error: versionError,
isPending: versionPending,
} = useQuery({
queryKey: computed(
() => ['project', project.value.id, 'version', 'v3', versionLookupKey.value] as const,
@@ -535,6 +572,30 @@ const {
staleTime: STALE_TIME,
})
const showVersionSkeleton = computed(() => import.meta.client && versionPending.value)
onServerPrefetch(async () => {
if (!project.value.id) return
let lookupKey = versionRouteParam.value
if (isLatestRoute.value) {
loadVersions()
const versionsData = await queryClient.ensureQueryData(
projectQueryOptions.versionsV3(project.value.id, client),
)
const filtered = filterVersionsForLatestRoute(versionsData ?? [])
if (filtered.length === 0) return
lookupKey = filtered.reduce((a, b) => (a.date_published > b.date_published ? a : b)).id
}
if (!lookupKey || lookupKey === 'latest') return
await queryClient.ensureQueryData(
versionQueryOptions.fromProject(project.value.id, lookupKey, client),
)
})
watch(
versionError,
(error) => {
@@ -18,6 +18,7 @@
<div class="group relative float-end ml-4">
<OverflowMenu
v-tooltip="formatMessage(messages.editIconButton)"
:dropdown-id="`${baseId}-edit-icon`"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:options="[
{
@@ -138,7 +139,7 @@
</NewModal>
<NormalPage :sidebar="cosmetics.leftContentLayout ? 'left' : 'right'">
<template #header>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<ClientOnly>
<nuxt-link
v-if="returnLink"
@@ -201,19 +202,32 @@
</span>
</div>
</div>
<div class="col-span-2 flex gap-2 sm:col-span-1">
<div class="col-span-2 flex items-center gap-2 sm:col-span-1">
<template v-if="canEdit">
<ButtonStyled>
<ButtonStyled size="large">
<button @click="openEditModal">
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red" color-fill="text">
<button @click="() => $refs.deleteModal.show()">
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:dropdown-id="`${baseId}-more-options`"
:options="[
{
id: 'delete',
color: 'red',
action: () => deleteModal?.show(),
},
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #delete>
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
@@ -226,7 +240,12 @@
v-if="collection.description"
:title="formatMessage(commonMessages.descriptionLabel)"
>
<p class="m-0 break-words">{{ collection.description }}</p>
<div
v-if="supportsMarkdown"
class="description-body"
v-html="renderString(collection.description)"
/>
<p v-else class="m-0 break-words">{{ collection.description }}</p>
</SidebarCard>
<SidebarCard
v-if="collection.id !== 'following'"
@@ -238,9 +257,7 @@
>
<Avatar :src="creator.avatar_url" :alt="creator.username" size="32px" circle />
<div class="flex flex-col">
<span
class="grid w-full grid-cols-[1fr_auto] flex-nowrap items-center gap-1 group-hover:underline"
>
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate">{{ creator.username }}</span>
</span>
</div>
@@ -373,6 +390,7 @@ import {
HeartMinusIcon,
LinkIcon,
LockIcon,
MoreVerticalIcon,
SaveIcon,
SpinnerIcon,
TrashIcon,
@@ -411,9 +429,10 @@ import {
useSavable,
useVIntl,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { isAdmin, renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { onServerPrefetch } from 'vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
@@ -431,6 +450,32 @@ const route = useNativeRoute()
const router = useRouter()
const auth = await useAuth()
const cosmetics = useCosmetics()
const queryClient = useQueryClient()
const baseId = useId()
async function fetchProjectsByIds(projectIds) {
const segmentSize = 800
const segments = []
for (let i = 0; i < projectIds.length; i += segmentSize) {
segments.push(projectIds.slice(i, i + segmentSize))
}
const results = await Promise.all(
segments.map((ids) => api.labrinth.projects_v2.getMultiple(ids)),
)
const projects = results.flat()
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
}
async function fetchFollowedProjects(userId) {
const projects = await api.labrinth.users_v2.getFollowedProjects(userId)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
}
const messages = defineMessages({
collectionDescription: {
@@ -595,6 +640,8 @@ const creator = computed(() =>
isFollowingCollection.value ? auth.value.user : fetchedCreator.value,
)
const supportsMarkdown = computed(() => creator.value?.id === '2REoufqX')
// Query for followed projects
const {
data: followedProjects,
@@ -602,13 +649,7 @@ const {
isFetching: followedProjectsIsFetching,
} = useQuery({
queryKey: computed(() => ['user', auth.value.user?.id, 'follows']),
queryFn: async () => {
const projects = await api.labrinth.users_v2.getFollowedProjects(auth.value.user.id)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
},
queryFn: async () => fetchFollowedProjects(auth.value.user.id),
enabled: computed(() => isFollowingCollection.value && !!auth.value.user?.id),
placeholderData: [],
})
@@ -620,22 +661,7 @@ const {
isFetching: collectionProjectsIsFetching,
} = useQuery({
queryKey: computed(() => ['projects', collection.value?.projects]),
queryFn: async () => {
const projectIds = collection.value.projects
const segmentSize = 800
const segments = []
for (let i = 0; i < projectIds.length; i += segmentSize) {
segments.push(projectIds.slice(i, i + segmentSize))
}
const results = await Promise.all(
segments.map((ids) => api.labrinth.projects_v2.getMultiple(ids)),
)
const projects = results.flat()
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
},
queryFn: () => fetchProjectsByIds(collection.value.projects),
enabled: computed(() => !isFollowingCollection.value && !!collection.value?.projects?.length),
placeholderData: [],
})
@@ -647,12 +673,48 @@ const projects = computed(() =>
// Loading state
const isLoading = computed(() => {
if (!import.meta.client) return false
if (isFollowingCollection.value) {
return followedProjectsIsFetching.value
}
return collectionIsPending.value || creatorIsPending.value || collectionProjectsIsFetching.value
})
onServerPrefetch(async () => {
if (isFollowingCollection.value) {
const userId = auth.value.user?.id
if (!userId) return
await queryClient.ensureQueryData({
queryKey: ['user', userId, 'follows'],
queryFn: () => fetchFollowedProjects(userId),
})
return
}
if (!collectionId) return
const collectionData = await queryClient.ensureQueryData({
queryKey: ['collection', collectionId],
queryFn: () => api.labrinth.collections.get(collectionId),
})
if (collectionData?.user) {
await queryClient.ensureQueryData({
queryKey: ['user', collectionData.user],
queryFn: () => api.labrinth.users_v2.get(collectionData.user),
})
}
if (collectionData?.projects?.length) {
await queryClient.ensureQueryData({
queryKey: ['projects', collectionData.projects],
queryFn: () => fetchProjectsByIds(collectionData.projects),
})
}
})
watch(
[collection, creator],
([col, cre]) => {
@@ -716,6 +778,7 @@ const showUpdatedDate = computed(() => {
})
const editModal = ref(null)
const deleteModal = ref(null)
const iconInputRef = ref(null)
const icon = ref(null)
const deletedIcon = ref(false)
@@ -881,4 +944,15 @@ function openEditModal(event) {
// Omorphia's dropdowns are harcoded in width, so we need to override that
width: 100% !important;
}
:deep(.description-body) {
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: 600;
}
}
</style>
@@ -146,9 +146,8 @@
<div class="normal-page__sidebar">
<AdPlaceholder v-if="!auth.user" />
<div class="card flex-card">
<h2>Members</h2>
<div class="details-list">
<SidebarCard title="Members">
<div class="flex flex-col gap-3 font-semibold">
<nuxt-link
v-for="member in acceptedMembers"
:key="`member-${member?.user?.id}`"
@@ -163,22 +162,20 @@
/>
<div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate font-normal text-contrast">{{
member.user.username
}}</span>
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="'Organization owner'"
class="text-brand-orange"
/>
</span>
<span class="text-sm font-normal">
<span class="text-sm font-normal text-secondary">
{{ member?.role ? member.role : 'Member' }}
</span>
</div>
</nuxt-link>
</div>
</div>
</SidebarCard>
</div>
<div class="normal-page__content">
<div v-if="isInvited" class="universal-card information invited">
@@ -317,6 +314,7 @@ import {
PROJECT_DEP_MARKER_QUERY,
ProjectCard,
ProjectCardList,
SidebarCard,
useCompactNumber,
useFormatNumber,
useVIntl,
@@ -774,4 +772,8 @@ async function copyPermalink() {
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
}
.new-page {
column-gap: 1.5rem;
}
</style>
+5 -1
View File
@@ -477,7 +477,7 @@
v-if="organizations?.length > 0"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
>
<h2 class="m-0 mb-2 text-lg text-contrast">
<h2 class="m-0 mb-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profileOrganizations) }}
</h2>
<div class="flex flex-wrap gap-2">
@@ -1040,4 +1040,8 @@ export default defineNuxtComponent({
}
}
}
.new-page {
column-gap: 1.5rem;
}
</style>
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tinsert into file_scans (file_id)\n\t\tselect f.id\n\t\tfrom files f\n\t\twhere f.version_id = any($1)\n\t\ton conflict (file_id) do nothing\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": []
},
"hash": "1206e354ee5be83bca26b802b1e7a7ebdf9ee4ae9a142ee93e89ddc1536e7284"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tselect distinct f.version_id as \"version_id: DBVersionId\"\n\t\tfrom override_file_sources ofs\n\t\tinner join files f on f.id = ofs.file_id\n\t\tinner join versions v on v.id = f.version_id\n\t\twhere ofs.sha1 = $1\n\t\t\tand v.mod_id = $2\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Bytea",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "365d51d5c56e9420618bd23ec2f0624cb261a1aeb1f929277fa063d730c2d99a"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tinsert into file_scans (file_id)\n\t\tselect f.id\n\t\tfrom files f\n\t\tinner join attribution_enforced_versions aev on aev.id = f.version_id\n\t\twhere f.version_id = any($1)\n\t\ton conflict (file_id) do nothing\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": []
},
"hash": "43ce7cbf44ce123c3d71c5dee035022d2e6b537eb53fcb39608e820e77174b81"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n update file_scans\n set attributions_scanned_at = $2\n where file_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Timestamptz"
]
},
"nullable": []
},
"hash": "4748e425c6bbd4d154bf658bf695661485a9700bffa413c5e94d80a525b53e27"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n select\n fa.file_id as \"file_id: DBFileId\",\n f.url,\n v.mod_id as \"project_id: DBProjectId\"\n from file_scans fa\n inner join files f on f.id = fa.file_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join versions v on v.id = f.version_id\n where fa.attributions_scanned_at is null\n ",
"query": "\n select\n fa.file_id as \"file_id: DBFileId\",\n f.url,\n v.mod_id as \"project_id: DBProjectId\"\n from file_scans fa\n inner join files f on f.id = fa.file_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join versions v on v.id = f.version_id\n where fa.attributions_scanned_at is null\n order by fa.file_id\n limit $1\n ",
"describe": {
"columns": [
{
@@ -20,7 +20,9 @@
}
],
"parameters": {
"Left": []
"Left": [
"Int8"
]
},
"nullable": [
false,
@@ -28,5 +30,5 @@
false
]
},
"hash": "0dda0265b39d4c8b019eb1ea6d164ee639a6deb0f47c1dcdd83a5816308faab0"
"hash": "502a0fcf14a874beb2709d5518fb7f4dd3ec2d110bd6e75caa1fbe3014d8e412"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tdelete from attributions_exemptions\n\t\twhere version_id = any($1) or project_id = any($2)\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8Array",
"Int8Array"
]
},
"nullable": []
},
"hash": "70d1e6c703a2124e96e60a3fabe90b0b2dcb0c8631fdf5da57b3bc8047acfef2"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n select version_id as \"version_id: DBVersionId\"\n from files\n where id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "752eb66451b57e9595b8a1671b1f98465ddb903a57d566de9e472b0b78ad2d39"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n update file_scans\n set attributions_scanned_at = now\n from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)\n where file_scans.file_id = u.id\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8Array",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tselect distinct f.version_id as \"version_id: DBVersionId\"\n\t\tfrom project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\tinner join override_file_sources ofs on ofs.sha1 = paf.sha1\n\t\tinner join files f on f.id = ofs.file_id\n\t\tinner join versions v on v.id = f.version_id\n\t\twhere paf.group_id = any($1)\n\t\t\tand pag.project_id = v.mod_id\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "a68a8b6e1fb06fac7021657120e8446067fcc40dc1cf7f75670fdf2486b19f41"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n select count(*) as \"count!\" from file_scans\n where attributions_scanned_at is null\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "eaafc6b2e29562112ae323e68955aa99e70f8ecbdc1b2500f7d7e89af8fc6e29"
}
+2 -2
View File
@@ -29,6 +29,6 @@
- You can force a search reindex by:
- Running `cd apps/labrinth && cargo run -p labrinth -- --run-background-task index-search` (prefer this if backend is running locally)
- Hitting the force reindex admin endpoint
- To seed the database locally: `psql postgresql://labrinth:labrinth@localhost/labrinth -f apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql
`
- To seed the database locally: `psql postgresql://labrinth:labrinth@localhost/labrinth -f apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql`
- When writing `sqlx` queries, prefer `r#` raw strings over escaping quotes
- When interacting with the Postgres database, prefer using a `ro_pool: ReadOnlyPgPool` when performing read-only operations
-34
View File
@@ -1,34 +0,0 @@
# Labrinth
Labrinth is the backend API service for Modrinth, written in Rust.
## Code style
- When writing `sqlx` queries, NEVER use `query` directly. Always prefer using the `query!`, `query_as!`, `query_scalar!` macros.
## Pre-PR Checks
When the user refers to "perform[ing] pre-PR checks", do the following:
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
- NEVER run `cargo sqlx prepare --workspace`
## Testing
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
## Local Services
- Read the root `docker-compose.yml` to see what running services are available while developing
- Use `docker exec` to access these services
### Clickhouse
- Access: `docker exec labrinth-clickhouse clickhouse-client`
- Database: `staging_ariadne`
### Postgres
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
+2 -27
View File
@@ -10,34 +10,11 @@ use crate::models::projects::{
MissingAttributionFile, OverrideSource, Version,
};
use crate::models::users::User;
use crate::queue::file_scan::{
get_dependency_attributions, get_files_missing_attribution,
};
use crate::queue::file_scan::get_files_missing_attribution;
use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools;
pub async fn enrich_dependency_attributions(
versions: &mut [VersionQueryResult],
pool: &ReadOnlyPgPool,
) {
let version_ids = versions.iter().map(|v| v.inner.id).collect::<Vec<_>>();
let dep_attr = get_dependency_attributions(&**pool, &version_ids)
.await
.unwrap_or_default();
for version in versions {
for dep in &mut version.dependencies {
if let Some(attr) = dep_attr.get(&dep.id)
&& (attr.attribution.flame_project.is_some()
|| attr.attribution.resolution.is_some())
{
dep.attribution = Some(attr.attribution.clone());
}
}
}
}
pub trait ValidateAuthorized {
fn validate_authorized(
&self,
@@ -235,12 +212,10 @@ pub async fn filter_visible_versions(
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
let missing = get_files_missing_attribution(pool, &version_ids)
let missing = get_files_missing_attribution(&**ro_pool, &version_ids)
.await
.unwrap_or_default();
enrich_dependency_attributions(&mut versions, ro_pool).await;
Ok(versions
.into_iter()
.map(|v| {
+10 -3
View File
@@ -8,7 +8,7 @@ use crate::models::notifications::NotificationBody;
use crate::queue::analytics::cache::cache_analytics;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::email::EmailQueue;
use crate::queue::file_scan::scan_all_files;
use crate::queue::file_scan::scan_all_pending_files;
use crate::queue::payouts::{
PayoutsQueue, index_payouts_notifications,
insert_bank_balances_and_webhook, process_affiliate_payouts,
@@ -43,7 +43,7 @@ pub enum BackgroundTask {
/// Finds files of versions which have not been scanned for attributions
/// yet, extracts them to find file overrides, and finds any overrides which
/// require attribution from the creator.
ScanFiles,
ScanPendingFiles,
/// Queues Discord Creator Club role claim emails for newly eligible users.
DiscordRoleEmailCampaign,
}
@@ -119,7 +119,14 @@ impl BackgroundTask {
)
.await
}
ScanFiles => scan_all_files(&pool, &redis_pool, &**file_host).await,
ScanPendingFiles => {
scan_all_pending_files(
&pool,
&redis_pool,
file_host.into_inner(),
)
.await
}
DiscordRoleEmailCampaign => {
discord_role_email_campaign(pool, redis_pool).await
}
@@ -808,6 +808,18 @@ impl DBVersion {
}
).await?;
let dependency_attributions =
crate::queue::file_scan::get_dependency_attributions(
&mut exec,
&version_ids
.iter()
.copied()
.map(DBVersionId)
.collect_vec(),
)
.await
.unwrap_or_default();
let res = sqlx::query!(
r#"
SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,
@@ -832,6 +844,19 @@ impl DBVersion {
let hashes = hashes.remove(&version_id).map(|x|x.1).unwrap_or_default();
let version_fields = version_fields.remove(&version_id).map(|x|x.1).unwrap_or_default();
let dependencies = dependencies.remove(&version_id).map(|x|x.1).unwrap_or_default();
let dependencies = dependencies
.into_iter()
.map(|mut dependency| {
if let Some(attr) = dependency_attributions.get(&dependency.id)
&& (attr.attribution.flame_project.is_some()
|| attr.attribution.resolution.is_some())
{
dependency.attribution = Some(attr.attribution.clone());
}
dependency
})
.collect_vec();
let loader_fields = loader_fields.iter()
.filter(|x| loader_loader_field_ids.contains(&x.id))
@@ -1030,6 +1055,22 @@ impl DBVersion {
.await?;
Ok(())
}
pub async fn clear_cache_ids(
version_ids: &[DBVersionId],
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete_many(
version_ids
.iter()
.map(|id| (VERSIONS_NAMESPACE, Some(id.0.to_string()))),
)
.await?;
Ok(())
}
}
#[derive(Clone, Deserialize, Serialize)]
+15 -3
View File
@@ -25,6 +25,8 @@ pub mod util;
const DEFAULT_EXPIRY: i64 = 60 * 60 * 12; // 12 hours
const ACTUAL_EXPIRY: i64 = 60 * 30; // 30 minutes
const VERSION_DEFAULT_EXPIRY: i64 = 60 * 60 * 48; // 48 hours
const VERSION_ACTUAL_EXPIRY: i64 = 60 * 60 * 24; // 24 hours
// Bound how many commands we send in a single Redis pipeline. The multiplexed
// connection's BytesMut write buffer keeps its peak capacity for the life of
@@ -39,6 +41,15 @@ const MGET_CHUNK_SIZE: usize = 32;
// BytesMut peak capacity that builds up under steady load.
const REDIS_MAX_CONN_AGE: Duration = Duration::from_secs(120);
fn cache_expiries(namespace: &str) -> (i64, i64) {
match namespace {
"versions" | "versions_files" => {
(VERSION_DEFAULT_EXPIRY, VERSION_ACTUAL_EXPIRY)
}
_ => (DEFAULT_EXPIRY, ACTUAL_EXPIRY),
}
}
#[derive(Clone)]
pub struct RedisPool {
pub url: String,
@@ -372,6 +383,7 @@ impl RedisPool {
.instrument(info_span!("get cached values"))
};
let (default_expiry, actual_expiry) = cache_expiries(namespace);
let current_time = Utc::now();
let mut expired_values = HashMap::new();
@@ -379,7 +391,7 @@ impl RedisPool {
let mut cached_values = cached_values_raw
.into_iter()
.filter_map(|(key, val)| {
if Utc.timestamp_opt(val.iat + ACTUAL_EXPIRY, 0).unwrap()
if Utc.timestamp_opt(val.iat + actual_expiry, 0).unwrap()
< current_time
{
expired_values.insert(val.key.to_string(), val);
@@ -481,7 +493,7 @@ impl RedisPool {
self.meta_namespace
),
serde_json::to_string(&value)?,
DEFAULT_EXPIRY as u64,
default_expiry as u64,
);
pipe_cmds += 1;
@@ -501,7 +513,7 @@ impl RedisPool {
self.meta_namespace, actual_slug
),
key.to_string(),
DEFAULT_EXPIRY as u64,
default_expiry as u64,
);
pipe_cmds += 1;
}
+1
View File
@@ -166,6 +166,7 @@ vars! {
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
FILE_SCAN_CONCURRENCY: i64 = 8i64;
// s3
S3_PUBLIC_BUCKET_NAME: String = "";
+157 -34
View File
@@ -1,11 +1,12 @@
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::sync::Arc;
use chrono::Utc;
use eyre::{Result, eyre};
use hex::ToHex;
use sha1::Digest;
use tokio::task::spawn_blocking;
use tokio::task::{spawn, spawn_blocking};
use tracing::{Instrument, info, info_span, warn};
use zip::ZipArchive;
@@ -29,6 +30,15 @@ use crate::queue::moderation::{
use crate::util::error::Context;
use crate::util::http::HTTP_CLIENT;
const PENDING_FILE_SCAN_BATCH_SIZE: i64 = 100;
#[derive(Clone)]
struct PendingFileScan {
file_id: DBFileId,
url: String,
project_id: DBProjectId,
}
/// Attribution enforcement is version/project-scoped, not file-hash-scoped.
///
/// Versions or projects listed in `attributions_exemptions` predate this
@@ -39,11 +49,50 @@ use crate::util::http::HTTP_CLIENT;
/// versions must go through the `attribution_enforced_versions` view so
/// grandfathered versions and projects are ignored without making the SHA1
/// itself exempt.
pub async fn scan_all_files(
pub async fn scan_all_pending_files(
db: &PgPool,
redis: &RedisPool,
file_host: &dyn FileHost,
file_host: Arc<dyn FileHost>,
) -> Result<()> {
let scan_concurrency = ENV.FILE_SCAN_CONCURRENCY.max(1);
let total_to_scan = sqlx::query_scalar!(
r#"
select count(*) as "count!" from file_scans
where attributions_scanned_at is null
"#,
)
.fetch_one(db)
.await
.wrap_err("fetching number of files to scan")?;
info!(
"Found {total_to_scan} total pending files to scan, running in batches of {PENDING_FILE_SCAN_BATCH_SIZE} with concurrency {scan_concurrency}"
);
loop {
let scanned_count = scan_pending_files_batch(
db,
redis,
file_host.clone(),
scan_concurrency * PENDING_FILE_SCAN_BATCH_SIZE,
)
.await?;
if scanned_count == 0 {
break;
}
}
Ok(())
}
async fn scan_pending_files_batch(
db: &PgPool,
redis: &RedisPool,
file_host: Arc<dyn FileHost>,
scan_limit: i64,
) -> Result<usize> {
let files_to_scan = sqlx::query!(
r#"
select
@@ -55,14 +104,61 @@ pub async fn scan_all_files(
inner join attribution_enforced_versions aev on aev.id = f.version_id
inner join versions v on v.id = f.version_id
where fa.attributions_scanned_at is null
"#
order by fa.file_id
limit $1
"#,
scan_limit,
)
.fetch_all(db)
.await
.wrap_err("fetching files to scan")?;
info!("Found {} files to scan", files_to_scan.len());
info!(
"Found {} pending files to scan, splitting into jobs of {PENDING_FILE_SCAN_BATCH_SIZE}",
files_to_scan.len(),
);
let files_to_scan: Vec<_> = files_to_scan
.into_iter()
.map(|row| PendingFileScan {
file_id: row.file_id,
url: row.url,
project_id: row.project_id,
})
.collect();
let mut tasks = Vec::new();
for chunk in files_to_scan.chunks(PENDING_FILE_SCAN_BATCH_SIZE as usize) {
let db = db.clone();
let redis = redis.clone();
let file_host = file_host.clone();
let chunk = chunk.to_vec();
tasks.push(spawn(async move {
scan_pending_files_chunk(&db, &redis, &*file_host, chunk).await
}));
}
let mut scanned_count = 0;
for task in tasks {
scanned_count += task
.await
.wrap_err("joining file scan task")?
.wrap_err("scanning pending file chunk")?;
}
info!("Marked {} files as scanned", scanned_count);
Ok(scanned_count)
}
async fn scan_pending_files_chunk(
db: &PgPool,
redis: &RedisPool,
file_host: &dyn FileHost,
files_to_scan: Vec<PendingFileScan>,
) -> Result<usize> {
info!("Scanning {} files", files_to_scan.len());
let mut scanned_count = 0;
for row in files_to_scan {
@@ -72,10 +168,6 @@ pub async fn scan_all_files(
info!("Scanning file");
let file_id = row.file_id;
let mut txn = db
.begin()
.await
.wrap_err("beginning file scan transaction")?;
let overrides = extract_override_files_from_storage(
file_host, file_id, &row.url,
@@ -87,28 +179,35 @@ pub async fn scan_all_files(
if overrides.is_empty() {
info!("Found no overrides");
} else {
info!("Found {} overrides", overrides.len());
return Ok(());
}
let resolved = resolve_overrides(&overrides, redis, &mut txn)
.await
.wrap_err_with(|| {
eyre!("resolving overrides for file {file_id:?}")
})?;
info!("Resolved: {resolved:#?}");
info!("Found {} overrides", overrides.len());
persist_attribution_results(
row.project_id,
file_id,
&overrides,
&resolved,
&mut txn,
)
let mut txn = db
.begin()
.await
.wrap_err("beginning file scan transaction")?;
let resolved = resolve_overrides(&overrides, redis, &mut txn)
.await
.wrap_err_with(|| {
eyre!("persisting attribution results for file {file_id:?}")
eyre!("resolving overrides for file {file_id:?}")
})?;
}
info!("Resolved: {resolved:#?}");
persist_attribution_results(
row.project_id,
file_id,
&overrides,
&resolved,
redis,
&mut txn,
)
.await
.wrap_err_with(|| {
eyre!("persisting attribution results for file {file_id:?}")
})?;
let now = Utc::now();
sqlx::query!(
@@ -136,9 +235,7 @@ pub async fn scan_all_files(
scanned_count += 1;
}
info!("Marked {} files as scanned", scanned_count);
Ok(())
Ok(scanned_count)
}
pub async fn scan_file(
@@ -164,7 +261,7 @@ pub async fn scan_file(
})?;
persist_attribution_results(
project_id, file_id, &overrides, &resolved, txn,
project_id, file_id, &overrides, &resolved, redis, txn,
)
.await
.wrap_err_with(|| {
@@ -275,10 +372,19 @@ fn extract_override_files(data: &[u8]) -> Result<Vec<OverrideFile>> {
continue;
}
let should_scan_file = name.contains(".jar")
|| (name.contains(".zip") && !name.ends_with(".zip.txt"));
if name.matches('/').count() > 2 || !should_scan_file {
let should_skip = name.starts_with("mods/.connector/")
|| name.starts_with(".sable/natives/")
|| name.starts_with("local/crash_assistant/")
|| name.starts_with("mods/mcef-libraries/")
|| name.starts_with("mods/mcef-cache/")
|| name.starts_with("config/super_resolution/libraries/")
|| name.starts_with("config/Veinminer/update/")
|| name.starts_with("config/epicfight/native/")
|| name.starts_with("essential/")
|| name.ends_with(".rpo")
|| name.ends_with(".txt");
let should_scan = name.contains(".jar") || name.contains(".zip");
if should_scan && !should_skip {
continue;
}
@@ -303,6 +409,7 @@ async fn persist_attribution_results(
file_id: DBFileId,
overrides: &[OverrideFile],
resolved: &HashMap<String, OverrideResolution>,
redis: &RedisPool,
txn: &mut PgTransaction<'_>,
) -> Result<()> {
let all_sha1s: Vec<Vec<u8>> = overrides
@@ -552,6 +659,22 @@ async fn persist_attribution_results(
.wrap_err("inserting override file sources")?;
}
let version_id = sqlx::query_scalar!(
r#"
select version_id as "version_id: DBVersionId"
from files
where id = $1
"#,
file_id as DBFileId,
)
.fetch_one(&mut *txn)
.await
.wrap_err("fetching scanned file version")?;
DBVersion::clear_cache_ids(&[version_id], redis)
.await
.wrap_err("clearing version cache after attribution scan")?;
Ok(())
}
+4 -356
View File
@@ -1,6 +1,4 @@
use crate::auth::checks::filter_visible_versions;
use crate::database;
use crate::database::models::DBUserId;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
@@ -8,20 +6,15 @@ use crate::database::{PgPool, ReadOnlyPgPool};
use crate::env::ENV;
use crate::models::ids::ProjectId;
use crate::models::notifications::NotificationBody;
use crate::models::pack::{PackFile, PackFileHash, PackFormat};
use crate::models::projects::ProjectStatus;
use crate::models::threads::MessageBody;
use crate::routes::ApiError;
use dashmap::DashSet;
use hex::ToHex;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sha1::Digest;
use std::collections::HashMap;
use std::fmt::Write;
use std::io::{Cursor, Read};
use std::time::Duration;
use zip::ZipArchive;
pub const AUTOMOD_ID: i64 = 0;
@@ -242,7 +235,8 @@ impl AutomatedModerationQueue {
for project in projects {
async {
let project =
database::DBProject::get_id((project).into(), &pool, &redis).await?;
database::DBProject::get_id((project).into(), &*ro_pool, &redis)
.await?;
if let Some(project) = project {
let res = async {
@@ -282,7 +276,7 @@ impl AutomatedModerationQueue {
}
let versions =
database::DBVersion::get_many(&project.versions, &pool, &redis)
database::DBVersion::get_many(&project.versions, &*ro_pool, &redis)
.await?
.into_iter()
// we only support modpacks at this time
@@ -292,343 +286,7 @@ impl AutomatedModerationQueue {
for version in versions {
let primary_file = version.files.iter().find_or_first(|x| x.primary);
if let Some(primary_file) = primary_file {
let data = reqwest::get(&primary_file.url).await?.bytes().await?;
let reader = Cursor::new(data);
let mut zip = ZipArchive::new(reader)?;
let pack: PackFormat = {
let Ok(mut file) = zip.by_name("modrinth.index.json") else {
continue;
};
let mut contents = String::new();
file.read_to_string(&mut contents)?;
serde_json::from_str(&contents)?
};
// sha1, pack file, file path, murmur
let mut hashes: Vec<(
String,
Option<PackFile>,
String,
Option<u32>,
)> = pack
.files
.clone()
.into_iter()
.filter_map(|x| {
let hash = x.hashes.get(&PackFileHash::Sha1);
if let Some(hash) = hash {
let path = x.path.to_string();
Some((hash.clone(), Some(x), path, None))
} else {
None
}
})
.collect();
for i in 0..zip.len() {
let mut file = zip.by_index(i)?;
if file.name().starts_with("overrides/mods")
|| file.name().starts_with("client-overrides/mods")
|| file.name().starts_with("server-overrides/mods")
|| file.name().starts_with("overrides/shaderpacks")
|| file.name().starts_with("client-overrides/shaderpacks")
|| file.name().starts_with("overrides/resourcepacks")
|| file.name().starts_with("client-overrides/resourcepacks")
{
if file.name().matches('/').count() > 2 || file.name().ends_with(".txt") || file.name().ends_with(".rpo") {
continue;
}
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
let hash = sha1::Sha1::digest(&contents).encode_hex::<String>();
let murmur = hash_flame_murmur32(contents);
hashes.push((
hash,
None,
file.name().to_string(),
Some(murmur),
));
}
}
let files = database::models::DBVersion::get_files_from_hash(
"sha1".to_string(),
&hashes.iter().map(|x| x.0.clone()).collect::<Vec<_>>(),
&pool,
&redis,
)
.await?;
let version_ids =
files.iter().map(|x| x.version_id).collect::<Vec<_>>();
let versions_data = filter_visible_versions(
database::models::DBVersion::get_many(
&version_ids,
&pool,
&redis,
)
.await?,
&None,
&pool,
&ro_pool,
&redis,
)
.await?;
let mut final_hashes = HashMap::new();
for version in versions_data {
for file in
files.iter().filter(|x| x.version_id == version.id.into())
{
if let Some(hash) = file.hashes.get("sha1")
&& let Some((index, (sha1, _, file_name, _))) = hashes
.iter()
.enumerate()
.find(|(_, (value, _, _, _))| value == hash)
{
final_hashes
.insert(sha1.clone(), IdentifiedFile { status: ApprovalType::Yes, file_name: file_name.clone() });
hashes.remove(index);
}
}
}
// All files are on Modrinth, so we don't send any messages
if hashes.is_empty() {
sqlx::query!(
"
UPDATE files
SET metadata = $1
WHERE id = $2
",
serde_json::to_value(&MissingMetadata {
identified: final_hashes,
flame_files: HashMap::new(),
unknown_files: HashMap::new(),
})?,
primary_file.id.0
)
.execute(&pool)
.await?;
continue;
}
let rows = sqlx::query!(
"
SELECT encode(mef.sha1, 'escape') sha1, mel.status status
FROM moderation_external_files mef
INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id
WHERE mef.sha1 = ANY($1)
",
&hashes.iter().map(|x| x.0.as_bytes().to_vec()).collect::<Vec<_>>()
)
.fetch_all(&pool)
.await?;
for row in rows {
if let Some(sha1) = row.sha1
&& let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == &sha1) {
final_hashes.insert(sha1.clone(), IdentifiedFile { file_name: file_name.clone(), status: ApprovalType::from_string(&row.status).unwrap_or(ApprovalType::Unidentified) });
hashes.remove(index);
}
}
if hashes.is_empty() {
let metadata = MissingMetadata {
identified: final_hashes,
flame_files: HashMap::new(),
unknown_files: HashMap::new(),
};
sqlx::query!(
"
UPDATE files
SET metadata = $1
WHERE id = $2
",
serde_json::to_value(&metadata)?,
primary_file.id.0
)
.execute(&pool)
.await?;
if metadata.identified.values().any(|x| x.status != ApprovalType::Yes && x.status != ApprovalType::WithAttributionAndSource) {
let val = mod_messages.version_specific.entry(version.inner.version_number).or_default();
val.push(ModerationMessage::PackFilesNotAllowed {files: metadata.identified, incomplete: false });
}
continue;
}
let client = reqwest::Client::new();
let res = client
.post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"fingerprints": hashes.iter().filter_map(|x| x.3).collect::<Vec<u32>>()
}))
.send()
.await?.text()
.await?;
let flame_hashes = serde_json::from_str::<FlameResponse<FingerprintResponse>>(&res)?
.data
.exact_matches
.into_iter()
.map(|x| x.file)
.collect::<Vec<_>>();
let mut flame_files = Vec::new();
for file in flame_hashes {
let hash = file
.hashes
.iter()
.find(|x| x.algo == 1)
.map(|x| x.value.clone());
if let Some(hash) = hash {
flame_files.push((hash, file.mod_id))
}
}
let rows = sqlx::query!(
"
SELECT mel.id, mel.flame_project_id, mel.status status
FROM moderation_external_licenses mel
WHERE mel.flame_project_id = ANY($1)
",
&flame_files.iter().map(|x| x.1 as i32).collect::<Vec<_>>()
)
.fetch_all(&pool).await?;
let mut insert_hashes = Vec::new();
let mut insert_filenames = Vec::new();
let mut insert_ids = Vec::new();
for row in rows {
if let Some((curse_index, (hash, _flame_id))) = flame_files.iter().enumerate().find(|(_, x)| Some(x.1 as i32) == row.flame_project_id)
&& let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == hash) {
final_hashes.insert(sha1.clone(), IdentifiedFile {
file_name: file_name.clone(),
status: ApprovalType::from_string(&row.status).unwrap_or(ApprovalType::Unidentified),
});
insert_hashes.push(hash.clone().as_bytes().to_vec());
insert_filenames.push(Some(file_name.clone()));
insert_ids.push(row.id);
hashes.remove(index);
flame_files.remove(curse_index);
}
}
if !insert_ids.is_empty() && !insert_hashes.is_empty() {
crate::database::models::moderation_external_item::ExternalLicense::insert_files(
&pool,
&insert_hashes,
&insert_filenames,
&insert_ids,
DBUserId(0),
)
.await?;
}
if hashes.is_empty() {
let metadata = MissingMetadata {
identified: final_hashes,
flame_files: HashMap::new(),
unknown_files: HashMap::new(),
};
sqlx::query!(
"
UPDATE files
SET metadata = $1
WHERE id = $2
",
serde_json::to_value(&metadata)?,
primary_file.id.0
)
.execute(&pool)
.await?;
if metadata.identified.values().any(|x| x.status != ApprovalType::Yes && x.status != ApprovalType::WithAttributionAndSource) {
let val = mod_messages.version_specific.entry(version.inner.version_number).or_default();
val.push(ModerationMessage::PackFilesNotAllowed {files: metadata.identified, incomplete: false });
}
continue;
}
let flame_projects = if flame_files.is_empty() {
Vec::new()
} else {
let res = client
.post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"modIds": flame_files.iter().map(|x| x.1).collect::<Vec<_>>()
}))
.send()
.await?
.text()
.await?;
serde_json::from_str::<FlameResponse<Vec<FlameProjectResponse>>>(&res)?.data
};
let mut missing_metadata = MissingMetadata {
identified: final_hashes,
flame_files: HashMap::new(),
unknown_files: HashMap::new(),
};
for (sha1, _pack_file, file_name, _mumur2) in hashes {
let flame_file = flame_files.iter().find(|x| x.0 == sha1);
if let Some((_, flame_project_id)) = flame_file
&& let Some(project) = flame_projects.iter().find(|x| &x.id == flame_project_id) {
missing_metadata.flame_files.insert(sha1, MissingMetadataFlame {
title: project.name.clone(),
file_name,
url: project.links.website_url.clone(),
id: *flame_project_id,
});
continue;
}
missing_metadata.unknown_files.insert(sha1, file_name);
}
sqlx::query!(
"
UPDATE files
SET metadata = $1
WHERE id = $2
",
serde_json::to_value(&missing_metadata)?,
primary_file.id.0
)
.execute(&pool)
.await?;
if missing_metadata.identified.values().any(|x| x.status != ApprovalType::Yes && x.status != ApprovalType::WithAttributionAndSource) {
let val = mod_messages.version_specific.entry(version.inner.version_number).or_default();
val.push(ModerationMessage::PackFilesNotAllowed {files: missing_metadata.identified, incomplete: true });
}
} else {
if primary_file.is_none() {
let val = mod_messages.version_specific.entry(version.inner.version_number).or_default();
val.push(ModerationMessage::NoPrimaryFile);
}
@@ -921,13 +579,3 @@ pub struct FlameLogo {
pub struct FlameLinks {
pub website_url: String,
}
fn hash_flame_murmur32(input: Vec<u8>) -> u32 {
murmur2::murmur2(
&input
.into_iter()
.filter(|x| *x != 9 && *x != 10 && *x != 13 && *x != 32)
.collect::<Vec<u8>>(),
1,
)
}
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::{
DBOrganization, DBTeamMember,
DBOrganization, DBTeamMember, DBVersion,
ids::{
DBAttributionGroupId, DBProjectId, DBVersionId,
generate_attribution_group_id,
@@ -143,31 +143,59 @@ async fn scan(
project_ids.sort_unstable_by_key(|id| id.0);
project_ids.dedup_by_key(|id| id.0);
for project_id in project_ids {
for project_id in &project_ids {
ensure_can_upload_versions_to_project(
pool.as_ref(),
project_id,
*project_id,
&user,
"you do not have permission to upload versions to this project",
)
.await?;
}
let project_ids = project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin attribution scan transaction")?;
sqlx::query!(
r#"
delete from attributions_exemptions
where version_id = any($1) or project_id = any($2)
"#,
&version_ids,
&project_ids,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to remove attribution scan exemptions")?;
let result = sqlx::query!(
r#"
insert into file_scans (file_id)
select f.id
from files f
inner join attribution_enforced_versions aev on aev.id = f.version_id
where f.version_id = any($1)
on conflict (file_id) do nothing
"#,
&version_ids,
)
.execute(pool.as_ref())
.execute(&mut transaction)
.await
.wrap_internal_err("failed to queue version files for attribution scan")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit attribution scan transaction")?;
let version_ids =
version_ids.into_iter().map(DBVersionId).collect::<Vec<_>>();
DBVersion::clear_cache_ids(&version_ids, redis.as_ref())
.await
.wrap_internal_err("failed to clear version cache")?;
Ok(web::Json(ScanResponse {
queued_files: result.rows_affected(),
}))
@@ -491,6 +519,9 @@ async fn update_group(
return Err(ApiError::NotFound);
}
clear_group_version_cache(pool.as_ref(), redis.as_ref(), &[group_id])
.await?;
Ok(())
}
@@ -640,6 +671,14 @@ async fn assign(
.await
.wrap_internal_err("failed to clean up empty attribution groups")?;
clear_project_sha1_version_cache(
pool.as_ref(),
redis.as_ref(),
project_id,
&sha1_bytes,
)
.await?;
Ok(())
}
@@ -745,6 +784,72 @@ async fn split(
.await
.wrap_internal_err("failed to clean up empty attribution groups")?;
clear_project_sha1_version_cache(
pool.as_ref(),
redis.as_ref(),
project_id,
&sha1_bytes,
)
.await?;
Ok(())
}
async fn clear_group_version_cache(
pool: &PgPool,
redis: &RedisPool,
group_ids: &[i64],
) -> Result<(), ApiError> {
let version_ids = sqlx::query_scalar!(
r#"
select distinct f.version_id as "version_id: DBVersionId"
from project_attribution_files paf
inner join project_attribution_groups pag on pag.id = paf.group_id
inner join override_file_sources ofs on ofs.sha1 = paf.sha1
inner join files f on f.id = ofs.file_id
inner join versions v on v.id = f.version_id
where paf.group_id = any($1)
and pag.project_id = v.mod_id
"#,
group_ids,
)
.fetch_all(pool)
.await
.wrap_internal_err("failed to fetch attribution group versions")?;
DBVersion::clear_cache_ids(&version_ids, redis)
.await
.wrap_internal_err("failed to clear version attribution cache")?;
Ok(())
}
async fn clear_project_sha1_version_cache(
pool: &PgPool,
redis: &RedisPool,
project_id: DBProjectId,
sha1: &[u8],
) -> Result<(), ApiError> {
let version_ids = sqlx::query_scalar!(
r#"
select distinct f.version_id as "version_id: DBVersionId"
from override_file_sources ofs
inner join files f on f.id = ofs.file_id
inner join versions v on v.id = f.version_id
where ofs.sha1 = $1
and v.mod_id = $2
"#,
sha1,
project_id as DBProjectId,
)
.fetch_all(pool)
.await
.wrap_internal_err("failed to fetch attribution file versions")?;
DBVersion::clear_cache_ids(&version_ids, redis)
.await
.wrap_internal_err("failed to clear version attribution cache")?;
Ok(())
}
+1 -1
View File
@@ -65,7 +65,7 @@ pub async fn forge_updates(
let versions = database::models::DBVersion::get_many(
&project.versions,
&**pool,
&***ro_pool,
&redis,
)
.await?;
+15 -6
View File
@@ -1307,7 +1307,8 @@ pub async fn dependency_list_internal(
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let result = db_models::DBProject::get(&string, &**pool, &redis).await?;
let result =
db_models::DBProject::get(&string, &***ro_pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
@@ -1329,7 +1330,7 @@ pub async fn dependency_list_internal(
let dependencies = database::DBProject::get_dependencies(
project.inner.id,
&**pool,
&***ro_pool,
&redis,
)
.await?;
@@ -1355,11 +1356,19 @@ pub async fn dependency_list_internal(
.unique()
.collect::<Vec<db_models::DBVersionId>>();
let (projects_result, versions_result) = futures::future::try_join(
database::DBProject::get_many_ids(&project_ids, &**pool, &redis),
database::DBProject::get_many_ids(
&project_ids,
&***ro_pool,
&redis,
),
async {
database::DBVersion::get_many(&dep_version_ids, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch dependency versions")
database::DBVersion::get_many(
&dep_version_ids,
&***ro_pool,
&redis,
)
.await
.wrap_internal_err("failed to fetch dependency versions")
},
)
.await?;
+1 -1
View File
@@ -251,7 +251,7 @@ pub async fn get_versions_from_hashes(
.await?,
&user_option,
&pool,
&pool,
pool.as_ref(),
&redis,
)
.await?;
+25 -28
View File
@@ -2,8 +2,7 @@ use std::collections::HashMap;
use super::ApiError;
use crate::auth::checks::{
enrich_dependency_attributions, filter_visible_versions,
is_visible_project, is_visible_version,
filter_visible_versions, is_visible_project, is_visible_version,
};
use crate::auth::get_user_from_headers;
use crate::database;
@@ -81,7 +80,7 @@ pub async fn version_project_get_helper(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let result =
database::models::DBProject::get(&id.0, &**pool, &redis).await?;
database::models::DBProject::get(&id.0, &***ro_pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
@@ -103,7 +102,7 @@ pub async fn version_project_get_helper(
let versions = database::models::DBVersion::get_many(
&project.versions,
&**pool,
&***ro_pool,
&redis,
)
.await?;
@@ -114,20 +113,16 @@ pub async fn version_project_get_helper(
|| x.inner.version_number == id.1
});
if let Some(mut version) = version
if let Some(version) = version
&& is_visible_version(&version.inner, &user_option, &pool, &redis)
.await?
{
let version_id = version.inner.id;
enrich_dependency_attributions(
std::slice::from_mut(&mut version),
&ro_pool,
)
.await;
let mut v = models::projects::Version::from(version);
let missing = get_files_missing_attribution(&**pool, &[version_id])
.await
.unwrap_or_default();
let missing =
get_files_missing_attribution(&***ro_pool, &[version_id])
.await
.unwrap_or_default();
v.files_missing_attribution = missing
.get(&version_id)
.map(|entries| {
@@ -180,9 +175,12 @@ pub async fn versions_get(
.into_iter()
.map(|x| x.into())
.collect::<Vec<database::models::DBVersionId>>();
let versions_data =
database::models::DBVersion::get_many(&version_ids, &**pool, &redis)
.await?;
let versions_data = database::models::DBVersion::get_many(
&version_ids,
&***ro_pool,
&redis,
)
.await?;
let user_option = get_user_from_headers(
&req,
@@ -234,7 +232,8 @@ pub async fn version_get_helper(
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<models::projects::Version>, ApiError> {
let version_data =
database::models::DBVersion::get(id.into(), &**pool, &redis).await?;
database::models::DBVersion::get(id.into(), &***ro_pool, &redis)
.await?;
let user_option = get_user_from_headers(
&req,
@@ -247,17 +246,12 @@ pub async fn version_get_helper(
.map(|x| x.1)
.ok();
if let Some(mut data) = version_data
if let Some(data) = version_data
&& is_visible_version(&data.inner, &user_option, &pool, &redis).await?
{
let version_id = data.inner.id;
enrich_dependency_attributions(
std::slice::from_mut(&mut data),
&ro_pool,
)
.await;
let mut version = models::projects::Version::from(data);
let missing = get_files_missing_attribution(&**pool, &[version_id])
let missing = get_files_missing_attribution(&***ro_pool, &[version_id])
.await
.unwrap_or_default();
version.files_missing_attribution = missing
@@ -839,7 +833,7 @@ pub async fn version_list_internal(
let string = info.into_inner().0;
let result =
database::models::DBProject::get(&string, &**pool, &redis).await?;
database::models::DBProject::get(&string, &***ro_pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
@@ -868,7 +862,7 @@ pub async fn version_list_internal(
});
let mut versions = database::models::DBVersion::get_many(
&project.versions,
&**pool,
&***ro_pool,
&redis,
)
.await?
@@ -926,11 +920,14 @@ pub async fn version_list_internal(
// TODO: This is a bandaid fix for detecting auto-featured versions.
// In the future, not all versions will have 'game_versions' fields, so this will need to be changed.
let (loaders, game_versions) = futures::future::try_join(
database::models::loader_fields::Loader::list(&**pool, &redis),
database::models::loader_fields::Loader::list(
&***ro_pool,
&redis,
),
database::models::legacy_loader_fields::MinecraftGameVersion::list(
None,
Some(true),
&**pool,
&***ro_pool,
&redis,
),
)
+9
View File
@@ -10,6 +10,14 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-24T00:30:00-07:00`,
product: 'web',
body: `## Fixed
- Fixed a number of consistency issues across collection, project, and user pages.
- Fixed a number of bugs with the new modpack permissions system.
- Fxied some issues with the new version page.`,
},
{
date: `2026-06-23T19:31:16+00:00`,
product: 'web',
@@ -25,6 +33,7 @@ const VERSIONS: VersionEntry[] = [
- Added members breakdown/filter to revenue analytics.
## Changed
- Overhauled the version page.
- Removed project version's project column when there were multiple projects selected with project version breakdown as it's redundant when project breakdown can be added.
## Fixed
@@ -36,7 +36,7 @@ const { hierarchicalSidebarAvailable } = injectPageContext()
</template>
<style scoped>
.ui-normal-page {
@apply grid gap-6 mx-auto py-4;
@apply grid gap-x-6 gap-y-4 mx-auto py-4;
width: min(calc(100% - 2rem), calc(80rem - 3rem));
grid-template:
@@ -9,12 +9,13 @@ defineProps<{
</script>
<template>
<div
class="flex flex-col gap-3 p-4"
class="flex flex-col gap-3 p-4 pt-3"
:class="{
'card-shadow mb-4 last:mb-0 rounded-2xl bg-bg-raised': !hierarchicalSidebarAvailable,
'card-shadow mb-4 last:mb-0 rounded-2xl bg-surface-3 border border-solid border-surface-4':
!hierarchicalSidebarAvailable,
}"
>
<span class="font-semibold">{{ title }}</span>
<h2 class="text-lg font-semibold m-0">{{ title }}</h2>
<slot />
</div>
</template>
@@ -10,10 +10,10 @@
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="flex flex-col flex-nowrap justify-center">
<span class="group-hover:underline font-normal text-contrast">
<span class="group-hover:underline font-medium">
{{ organization.name }}
</span>
<span class="text-sm font-normal flex items-center gap-1"
<span class="text-sm font-normal text-secondary flex items-center gap-1"
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
>
</div>
@@ -30,9 +30,7 @@
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden font-normal text-contrast truncate">{{
member.user.username
}}</span>
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(messages.owner)"
@@ -40,7 +38,7 @@
/>
<ExternalIcon v-if="linkTarget === '_blank'" />
</span>
<span class="text-sm font-normal">{{ member.role }}</span>
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
</div>
</AutoLink>
</div>
@@ -483,7 +483,7 @@ const earnedBadges = computed(() => {
<template>
<div v-if="earnedBadges.length > 0 || !!downloadsBadge" class="flex flex-col">
<h2 class="text-lg text-contrast m-0 mb-2">
<h2 class="text-lg font-semibold text-contrast m-0 mb-2">
{{ formatMessage(messages.title) }}
</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(64px,1fr))] gap-2">
@@ -1,7 +1,12 @@
<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 { DownloadIcon, ExternalIcon, FileIcon, SearchIcon } from '@modrinth/assets'
import {
capitalizeString,
formatVersionsForDisplay,
type GameVersionTag,
renderHighlightedString,
} from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
@@ -11,6 +16,7 @@ import { useCompactNumber, useFormatNumber } from '#ui/composables/format-number
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 { injectTags } from '#ui/providers/tags.ts'
import {
commonMessages,
fileTypeMessages,
@@ -41,12 +47,23 @@ const { formatCompactNumber } = useCompactNumber()
const props = defineProps<{
version: Labrinth.Versions.v3.Version
enrichment?: Labrinth.Projects.v2.DependencyInfo
enrichmentLoading?: boolean
dependencyLinkCreator: (context: DependencyContext) => string | undefined
members?: Labrinth.Projects.v3.TeamMember[]
userLinkCreator?: (user: Labrinth.Users.v3.User) => string | undefined
}>()
const api = injectModrinthClient()
const tags = injectTags(null)
const gameVersionsToDisplay = computed(() =>
tags?.gameVersions.value?.length
? formatVersionsForDisplay(
props.version.game_versions,
tags.gameVersions.value as GameVersionTag[],
)
: props.version.game_versions,
)
const versionNumber = computed(() => props.version.version_number)
const versionSubtitle = computed(() => props.version.name)
@@ -101,22 +118,82 @@ const optionalContent = computed(() =>
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 embeddedDependencies = computed(() =>
dependencies.value.filter((context) => context.dependency.dependency_type === 'embedded'),
)
const includedContentLoading = computed(
() =>
!!props.enrichmentLoading &&
embeddedDependencies.value.some((context) => context.dependency.project_id && !context.project),
)
function getEmbeddedDependencyId(context: DependencyContext) {
return (
context.dependency.project_id ??
context.dependency.file_name ??
context.dependency.version_id ??
'included'
)
}
type IncludedContentLoadingRow = { loading: true; id: string }
type IncludedContentRow = {
id: string
icon_url?: string
name?: string
version?: string
link?: string
hasProject: boolean
}
function isIncludedContentLoadingRow(
row: IncludedContentLoadingRow | IncludedContentRow,
): row is IncludedContentLoadingRow {
return 'loading' in row
}
const includedContent = computed((): (IncludedContentLoadingRow | IncludedContentRow)[] => {
if (includedContentLoading.value) {
return embeddedDependencies.value.map((context) => ({
loading: true as const,
id: getEmbeddedDependencyId(context),
}))
}
return embeddedDependencies.value
.map((context) => {
const resolution = context.dependency.attribution?.resolution
return {
id: getEmbeddedDependencyId(context),
icon_url: context.project
? context.project.icon_url
: context.dependency.attribution?.flame_project?.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)
: resolution && 'link_to_work' in resolution
? resolution.link_to_work
: undefined,
hasProject: !!context.project,
}
})
.sort((a, b) => {
const priority = (row: typeof a) => {
if (row.hasProject) return 0
if (row.link) return 1
return 2
}
const priorityDiff = priority(a) - priority(b)
if (priorityDiff !== 0) {
return priorityDiff
}
const sortName = (name: string | undefined) => (name ?? '').replace(/[^a-zA-Z0-9]/g, '')
return sortName(a.name).localeCompare(sortName(b.name))
})
})
const contentTableColumns = computed(() => {
const cols = [
{
@@ -128,7 +205,11 @@ const contentTableColumns = computed(() => {
label: formatMessage(defineMessage({ id: 'version.content.name', defaultMessage: 'Name' })),
},
]
if (includedContent.value.some((x) => x.version)) {
if (
includedContentLoading.value
? embeddedDependencies.value.some((context) => context.dependency.version_id)
: includedContent.value.some((row) => 'version' in row && row.version)
) {
cols.push({
key: 'version',
label: formatMessage(
@@ -306,21 +387,6 @@ const authorLink = computed(() =>
</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
@@ -333,7 +399,7 @@ const authorLink = computed(() =>
{{ formatMessage(projectCompatibilityMessages.minecraftJava) }}
<div class="flex gap-1 flex-wrap mt-2">
<TagItem
v-for="gameVersion in version.game_versions"
v-for="gameVersion in gameVersionsToDisplay"
:key="`version-compat-game-version-${gameVersion}`"
>{{ gameVersion }}</TagItem
>
@@ -373,9 +439,24 @@ const authorLink = computed(() =>
</div>
</div>
</section>
<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="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 class="p-4 bg-surface-3 rounded-2xl border-solid border border-surface-4">
<div
v-if="version.changelog"
class="markdown-body"
@@ -414,7 +495,7 @@ const authorLink = computed(() =>
/>
</div>
</section>
<section v-if="includedContent?.length > 0" id="content">
<section v-if="embeddedDependencies.length > 0" id="content">
<h3 class="mt-0 mb-2 text-lg font-semibold">
{{
formatMessage(
@@ -422,36 +503,60 @@ const authorLink = computed(() =>
)
}}
</h3>
<Table :columns="contentTableColumns" :data="includedContent">
<Table :columns="contentTableColumns" :data="includedContent" row-key="id">
<template #header-actions>
<StyledInput
v-model="contentSearchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchContent)"
:disabled="includedContentLoading"
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-if="isIncludedContentLoadingRow(row)"
class="size-[2rem] shrink-0 rounded-[16%] bg-surface-3 animate-pulse"
/>
<AutoLink v-else :to="row.link" tabindex="-1" class="flex" target="_blank">
<Avatar v-if="row.hasProject || 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 }">
<div
v-if="isIncludedContentLoadingRow(row)"
class="h-4 max-w-[12rem] rounded-lg bg-surface-3 animate-pulse"
/>
<AutoLink
v-else
:to="row.link"
class="flex w-fit"
link-class="hover:underline hover:text-contrast"
target="_blank"
>
{{ row.name }}
<ExternalIcon v-if="row.link?.startsWith('http')" class="shrink-0 ml-2" />
</AutoLink>
</template>
<template #cell-version="{ row }">
{{ (row.version ?? row.hasProject) ? formatMessage(commonMessages.unknownLabel) : '—' }}
<div
v-if="isIncludedContentLoadingRow(row)"
class="h-4 w-16 rounded-lg bg-surface-3 animate-pulse"
/>
<AutoLink
v-else
:to="row.version ? row.link : undefined"
class="flex w-fit"
tabindex="-1"
link-class="hover:underline hover:text-contrast"
target="_blank"
>
{{ row.version ?? '—' }}
</AutoLink>
</template>
</Table>
</section>