({
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
-
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/analytics.vue b/apps/frontend/src/pages/[type]/[project]/settings/analytics.vue
index 352e4ffae3..7b3651a0c9 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/analytics.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/analytics.vue
@@ -3,5 +3,9 @@
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
index ef168ec53f..6cd1c44bef 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
@@ -1,5 +1,6 @@
+
@@ -23,9 +24,10 @@
"
:on-image-upload="onUploadHandler"
/>
-
-
- {{ descriptionWarning }}
+
+
+ {{ descriptionWarning }}
+
+
+
+
+
+
+ {{ formatMessage(messages.title) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setDisclosureLockStatus('ai_content', status)
+ "
+ />
+ setDisclosureLockStatus('advertisements', status)
+ "
+ />
+ setDisclosureLockStatus('paid_features', status)
+ "
+ />
+ setDisclosureLockStatus('telemetry', status)
+ "
+ />
+ setDisclosureLockStatus('derivative_work', status)
+ "
+ />
+ setDisclosureLockStatus('epilepsy_triggers', status)
+ "
+ />
+ setDisclosureLockStatus('system_interactions', status)
+ "
+ />
+ setDisclosureLockStatus('archived', status)
+ "
+ />
+
+
+
+
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/environment.vue b/apps/frontend/src/pages/[type]/[project]/settings/environment.vue
index 84e4ec6cb9..6df9dd5c2b 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/environment.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/environment.vue
@@ -12,14 +12,14 @@
Visit Project Settings to manage environments for each version.
-
-
- Edit versions
-
-
+
+ Edit versions
+
@@ -27,7 +27,8 @@
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/license.vue b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
index 34e2f3564d..fea81bc4ff 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/license.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
@@ -28,12 +28,13 @@
-
@@ -155,24 +156,28 @@
+
+
+
+
+ {{ formatMessage(messages.title) }}
+
+
+
+
+
+
-
+
+
+
+ {{ formatMessage(messages.featuredTags) }}
+
+
+ {{ current.featuredTags.length }}/{{ MAX_FEATURED_TAGS }}
+
+
+
+ {{ formatMessage(messages.featuredTagsDescription) }}
+
+
+ {{ formatMessage(messages.selectAtLeastOneCategory) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
index a0f385a639..c6e29f821b 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
@@ -35,16 +35,16 @@
>
-
-
- {{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
-
-
-
+
+ {{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
+
+
@@ -67,180 +67,189 @@
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
-
-
+
+
+
+ {{ formatMessage(messages.editFilesOption) }}
+
+
+
+ {{ formatMessage(messages.editDetailsOption) }}
+
+
+
+ {{ formatMessage(messages.editMetadataOption) }}
+
+
+
-
-
-
- {{ formatMessage(messages.editFilesOption) }}
-
-
-
- {{ formatMessage(messages.editDetailsOption) }}
-
-
-
- {{ formatMessage(messages.editMetadataOption) }}
-
-
-
-
-
-
-
-
- {{ formatMessage(commonMessages.downloadButton) }}
-
-
-
- {{ formatMessage(messages.openInNewTabOption) }}
-
-
-
- {{ formatMessage(messages.copyLinkOption) }}
-
-
-
- {{ formatMessage(messages.shareOption) }}
-
-
-
- {{ formatMessage(commonMessages.reportButton) }}
-
-
-
- {{ formatMessage(messages.editFilesOption) }}
-
-
-
- {{ formatMessage(messages.editDetailsOption) }}
-
-
-
- {{ formatMessage(messages.editMetadataOption) }}
-
-
-
- {{ formatMessage(commonMessages.deleteLabel) }}
-
-
-
- {{ formatMessage(messages.copyIdOption) }}
-
-
-
- {{ formatMessage(messages.copyMavenCoordinatesOption) }}
-
-
-
+ shown: !!currentMember,
+ },
+ ]"
+ @select="$event.id === 'download' && emit('onDownload')"
+ >
+
+
+
+ {{ formatMessage(commonMessages.downloadButton) }}
+
+
+
+ {{ formatMessage(messages.openInNewTabOption) }}
+
+
+
+ {{ formatMessage(messages.copyLinkOption) }}
+
+
+
+ {{ formatMessage(messages.shareOption) }}
+
+
+
+ {{ formatMessage(commonMessages.reportButton) }}
+
+
+
+ {{ formatMessage(messages.editFilesOption) }}
+
+
+
+ {{ formatMessage(messages.editDetailsOption) }}
+
+
+
+ {{ formatMessage(messages.editMetadataOption) }}
+
+
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
+
+ {{ formatMessage(messages.copyIdOption) }}
+
+
+
+ {{ formatMessage(messages.copyMavenCoordinatesOption) }}
+
+
@@ -304,11 +313,13 @@
No versions created
Create your first project version.
-
- createProjectVersionModal?.openCreateVersionModal()">
- Create version
-
-
+
createProjectVersionModal?.openCreateVersionModal()"
+ >
+ Create version
+
@@ -335,7 +346,8 @@ import {
} from '@modrinth/assets'
import {
Admonition,
- ButtonStyled,
+ Button,
+ ButtonLink,
commonMessages,
commonProjectSettingsMessages,
ConfirmModal,
@@ -343,8 +355,8 @@ import {
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
- OverflowMenu,
ProjectPageVersions,
+ TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef, watch } from 'vue'
@@ -369,6 +381,8 @@ const {
cdnDownloadReason,
} = injectProjectPageContext()
+useProjectSettingsHeadTitle(commonProjectSettingsMessages.versions)
+
// Load versions on mount (client-side)
onMounted(() => {
loadVersions()
diff --git a/apps/frontend/src/pages/[type]/[project]/version/[version].vue b/apps/frontend/src/pages/[type]/[project]/version/[version].vue
index 5facef220d..193aeb89f7 100644
--- a/apps/frontend/src/pages/[type]/[project]/version/[version].vue
+++ b/apps/frontend/src/pages/[type]/[project]/version/[version].vue
@@ -65,18 +65,19 @@
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
- {{ formatMessage(messages.packageDataPack) }}
-
-
-
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+ {{ formatMessage(messages.packageDataPack) }}
+
+
@@ -104,16 +105,16 @@
>
-
-
- {{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
-
-
-
+
+ {{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
+
+
@@ -127,23 +128,39 @@
class="mb-4"
>
-
-
-
- {{ formatMessage(commonMessages.downloadButton) }}
-
-
-
+
+ {{ formatMessage(commonMessages.downloadButton) }}
+
+
+
+ Open
+
+
-
-
-
- {{ formatMessage(messages.requiredResourcePack) }}
-
-
- {{ formatMessage(messages.optionalResourcePack) }}
-
-
-
+
+
+ {{ formatMessage(messages.requiredResourcePack) }}
+
+
+ {{ formatMessage(messages.optionalResourcePack) }}
+
+
-
-
-
- {{ formatMessage(messages.packageAsMod) }}
-
-
-
-
- {{ formatMessage(messages.edit) }}
-
-
-
- {{ formatMessage(messages.editMetadata) }}
-
-
-
- {{ formatMessage(messages.editDetails) }}
-
-
-
- {{ formatMessage(messages.editFiles) }}
-
-
-
- {{ formatMessage(commonMessages.deleteLabel) }}
-
-
-
-
-
-
+ {{ formatMessage(messages.packageAsMod) }}
+
+
-
-
-
- {{ formatMessage(commonMessages.reportButton) }}
+
+
+
+ {{ formatMessage(messages.editMetadata) }}
-
-
- {{ formatMessage(commonMessages.reportButton) }}
+
+
+ {{ formatMessage(messages.editDetails) }}
-
-
- {{ formatMessage(commonMessages.copyIdButton) }}
+
+
+ {{ formatMessage(messages.editFiles) }}
-
-
- {{ formatMessage(commonMessages.copyPermalinkButton) }}
+
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
-
-
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.reportButton) }}
+
+
+
+ {{ formatMessage(commonMessages.reportButton) }}
+
+
+
+ {{ formatMessage(commonMessages.copyIdButton) }}
+
+
+
+ {{ formatMessage(commonMessages.copyPermalinkButton) }}
+
+
-
-
-
- {{ formatMessage(commonMessages.downloadButton) }}
-
-
-
-
-
-
-
- {{ formatMessage(messages.copySha1) }}
-
-
-
- {{ formatMessage(messages.copySha512) }}
-
-
-
+
+
+ {{ formatMessage(commonMessages.downloadButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.copySha1) }}
+
+
+
+ {{ formatMessage(messages.copySha512) }}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
getSignInRouteObj(route))
const versionRouteParam = computed(() => route.params.version as string)
diff --git a/apps/frontend/src/pages/[type]/[project]/versions.vue b/apps/frontend/src/pages/[type]/[project]/versions.vue
index 7770d541b6..adefb0711f 100644
--- a/apps/frontend/src/pages/[type]/[project]/versions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/versions.vue
@@ -41,192 +41,217 @@
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
-
-
+
+
+
+
+
+
+
+
+
+
+ Edit files
+
+
+
+ Edit details
+
+
+
+ Edit metadata
+
+
+
+
+
-
-
-
-
-
-
-
- Edit files
-
-
-
- Edit details
-
-
-
- Edit metadata
-
-
-
-
-
-
-
-
- Download
-
-
-
- Open in new tab
-
-
-
- Copy link
-
-
-
- Share
-
-
-
- Report
-
-
-
- Edit files
-
-
-
- Edit details
-
-
-
- Edit metadata
-
-
-
- Delete
-
-
-
- Copy ID
-
-
-
- Copy Maven coordinates
-
-
-
+ Download
+
+
+
+ Open in new tab
+
+
+
+ Copy link
+
+
+
+ Share
+
+
+
+ Report
+
+
+
+ Edit files
+
+
+
+ Edit details
+
+
+
+ Edit metadata
+
+
+
+ Delete
+
+
+
+ Copy ID
+
+
+
+ Copy Maven coordinates
+
+
@@ -258,15 +283,17 @@ import {
SpinnerIcon,
TrashIcon,
} from '@modrinth/assets'
+import { moderationSettings } from '@modrinth/moderation'
import {
- ButtonStyled,
+ ButtonLink,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
- OverflowMenu,
ProjectPageVersions,
+ TeleportOverflowMenu,
} from '@modrinth/ui'
+import { isStaff } from '@modrinth/utils'
import { onMounted, useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
@@ -279,6 +306,7 @@ const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownload
const tags = useGeneratedState()
const flags = useFeatureFlags()
+const modSettings = useModerationSettings()
const auth = await useAuth()
const client = injectModrinthClient()
diff --git a/apps/frontend/src/pages/admin/affiliates.vue b/apps/frontend/src/pages/admin/affiliates.vue
index 92925757ce..c67abeb81c 100644
--- a/apps/frontend/src/pages/admin/affiliates.vue
+++ b/apps/frontend/src/pages/admin/affiliates.vue
@@ -27,12 +27,10 @@
placeholder="Search affiliates..."
clearable
/>
-
-
-
- Create affiliate code
-
-
+
+
+ Create affiliate code
+
@@ -90,7 +88,7 @@ import {
AffiliateLinkCard,
AffiliateLinkCreateModal,
Avatar,
- ButtonStyled,
+ Button,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
diff --git a/apps/frontend/src/pages/admin/analytics/events.vue b/apps/frontend/src/pages/admin/analytics/events.vue
index bffebfaada..f3a9dddfb3 100644
--- a/apps/frontend/src/pages/admin/analytics/events.vue
+++ b/apps/frontend/src/pages/admin/analytics/events.vue
@@ -33,19 +33,20 @@
-
- Cancel
-
-
-
-
- {{ modalMode === 'create' ? 'Create event' : 'Save' }}
-
-
+ Cancel
+
+
+ {{ modalMode === 'create' ? 'Create event' : 'Save' }}
+
@@ -137,12 +139,10 @@
clearable
wrapper-class="w-full sm:w-72"
/>
-
-
-
- New event
-
-
+
+
+ New event
+
@@ -196,21 +196,23 @@
-
-
-
-
-
-
-
- Edit
-
-
-
+
+
+
+
+ Edit
+
+
@@ -239,8 +241,8 @@ import {
SpinnerIcon,
TrashIcon,
} from '@modrinth/assets'
+import { Button, ButtonLink, IconButton } from '@modrinth/ui'
import {
- ButtonStyled,
ConfirmModal,
DatePicker,
injectModrinthClient,
@@ -485,9 +487,9 @@ async function saveEvent() {
const payload = buildEventPayload()
if (modalMode.value === 'edit' && editingEventId.value !== null) {
- await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
+ await client.labrinth.analytics_internal.editEvent(editingEventId.value, payload)
} else {
- await client.labrinth.analytics_v3.createEvent(payload)
+ await client.labrinth.analytics_internal.createEvent(payload)
}
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
@@ -528,7 +530,7 @@ async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
setDeletingEvent(eventId, true)
try {
- await client.labrinth.analytics_v3.deleteEvent(eventId)
+ await client.labrinth.analytics_internal.deleteEvent(eventId)
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
addNotification({
title: 'Analytics event deleted',
@@ -581,7 +583,7 @@ function commitAnnouncementUrl() {
committedAnnouncementUrl.value = form.value.announcementUrl
}
-function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
+function buildEventPayload(): Labrinth.Analytics.Internal.AnalyticsEventUpsert {
const selectedRange = getEventFormDateRange()
if (!selectedRange) {
throw new Error('Select a valid start and end date')
diff --git a/apps/frontend/src/pages/admin/billing/[user].vue b/apps/frontend/src/pages/admin/billing/[user].vue
index 936ac39ca3..e8e9d75300 100644
--- a/apps/frontend/src/pages/admin/billing/[user].vue
+++ b/apps/frontend/src/pages/admin/billing/[user].vue
@@ -43,18 +43,14 @@
-
-
-
- Refund charge
-
-
-
-
-
- Cancel
-
-
+
+
+ Refund charge
+
+
+
+ Cancel
+
@@ -82,18 +78,14 @@
-
-
-
- Modify charge
-
-
-
-
-
- Cancel
-
-
+
+
+ Modify charge
+
+
+
+ Cancel
+
@@ -117,18 +109,14 @@
-
-
-
- Apply credit
-
-
-
-
-
- Cancel
-
-
+
+
+ Apply credit
+
+
+
+ Cancel
+
@@ -141,13 +129,11 @@
-
-
-
- User profile
-
-
-
+
+
+ User profile
+
+
@@ -176,25 +162,20 @@
-
-
- Server panel
-
-
-
-
-
- Credit
-
-
+ Server panel
+
+
+
+ Credit
+
@@ -226,7 +207,8 @@ import {
} from '@modrinth/assets'
import {
Avatar,
- ButtonStyled,
+ Button,
+ ButtonLink,
CopyCode,
defineMessages,
DropdownSelect,
diff --git a/apps/frontend/src/pages/admin/docs.vue b/apps/frontend/src/pages/admin/docs.vue
index 557caf30fd..59847b213a 100644
--- a/apps/frontend/src/pages/admin/docs.vue
+++ b/apps/frontend/src/pages/admin/docs.vue
@@ -1,6 +1,6 @@
-
+
| null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
const handleProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
- const slug = result.slug || result.project_id
+ const projectId = result.project_id
prefetchTimeout = useTimeoutFn(
() => {
- queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
- queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
- queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
- queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
- queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
+ warmProjectCheckCaches(queryClient, result)
+ queryClient.prefetchQuery(projectQueryOptions.v2(projectId, client))
+ queryClient.prefetchQuery(projectQueryOptions.v3(projectId, client))
+ queryClient.prefetchQuery(projectQueryOptions.members(projectId, client))
+ queryClient.prefetchQuery(projectQueryOptions.dependencies(projectId, client))
+ queryClient.prefetchQuery(projectQueryOptions.versionsV3(projectId, client))
},
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
@@ -83,12 +84,13 @@ const handleProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject)
}
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
- const slug = result.slug || result.project_id
+ const projectId = result.project_id
prefetchTimeout = useTimeoutFn(
async () => {
- queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
- queryClient.prefetchQuery(projectQueryOptions.v3(slug, client))
+ warmProjectCheckCaches(queryClient, result)
+ queryClient.prefetchQuery(projectQueryOptions.v2(projectId, client))
+ queryClient.prefetchQuery(projectQueryOptions.v3(projectId, client))
const content = result.minecraft_java_server?.content
if (content?.kind === 'modpack' && content.version_id) {
@@ -161,6 +163,7 @@ const {
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
+ queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
@@ -228,6 +231,19 @@ function parseSearchParams(requestParams: string): Labrinth.Search.SearchParams
}
}
+// Search returns expanded dependency data that no card renders and that isn't part of
+// ResultSearchProject. On modpacks it is ~90% of the response, and everything cached here
+// is serialized into the SSR payload, so drop it before it reaches the query cache.
+function stripUnrenderedFields(
+ hits: Labrinth.Search.v3.ResultSearchProject[],
+): Labrinth.Search.v3.ResultSearchProject[] {
+ return hits.map((hit) => {
+ const { dependencies, dependency_project_ids, compatible_dependency_project_ids, ...rendered } =
+ hit as Labrinth.Search.v3.ResultSearchProject & Record
+ return rendered as Labrinth.Search.v3.ResultSearchProject
+ })
+}
+
async function fetchSearch(requestParams: string) {
debug('search() called', {
requestParams: requestParams.substring(0, 100),
@@ -241,17 +257,19 @@ async function fetchSearch(requestParams: string) {
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
+ const hits = stripUnrenderedFields(raw.hits ?? [])
+
if (isServerType.value) {
return {
projectHits: [],
- serverHits: raw.hits,
+ serverHits: hits,
total_hits: raw.total_hits,
per_page: raw.hits_per_page,
}
}
return {
- projectHits: raw.hits,
+ projectHits: hits,
serverHits: [],
total_hits: raw.total_hits,
per_page: raw.hits_per_page,
@@ -310,6 +328,7 @@ function getCardActions(
if (serverData.value) {
const isQueued = queuedServerInstallProjectIds.value.has(result.project_id)
+ const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(result.project_id)
const isInstalled =
projectResult.installed ||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
@@ -345,7 +364,8 @@ function getCardActions(
? CheckIcon
: DownloadIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
- disabled: !!isInstalled || isInstalling || isInstallingSelection,
+ disabled:
+ !!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: () => serverInstall(projectResult),
@@ -401,6 +421,14 @@ const advancedFiltersCollapsed = computed({
},
})
+const dismissedPhotosensitivityFilterWarning = computed({
+ get: () => flags.value.dismissedPhotosensitivityFilterWarning,
+ set: (value) => {
+ flags.value.dismissedPhotosensitivityFilterWarning = value
+ saveFeatureFlags()
+ },
+})
+
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
debug('projectTypeId:', projectTypeId.value)
@@ -422,6 +450,15 @@ const searchState = useBrowseSearch({
})
setBrowseSearchState(searchState)
+// Warm check caches for every visible hit so clicking a result skips /project/{slug}/check
+watch(
+ [() => searchState.projectHits.value, () => searchState.serverHits.value],
+ ([projectHits, serverHits]) => {
+ warmProjectCheckCaches(queryClient, [...projectHits, ...serverHits])
+ },
+ { immediate: true },
+)
+
watch(
() =>
searchState.isServerType.value
@@ -510,6 +547,7 @@ provideBrowseManager({
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
advancedFiltersCollapsed,
+ dismissedPhotosensitivityFilterWarning,
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
diff --git a/apps/frontend/src/pages/hosting/index.vue b/apps/frontend/src/pages/hosting/index.vue
index dece65edec..827298042b 100644
--- a/apps/frontend/src/pages/hosting/index.vue
+++ b/apps/frontend/src/pages/hosting/index.vue
@@ -33,7 +33,7 @@
{{ formatMessage(commonMessages.betaRelease) }}
@@ -50,21 +50,23 @@
-
-
-
- {{
- hasServers
- ? formatMessage(messages.startANewServer)
- : formatMessage(messages.startYourServer)
- }}
-
-
-
-
- {{ formatMessage(messages.manageYourServers) }}
-
-
+
+
+ {{
+ hasServers
+ ? formatMessage(messages.startANewServer)
+ : formatMessage(messages.startYourServer)
+ }}
+
+
+ {{ formatMessage(messages.manageYourServers) }}
+
@@ -108,7 +110,7 @@
{{ formatMessage(messages.whyModrinthHosting) }}
@@ -152,7 +154,7 @@
-
+
{{ formatMessage(messages.yourFavoriteMods) }}
{{ formatMessage(messages.yourFavoriteModsDescription) }}
@@ -236,7 +238,7 @@
{{ formatMessage(messages.includedWithYourServer) }}
@@ -603,12 +605,10 @@
-
-
- {{ formatMessage(messages.getStartedButton) }}
-
-
-
+
+ {{ formatMessage(messages.getStartedButton) }}
+
+
{{
formatMessage(messages.startingAtPrice, {
@@ -634,14 +634,16 @@ import {
VersionIcon,
} from '@modrinth/assets'
import {
- ButtonStyled,
+ Button,
+ ButtonLink,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
- LoaderIcon,
ModrinthServersPurchaseModal,
+ OptionGroup,
+ TagIcon,
useFormatPrice,
useVIntl,
} from '@modrinth/ui'
@@ -649,7 +651,6 @@ import { monthsInInterval } from '@modrinth/ui/src/utils/billing.ts'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
-import OptionGroup from '~/components/ui/OptionGroup.vue'
import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue'
import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
import { products } from '~/generated/state.json'
diff --git a/apps/frontend/src/pages/hosting/manage/index.vue b/apps/frontend/src/pages/hosting/manage/index.vue
index 3ea3fb216b..0fb27696f6 100644
--- a/apps/frontend/src/pages/hosting/manage/index.vue
+++ b/apps/frontend/src/pages/hosting/manage/index.vue
@@ -1,7 +1,8 @@
diff --git a/apps/frontend/src/pages/index.vue b/apps/frontend/src/pages/index.vue
index 406db83516..b74b666b6b 100644
--- a/apps/frontend/src/pages/index.vue
+++ b/apps/frontend/src/pages/index.vue
@@ -25,33 +25,37 @@
{{ formatMessage(messages.discoverHeading) }}
-
-
@@ -63,7 +67,7 @@
{{ project.description }}
-
+
@@ -444,7 +448,7 @@ import {
} from '@modrinth/assets'
import {
Avatar,
- ButtonStyled,
+ ButtonLink,
commonMessages,
defineMessages,
DropdownSelect,
@@ -736,13 +740,6 @@ const creatorFeatureMessages = defineMessages({
color: var(--landing-color-subheading);
max-width: 50rem;
}
-
- .button-group {
- width: fit-content;
- gap: 1.25rem;
- margin: 0 auto 5rem;
- justify-content: center;
- }
}
.users-section-outer {
diff --git a/apps/frontend/src/pages/legal/rules.vue b/apps/frontend/src/pages/legal/rules.vue
index 7637a31c35..15e3f4a8d2 100644
--- a/apps/frontend/src/pages/legal/rules.vue
+++ b/apps/frontend/src/pages/legal/rules.vue
@@ -1,6 +1,7 @@
Content Rules
+
Last modified: August 13, 2026
These Content Rules are to be considered part of our
@@ -72,8 +73,12 @@
Projects, a form of Content, must make a clear and honest attempt to describe their purpose in
- designated areas on the project page. Necessary information must not be obscured in any way.
- Using confusing language or technical jargon when it is not necessary constitutes a violation.
+ designated areas on the project page. Necessary information must not be obscured in any way or
+ use confusing language or technical jargon when it is unnecessary.
+
+
+ Important information like content disclosures must always be described accurately and
+ appropriately.
2.1. General Expectations
@@ -144,17 +149,22 @@
which have diverged substantially from the original project.
+
+ When uploading content you have the necessary permissions to distribute but did not author
+ yourself, you must make a meaningful effort to credit each original source properly.
+
+
5. Miscellaneous
There are certain other small aspects to creating projects that all authors should attempt to
- abide by. These will not necessarily always be enforced, but abiding by all will result in a
- faster review with fewer potential issues.
+ abide by. These will not be enforced as strictly, but abiding by all will result fewer
+ potential issues during project review, and a better project with more potential reach.
- All metadata, including license, client/server-side information, tags, etc. are filled out
- correctly and are consistent with information found elsewhere.
+ All metadata, including license, environment information, tags, content disclosures, etc.
+ are filled out correctly and are consistent with information found elsewhere.
Project titles are only the name of the project, without any other unnecessary filler data.
@@ -164,13 +174,61 @@
repeating the project title.
All external links lead to public resources that are relevant.
- Gallery images are relevant to the project and each contain a title.
+
+ Gallery images are relevant to the project and do not give a false impression of the
+ project's contents.
+
All dependencies must be specified in the Dependencies section of each version.
"Additional files" are only used for special designated purposes, such as source JAR files.
In other words, separate versions and/or projects are used where appropriate instead of
additional files.
+
+ Files uploaded to a project should be directly related to the content of the initial project
+ page or a direct linear upgrade. Files with different functionality should not be uploaded
+ as new versions within a single project; instead, they should be published as separate
+ projects.
+
+
+ All applicable content disclosures are filled out accurately and maintained with up-to-date
+ information.
+
+
+
+
6. Usage of Generative "AI"
+
+
+ Projects must be forthright and honest about the usage of generative AI in their production,
+ publication, and within the project itself. Projects cannot be entirely or primarily comprised
+ of content created or derived from generative AI output.
+
+
+
6.1. Disclosure of AI generated content
+
+
You must apply the appropriate “Contains AI-generated content” content disclosure when:
+
+ a substantial portion of the project's code is a product of AI output.
+
+ the project includes any assets that are primarily or entirely a product of AI output.
+
+ the project's design or functionality relies on the use of generative AI.
+
+ any element of the project's page such as description or publishing relies on generative AI.
+
+
+
+
6.2. Prohibited AI generated content
+
+
+
+ No images uploaded to a gallery, icon, description, or any other part of a project page may
+ be created or derived from generative AI output. Any such images may be removed.
+
+
+ Projects may not be published publicly if the contents are primarily or entirely a product
+ of AI output.
+
@@ -186,3 +244,32 @@ useSeoMeta({
ogDescription: description,
})
+
+
diff --git a/apps/frontend/src/pages/legal/terms.vue b/apps/frontend/src/pages/legal/terms.vue
index 14b9d8f43c..1c76a6e15f 100644
--- a/apps/frontend/src/pages/legal/terms.vue
+++ b/apps/frontend/src/pages/legal/terms.vue
@@ -1,7 +1,7 @@
Terms of Use
-
Last modified: September 16, 2023
+
Last modified: August 13, 2026
Acceptance of the Terms of Use
These terms of use are entered into by and between You and Rinth, Inc.
@@ -223,6 +223,10 @@
Use any manual process to monitor or copy any of the material contained in the Service or
for any other unauthorized purpose without our prior written consent.
+
+ Use any data, content, or materials available on or through the Service to train, develop,
+ or improve any artificial intelligence or machine learning models.
+
Use any device, software or routine that interferes with the proper working of the Service.
diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue
index 9352acabba..4334b52079 100644
--- a/apps/frontend/src/pages/moderation.vue
+++ b/apps/frontend/src/pages/moderation.vue
@@ -1,5 +1,8 @@
-
+
Moderation
@@ -16,6 +19,7 @@
diff --git a/apps/frontend/src/pages/moderation/reports/[report].vue b/apps/frontend/src/pages/moderation/reports/[report].vue
index 806545561c..fdd656cafe 100644
--- a/apps/frontend/src/pages/moderation/reports/[report].vue
+++ b/apps/frontend/src/pages/moderation/reports/[report].vue
@@ -28,6 +28,6 @@ const { data: report } = useQuery({
-
+
diff --git a/apps/frontend/src/pages/moderation/reports/index.vue b/apps/frontend/src/pages/moderation/reports/index.vue
index 8f231c28e1..e769d666b2 100644
--- a/apps/frontend/src/pages/moderation/reports/index.vue
+++ b/apps/frontend/src/pages/moderation/reports/index.vue
@@ -21,6 +21,8 @@
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
:options="messageFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
+ trigger-type="base"
+ trigger-size="lg"
@select="goToPage(1)"
>
@@ -38,6 +40,8 @@
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
+ trigger-type="base"
+ trigger-size="lg"
@select="goToPage(1)"
>
@@ -61,6 +65,8 @@
:searchable="reporterOrProjectOptions.length > 6"
:max-tag-rows="1"
fit-content
+ trigger-type="base"
+ trigger-size="lg"
checkbox-position="right"
show-selection-actions
should-show-select-all
@@ -69,7 +75,7 @@
-
+
{{
currentReporterOrProject.length === 0
? 'All Reports'
@@ -118,8 +124,11 @@
-
- Advanced filters
+
+
+
+ Advanced filters
+
-
+
@@ -177,7 +186,12 @@
@@ -203,12 +217,13 @@ import {
Combobox,
type ComboboxOption,
commonMessages,
- FloatingPanel,
+ formatReportType,
injectModrinthClient,
MultiSelect,
type MultiSelectItem,
Pagination,
StyledInput,
+ TeleportPopoutMenu,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js'
@@ -235,9 +250,12 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true
while (hasMoreReports) {
- reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, {
- apiVersion: 3,
- })) as Labrinth.Reports.v3.Report[]
+ reports = (await useBaseFetch(
+ `report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
+ {
+ apiVersion: 3,
+ },
+ )) as Labrinth.Reports.v3.Report[]
hasMoreReports = reports.length > 0
if (!hasMoreReports) {
@@ -344,7 +362,13 @@ const reportIssueFilterTypes = computed
[]>(() => {
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
const sortedTypes = Array.from(issueTypes).sort()
- return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
+ return [
+ ...base,
+ ...sortedTypes.map((type) => ({
+ value: type,
+ label: formatReportType(formatMessage, type),
+ })),
+ ]
})
type ReportedType = T & { report_item_count: number }
diff --git a/apps/frontend/src/pages/moderation/technical-review/[project].vue b/apps/frontend/src/pages/moderation/technical-review/[project].vue
index e4da9ba339..e3f413c26b 100644
--- a/apps/frontend/src/pages/moderation/technical-review/[project].vue
+++ b/apps/frontend/src/pages/moderation/technical-review/[project].vue
@@ -1,7 +1,7 @@
-
-
- Back to queue
-
+
+
+ Back to queue
+
@@ -305,6 +332,7 @@ function refetch() {
:focused-detail-id="focusedDetailId"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
+ :collapsed="false"
@refetch="refetch"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
diff --git a/apps/frontend/src/pages/moderation/technical-review/index.vue b/apps/frontend/src/pages/moderation/technical-review/index.vue
index 9c9773c2a9..b46ed59776 100644
--- a/apps/frontend/src/pages/moderation/technical-review/index.vue
+++ b/apps/frontend/src/pages/moderation/technical-review/index.vue
@@ -12,10 +12,10 @@ import {
Combobox,
type ComboboxOption,
commonMessages,
- FloatingPanel,
injectModrinthClient,
Pagination,
StyledInput,
+ TeleportPopoutMenu,
Toggle,
useVIntl,
} from '@modrinth/ui'
@@ -32,6 +32,7 @@ useHead({ title: 'Tech review queue - Modrinth' })
const client = injectModrinthClient()
const queryClient = useQueryClient()
+const keybinds = useModerationKeybinds()
const currentPage = ref(1)
const API_PAGE_SIZE = 50
@@ -571,6 +572,50 @@ watch(totalPages, (pages) => {
// complete: 20,
// }
// })
+
+const CARD_BOTTOM_OFFSET = 210
+
+function handleKeybinds(event: KeyboardEvent) {
+ keybinds.value.handle(event, {
+ scope: 'tech-review',
+ actions: {
+ goToTop: () => {
+ Array.from(cardRefs.values())
+ .filter((card) => card.getBoundingClientRect().top <= 0)
+ .reduce((prev, curr) =>
+ curr.getBoundingClientRect().top > prev.getBoundingClientRect().top ? curr : prev,
+ )
+ ?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ },
+ goToBottom: () => {
+ const nearestCard = Array.from(cardRefs.values())
+ .filter((card) => card.getBoundingClientRect().bottom >= window.innerHeight)
+ .reduce((prev, curr) =>
+ curr.getBoundingClientRect().top < prev.getBoundingClientRect().top ? curr : prev,
+ )
+
+ if (nearestCard) {
+ window.scrollTo({
+ behavior: 'smooth',
+ top:
+ nearestCard.getBoundingClientRect().bottom +
+ window.scrollY -
+ window.innerHeight +
+ CARD_BOTTOM_OFFSET,
+ })
+ }
+ },
+ },
+ })
+}
+
+onMounted(() => {
+ window.addEventListener('keydown', handleKeybinds)
+})
+
+onUnmounted(() => {
+ window.removeEventListener('keydown', handleKeybinds)
+})
@@ -611,6 +656,8 @@ watch(totalPages, (pages) => {
v-model="currentResponseFilter"
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
:options="responseFilterTypes"
+ trigger-type="base"
+ trigger-size="lg"
>
@@ -625,6 +672,8 @@ watch(totalPages, (pages) => {
class="!w-full flex-grow sm:!w-[215px] sm:flex-grow-0"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
+ trigger-type="base"
+ trigger-size="lg"
>
@@ -638,8 +687,11 @@ watch(totalPages, (pages) => {
-
- Advanced filters
+
+
+
+ Advanced filters
+
@@ -684,7 +736,7 @@ watch(totalPages, (pages) => {
-
+
@@ -719,6 +771,7 @@ watch(totalPages, (pages) => {
:item="item"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
+ :collapsed="true"
@refetch="refetch"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
diff --git a/apps/frontend/src/pages/news/article/[slug].vue b/apps/frontend/src/pages/news/article/[slug].vue
index e4131c8d51..5be54272f1 100644
--- a/apps/frontend/src/pages/news/article/[slug].vue
+++ b/apps/frontend/src/pages/news/article/[slug].vue
@@ -4,7 +4,7 @@ import { articles as rawArticles } from '@modrinth/blog'
import {
ArticleBody,
Avatar,
- ButtonStyled,
+ ButtonLink,
injectModrinthClient,
useFormatDateTime,
} from '@modrinth/ui'
@@ -119,24 +119,31 @@ onMounted(() => {
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
>
-
+ News
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
- {{ article.summary }}
@@ -218,9 +225,19 @@ onMounted(() => {
padding: 0;
}
+ ul,
+ ol {
+ margin-bottom: 0.5rem;
+
+ ul,
+ ol {
+ margin-top: 0.25rem;
+ }
+ }
+
ul > li:not(:last-child),
ol > li:not(:last-child) {
- margin-bottom: 0.5rem;
+ margin-bottom: 0.25rem;
}
ul,
@@ -295,8 +312,10 @@ onMounted(() => {
h1,
h2 {
+ font-weight: 600;
+
a {
- font-weight: 800;
+ font-weight: 600;
}
}
diff --git a/apps/frontend/src/pages/news/changelog.vue b/apps/frontend/src/pages/news/changelog.vue
index b59fb4f7a8..2bca688d31 100644
--- a/apps/frontend/src/pages/news/changelog.vue
+++ b/apps/frontend/src/pages/news/changelog.vue
@@ -1,6 +1,6 @@
-
+
Changelog
Keep up-to-date on what's new with Modrinth.
diff --git a/apps/frontend/src/pages/news/index.vue b/apps/frontend/src/pages/news/index.vue
index b15e1ee82c..1168d4d8d1 100644
--- a/apps/frontend/src/pages/news/index.vue
+++ b/apps/frontend/src/pages/news/index.vue
@@ -1,7 +1,7 @@
-
-
diff --git a/apps/frontend/src/pages/settings/billing/index.vue b/apps/frontend/src/pages/settings/billing/index.vue
index e176b22d4e..f59a03f07e 100644
--- a/apps/frontend/src/pages/settings/billing/index.vue
+++ b/apps/frontend/src/pages/settings/billing/index.vue
@@ -165,107 +165,114 @@
v-if="midasCharge && midasCharge.status === 'failed'"
class="ml-auto flex flex-row-reverse items-center gap-2"
>
-
- {
- $refs.midasPurchaseModal.show()
- }
- "
- >
-
- {{ formatMessage(messages.updateMethod) }}
-
-
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
- {
- cancelSubscriptionId = midasSubscription.id
- $refs.modalCancel.show()
- }
- "
- >
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
-
- {{
- changingInterval
- ? formatMessage(messages.switchingToInterval, {
- interval: oppositeInterval,
- })
- : formatMessage(messages.switchToInterval, {
- interval: oppositeInterval,
- })
- }}
-
-
-
-
-
- {{ formatMessage(messages.resubscribe) }}
-
-
-
- {
$refs.midasPurchaseModal.show()
}
"
>
- {{ formatMessage(messages.subscribe) }}
-
-
+
+ {{ formatMessage(messages.updateMethod) }}
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+ {
+ cancelSubscriptionId = midasSubscription.id
+ $refs.modalCancel.show()
+ }
+ "
+ >
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ {{
+ changingInterval
+ ? formatMessage(messages.switchingToInterval, {
+ interval: oppositeInterval,
+ })
+ : formatMessage(messages.switchToInterval, {
+ interval: oppositeInterval,
+ })
+ }}
+
+
+
+ {{ formatMessage(messages.resubscribe) }}
+
+ {
+ $refs.midasPurchaseModal.show()
+ }
+ "
+ >
+ {{ formatMessage(messages.subscribe) }}
+
@@ -477,44 +484,44 @@
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
-
-
- {{ formatMessage(messages.upgrade) }}
-
-
-
+ {{ formatMessage(messages.upgrade) }}
+
+
-
- {{ formatMessage(messages.resubscribe) }}
-
-
-
+ {{ formatMessage(messages.resubscribe) }}
+
+
@@ -565,16 +572,12 @@
-
-
- {{ formatMessage(messages.paymentMethodHistory) }}
-
-
-
-
- {{ formatMessage(messages.paymentMethodAdd) }}
-
-
+
+ {{ formatMessage(messages.paymentMethodHistory) }}
+
+
+ {{ formatMessage(messages.paymentMethodAdd) }}
+
-
-
-
-
-
- {{ formatMessage(messages.paymentMethodMakePrimary) }}
-
-
-
- {{ formatMessage(commonMessages.editButton) }}
-
-
-
- {{ formatMessage(commonMessages.deleteLabel) }}
-
-
-
+ tone: 'red',
+ },
+ ].slice(primaryPaymentMethodId === method.id ? 1 : 0, 2)
+ "
+ >
+
+
+
+ {{ formatMessage(messages.paymentMethodMakePrimary) }}
+
+
+
+ {{ formatMessage(commonMessages.editButton) }}
+
+
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
@@ -694,7 +697,8 @@ import {
} from '@modrinth/assets'
import {
AddPaymentMethodModal,
- ButtonStyled,
+ Button,
+ ButtonLink,
commonMessages,
ConfirmModal,
CopyCode,
@@ -702,11 +706,11 @@ import {
getPaymentMethodIcon,
injectModrinthClient,
injectNotificationManager,
- OverflowMenu,
paymentMethodMessages,
PurchaseModal,
ResubscribeModal,
ServerListing,
+ TeleportOverflowMenu,
useFormatDateTime,
useFormatPrice,
useServerBackupDownload,
@@ -729,7 +733,6 @@ definePageMeta({
})
const auth = await useAuth()
-const baseId = useId()
useHead({
script: [
diff --git a/apps/frontend/src/pages/settings/flags.vue b/apps/frontend/src/pages/settings/flags.vue
index 796f6f6a69..57d0a35c71 100644
--- a/apps/frontend/src/pages/settings/flags.vue
+++ b/apps/frontend/src/pages/settings/flags.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
diff --git a/apps/frontend/src/pages/settings/pats.vue b/apps/frontend/src/pages/settings/pats.vue
index 8441682a36..ec475b4f7d 100644
--- a/apps/frontend/src/pages/settings/pats.vue
+++ b/apps/frontend/src/pages/settings/pats.vue
@@ -62,28 +62,40 @@
{{ formatMessage(createModalMessages.expiresLabel) }}
-
+
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
- {{ formatMessage(commonMessages.saveChangesButton) }}
-
-
-
-
-
- {{ formatMessage(createModalMessages.action) }}
-
-
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+ {{ formatMessage(commonMessages.saveChangesButton) }}
+
+
+
+ {{ formatMessage(createModalMessages.action) }}
+
@@ -92,21 +104,21 @@
-
- {
- name = null
- scopesVal = 0
- expires = null
- editPatId = null
- $refs.patModal.show()
- }
- "
- >
- {{ formatMessage(messages.create) }}
-
-
+ {
+ name = null
+ scopesVal = 0
+ expires = null
+ editPatId = null
+ $refs.patModal.show()
+ }
+ "
+ >
+ {{ formatMessage(messages.create) }}
+
@@ -123,8 +135,8 @@
{{ pat.name }}
-
-
+
+
@@ -166,33 +178,29 @@
-
- {
- editPatId = pat.id
- name = pat.name
- scopesVal = pat.scopes
- expires = $dayjs(pat.expires).format('YYYY-MM-DD')
- $refs.patModal.show()
- }
- "
- >
- {{ formatMessage(tokenMessages.edit) }}
-
-
-
- {
- deletePatIndex = pat.id
- $refs.modal_confirm.show()
- }
- "
- >
- {{ formatMessage(tokenMessages.revoke) }}
-
-
+ {
+ editPatId = pat.id
+ name = pat.name
+ scopesVal = pat.scopes
+ expires = $dayjs(pat.expires).format('YYYY-MM-DD')
+ $refs.patModal.show()
+ }
+ "
+ >
+ {{ formatMessage(tokenMessages.edit) }}
+
+ {
+ deletePatIndex = pat.id
+ $refs.modal_confirm.show()
+ }
+ "
+ >
+ {{ formatMessage(tokenMessages.revoke) }}
+
@@ -200,7 +208,7 @@
-
diff --git a/apps/frontend/src/pages/settings/sessions.vue b/apps/frontend/src/pages/settings/sessions.vue
index 993844a104..7acd04d343 100644
--- a/apps/frontend/src/pages/settings/sessions.vue
+++ b/apps/frontend/src/pages/settings/sessions.vue
@@ -34,11 +34,9 @@
{{ formatMessage(messages.currentSessionLabel) }}
-
-
- {{ formatMessage(messages.revokeSessionButton) }}
-
-
+
+ {{ formatMessage(messages.revokeSessionButton) }}
+
@@ -46,7 +44,7 @@
diff --git a/apps/frontend/src/pages/share/[inviteId].vue b/apps/frontend/src/pages/share/[inviteId].vue
index 3e697d9676..d32602bf28 100644
--- a/apps/frontend/src/pages/share/[inviteId].vue
+++ b/apps/frontend/src/pages/share/[inviteId].vue
@@ -101,12 +101,10 @@
-
-
-
- {{ formatMessage(messages.acceptInvite) }}
-
-
+
+
+ {{ formatMessage(messages.acceptInvite) }}
+
{{
@@ -133,7 +131,7 @@ import {
import {
Admonition,
Avatar,
- ButtonStyled,
+ Button,
defineMessages,
injectModrinthClient,
useVIntl,
diff --git a/apps/frontend/src/pages/user/[user].vue b/apps/frontend/src/pages/user/[user].vue
index 1d8565e819..c7f3ec27ed 100644
--- a/apps/frontend/src/pages/user/[user].vue
+++ b/apps/frontend/src/pages/user/[user].vue
@@ -24,6 +24,7 @@ import { useQueryClient } from '@tanstack/vue-query'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
+import { warmProjectCheckCaches } from '~/composables/queries/project'
const route = useNativeRoute()
const client = injectModrinthClient()
@@ -34,6 +35,12 @@ const userProfile = provideUserProfile({
getOrganizations: (userId) => client.labrinth.users_v2.getOrganizations(userId),
getCollections: (userId) => client.labrinth.users_v2.getCollections(userId),
patchUser: (userId, patch) => client.labrinth.users_v2.patch(userId, patch),
+ changeAvatar: (userId, file, extension) =>
+ client.labrinth.users_v2.changeIcon(userId, file, extension),
+ deleteAvatar: (userId) => client.labrinth.users_v2.deleteIcon(userId),
+ getBlockedUsers: () => client.labrinth.blocked_users_v3.list(),
+ blockUser: (userId) => client.labrinth.blocked_users_v3.block(userId),
+ unblockUser: (userId) => client.labrinth.blocked_users_v3.unblock(userId),
})
const auth = await useAuth()
const cosmetics = useCosmetics()
@@ -56,7 +63,7 @@ try {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
-await Promise.allSettled([
+const [projectsResult] = await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['user', userId.value, 'projects'],
queryFn: () => userProfile.getProjects(userId.value),
@@ -74,6 +81,9 @@ await Promise.allSettled([
}),
])
+if (projectsResult.status === 'fulfilled') {
+ warmProjectCheckCaches(queryClient, projectsResult.value)
+}
const title = computed(() =>
prefetchedUser ? `${prefetchedUser.username} - Modrinth` : 'Modrinth',
)
diff --git a/apps/frontend/src/plugins/update-game-versions.client.ts b/apps/frontend/src/plugins/update-game-versions.client.ts
new file mode 100644
index 0000000000..b73976b254
--- /dev/null
+++ b/apps/frontend/src/plugins/update-game-versions.client.ts
@@ -0,0 +1,19 @@
+import type { Labrinth } from '@modrinth/api-client'
+
+import { setGameVersions } from '~/composables/generated'
+
+export default defineNuxtPlugin((nuxtApp) => {
+ // Deferred until after hydration: the server renders with the build-time game versions, so
+ // swapping them in during setup would make the first client render disagree with the markup.
+ nuxtApp.hook('app:suspense:resolve', async () => {
+ try {
+ const gameVersions = await $fetch('/api/tags/game-versions')
+
+ if (gameVersions && gameVersions.length > 0) {
+ setGameVersions(gameVersions)
+ }
+ } catch (error) {
+ console.error('[Game Version Updater] Failed to fetch:', error)
+ }
+ })
+})
diff --git a/apps/frontend/src/plugins/update-game-versions.ts b/apps/frontend/src/plugins/update-game-versions.ts
deleted file mode 100644
index 5ded43620b..0000000000
--- a/apps/frontend/src/plugins/update-game-versions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { Labrinth } from '@modrinth/api-client'
-
-export default defineNuxtPlugin(async () => {
- try {
- const gameVersions = await $fetch('/api/tags/game-versions')
-
- if (gameVersions && gameVersions.length > 0) {
- const state = useState<{ gameVersions: Labrinth.Tags.v2.GameVersion[] }>('generatedState')
-
- if (state.value) {
- state.value.gameVersions = gameVersions
- }
- }
- } catch (error) {
- console.error('[Game Version Updater] Failed to fetch:', error)
- }
-})
diff --git a/apps/frontend/src/providers/setup/user-country.ts b/apps/frontend/src/providers/setup/user-country.ts
new file mode 100644
index 0000000000..457b521387
--- /dev/null
+++ b/apps/frontend/src/providers/setup/user-country.ts
@@ -0,0 +1,41 @@
+import { provideUserCountry } from '@modrinth/ui'
+import { onMounted } from 'vue'
+
+import { useRequestHeaders, useState } from '#imports'
+
+function getCountryFromLocale(locale: string | undefined) {
+ if (!locale) return undefined
+
+ try {
+ return new Intl.Locale(locale).region?.toUpperCase()
+ } catch {
+ return undefined
+ }
+}
+
+export function setupUserCountryProvider() {
+ const country = useState('userCountry', () => 'US')
+ const fromServer = useState('userCountryFromServer', () => false)
+
+ if (import.meta.server) {
+ const headers = useRequestHeaders(['cf-ipcountry', 'accept-language'])
+ const cloudflareCountry = headers['cf-ipcountry']
+ const locale = headers['accept-language']?.split(',')[0]?.split(';')[0]?.trim()
+ const detectedCountry = cloudflareCountry?.toUpperCase() ?? getCountryFromLocale(locale)
+
+ if (detectedCountry) {
+ country.value = detectedCountry
+ fromServer.value = true
+ }
+ }
+
+ if (import.meta.client) {
+ onMounted(() => {
+ if (!fromServer.value) {
+ country.value = getCountryFromLocale(navigator.language) ?? country.value
+ }
+ })
+ }
+
+ return provideUserCountry(country)
+}
diff --git a/apps/frontend/src/public/news/article/ai-policy-and-disclosures/details.webp b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/details.webp
new file mode 100644
index 0000000000..597a833e9a
Binary files /dev/null and b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/details.webp differ
diff --git a/apps/frontend/src/public/news/article/ai-policy-and-disclosures/filters.webp b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/filters.webp
new file mode 100644
index 0000000000..42f74bde17
Binary files /dev/null and b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/filters.webp differ
diff --git a/apps/frontend/src/public/news/article/ai-policy-and-disclosures/settings.webp b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/settings.webp
new file mode 100644
index 0000000000..5ad8b30f9f
Binary files /dev/null and b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/settings.webp differ
diff --git a/apps/frontend/src/public/news/article/ai-policy-and-disclosures/thumbnail.webp b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/thumbnail.webp
new file mode 100644
index 0000000000..b5a135e87e
Binary files /dev/null and b/apps/frontend/src/public/news/article/ai-policy-and-disclosures/thumbnail.webp differ
diff --git a/apps/frontend/src/public/news/feed/articles.json b/apps/frontend/src/public/news/feed/articles.json
index 3c3e72849c..9a0f99664e 100644
--- a/apps/frontend/src/public/news/feed/articles.json
+++ b/apps/frontend/src/public/news/feed/articles.json
@@ -1,5 +1,12 @@
{
"articles": [
+ {
+ "title": "New AI rules and project disclosures",
+ "summary": "An update to Modrinth’s Content Rules and our new mandatory content disclosures.",
+ "thumbnail": "https://modrinth.com/news/article/ai-policy-and-disclosures/thumbnail.webp",
+ "date": "2026-08-13T16:00:00.000Z",
+ "link": "https://modrinth.com/news/article/ai-policy-and-disclosures"
+ },
{
"title": "Share instances with friends",
"summary": "Play more easily with friends without having to share .mrpack files.",
diff --git a/apps/frontend/src/public/news/feed/rss.xml b/apps/frontend/src/public/news/feed/rss.xml
index 4a32f32ec0..460f87a8d7 100644
--- a/apps/frontend/src/public/news/feed/rss.xml
+++ b/apps/frontend/src/public/news/feed/rss.xml
@@ -4,12 +4,20 @@
https://modrinth.com/news/
@modrinth/blog
- Fri, 24 Jul 2026 14:07:14 GMT
+ Thu, 13 Aug 2026 18:28:28 GMT
+ -
+
+
+ https://modrinth.com/news/article/ai-policy-and-disclosures/
+ https://modrinth.com/news/article/ai-policy-and-disclosures/
+ Thu, 13 Aug 2026 16:00:00 GMT
+ <![CDATA[<p>This year, fully AI-generated content has begun <em>flooding</em> Modrinth. Project submissions have gone up from about 2,500 per week at the start of the year to over 5,000 per week now.</p><p>We’ve never had a quality threshold for uploading to Modrinth, and we continue to believe that you should not need to make professional, perfect work in order to publish on Modrinth. But we do believe that it has to be <em>your</em> work, and generating it completely with AI is not your work. Modrinth is a community of creators. In order to be creators, you need to actually create something.</p><p>Today, we’re announcing a set of new features and policies aimed at informing users about things they may want to know about a project before downloading, eliminating fully AI-generated content, and enforcing transparency for content that has been made in part with generative AI.</p><p>In addition, we have made a very minor update to our <a href="/legal/terms" rel="noopener nofollow ugc">Terms of Use</a>, to prohibit the use of data on Modrinth to train AI models.</p><h2>Content disclosures</h2><p>We have introduced content disclosures to provide a standardized place for certain things that we feel it is important for users to understand before downloading a project on Modrinth.</p><p><img src="/news/article/ai-policy-and-disclosures/details.webp" alt="In the Details panel on the sidebar of a project, a couple disclosures are listed as an example. Contains paid features: Cosmetic items are purchaseable with Mod Coins; Contains opt-out telemetry: Anonymous launch analytics about game version and mod loaders."></p><p>Here’s the current list of content disclosures available at this time:</p><ul><li><strong>Contains AI-generated content</strong><ul><li>Projects must enable this if they:<ul><li>contain a substantial amount of AI-generated code</li><li><em>any</em> assets that are substantially AI-generated</li><li>the project’s functionality relies on the use of generative AI</li><li>or, if any element of your project’s page such as description or publishing relies on generative AI</li></ul></li><li>There are also options available to disclose if you use it for code, assets, text, or functionality within the project.</li></ul></li><li><strong>Contains advertising</strong><ul><li>Projects that contain advertisements, sponsorships, or promotions of other works must enable this disclosure.</li><li>If the promotion has no direct monetary value <strong><em>and</em></strong> it is for something that the average person would consider <em>relevant</em> and <em>unobtrusive</em> (such as a link to your Modrinth profile in the corner of the settings page for your own mod), we would not consider that an advertisement for the purposes of this disclosure.</li></ul></li><li><strong>Contains paid features</strong><ul><li>Projects that contain features that can be obtained by spending real-world money.</li><li>You must provide a list of the types of paid features.</li></ul></li><li><strong>Contains telemetry</strong><ul><li>If your project collects any data on users (sent either to yourself or a third party), it must be disclosed here.</li><li>There’s three types of telemetry consent options you can select: Opt-in, Opt-out, and Always active</li><li>You must provide a list of the types of data that is collected.</li></ul></li><li><strong>Contains derivative content</strong><ul><li>If your project is a fork or uses substantial parts of another work, you must declare it as a derivative work of the source work.</li><li>You must at least provide the name of the work, and if available, a link. You may also provide a small explanation of how it is a derivative work, such as a brief summary of what is different about your fork.</li></ul></li><li><strong>Photosensitivity warning</strong><ul><li>If your project contains anything that you think may be dangerous to certain people who are sensitive to flashing lights or patterns, you must enable the photosensitivity disclosure.</li></ul></li><li><strong>Contains external system interactions</strong><ul><li>Projects that read or edit things on the user’s system outside of the game must enable this disclosure.</li><li>You must describe precisely how it interacts with the user’s system in the required note.</li><li>The most common usage of this is in some “horror” mods, where they may do harmless but spooky things on your computer such as adding creepy files to your desktop.</li></ul></li><li><strong>Archived</strong><ul><li>This isn’t new, but we’re transitioning it from a project visibility status to a disclosure.</li><li>This will allow you to have an Unlisted project also be archived, previously archived projects were always Public.</li><li>You can also include a note to explain why your project has been archived.</li></ul></li></ul><p><img src="/news/article/ai-policy-and-disclosures/settings.webp" alt="In project settings, there is a new Disclosures tab. On it, you can toggle disclosures on and off, and some of them have additional fields you need to fill out."></p><p>Projects with disclosures you’d like to avoid can be filtered out in search under the “Advanced exclusions” options. When filtering out projects with photosensitivity warnings, we will warn the user that not all projects without the warning are guaranteed to be safe for users with photosensitivities. Advanced exclusion filters will be persistent across searches in your browser so that you don't have to apply them every time you search.</p><p><img src="/news/article/ai-policy-and-disclosures/filters.webp" alt="At the bottom of the search filters, there is an "Advanced exclusions" section. You can select different disclosures to exclude from your search results."></p><h2>New AI policy</h2><p>We’ve added Section 6 to the <a href="/legal/rules" rel="noopener nofollow ugc">Content Rules</a>, which is as follows:</p><div id="rule-6"></div><p>To summarize:</p><ul><li>AI images as project icons, banners, gallery images, or in their descriptions are no longer allowed.</li><li>Fully or nearly fully AI-generated projects are no longer allowed.</li><li>AI-assisted projects, where the human creator contributed in a primary and significant way, are allowed but must disclose that AI assistance was used.</li></ul><h2>Enforcement</h2><p>Obviously, this is a big change and creators will need to take some time to update their projects to have all necessary disclosures or take down projects that are no longer allowed. For this reason, we will have a 45 day grace period to give everyone enough time to get their projects in order.</p><p>During this grace period, we will not be accepting reports for projects in violation of the new AI policy or for missing content disclosures to give them a fair chance to update them. The 45-day grace period ends on <strong>September 27, 2026</strong>.</p><p>In addition, we will not tolerate witch-hunting on the Modrinth platform. These disclosures are to inform users and provide transparency between user and creator. Anyone using them to shame, insult, belittle, or harass others will be swiftly and permanently removed from the Modrinth platform and community Discord server. Be kind to each other.</p><h2>Terms of Use update</h2><p>Modrinth's <a href="/legal/terms" rel="noopener nofollow ugc">Terms of Use</a> have been updated in a very minor way to prohibit the use of data on Modrinth to train AI models. The exact change is as follows:</p><blockquote><p>Additionally, you agree not to:</p><ul><li>[...]</li><li>Use any data, content, or materials available on or through the Service to train, develop, or improve any artificial intelligence or machine learning models.</li></ul></blockquote><p>A larger TOU update will be coming in the future to address other issues since the last update was in 2023, but we felt it was important to address this sooner rather than later.</p><p>—</p><p>Thank you for your patience with us on this feature. We know it’s been long-requested and overdue, but we wanted to make sure we shipped something comprehensive, enforceable, and more than just a publicity stunt.</p><p>Thank you as always for continuing to support us. It means a lot. 💚</p>]]>
+
-
-
+
https://modrinth.com/news/article/shared-instances/
https://modrinth.com/news/article/shared-instances/
Fri, 24 Jul 2026 11:00:00 GMT
@@ -131,7 +139,7 @@
https://modrinth.com/news/article/new-environments/
https://modrinth.com/news/article/new-environments/
Thu, 28 Aug 2025 23:50:00 GMT
- <![CDATA[<p><strong>Hey creators!</strong></p><p>Over the years, we've taken in lots of feedback regarding how we identify client-side and server-side mods and modpacks on Modrinth. It's a surprisingly nuanced issue, and careful consideration has finally led us to implementing a new system that fixes many of the issues with the old one.</p><h2>What do I need to do?</h2><p>If you want to jump right into what you need to do now, just visit your <a href="/dashboard/projects" rel="noopener nofollow ugc">Projects page</a> and look for any of your mod or modpack projects with an orange warning button next to the settings button. This will take you to the new Environments page in your project's settings with a bunch of different options for configuring your project's environment.</p><p><img src="/news/article/new-environments/env-settings.webp" alt="Screenshot of the new Modrinth Environment settings page. A warning message explains that the environment system has been updated. Options are listed with radio buttons, including Client-side only (selected), Server-side only, Client and server, and Singleplayer only, each with descriptions and sub-options."></p><p><strong>If you do not verify your environments, in the future a warning may display on your project to inform users that the environment information may be outdated or incorrect.</strong></p><p>Read on to learn more about why we've made this change and for a more thorough explanation of each option, in case you find any of them confusing.</p><h2>What was wrong with the old system?</h2><p>Originally, Modrinth's environment metadata came in the form of two fields for Client-side and Server-side compatibility. Each option could be set to 'Required', 'Optional', or 'Unsupported'. This did the job, but it left some ambiguities and led to many confused creators mis-labeling their mods.</p><ol><li><strong>Certain combinations of options don't make logical sense</strong>, such as both sides being 'Unsupported', or one side being 'Optional' and the other being 'Unsupported'. To some people, they may feel that since <em>installing</em> the mod is optional, that might be a logical choice, when users and automated tools might be expecting it to be labeled as 'Required'</li><li><strong>Terms like 'Unsupported' are interpreted differently</strong> by different people. If something is 'Unsupported' on the server-side, does that mean it crashes when installed on a server?</li><li><strong>Most server-side only mods also work in Singleplayer</strong>, even if they don't perform any functions on the client-side directly. Some creators of server-sided mods chose to mark client-side as 'Optional' because of this, even if it did absolutely nothing on the client-side because in order to use it in Singleplayer, you technically install it on the "client"</li><li><strong>Not all real-world combinations even could be represented</strong> by this old system. There are some mods that only make sense in a singleplayer environment, or some that only make sense on dedicated servers and <em>not</em> in Singleplayer.</li><li><strong>Conflicting information is out there</strong> on what exactly these terms meant. The website told creators to treat the client and server as the <em>logical</em> client and servers, but some other people's guides and tooling treated them as referring to the <em>physical</em> client and server. This includes the Modrinth Pack (.mrpack) specification, which confusingly uses the same required/optional/unsupported terminology to refer to the physical sides when defining which files should be installed in the client and server distributions.</li></ol><h2>How does the new system work?</h2><p>The new system enumerates all expected use-cases into distinct options that can be handled in unique ways by tools like launchers, mod managers, and modpack assemblers.</p><p>The new options are as follows:</p><ul><li><strong>Client-side only</strong> (<code>client_only</code>)<ul><li>All functionality is performed exclusively on the client side. Should be compatible with vanilla servers.</li><li>Example: <a href="/mod/modmenu" rel="noopener nofollow ugc">Mod Menu</a>. It only adds a menu to view the list of mods installed on your client, which doesn't need to be installed on the server.</li></ul></li><li><strong>Server-side only / Works in singleplayer</strong> (<code>server_only</code>)<ul><li>All functionality is performed exclusively on the server side. Should be compatible with vanilla clients if only installed on the server. Also works in Singleplayer.</li><li>Example: <a href="/mod/yungs-bridges" rel="noopener nofollow ugc">YUNG's Bridges</a>. It only adds structures which don't need to be present on the client-side.</li></ul></li><li><strong>Server-side only / Dedicated server only</strong> (<code>dedicated_server_only</code>)<ul><li>Only runs on a dedicated server, and not in Singleplayer.</li><li>Example: <a href="/mod/better-fabric-console" rel="noopener nofollow ugc">Better Fabric Console</a>. Its functionality does not work in singleplayer, because it modifies the dedicated server console.</li></ul></li><li><strong>Client and server / Required on both</strong> (<code>client_and_server</code>)<ul><li>Must be installed on both the client and server.</li><li>Example: <a href="/mod/cobblemon" rel="noopener nofollow ugc">Cobblemon</a>. It adds entities, blocks, and items that need to be on both the client and server to work.</li></ul></li><li><strong>Client and server / Optional on client</strong> (<code>server_only_client_optional</code>)<ul><li>Must be on the server, but can be on the client as well for enhanced functionality</li><li>Example: <a href="/mod/polymer" rel="noopener nofollow ugc">Polymer</a>. It functions on the server-side, but if installed on the client it can improve the experience when playing on a server running Polymer.</li></ul></li><li><strong>Client and server / Optional on server</strong> (<code>client_only_server_optional</code>)<ul><li>Must be on the client, but can be on the server as well for enhanced functionality</li><li>Example: <a href="/mod/appleskin" rel="noopener nofollow ugc">AppleSkin</a>. It functions on the client-side, but if installed on the server it can provide more accurate saturation information.</li></ul></li><li><strong>Client or server / Works best on both</strong> (<code>client_or_server_prefers_both</code>)<ul><li>Can be installed on just the client or just the server to function, but functionality is enhanced when it is on both.</li><li>Example: <a href="/mod/no-chat-reports" rel="noopener nofollow ugc">No Chat Reports</a>. The mod functions on just the client or just the server, but each comes with drawbacks. For the best functionality, you need to install it on both.</li></ul></li><li><strong>Client or server / Works the same on either</strong> (<code>client_or_server</code>)<ul><li>Can be installed on just the client or just the server, and either one would enable full functionality. There would be no reason to install it on both.</li><li>Example: <a href="/mod/entity-view-distance" rel="noopener nofollow ugc">Entity View Distance</a>. It lets you perform the same functionality of limiting entity view distance on either the client or the server.</li></ul></li><li><strong>Singleplayer only</strong> (<code>singleplayer_only</code>)<ul><li>Only works in Singleplayer, does not function in a Multiplayer environment.</li><li>Example: <a href="/mod/lan-server-properties" rel="noopener nofollow ugc">LAN Server Properties</a>. It modifies a feature that only exists in Singleplayer, the Open to LAN menu.</li></ul></li></ul><h2>What's next</h2><p>This is a great first step towards us fixing many common issues that have been affecting Modrinth users, such as:</p><ul><li>Client-side mods being installed to Modrinth Servers, causing crashes</li><li>Modpack exporting in Modrinth App and other launchers using the Modrinth API such as Prism Launcher, MultiMC, and ATLauncher not having accurate and reliable metadata to pull from in order to build universal client and server Modrinth Pack files.</li></ul><p>However, this is just the first step. Before we can improve the tooling around creating and using modpacks, we need as many Modrinth projects as possible to have accurate metadata.</p><p>Currently, the new environment metadata is also only available in the experimental API v3, which is <em>not</em> intended for general use. When we're ready, we plan to integrate this metadata into API v2, so that it can be used in production by third parties. For now, developers using the Modrinth API should not worry about these environment changes, just keep them in mind for the future.</p><p>Thank you all for continuing to support Modrinth!</p><p><strong>Prospector</strong><br><em>Founding Software Engineer</em></p>]]>
+ <![CDATA[<p><strong>Hey creators!</strong></p><p>Over the years, we've taken in lots of feedback regarding how we identify client-side and server-side mods and modpacks on Modrinth. It's a surprisingly nuanced issue, and careful consideration has finally led us to implementing a new system that fixes many of the issues with the old one.</p><h2>What do I need to do?</h2><p>If you want to jump right into what you need to do now, just visit your <a href="/dashboard/projects" rel="noopener nofollow ugc">Projects page</a> and look for any of your mod or modpack projects with an orange warning button next to the settings button. This will take you to the new Environments page in your project's settings with a bunch of different options for configuring your project's environment.</p><p><img src="/news/article/new-environments/env-settings.webp" alt="Screenshot of the new Modrinth Environment settings page. A warning message explains that the environment system has been updated. Options are listed with radio buttons, including Client-side only (selected), Server-side only, Client and server, and Singleplayer only, each with descriptions and sub-options."></p><p><strong>If you do not verify your environments, in the future a warning may display on your project to inform users that the environment information may be outdated or incorrect.</strong></p><p>Read on to learn more about why we've made this change and for a more thorough explanation of each option, in case you find any of them confusing.</p><h2>What was wrong with the old system?</h2><p>Originally, Modrinth's environment metadata came in the form of two fields for Client-side and Server-side compatibility. Each option could be set to 'Required', 'Optional', or 'Unsupported'. This did the job, but it left some ambiguities and led to many confused creators mis-labeling their mods.</p><ol><li><strong>Certain combinations of options don't make logical sense</strong>, such as both sides being 'Unsupported', or one side being 'Optional' and the other being 'Unsupported'. To some people, they may feel that since <em>installing</em> the mod is optional, that might be a logical choice, when users and automated tools might be expecting it to be labeled as 'Required'</li><li><strong>Terms like 'Unsupported' are interpreted differently</strong> by different people. If something is 'Unsupported' on the server-side, does that mean it crashes when installed on a server?</li><li><strong>Most server-side only mods also work in Singleplayer</strong>, even if they don't perform any functions on the client-side directly. Some creators of server-sided mods chose to mark client-side as 'Optional' because of this, even if it did absolutely nothing on the client-side because in order to use it in Singleplayer, you technically install it on the "client"</li><li><strong>Not all real-world combinations even could be represented</strong> by this old system. There are some mods that only make sense in a singleplayer environment, or some that only make sense on dedicated servers and <em>not</em> in Singleplayer.</li><li><strong>Conflicting information is out there</strong> on what exactly these terms meant. The website told creators to treat the client and server as the <em>logical</em> client and servers, but some other people's guides and tooling treated them as referring to the <em>physical</em> client and server. This includes the Modrinth Pack (.mrpack) specification, which confusingly uses the same required/optional/unsupported terminology to refer to the physical sides when defining which files should be installed in the client and server distributions.</li></ol><h2 id="new-system">How does the new system work?</h2><p>The new system enumerates all expected use-cases into distinct options that can be handled in unique ways by tools like launchers, mod managers, and modpack assemblers.</p><p>The new options are as follows:</p><ul><li><strong>Client-side only</strong> (<code>client_only</code>)<ul><li>All functionality is performed exclusively on the client side. Should be compatible with vanilla servers.</li><li>Example: <a href="/mod/modmenu" rel="noopener nofollow ugc">Mod Menu</a>. It only adds a menu to view the list of mods installed on your client, which doesn't need to be installed on the server.</li></ul></li><li><strong>Server-side only / Works in singleplayer</strong> (<code>server_only</code>)<ul><li>All functionality is performed exclusively on the server side. Should be compatible with vanilla clients if only installed on the server. Also works in Singleplayer.</li><li>Example: <a href="/mod/yungs-bridges" rel="noopener nofollow ugc">YUNG's Bridges</a>. It only adds structures which don't need to be present on the client-side.</li></ul></li><li><strong>Server-side only / Dedicated server only</strong> (<code>dedicated_server_only</code>)<ul><li>Only runs on a dedicated server, and not in Singleplayer.</li><li>Example: <a href="/mod/better-fabric-console" rel="noopener nofollow ugc">Better Fabric Console</a>. Its functionality does not work in singleplayer, because it modifies the dedicated server console.</li></ul></li><li><strong>Client and server / Required on both</strong> (<code>client_and_server</code>)<ul><li>Must be installed on both the client and server.</li><li>Example: <a href="/mod/cobblemon" rel="noopener nofollow ugc">Cobblemon</a>. It adds entities, blocks, and items that need to be on both the client and server to work.</li></ul></li><li><strong>Client and server / Optional on client</strong> (<code>server_only_client_optional</code>)<ul><li>Must be on the server, but can be on the client as well for enhanced functionality</li><li>Example: <a href="/mod/polymer" rel="noopener nofollow ugc">Polymer</a>. It functions on the server-side, but if installed on the client it can improve the experience when playing on a server running Polymer.</li></ul></li><li><strong>Client and server / Optional on server</strong> (<code>client_only_server_optional</code>)<ul><li>Must be on the client, but can be on the server as well for enhanced functionality</li><li>Example: <a href="/mod/appleskin" rel="noopener nofollow ugc">AppleSkin</a>. It functions on the client-side, but if installed on the server it can provide more accurate saturation information.</li></ul></li><li><strong>Client or server / Works best on both</strong> (<code>client_or_server_prefers_both</code>)<ul><li>Can be installed on just the client or just the server to function, but functionality is enhanced when it is on both.</li><li>Example: <a href="/mod/no-chat-reports" rel="noopener nofollow ugc">No Chat Reports</a>. The mod functions on just the client or just the server, but each comes with drawbacks. For the best functionality, you need to install it on both.</li></ul></li><li><strong>Client or server / Works the same on either</strong> (<code>client_or_server</code>)<ul><li>Can be installed on just the client or just the server, and either one would enable full functionality. There would be no reason to install it on both.</li><li>Example: <a href="/mod/entity-view-distance" rel="noopener nofollow ugc">Entity View Distance</a>. It lets you perform the same functionality of limiting entity view distance on either the client or the server.</li></ul></li><li><strong>Singleplayer only</strong> (<code>singleplayer_only</code>)<ul><li>Only works in Singleplayer, does not function in a Multiplayer environment.</li><li>Example: <a href="/mod/lan-server-properties" rel="noopener nofollow ugc">LAN Server Properties</a>. It modifies a feature that only exists in Singleplayer, the Open to LAN menu.</li></ul></li></ul><h2>What's next</h2><p>This is a great first step towards us fixing many common issues that have been affecting Modrinth users, such as:</p><ul><li>Client-side mods being installed to Modrinth Servers, causing crashes</li><li>Modpack exporting in Modrinth App and other launchers using the Modrinth API such as Prism Launcher, MultiMC, and ATLauncher not having accurate and reliable metadata to pull from in order to build universal client and server Modrinth Pack files.</li></ul><p>However, this is just the first step. Before we can improve the tooling around creating and using modpacks, we need as many Modrinth projects as possible to have accurate metadata.</p><p>Currently, the new environment metadata is also only available in the experimental API v3, which is <em>not</em> intended for general use. When we're ready, we plan to integrate this metadata into API v2, so that it can be used in production by third parties. For now, developers using the Modrinth API should not worry about these environment changes, just keep them in mind for the future.</p><p>Thank you all for continuing to support Modrinth!</p><p><strong>Prospector</strong><br><em>Founding Software Engineer</em></p>]]>
-
diff --git a/apps/frontend/src/services/moderation/checklist-session-storage.ts b/apps/frontend/src/services/moderation/checklist-session-storage.ts
new file mode 100644
index 0000000000..5b1c5238be
--- /dev/null
+++ b/apps/frontend/src/services/moderation/checklist-session-storage.ts
@@ -0,0 +1,38 @@
+export interface SessionChecklistState {
+ visitedStages?: string[]
+}
+
+function sessionStorageKey(projectId: string): string {
+ return `moderation-checklist-session:${projectId}`
+}
+
+export function getSessionChecklistState(projectId: string): SessionChecklistState {
+ try {
+ const raw = sessionStorage.getItem(sessionStorageKey(projectId))
+ return raw ? JSON.parse(raw) : {}
+ } catch {
+ return {}
+ }
+}
+
+export function patchSessionChecklistState(
+ projectId: string,
+ patch: Partial,
+): void {
+ try {
+ sessionStorage.setItem(
+ sessionStorageKey(projectId),
+ JSON.stringify({ ...getSessionChecklistState(projectId), ...patch }),
+ )
+ } catch {
+ // Shush
+ }
+}
+
+export function clearSessionChecklistState(projectId: string): void {
+ try {
+ sessionStorage.removeItem(sessionStorageKey(projectId))
+ } catch {
+ // Shush
+ }
+}
diff --git a/apps/frontend/src/services/moderation-checklist-storage.ts b/apps/frontend/src/services/moderation/checklist-storage.ts
similarity index 95%
rename from apps/frontend/src/services/moderation-checklist-storage.ts
rename to apps/frontend/src/services/moderation/checklist-storage.ts
index 3b4c1b356c..fa5c13d215 100644
--- a/apps/frontend/src/services/moderation-checklist-storage.ts
+++ b/apps/frontend/src/services/moderation/checklist-storage.ts
@@ -1,6 +1,6 @@
-import type { NodeState } from '@modrinth/moderation'
+import type { NodeState } from '@modrinth/moderation/src/types/node'
-import { dbDelete, dbGet, dbPut, dbScan } from './moderation-db.ts'
+import { dbDelete, dbGet, dbPut, dbScan } from './db.ts'
export interface PersistedChecklistState {
savedAt: string
@@ -9,6 +9,7 @@ export interface PersistedChecklistState {
stage?: string
message?: string
state?: Record>
+ activatedStages?: string[]
}
const STORE = 'checklist'
diff --git a/apps/frontend/src/services/moderation-db.ts b/apps/frontend/src/services/moderation/db.ts
similarity index 100%
rename from apps/frontend/src/services/moderation-db.ts
rename to apps/frontend/src/services/moderation/db.ts
diff --git a/apps/frontend/src/services/moderation/queue-eligibility.ts b/apps/frontend/src/services/moderation/queue-eligibility.ts
new file mode 100644
index 0000000000..25da387057
--- /dev/null
+++ b/apps/frontend/src/services/moderation/queue-eligibility.ts
@@ -0,0 +1,91 @@
+import type { AbstractModrinthClient } from '@modrinth/api-client'
+
+import type { ModerationQueueService } from './queue.ts'
+
+export interface QueueCandidateCheck {
+ locked: boolean
+ expired?: boolean
+ isOwnLock?: boolean
+ slug?: string
+ projectType?: string
+ status?: string
+ isProcessing: boolean
+}
+
+export interface EligibleQueueProject {
+ project: string
+ result: QueueCandidateCheck
+ excluded: string[]
+}
+
+const BATCH_SIZE = 5
+
+export function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
+ if (!result?.isProcessing) return false
+ return !result.locked || !!result.expired || !!result.isOwnLock
+}
+
+export async function batchCheckQueueCandidates(
+ client: AbstractModrinthClient,
+ moderationQueue: ModerationQueueService,
+ projectIds: string[],
+): Promise> {
+ const results = new Map()
+
+ const projects = await client.labrinth.projects_v3.getMultiple(projectIds).catch(() => [])
+ const projectsById = new Map(projects.map((project) => [project.id, project]))
+
+ const checks = await Promise.allSettled(
+ projectIds.map(async (id) => {
+ const lockResponse = await moderationQueue.checkLock(id)
+ const project = projectsById.get(id) ?? null
+
+ return {
+ id,
+ locked: lockResponse.locked,
+ expired: lockResponse.expired,
+ isOwnLock: lockResponse.is_own_lock,
+ slug: project?.slug,
+ projectType: project?.project_types[0],
+ status: project?.status,
+ isProcessing: project === null ? true : project.status === 'processing',
+ }
+ }),
+ )
+
+ checks.forEach((result, index) => {
+ if (result.status === 'fulfilled') {
+ results.set(result.value.id, result.value)
+ } else {
+ results.set(projectIds[index], { locked: false, isProcessing: true })
+ }
+ })
+
+ return results
+}
+
+export async function findNextEligibleQueueProject(
+ client: AbstractModrinthClient,
+ moderationQueue: ModerationQueueService,
+ candidateIds: string[],
+): Promise {
+ const excluded: string[] = []
+ let checkedCount = 0
+
+ while (checkedCount < candidateIds.length) {
+ const batch = candidateIds.slice(checkedCount, checkedCount + BATCH_SIZE)
+ checkedCount += batch.length
+
+ const results = await batchCheckQueueCandidates(client, moderationQueue, batch)
+
+ for (const id of batch) {
+ const result = results.get(id)
+ if (isEligibleQueueCandidate(result)) {
+ return { project: id, result: result!, excluded: [...excluded] }
+ }
+ excluded.push(id)
+ }
+ }
+
+ return null
+}
diff --git a/apps/frontend/src/services/moderation-queue-storage.ts b/apps/frontend/src/services/moderation/queue-storage.ts
similarity index 87%
rename from apps/frontend/src/services/moderation-queue-storage.ts
rename to apps/frontend/src/services/moderation/queue-storage.ts
index d90ac1121d..22a450a337 100644
--- a/apps/frontend/src/services/moderation-queue-storage.ts
+++ b/apps/frontend/src/services/moderation/queue-storage.ts
@@ -1,13 +1,13 @@
-import { dbDelete, dbGet, dbPut } from './moderation-db.ts'
+import { dbDelete, dbGet, dbPut } from './db.ts'
export interface PersistedModerationQueueState {
version: 1
savedAt: string
currentQueue: {
items: string[]
+ skipped?: string[]
total: number
- completed: number
- skipped: number
+ completed: string[]
lastUpdated: string
}
isQueueMode: boolean
@@ -31,9 +31,9 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
const queue = candidate.currentQueue
if (!queue || typeof queue !== 'object') return false
if (!isStringArray(queue.items)) return false
+ if (queue.skipped !== undefined && !isStringArray(queue.skipped)) return false
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
- if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false
- if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
+ if (!isStringArray(queue.completed)) return false
if (typeof queue.lastUpdated !== 'string') return false
return true
diff --git a/apps/frontend/src/services/moderation-queue.ts b/apps/frontend/src/services/moderation/queue.ts
similarity index 76%
rename from apps/frontend/src/services/moderation-queue.ts
rename to apps/frontend/src/services/moderation/queue.ts
index 2ebe96e8c6..9c0a45a884 100644
--- a/apps/frontend/src/services/moderation-queue.ts
+++ b/apps/frontend/src/services/moderation/queue.ts
@@ -6,13 +6,13 @@ import {
loadQueueState,
type PersistedModerationQueueState,
saveQueueState,
-} from './moderation-queue-storage.ts'
+} from './queue-storage.ts'
export interface ModerationQueue {
items: string[]
+ skipped: string[]
total: number
- completed: number
- skipped: number
+ completed: string[]
lastUpdated: Date
}
@@ -29,11 +29,14 @@ export interface ModerationQueueService {
queueLength: number
hasItems: boolean
+ hasSkipped: boolean
progress: number
setQueue(projectIds: string[]): Promise
- setSingleProject(projectId: string): Promise
- completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise
+ completeProject(projectId: string): Promise
+ deferProject(projectId: string): Promise
+ excludeProject(projectId: string): Promise
+ startSkippedReview(): Promise
getCurrentProjectId(): string | null
resetQueue(): Promise
@@ -46,31 +49,31 @@ export interface ModerationQueueService {
const EMPTY_QUEUE: ModerationQueue = {
items: [],
+ skipped: [],
total: 0,
- completed: 0,
- skipped: 0,
+ completed: [],
lastUpdated: new Date(),
}
function createEmptyQueue(): ModerationQueue {
- return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
+ return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [], skipped: [], completed: [] }
}
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
const lastUpdated = new Date(raw.lastUpdated)
const items = raw.items.filter((id): id is string => typeof id === 'string')
- const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0
- const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
- const minimumTotal = items.length + completed + skipped
+ const skipped = (raw.skipped ?? []).filter((id): id is string => typeof id === 'string')
+ const completed = (raw.completed ?? []).filter((id): id is string => typeof id === 'string')
+ const minimumTotal = items.length + completed.length
const total = Number.isFinite(raw.total)
? Math.max(Math.trunc(raw.total), minimumTotal)
: minimumTotal
return {
items,
+ skipped,
total,
completed,
- skipped,
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
}
}
@@ -84,9 +87,9 @@ function persistedPayload(
savedAt: new Date().toISOString(),
currentQueue: {
items: [...queue.items],
+ skipped: [...queue.skipped],
total: queue.total,
- completed: queue.completed,
- skipped: queue.skipped,
+ completed: [...queue.completed],
lastUpdated: queue.lastUpdated.toISOString(),
},
isQueueMode,
@@ -101,9 +104,10 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0)
+ const hasSkipped = computed(() => currentQueue.value.skipped.length > 0)
const progress = computed(() => {
if (currentQueue.value.total === 0) return 0
- return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
+ return (currentQueue.value.total - currentQueue.value.items.length) / currentQueue.value.total
})
let mutationChain = Promise.resolve()
@@ -148,42 +152,41 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
return result
}
- function setQueueState(items: string[], mode: boolean) {
- isQueueMode.value = mode
+ function setQueueState(items: string[]) {
+ isQueueMode.value = true
currentQueue.value = {
items: [...items],
+ skipped: [],
total: items.length,
- completed: 0,
- skipped: 0,
+ completed: [],
lastUpdated: new Date(),
}
}
async function setQueue(projectIds: string[]): Promise {
await withMutation(() => {
- setQueueState(projectIds, true)
+ setQueueState(projectIds)
})
}
- async function setSingleProject(projectId: string): Promise {
- await withMutation(() => {
- setQueueState([projectId], false)
- })
- }
-
- async function completeCurrentProject(
- projectId: string,
- status: 'completed' | 'skipped' = 'completed',
- ): Promise {
+ async function completeProject(projectId: string): Promise {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
- if (status === 'completed') {
- currentQueue.value.completed++
- } else {
- currentQueue.value.skipped++
+ currentQueue.value.completed = [...currentQueue.value.completed, projectId]
+ currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
+ currentQueue.value.lastUpdated = new Date()
+
+ return currentQueue.value.items.length > 0
+ })
+ }
+
+ async function excludeProject(projectId: string): Promise {
+ return withMutation(() => {
+ if (!currentQueue.value.items.includes(projectId)) {
+ return currentQueue.value.items.length > 0
}
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
@@ -193,6 +196,26 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
})
}
+ async function deferProject(projectId: string): Promise {
+ return withMutation(() => {
+ if (!currentQueue.value.items.includes(projectId)) {
+ return currentQueue.value.items.length > 0
+ }
+
+ currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
+ currentQueue.value.skipped = [...currentQueue.value.skipped, projectId]
+ currentQueue.value.lastUpdated = new Date()
+
+ return currentQueue.value.items.length > 0
+ })
+ }
+
+ async function startSkippedReview(): Promise {
+ await withMutation(() => {
+ setQueueState(currentQueue.value.skipped)
+ })
+ }
+
function getCurrentProjectId(): string | null {
return currentQueue.value.items[0] || null
}
@@ -294,11 +317,14 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
queueLength,
hasItems,
+ hasSkipped,
progress,
setQueue,
- setSingleProject,
- completeCurrentProject,
+ completeProject,
+ deferProject,
+ excludeProject,
+ startSkippedReview,
getCurrentProjectId,
resetQueue,
diff --git a/apps/frontend/src/store/moderation.ts b/apps/frontend/src/store/moderation.ts
index d9ac077d57..d488524b7f 100644
--- a/apps/frontend/src/store/moderation.ts
+++ b/apps/frontend/src/store/moderation.ts
@@ -4,8 +4,8 @@ import type {
LockStatusResponse,
ModerationQueue,
ModerationQueueService,
-} from '~/services/moderation-queue.ts'
-import { useModerationQueue } from '~/services/moderation-queue.ts'
+} from '~/services/moderation/queue.ts'
+import { useModerationQueue } from '~/services/moderation/queue.ts'
export type {
LockAcquireResponse,
diff --git a/apps/frontend/wrangler.jsonc b/apps/frontend/wrangler.jsonc
index 17d5bb1360..22da20148b 100644
--- a/apps/frontend/wrangler.jsonc
+++ b/apps/frontend/wrangler.jsonc
@@ -1,22 +1,28 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "frontend",
- "compatibility_date": "2025-12-10",
+ "compatibility_date": "2026-08-05",
"main": "./.output/server/index.mjs",
"assets": {
"binding": "ASSETS",
"directory": "./.output/public/"
},
- "compatibility_flags": ["nodejs_compat_v2"],
"routes": ["modrinth.com/*"],
"preview_urls": true,
"workers_dev": true,
+ "upload_source_maps": true,
"limits": {
"cpu_ms": 5000
},
"observability": {
- "enabled": true,
- "head_sampling_rate": 0.0001
+ "traces": {
+ "enabled": true,
+ "head_sampling_rate": 0.0001
+ },
+ "logs": {
+ "enabled": true,
+ "head_sampling_rate": 0.0001
+ }
},
"keep_vars": false,
"secrets_store_secrets": [
@@ -49,8 +55,14 @@
"env": {
"staging": {
"observability": {
- "enabled": true,
- "head_sampling_rate": 0.1
+ "traces": {
+ "enabled": true,
+ "head_sampling_rate": 0.1
+ },
+ "logs": {
+ "enabled": true,
+ "head_sampling_rate": 0.1
+ }
},
"routes": ["staging.modrinth.com/*"],
"vars": {
diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose
index 81bd6934a9..bff0a03574 100644
--- a/apps/labrinth/.env.docker-compose
+++ b/apps/labrinth/.env.docker-compose
@@ -17,14 +17,14 @@ DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
-SEARCH_BACKEND=typesense
+SEARCH_BACKEND=elasticsearch
MEILISEARCH_READ_ADDR=http://localhost:7700
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
MEILISEARCH_KEY=modrinth
-ELASTICSEARCH_URL=http://localhost:9200
+ELASTICSEARCH_URL=http://elasticsearch0:9200
ELASTICSEARCH_INDEX_PREFIX=labrinth
-ELASTICSEARCH_USERNAME=elastic
-ELASTICSEARCH_PASSWORD=elastic
+ELASTICSEARCH_USERNAME=
+ELASTICSEARCH_PASSWORD=
SEARCH_INDEX_CHUNK_SIZE=5000
SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS=5
SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE=1000
diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local
index ddc5096de8..79220889d5 100644
--- a/apps/labrinth/.env.local
+++ b/apps/labrinth/.env.local
@@ -17,7 +17,7 @@ DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
-SEARCH_BACKEND=typesense
+SEARCH_BACKEND=elasticsearch
# Meilisearch configuration
MEILISEARCH_READ_ADDR=http://localhost:7700
@@ -32,7 +32,7 @@ ELASTICSEARCH_INDEX_PREFIX=labrinth
# MEILISEARCH_READ_ADDR=http://localhost:7710
# MEILISEARCH_WRITE_ADDRS=http://localhost:7700,http://localhost:7701
-SEARCH_BACKEND=typesense
+SEARCH_BACKEND=elasticsearch
MEILISEARCH_KEY=modrinth
MEILISEARCH_META_NAMESPACE=
diff --git a/apps/labrinth/.sqlx/query-115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1.json b/apps/labrinth/.sqlx/query-115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1.json
new file mode 100644
index 0000000000..9245b56bc2
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1"
+}
diff --git a/apps/labrinth/.sqlx/query-87d30e8802ebe69858141d0d918cafb5286f984cfe55ad1a7ac2219ec50539a4.json b/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json
similarity index 77%
rename from apps/labrinth/.sqlx/query-87d30e8802ebe69858141d0d918cafb5286f984cfe55ad1a7ac2219ec50539a4.json
rename to apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json
index eabfd4299d..db45e607d0 100644
--- a/apps/labrinth/.sqlx/query-87d30e8802ebe69858141d0d918cafb5286f984cfe55ad1a7ac2219ec50539a4.json
+++ b/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n WITH ranked_local_traces AS (\n SELECT\n didws.key AS detail_key,\n didws.id AS detail_id,\n didws.issue_id,\n dri.issue_type,\n m.id AS project_id,\n m.slug AS project_slug,\n m.name AS project_name,\n v.id AS version_id,\n v.version_number,\n f.id AS file_id,\n f.filename AS file_name,\n didws.jar,\n didws.file_path,\n didws.severity,\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)\n AS local_status,\n didws.status AS effective_status,\n ROW_NUMBER() OVER (\n PARTITION BY didws.key\n ORDER BY didws.id\n ) AS row_num\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = didws.project_id\n AND didv.detail_key = didws.key\n WHERE\n didws.key = ANY($1::text[])\n AND dri.issue_type != '__dummy'\n )\n SELECT\n detail_key AS \"detail_key!\",\n detail_id AS \"detail_id!: DelphiReportIssueDetailsId\",\n issue_id AS \"issue_id!: DelphiReportIssueId\",\n issue_type,\n project_id AS \"project_id!: DBProjectId\",\n project_slug AS \"project_slug?\",\n project_name AS \"project_name!\",\n version_id AS \"version_id!: DBVersionId\",\n v.version_number,\n file_id AS \"file_id!: DBFileId\",\n file_name AS \"file_name!\",\n jar AS \"jar?\",\n file_path AS \"file_path!\",\n severity AS \"severity!: DelphiSeverity\",\n local_status AS \"local_status!: DelphiStatus\",\n effective_status AS \"effective_status!: DelphiStatus\"\n FROM ranked_local_traces v\n WHERE row_num <= $2\n ORDER BY detail_key, detail_id\n ",
+ "query": "\n WITH ranked_local_traces AS (\n SELECT\n didws.key AS detail_key,\n didws.id AS detail_id,\n didws.issue_id,\n dri.issue_type,\n m.id AS project_id,\n m.slug AS project_slug,\n m.name AS project_name,\n v.id AS version_id,\n v.version_number,\n f.id AS file_id,\n f.filename AS file_name,\n didws.jar,\n didws.file_path,\n didws.severity,\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)\n AS local_status,\n didws.status AS effective_status,\n ROW_NUMBER() OVER (\n PARTITION BY didws.key\n ORDER BY didws.id\n ) AS row_num\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = didws.project_id\n AND didv.detail_key = didws.key\n WHERE\n didws.key = ANY($1::text[])\n AND dri.issue_type != '__dummy'\n AND (\n $3::text IS NULL\n OR didws.key ILIKE '%' || $3 || '%'\n OR didws.file_path ILIKE '%' || $3 || '%'\n )\n )\n SELECT\n detail_key AS \"detail_key!\",\n detail_id AS \"detail_id!: DelphiReportIssueDetailsId\",\n issue_id AS \"issue_id!: DelphiReportIssueId\",\n issue_type,\n project_id AS \"project_id!: DBProjectId\",\n project_slug AS \"project_slug?\",\n project_name AS \"project_name!\",\n version_id AS \"version_id!: DBVersionId\",\n v.version_number,\n file_id AS \"file_id!: DBFileId\",\n file_name AS \"file_name!\",\n jar AS \"jar?\",\n file_path AS \"file_path!\",\n severity AS \"severity!: DelphiSeverity\",\n local_status AS \"local_status!: DelphiStatus\",\n effective_status AS \"effective_status!: DelphiStatus\"\n FROM ranked_local_traces v\n WHERE row_num <= $2\n ORDER BY detail_key, detail_id\n ",
"describe": {
"columns": [
{
@@ -121,7 +121,8 @@
"parameters": {
"Left": [
"TextArray",
- "Int8"
+ "Int8",
+ "Text"
]
},
"nullable": [
@@ -143,5 +144,5 @@
true
]
},
- "hash": "87d30e8802ebe69858141d0d918cafb5286f984cfe55ad1a7ac2219ec50539a4"
+ "hash": "1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005"
}
diff --git a/apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json b/apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json
new file mode 100644
index 0000000000..b3a813243e
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $7::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n )\n SELECT id\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4)\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ORDER BY\n CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "TextArray",
+ "Text",
+ "Text",
+ "Bool",
+ "Text",
+ "Bool"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8"
+}
diff --git a/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json b/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json
similarity index 68%
rename from apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json
rename to apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json
index 6c62c2b6b6..f7c409af75 100644
--- a/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json
+++ b/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
+ "query": "\n SELECT id, enum_id, value, ordering,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\",\n created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
"describe": {
"columns": [
{
@@ -25,11 +25,16 @@
},
{
"ordinal": 4,
- "name": "metadata",
- "type_info": "Jsonb"
+ "name": "ty?",
+ "type_info": "Text"
},
{
"ordinal": 5,
+ "name": "major?",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 6,
"name": "created",
"type_info": "Timestamptz"
}
@@ -44,9 +49,10 @@
false,
false,
true,
- true,
+ null,
+ null,
false
]
},
- "hash": "04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433"
+ "hash": "214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd"
}
diff --git a/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json b/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json
deleted file mode 100644
index 38d43c6043..0000000000
--- a/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $7::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN mp.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n )\n SELECT id\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4)\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ORDER BY\n CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "TextArray",
- "Text",
- "Text",
- "Bool",
- "Text",
- "Bool"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9"
-}
diff --git a/apps/labrinth/.sqlx/query-26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049.json b/apps/labrinth/.sqlx/query-26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049.json
new file mode 100644
index 0000000000..5ab18b5979
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE mods\n SET organization_id = NULL, slug = COALESCE($1, slug)\n WHERE id = $2\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049"
+}
diff --git a/apps/labrinth/.sqlx/query-2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc.json b/apps/labrinth/.sqlx/query-2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc.json
new file mode 100644
index 0000000000..7cf8db01cb
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc"
+}
diff --git a/apps/labrinth/.sqlx/query-4680c4a59c6679f90e3b9e1a33ed1cb1fb60b93ffb79ba5b99e01ee0c14c991a.json b/apps/labrinth/.sqlx/query-33d4e31565ece4a99832cbfd0a39bc49666e9fc8a400a5c41660504205e7cac5.json
similarity index 63%
rename from apps/labrinth/.sqlx/query-4680c4a59c6679f90e3b9e1a33ed1cb1fb60b93ffb79ba5b99e01ee0c14c991a.json
rename to apps/labrinth/.sqlx/query-33d4e31565ece4a99832cbfd0a39bc49666e9fc8a400a5c41660504205e7cac5.json
index 21c5f042e6..509e4cc4dd 100644
--- a/apps/labrinth/.sqlx/query-4680c4a59c6679f90e3b9e1a33ed1cb1fb60b93ffb79ba5b99e01ee0c14c991a.json
+++ b/apps/labrinth/.sqlx/query-33d4e31565ece4a99832cbfd0a39bc49666e9fc8a400a5c41660504205e7cac5.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n dgdv.detail_key,\n dgdv.verdict AS \"verdict!: DelphiStatus\",\n COUNT(dri.id) AS \"local_trace_count!\"\n FROM delphi_global_detail_verdicts dgdv\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.key = dgdv.detail_key\n LEFT JOIN delphi_report_issues dri\n ON dri.id = didws.issue_id\n AND dri.issue_type != '__dummy'\n WHERE (\n $1::text IS NULL\n OR dgdv.detail_key ILIKE '%' || $1 || '%'\n )\n GROUP BY dgdv.detail_key, dgdv.verdict\n ORDER BY dgdv.detail_key\n LIMIT $2 OFFSET $3\n ",
+ "query": "\n SELECT\n dgdv.detail_key,\n dgdv.verdict AS \"verdict!: DelphiStatus\",\n COUNT(dri.id) AS \"local_trace_count!\"\n FROM delphi_global_detail_verdicts dgdv\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.key = dgdv.detail_key\n LEFT JOIN delphi_report_issues dri\n ON dri.id = didws.issue_id\n AND dri.issue_type != '__dummy'\n WHERE (\n $1::text IS NULL\n OR dgdv.detail_key ILIKE '%' || $1 || '%'\n OR EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses matching_didws\n INNER JOIN delphi_report_issues matching_dri\n ON matching_dri.id = matching_didws.issue_id\n WHERE\n matching_didws.key = dgdv.detail_key\n AND matching_dri.issue_type != '__dummy'\n AND matching_didws.file_path ILIKE '%' || $1 || '%'\n )\n )\n GROUP BY dgdv.detail_key, dgdv.verdict\n ORDER BY dgdv.detail_key\n LIMIT $2 OFFSET $3\n ",
"describe": {
"columns": [
{
@@ -43,5 +43,5 @@
null
]
},
- "hash": "4680c4a59c6679f90e3b9e1a33ed1cb1fb60b93ffb79ba5b99e01ee0c14c991a"
+ "hash": "33d4e31565ece4a99832cbfd0a39bc49666e9fc8a400a5c41660504205e7cac5"
}
diff --git a/apps/labrinth/.sqlx/query-398cd48fc641dc32a126232835cf3be21abbde6bd5dfe4ec064b471f6959f0c0.json b/apps/labrinth/.sqlx/query-398cd48fc641dc32a126232835cf3be21abbde6bd5dfe4ec064b471f6959f0c0.json
new file mode 100644
index 0000000000..cd3c54e541
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-398cd48fc641dc32a126232835cf3be21abbde6bd5dfe4ec064b471f6959f0c0.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT project_id, type, metadata\n FROM project_disclosures\n WHERE project_id = ANY($1) AND deleted_at IS NULL\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "type",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "metadata",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "398cd48fc641dc32a126232835cf3be21abbde6bd5dfe4ec064b471f6959f0c0"
+}
diff --git a/apps/labrinth/.sqlx/query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json b/apps/labrinth/.sqlx/query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json
new file mode 100644
index 0000000000..b1003df726
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json
@@ -0,0 +1,65 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator, deleted_at, lock_status\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1 AND ($2 OR deleted_at IS NULL)\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "disclosure_type!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "metadata",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 3,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "updated_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 5,
+ "name": "set_by_moderator",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 6,
+ "name": "deleted_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "lock_status",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Bool"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2"
+}
diff --git a/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json b/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json
new file mode 100644
index 0000000000..212faa2dca
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT project_id, type, metadata\n FROM project_disclosures\n WHERE project_id = ANY($1)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "type",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "metadata",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed"
+}
diff --git a/apps/labrinth/.sqlx/query-58a1fc9e1d76a891c961e02544b6d08579dff663d5146c2bdfa0812fed6a4587.json b/apps/labrinth/.sqlx/query-58a1fc9e1d76a891c961e02544b6d08579dff663d5146c2bdfa0812fed6a4587.json
new file mode 100644
index 0000000000..772f1d9346
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-58a1fc9e1d76a891c961e02544b6d08579dff663d5146c2bdfa0812fed6a4587.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT COUNT(*) AS \"total!\"\n FROM delphi_global_detail_verdicts dgdv\n WHERE (\n $1::text IS NULL\n OR dgdv.detail_key ILIKE '%' || $1 || '%'\n OR EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses matching_didws\n INNER JOIN delphi_report_issues matching_dri\n ON matching_dri.id = matching_didws.issue_id\n WHERE\n matching_didws.key = dgdv.detail_key\n AND matching_dri.issue_type != '__dummy'\n AND matching_didws.file_path ILIKE '%' || $1 || '%'\n )\n )\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "total!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "58a1fc9e1d76a891c961e02544b6d08579dff663d5146c2bdfa0812fed6a4587"
+}
diff --git a/apps/labrinth/.sqlx/query-5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6.json b/apps/labrinth/.sqlx/query-5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6.json
new file mode 100644
index 0000000000..7985334938
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM mod_follows\n WHERE mod_id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6"
+}
diff --git a/apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json b/apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json
new file mode 100644
index 0000000000..8346ed4b2a
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json
@@ -0,0 +1,29 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tSELECT type AS \"disclosure_type!\", lock_status\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2)\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "disclosure_type!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "lock_status",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "TextArray"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52"
+}
diff --git a/apps/labrinth/.sqlx/query-6241bc72a80de01fd8e5506442da48057c24dece008dbf2c00c6ec135d8cef41.json b/apps/labrinth/.sqlx/query-6241bc72a80de01fd8e5506442da48057c24dece008dbf2c00c6ec135d8cef41.json
new file mode 100644
index 0000000000..665cd936e4
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-6241bc72a80de01fd8e5506442da48057c24dece008dbf2c00c6ec135d8cef41.json
@@ -0,0 +1,60 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n WITH project_ids AS (\n SELECT unnest($1::bigint[]) AS project_id\n ),\n detail_states AS (\n SELECT\n p.project_id,\n COALESCE(\n BOOL_OR(\n didws.status = 'pending'\n AND dri.issue_type != $3\n ),\n FALSE\n ) AS has_pending_detail,\n COALESCE(\n BOOL_OR(\n didws.status = 'unsafe'\n AND dri.issue_type != $3\n ),\n FALSE\n ) AS has_unsafe_detail,\n COALESCE(\n BOOL_OR(\n didws.status = 'pending'\n AND dri.issue_type = $3\n ),\n FALSE\n ) AS has_dummy\n FROM project_ids p\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = p.project_id\n LEFT JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n GROUP BY p.project_id\n )\n SELECT\n p.project_id AS \"project_id!: DBProjectId\",\n p.has_pending_detail AS \"has_pending_detail!\",\n p.has_unsafe_detail AS \"has_unsafe_detail!\",\n p.has_dummy AS \"has_dummy!\",\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = p.project_id\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = p.project_id\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\",\n (\n SELECT dr.id\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n WHERE v.mod_id = p.project_id\n ORDER BY dr.created DESC, dr.id DESC\n LIMIT 1\n ) AS \"report_id: DelphiReportId\"\n FROM detail_states p\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id!: DBProjectId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "has_pending_detail!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 2,
+ "name": "has_unsafe_detail!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 3,
+ "name": "has_dummy!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 4,
+ "name": "thread_id: DBThreadId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 5,
+ "name": "last_tech_review_message_type",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "report_id: DelphiReportId",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array",
+ "TextArray",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ ]
+ },
+ "hash": "6241bc72a80de01fd8e5506442da48057c24dece008dbf2c00c6ec135d8cef41"
+}
diff --git a/apps/labrinth/.sqlx/query-6ebb0cffc875f50ebe95b91e4735f1c520155d65a5c51021224ab4461c688db8.json b/apps/labrinth/.sqlx/query-6ebb0cffc875f50ebe95b91e4735f1c520155d65a5c51021224ab4461c688db8.json
deleted file mode 100644
index 6d00c56a9f..0000000000
--- a/apps/labrinth/.sqlx/query-6ebb0cffc875f50ebe95b91e4735f1c520155d65a5c51021224ab4461c688db8.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT id FROM mods\n WHERE id = ANY($1)\n AND components ? 'minecraft_server'\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": [
- "Int8Array"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "6ebb0cffc875f50ebe95b91e4735f1c520155d65a5c51021224ab4461c688db8"
-}
diff --git a/apps/labrinth/.sqlx/query-6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97.json b/apps/labrinth/.sqlx/query-6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97.json
new file mode 100644
index 0000000000..e2b1133eb2
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97"
+}
diff --git a/apps/labrinth/.sqlx/query-7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16.json b/apps/labrinth/.sqlx/query-7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16.json
new file mode 100644
index 0000000000..bcf9c634ce
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n m.id AS \"id!\",\n m.status AS \"status!\",\n m.slug,\n t.id AS \"thread_id!\"\n FROM mods m\n INNER JOIN team_members tm\n ON tm.team_id = m.team_id\n AND tm.user_id = $1\n AND tm.is_owner = TRUE\n INNER JOIN threads t ON t.mod_id = m.id\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "status!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "slug",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "thread_id!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16"
+}
diff --git a/apps/labrinth/.sqlx/query-7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23.json b/apps/labrinth/.sqlx/query-7ca9b8f5699f4d60acc50647b7577935ac84730b52a2ee090512a460cf512432.json
similarity index 64%
rename from apps/labrinth/.sqlx/query-7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23.json
rename to apps/labrinth/.sqlx/query-7ca9b8f5699f4d60acc50647b7577935ac84730b52a2ee090512a460cf512432.json
index f97540b2bc..606f1b807f 100644
--- a/apps/labrinth/.sqlx/query-7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23.json
+++ b/apps/labrinth/.sqlx/query-7ca9b8f5699f4d60acc50647b7577935ac84730b52a2ee090512a460cf512432.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n ),\n latest AS (\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict\n FROM incoming\n ORDER BY detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_global_detail_verdicts dgdv\n USING latest\n WHERE\n dgdv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT\n detail_key,\n verdict::delphi_report_issue_status\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
+ "query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n ),\n latest AS (\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict\n FROM incoming\n ORDER BY detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_global_detail_verdicts dgdv\n USING latest\n WHERE\n dgdv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict,\n updated_at\n )\n SELECT\n detail_key,\n verdict::delphi_report_issue_status,\n NOW()\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (detail_key)\n DO UPDATE SET\n verdict = EXCLUDED.verdict,\n updated_at = EXCLUDED.updated_at\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
- "hash": "7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23"
+ "hash": "7ca9b8f5699f4d60acc50647b7577935ac84730b52a2ee090512a460cf512432"
}
diff --git a/apps/labrinth/.sqlx/query-82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2.json b/apps/labrinth/.sqlx/query-82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2.json
new file mode 100644
index 0000000000..73142111d3
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE mods\n SET status = $1\n WHERE id = $2\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2"
+}
diff --git a/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json b/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json
similarity index 58%
rename from apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json
rename to apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json
index c66e860181..d0bc6abfc1 100644
--- a/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json
+++ b/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
+ "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
"describe": {
"columns": [
{
@@ -30,8 +30,13 @@
},
{
"ordinal": 5,
- "name": "metadata",
- "type_info": "Jsonb"
+ "name": "ty?",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "major?",
+ "type_info": "Bool"
}
],
"parameters": {
@@ -45,8 +50,9 @@
false,
true,
false,
- true
+ null,
+ null
]
},
- "hash": "43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af"
+ "hash": "83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65"
}
diff --git a/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json b/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json
new file mode 100644
index 0000000000..5d5edc88ae
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tSELECT project_id\n\t\t\tFROM project_disclosures\n\t\t\tWHERE type = $1 AND project_id = ANY($2)\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05"
+}
diff --git a/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json b/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json
deleted file mode 100644
index d627ef2dc6..0000000000
--- a/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Int8"
- ]
- },
- "nullable": []
- },
- "hash": "92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e"
-}
diff --git a/apps/labrinth/.sqlx/query-93b19a49ae305ab760792d22c5b955a75323663880b47aa9ed42c9f33cd65f16.json b/apps/labrinth/.sqlx/query-93b19a49ae305ab760792d22c5b955a75323663880b47aa9ed42c9f33cd65f16.json
new file mode 100644
index 0000000000..3e0b89ec2e
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-93b19a49ae305ab760792d22c5b955a75323663880b47aa9ed42c9f33cd65f16.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT v.id, v.mod_id\n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n WHERE\n v.id = ANY($1)\n AND jsonb_typeof(m.components -> 'minecraft_server') IS DISTINCT FROM 'object'\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "mod_id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "93b19a49ae305ab760792d22c5b955a75323663880b47aa9ed42c9f33cd65f16"
+}
diff --git a/apps/labrinth/.sqlx/query-964f1acc9dfc7b0c6b2ce6adec3c535c77a6e6e98d13c4543f503cf8a86a743c.json b/apps/labrinth/.sqlx/query-964f1acc9dfc7b0c6b2ce6adec3c535c77a6e6e98d13c4543f503cf8a86a743c.json
new file mode 100644
index 0000000000..1454d53477
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-964f1acc9dfc7b0c6b2ce6adec3c535c77a6e6e98d13c4543f503cf8a86a743c.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tSELECT project_id\n\t\t\tFROM project_disclosures\n\t\t\tWHERE type = $1 AND project_id = ANY($2) AND deleted_at IS NULL\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "964f1acc9dfc7b0c6b2ce6adec3c535c77a6e6e98d13c4543f503cf8a86a743c"
+}
diff --git a/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json b/apps/labrinth/.sqlx/query-9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d.json
similarity index 50%
rename from apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json
rename to apps/labrinth/.sqlx/query-9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d.json
index 3d9785e4dd..45fb189ae2 100644
--- a/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json
+++ b/apps/labrinth/.sqlx/query-9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ",
+ "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
- "hash": "d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b"
+ "hash": "9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d"
}
diff --git a/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json b/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json
new file mode 100644
index 0000000000..e43bc36603
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tDELETE FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = $2\n\t\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13"
+}
diff --git a/apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json b/apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json
new file mode 100644
index 0000000000..6329652107
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json
@@ -0,0 +1,19 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator, lock_status)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5,\n\t\t\t\tdeleted_at = NULL,\n\t\t\t\tlock_status = $6\n\t\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Jsonb",
+ "Int8",
+ "Bool",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076"
+}
diff --git a/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json b/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json
deleted file mode 100644
index 73d8a8eec0..0000000000
--- a/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Varchar",
- "Int8"
- ]
- },
- "nullable": []
- },
- "hash": "a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad"
-}
diff --git a/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json b/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json
similarity index 63%
rename from apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json
rename to apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json
index 0af23b8554..2f1c614db8 100644
--- a/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json
+++ b/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ",
+ "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ",
"describe": {
"columns": [
{
@@ -30,8 +30,13 @@
},
{
"ordinal": 5,
- "name": "metadata",
- "type_info": "Jsonb"
+ "name": "ty?",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "major?",
+ "type_info": "Bool"
}
],
"parameters": {
@@ -43,8 +48,9 @@
false,
true,
false,
- true
+ null,
+ null
]
},
- "hash": "fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c"
+ "hash": "a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8"
}
diff --git a/apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json b/apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json
deleted file mode 100644
index 340549e517..0000000000
--- a/apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Int4"
- },
- {
- "ordinal": 1,
- "name": "loader",
- "type_info": "Varchar"
- },
- {
- "ordinal": 2,
- "name": "icon",
- "type_info": "Varchar"
- },
- {
- "ordinal": 3,
- "name": "metadata",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 4,
- "name": "project_types",
- "type_info": "VarcharArray"
- },
- {
- "ordinal": 5,
- "name": "games",
- "type_info": "VarcharArray"
- }
- ],
- "parameters": {
- "Left": []
- },
- "nullable": [
- false,
- false,
- false,
- false,
- null,
- null
- ]
- },
- "hash": "ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9"
-}
diff --git a/apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json b/apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json
new file mode 100644
index 0000000000..e54ff20ebf
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json
@@ -0,0 +1,138 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $9::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n ),\n filtered_projects AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND\n project_types[1] = $4\n )\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM filtered_projects\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $7\n LIMIT $6\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n m.team_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n page_ids.project_types,\n page_ids.external_dependencies_count\n FROM page_ids\n INNER JOIN mods m ON m.id = page_ids.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "total_count!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "id?",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "slug?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "name?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "summary?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 5,
+ "name": "icon_url?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "status?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 7,
+ "name": "requested_status?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 8,
+ "name": "queued?",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "published?",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "updated?",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 11,
+ "name": "organization_id?",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 12,
+ "name": "organization_name?",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 13,
+ "name": "organization_icon_url?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 14,
+ "name": "owner_id?",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 15,
+ "name": "owner_name?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 16,
+ "name": "owner_icon_url?",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 17,
+ "name": "project_types?: Vec",
+ "type_info": "TextArray"
+ },
+ {
+ "ordinal": 18,
+ "name": "external_dependencies_count?",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "TextArray",
+ "Text",
+ "Text",
+ "Bool",
+ "Int8",
+ "Int8",
+ "Text",
+ "Bool"
+ ]
+ },
+ "nullable": [
+ null,
+ false,
+ true,
+ false,
+ false,
+ true,
+ false,
+ true,
+ true,
+ false,
+ false,
+ true,
+ false,
+ true,
+ false,
+ false,
+ true,
+ null,
+ null
+ ]
+ },
+ "hash": "b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814"
+}
diff --git a/apps/labrinth/.sqlx/query-bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30.json b/apps/labrinth/.sqlx/query-bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30.json
deleted file mode 100644
index 247c99cd89..0000000000
--- a/apps/labrinth/.sqlx/query-bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n WITH project_ids AS (\n SELECT unnest($1::bigint[]) AS project_id\n )\n SELECT\n p.project_id AS \"project_id!: DBProjectId\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type != $3\n ) AS \"has_pending_detail!\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'unsafe'\n AND dri.issue_type != $3\n ) AS \"has_unsafe_detail!\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type = $3\n ) AS \"has_dummy!\",\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = p.project_id\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = p.project_id\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\",\n (\n SELECT dr.id\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n WHERE v.mod_id = p.project_id\n ORDER BY dr.created DESC, dr.id DESC\n LIMIT 1\n ) AS \"report_id: DelphiReportId\"\n FROM project_ids p\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "project_id!: DBProjectId",
- "type_info": "Int8"
- },
- {
- "ordinal": 1,
- "name": "has_pending_detail!",
- "type_info": "Bool"
- },
- {
- "ordinal": 2,
- "name": "has_unsafe_detail!",
- "type_info": "Bool"
- },
- {
- "ordinal": 3,
- "name": "has_dummy!",
- "type_info": "Bool"
- },
- {
- "ordinal": 4,
- "name": "thread_id: DBThreadId",
- "type_info": "Int8"
- },
- {
- "ordinal": 5,
- "name": "last_tech_review_message_type",
- "type_info": "Text"
- },
- {
- "ordinal": 6,
- "name": "report_id: DelphiReportId",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": [
- "Int8Array",
- "TextArray",
- "Text"
- ]
- },
- "nullable": [
- null,
- null,
- null,
- null,
- null,
- null,
- null
- ]
- },
- "hash": "bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30"
-}
diff --git a/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json b/apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json
similarity index 63%
rename from apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json
rename to apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json
index 450ec6ad79..bf61274204 100644
--- a/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json
+++ b/apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n WITH filtered_projects AS (\n SELECT\n m.id,\n m.queued,\n m.published\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $6::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published\n FROM filtered_projects\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $4\n LIMIT $3\n ),\n page_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n page_external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n CASE\n WHEN m.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(ppt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count\n FROM page_ids page\n INNER JOIN mods m ON m.id = page.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id\n LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ",
+ "query": "\n WITH filtered_projects AS (\n SELECT\n m.id,\n m.queued,\n m.published\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $6::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published\n FROM filtered_projects\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $4\n LIMIT $3\n ),\n page_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n page_external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n CASE\n WHEN jsonb_typeof(m.components -> 'minecraft_server') = 'object'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(ppt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count\n FROM page_ids page\n INNER JOIN mods m ON m.id = page.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id\n LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ",
"describe": {
"columns": [
{
@@ -131,5 +131,5 @@
null
]
},
- "hash": "5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b"
+ "hash": "c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b"
}
diff --git a/apps/labrinth/.sqlx/query-c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd.json b/apps/labrinth/.sqlx/query-c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd.json
new file mode 100644
index 0000000000..d813a19859
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd"
+}
diff --git a/apps/labrinth/.sqlx/query-e8b6e423aaf62046afd19fb1e177a3ef1f18f4814df8924926c47d4b0c7145c7.json b/apps/labrinth/.sqlx/query-c40c2085202bc4486568fb24db9201239cefbe174f4a5cc4b8eb21a10268cd73.json
similarity index 72%
rename from apps/labrinth/.sqlx/query-e8b6e423aaf62046afd19fb1e177a3ef1f18f4814df8924926c47d4b0c7145c7.json
rename to apps/labrinth/.sqlx/query-c40c2085202bc4486568fb24db9201239cefbe174f4a5cc4b8eb21a10268cd73.json
index 1e3e8c4d7f..81c343b584 100644
--- a/apps/labrinth/.sqlx/query-e8b6e423aaf62046afd19fb1e177a3ef1f18f4814df8924926c47d4b0c7145c7.json
+++ b/apps/labrinth/.sqlx/query-c40c2085202bc4486568fb24db9201239cefbe174f4a5cc4b8eb21a10268cd73.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY\n AS u(detail_id, verdict, ord)\n ),\n resolved AS (\n SELECT\n i.ord,\n didws.project_id,\n didws.key AS detail_key,\n i.verdict\n FROM incoming i\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n -- see delphi.rs todo comment\n dri.issue_type != '__dummy'\n ),\n validated AS (\n SELECT\n (SELECT COUNT(*) FROM incoming) AS incoming_count,\n (SELECT COUNT(*) FROM resolved) AS resolved_count\n ),\n latest AS (\n SELECT DISTINCT ON (project_id, detail_key)\n project_id,\n detail_key,\n verdict\n FROM resolved\n ORDER BY project_id, detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_issue_detail_verdicts didv\n USING latest\n WHERE\n didv.project_id = latest.project_id\n AND didv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n ),\n upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT\n project_id,\n detail_key,\n verdict::delphi_report_issue_status\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n RETURNING 1\n )\n SELECT\n (v.incoming_count = v.resolved_count) AS \"all_found!\",\n (SELECT COUNT(*) FROM upserted) AS \"upserted_count!\"\n FROM validated v\n ",
+ "query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY\n AS u(detail_id, verdict, ord)\n ),\n resolved AS (\n SELECT\n i.ord,\n didws.project_id,\n didws.key AS detail_key,\n i.verdict\n FROM incoming i\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n -- see delphi.rs todo comment\n dri.issue_type != '__dummy'\n ),\n validated AS (\n SELECT\n (SELECT COUNT(*) FROM incoming) AS incoming_count,\n (SELECT COUNT(*) FROM resolved) AS resolved_count\n ),\n latest AS (\n SELECT DISTINCT ON (project_id, detail_key)\n project_id,\n detail_key,\n verdict\n FROM resolved\n ORDER BY project_id, detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_issue_detail_verdicts didv\n USING latest\n WHERE\n didv.project_id = latest.project_id\n AND didv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n ),\n upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict,\n updated_at\n )\n SELECT\n project_id,\n detail_key,\n verdict::delphi_report_issue_status,\n NOW()\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET\n verdict = EXCLUDED.verdict,\n updated_at = EXCLUDED.updated_at\n RETURNING 1\n )\n SELECT\n (v.incoming_count = v.resolved_count) AS \"all_found!\",\n (SELECT COUNT(*) FROM upserted) AS \"upserted_count!\"\n FROM validated v\n ",
"describe": {
"columns": [
{
@@ -25,5 +25,5 @@
null
]
},
- "hash": "e8b6e423aaf62046afd19fb1e177a3ef1f18f4814df8924926c47d4b0c7145c7"
+ "hash": "c40c2085202bc4486568fb24db9201239cefbe174f4a5cc4b8eb21a10268cd73"
}
diff --git a/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json b/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json
deleted file mode 100644
index dc7aa810bb..0000000000
--- a/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Int8"
- ]
- },
- "nullable": []
- },
- "hash": "c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b"
-}
diff --git a/apps/labrinth/.sqlx/query-c9d5796b2c98dedd9f2f237a1a2cf7a059a49387c0c564b730733bda7358f7fe.json b/apps/labrinth/.sqlx/query-c9d5796b2c98dedd9f2f237a1a2cf7a059a49387c0c564b730733bda7358f7fe.json
deleted file mode 100644
index 3d0aa419ad..0000000000
--- a/apps/labrinth/.sqlx/query-c9d5796b2c98dedd9f2f237a1a2cf7a059a49387c0c564b730733bda7358f7fe.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT COUNT(*) AS \"total!\"\n FROM delphi_global_detail_verdicts dgdv\n WHERE (\n $1::text IS NULL\n OR dgdv.detail_key ILIKE '%' || $1 || '%'\n )\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "total!",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": [
- "Text"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "c9d5796b2c98dedd9f2f237a1a2cf7a059a49387c0c564b730733bda7358f7fe"
-}
diff --git a/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json b/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json
deleted file mode 100644
index 91c2728ad9..0000000000
--- a/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json
+++ /dev/null
@@ -1,138 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $9::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN mp.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n ),\n filtered_projects AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND\n project_types[1] = $4\n )\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM filtered_projects\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $7\n LIMIT $6\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n m.team_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n page_ids.project_types,\n page_ids.external_dependencies_count\n FROM page_ids\n INNER JOIN mods m ON m.id = page_ids.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "total_count!",
- "type_info": "Int8"
- },
- {
- "ordinal": 1,
- "name": "id?",
- "type_info": "Int8"
- },
- {
- "ordinal": 2,
- "name": "slug?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 3,
- "name": "name?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 4,
- "name": "summary?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 5,
- "name": "icon_url?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 6,
- "name": "status?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 7,
- "name": "requested_status?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 8,
- "name": "queued?",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 9,
- "name": "published?",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 10,
- "name": "updated?",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 11,
- "name": "organization_id?",
- "type_info": "Int8"
- },
- {
- "ordinal": 12,
- "name": "organization_name?",
- "type_info": "Text"
- },
- {
- "ordinal": 13,
- "name": "organization_icon_url?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 14,
- "name": "owner_id?",
- "type_info": "Int8"
- },
- {
- "ordinal": 15,
- "name": "owner_name?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 16,
- "name": "owner_icon_url?",
- "type_info": "Varchar"
- },
- {
- "ordinal": 17,
- "name": "project_types?: Vec",
- "type_info": "TextArray"
- },
- {
- "ordinal": 18,
- "name": "external_dependencies_count?",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "TextArray",
- "Text",
- "Text",
- "Bool",
- "Int8",
- "Int8",
- "Text",
- "Bool"
- ]
- },
- "nullable": [
- null,
- false,
- true,
- false,
- false,
- true,
- false,
- true,
- true,
- false,
- false,
- true,
- false,
- true,
- false,
- false,
- true,
- null,
- null
- ]
- },
- "hash": "ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41"
-}
diff --git a/apps/labrinth/.sqlx/query-ca976dee555cef5affd47c5ca69179c9862e760eb5ebb3951d0546f59e46a2fb.json b/apps/labrinth/.sqlx/query-ca976dee555cef5affd47c5ca69179c9862e760eb5ebb3951d0546f59e46a2fb.json
new file mode 100644
index 0000000000..fd8a991090
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ca976dee555cef5affd47c5ca69179c9862e760eb5ebb3951d0546f59e46a2fb.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tUPDATE project_disclosures\n\t\t\tSET deleted_at = now(), updated_at = now(), updated_by = $3, set_by_moderator = $4\n\t\t\tWHERE project_id = $1 AND type = $2 AND deleted_at IS NULL\n\t\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Int8",
+ "Bool"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "ca976dee555cef5affd47c5ca69179c9862e760eb5ebb3951d0546f59e46a2fb"
+}
diff --git a/apps/labrinth/.sqlx/query-d4b427672138b0c75f123a23ce104f69a6d12ff29e21a15f8fdd73386634a3ec.json b/apps/labrinth/.sqlx/query-d4b427672138b0c75f123a23ce104f69a6d12ff29e21a15f8fdd73386634a3ec.json
new file mode 100644
index 0000000000..a904ea2708
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-d4b427672138b0c75f123a23ce104f69a6d12ff29e21a15f8fdd73386634a3ec.json
@@ -0,0 +1,19 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator, lock_status)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5,\n\t\t\t\tlock_status = $6\n\t\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Jsonb",
+ "Int8",
+ "Bool",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "d4b427672138b0c75f123a23ce104f69a6d12ff29e21a15f8fdd73386634a3ec"
+}
diff --git a/apps/labrinth/.sqlx/query-dc13b4d8b84681dc4f1ea5f4f735a5176583d2781dbb9aa428f8f920844f2a1e.json b/apps/labrinth/.sqlx/query-dc13b4d8b84681dc4f1ea5f4f735a5176583d2781dbb9aa428f8f920844f2a1e.json
new file mode 100644
index 0000000000..4004f38b3b
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-dc13b4d8b84681dc4f1ea5f4f735a5176583d2781dbb9aa428f8f920844f2a1e.json
@@ -0,0 +1,58 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator, lock_status\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "project_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "disclosure_type!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "metadata",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 3,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "updated_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 5,
+ "name": "set_by_moderator",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 6,
+ "name": "lock_status",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "dc13b4d8b84681dc4f1ea5f4f735a5176583d2781dbb9aa428f8f920844f2a1e"
+}
diff --git a/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json b/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json
similarity index 58%
rename from apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json
rename to apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json
index 7141f46a13..478c4dcd62 100644
--- a/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json
+++ b/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ",
+ "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ",
"describe": {
"columns": [
{
@@ -30,8 +30,13 @@
},
{
"ordinal": 5,
- "name": "metadata",
- "type_info": "Jsonb"
+ "name": "ty?",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "major?",
+ "type_info": "Bool"
}
],
"parameters": {
@@ -45,8 +50,9 @@
false,
true,
false,
- true
+ null,
+ null
]
},
- "hash": "d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51"
+ "hash": "ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718"
}
diff --git a/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json b/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json
new file mode 100644
index 0000000000..9fda28ebba
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json
@@ -0,0 +1,50 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n (l.metadata->>'platform')::boolean AS platform,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 1,
+ "name": "loader",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "icon",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "platform",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 4,
+ "name": "project_types",
+ "type_info": "VarcharArray"
+ },
+ {
+ "ordinal": 5,
+ "name": "games",
+ "type_info": "VarcharArray"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ null,
+ null,
+ null
+ ]
+ },
+ "hash": "f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837"
+}
diff --git a/apps/labrinth/.sqlx/query-f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358.json b/apps/labrinth/.sqlx/query-f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358.json
new file mode 100644
index 0000000000..29d412eb36
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM team_members\n WHERE team_id = $1\n RETURNING user_id\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358"
+}
diff --git a/apps/labrinth/.sqlx/query-ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f.json b/apps/labrinth/.sqlx/query-ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f.json
new file mode 100644
index 0000000000..7db72f3ff1
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT EXISTS(\n SELECT 1 FROM mods\n WHERE\n (slug = LOWER($1) OR text_id_lower = LOWER($1))\n AND id != $2\n ) AS \"exists!\"\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "exists!",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f"
+}
diff --git a/apps/labrinth/AGENTS.md b/apps/labrinth/AGENTS.md
index 0c1e193f6f..3c11a2248b 100644
--- a/apps/labrinth/AGENTS.md
+++ b/apps/labrinth/AGENTS.md
@@ -12,8 +12,10 @@
- no trailing punctuation
- wrap code items e.g. type names in backticks
- Prefer `wrap_internal_err`, `wrap_request_err` when attaching context to an existing error (like Anyhow `context` or Eyre `wrap_err`)
+- Prefer importing `eyre::Result` and using `Result` instead of `eyre::Result`
+- Prefer `eyre::Ok(value)` instead of `Ok::<_, eyre::Report>(value)` when an explicit Eyre result type is needed
- All operations should ideally have some context attached
- - Database operations can have a message like `.wrap_internal_err("failed to fetch XYZ")`
+ - Database operations can have a message like `.wrap_internal_err("fetching XYZ")`
- You can perform real-time queries against the databases in the Docker Compose
- `docker exec labrinth-postgres psql -c "select 1"`
- `docker exec labrinth-redis redis-cli flushall`
diff --git a/apps/labrinth/CLAUDE.md b/apps/labrinth/CLAUDE.md
deleted file mode 100644
index 8d770e2555..0000000000
--- a/apps/labrinth/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-Read @AGENTS.md
diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml
index 1081074d9a..fbfe31b0bf 100644
--- a/apps/labrinth/Cargo.toml
+++ b/apps/labrinth/Cargo.toml
@@ -73,7 +73,6 @@ image = { workspace = true, features = [
itertools = { workspace = true }
json-patch = { workspace = true }
lettre = { workspace = true }
-meilisearch-sdk = { workspace = true, features = ["reqwest"] }
modrinth-content-management = { workspace = true }
modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] }
muralpay = { workspace = true, features = ["client", "mock", "utoipa"] }
@@ -111,6 +110,7 @@ rusty-money = { workspace = true }
scalar_api_reference = { workspace = true, features = ["actix-web"] }
sentry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+serde-binhum = { workspace = true }
serde_json = { workspace = true }
serde_with = { workspace = true }
sha1 = { workspace = true }
diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile
index 328c4137d4..6d2923265c 100644
--- a/apps/labrinth/Dockerfile
+++ b/apps/labrinth/Dockerfile
@@ -11,7 +11,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
-COPY labrinth /labrinth/labrinth
+COPY --chmod=0755 labrinth /labrinth/labrinth
COPY migrations /labrinth/migrations
COPY assets /labrinth/assets
diff --git a/apps/labrinth/migrations/20260730120000_project_disclosures.sql b/apps/labrinth/migrations/20260730120000_project_disclosures.sql
new file mode 100644
index 0000000000..58af9af11e
--- /dev/null
+++ b/apps/labrinth/migrations/20260730120000_project_disclosures.sql
@@ -0,0 +1,9 @@
+CREATE TABLE project_disclosures (
+ project_id BIGINT NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ metadata JSONB NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ set_by_moderator BOOLEAN NOT NULL,
+ PRIMARY KEY (project_id, type)
+);
diff --git a/apps/labrinth/migrations/20260803120000_delphi_detail_verdict_timestamps.sql b/apps/labrinth/migrations/20260803120000_delphi_detail_verdict_timestamps.sql
new file mode 100644
index 0000000000..8ed20055a5
--- /dev/null
+++ b/apps/labrinth/migrations/20260803120000_delphi_detail_verdict_timestamps.sql
@@ -0,0 +1,11 @@
+ALTER TABLE delphi_issue_detail_verdicts
+ ADD COLUMN updated_at TIMESTAMPTZ;
+
+ALTER TABLE delphi_global_detail_verdicts
+ ADD COLUMN updated_at TIMESTAMPTZ;
+
+CREATE INDEX delphi_report_issue_details_issue_id
+ ON delphi_report_issue_details (issue_id);
+
+CREATE INDEX threads_messages_thread_id_created_id
+ ON threads_messages (thread_id, created DESC, id DESC);
diff --git a/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql b/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql
new file mode 100644
index 0000000000..de94c60023
--- /dev/null
+++ b/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql
@@ -0,0 +1,9 @@
+INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)
+SELECT id, 'archived', '{"note": null}'::jsonb, 0, FALSE
+FROM mods
+WHERE status = 'archived'
+ON CONFLICT (project_id, type) DO NOTHING;
+
+UPDATE mods
+SET status = 'approved'
+WHERE status = 'archived';
diff --git a/apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql b/apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql
new file mode 100644
index 0000000000..d75db77a62
--- /dev/null
+++ b/apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql
@@ -0,0 +1,6 @@
+ALTER TABLE project_disclosures
+ ADD COLUMN lock_status TEXT NOT NULL DEFAULT 'unlocked';
+
+UPDATE project_disclosures
+SET lock_status = 'fully_locked'
+WHERE set_by_moderator;
diff --git a/apps/labrinth/migrations/20260812120000_soft-delete-disclosures.sql b/apps/labrinth/migrations/20260812120000_soft-delete-disclosures.sql
new file mode 100644
index 0000000000..e753f329e6
--- /dev/null
+++ b/apps/labrinth/migrations/20260812120000_soft-delete-disclosures.sql
@@ -0,0 +1,2 @@
+ALTER TABLE project_disclosures
+ ADD COLUMN deleted_at TIMESTAMPTZ;
diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs
index ea3d1e259b..daf9d35d00 100644
--- a/apps/labrinth/src/auth/checks.rs
+++ b/apps/labrinth/src/auth/checks.rs
@@ -11,6 +11,8 @@ use crate::models::projects::{
use crate::models::users::User;
use crate::queue::file_scan::get_files_missing_attribution;
use crate::routes::ApiError;
+use crate::util::error::ApiContext as _;
+use crate::util::error::Context as _;
use futures::TryStreamExt;
use itertools::Itertools;
use xredis::RedisPool;
@@ -91,7 +93,8 @@ pub async fn filter_visible_projects(
pool,
hide_unlisted,
)
- .await?;
+ .await
+ .wrap_api_err("filtering visible project ids")?;
projects.retain(|x| filtered_project_ids.contains(&x.inner.id));
Ok(projects.into_iter().map(|x| x.into()).collect())
}
@@ -128,7 +131,8 @@ pub async fn filter_visible_project_ids(
if !check_projects.is_empty() {
return_projects.extend(
filter_enlisted_projects_ids(check_projects, user_option, pool)
- .await?,
+ .await
+ .wrap_api_err("filtering enlisted projects ids")?,
);
}
@@ -178,7 +182,8 @@ pub async fn filter_enlisted_projects_ids(
}
})
.try_collect::>()
- .await?;
+ .await
+ .wrap_internal_err("fetching query results from database")?;
}
Ok(return_projects)
}
@@ -218,7 +223,8 @@ pub async fn filter_visible_versions(
pool,
redis,
)
- .await?;
+ .await
+ .wrap_api_err("filtering visible version ids")?;
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
@@ -265,10 +271,9 @@ impl ValidateAuthorized for models::DBOAuthClient {
return if user.role.is_mod() || user.id == self.created_by.into() {
Ok(())
} else {
- Err(ApiError::CustomAuthentication(
- "You don't have sufficient permissions to interact with this OAuth application"
- .to_string(),
- ))
+ Err(ApiError::Auth(eyre::eyre!(
+ "You don't have sufficient permissions to interact with this OAuth application",
+ )))
};
}
@@ -292,7 +297,8 @@ pub async fn filter_visible_version_ids(
// Get visible projects- ones we are allowed to see public versions for.
let visible_project_ids = filter_visible_project_ids(
DBProject::get_many_ids(&project_ids, pool, redis)
- .await?
+ .await
+ .wrap_api_err("fetching projects for visibility filtering")?
.iter()
.map(|x| &x.inner)
.collect(),
@@ -300,12 +306,14 @@ pub async fn filter_visible_version_ids(
pool,
false,
)
- .await?;
+ .await
+ .wrap_api_err("filtering visible project IDs")?;
// Then, get enlisted versions (Versions that are a part of a project we are a member of)
let enlisted_version_ids =
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
- .await?;
+ .await
+ .wrap_api_err("filtering enlisted version ids")?;
let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
let withheld_versions = get_files_missing_attribution(pool, &version_ids)
@@ -346,14 +354,16 @@ pub async fn filter_enlisted_version_ids(
// Get enlisted projects- ones we are allowed to see hidden versions for.
let authorized_project_ids = filter_enlisted_projects_ids(
DBProject::get_many_ids(&project_ids, pool, redis)
- .await?
+ .await
+ .wrap_api_err("fetching projects for membership filtering")?
.iter()
.map(|x| &x.inner)
.collect(),
user_option,
pool,
)
- .await?;
+ .await
+ .wrap_api_err("filtering projects by membership")?;
for version in versions {
if user_option.as_ref().is_some_and(|x| x.role.is_mod())
@@ -428,7 +438,8 @@ pub async fn is_visible_organization(
) -> Result {
let members =
DBTeamMember::get_from_team_full(organization.team_id, pool, redis)
- .await?;
+ .await
+ .wrap_internal_err("fetching team members from database")?;
// This is meant to match the same projects as the `Project::is_searchable` method, but we're not using
// it here because that'd entail pulling in all projects for the organization
@@ -437,7 +448,7 @@ pub async fn is_visible_organization(
organization.id as database::models::ids::DBOrganizationId
)
.fetch_optional(pool)
- .await?
+ .await.wrap_internal_err("checking organization for searchable projects")?
.flatten()
.unwrap_or(false);
diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs
index bf0a963f81..c58a032f13 100644
--- a/apps/labrinth/src/auth/mod.rs
+++ b/apps/labrinth/src/auth/mod.rs
@@ -59,12 +59,6 @@ pub enum AuthenticationError {
Url,
}
-impl From for AuthenticationError {
- fn from(error: xredis::Error) -> Self {
- Self::Database(error.into())
- }
-}
-
impl actix_web::ResponseError for AuthenticationError {
fn status_code(&self) -> StatusCode {
match self {
diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs
index 9a5150b3c5..32b32551aa 100644
--- a/apps/labrinth/src/background_task.rs
+++ b/apps/labrinth/src/background_task.rs
@@ -100,7 +100,7 @@ impl BackgroundTask {
}
IncrementalIndexSearch => {
crate::search::incremental::consume::run(
- ro_pool,
+ pool,
redis_pool,
search_backend,
kafka_client,
@@ -168,6 +168,9 @@ pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
+ crate::clickhouse::run_migrations()
+ .await
+ .wrap_err("failed to run ClickHouse migrations")?;
Ok(())
}
diff --git a/apps/labrinth/src/clickhouse/fetch.rs b/apps/labrinth/src/clickhouse/fetch.rs
index b0245075b8..73d9be0e52 100644
--- a/apps/labrinth/src/clickhouse/fetch.rs
+++ b/apps/labrinth/src/clickhouse/fetch.rs
@@ -1,3 +1,4 @@
+use crate::util::error::Context as _;
use std::sync::Arc;
use crate::{models::ids::ProjectId, routes::ApiError};
@@ -47,7 +48,10 @@ pub async fn fetch_playtimes(
.bind(end_date.timestamp())
.bind(projects.iter().map(|x| x.0).collect::>());
- Ok(query.fetch_all().await?)
+ query
+ .fetch_all()
+ .await
+ .wrap_internal_err("querying database for `fetch_playtimes`")
}
// Fetches views as a Vec of ReturnViews
@@ -77,7 +81,10 @@ pub async fn fetch_views(
.bind(end_date.timestamp())
.bind(projects.iter().map(|x| x.0).collect::>());
- Ok(query.fetch_all().await?)
+ query
+ .fetch_all()
+ .await
+ .wrap_internal_err("querying database for `fetch_views`")
}
// Fetches downloads as a Vec of ReturnDownloads
@@ -106,7 +113,10 @@ pub async fn fetch_downloads(
.bind(end_date.timestamp())
.bind(projects.iter().map(|x| x.0).collect::>());
- Ok(query.fetch_all().await?)
+ query
+ .fetch_all()
+ .await
+ .wrap_internal_err("querying database for `fetch_downloads`")
}
pub async fn fetch_countries_downloads(
@@ -133,7 +143,10 @@ pub async fn fetch_countries_downloads(
.bind(end_date.timestamp())
.bind(projects.iter().map(|x| x.0).collect::>());
- Ok(query.fetch_all().await?)
+ query
+ .fetch_all()
+ .await
+ .wrap_internal_err("querying database for `fetch_countries_downloads`")
}
pub async fn fetch_countries_views(
@@ -160,5 +173,8 @@ pub async fn fetch_countries_views(
.bind(end_date.timestamp())
.bind(projects.iter().map(|x| x.0).collect::>());
- Ok(query.fetch_all().await?)
+ query
+ .fetch_all()
+ .await
+ .wrap_internal_err("querying database for `fetch_countries_views`")
}
diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs
index 23c7804351..1e3f92e73b 100644
--- a/apps/labrinth/src/clickhouse/mod.rs
+++ b/apps/labrinth/src/clickhouse/mod.rs
@@ -19,29 +19,44 @@ pub async fn init_client() -> clickhouse::error::Result {
pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result {
- const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
+ Ok(connect()?.with_database(database))
+}
- let client = {
- let https_connector = HttpsConnectorBuilder::new()
- .with_native_roots()?
- .https_or_http()
- .enable_all_versions()
- .build();
- let hyper_client =
- hyper_util::client::legacy::Client::builder(TokioExecutor::new())
- .build(https_connector);
+fn connect() -> clickhouse::error::Result {
+ let https_connector = HttpsConnectorBuilder::new()
+ .with_native_roots()?
+ .https_or_http()
+ .enable_all_versions()
+ .build();
+ let hyper_client =
+ hyper_util::client::legacy::Client::builder(TokioExecutor::new())
+ .build(https_connector);
- clickhouse::Client::with_http_client(hyper_client)
- .with_url(&ENV.CLICKHOUSE_URL)
- .with_user(&ENV.CLICKHOUSE_USER)
- .with_password(&ENV.CLICKHOUSE_PASSWORD)
- .with_validation(false)
- };
+ Ok(clickhouse::Client::with_http_client(hyper_client)
+ .with_url(&ENV.CLICKHOUSE_URL)
+ .with_user(&ENV.CLICKHOUSE_USER)
+ .with_password(&ENV.CLICKHOUSE_PASSWORD)
+ .with_validation(false))
+}
- client
+#[cfg(feature = "test")]
+pub async fn create_database(database: &str) -> clickhouse::error::Result<()> {
+ connect()?
.query(&format!("CREATE DATABASE IF NOT EXISTS {database}"))
.execute()
- .await?;
+ .await
+}
+
+pub async fn run_migrations() -> clickhouse::error::Result<()> {
+ run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await
+}
+
+pub async fn run_migrations_on_database(
+ database: &str,
+) -> clickhouse::error::Result<()> {
+ const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
+
+ let client = connect()?;
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
let cluster_line = if clickhouse_replicated {
@@ -79,9 +94,10 @@ pub async fn init_client_with_database(
ip IPv6,
country String,
user_agent String,
- headers Array(Tuple(String, String))
+ headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
)
ENGINE = {engine}
+ PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded, ip)
SETTINGS index_granularity = 8192
@@ -106,9 +122,10 @@ pub async fn init_client_with_database(
ip IPv6,
country String,
user_agent String,
- headers Array(Tuple(String, String))
+ headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
)
ENGINE = {engine}
+ PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded, ip)
SETTINGS index_granularity = 8192
@@ -134,6 +151,7 @@ pub async fn init_client_with_database(
parent UInt64
)
ENGINE = {engine}
+ PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded, user_id)
SETTINGS index_granularity = 8192
@@ -156,9 +174,10 @@ pub async fn init_client_with_database(
ip IPv6,
country String,
user_agent String,
- headers Array(Tuple(String, String))
+ headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
)
ENGINE = {engine}
+ PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (affiliate_code_id, recorded)
SETTINGS index_granularity = 8192
@@ -184,6 +203,7 @@ pub async fn init_client_with_database(
players_max Nullable(Int32)
)
ENGINE = {engine}
+ PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
@@ -203,6 +223,7 @@ pub async fn init_client_with_database(
minecraft_uuid UUID
)
ENGINE = {engine}
+ PARTITION BY toYYYYMM(recorded)
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
@@ -264,5 +285,5 @@ pub async fn init_client_with_database(
.execute()
.await?;
- Ok(client.with_database(database))
+ Ok(())
}
diff --git a/apps/labrinth/src/database/models/analytics_event_item.rs b/apps/labrinth/src/database/models/analytics_event_item.rs
index 268c0fc538..47493582aa 100644
--- a/apps/labrinth/src/database/models/analytics_event_item.rs
+++ b/apps/labrinth/src/database/models/analytics_event_item.rs
@@ -9,7 +9,7 @@ use crate::{
};
use serde::{Deserialize, Serialize};
-const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v3";
+const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v4";
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
#[derive(Debug, Clone, Serialize, Deserialize)]
diff --git a/apps/labrinth/src/database/models/categories.rs b/apps/labrinth/src/database/models/categories.rs
index edddeb7d21..c2b60ebeb8 100644
--- a/apps/labrinth/src/database/models/categories.rs
+++ b/apps/labrinth/src/database/models/categories.rs
@@ -7,7 +7,7 @@ use super::ids::*;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
-const TAGS_NAMESPACE: &str = "tags:v3";
+const TAGS_NAMESPACE: &str = "tags:v4";
pub struct ProjectType {
pub id: ProjectTypeId,
diff --git a/apps/labrinth/src/database/models/collection_item.rs b/apps/labrinth/src/database/models/collection_item.rs
index b2c53e1f21..6f89b75e71 100644
--- a/apps/labrinth/src/database/models/collection_item.rs
+++ b/apps/labrinth/src/database/models/collection_item.rs
@@ -8,7 +8,7 @@ use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const COLLECTIONS_NAMESPACE: &str = "collections:v3";
+const COLLECTIONS_NAMESPACE: &str = "collections:v4";
#[derive(Clone)]
pub struct CollectionBuilder {
diff --git a/apps/labrinth/src/database/models/delphi_report_item.rs b/apps/labrinth/src/database/models/delphi_report_item.rs
index 4ae71749b4..41bfa6b782 100644
--- a/apps/labrinth/src/database/models/delphi_report_item.rs
+++ b/apps/labrinth/src/database/models/delphi_report_item.rs
@@ -84,6 +84,8 @@ pub enum DelphiSeverity {
High,
#[serde(alias = "SEVERE")]
Severe,
+ #[serde(alias = "MALWARE")]
+ Malware,
}
/// An issue found in a Delphi report. Every issue belongs to a report,
diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs
index 649da111be..dd675941ae 100644
--- a/apps/labrinth/src/database/models/flow_item.rs
+++ b/apps/labrinth/src/database/models/flow_item.rs
@@ -8,14 +8,14 @@ use rand::Rng;
use rand::distributions::Alphanumeric;
use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
-use serde::{Deserialize, Serialize};
+use serde_binhum::serde_binhum;
use url::Url;
use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration};
use xredis::RedisPool;
-const FLOWS_NAMESPACE: &str = "flows:v3";
+const FLOWS_NAMESPACE: &str = "flows:v4";
-#[derive(Deserialize, Serialize)]
+#[serde_binhum]
pub enum DBFlow {
OAuth {
user_id: Option,
@@ -60,13 +60,39 @@ pub enum DBFlow {
},
RegisterPasskey {
user_id: DBUserId,
+ #[serde_binhum(binary(with = "json_string"))]
state: PasskeyRegistration,
},
AuthenticatePasskey {
+ #[serde_binhum(binary(with = "json_string"))]
state: DiscoverableAuthentication,
},
}
+mod json_string {
+ use serde::de::DeserializeOwned;
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+ pub fn serialize(value: &T, serializer: S) -> Result
+ where
+ T: Serialize,
+ S: Serializer,
+ {
+ let value =
+ serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
+ value.serialize(serializer)
+ }
+
+ pub fn deserialize<'de, T, D>(deserializer: D) -> Result
+ where
+ T: DeserializeOwned,
+ D: Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ serde_json::from_str(&value).map_err(serde::de::Error::custom)
+ }
+}
+
impl DBFlow {
pub async fn insert_with_state(
&self,
diff --git a/apps/labrinth/src/database/models/image_item.rs b/apps/labrinth/src/database/models/image_item.rs
index edb3d5add9..f3403ed7fc 100644
--- a/apps/labrinth/src/database/models/image_item.rs
+++ b/apps/labrinth/src/database/models/image_item.rs
@@ -6,7 +6,7 @@ use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const IMAGES_NAMESPACE: &str = "images:v3";
+const IMAGES_NAMESPACE: &str = "images:v4";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DBImage {
diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs
index a11a8a504f..f24291c25e 100644
--- a/apps/labrinth/src/database/models/legacy_loader_fields.rs
+++ b/apps/labrinth/src/database/models/legacy_loader_fields.rs
@@ -117,17 +117,8 @@ impl MinecraftGameVersion {
id: loader_field_enum_value.id,
version: loader_field_enum_value.value,
created: loader_field_enum_value.created,
- type_: loader_field_enum_value
- .metadata
- .get("type")
- .and_then(|x| x.as_str())
- .map(|x| x.to_string())
- .unwrap_or_default(),
- major: loader_field_enum_value
- .metadata
- .get("major")
- .and_then(|x| x.as_bool())
- .unwrap_or_default(),
+ type_: loader_field_enum_value.ty.unwrap_or_default(),
+ major: loader_field_enum_value.major.unwrap_or_default(),
}
}
}
diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs
index a794812b25..bb599a1764 100644
--- a/apps/labrinth/src/database/models/loader_fields.rs
+++ b/apps/labrinth/src/database/models/loader_fields.rs
@@ -12,14 +12,14 @@ use itertools::Itertools;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const GAMES_LIST_NAMESPACE: &str = "games:v3";
-const LOADER_ID: &str = "loader_id:v3";
-const LOADERS_LIST_NAMESPACE: &str = "loaders:v3";
-const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v3";
-const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v3";
-const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v3";
+const GAMES_LIST_NAMESPACE: &str = "games:v4";
+const LOADER_ID: &str = "loader_id:v4";
+const LOADERS_LIST_NAMESPACE: &str = "loaders:v4";
+const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v4";
+const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v4";
+const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v4";
pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str =
- "loader_field_enum_values:v3";
+ "loader_field_enum_values:v4";
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Game {
@@ -87,6 +87,11 @@ impl Game {
}
}
+#[derive(Clone, Serialize, Deserialize)]
+pub struct LoaderMetadata {
+ pub platform: Option,
+}
+
#[derive(Serialize, Deserialize, Clone)]
pub struct Loader {
pub id: LoaderId,
@@ -94,7 +99,7 @@ pub struct Loader {
pub icon: String,
pub supported_project_types: Vec,
pub supported_games: Vec, // slugs
- pub metadata: serde_json::Value,
+ pub metadata: LoaderMetadata,
}
impl Loader {
@@ -154,7 +159,8 @@ impl Loader {
let result = sqlx::query!(
"
- SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata,
+ SELECT l.id id, l.loader loader, l.icon icon,
+ (l.metadata->>'platform')::boolean AS platform,
ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,
ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games
FROM loaders l
@@ -179,7 +185,9 @@ impl Loader {
supported_games: x
.games
.unwrap_or_default(),
- metadata: x.metadata
+ metadata: LoaderMetadata {
+ platform: x.platform,
+ },
})
.try_collect::>()
.await?;
@@ -277,8 +285,9 @@ pub struct LoaderFieldEnumValue {
pub value: String,
pub ordering: Option,
pub created: DateTime,
- #[serde(flatten)]
- pub metadata: serde_json::Value,
+ #[serde(rename = "type")]
+ pub ty: Option,
+ pub major: Option,
}
impl std::hash::Hash for LoaderFieldEnumValue {
@@ -357,7 +366,8 @@ pub struct QueryLoaderFieldEnumValue {
pub value: String,
pub ordering: Option,
pub created: DateTime,
- pub metadata: Option,
+ pub ty: Option,
+ pub major: Option,
}
impl LoaderField {
@@ -612,42 +622,50 @@ impl LoaderFieldEnumValue {
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
- let val = redis.get_cached_keys_raw(
- LOADER_FIELD_ENUM_VALUES_NAMESPACE,
- &loader_field_enum_ids.iter().map(|x| x.0).collect::>(),
- |loader_field_enum_ids| async move {
- let values = sqlx::query!(
- "
- SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values
+ let val = redis
+ .get_cached_keys_raw(
+ LOADER_FIELD_ENUM_VALUES_NAMESPACE,
+ &loader_field_enum_ids
+ .iter()
+ .map(|x| x.0)
+ .collect::>(),
+ |loader_field_enum_ids| async move {
+ let values = sqlx::query!(
+ r#"
+ SELECT id, enum_id, value, ordering,
+ metadata->>'type' AS "ty?",
+ (metadata->>'major')::boolean AS "major?",
+ created FROM loader_field_enum_values
WHERE enum_id = ANY($1)
ORDER BY enum_id, ordering, created DESC
- ",
- &loader_field_enum_ids
- )
+ "#,
+ &loader_field_enum_ids
+ )
.fetch(exec)
- .try_fold(DashMap::new(), |acc: DashMap>, c| {
- let value = LoaderFieldEnumValue {
- id: LoaderFieldEnumValueId(c.id),
- enum_id: LoaderFieldEnumId(c.enum_id),
- value: c.value,
- ordering: c.ordering,
- created: c.created,
- metadata: c.metadata.unwrap_or_default(),
- };
+ .try_fold(
+ DashMap::new(),
+ |acc: DashMap>, c| {
+ let value = LoaderFieldEnumValue {
+ id: LoaderFieldEnumValueId(c.id),
+ enum_id: LoaderFieldEnumId(c.enum_id),
+ value: c.value,
+ ordering: c.ordering,
+ created: c.created,
+ ty: c.ty,
+ major: c.major,
+ };
- acc.entry(c.enum_id)
- .or_default()
- .push(value);
+ acc.entry(c.enum_id).or_default().push(value);
- async move {
- Ok(acc)
- }
- })
+ async move { Ok(acc) }
+ },
+ )
.await?;
- Ok::<_, DatabaseError>(values)
- },
- ).await?;
+ Ok::<_, DatabaseError>(values)
+ },
+ )
+ .await?;
Ok(val
.into_iter()
@@ -669,15 +687,16 @@ impl LoaderFieldEnumValue {
.await?
.into_iter()
.filter(|x| {
- let mut bool = true;
- for (key, value) in &filter {
- if let Some(metadata_value) = x.metadata.get(key) {
- bool &= metadata_value == value;
- } else {
- bool = false;
+ filter.iter().all(|(key, value)| match key.as_str() {
+ "type" => {
+ x.ty.as_deref()
+ .is_some_and(|type_| value.as_str() == Some(type_))
}
- }
- bool
+ "major" => x
+ .major
+ .is_some_and(|major| value.as_bool() == Some(major)),
+ _ => false,
+ })
})
.collect();
@@ -1170,10 +1189,8 @@ impl VersionFieldValue {
value: lfev.value.clone(),
ordering: lfev.ordering,
created: lfev.created,
- metadata: lfev
- .metadata
- .clone()
- .unwrap_or_default(),
+ ty: lfev.ty.clone(),
+ major: lfev.major,
}
}),
))
@@ -1249,10 +1266,8 @@ impl VersionFieldValue {
value: lfev.value.clone(),
ordering: lfev.ordering,
created: lfev.created,
- metadata: lfev
- .metadata
- .clone()
- .unwrap_or_default(),
+ ty: lfev.ty.clone(),
+ major: lfev.major,
})
})
.collect::>()?,
diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs
index 09eddcaed7..896791e7d3 100644
--- a/apps/labrinth/src/database/models/mod.rs
+++ b/apps/labrinth/src/database/models/mod.rs
@@ -30,6 +30,7 @@ pub mod payout_item;
pub mod payouts_values_notifications;
pub mod product_item;
pub mod products_tax_identifier_item;
+pub mod project_disclosure_item;
pub mod project_item;
pub mod report_item;
pub mod session_item;
@@ -53,6 +54,7 @@ pub use image_item::DBImage;
pub use oauth_client_item::DBOAuthClient;
pub use organization_item::DBOrganization;
pub use passkey_item::DBPasskey;
+pub use project_disclosure_item::DBProjectDisclosure;
pub use project_item::DBProject;
pub use team_item::DBTeam;
pub use team_item::DBTeamMember;
@@ -77,8 +79,6 @@ pub enum DatabaseError {
SerdeCacheError(#[from] serde_json::Error),
#[error("error while encoding or decoding the cache: {0}")]
PostcardCacheError(#[from] postcard::Error),
- #[error(transparent)]
- Redis(#[from] xredis::Error),
#[error("Schema error: {0}")]
SchemaError(String),
}
diff --git a/apps/labrinth/src/database/models/moderation_note_item.rs b/apps/labrinth/src/database/models/moderation_note_item.rs
index 5f837b5da1..b0f14d175e 100644
--- a/apps/labrinth/src/database/models/moderation_note_item.rs
+++ b/apps/labrinth/src/database/models/moderation_note_item.rs
@@ -7,9 +7,9 @@ use xredis::RedisPool;
use super::{DBOrganizationId, DBUserId, DatabaseError};
-const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v3";
+const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v4";
const MODERATION_NOTES_ORGANIZATIONS_NAMESPACE: &str =
- "moderation_notes_organizations:v3";
+ "moderation_notes_organizations:v4";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBModerationNote {
diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs
index b97972c647..6421a6d0bf 100644
--- a/apps/labrinth/src/database/models/notification_item.rs
+++ b/apps/labrinth/src/database/models/notification_item.rs
@@ -10,7 +10,7 @@ use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v3";
+const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v4";
pub struct NotificationBuilder {
pub body: NotificationBody,
diff --git a/apps/labrinth/src/database/models/notifications_template_item.rs b/apps/labrinth/src/database/models/notifications_template_item.rs
index e5b7911561..3c9ef97f70 100644
--- a/apps/labrinth/src/database/models/notifications_template_item.rs
+++ b/apps/labrinth/src/database/models/notifications_template_item.rs
@@ -1,14 +1,16 @@
use crate::database::models::DatabaseError;
use crate::models::v3::notifications::{NotificationChannel, NotificationType};
use crate::routes::ApiError;
+use crate::util::error::ApiContext as _;
+use crate::util::error::Context as _;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const TEMPLATES_NAMESPACE: &str = "notifications_templates:v3";
+const TEMPLATES_NAMESPACE: &str = "notifications_templates:v4";
const TEMPLATES_HTML_DATA_NAMESPACE: &str =
- "notifications_templates_html_data:v3";
+ "notifications_templates_html_data:v4";
const TEMPLATES_DYNAMIC_HTML_NAMESPACE: &str =
- "notifications_templates_dynamic_html:v3";
+ "notifications_templates_dynamic_html:v4";
const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes
const TEMPLATES_CACHE_EXPIRY: i64 = 60 * 30; // 30 minutes
@@ -123,27 +125,38 @@ where
html: String,
}
- let mut redis_conn = redis.connect().await?;
+ let mut redis_conn = redis.connect().await.wrap_internal_err(
+ "connecting to redis for dynamic notification html",
+ )?;
let redis_key = redis_conn
.key()
.metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key);
- if let Some(body) =
- redis_conn.get_deserialized::(&redis_key).await?
+ if let Some(body) = redis_conn
+ .get_deserialized::(&redis_key)
+ .await
+ .wrap_internal_err("fetching dynamic notification html from redis")?
{
return Ok(body.html);
}
drop(redis_conn);
- let cached = HtmlBody { html: get().await? };
- let mut redis_conn = redis.connect().await?;
+ let cached = HtmlBody {
+ html: get()
+ .await
+ .wrap_api_err("generating notification template HTML")?,
+ };
+ let mut redis_conn = redis.connect().await.wrap_internal_err(
+ "connecting to redis for dynamic notification html",
+ )?;
let redis_key = redis_conn
.key()
.metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key);
redis_conn
.set_serialized(&redis_key, &cached, Some(HTML_DATA_CACHE_EXPIRY))
- .await?;
+ .await
+ .wrap_internal_err("writing dynamic notification html to redis")?;
Ok(cached.html)
}
diff --git a/apps/labrinth/src/database/models/notifications_type_item.rs b/apps/labrinth/src/database/models/notifications_type_item.rs
index 4663d90920..c2d036a3f7 100644
--- a/apps/labrinth/src/database/models/notifications_type_item.rs
+++ b/apps/labrinth/src/database/models/notifications_type_item.rs
@@ -3,7 +3,7 @@ use crate::models::v3::notifications::NotificationType;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v3";
+const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v4";
#[derive(Serialize, Deserialize)]
pub struct NotificationTypeItem {
diff --git a/apps/labrinth/src/database/models/organization_item.rs b/apps/labrinth/src/database/models/organization_item.rs
index 2cb83b6fc3..845ff1235d 100644
--- a/apps/labrinth/src/database/models/organization_item.rs
+++ b/apps/labrinth/src/database/models/organization_item.rs
@@ -9,8 +9,8 @@ use xredis::RedisPool;
use super::{DBTeamMember, ids::*};
use serde::{Deserialize, Serialize};
-const ORGANIZATIONS_NAMESPACE: &str = "organizations:v3";
-const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v3";
+const ORGANIZATIONS_NAMESPACE: &str = "organizations:v4";
+const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v4";
#[derive(Deserialize, Serialize, Clone, Debug)]
/// An organization of users who together control one or more projects and organizations.
diff --git a/apps/labrinth/src/database/models/pat_item.rs b/apps/labrinth/src/database/models/pat_item.rs
index 50a05c84c7..deb6f4a0b2 100644
--- a/apps/labrinth/src/database/models/pat_item.rs
+++ b/apps/labrinth/src/database/models/pat_item.rs
@@ -11,9 +11,9 @@ use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
-const PATS_NAMESPACE: &str = "pats:v3";
-const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v3";
-const PATS_USERS_NAMESPACE: &str = "pats_users:v3";
+const PATS_NAMESPACE: &str = "pats:v4";
+const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v4";
+const PATS_USERS_NAMESPACE: &str = "pats_users:v4";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBPersonalAccessToken {
diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs
index 266eafa773..a1f4cc291e 100644
--- a/apps/labrinth/src/database/models/product_item.rs
+++ b/apps/labrinth/src/database/models/product_item.rs
@@ -9,7 +9,7 @@ use std::convert::TryFrom;
use std::convert::TryInto;
use xredis::RedisPool;
-const PRODUCTS_NAMESPACE: &str = "products:v3";
+const PRODUCTS_NAMESPACE: &str = "products:v4";
pub struct DBProduct {
pub id: DBProductId,
@@ -136,7 +136,7 @@ pub struct QueryProductWithPrices {
pub id: DBProductId,
pub metadata: ProductMetadata,
pub unitary: bool,
- #[serde(skip_serializing_if = "Option::is_none", default)]
+ #[serde(default)]
pub name: Option,
pub prices: Vec,
}
diff --git a/apps/labrinth/src/database/models/products_tax_identifier_item.rs b/apps/labrinth/src/database/models/products_tax_identifier_item.rs
index 506a82ce2e..0a8a5a85e5 100644
--- a/apps/labrinth/src/database/models/products_tax_identifier_item.rs
+++ b/apps/labrinth/src/database/models/products_tax_identifier_item.rs
@@ -1,6 +1,7 @@
use crate::database::models::ids::{DBProductId, DBProductPriceId};
use crate::models::billing::ProductMetadata;
use crate::routes::ApiError;
+use crate::util::error::Context as _;
pub struct DBProductsTaxIdentifier {
pub id: i32,
@@ -18,7 +19,8 @@ impl DBProductsTaxIdentifier {
product_id.0,
)
.fetch_optional(exec)
- .await?;
+ .await
+ .wrap_internal_err("querying database for `get_product`")?;
Ok(maybe_row.map(|row| DBProductsTaxIdentifier {
id: row.id,
@@ -41,7 +43,8 @@ impl DBProductsTaxIdentifier {
price_id.0,
)
.fetch_optional(exec)
- .await?;
+ .await
+ .wrap_internal_err("querying database for `get_price`")?;
Ok(maybe_row.map(|row| DBProductsTaxIdentifier {
id: row.id,
@@ -73,7 +76,7 @@ pub async fn product_info_by_product_price_id(
product_price_id.0 as i64,
)
.fetch_optional(exec)
- .await?;
+ .await.wrap_internal_err("querying database for `product_info_by_product_price_id`")?;
match maybe_row {
None => Ok(None),
@@ -83,7 +86,8 @@ pub async fn product_info_by_product_price_id(
tax_processor_id: row.tax_processor_id,
product_id: DBProductId(row.product_id),
},
- product_metadata: serde_json::from_value(row.product_metadata)?,
+ product_metadata: serde_json::from_value(row.product_metadata)
+ .wrap_request_err("deserializing JSON data")?,
})),
}
}
diff --git a/apps/labrinth/src/database/models/project_disclosure_item.rs b/apps/labrinth/src/database/models/project_disclosure_item.rs
new file mode 100644
index 0000000000..ef8cdf0866
--- /dev/null
+++ b/apps/labrinth/src/database/models/project_disclosure_item.rs
@@ -0,0 +1,188 @@
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ database::models::{DBProjectId, DBUserId, DatabaseError},
+ models::v3::disclosures::{
+ DisclosureLockStatus, ProjectDisclosure, ProjectDisclosureType,
+ },
+};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DBProjectDisclosure {
+ pub project_id: DBProjectId,
+ pub disclosure: ProjectDisclosure,
+ pub updated_at: DateTime,
+ pub updated_by: DBUserId,
+ pub set_by_moderator: bool,
+ pub deleted_at: Option>,
+ pub lock_status: DisclosureLockStatus,
+}
+
+impl DBProjectDisclosure {
+ pub async fn upsert(
+ &self,
+ exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result<(), DatabaseError> {
+ let (disclosure_type, metadata) =
+ self.disclosure.to_parts().map_err(|e| {
+ DatabaseError::Internal(eyre::Report::new(e).wrap_err(
+ "failed to serialize project disclosure metadata",
+ ))
+ })?;
+
+ sqlx::query!(
+ r#"
+ INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator, lock_status)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (project_id, type) DO UPDATE SET
+ metadata = $3,
+ updated_at = now(),
+ updated_by = $4,
+ set_by_moderator = $5,
+ deleted_at = NULL,
+ lock_status = $6
+ "#,
+ self.project_id as DBProjectId,
+ disclosure_type,
+ metadata,
+ self.updated_by as DBUserId,
+ self.set_by_moderator,
+ <&'static str>::from(self.lock_status),
+ )
+ .execute(exec)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn get_many_for_project(
+ project_id: DBProjectId,
+ include_deleted: bool,
+ exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result, DatabaseError> {
+ let rows = sqlx::query!(
+ r#"
+ SELECT project_id, type AS "disclosure_type!", metadata, updated_at, updated_by, set_by_moderator, deleted_at, lock_status
+ FROM project_disclosures
+ WHERE project_id = $1 AND ($2 OR deleted_at IS NULL)
+ ORDER BY updated_at DESC
+ "#,
+ project_id as DBProjectId,
+ include_deleted,
+ )
+ .fetch_all(exec)
+ .await?;
+
+ rows.into_iter()
+ .map(|row| {
+ Ok(DBProjectDisclosure {
+ project_id: DBProjectId(row.project_id),
+ disclosure: ProjectDisclosure::from_parts(
+ &row.disclosure_type,
+ row.metadata,
+ )
+ .map_err(|e| {
+ DatabaseError::Internal(eyre::Report::new(e).wrap_err(
+ "failed to deserialize project disclosure metadata",
+ ))
+ })?,
+ updated_at: row.updated_at,
+ updated_by: DBUserId(row.updated_by),
+ set_by_moderator: row.set_by_moderator,
+ deleted_at: row.deleted_at,
+ lock_status: DisclosureLockStatus::from_str(
+ &row.lock_status,
+ )
+ .map_err(|e| {
+ DatabaseError::Internal(eyre::Report::new(e).wrap_err(
+ "failed to parse project disclosure lock status",
+ ))
+ })?,
+ })
+ })
+ .collect()
+ }
+
+ /// Returns the subset of `project_ids` that carry a disclosure of the given type.
+ pub async fn projects_with_type(
+ disclosure_type: ProjectDisclosureType,
+ project_ids: &[DBProjectId],
+ exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result, DatabaseError> {
+ let ids = project_ids.iter().map(|id| id.0).collect::>();
+ let rows = sqlx::query_scalar!(
+ r#"
+ SELECT project_id
+ FROM project_disclosures
+ WHERE type = $1 AND project_id = ANY($2) AND deleted_at IS NULL
+ "#,
+ <&'static str>::from(disclosure_type),
+ &ids,
+ )
+ .fetch_all(exec)
+ .await?;
+
+ Ok(rows.into_iter().map(DBProjectId).collect())
+ }
+
+ pub async fn get_lock_statuses_for_project(
+ project_id: DBProjectId,
+ types: &[String],
+ exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result, DatabaseError> {
+ let rows = sqlx::query!(
+ r#"
+ SELECT type AS "disclosure_type!", lock_status
+ FROM project_disclosures
+ WHERE project_id = $1 AND type = ANY($2)
+ "#,
+ project_id as DBProjectId,
+ types,
+ )
+ .fetch_all(exec)
+ .await?;
+
+ rows.into_iter()
+ .map(|row| {
+ let lock_status = DisclosureLockStatus::from_str(
+ &row.lock_status,
+ )
+ .map_err(|e| {
+ DatabaseError::Internal(eyre::Report::new(e).wrap_err(
+ "failed to parse project disclosure lock status",
+ ))
+ })?;
+
+ Ok((row.disclosure_type, lock_status))
+ })
+ .collect()
+ }
+
+ pub async fn remove(
+ project_id: DBProjectId,
+ disclosure_type: ProjectDisclosureType,
+ updated_by: DBUserId,
+ set_by_moderator: bool,
+ exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result {
+ let result = sqlx::query!(
+ r#"
+ UPDATE project_disclosures
+ SET deleted_at = now(), updated_at = now(), updated_by = $3, set_by_moderator = $4
+ WHERE project_id = $1 AND type = $2 AND deleted_at IS NULL
+ "#,
+ project_id as DBProjectId,
+ <&'static str>::from(disclosure_type),
+ updated_by as DBUserId,
+ set_by_moderator,
+ )
+ .execute(exec)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+ }
+}
diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs
index e862cf76f3..f4d0d3118a 100644
--- a/apps/labrinth/src/database/models/project_item.rs
+++ b/apps/labrinth/src/database/models/project_item.rs
@@ -19,13 +19,14 @@ use dashmap::{DashMap, DashSet};
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
+use serde_binhum::serde_binhum;
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
-pub const PROJECTS_NAMESPACE: &str = "projects:v3";
-pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v3";
-const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v3";
+pub const PROJECTS_NAMESPACE: &str = "projects:v4";
+pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v4";
+const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v4";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LinkUrl {
@@ -657,12 +658,14 @@ impl DBProject {
.await?;
let loader_field_enum_values: Vec = sqlx::query!(
- "
- SELECT DISTINCT id, enum_id, value, ordering, created, metadata
+ r#"
+ SELECT DISTINCT id, enum_id, value, ordering, created,
+ metadata->>'type' AS "ty?",
+ (metadata->>'major')::boolean AS "major?"
FROM loader_field_enum_values lfev
WHERE id = ANY($1)
ORDER BY enum_id, ordering, created DESC
- ",
+ "#,
&loader_field_enum_value_ids
.iter()
.map(|x| x.0)
@@ -675,7 +678,8 @@ impl DBProject {
value: m.value,
ordering: m.ordering,
created: m.created,
- metadata: m.metadata,
+ ty: m.ty,
+ major: m.major,
})
.try_collect()
.await?;
@@ -946,7 +950,7 @@ impl DBProject {
},
)
.await
- .wrap_internal_err("failed to fetch cached projects")?;
+ .wrap_internal_err("fetching cached projects")?;
Ok(val)
}
@@ -1041,8 +1045,10 @@ impl DBProject {
}
}
-#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde_binhum]
+#[derive(Clone, Debug)]
pub struct ProjectQueryResult {
+ #[serde(flatten)]
pub inner: DBProject,
pub categories: Vec,
pub additional_categories: Vec,
@@ -1053,6 +1059,5 @@ pub struct ProjectQueryResult {
pub gallery_items: Vec,
pub thread_id: DBThreadId,
pub aggregate_version_fields: Vec,
- #[serde(flatten)]
pub components: exp::ProjectQuery,
}
diff --git a/apps/labrinth/src/database/models/session_item.rs b/apps/labrinth/src/database/models/session_item.rs
index 9d58dd4f12..9ca8ae560d 100644
--- a/apps/labrinth/src/database/models/session_item.rs
+++ b/apps/labrinth/src/database/models/session_item.rs
@@ -10,9 +10,9 @@ use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
-const SESSIONS_NAMESPACE: &str = "sessions:v3";
-const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v3";
-const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v3";
+const SESSIONS_NAMESPACE: &str = "sessions:v4";
+const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v4";
+const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v4";
pub struct SessionBuilder {
pub session: String,
diff --git a/apps/labrinth/src/database/models/team_item.rs b/apps/labrinth/src/database/models/team_item.rs
index 698c9d08a5..4246e60d7d 100644
--- a/apps/labrinth/src/database/models/team_item.rs
+++ b/apps/labrinth/src/database/models/team_item.rs
@@ -10,7 +10,7 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
-const TEAMS_NAMESPACE: &str = "teams:v3";
+const TEAMS_NAMESPACE: &str = "teams:v4";
pub struct TeamBuilder {
pub members: Vec,
diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs
index 416d1bad18..cb88f48a72 100644
--- a/apps/labrinth/src/database/models/user_item.rs
+++ b/apps/labrinth/src/database/models/user_item.rs
@@ -1,24 +1,28 @@
use super::ids::{DBProjectId, DBUserId};
use super::{DBCollectionId, DBReportId, DBThreadId};
use crate::database::models::charge_item::DBCharge;
+use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::models::user_subscription_item::DBUserSubscription;
use crate::database::models::{DBOrganizationId, DatabaseError};
use crate::database::{PgTransaction, models};
use crate::models::billing::ChargeStatus;
+use crate::models::projects::ProjectStatus;
+use crate::models::threads::MessageBody;
use crate::models::users::Badges;
use crate::util::error::Context;
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
+use futures::TryStreamExt;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
-const USERS_NAMESPACE: &str = "users:v3";
-const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v3";
-const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v3";
+const USERS_NAMESPACE: &str = "users:v4";
+const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v4";
+const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v4";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBUser {
@@ -593,12 +597,120 @@ impl DBUser {
.wrap_err("failed to get user by ID")?;
if let Some(delete_user) = user {
+ let username = delete_user.username.clone();
DBUser::clear_caches(&[(id, Some(delete_user.username))], redis)
.await
.wrap_err("failed to clear caches")?;
let deleted_user: DBUserId =
crate::models::users::DELETED_USER.into();
+ let user_id_str = ariadne::ids::UserId::from(id).to_string();
+
+ let owned_projects = sqlx::query!(
+ r#"
+ SELECT
+ m.id AS "id!",
+ m.status AS "status!",
+ m.slug,
+ t.id AS "thread_id!"
+ FROM mods m
+ INNER JOIN team_members tm
+ ON tm.team_id = m.team_id
+ AND tm.user_id = $1
+ AND tm.is_owner = TRUE
+ INNER JOIN threads t ON t.mod_id = m.id
+ "#,
+ id as DBUserId,
+ )
+ .fetch(&mut *transaction)
+ .try_collect::>()
+ .await
+ .wrap_err("failed to fetch projects owned by deleted user")?;
+
+ for project in &owned_projects {
+ let thread_id = DBThreadId(project.thread_id);
+
+ ThreadMessageBuilder {
+ author_id: Some(deleted_user),
+ body: MessageBody::Text {
+ body: format!(
+ "Project transferred to Ghost when user account `{username}` (`{user_id_str}`) was deleted"
+ ),
+ private: true,
+ replying_to: None,
+ associated_images: Vec::new(),
+ },
+ thread_id,
+ hide_identity: false,
+ }
+ .insert(&mut *transaction)
+ .await
+ .wrap_err(
+ "failed to insert project transfer thread message",
+ )?;
+
+ if ProjectStatus::from_string(&project.status)
+ == ProjectStatus::Processing
+ {
+ ThreadMessageBuilder {
+ author_id: Some(deleted_user),
+ body: MessageBody::Text {
+ body: format!(
+ "Automatically rejected when user account `{username}` (`{user_id_str}`) was deleted"
+ ),
+ private: true,
+ replying_to: None,
+ associated_images: Vec::new(),
+ },
+ thread_id,
+ hide_identity: false,
+ }
+ .insert(&mut *transaction)
+ .await
+ .wrap_err(
+ "failed to insert automatic rejection thread message",
+ )?;
+
+ ThreadMessageBuilder {
+ author_id: Some(deleted_user),
+ body: MessageBody::StatusChange {
+ new_status: ProjectStatus::Rejected,
+ old_status: ProjectStatus::Processing,
+ },
+ thread_id,
+ hide_identity: false,
+ }
+ .insert(&mut *transaction)
+ .await
+ .wrap_err(
+ "failed to insert automatic rejection status change",
+ )?;
+
+ sqlx::query!(
+ r#"
+ UPDATE mods
+ SET status = $1
+ WHERE id = $2
+ "#,
+ ProjectStatus::Rejected.as_str(),
+ project.id,
+ )
+ .execute(&mut *transaction)
+ .await
+ .wrap_err(
+ "failed to reject processing project owned by deleted user",
+ )?;
+ }
+
+ models::DBProject::clear_cache(
+ DBProjectId(project.id),
+ project.slug.clone(),
+ None,
+ redis,
+ )
+ .await
+ .wrap_err("failed to clear project cache")?;
+ }
sqlx::query!(
"
@@ -626,7 +738,6 @@ impl DBUser {
.await
.wrap_err("failed to update versions author_id")?;
- use futures::TryStreamExt;
let notifications: Vec = sqlx::query!(
"
SELECT n.id FROM notifications n
diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs
index 096bed6614..aff56773e5 100644
--- a/apps/labrinth/src/database/models/version_item.rs
+++ b/apps/labrinth/src/database/models/version_item.rs
@@ -17,12 +17,13 @@ use dashmap::{DashMap, DashSet};
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
+use serde_binhum::serde_binhum;
use std::cmp::Ordering;
use std::collections::HashMap;
use tracing::error;
-pub const VERSIONS_NAMESPACE: &str = "versions:v3";
-const VERSION_FILES_NAMESPACE: &str = "versions_files:v3";
+pub const VERSIONS_NAMESPACE: &str = "versions:v4";
+const VERSION_FILES_NAMESPACE: &str = "versions_files:v4";
pub async fn cleanup_unused_attribution_files_and_groups(
transaction: &mut PgTransaction<'_>,
@@ -704,12 +705,14 @@ impl DBVersion {
.await?;
let loader_field_enum_values: Vec = sqlx::query!(
- "
- SELECT DISTINCT id, enum_id, value, ordering, created, metadata
+ r#"
+ SELECT DISTINCT id, enum_id, value, ordering, created,
+ metadata->>'type' AS "ty?",
+ (metadata->>'major')::boolean AS "major?"
FROM loader_field_enum_values lfev
WHERE id = ANY($1)
ORDER BY enum_id, ordering, created ASC
- ",
+ "#,
&loader_field_enum_value_ids
.iter()
.map(|x| x.0)
@@ -722,7 +725,8 @@ impl DBVersion {
value: m.value,
ordering: m.ordering,
created: m.created,
- metadata: m.metadata,
+ ty: m.ty,
+ major: m.major,
})
.try_collect()
.await?;
@@ -1080,8 +1084,10 @@ impl DBVersion {
}
}
-#[derive(Clone, Deserialize, Serialize)]
+#[serde_binhum]
+#[derive(Clone)]
pub struct VersionQueryResult {
+ #[serde(flatten)]
pub inner: DBVersion,
pub files: Vec,
@@ -1090,7 +1096,6 @@ pub struct VersionQueryResult {
pub project_types: Vec,
pub games: Vec,
pub dependencies: Vec,
- #[serde(flatten)]
pub components: exp::VersionQuery,
}
diff --git a/apps/labrinth/src/database/redis.rs b/apps/labrinth/src/database/redis.rs
deleted file mode 100644
index 0bc5f37231..0000000000
--- a/apps/labrinth/src/database/redis.rs
+++ /dev/null
@@ -1,56 +0,0 @@
-use std::sync::Arc;
-
-use crate::env::ENV;
-
-struct RedisConfig {
- inner: xredis::RedisConfig,
- cache_settings: xredis::CacheSettings,
-}
-
-impl RedisConfig {
- fn from_env() -> Result {
- let inner = xredis::RedisConfig::new(
- ENV.REDIS_TOPOLOGY,
- ENV.REDIS_CONNECTION_TYPE,
- &ENV.REDIS_URL,
- ENV.REDIS_WAIT_TIMEOUT_MS,
- (
- ENV.REDIS_MAX_CONNECTIONS as usize,
- ENV.REDIS_MIN_CONNECTIONS,
- ),
- (
- ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize,
- ENV.REDIS_CLUSTER_MIN_CONNECTIONS,
- ),
- (ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0),
- ENV.REDIS_CACHE_LOCKING_STRATEGY,
- ENV.REDIS_READ_REPLICA_STRATEGY,
- )?;
- let cache_settings = xredis::CacheSettings {
- default_expiry: ENV.REDIS_DEFAULT_EXPIRY,
- actual_expiry: ENV.REDIS_ACTUAL_EXPIRY,
- version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY,
- version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY,
- encoding_format: ENV.REDIS_ENCODING_FORMAT,
- compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM,
- compression_level: ENV.REDIS_COMPRESSION_LEVEL,
- compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES,
- compression_min_savings_ratio: ENV
- .REDIS_COMPRESSION_MIN_SAVINGS_RATIO,
- };
-
- Ok(Self {
- inner,
- cache_settings,
- })
- }
-}
-
-pub async fn from_env(
- meta_namespace: impl Into>,
-) -> xredis::RedisPool {
- let config = RedisConfig::from_env().expect("invalid Redis configuration");
- xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings)
- .await
- .expect("failed to initialize Redis connections")
-}
diff --git a/apps/labrinth/src/database/redis/mod.rs b/apps/labrinth/src/database/redis/mod.rs
new file mode 100644
index 0000000000..b2d4d4724f
--- /dev/null
+++ b/apps/labrinth/src/database/redis/mod.rs
@@ -0,0 +1,411 @@
+use std::sync::Arc;
+
+use crate::env::ENV;
+use eyre::{Result, WrapErr};
+
+struct RedisConfig {
+ inner: xredis::RedisConfig,
+ cache_settings: xredis::CacheSettings,
+}
+
+impl RedisConfig {
+ fn from_env() -> Result {
+ let inner = xredis::RedisConfig::new(
+ ENV.REDIS_TOPOLOGY,
+ ENV.REDIS_CONNECTION_TYPE,
+ &ENV.REDIS_URL,
+ ENV.REDIS_WAIT_TIMEOUT_MS,
+ (
+ ENV.REDIS_MAX_CONNECTIONS as usize,
+ ENV.REDIS_MIN_CONNECTIONS,
+ ),
+ (
+ ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize,
+ ENV.REDIS_CLUSTER_MIN_CONNECTIONS,
+ ),
+ (ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0),
+ ENV.REDIS_CACHE_LOCKING_STRATEGY,
+ ENV.REDIS_READ_REPLICA_STRATEGY,
+ )
+ .wrap_err("loading Redis configuration from environment")?;
+ let cache_settings = xredis::CacheSettings {
+ default_expiry: ENV.REDIS_DEFAULT_EXPIRY,
+ actual_expiry: ENV.REDIS_ACTUAL_EXPIRY,
+ version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY,
+ version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY,
+ encoding_format: ENV.REDIS_ENCODING_FORMAT,
+ compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM,
+ compression_level: ENV.REDIS_COMPRESSION_LEVEL,
+ compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES,
+ compression_min_savings_ratio: ENV
+ .REDIS_COMPRESSION_MIN_SAVINGS_RATIO,
+ };
+
+ Ok(Self {
+ inner,
+ cache_settings,
+ })
+ }
+}
+
+pub async fn from_env(
+ meta_namespace: impl Into>,
+) -> xredis::RedisPool {
+ let config = RedisConfig::from_env().expect("invalid Redis configuration");
+ xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings)
+ .await
+ .expect("failed to initialize Redis connections")
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+
+ use ::serde::{Serialize, de::DeserializeOwned};
+ use chrono::Utc;
+ use url::Url;
+ use uuid::Uuid;
+ use webauthn_rs::WebauthnBuilder;
+
+ use crate::database::models::flow_item::DBFlow;
+ use crate::database::models::ids::{
+ DBNotificationId, DBProjectId, DBTeamId, DBThreadId, DBUserId,
+ DBVersionId, LoaderFieldEnumId, LoaderFieldEnumValueId, LoaderFieldId,
+ LoaderId,
+ };
+ use crate::database::models::loader_fields::{
+ Loader, LoaderFieldEnumValue, LoaderMetadata, VersionField,
+ VersionFieldValue,
+ };
+ use crate::database::models::notification_item::DBNotification;
+ use crate::database::models::project_item::{
+ DBProject, ProjectQueryResult,
+ };
+ use crate::database::models::version_item::{
+ DBVersion, VersionQueryResult,
+ };
+ use crate::models::billing::{Price, ProductMetadata};
+ use crate::models::exp::{self, minecraft};
+ use crate::models::notifications::NotificationBody;
+ use crate::models::projects::{
+ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
+ VersionStatus,
+ };
+
+ fn postcard_round_trip(value: &T) -> T
+ where
+ T: Serialize + DeserializeOwned,
+ {
+ let serialized =
+ postcard::to_allocvec(value).expect("serializing with postcard");
+ postcard::from_bytes(&serialized).expect("deserializing with postcard")
+ }
+
+ fn loader_field_enum_value(ty: &str, major: bool) -> LoaderFieldEnumValue {
+ LoaderFieldEnumValue {
+ id: LoaderFieldEnumValueId(1),
+ enum_id: LoaderFieldEnumId(2),
+ value: "1.21.8".to_string(),
+ ordering: None,
+ created: Utc::now(),
+ ty: Some(ty.to_string()),
+ major: Some(major),
+ }
+ }
+
+ fn db_project() -> DBProject {
+ let now = Utc::now();
+
+ DBProject {
+ id: DBProjectId(1),
+ team_id: DBTeamId(2),
+ organization_id: None,
+ name: "project".to_string(),
+ summary: "summary".to_string(),
+ description: "description".to_string(),
+ published: now,
+ updated: now,
+ approved: Some(now),
+ queued: None,
+ status: ProjectStatus::Approved,
+ requested_status: None,
+ downloads: 3,
+ follows: 4,
+ icon_url: None,
+ raw_icon_url: None,
+ license_url: None,
+ license: "MIT".to_string(),
+ slug: Some("project".to_string()),
+ moderation_message: None,
+ moderation_message_body: None,
+ webhook_sent: false,
+ color: None,
+ monetization_status: MonetizationStatus::Monetized,
+ side_types_migration_review_status:
+ SideTypesMigrationReviewStatus::Reviewed,
+ loaders: vec!["fabric".to_string()],
+ components: exp::ProjectSerial::default(),
+ }
+ }
+
+ fn db_version() -> DBVersion {
+ DBVersion {
+ id: DBVersionId(1),
+ project_id: DBProjectId(2),
+ author_id: DBUserId(3),
+ name: "version".to_string(),
+ version_number: "1.0.0".to_string(),
+ changelog: "changelog".to_string(),
+ date_published: Utc::now(),
+ downloads: 4,
+ version_type: "release".to_string(),
+ featured: true,
+ status: VersionStatus::Listed,
+ requested_status: None,
+ ordering: None,
+ components: exp::VersionSerial::default(),
+ }
+ }
+
+ #[test]
+ fn loader_metadata_round_trips_with_postcard() {
+ for platform in [None, Some(false), Some(true)] {
+ let metadata = LoaderMetadata { platform };
+ let round_tripped = postcard_round_trip(&metadata);
+
+ assert_eq!(round_tripped.platform, platform);
+ }
+ }
+
+ #[test]
+ fn loader_round_trips_with_postcard() {
+ for platform in [None, Some(false), Some(true)] {
+ let loader = Loader {
+ id: LoaderId(1),
+ loader: "paper".to_string(),
+ icon: "icon".to_string(),
+ supported_project_types: vec!["plugin".to_string()],
+ supported_games: vec!["minecraft-java".to_string()],
+ metadata: LoaderMetadata { platform },
+ };
+ let round_tripped = postcard_round_trip(&loader);
+
+ assert_eq!(round_tripped.id, loader.id);
+ assert_eq!(round_tripped.loader, loader.loader);
+ assert_eq!(round_tripped.icon, loader.icon);
+ assert_eq!(
+ round_tripped.supported_project_types,
+ loader.supported_project_types
+ );
+ assert_eq!(round_tripped.supported_games, loader.supported_games);
+ assert_eq!(round_tripped.metadata.platform, platform);
+ }
+ }
+
+ #[test]
+ fn loader_field_enum_values_round_trip_with_postcard() {
+ let metadata_values = [
+ (None, None),
+ (Some("snapshot"), Some(false)),
+ (Some("alpha"), Some(false)),
+ (Some("beta"), Some(true)),
+ (Some("release"), Some(true)),
+ (Some("beta"), Some(false)),
+ (Some("release"), Some(false)),
+ ];
+
+ for (ty, major) in metadata_values {
+ let enum_value = LoaderFieldEnumValue {
+ id: LoaderFieldEnumValueId(1),
+ enum_id: LoaderFieldEnumId(2),
+ value: "1.21.8".to_string(),
+ ordering: None,
+ created: Utc::now(),
+ ty: ty.map(str::to_string),
+ major,
+ };
+
+ assert_eq!(postcard_round_trip(&enum_value), enum_value);
+ }
+ }
+
+ #[test]
+ fn loader_field_enum_value_keeps_flattened_json_layout() {
+ let enum_value = loader_field_enum_value("release", true);
+ let json = serde_json::to_value(&enum_value)
+ .expect("serializing loader field enum value as JSON");
+
+ assert_eq!(json.get("type"), Some(&serde_json::json!("release")));
+ assert_eq!(json.get("major"), Some(&serde_json::json!(true)));
+ assert!(json.get("metadata").is_none());
+ assert_eq!(
+ serde_json::from_value::(json)
+ .expect("deserializing loader field enum value from JSON"),
+ enum_value
+ );
+ }
+
+ #[test]
+ fn non_enum_version_fields_round_trip_with_postcard() {
+ let values = [
+ VersionFieldValue::Integer(5),
+ VersionFieldValue::Text("value".to_string()),
+ VersionFieldValue::Boolean(true),
+ VersionFieldValue::ArrayInteger(vec![1, 2]),
+ VersionFieldValue::ArrayText(vec!["one".to_string()]),
+ VersionFieldValue::ArrayBoolean(vec![true, false]),
+ ];
+
+ for value in values {
+ let field = VersionField {
+ version_id: DBVersionId(1),
+ field_id: LoaderFieldId(2),
+ field_name: "field".to_string(),
+ value,
+ };
+
+ assert_eq!(postcard_round_trip(&field), field);
+ }
+ }
+
+ #[test]
+ fn flattened_cache_values_round_trip_with_postcard() {
+ let enum_value = loader_field_enum_value("release", true);
+ postcard_round_trip(&enum_value);
+
+ let version = VersionQueryResult {
+ inner: db_version(),
+ files: Vec::new(),
+ version_fields: vec![VersionField {
+ version_id: DBVersionId(1),
+ field_id: LoaderFieldId(2),
+ field_name: "game_versions".to_string(),
+ value: VersionFieldValue::Enum(
+ LoaderFieldEnumId(2),
+ enum_value,
+ ),
+ }],
+ loaders: vec!["fabric".to_string()],
+ project_types: vec!["mod".to_string()],
+ games: vec!["minecraft-java".to_string()],
+ dependencies: Vec::new(),
+ components: exp::VersionQuery::default(),
+ };
+ postcard_round_trip(&version);
+
+ let project = ProjectQueryResult {
+ inner: db_project(),
+ categories: Vec::new(),
+ additional_categories: Vec::new(),
+ versions: vec![DBVersionId(1)],
+ project_types: vec!["mod".to_string()],
+ games: vec!["minecraft-java".to_string()],
+ urls: Vec::new(),
+ gallery_items: Vec::new(),
+ thread_id: DBThreadId(1),
+ aggregate_version_fields: Vec::new(),
+ components: exp::ProjectQuery::default(),
+ };
+ postcard_round_trip(&project);
+ }
+
+ #[test]
+ fn skipped_cache_fields_round_trip_with_postcard() {
+ postcard_round_trip(&exp::ProjectSerial::default());
+ postcard_round_trip(&exp::ProjectQuery::default());
+ postcard_round_trip(&db_project());
+ }
+
+ #[test]
+ fn internally_tagged_cache_values_round_trip_with_postcard() {
+ for metadata in [
+ ProductMetadata::Midas,
+ ProductMetadata::Pyro {
+ cpu: 1,
+ ram: 2,
+ swap: 3,
+ storage: 4,
+ },
+ ProductMetadata::Medal {
+ cpu: 1,
+ ram: 2,
+ swap: 3,
+ storage: 4,
+ region: "us-east".to_string(),
+ },
+ ] {
+ postcard_round_trip(&metadata);
+ }
+
+ for price in [
+ Price::OneTime { price: 500 },
+ Price::Recurring {
+ intervals: HashMap::new(),
+ },
+ ] {
+ postcard_round_trip(&price);
+ }
+
+ postcard_round_trip(&NotificationBody::TwoFactorEnabled);
+ postcard_round_trip(&DBNotification {
+ id: DBNotificationId(1),
+ user_id: DBUserId(2),
+ body: NotificationBody::TwoFactorEnabled,
+ read: false,
+ created: Utc::now(),
+ });
+ postcard_round_trip(&minecraft::ServerContent::Vanilla {
+ supported_game_versions: vec!["1.21.8".to_string()],
+ recommended_game_version: Some("1.21.8".to_string()),
+ });
+ postcard_round_trip(&minecraft::ServerContentQuery::Vanilla {
+ supported_game_versions: vec!["1.21.8".to_string()],
+ recommended_game_version: Some("1.21.8".to_string()),
+ });
+ }
+
+ #[test]
+ fn simple_flow_variants_round_trip_with_postcard() {
+ assert!(matches!(
+ postcard_round_trip(&DBFlow::MinecraftAuth),
+ DBFlow::MinecraftAuth
+ ));
+
+ let flow = postcard_round_trip(&DBFlow::Login2FA {
+ user_id: DBUserId(1),
+ });
+ assert!(matches!(
+ flow,
+ DBFlow::Login2FA {
+ user_id: DBUserId(1)
+ }
+ ));
+ }
+
+ #[test]
+ fn passkey_flow_variants_round_trip_with_postcard() {
+ let origin = Url::parse("https://example.com").unwrap();
+ let webauthn = WebauthnBuilder::new("example.com", &origin)
+ .unwrap()
+ .build()
+ .unwrap();
+ let (_, registration) = webauthn
+ .start_passkey_registration(
+ Uuid::from_u128(1),
+ "user@example.com",
+ "user",
+ None,
+ )
+ .unwrap();
+ postcard_round_trip(&DBFlow::RegisterPasskey {
+ user_id: DBUserId(1),
+ state: registration,
+ });
+
+ let (_, authentication) =
+ webauthn.start_discoverable_authentication().unwrap();
+ postcard_round_trip(&DBFlow::AuthenticatePasskey {
+ state: authentication,
+ });
+ }
+}
diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs
index 20ce98f546..ce0bba6fed 100644
--- a/apps/labrinth/src/env.rs
+++ b/apps/labrinth/src/env.rs
@@ -165,7 +165,7 @@ vars! {
REDIS_BLOCKING_MAX_CONNECTIONS: u32 = 256u32;
// The encoding format used for Redis cache values.
- REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Json;
+ REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Postcard;
// The level of LZ4 compression used for Redis cache values. A value of 0 disables compression (supports 1-12)
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
// The compression algorithm used for Redis cache values. Currently only LZ4 is supported.
@@ -237,6 +237,11 @@ vars! {
SEARCH_TYPESENSE_DEFAULT_BUCKETING: Json =
Json(crate::search::backend::typesense::Bucketing::Buckets(5));
SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES: usize = 24usize;
+ ELASTICSEARCH_URL: String = "http://localhost:9200";
+ ELASTICSEARCH_INDEX_PREFIX: String = "labrinth";
+ ELASTICSEARCH_USERNAME: String = "";
+ ELASTICSEARCH_PASSWORD: String = "";
+ ELASTICSEARCH_BULK_BATCH_SIZE: usize = 1000usize;
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
@@ -313,6 +318,7 @@ vars! {
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
EMAIL_DOMAIN_BLACKLIST: StringCsv = StringCsv(vec![]);
+ EMAIL_DOMAIN_WHITELIST: StringCsv = StringCsv(vec![]);
CLICKHOUSE_REPLICATED: bool = false;
CLICKHOUSE_URL: String = "http://localhost:8123";
diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs
index e3ffe16b75..382dd9fc66 100644
--- a/apps/labrinth/src/lib.rs
+++ b/apps/labrinth/src/lib.rs
@@ -328,16 +328,16 @@ pub fn app_data_config(
labrinth_config: LabrinthConfig,
) {
cfg.app_data(web::FormConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ routes::ApiError::Request(eyre::eyre!("{err}")).into()
}))
.app_data(web::PathConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ routes::ApiError::Request(eyre::eyre!("{err}")).into()
}))
.app_data(web::QueryConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ routes::ApiError::Request(eyre::eyre!("{err}")).into()
}))
.app_data(web::JsonConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ routes::ApiError::Request(eyre::eyre!("{err}")).into()
}))
.app_data(web::Data::new(labrinth_config.redis_pool.clone()))
.app_data(web::Data::new(labrinth_config.pool.clone()))
diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs
index d788349117..b456b77a67 100644
--- a/apps/labrinth/src/main.rs
+++ b/apps/labrinth/src/main.rs
@@ -98,7 +98,7 @@ async fn app() -> std::io::Result<()> {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
if !args.no_migrations {
- database::check_for_migrations()
+ labrinth::background_task::run_migrations()
.await
.expect("An error occurred while running migrations.");
}
diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs
index 3b8fb5508c..54f7e037c5 100644
--- a/apps/labrinth/src/models/exp/minecraft.rs
+++ b/apps/labrinth/src/models/exp/minecraft.rs
@@ -3,6 +3,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use eyre::Result;
use serde::{Deserialize, Serialize};
+use serde_binhum::serde_binhum;
use tracing::warn;
use validator::Validate;
@@ -324,7 +325,8 @@ impl ComponentEdit for JavaServerProjectEdit {
}
/// What game content a [`JavaServerProject`] is using.
-#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
+#[derive(Debug, Clone)]
+#[serde_binhum(schema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContent {
/// Server runs modded content with a modpack found on the Modrinth platform.
@@ -346,7 +348,8 @@ pub enum ServerContent {
}
/// What game content a [`JavaServerProject`] is using.
-#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
+#[derive(Debug, Clone)]
+#[serde_binhum(schema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContentQuery {
/// Server runs modded content with a modpack found on the Modrinth platform.
diff --git a/apps/labrinth/src/models/exp/project.rs b/apps/labrinth/src/models/exp/project.rs
index 46694b2ec8..4dcc06fcab 100644
--- a/apps/labrinth/src/models/exp/project.rs
+++ b/apps/labrinth/src/models/exp/project.rs
@@ -54,7 +54,7 @@ macro_rules! define_project_components {
pub struct ProjectSerial {
$(
#[validate(nested)]
- #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[serde(default)]
pub $field_name: Option<$ty>,
)*
}
@@ -114,7 +114,6 @@ macro_rules! define_project_components {
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectQuery {
$(
- #[serde(skip_serializing_if = "Option::is_none")]
pub $field_name: Option>,
)*
}
diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs
index c39de80557..e21616f12b 100644
--- a/apps/labrinth/src/models/mod.rs
+++ b/apps/labrinth/src/models/mod.rs
@@ -6,6 +6,7 @@ pub mod v3;
pub use v3::analytics;
pub use v3::billing;
pub use v3::collections;
+pub use v3::disclosures;
pub use v3::ids;
pub use v3::images;
pub use v3::moderation_notes;
diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs
index a5edfd216b..c4501b7a8e 100644
--- a/apps/labrinth/src/models/v2/projects.rs
+++ b/apps/labrinth/src/models/v2/projects.rs
@@ -3,7 +3,10 @@ use std::convert::TryFrom;
use std::collections::HashMap;
use super::super::ids::OrganizationId;
-use crate::database::models::{DatabaseError, version_item};
+use crate::database::models::{
+ DBProjectDisclosure, DBProjectId, DatabaseError, version_item,
+};
+use crate::models::disclosures::ProjectDisclosureType;
use crate::models::ids::{ProjectId, TeamId, ThreadId, VersionId};
use crate::models::projects::{
Dependency, License, Link, Loader, ModeratorMessage, MonetizationStatus,
@@ -28,6 +31,8 @@ pub struct LegacyProject {
pub server_side: LegacySideType,
/// A list of game versions this project supports
pub game_versions: Vec,
+ /// The environments this project supports
+ pub environment: Vec,
// All other fields are the same as V3
// If they change, or their constituent types change, we may need to
@@ -56,6 +61,7 @@ pub struct LegacyProject {
pub loaders: Vec,
pub versions: Vec,
pub icon_url: Option,
+ pub raw_icon_url: Option,
pub issues_url: Option,
pub source_url: Option,
pub wiki_url: Option,
@@ -125,6 +131,14 @@ impl LegacyProject {
.filter_map(|v| v.as_str())
.map(|v| v.to_string())
.collect();
+ let environment = data
+ .fields
+ .get("environment")
+ .unwrap_or(&Vec::new())
+ .iter()
+ .filter_map(|v| v.as_str())
+ .map(|v| v.to_string())
+ .collect();
if let Some(versions_item) = versions_item {
// Extract side types from remaining fields
@@ -205,6 +219,7 @@ impl LegacyProject {
loaders,
versions: data.versions,
icon_url: data.icon_url,
+ raw_icon_url: data.raw_icon_url,
issues_url,
source_url,
wiki_url,
@@ -221,32 +236,47 @@ impl LegacyProject {
client_side,
server_side,
game_versions,
+ environment,
}
}
// Because from needs a version_item, this is a helper function to get many from one db query.
- pub async fn from_many<'a, E>(
+ pub async fn from_many(
data: Vec,
- exec: E,
+ pool: &crate::database::PgPool,
redis: &RedisPool,
- ) -> Result, DatabaseError>
- where
- E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
- {
+ ) -> Result, DatabaseError> {
let version_ids: Vec<_> = data
.iter()
.filter_map(|p| p.versions.first().map(|i| (*i).into()))
.collect();
+ let project_ids: Vec =
+ data.iter().map(|p| p.id.into()).collect();
+
let example_versions =
- version_item::DBVersion::get_many(&version_ids, exec, redis)
+ version_item::DBVersion::get_many(&version_ids, pool, redis)
.await?;
+ let archived_disclosure_ids = DBProjectDisclosure::projects_with_type(
+ ProjectDisclosureType::Archived,
+ &project_ids,
+ pool,
+ )
+ .await?;
+
let mut legacy_projects = Vec::new();
for project in data {
let version_item = example_versions
.iter()
.find(|v| v.inner.project_id == project.id.into())
.cloned();
- let project = LegacyProject::from(project, version_item);
+ let has_archived_disclosure =
+ archived_disclosure_ids.contains(&project.id.into());
+ let mut project = LegacyProject::from(project, version_item);
+ if has_archived_disclosure
+ && project.status == ProjectStatus::Approved
+ {
+ project.status = ProjectStatus::Archived;
+ }
legacy_projects.push(project);
}
Ok(legacy_projects)
@@ -302,6 +332,9 @@ pub struct LegacyVersion {
/// A list of loaders this project supports (has a newtype struct)
pub loaders: Vec,
+ /// The environment this version supports
+ pub environment: String,
+
pub id: VersionId,
pub project_id: ProjectId,
pub author_id: UserId,
@@ -332,6 +365,12 @@ impl From for LegacyVersion {
}
}
}
+ let environment = data
+ .fields
+ .get("environment")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown")
+ .to_string();
// - if loader is mrpack, this is a modpack
// the v2 loaders are whatever the corresponding loader fields are
@@ -366,6 +405,7 @@ impl From for LegacyVersion {
dependencies: data.dependencies,
game_versions,
loaders,
+ environment,
}
}
}
diff --git a/apps/labrinth/src/models/v2/search.rs b/apps/labrinth/src/models/v2/search.rs
index 46865a47a5..5f617153bc 100644
--- a/apps/labrinth/src/models/v2/search.rs
+++ b/apps/labrinth/src/models/v2/search.rs
@@ -41,6 +41,7 @@ pub struct LegacyResultSearchProject {
pub license: String,
pub client_side: String,
pub server_side: String,
+ pub environment: Vec,
pub gallery: Vec,
pub featured_gallery: Option,
pub color: Option,
@@ -118,6 +119,14 @@ impl LegacyResultSearchProject {
let environment =
get_one_string_loader_field("environment").unwrap_or("unknown");
+ let environments = result_search_project
+ .loader_fields
+ .get("environment")
+ .cloned()
+ .unwrap_or_default()
+ .into_iter()
+ .filter_map(|environment| environment.as_str().map(String::from))
+ .collect();
let (client_side, server_side) =
v2_reroute::convert_v3_environment_to_v2_side_types(
@@ -141,6 +150,7 @@ impl LegacyResultSearchProject {
all_project_types: result_search_project.all_project_types,
client_side,
server_side,
+ environment: environments,
versions,
latest_version: result_search_project.version_id,
categories,
diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs
index c3ef13facb..2557184e6b 100644
--- a/apps/labrinth/src/models/v3/billing.rs
+++ b/apps/labrinth/src/models/v3/billing.rs
@@ -4,6 +4,7 @@ use crate::models::ids::{
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+use serde_binhum::serde_binhum;
use std::collections::HashMap;
#[derive(Serialize, Deserialize)]
@@ -14,7 +15,7 @@ pub struct Product {
pub unitary: bool,
}
-#[derive(Serialize, Deserialize)]
+#[serde_binhum]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ProductMetadata {
Midas,
@@ -55,7 +56,7 @@ pub struct ProductPrice {
pub currency_code: String,
}
-#[derive(Serialize, Deserialize)]
+#[serde_binhum]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum Price {
OneTime {
diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs
new file mode 100644
index 0000000000..52f6c06187
--- /dev/null
+++ b/apps/labrinth/src/models/v3/disclosures.rs
@@ -0,0 +1,177 @@
+use crate::database::models::DBProjectDisclosure;
+use ariadne::ids::UserId;
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeSet;
+use strum::{EnumDiscriminants, EnumString, IntoStaticStr};
+use utoipa::ToSchema;
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, EnumDiscriminants)]
+#[serde(rename_all = "snake_case", tag = "type")]
+#[strum_discriminants(
+ name(ProjectDisclosureType),
+ derive(IntoStaticStr, EnumString),
+ strum(serialize_all = "snake_case")
+)]
+pub enum ProjectDisclosure {
+ AiContent {
+ note: Option,
+ uses: BTreeSet,
+ },
+ Advertisements {
+ note: Option,
+ },
+ EpilepsyTriggers {
+ note: Option,
+ },
+ SystemInteractions {
+ note: Option,
+ interactions: Vec,
+ },
+ Telemetry {
+ consent: TelemetryConsent,
+ data_collected: Vec,
+ },
+ DerivativeWork {
+ sources: Vec,
+ },
+ PaidFeatures {
+ features: Vec,
+ },
+ Archived {
+ note: Option,
+ },
+}
+
+impl ProjectDisclosure {
+ pub fn to_parts(
+ &self,
+ ) -> Result<(&'static str, serde_json::Value), serde_json::Error> {
+ let serde_json::Value::Object(mut object) = serde_json::to_value(self)?
+ else {
+ return Err(serde::ser::Error::custom(
+ "project disclosure must serialize to a JSON object",
+ ));
+ };
+ object.remove("type");
+ Ok((
+ ProjectDisclosureType::from(self).into(),
+ serde_json::Value::Object(object),
+ ))
+ }
+
+ pub fn from_parts(
+ kind: &str,
+ metadata: serde_json::Value,
+ ) -> Result {
+ let serde_json::Value::Object(mut object) = metadata else {
+ return Err(serde::de::Error::custom(
+ "project disclosure metadata must be a JSON object",
+ ));
+ };
+ object.insert(
+ "type".to_owned(),
+ serde_json::Value::String(kind.to_owned()),
+ );
+ serde_json::from_value(serde_json::Value::Object(object))
+ }
+}
+
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ Default,
+ Serialize,
+ Deserialize,
+ ToSchema,
+ IntoStaticStr,
+ EnumString,
+)]
+#[serde(rename_all = "snake_case")]
+#[strum(serialize_all = "snake_case")]
+pub enum DisclosureLockStatus {
+ #[default]
+ Unlocked,
+ CannotDisable,
+ FullyLocked,
+}
+
+impl DisclosureLockStatus {
+ pub fn allows_edit(self) -> bool {
+ !matches!(self, Self::FullyLocked)
+ }
+
+ pub fn allows_removal(self) -> bool {
+ matches!(self, Self::Unlocked)
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct ProjectDisclosureData {
+ #[serde(flatten)]
+ pub disclosure: ProjectDisclosure,
+ pub set_by_moderator: bool,
+ pub lock_status: DisclosureLockStatus,
+ pub updated_at: DateTime,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub updated_by: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub deleted_at: Option>,
+}
+
+impl ProjectDisclosureData {
+ pub fn from_db(
+ value: DBProjectDisclosure,
+ viewer_is_moderator: bool,
+ ) -> Self {
+ let updated_by = (!value.set_by_moderator || viewer_is_moderator)
+ .then_some(value.updated_by.into());
+
+ Self {
+ disclosure: value.disclosure,
+ set_by_moderator: value.set_by_moderator,
+ lock_status: value.lock_status,
+ updated_at: value.updated_at,
+ updated_by,
+ deleted_at: value.deleted_at,
+ }
+ }
+}
+
+#[derive(
+ Debug,
+ Clone,
+ Serialize,
+ Deserialize,
+ ToSchema,
+ IntoStaticStr,
+ PartialEq,
+ Eq,
+ PartialOrd,
+ Ord,
+)]
+#[serde(rename_all = "snake_case")]
+#[strum(serialize_all = "snake_case")]
+pub enum AiUsages {
+ Code,
+ Assets,
+ Text,
+ Functionality,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)]
+#[serde(rename_all = "snake_case")]
+#[strum(serialize_all = "snake_case")]
+pub enum TelemetryConsent {
+ OptIn,
+ OptOut,
+ AlwaysActive,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct DerivativeSource {
+ pub link: Option,
+ pub label: String,
+ pub note: Option,
+}
diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs
index 8925b7caec..777de99443 100644
--- a/apps/labrinth/src/models/v3/mod.rs
+++ b/apps/labrinth/src/models/v3/mod.rs
@@ -3,6 +3,7 @@ pub mod analytics;
pub mod analytics_event;
pub mod billing;
pub mod collections;
+pub mod disclosures;
pub mod ids;
pub mod images;
pub mod moderation_notes;
diff --git a/apps/labrinth/src/models/v3/moderation_notes.rs b/apps/labrinth/src/models/v3/moderation_notes.rs
index b6621dc8ea..5488ec0ddf 100644
--- a/apps/labrinth/src/models/v3/moderation_notes.rs
+++ b/apps/labrinth/src/models/v3/moderation_notes.rs
@@ -4,6 +4,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::routes::ApiError;
+use crate::util::error::Context as _;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ModerationNote {
@@ -37,9 +38,9 @@ pub struct PatchModerationNote {
impl PatchModerationNote {
pub fn validate_not_empty(&self) -> Result<(), ApiError> {
if self.notes.is_none() && self.user_rating.is_none() {
- return Err(ApiError::InvalidInput(
- "must specify `notes` or `user_rating`".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "must specify `notes` or `user_rating`",
+ )));
}
Ok(())
@@ -53,16 +54,14 @@ pub fn parse_if_match_header(
return Ok(None);
};
- let value = value.to_str().map_err(|_| {
- ApiError::InvalidInput(
- "`if-match` header must be a valid integer".to_string(),
- )
- })?;
+ let value = value.to_str().wrap_request_err(
+ "`if-match` header must be a valid integer".to_string(),
+ )?;
Some(value.parse::().map_err(|_| {
- ApiError::InvalidInput(
- "`if-match` header must be a valid integer".to_string(),
- )
+ ApiError::Request(eyre::eyre!(
+ "`if-match` header must be a valid integer",
+ ))
}))
.transpose()
}
diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs
index 6a655958f9..2b9cb26dee 100644
--- a/apps/labrinth/src/models/v3/notifications.rs
+++ b/apps/labrinth/src/models/v3/notifications.rs
@@ -12,6 +12,7 @@ use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+use serde_binhum::serde_binhum;
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
@@ -151,7 +152,8 @@ impl NotificationType {
}
}
-#[derive(Serialize, Deserialize, Clone)]
+#[derive(Clone)]
+#[serde_binhum]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotificationBody {
ProjectUpdate {
@@ -765,8 +767,8 @@ impl NotificationDeliveryStatus {
NotificationDeliveryStatus::Delivered => Ok(()),
NotificationDeliveryStatus::SkippedPreferences |
NotificationDeliveryStatus::SkippedDefault |
- NotificationDeliveryStatus::Pending => Err(ApiError::InvalidInput("An error occurred while sending an email to your email address. Please try again later.".to_owned())),
- NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::InvalidInput("This email address doesn't exist! Please try another one.".to_owned())),
+ NotificationDeliveryStatus::Pending => Err(ApiError::Request(eyre::eyre!("An error occurred while sending an email to your email address. Please try again later.".to_owned()))),
+ NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::Request(eyre::eyre!("This email address doesn't exist! Please try another one.".to_owned()))),
}
}
diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs
index ac50b33228..03e05e51b6 100644
--- a/apps/labrinth/src/models/v3/projects.rs
+++ b/apps/labrinth/src/models/v3/projects.rs
@@ -79,6 +79,8 @@ pub struct Project {
pub versions: Vec,
/// The URL of the icon of the project
pub icon_url: Option,
+ /// The URL of the unoptimized icon of the project
+ pub raw_icon_url: Option,
/// A collection of links to the project's various pages.
pub link_urls: HashMap,
@@ -193,6 +195,7 @@ impl From for Project {
loaders: m.loaders,
versions: data.versions.into_iter().map(|v| v.into()).collect(),
icon_url: m.icon_url,
+ raw_icon_url: m.raw_icon_url,
link_urls: data
.urls
.into_iter()
@@ -567,11 +570,13 @@ impl ProjectStatus {
pub fn can_be_requested(&self) -> bool {
match self {
ProjectStatus::Approved => true,
- ProjectStatus::Archived => true,
ProjectStatus::Unlisted => true,
ProjectStatus::Private => true,
ProjectStatus::Draft => true,
+ // `archived` is represented by a disclosure, not a status, so it
+ // can no longer be requested or set as a status.
+ ProjectStatus::Archived => false,
ProjectStatus::Rejected => false,
ProjectStatus::Processing => false,
ProjectStatus::Unknown => false,
diff --git a/apps/labrinth/src/queue/analytics/cache.rs b/apps/labrinth/src/queue/analytics/cache.rs
index 386cbdaad5..a82c69be29 100644
--- a/apps/labrinth/src/queue/analytics/cache.rs
+++ b/apps/labrinth/src/queue/analytics/cache.rs
@@ -12,7 +12,7 @@ use crate::{
routes::analytics::MINECRAFT_SERVER_PLAYS, util::error::Context,
};
-pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v3";
+pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v4";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinecraftServerAnalytics {
diff --git a/apps/labrinth/src/queue/analytics/mod.rs b/apps/labrinth/src/queue/analytics/mod.rs
index 18766e48da..f1be710b4b 100644
--- a/apps/labrinth/src/queue/analytics/mod.rs
+++ b/apps/labrinth/src/queue/analytics/mod.rs
@@ -4,6 +4,7 @@ use crate::models::analytics::{
};
use crate::routes::ApiError;
use crate::routes::analytics::MINECRAFT_SERVER_PLAYS;
+use crate::util::error::Context as _;
use dashmap::{DashMap, DashSet};
use std::collections::HashMap;
use tracing::trace;
@@ -11,9 +12,9 @@ use xredis::RedisPool;
pub mod cache;
-const DOWNLOADS_NAMESPACE: &str = "downloads:v3";
-const VIEWS_NAMESPACE: &str = "views:v3";
-const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v3";
+const DOWNLOADS_NAMESPACE: &str = "downloads:v4";
+const VIEWS_NAMESPACE: &str = "views:v4";
+const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v4";
const MINECRAFT_SERVER_PLAYS_EXPIRY: u64 = 86_400; // 24 hours
const MINECRAFT_SERVER_PLAYS_LIMIT: u32 = 5;
@@ -99,25 +100,39 @@ impl AnalyticsQueue {
if !affiliate_code_clicks_queue.is_empty() {
let mut insert_clicks = client
.insert::("affiliate_code_clicks")
- .await?;
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
for (_, click_vec) in affiliate_code_clicks_queue {
for click in click_vec {
- insert_clicks.write(&click).await?;
+ insert_clicks.write(&click).await.wrap_internal_err(
+ "writing analytics data to ClickHouse",
+ )?;
}
}
- insert_clicks.end().await?;
+ insert_clicks
+ .end()
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
}
if !playtime_queue.is_empty() {
- let mut playtimes = client.insert::("playtime").await?;
+ let mut playtimes = client
+ .insert::("playtime")
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
for playtime in playtime_queue {
- playtimes.write(&playtime).await?;
+ playtimes.write(&playtime).await.wrap_internal_err(
+ "writing analytics data to ClickHouse",
+ )?;
}
- playtimes.end().await?;
+ playtimes
+ .end()
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
}
if !minecraft_server_plays_queue.is_empty() {
@@ -142,10 +157,15 @@ impl AnalyticsQueue {
)
})
.collect::>();
- let mut redis_connection = redis.connect().await?;
+ let mut redis_connection =
+ redis.connect().await.wrap_internal_err(
+ "connecting to redis for server play counts",
+ )?;
- let results =
- redis_connection.get_many_typed::(&redis_keys).await?;
+ let results = redis_connection
+ .get_many_typed::(&redis_keys)
+ .await
+ .wrap_internal_err("fetching server play counts from redis")?;
for (idx, count) in results.into_iter().enumerate() {
let new_count = if let Some(count) = count {
if count >= MINECRAFT_SERVER_PLAYS_LIMIT {
@@ -164,18 +184,25 @@ impl AnalyticsQueue {
new_count,
Some(MINECRAFT_SERVER_PLAYS_EXPIRY as i64),
)
- .await?;
+ .await
+ .wrap_internal_err("writing server play count to redis")?;
}
let mut plays = client
.insert::(MINECRAFT_SERVER_PLAYS)
- .await?;
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
for (_, play) in raw_plays {
- plays.write(&play).await?;
+ plays.write(&play).await.wrap_internal_err(
+ "writing analytics data to ClickHouse",
+ )?;
}
- plays.end().await?;
+ plays
+ .end()
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
}
if !views_queue.is_empty() {
@@ -198,10 +225,15 @@ impl AnalyticsQueue {
)
})
.collect::>();
- let mut redis_connection = redis.connect().await?;
+ let mut redis_connection = redis
+ .connect()
+ .await
+ .wrap_internal_err("connecting to redis for view counts")?;
- let results =
- redis_connection.get_many_typed::(&redis_keys).await?;
+ let results = redis_connection
+ .get_many_typed::(&redis_keys)
+ .await
+ .wrap_internal_err("fetching view counts from redis")?;
for (idx, count) in results.into_iter().enumerate() {
let new_count =
if let Some((views, monetized)) = raw_views.get_mut(idx) {
@@ -226,10 +258,14 @@ impl AnalyticsQueue {
let key = &redis_keys[idx];
redis_connection
.set(key, new_count, Some(6 * 60 * 60))
- .await?;
+ .await
+ .wrap_internal_err("writing view count to redis")?;
}
- let mut views = client.insert::("views").await?;
+ let mut views = client
+ .insert::("views")
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
for (all_views, monetized) in raw_views {
for (idx, mut view) in all_views.into_iter().enumerate() {
@@ -237,11 +273,16 @@ impl AnalyticsQueue {
view.monetized = false;
}
- views.write(&view).await?;
+ views.write(&view).await.wrap_internal_err(
+ "writing analytics data to ClickHouse",
+ )?;
}
}
- views.end().await?;
+ views
+ .end()
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
}
if !downloads_queue.is_empty() {
@@ -267,10 +308,15 @@ impl AnalyticsQueue {
)
})
.collect::>();
- let mut redis_connection = redis.connect().await?;
+ let mut redis_connection = redis
+ .connect()
+ .await
+ .wrap_internal_err("connecting to redis for download counts")?;
- let results =
- redis_connection.get_many_typed::(&redis_keys).await?;
+ let results = redis_connection
+ .get_many_typed::(&redis_keys)
+ .await
+ .wrap_internal_err("fetching download counts from redis")?;
for (idx, count) in results.into_iter().enumerate() {
let new_count = if let Some(count) = count {
if count > 5 {
@@ -286,11 +332,18 @@ impl AnalyticsQueue {
let key = &redis_keys[idx];
redis_connection
.set(key, new_count, Some(6 * 60 * 60))
- .await?;
+ .await
+ .wrap_internal_err("writing download count to redis")?;
}
- let mut transaction = pool.begin().await?;
- let mut downloads = client.insert::("downloads").await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
+ let mut downloads = client
+ .insert::("downloads")
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
let mut version_downloads: HashMap = HashMap::new();
let mut project_downloads: HashMap = HashMap::new();
@@ -310,7 +363,9 @@ impl AnalyticsQueue {
trace!("writing download {download:?}");
- downloads.write(&download).await?;
+ downloads.write(&download).await.wrap_internal_err(
+ "writing analytics data to ClickHouse",
+ )?;
}
sqlx::query!(
@@ -324,7 +379,8 @@ impl AnalyticsQueue {
&version_downloads.values().copied().collect::>(),
)
.execute(&mut transaction)
- .await?;
+ .await
+ .wrap_internal_err("incrementing version download counts")?;
sqlx::query!(
"
@@ -337,10 +393,17 @@ impl AnalyticsQueue {
&project_downloads.values().copied().collect::>(),
)
.execute(&mut transaction)
- .await?;
+ .await
+ .wrap_internal_err("incrementing project download counts")?;
- transaction.commit().await?;
- downloads.end().await?;
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
+ downloads
+ .end()
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
}
Ok(())
diff --git a/apps/labrinth/src/queue/billing.rs b/apps/labrinth/src/queue/billing.rs
index f982ce48f8..504720adb4 100644
--- a/apps/labrinth/src/queue/billing.rs
+++ b/apps/labrinth/src/queue/billing.rs
@@ -24,6 +24,7 @@ use crate::routes::internal::billing::payments::*;
use crate::util::anrok;
use crate::util::archon::ArchonClient;
use crate::util::archon::{CreateServerRequest, Specs};
+use crate::util::error::ApiContext as _;
use crate::util::error::Context;
use ariadne::ids::base62_impl::to_base62;
use chrono::Utc;
@@ -48,9 +49,14 @@ async fn update_tax_amounts(
let mut processed_charges = 0;
loop {
- let mut txn = pg.begin().await?;
+ let mut txn = pg
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
- let charges = DBCharge::get_updateable_lock(&mut txn, 5).await?;
+ let charges = DBCharge::get_updateable_lock(&mut txn, 5)
+ .await
+ .wrap_internal_err("fetching charges from database")?;
if charges.is_empty() {
info!("No more charges to process");
@@ -82,67 +88,70 @@ async fn update_tax_amounts(
charge.price_id,
&pg,
)
- .await?
+ .await
+ .wrap_api_err("fetching price")?
.ok_or_else(|| {
DatabaseError::Database(sqlx::Error::RowNotFound)
- })?;
+ })
+ .wrap_internal_err("querying database for `update_tax_amounts`")?;
- let product =
- DBProduct::get_price(charge.price_id, &pg)
- .await?
- .ok_or_else(|| {
- DatabaseError::Database(
- sqlx::Error::RowNotFound,
- )
- })?;
+ let product = DBProduct::get_price(charge.price_id, &pg)
+ .await
+ .wrap_internal_err(
+ "fetching product price from database",
+ )?
+ .ok_or_else(|| {
+ DatabaseError::Database(sqlx::Error::RowNotFound)
+ })
+ .wrap_internal_err(
+ "finding product price in database",
+ )?;
let stripe_address = 'a: {
- let stripe_customer_id =
+ let stripe_customer =
DBUser::get_id(charge.user_id, &pg, &redis)
- .await?
- .ok_or_else(|| {
- ApiError::from(DatabaseError::Database(
- sqlx::Error::RowNotFound,
- ))
- })
- .and_then(|user| {
- user.stripe_customer_id.ok_or_else(
- || {
- ApiError::InvalidInput(
- "User has no Stripe customer ID"
- .to_owned(),
- )
- },
- )
- })?
- .parse()
- .map_err(|_| {
- ApiError::InvalidInput(
- "User Stripe customer ID was invalid".to_owned(),
- )
- })?;
+ .await
+ .wrap_internal_err(
+ "fetching Stripe customer from database",
+ )?
+ .wrap_internal_err(
+ "finding Stripe customer in database",
+ )?;
+ let stripe_customer_id = stripe_customer
+ .stripe_customer_id
+ .wrap_request_err(
+ "finding Stripe customer ID on user",
+ )?
+ .parse()
+ .wrap_request_err(
+ "parsing user Stripe customer ID",
+ )?;
let customer = stripe::Customer::retrieve(
&stripe_client,
&stripe_customer_id,
&["invoice_settings.default_payment_method"],
)
- .await?;
+ .await
+ .wrap_failed_dependency_err(
+ "communicating with payment provider",
+ )?;
// A customer should have a default payment method if they have an active subscription.
let payment_method = customer
.invoice_settings
.and_then(|x| {
- x.default_payment_method.and_then(|x| x.into_object())
+ x.default_payment_method
+ .and_then(|x| x.into_object())
})
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "Customer has no default payment method!".to_string(),
- )
+ .wrap_request_err_with(|| {
+ "customer has no default payment method!"
+ .to_string()
})?;
- let stripe_address = payment_method.billing_details.address;
+ let stripe_address =
+ payment_method.billing_details.address;
// Attempt the default payment method's address first, then the customer's address.
match stripe_address {
@@ -152,18 +161,14 @@ async fn update_tax_amounts(
}
};
- customer.address.ok_or_else(|| {
- ApiError::InvalidInput(
- "Couldn't get an address for the Stripe customer"
- .to_owned(),
- )
+ customer.address.wrap_request_err_with(|| {
+ "couldn't get an address for the Stripe customer"
+ .to_owned()
})?
};
let customer_address =
- anrok::Address::from_stripe_address(
- &stripe_address,
- );
+ anrok::Address::from_stripe_address(&stripe_address);
let tax_amount = anrok_client
.create_ephemeral_txn(&anrok::TransactionFields {
@@ -179,17 +184,16 @@ async fn update_tax_amounts(
customer_id: None,
customer_name: None,
})
- .await?
+ .await
+ .wrap_internal_err("inserting database records for `update_tax_amounts`")?
.tax_amount_to_collect;
- Result::::Ok(
- ProcessedCharge {
- new_tax_amount: tax_amount,
- product_name: product
- .name
- .unwrap_or_else(|| "Modrinth".to_owned()),
- },
- )
+ Result::::Ok(ProcessedCharge {
+ new_tax_amount: tax_amount,
+ product_name: product
+ .name
+ .unwrap_or_else(|| "Modrinth".to_owned()),
+ })
};
op_fut.then(move |res| async move { (charge_clone, res) })
@@ -212,11 +216,9 @@ async fn update_tax_amounts(
// for this.
let subscription_id =
- charge.subscription_id.ok_or_else(|| {
- ApiError::InvalidInput(
- "Charge has no subscription ID".to_owned(),
- )
- })?;
+ charge.subscription_id.wrap_request_err_with(
+ || "charge has no subscription ID".to_owned(),
+ )?;
NotificationBuilder {
body: NotificationBody::TaxNotification {
@@ -234,7 +236,8 @@ async fn update_tax_amounts(
},
}
.insert(charge.user_id, &mut txn, redis)
- .await?;
+ .await
+ .wrap_internal_err("inserting database records for `update_tax_amounts`")?;
charge.tax_amount = new_tax_amount;
}
@@ -248,10 +251,15 @@ async fn update_tax_amounts(
};
charge.tax_last_updated = Some(Utc::now());
- charge.upsert(&mut txn).await?;
+ charge
+ .upsert(&mut txn)
+ .await
+ .wrap_internal_err("updating subscription id in database")?;
}
- txn.commit().await?;
+ txn.commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
if processed_charges >= limit {
break Ok(());
@@ -287,11 +295,9 @@ async fn update_anrok_transactions(
.payment_platform_id
.as_ref()
.and_then(|x| x.parse().ok())
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "Refund charge has no or an invalid refund ID"
- .to_owned(),
- )
+ .wrap_request_err_with(|| {
+ "refund charge has no or an invalid refund ID"
+ .to_owned()
})?;
let refund = stripe::Refund::retrieve(
@@ -299,15 +305,16 @@ async fn update_anrok_transactions(
&refund_id,
&["payment_intent.payment_method"],
)
- .await?;
+ .await
+ .wrap_failed_dependency_err(
+ "communicating with payment provider",
+ )?;
let pi = refund
.payment_intent
.and_then(|x| x.into_object())
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "Refund charge has no payment intent".to_owned(),
- )
+ .wrap_request_err_with(|| {
+ "refund charge has no payment intent".to_owned()
})?;
(pi, anrok::transaction_id_stripe_pyr(&refund_id))
@@ -316,10 +323,8 @@ async fn update_anrok_transactions(
.payment_platform_id
.as_ref()
.and_then(|x| x.parse().ok())
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "Charge has no payment platform ID".to_owned(),
- )
+ .wrap_request_err_with(|| {
+ "charge has no payment platform ID".to_owned()
})?;
// Attempt retrieving the address via the payment intent's payment method
@@ -329,7 +334,10 @@ async fn update_anrok_transactions(
&stripe_id,
&["payment_method"],
)
- .await?;
+ .await
+ .wrap_failed_dependency_err(
+ "communicating with payment provider",
+ )?;
let anrok_id = anrok::transaction_id_stripe_pi(&stripe_id);
@@ -341,27 +349,17 @@ async fn update_anrok_transactions(
.and_then(|x| x.into_object())
.and_then(|x| x.billing_details.address);
- let stripe_customer_id =
- DBUser::get_id(c.user_id, &mut *txn, redis)
- .await?
- .ok_or_else(|| {
- ApiError::from(DatabaseError::Database(
- sqlx::Error::RowNotFound,
- ))
- })
- .and_then(|user| {
- user.stripe_customer_id.ok_or_else(|| {
- ApiError::InvalidInput(
- "User has no Stripe customer ID".to_owned(),
- )
- })
- })?;
+ let stripe_customer = DBUser::get_id(c.user_id, &mut *txn, redis)
+ .await
+ .wrap_internal_err("fetching Stripe customer from database")?
+ .wrap_internal_err("finding Stripe customer in database")?;
+ let stripe_customer_id = stripe_customer
+ .stripe_customer_id
+ .wrap_request_err("finding Stripe customer ID on user")?;
- let customer_id = stripe_customer_id.parse().map_err(|e| {
- ApiError::InvalidInput(format!(
- "Charge's Stripe customer ID was invalid ({e})"
- ))
- })?;
+ let customer_id = stripe_customer_id
+ .parse()
+ .wrap_request_err("parsing request value")?;
match pi_stripe_address {
Some(address) => {
@@ -377,7 +375,10 @@ async fn update_anrok_transactions(
let customer =
stripe::Customer::retrieve(stripe_client, &customer_id, &[])
- .await?;
+ .await
+ .wrap_failed_dependency_err(
+ "communicating with payment provider",
+ )?;
let Some(address) = customer.address else {
// We won't really be able to do anything about this.
@@ -388,7 +389,9 @@ async fn update_anrok_transactions(
);
c.tax_platform_id = Some("unresolved".to_owned());
- c.upsert(txn).await?;
+ c.upsert(txn)
+ .await
+ .wrap_internal_err("updating customer in database")?;
return Ok(());
};
@@ -397,8 +400,12 @@ async fn update_anrok_transactions(
};
let tax_id = DBProductsTaxIdentifier::get_price(c.price_id, &mut *txn)
- .await?
- .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
+ .await
+ .wrap_api_err("fetching price")?
+ .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))
+ .wrap_internal_err(
+ "fetching products tax identifier from database",
+ )?;
// Note: if the tax amount that was charged to the customer is *different* than
// what it *should* be NOW, we will take on a loss here.
@@ -427,18 +434,18 @@ async fn update_anrok_transactions(
match result {
Ok(response) => {
- let version = response.version.ok_or_else(|| {
- ApiError::InvalidInput(
- "Anrok response is missing tax transaction version"
- .to_owned(),
- )
+ let version = response.version.wrap_request_err_with(|| {
+ "anrok response is missing tax transaction version"
+ .to_owned()
})?;
c.tax_drift_loss = Some(response.tax_amount_to_collect);
c.tax_platform_id = Some(tax_platform_id);
c.tax_transaction_version = Some(version);
c.tax_platform_accounting_time = Some(c.due);
- c.upsert(txn).await?;
+ c.upsert(txn)
+ .await
+ .wrap_internal_err("updating version in database")?;
Ok(())
}
@@ -449,11 +456,15 @@ async fn update_anrok_transactions(
.is_conflict_and(|x| x == "customerAddressCouldNotResolve")
{
c.tax_platform_id = Some("unresolved".to_owned());
- c.upsert(txn).await?;
+ c.upsert(txn)
+ .await
+ .wrap_internal_err("updating version in database")?;
Ok(())
} else {
- Err(error.into())
+ Err(ApiError::Internal(eyre::eyre!(
+ "calculating tax with Anrok: {error}"
+ )))
}
}
}
@@ -464,11 +475,15 @@ async fn update_anrok_transactions(
let mut offset = 0;
loop {
- let mut txn = pg.begin().await?;
+ let mut txn = pg
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
let mut charges =
DBCharge::get_missing_tax_identifier_lock(&mut txn, offset, 1)
- .await?;
+ .await
+ .wrap_internal_err("fetching charges from database")?;
let Some(c) = charges.pop() else {
info!("No more charges to process");
@@ -492,7 +507,9 @@ async fn update_anrok_transactions(
offset += 1;
}
- txn.commit().await?;
+ txn.commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
if processed_charges >= limit {
break Ok(());
@@ -512,7 +529,10 @@ pub async fn try_process_user_redeemal(
user_redeemal.last_attempt = Some(Utc::now());
user_redeemal.n_attempts += 1;
user_redeemal.status = users_redeemals::Status::Processing;
- let updated = user_redeemal.update_status_if_pending(pool).await?;
+ let updated = user_redeemal
+ .update_status_if_pending(pool)
+ .await
+ .wrap_internal_err("updating updated in database")?;
if !updated {
return Ok(());
@@ -526,7 +546,8 @@ pub async fn try_process_user_redeemal(
product_item::QueryProductWithPrices::list_by_product_type(
pool, "medal",
)
- .await?;
+ .await
+ .wrap_internal_err("fetching query product with prices from Redis")?;
let Some(product_item::QueryProductWithPrices {
id: _product_id,
@@ -536,9 +557,9 @@ pub async fn try_process_user_redeemal(
name: _,
}) = medal_products.pop()
else {
- return Err(ApiError::Conflict(
+ return Err(ApiError::Conflict(eyre::eyre!(
"Missing Medal subscription product".to_owned(),
- ));
+ )));
};
let ProductMetadata::Medal {
@@ -549,31 +570,31 @@ pub async fn try_process_user_redeemal(
region,
} = metadata
else {
- return Err(ApiError::Conflict(
+ return Err(ApiError::Conflict(eyre::eyre!(
"Missing or incorrect metadata for Medal subscription".to_owned(),
- ));
+ )));
};
let Some(medal_price) = prices.pop() else {
- return Err(ApiError::Conflict(
+ return Err(ApiError::Conflict(eyre::eyre!(
"Missing price for Medal subscription".to_owned(),
- ));
+ )));
};
let (price_duration, price_amount) = match medal_price.prices {
Price::OneTime { price: _ } => {
- return Err(ApiError::Conflict(
+ return Err(ApiError::Conflict(eyre::eyre!(
"Unexpected metadata for Medal subscription price".to_owned(),
- ));
+ )));
}
Price::Recurring { intervals } => {
let Some((price_duration, price_amount)) =
intervals.into_iter().next()
else {
- return Err(ApiError::Conflict(
+ return Err(ApiError::Conflict(eyre::eyre!(
"Missing price interval for Medal subscription".to_owned(),
- ));
+ )));
};
(price_duration, price_amount)
@@ -585,13 +606,15 @@ pub async fn try_process_user_redeemal(
// Get the user's username
let user = DBUser::get_id(user_id, pool, redis)
- .await?
- .ok_or(ApiError::NotFound)?;
+ .await
+ .wrap_internal_err("fetching user from database")?
+ .wrap_not_found_err("resource not found")?;
// Send the provision request to Archon. On failure, the redeemal will be "stuck" processing,
// and moved back to pending by `index_subscriptions`.
- let archon_client = ArchonClient::from_env()?;
+ let archon_client = ArchonClient::from_env()
+ .wrap_api_err("executing `ArchonClient::from_env`")?;
let server_id = archon_client
.create_server(&CreateServerRequest {
user_id: to_base62(user_id.0 as u64),
@@ -606,13 +629,21 @@ pub async fn try_process_user_redeemal(
region,
tags: vec!["medal".to_owned()],
})
- .await?;
+ .await
+ .wrap_internal_err(
+ "inserting database records for `try_process_user_redeemal`",
+ )?;
- let mut txn = pool.begin().await?;
+ let mut txn = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
// Build a subscription using this price ID.
let subscription = DBUserSubscription {
- id: generate_user_subscription_id(&mut txn).await?,
+ id: generate_user_subscription_id(&mut txn)
+ .await
+ .wrap_internal_err("generating user subscription ID")?,
user_id,
price_id,
interval: PriceDuration::FiveDays,
@@ -623,12 +654,17 @@ pub async fn try_process_user_redeemal(
}),
};
- subscription.upsert(&mut txn).await?;
+ subscription
+ .upsert(&mut txn)
+ .await
+ .wrap_internal_err("generating user subscription ID")?;
// Insert an expiring charge, `index_subscriptions` will unprovision the
// subscription when expired.
DBCharge {
- id: generate_charge_id(&mut txn).await?,
+ id: generate_charge_id(&mut txn)
+ .await
+ .wrap_internal_err("generating redeemal charge ID")?,
user_id,
price_id,
amount: price_amount.into(),
@@ -651,26 +687,42 @@ pub async fn try_process_user_redeemal(
tax_platform_accounting_time: None,
}
.upsert(&mut txn)
- .await?;
+ .await
+ .wrap_internal_err("upserting redeemal charge")?;
// Update `users_redeemal`, mark subscription as redeemed.
user_redeemal.status = users_redeemals::Status::Processed;
- user_redeemal.update(&mut txn).await?;
+ user_redeemal.update(&mut txn).await.wrap_internal_err(
+ "updating database records for `try_process_user_redeemal`",
+ )?;
- txn.commit().await?;
+ txn.commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
Ok(())
}
pub async fn cancel_failing_charges(pool: &PgPool) -> Result<(), ApiError> {
- let charges_to_cancel = DBCharge::get_cancellable(pool).await?;
+ let charges_to_cancel = DBCharge::get_cancellable(pool)
+ .await
+ .wrap_internal_err("fetching charge from database")?;
for mut charge in charges_to_cancel {
charge.status = ChargeStatus::Cancelled;
- let mut transaction = pool.begin().await?;
- charge.upsert(&mut transaction).await?;
- transaction.commit().await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
+ charge
+ .upsert(&mut transaction)
+ .await
+ .wrap_internal_err("updating transaction in database")?;
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
}
Ok(())
@@ -682,7 +734,9 @@ pub async fn process_chargeable_charges(
stripe_client: &stripe::Client,
anrok_client: &anrok::Client,
) -> Result<(), ApiError> {
- let charges_to_do = DBCharge::get_chargeable(pool).await?;
+ let charges_to_do = DBCharge::get_chargeable(pool)
+ .await
+ .wrap_internal_err("fetching charge from database")?;
let prices = product_item::DBProductPrice::get_many(
&charges_to_do
@@ -693,7 +747,8 @@ pub async fn process_chargeable_charges(
.collect::>(),
pool,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching product prices from database")?;
let users = crate::database::models::DBUser::get_many_ids(
&charges_to_do
@@ -705,7 +760,8 @@ pub async fn process_chargeable_charges(
pool,
redis,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching users from database")?;
for mut charge in charges_to_do {
let Some(product_price) =
@@ -784,9 +840,18 @@ pub async fn process_chargeable_charges(
charge.status = ChargeStatus::Failed;
}
- let mut transaction = pool.begin().await?;
- charge.upsert(&mut transaction).await?;
- transaction.commit().await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
+ charge
+ .upsert(&mut transaction)
+ .await
+ .wrap_internal_err("updating transaction in database")?;
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
}
Ok(())
@@ -798,7 +863,10 @@ async fn unprovision_subscriptions(
) -> Result<(), ApiError> {
info!("Gathering charges to unprovision");
- let mut transaction = pool.begin().await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
let mut clear_cache_users = Vec::new();
// If an active subscription has:
@@ -806,7 +874,9 @@ async fn unprovision_subscriptions(
// - An expiring charge due now
// - A failed charge more than two days ago
// It should be unprovisioned
- let all_charges = DBCharge::get_unprovision(pool).await?;
+ let all_charges = DBCharge::get_unprovision(pool)
+ .await
+ .wrap_internal_err("fetching charges from database")?;
let mut all_subscriptions =
user_subscription_item::DBUserSubscription::get_many(
@@ -818,7 +888,8 @@ async fn unprovision_subscriptions(
.collect::>(),
pool,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching user subscriptions from database")?;
let subscription_prices = product_item::DBProductPrice::get_many(
&all_subscriptions
.iter()
@@ -828,7 +899,8 @@ async fn unprovision_subscriptions(
.collect::>(),
pool,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching product prices from database")?;
let subscription_products = product_item::DBProduct::get_many(
&subscription_prices
.iter()
@@ -838,7 +910,8 @@ async fn unprovision_subscriptions(
.collect::>(),
pool,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching products from database")?;
let users = DBUser::get_many_ids(
&all_subscriptions
.iter()
@@ -849,7 +922,8 @@ async fn unprovision_subscriptions(
pool,
redis,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching users from database")?;
for charge in all_charges {
debug!("Unprovisioning charge '{}'", to_base62(charge.id.0 as u64));
@@ -898,7 +972,10 @@ async fn unprovision_subscriptions(
user.id as DBUserId,
)
.execute(&mut transaction)
- .await?;
+ .await
+ .wrap_internal_err(
+ "querying database for `unprovision_subscriptions`",
+ )?;
true
}
@@ -939,7 +1016,10 @@ async fn unprovision_subscriptions(
if unprovisioned {
subscription.status = SubscriptionStatus::Unprovisioned;
- subscription.upsert(&mut transaction).await?;
+ subscription
+ .upsert(&mut transaction)
+ .await
+ .wrap_internal_err("updating err in database")?;
DBUsersSubscriptionsAffiliations::deactivate(
subscription.id,
@@ -961,8 +1041,12 @@ async fn unprovision_subscriptions(
.collect::>(),
redis,
)
- .await?;
- transaction.commit().await?;
+ .await
+ .wrap_internal_err("clearing cached data from Redis")?;
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
Ok(())
}
@@ -972,11 +1056,17 @@ async fn process_redeemals(
redis: &RedisPool,
) -> Result<(), ApiError> {
// If an offer redeemal has been processing for over 5 minutes, it should be set pending.
- UserRedeemal::update_stuck_5_minutes(pool).await?;
+ UserRedeemal::update_stuck_5_minutes(pool)
+ .await
+ .wrap_internal_err(
+ "updating database records for `process_redeemals`",
+ )?;
// If an offer redeemal is pending, try processing it.
// Try processing it.
- let pending_redeemals = UserRedeemal::get_pending(pool, 100).await?;
+ let pending_redeemals = UserRedeemal::get_pending(pool, 100)
+ .await
+ .wrap_internal_err("fetching user redeemal from Redis")?;
for redeemal in pending_redeemals {
if let Err(error) =
try_process_user_redeemal(pool, redis, redeemal).await
diff --git a/apps/labrinth/src/queue/email.rs b/apps/labrinth/src/queue/email.rs
index ff1b9cfb7b..5ee5374b17 100644
--- a/apps/labrinth/src/queue/email.rs
+++ b/apps/labrinth/src/queue/email.rs
@@ -10,6 +10,8 @@ use crate::models::v3::notifications::{
NotificationChannel, NotificationDeliveryStatus,
};
use crate::routes::ApiError;
+use crate::util::error::ApiContext as _;
+use crate::util::error::Context as _;
use chrono::Utc;
use futures::stream::{FuturesUnordered, StreamExt};
use lettre::message::Mailbox;
@@ -150,7 +152,13 @@ impl EmailQueue {
/// Returns `Ok(false)` if no emails were processed, `Ok(true)` if some were processed.
#[instrument(name = "EmailQueue::index", skip_all)]
pub async fn index(&self, limit: i64) -> Result {
- let transport = self.mailer.lock().await.to_transport().await?;
+ let transport = self
+ .mailer
+ .lock()
+ .await
+ .to_transport()
+ .await
+ .wrap_internal_err("creating email transport")?;
let begin = std::time::Instant::now();
@@ -159,7 +167,8 @@ impl EmailQueue {
limit,
&self.pg,
)
- .await?;
+ .await
+ .wrap_internal_err("creating email transport")?;
if deliveries.is_empty() {
return Ok(false);
@@ -171,7 +180,9 @@ impl EmailQueue {
// ballooning the error rate.
for d in deliveries.iter_mut().filter(|d| d.attempt_count >= 3) {
d.status = NotificationDeliveryStatus::PermanentlyFailed;
- d.update(&self.pg).await?;
+ d.update(&self.pg).await.wrap_internal_err(
+ "marking exhausted email delivery as failed",
+ )?;
}
// We hold a FOR UPDATE lock on the rows here, so no other workers are accessing them
@@ -183,7 +194,9 @@ impl EmailQueue {
.map(|d| d.notification_id)
.collect::>();
let notifications =
- DBNotification::get_many(¬ification_ids, &self.pg).await?;
+ DBNotification::get_many(¬ification_ids, &self.pg)
+ .await
+ .wrap_internal_err("fetching notifications from database")?;
// For all notifications we collected, fill out the template
// and send it via SMTP in parallel.
@@ -201,11 +214,16 @@ impl EmailQueue {
let seq = Arc::clone(&sequential_processing);
futures.push(async move {
- let mut txn = this.pg.begin().await?;
+ let mut txn = this
+ .pg
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
let maybe_user =
DBUser::get_id(notification.user_id, &mut txn, &this.redis)
- .await?;
+ .await
+ .wrap_internal_err("fetching user from database")?;
let Some(mailbox) = maybe_user
.and_then(|user| user.email)
@@ -268,7 +286,9 @@ impl EmailQueue {
};
delivery.attempt_count += 1;
- delivery.update(&self.pg).await?;
+ delivery.update(&self.pg).await.wrap_internal_err(
+ "updating processed email delivery",
+ )?;
}
}
@@ -283,7 +303,10 @@ impl EmailQueue {
delivery.next_attempt = Utc::now()
+ chrono::Duration::seconds(EMAIL_RETRY_DELAY_SECONDS);
- delivery.update(&self.pg).await?;
+ delivery
+ .update(&self.pg)
+ .await
+ .wrap_internal_err("scheduling email delivery retry")?;
}
info!(
@@ -302,7 +325,13 @@ impl EmailQueue {
user_id: DBUserId,
address: Mailbox,
) -> Result {
- let transport = self.mailer.lock().await.to_transport().await?;
+ let transport = self
+ .mailer
+ .lock()
+ .await
+ .to_transport()
+ .await
+ .wrap_internal_err("creating email transport")?;
self.send_one_with_transport(
txn,
transport,
@@ -329,7 +358,8 @@ impl EmailQueue {
&mut *txn,
&self.redis,
)
- .await?
+ .await
+ .wrap_internal_err("creating email transport")?
.into_iter()
.find(|t| t.notification_type == notification.notification_type()) else {
return Ok(NotificationDeliveryStatus::SkippedDefault);
@@ -345,7 +375,8 @@ impl EmailQueue {
self.identity.clone(),
address,
)
- .await?;
+ .await
+ .wrap_api_err("executing `templates::build_email`")?;
let send_result = transport.send(message).await;
diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs
index 62db4dab9a..1ba595cdae 100644
--- a/apps/labrinth/src/queue/email/templates.rs
+++ b/apps/labrinth/src/queue/email/templates.rs
@@ -4,17 +4,21 @@ use crate::database::models::ids::*;
use crate::database::models::notifications_template_item::{
NotificationTemplate, get_or_set_cached_dynamic_html,
};
+use crate::database::models::report_item::DBReport;
use crate::database::models::{
DBOrganization, DBProject, DBUser, DatabaseError,
};
use crate::env::ENV;
use crate::models::v3::notifications::NotificationBody;
use crate::routes::ApiError;
+use crate::util::error::ApiContext as _;
use crate::util::error::Context;
+use crate::util::http::HTTP_CLIENT;
use ariadne::ids::base62_impl::to_base62;
use futures::TryFutureExt;
use lettre::Message;
use lettre::message::{Mailbox, MultiPart, SinglePart};
+use serde::Deserialize;
use sqlx::query;
use std::collections::HashMap;
use std::time::Duration;
@@ -132,7 +136,11 @@ pub async fn build_email(
) -> Result {
let get_html_body = async {
let result: Result, ApiError> =
- match template.get_cached_html_data(redis).await? {
+ match template
+ .get_cached_html_data(redis)
+ .await
+ .wrap_internal_err("fetching email template HTML from Redis")?
+ {
Some(html_body) => Ok(Ok(html_body)),
None => {
let result = client
@@ -146,7 +154,10 @@ pub async fn build_email(
if let Ok(ref body) = result {
template
.set_cached_html_data(body.clone(), redis)
- .await?;
+ .await
+ .wrap_internal_err(
+ "updating database records for `build_email`",
+ )?;
}
Ok(result)
@@ -164,8 +175,10 @@ pub async fn build_email(
} = from;
let db_user = DBUser::get_id(user_id, &mut *exec, redis)
- .await?
- .ok_or(DatabaseError::Database(sqlx::Error::RowNotFound))?;
+ .await
+ .wrap_internal_err("fetching user from database")?
+ .ok_or(DatabaseError::Database(sqlx::Error::RowNotFound))
+ .wrap_internal_err("fetching user from database")?;
let map = [
(USER_NAME, db_user.username),
@@ -177,17 +190,24 @@ pub async fn build_email(
let (html_body_result, either) = futures::try_join!(
get_html_body,
collect_template_variables(exec, redis, user_id, body, map)
- )?;
+ )
+ .wrap_api_err("executing `collect_template_variables`")?;
let mut message_builder = Message::builder().from(Mailbox::new(
Some(from_name),
- from_address.parse().map_err(MailError::from)?,
+ from_address
+ .parse()
+ .map_err(MailError::from)
+ .wrap_internal_err("reading HTTP response body")?,
));
if let Some((name, address)) = reply_name.zip(reply_address) {
message_builder = message_builder.reply_to(Mailbox::new(
Some(name),
- address.parse().map_err(MailError::from)?,
+ address
+ .parse()
+ .map_err(MailError::from)
+ .wrap_internal_err("reading HTTP response body")?,
));
}
@@ -241,21 +261,26 @@ pub async fn build_email(
html: Some(html),
} => message_builder
.multipart(MultiPart::alternative_plain_html(plaintext, html))
- .map_err(MailError::from)?,
+ .map_err(MailError::from)
+ .wrap_internal_err(
+ "executing `MultiPart::alternative_plain_html`",
+ )?,
Body {
plaintext: Some(plaintext),
html: None,
} => message_builder
.singlepart(SinglePart::plain(plaintext))
- .map_err(MailError::from)?,
+ .map_err(MailError::from)
+ .wrap_internal_err("executing `SinglePart::plain`")?,
Body {
plaintext: None,
html: Some(html),
} => message_builder
.singlepart(SinglePart::html(html))
- .map_err(MailError::from)?,
+ .map_err(MailError::from)
+ .wrap_internal_err("executing `SinglePart::html`")?,
Body {
plaintext: None,
@@ -312,6 +337,73 @@ enum EmailTemplate {
},
}
+#[derive(Deserialize)]
+struct SharedInstance {
+ name: String,
+}
+
+async fn resolve_report_title(
+ exec: &mut PgTransaction<'_>,
+ report_id: DBReportId,
+ title: String,
+) -> Result {
+ if title != "unknown" {
+ return Ok(title);
+ }
+
+ let Some(report) = DBReport::get(report_id, &mut *exec)
+ .await
+ .wrap_internal_err("fetching report from database")?
+ else {
+ return Ok(title);
+ };
+ let Some(shared_instance_id) = report.shared_instance_id else {
+ return Ok(title);
+ };
+
+ let instance_id = to_base62(shared_instance_id.0 as u64);
+ let with_version = |name: String| match report.shared_instance_version_id {
+ Some(version) => format!("{name} (version {version})"),
+ None => name,
+ };
+ let fallback = with_version(format!("shared instance {instance_id}"));
+ let response = HTTP_CLIENT
+ .get(format!(
+ "{}/v1/instances/{instance_id}",
+ ENV.SHARED_INSTANCES_URL
+ ))
+ .bearer_auth(&ENV.SHARED_INSTANCES_KEY)
+ .send()
+ .await
+ .and_then(reqwest::Response::error_for_status);
+
+ let response = match response {
+ Ok(response) => response,
+ Err(error) => {
+ warn!(
+ %error,
+ report_id = report_id.0,
+ %instance_id,
+ "Failed to fetch shared instance name for report email"
+ );
+ return Ok(fallback);
+ }
+ };
+
+ match response.json::().await {
+ Ok(instance) => Ok(with_version(instance.name)),
+ Err(error) => {
+ warn!(
+ %error,
+ report_id = report_id.0,
+ %instance_id,
+ "Failed to parse shared instance name for report email"
+ );
+ Ok(fallback)
+ }
+ }
+}
+
async fn collect_template_variables(
exec: &mut PgTransaction<'_>,
redis: &RedisPool,
@@ -331,8 +423,10 @@ async fn collect_template_variables(
exec,
redis,
)
- .await?
- .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?
+ .await
+ .wrap_api_err("fetching email project")?
+ .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))
+ .wrap_internal_err("fetching project from database")?
.inner;
map.insert(PROJECT_ID, to_base62(project_id.0));
@@ -356,10 +450,19 @@ async fn collect_template_variables(
report_id.0 as i64
)
.fetch_one(&mut *exec)
- .await?;
+ .await.wrap_internal_err("querying database for `collect_template_variables`")?;
map.insert(REPORT_ID, to_base62(report_id.0));
- map.insert(REPORT_TITLE, result.title);
+ map.insert(
+ REPORT_TITLE,
+ resolve_report_title(
+ exec,
+ DBReportId(report_id.0 as i64),
+ result.title,
+ )
+ .await
+ .wrap_api_err("executing `resolve_report_title`")?,
+ );
map.insert(REPORT_DATE, date_human_readable(result.created));
Ok(EmailTemplate::Static(map))
}
@@ -378,9 +481,18 @@ async fn collect_template_variables(
report_id.0 as i64
)
.fetch_one(&mut *exec)
- .await?;
+ .await.wrap_internal_err("querying database for `collect_template_variables`")?;
- map.insert(REPORT_TITLE, result.title);
+ map.insert(
+ REPORT_TITLE,
+ resolve_report_title(
+ exec,
+ DBReportId(report_id.0 as i64),
+ result.title,
+ )
+ .await
+ .wrap_api_err("executing `resolve_report_title`")?,
+ );
map.insert(NEWREPORT_ID, to_base62(report_id.0));
Ok(EmailTemplate::Static(map))
}
@@ -393,7 +505,10 @@ async fn collect_template_variables(
project_id.0 as i64
)
.fetch_one(&mut *exec)
- .await?;
+ .await
+ .wrap_internal_err(
+ "querying database for `collect_template_variables`",
+ )?;
map.insert(PROJECT_ID, to_base62(project_id.0));
map.insert(PROJECT_NAME, result.name);
@@ -411,8 +526,10 @@ async fn collect_template_variables(
exec,
redis,
)
- .await?
- .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?
+ .await
+ .wrap_api_err("fetching email project")?
+ .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))
+ .wrap_internal_err("fetching project from database")?
.inner;
map.insert(PROJECT_ID, to_base62(project_id.0));
@@ -433,8 +550,10 @@ async fn collect_template_variables(
&mut *exec,
redis,
)
- .await?
- .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?
+ .await
+ .wrap_api_err("fetching email project")?
+ .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))
+ .wrap_internal_err("fetching project from database")?
.inner;
map.insert(PROJECT_ID, to_base62(project_id.0));
@@ -447,10 +566,14 @@ async fn collect_template_variables(
&mut *exec,
redis,
)
- .await?
+ .await
+ .wrap_internal_err("fetching user from database")?
.ok_or_else(|| {
DatabaseError::Database(sqlx::Error::RowNotFound)
- })?;
+ })
+ .wrap_internal_err(
+ "querying database for `collect_template_variables`",
+ )?;
map.insert(NEWOWNER_TYPE, "user".to_string());
map.insert(NEWOWNER_TYPE_CAPITALIZED, "User".to_string());
@@ -463,10 +586,14 @@ async fn collect_template_variables(
&mut *exec,
redis,
)
- .await?
+ .await
+ .wrap_internal_err("fetching organization from database")?
.ok_or_else(|| {
DatabaseError::Database(sqlx::Error::RowNotFound)
- })?;
+ })
+ .wrap_internal_err(
+ "querying database for `collect_template_variables`",
+ )?;
map.insert(NEWOWNER_TYPE, "organization".to_string());
map.insert(
@@ -501,7 +628,10 @@ async fn collect_template_variables(
user_id.0 as i64
)
.fetch_one(&mut *exec)
- .await?;
+ .await
+ .wrap_internal_err(
+ "querying database for `collect_template_variables`",
+ )?;
map.insert(TEAMINVITE_INVITER_NAME, result.inviter_name);
map.insert(TEAMINVITE_PROJECT_NAME, result.project_name);
@@ -533,7 +663,10 @@ async fn collect_template_variables(
user_id.0 as i64
)
.fetch_one(&mut *exec)
- .await?;
+ .await
+ .wrap_internal_err(
+ "querying database for `collect_template_variables`",
+ )?;
map.insert(ORGINVITE_INVITER_NAME, result.inviter_name);
map.insert(ORGINVITE_ORG_NAME, result.organization_name);
@@ -561,7 +694,10 @@ async fn collect_template_variables(
user_id.0 as i64,
)
.fetch_one(&mut *exec)
- .await?;
+ .await
+ .wrap_internal_err(
+ "querying database for `collect_template_variables`",
+ )?;
map.insert(STATUSCHANGE_PROJECT_NAME, result.project_name);
map.insert(STATUSCHANGE_OLD_STATUS, old_status.as_str().to_owned());
@@ -746,7 +882,9 @@ async fn collect_template_variables(
key,
} => Ok(EmailTemplate::Dynamic {
variables: map,
- body: dynamic_email_body(redis, title, body_md, key).await?,
+ body: dynamic_email_body(redis, title, body_md, key)
+ .await
+ .wrap_api_err("executing `dynamic_email_body`")?,
title: title.to_string(),
}),
@@ -761,8 +899,10 @@ async fn collect_template_variables(
&mut *exec,
redis,
)
- .await?
- .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
+ .await
+ .wrap_internal_err("fetching user from database")?
+ .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))
+ .wrap_internal_err("fetching user from database")?;
map.insert(SERVERINVITE_INVITER_NAME, inviter.username);
map.insert(SERVERINVITE_SERVER_NAME, server_name.clone());
@@ -800,9 +940,11 @@ async fn dynamic_email_body(
}))
.send()
.await
- .and_then(|res| res.error_for_status())?
+ .and_then(|res| res.error_for_status())
+ .wrap_internal_err("deserializing HTTP response")?
.bytes()
- .await?
+ .await
+ .wrap_internal_err("deserializing HTTP response")?
.as_ref(),
)
.wrap_internal_err("email body is not valid UTF-8")
diff --git a/apps/labrinth/src/queue/payouts/flow/mod.rs b/apps/labrinth/src/queue/payouts/flow/mod.rs
index 08c8d7776a..240b525acf 100644
--- a/apps/labrinth/src/queue/payouts/flow/mod.rs
+++ b/apps/labrinth/src/queue/payouts/flow/mod.rs
@@ -1,6 +1,7 @@
//! Centralized place where payout rails are defined - their fees, minimum and
//! maximum withdraw amounts, and execution logic.
+use crate::util::error::ApiContext as _;
use eyre::eyre;
use modrinth_util::decimal::Decimal2dp;
use rust_decimal::Decimal;
@@ -59,7 +60,9 @@ impl PayoutsQueue {
self,
withdrawal.amount,
method_details,
- &get_method.await?,
+ &get_method
+ .await
+ .wrap_api_err("executing `tremendous::create`")?,
)
.await
}
diff --git a/apps/labrinth/src/queue/payouts/flow/mural.rs b/apps/labrinth/src/queue/payouts/flow/mural.rs
index 15ff290e80..6cf2dad6f3 100644
--- a/apps/labrinth/src/queue/payouts/flow/mural.rs
+++ b/apps/labrinth/src/queue/payouts/flow/mural.rs
@@ -19,7 +19,7 @@ use crate::{
mural::MuralPayoutRequest,
},
routes::ApiError,
- util::error::Context,
+ util::error::{ApiContext as _, Context},
};
pub const PLATFORM_FEE: PayoutMethodFee = PayoutMethodFee {
@@ -67,7 +67,7 @@ pub(super) async fn create(
let mural = queue.muralpay.load();
let mural = mural
.as_ref()
- .wrap_internal_err("Mural client not available")?;
+ .wrap_internal_err("required Mural client is not available")?;
let method_fee_usd;
let forex_usd_to_currency;
@@ -158,14 +158,15 @@ pub(super) async fn execute(
recipient_info,
}: MuralFlow,
) -> Result<(), ApiError> {
- let user_email = get_verified_email(user)?;
+ let user_email = get_verified_email(user)
+ .wrap_api_err("fetching verified user email")?;
let sent_to_method_usd = net_usd + method_fee_usd;
let total_fee_usd = method_fee_usd + platform_fee_usd;
let mural = queue.muralpay.load();
let mural = mural
.as_ref()
- .wrap_internal_err("Mural client not available")?;
+ .wrap_internal_err("required Mural client is not available")?;
let payment_statement_doc = queue
.create_mural_payment_statement_doc(
@@ -175,7 +176,8 @@ pub(super) async fn execute(
&recipient_info,
gotenberg,
)
- .await?;
+ .await
+ .wrap_api_err("creating Mural payment statement document")?;
let user_id = UserId::from(user.id);
let method_id = match &payout_details {
@@ -235,13 +237,18 @@ pub(super) async fn execute(
Some(format!("User {user_id}")),
&[payout],
)
- .await
- .map_err(|err| match err {
- muralpay::MuralError::Api(err) => ApiError::Mural(Box::new(err)),
- err => ApiError::Internal(
+ .await;
+ let payout_request = match payout_request {
+ Ok(payout_request) => payout_request,
+ Err(muralpay::MuralError::Api(err)) => {
+ return Err(ApiError::Request(eyre::eyre!(Box::new(err))));
+ }
+ Err(err) => {
+ return Err(ApiError::Internal(
eyre!(err).wrap_err("failed to create payout request"),
- ),
- })?;
+ ));
+ }
+ };
// Once the Mural payout request has been created successfully,
// then we *must* commit *a* payout row into the DB, to link the Mural
diff --git a/apps/labrinth/src/queue/payouts/flow/paypal.rs b/apps/labrinth/src/queue/payouts/flow/paypal.rs
index c8e20e279a..de4b158f34 100644
--- a/apps/labrinth/src/queue/payouts/flow/paypal.rs
+++ b/apps/labrinth/src/queue/payouts/flow/paypal.rs
@@ -102,21 +102,20 @@ pub(super) async fn execute(
if let Some(venmo) = &user.venmo_handle {
("Venmo", "user_handle", venmo.clone(), venmo)
} else {
- return Err(ApiError::InvalidInput(
- "Venmo address has not been set for account!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "Venmo address has not been set for account!",
+ )));
}
} else if let Some(paypal_id) = &user.paypal_id {
if let Some(paypal_country) = &user.paypal_country {
if paypal_country == "US" && method_id != "paypal_us" {
- return Err(ApiError::InvalidInput(
- "Please use the US PayPal transfer option!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "Please use the US PayPal transfer option!",
+ )));
} else if paypal_country != "US" && method_id == "paypal_us" {
- return Err(ApiError::InvalidInput(
- "Please use the International PayPal transfer option!"
- .to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "Please use the International PayPal transfer option!",
+ )));
}
(
@@ -126,14 +125,14 @@ pub(super) async fn execute(
user.paypal_email.as_ref().unwrap_or(paypal_id),
)
} else {
- return Err(ApiError::InvalidInput(
- "Please re-link your PayPal account!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "Please re-link your PayPal account!",
+ )));
}
} else {
- return Err(ApiError::InvalidInput(
- "You have not linked a PayPal account!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "You have not linked a PayPal account!",
+ )));
};
let payout_req = json!({
diff --git a/apps/labrinth/src/queue/payouts/flow/tremendous.rs b/apps/labrinth/src/queue/payouts/flow/tremendous.rs
index 21f6b7100e..19b00bba95 100644
--- a/apps/labrinth/src/queue/payouts/flow/tremendous.rs
+++ b/apps/labrinth/src/queue/payouts/flow/tremendous.rs
@@ -1,3 +1,4 @@
+use crate::util::error::ApiContext as _;
use chrono::Utc;
use eyre::eyre;
use modrinth_util::decimal::Decimal2dp;
@@ -64,7 +65,8 @@ pub(super) async fn create(
"paypal" | "venmo" => {
let currency = details.currency.unwrap_or(TremendousCurrency::Usd);
let currency_code = currency.to_string();
- let usd_to_currency = usd_to_currency_for(¤cy_code)?;
+ let usd_to_currency = usd_to_currency_for(¤cy_code)
+ .wrap_api_err("executing `usd_to_currency_for`")?;
let fee = PayoutMethodFee {
// If a user withdraws $10:
@@ -127,7 +129,8 @@ pub(super) async fn create(
} else {
TremendousCurrency::Usd.to_string()
};
- let usd_to_currency = usd_to_currency_for(¤cy_code)?;
+ let usd_to_currency = usd_to_currency_for(¤cy_code)
+ .wrap_api_err("executing `usd_to_currency_for`")?;
let currency_to_usd = dec!(1) / usd_to_currency;
// no fees
@@ -190,7 +193,8 @@ pub(super) async fn execute(
pub order: Order,
}
- let user_email = get_verified_email(user)?;
+ let user_email = get_verified_email(user)
+ .wrap_api_err("fetching verified user email")?;
let order_req = json!({
"payment": {
diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs
index c237f6d624..cdc5b34cdd 100644
--- a/apps/labrinth/src/queue/payouts/mod.rs
+++ b/apps/labrinth/src/queue/payouts/mod.rs
@@ -8,6 +8,7 @@ use crate::models::payouts::{
};
use crate::models::projects::MonetizationStatus;
use crate::routes::ApiError;
+use crate::util::error::ApiContext as _;
use crate::util::error::Context;
use crate::util::webhook::{
PayoutSourceAlertType, send_slack_payout_source_alert_webhook,
@@ -207,19 +208,17 @@ impl PayoutsQueue {
.form(&form)
.send()
.await
- .map_err(|_| {
- ApiError::Payments(
- "Error while authenticating with PayPal".to_string(),
- )
- })?
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
+ "error while authenticating with PayPal".to_string(),
+ )?
.json()
.await
- .map_err(|_| {
- ApiError::Payments(
- "Error while authenticating with PayPal (deser error)"
- .to_string(),
- )
- })?;
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
+ "error while authenticating with PayPal (deser error)"
+ .to_string(),
+ )?;
let new_creds = PayPalCredentials {
access_token: credential.access_token,
@@ -244,21 +243,23 @@ impl PayoutsQueue {
let credentials = if let Some(credentials) = read.as_ref() {
if credentials.expires < Utc::now() {
drop(read);
- self.refresh_token().await.map_err(|_| {
- ApiError::Payments(
- "Error while authenticating with PayPal".to_string(),
- )
- })?
+ self.refresh_token()
+ .await
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
+ "error while authenticating with PayPal".to_string(),
+ )?
} else {
credentials.clone()
}
} else {
drop(read);
- self.refresh_token().await.map_err(|_| {
- ApiError::Payments(
- "Error while authenticating with PayPal".to_string(),
- )
- })?
+ self.refresh_token()
+ .await
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
+ "error while authenticating with PayPal".to_string(),
+ )?
};
let client = reqwest::Client::new();
@@ -287,17 +288,23 @@ impl PayoutsQueue {
.body(body);
}
- let resp = request.send().await.map_err(|_| {
- ApiError::Payments("could not communicate with PayPal".to_string())
- })?;
+ let resp = request
+ .send()
+ .await
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
+ "could not communicate with PayPal".to_string(),
+ )?;
let status = resp.status();
- let value = resp.json::().await.map_err(|_| {
- ApiError::Payments(
+ let value = resp
+ .json::()
+ .await
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
"could not retrieve PayPal response body".to_string(),
- )
- })?;
+ )?;
if !status.is_success() {
#[derive(Deserialize)]
@@ -318,27 +325,28 @@ impl PayoutsQueue {
if error.name == "INSUFFICIENT_FUNDS" {
error.message = "We're currently transferring funds to our PayPal account. Please try again in a couple days.".to_string();
}
- return Err(ApiError::Payments(format!(
+ return Err(ApiError::FailedDependency(eyre::eyre!(format!(
"error name: {}, message: {}",
error.name, error.message
- )));
+ ))));
}
if let Ok(error) =
serde_json::from_value::(value)
{
- return Err(ApiError::Payments(format!(
+ return Err(ApiError::FailedDependency(eyre::eyre!(format!(
"error name: {}, message: {}",
error.error, error.error_description
- )));
+ ))));
}
- return Err(ApiError::Payments(
- "could not retrieve PayPal error body".to_string(),
- ));
+ return Err(ApiError::FailedDependency(eyre::eyre!(
+ "could not retrieve PayPal error body",
+ )));
}
- Ok(serde_json::from_value(value)?)
+ serde_json::from_value(value)
+ .wrap_request_err("deserializing JSON data")
}
pub async fn make_tremendous_request(
@@ -359,19 +367,23 @@ impl PayoutsQueue {
request = request.json(&body);
}
- let resp = request.send().await.map_err(|_| {
- ApiError::Payments(
+ let resp = request
+ .send()
+ .await
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
"could not communicate with Tremendous".to_string(),
- )
- })?;
+ )?;
let status = resp.status();
- let value = resp.json::().await.map_err(|_| {
- ApiError::Payments(
+ let value = resp
+ .json::()
+ .await
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
"could not retrieve Tremendous response body".to_string(),
- )
- })?;
+ )?;
if !status.is_success()
&& let Some(obj) = value.as_object()
@@ -385,25 +397,25 @@ impl PayoutsQueue {
let err =
serde_json::from_value::(array.clone())
- .map_err(|_| {
- ApiError::Payments(
- "could not retrieve Tremendous error json body"
- .to_string(),
- )
- })?;
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_failed_dependency_err(
+ "could not retrieve Tremendous error json body"
+ .to_string(),
+ )?;
- return Err(ApiError::Payments(format!(
+ return Err(ApiError::FailedDependency(eyre::eyre!(format!(
"Tremendous error: {} ({:?})",
err.message, err.payload
- )));
+ ))));
}
- return Err(ApiError::Payments(
- "could not retrieve Tremendous error body".to_string(),
- ));
+ return Err(ApiError::FailedDependency(eyre::eyre!(
+ "could not retrieve Tremendous error body",
+ )));
}
- Ok(serde_json::from_value(value)?)
+ serde_json::from_value(value)
+ .wrap_request_err("deserializing JSON data")
}
pub async fn get_payout_methods(
@@ -469,13 +481,17 @@ impl PayoutsQueue {
let options = if let Some(options) = read.as_ref() {
if options.expires < Utc::now() {
drop(read);
- refresh_payout_methods(self).await?
+ refresh_payout_methods(self)
+ .await
+ .wrap_api_err("executing `refresh_payout_methods`")?
} else {
options.clone()
}
} else {
drop(read);
- refresh_payout_methods(self).await?
+ refresh_payout_methods(self)
+ .await
+ .wrap_api_err("executing `refresh_payout_methods`")?
};
Ok(options.options)
@@ -876,12 +892,18 @@ pub async fn make_aditude_request(
"interval": interval
}))
.send()
- .await?
- .error_for_status()?;
+ .await
+ .wrap_internal_err("deserializing HTTP response")?
+ .error_for_status()
+ .wrap_internal_err("deserializing HTTP response")?;
- let text = request.text().await?;
+ let text = request
+ .text()
+ .await
+ .wrap_internal_err("reading HTTP response body")?;
- let json: Vec = serde_json::from_str(&text)?;
+ let json: Vec = serde_json::from_str(&text)
+ .wrap_request_err("deserializing JSON data")?;
Ok(json)
}
@@ -900,7 +922,8 @@ pub async fn process_payout(
crate::models::payouts::PayoutStatus::InTransit.as_str(),
)
.execute(pool)
- .await?;
+ .await
+ .wrap_internal_err("writing analytics data to ClickHouse")?;
let start: DateTime = DateTime::from_naive_utc_and_offset(
(Utc::now() - Duration::days(1))
@@ -915,7 +938,8 @@ pub async fn process_payout(
start,
)
.fetch_one(pool)
- .await?;
+ .await
+ .wrap_internal_err("querying database for `process_payout`")?;
if results.exists.unwrap_or(false) {
return Ok(());
@@ -966,9 +990,12 @@ pub async fn process_payout(
.bind(end.timestamp())
.fetch_one::(),
)
- .await?;
+ .await.wrap_internal_err("querying database for `process_payout`")?;
- let mut transaction = pool.begin().await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
struct PayoutMultipliers {
sum: u64,
@@ -1029,7 +1056,7 @@ pub async fn process_payout(
.insert(r.user_id, r.payouts_split);
async move { Ok(acc) }
})
- .await?;
+ .await.wrap_internal_err("inserting project org members into database")?;
let project_team_members = sqlx::query!(
"
@@ -1055,7 +1082,7 @@ pub async fn process_payout(
async move { Ok(acc) }
},
)
- .await?;
+ .await.wrap_internal_err("inserting project team members into database")?;
for project_id in project_ids {
let team_members: HashMap = project_team_members
@@ -1098,7 +1125,8 @@ pub async fn process_payout(
"Yesterday",
"1d",
)
- .await?;
+ .await
+ .wrap_api_err("executing `make_aditude_request`")?;
let aditude_amount: Decimal = aditude_res
.iter()
@@ -1193,9 +1221,12 @@ pub async fn process_payout(
&insert_availables[..]
)
.execute(&mut transaction)
- .await?;
+ .await.wrap_internal_err("inserting database records for `process_payout`")?;
- transaction.commit().await?;
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
Ok(())
}
@@ -1230,14 +1261,18 @@ pub async fn index_payouts_notifications(
) -> Result<(), ApiError> {
info!("Updating payout notifications");
- let mut transaction = pool.begin().await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
payouts_values_notifications::synchronize_future_payout_values(
&mut transaction,
200,
)
- .await?;
- let items = payouts_values_notifications::PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit(&mut transaction, 200).await?;
+ .await
+ .wrap_internal_err("executing `payouts_values_notifications::synchronize_future_payout_values`")?;
+ let items = payouts_values_notifications::PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit(&mut transaction, 200).await.wrap_internal_err("executing `PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit`")?;
let payout_ref_ids = items.iter().map(|x| x.id).collect::>();
let dates_available =
@@ -1250,14 +1285,23 @@ pub async fn index_payouts_notifications(
&mut transaction,
redis,
)
- .await?;
+ .await
+ .wrap_internal_err(
+ "inserting database records for `index_payouts_notifications`",
+ )?;
payouts_values_notifications::PayoutsValuesNotification::set_notified_many(
&payout_ref_ids,
&mut transaction,
)
- .await?;
+ .await
+ .wrap_internal_err(
+ "updating database records for `index_payouts_notifications`",
+ )?;
- transaction.commit().await?;
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("committing database transaction")?;
Ok(())
}
diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs
index 75e8728260..070095c0b7 100644
--- a/apps/labrinth/src/queue/server_ping.rs
+++ b/apps/labrinth/src/queue/server_ping.rs
@@ -26,9 +26,9 @@ pub struct ServerPingQueue {
pub incremental_search_queue: IncrementalSearchQueue,
}
-pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v3";
+pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v4";
pub const REDIS_FAILURE_NAMESPACE: &str =
- "minecraft_java_server_ping_failures:v3";
+ "minecraft_java_server_ping_failures:v4";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs
index 5319350e7b..7330f80d59 100644
--- a/apps/labrinth/src/routes/analytics.rs
+++ b/apps/labrinth/src/routes/analytics.rs
@@ -9,6 +9,7 @@ use crate::queue::analytics::AnalyticsQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::date::get_current_tenths_of_ms;
+use crate::util::error::ApiContext as _;
use crate::util::error::Context;
use crate::util::http::HttpClient;
use actix_web::{HttpRequest, HttpResponse};
@@ -84,11 +85,11 @@ pub async fn page_view_ingest(
.ok();
let conn_info = req.connection_info().peer_addr().map(|x| x.to_string());
- let url = Url::parse(&url_input.url).map_err(|_| {
- ApiError::InvalidInput("invalid page view URL specified!".to_string())
- })?;
- let domain = url.host_str().ok_or_else(|| {
- ApiError::InvalidInput("invalid page view URL specified!".to_string())
+ let url = Url::parse(&url_input.url)
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_request_err("invalid page view URL specified!".to_string())?;
+ let domain = url.host_str().wrap_request_err_with(|| {
+ "invalid page view URL specified!".to_string()
})?;
let url_origin = url.origin().ascii_serialization();
@@ -98,9 +99,9 @@ pub async fn page_view_ingest(
.any(|origin| origin == "*" || url_origin == *origin);
if !is_valid_url_origin {
- return Err(ApiError::InvalidInput(
- "invalid page view URL specified!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "invalid page view URL specified!",
+ )));
}
let headers = req
@@ -158,7 +159,8 @@ pub async fn page_view_ingest(
&**pool,
&redis,
)
- .await?;
+ .await
+ .wrap_api_err("fetching project from database")?;
if let Some(project) = project {
view.project_id = project.inner.id.0 as u64;
@@ -209,14 +211,15 @@ pub async fn playtime_ingest(
&session_queue,
Scopes::PERFORM_ANALYTICS,
)
- .await?;
+ .await
+ .wrap_auth_err("authenticating API request")?;
let playtimes = playtime_input.0;
if playtimes.len() > 2000 {
- return Err(ApiError::InvalidInput(
- "Too much playtime entered for version!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "Too much playtime entered for version!",
+ )));
}
let versions = crate::database::models::DBVersion::get_many(
@@ -224,7 +227,8 @@ pub async fn playtime_ingest(
&**pool,
&redis,
)
- .await?;
+ .await
+ .wrap_internal_err("fetching versions from database")?;
let headers = req.headers();
@@ -300,8 +304,9 @@ pub async fn minecraft_server_play_ingest(
let project_id = play_input.project_id;
let project = DBProject::get(&project_id.to_string(), &**pool, &redis)
- .await?
- .ok_or(ApiError::NotFound)?;
+ .await
+ .wrap_api_err("fetching project from database")?
+ .wrap_not_found_err("resource not found")?;
if project.components.minecraft_server.is_none() {
return Err(ApiError::Request(eyre!(
diff --git a/apps/labrinth/src/routes/debug/pprof.rs b/apps/labrinth/src/routes/debug/pprof.rs
index cf338ee386..89845bb77c 100644
--- a/apps/labrinth/src/routes/debug/pprof.rs
+++ b/apps/labrinth/src/routes/debug/pprof.rs
@@ -1,7 +1,9 @@
use crate::routes::ApiError;
+use crate::util::error::ApiContext as _;
+use crate::util::error::Context as _;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpResponse, get};
-use eyre::{Context, eyre};
+use eyre::eyre;
use prometheus::{IntGauge, Registry};
use std::time::Duration;
@@ -21,10 +23,12 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
#[get("/pprof/heap", guard = "admin_key_guard")]
pub async fn heap() -> Result {
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
- require_profiling_activated(&prof_ctl)?;
+ require_profiling_activated(&prof_ctl)
+ .wrap_api_err("executing `require_profiling_activated`")?;
let pprof = prof_ctl
.dump_pprof()
- .map_err(|err| ApiError::InvalidInput(err.to_string()))?;
+ .map_err(|err| eyre::Report::msg(err.to_string()))
+ .wrap_request_err("processing request")?;
Ok(HttpResponse::Ok()
.content_type("application/octet-stream")
@@ -39,10 +43,12 @@ pub async fn heap() -> Result {
#[get("/pprof/heap/flamegraph", guard = "admin_key_guard")]
pub async fn flame_graph() -> Result {
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
- require_profiling_activated(&prof_ctl)?;
+ require_profiling_activated(&prof_ctl)
+ .wrap_api_err("executing `require_profiling_activated`")?;
let svg = prof_ctl
.dump_flamegraph()
- .map_err(|err| ApiError::InvalidInput(err.to_string()))?;
+ .map_err(|err| eyre::Report::msg(err.to_string()))
+ .wrap_request_err("processing request")?;
Ok(HttpResponse::Ok().content_type("image/svg+xml").body(svg))
}
@@ -53,8 +59,8 @@ fn require_profiling_activated(
if prof_ctl.activated() {
Ok(())
} else {
- Err(ApiError::InvalidInput(
- "Profiling is not activated".to_string(),
+ Err(ApiError::Request(
+ eyre::eyre!("Profiling is not activated",),
))
}
}
diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs
index dcce8286c5..0fa0196156 100644
--- a/apps/labrinth/src/routes/internal/admin.rs
+++ b/apps/labrinth/src/routes/internal/admin.rs
@@ -9,6 +9,7 @@ use crate::routes::ApiError;
use crate::search::SearchBackend;
use crate::search::incremental::consume::reindex_project_document;
use crate::util::date::get_current_tenths_of_ms;
+use crate::util::error::ApiContext as _;
use crate::util::error::Context;
use crate::util::guards::admin_key_guard;
use crate::util::tags::valid_download_tags;
@@ -78,12 +79,11 @@ fn parse_download_meta_from_query(
for (key, value) in url.query_pairs() {
match key.as_ref() {
"mr_download_reason" => {
- meta.reason =
- Some(DownloadReason::from_str(&value).map_err(|_| {
- ApiError::Request(eyre!(
- "invalid download reason specified"
- ))
- })?);
+ meta.reason = Some(
+ DownloadReason::from_str(&value)
+ .map_err(|()| eyre::eyre!("invalid download reason"))
+ .wrap_request_err("parsing download reason")?,
+ );
}
"mr_game_version" => {
meta.game_version = Some(value.into_owned());
@@ -92,8 +92,12 @@ fn parse_download_meta_from_query(
meta.loader = Some(value.into_owned());
}
"mr_dependent_on" => {
- meta.dependent_on =
- Some(parse_download_meta_version(&value, "dependent_on")?);
+ meta.dependent_on = Some(
+ parse_download_meta_version(&value, "dependent_on")
+ .wrap_api_err(
+ "executing `parse_download_meta_version`",
+ )?,
+ );
}
_ => {}
}
@@ -122,8 +126,8 @@ async fn resolve_download_attribution_version(
crate::database::models::DBVersion::get(version_id, pool, redis)
.await
.wrap_internal_err("failed to fetch download attribution version")?
- .ok_or_else(|| {
- ApiError::Request(eyre!("invalid `{field}` version specified"))
+ .wrap_request_err_with(|| {
+ format!("invalid `{field}` version specified")
})?;
Ok(version_id.0 as u64)
@@ -185,7 +189,8 @@ pub async fn count_download(
download_body.url,
)
.fetch_optional(pool.as_ref())
- .await?
+ .await
+ .wrap_internal_err("fetching version from database")?
{
(version.id, version.mod_id)
} else if let Some(version) = sqlx::query!(
@@ -198,18 +203,19 @@ pub async fn count_download(
id_option
)
.fetch_optional(pool.as_ref())
- .await?
+ .await
+ .wrap_internal_err("fetching version from database")?
{
(version.id, version.mod_id)
} else {
- return Err(ApiError::InvalidInput(
- "Specified version does not exist!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "Specified version does not exist!",
+ )));
};
- let url = url::Url::parse(&download_body.url).map_err(|_| {
- ApiError::InvalidInput("invalid download URL specified!".to_string())
- })?;
+ let url = url::Url::parse(&download_body.url)
+ .map_err(|err| eyre::eyre!(err))
+ .wrap_request_err("invalid download URL specified!".to_string())?;
let ip = crate::util::ip::convert_to_ip_v6(&download_body.ip)
.unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped());
@@ -220,7 +226,8 @@ pub async fn count_download(
.map(Some)
.wrap_request_err("invalid download meta")?
} else {
- parse_download_meta_from_query(&url)?
+ parse_download_meta_from_query(&url)
+ .wrap_api_err("parsing download metadata from URL")?
};
if let Some(meta) = &meta {
@@ -250,7 +257,8 @@ pub async fn count_download(
meta.as_ref().and_then(|m| m.dependent_on),
"dependent_on",
)
- .await?;
+ .await
+ .wrap_api_err("executing `resolve_download_attribution_version`")?;
let download = Download {
recorded: get_current_tenths_of_ms(),
diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs
index 85f6e9ae69..8716dffba1 100644
--- a/apps/labrinth/src/routes/internal/affiliate.rs
+++ b/apps/labrinth/src/routes/internal/affiliate.rs
@@ -66,8 +66,8 @@ pub async fn ingest_click(
let conn_info = req.connection_info().peer_addr().map(|x| x.to_string());
let url = ingest_click.url;
- let domain = url.host_str().ok_or_else(|| {
- ApiError::InvalidInput("invalid page view URL specified!".to_string())
+ let domain = url.host_str().wrap_request_err_with(|| {
+ "invalid page view URL specified!".to_string()
})?;
let url_origin = url.origin().ascii_serialization();
@@ -77,9 +77,9 @@ pub async fn ingest_click(
.any(|origin| origin == "*" || url_origin == *origin);
if !is_valid_url_origin {
- return Err(ApiError::InvalidInput(
- "invalid page view URL specified!".to_string(),
- ));
+ return Err(ApiError::Request(eyre::eyre!(
+ "invalid page view URL specified!",
+ )));
}
let exists = sqlx::query!(
@@ -159,7 +159,8 @@ pub async fn get_all(
&session_queue,
Scopes::SESSION_ACCESS,
)
- .await?;
+ .await
+ .wrap_auth_err("authenticating API request")?;
if user.role.is_admin() {
let codes = DBAffiliateCode::get_all(&**pool)
@@ -181,9 +182,9 @@ pub async fn get_all(
.collect::>();
Ok(web::Json(codes))
} else {
- Err(ApiError::CustomAuthentication(
- "You do not have permission to view affiliate codes!".to_string(),
- ))
+ Err(ApiError::Auth(eyre::eyre!(
+ "You do not have permission to view affiliate codes!",
+ )))
}
}
@@ -214,16 +215,16 @@ pub async fn create(
&session_queue,
Scopes::SESSION_ACCESS,
)
- .await?;
+ .await
+ .wrap_auth_err("authenticating API request")?;
let is_admin = creator.role.is_admin();
let is_affiliate = creator.badges.contains(Badges::AFFILIATE);
if !is_admin && !is_affiliate {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to create an affiliate code!"
- .to_string(),
- ));
+ return Err(ApiError::Auth(eyre::eyre!(
+ "You do not have permission to create an affiliate code!",
+ )));
}
let creator_id = DBUserId::from(creator.id);
@@ -239,19 +240,25 @@ pub async fn create(
if affiliate_id != creator_id {
let Some(_affiliate_user) =
- DBUser::get_id(affiliate_id, &**pool, &redis).await?
+ DBUser::get_id(affiliate_id, &**pool, &redis)
+ .await
+ .wrap_internal_err("fetching user from database")?
else {
- return Err(ApiError::CustomAuthentication(
- "Affiliate user not found!".to_string(),
- ));
+ return Err(ApiError::Auth(eyre::eyre!(
+ "Affiliate user not found!",
+ )));
};
}
- let mut transaction = pool.begin().await?;
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("starting database transaction")?;
let affiliate_code_id =
crate::database::models::generate_affiliate_code_id(&mut transaction)
- .await?;
+ .await
+ .wrap_internal_err("generating affiliate code ID")?;
let code = DBAffiliateCode {
id: affiliate_code_id,
@@ -293,13 +300,15 @@ pub async fn get(
&session_queue,
Scopes::SESSION_ACCESS,
)
- .await?;
+ .await
+ .wrap_auth_err("authenticating API request")?;
let (affiliate_code_id,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
- if let Some(model) =
- DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await?
+ if let Some(model) = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool)
+ .await
+ .wrap_internal_err("fetching affiliate code from database")?
{
let is_admin = user.role.is_admin();
let is_owner = model.affiliate == DBUserId::from(user.id);
@@ -307,10 +316,10 @@ pub async fn get(
if is_admin || is_owner {
Ok(web::Json(AffiliateCode::from(model, is_admin)))
} else {
- Err(ApiError::NotFound)
+ Err(ApiError::NotFound(eyre::eyre!("resource not found")))
}
} else {
- Err(ApiError::NotFound)
+ Err(ApiError::NotFound(eyre::eyre!("resource not found")))
}
}
@@ -335,30 +344,33 @@ pub async fn delete(
&session_queue,
Scopes::SESSION_ACCESS,
)
- .await?;
+ .await
+ .wrap_auth_err("authenticating API request")?;
let (affiliate_code_id,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
- if let Some(model) =
- DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await?
+ if let Some(model) = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool)
+ .await
+ .wrap_internal_err("fetching affiliate code from database")?
{
let is_admin = user.role.is_admin();
let is_owner = model.affiliate == DBUserId::from(user.id);
if is_admin || is_owner {
- let result =
- DBAffiliateCode::remove(affiliate_code_id, &**pool).await?;
+ let result = DBAffiliateCode::remove(affiliate_code_id, &**pool)
+ .await
+ .wrap_internal_err("deleting affiliate code from database")?;
if result.is_some() {
Ok(())
} else {
- Err(ApiError::NotFound)
+ Err(ApiError::NotFound(eyre::eyre!("resource not found")))
}
} else {
- Err(ApiError::NotFound)
+ Err(ApiError::NotFound(eyre::eyre!("resource not found")))
}
} else {
- Err(ApiError::NotFound)
+ Err(ApiError::NotFound(eyre::eyre!("resource not found")))
}
}
@@ -389,26 +401,28 @@ pub async fn patch(
&session_queue,
Scopes::SESSION_ACCESS,
)
- .await?;
+ .await
+ .wrap_auth_err("authenticating API request")?;
let (affiliate_code_id,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
let existing_code = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool)
- .await?
- .ok_or(ApiError::NotFound)?;
+ .await
+ .wrap_internal_err("fetching affiliate code from database")?
+ .wrap_not_found_err("resource not found")?;
let is_admin = user.role.is_admin();
let is_owner = existing_code.affiliate == DBUserId::from(user.id);
if !is_admin && !is_owner {
- return Err(ApiError::NotFound);
+ return Err(ApiError::NotFound(eyre::eyre!("resource not found")));
}
if !is_admin && !user.badges.contains(Badges::AFFILIATE) {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to update affiliate codes!".to_string(),
- ));
+ return Err(ApiError::Auth(eyre::eyre!(
+ "You do not have permission to update affiliate codes!",
+ )));
}
DBAffiliateCode::update_source_name(
diff --git a/apps/labrinth/src/routes/internal/analytics_event.rs b/apps/labrinth/src/routes/internal/analytics_event.rs
new file mode 100644
index 0000000000..5a6bec2b7e
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/analytics_event.rs
@@ -0,0 +1,195 @@
+use actix_web::{HttpRequest, delete, patch, post, web};
+use chrono::{DateTime, Utc};
+use eyre::eyre;
+use serde::{Deserialize, Serialize};
+use xredis::RedisPool;
+
+use crate::{
+ auth::get_user_from_headers,
+ database::{
+ PgPool,
+ models::{
+ DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
+ },
+ },
+ models::{
+ ids::AnalyticsEventId,
+ pats::Scopes,
+ v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
+ },
+ queue::session::AuthQueue,
+ routes::ApiError,
+ util::error::Context,
+};
+
+pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
+ cfg.service(analytics_event_create)
+ .service(analytics_event_edit)
+ .service(analytics_event_delete);
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
+pub struct AnalyticsEventUpsert {
+ #[serde(flatten)]
+ pub meta: AnalyticsEventMeta,
+ pub starts: DateTime,
+ pub ends: DateTime,
+}
+
+/// Create an analytics event.
+#[utoipa::path(
+ context_path = "/analytics-event",
+ tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
+)]
+#[post("")]
+pub async fn analytics_event_create(
+ req: HttpRequest,
+ event: web::Json,
+ pool: web::Data,
+ redis: web::Data