mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e6f5c06a | ||
|
|
0d7839404b | ||
|
|
671e3535a9 | ||
|
|
e4f02c399d | ||
|
|
c3968e3d0b | ||
|
|
f140d0c212 | ||
|
|
fa6c80767c | ||
|
|
13a6fafed0 | ||
|
|
c392c85917 | ||
|
|
29110890e1 | ||
|
|
a18f67bf0c | ||
|
|
623e01dbff | ||
|
|
14c728c8e3 | ||
|
|
a1b2073f57 | ||
|
|
45c417e406 | ||
|
|
3c60cc6f08 | ||
|
|
06353bbc14 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
}
|
||||
Generated
+23
@@ -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"
|
||||
}
|
||||
Generated
-14
@@ -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"
|
||||
}
|
||||
Generated
+15
@@ -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"
|
||||
}
|
||||
+5
-3
@@ -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"
|
||||
}
|
||||
Generated
+15
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
-15
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
+20
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>"`
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn forge_updates(
|
||||
|
||||
let versions = database::models::DBVersion::get_many(
|
||||
&project.versions,
|
||||
&**pool,
|
||||
&***ro_pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -251,7 +251,7 @@ pub async fn get_versions_from_hashes(
|
||||
.await?,
|
||||
&user_option,
|
||||
&pool,
|
||||
&pool,
|
||||
pool.as_ref(),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user