Compare commits

..
Author SHA1 Message Date
aecsocket 199b2b1fcf try fix ci? idk man 2026-02-18 04:52:17 +00:00
aecsocket 9849ffaa1f fix ci 2026-02-18 03:14:28 +00:00
aecsocket 43a7688b53 remove_documents more error logging 2026-02-18 02:45:49 +00:00
aecsocket 399529fdd8 Improve error logging in project delete route 2026-02-18 02:36:15 +00:00
96 changed files with 1150 additions and 2060 deletions
+6 -9
View File
@@ -65,7 +65,6 @@ import IncompatibilityWarningModal from '@/components/ui/install_flow/Incompatib
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
@@ -78,7 +77,7 @@ import UpdateToast from '@/components/ui/UpdateToast.vue'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
@@ -272,11 +271,12 @@ async function setupApp() {
isMaximized.value = await getCurrentWindow().isMaximized()
})
if (telemetry) {
initAnalytics()
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev, onboarded })
initAnalytics()
if (!telemetry) {
optOutAnalytics()
}
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev, onboarded })
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
@@ -389,7 +389,6 @@ loading.setEnabled(false)
const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const install = useInstall()
const modInstallModal = ref()
@@ -468,7 +467,6 @@ onMounted(() => {
invoke('show_window')
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
@@ -1134,7 +1132,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" />
@@ -33,7 +33,6 @@ const metadata = ref({})
defineExpose({
async show(errorVal, context, canClose = true, source = null) {
console.log(errorVal, context, canClose, source)
closable.value = canClose
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
@@ -49,14 +49,19 @@ const creatingInstance = ref(false)
const profiles = ref([])
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
profiles.value
.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
})
.filter((profile) => {
const version = {
game_versions: versions.value.flatMap((v) => v.game_versions),
loaders: versions.value.flatMap((v) => v.loaders),
}
return isVersionCompatible(version, project.value, profile)
}),
)
const isProfileCompatible = (profile) =>
versions.value?.some((version) => isVersionCompatible(version, project.value, profile))
const onInstall = ref(() => {})
defineExpose({
@@ -159,13 +164,13 @@ const createInstance = async () => {
const gameVersion = gameVersions[0]
const loaders = versions.value[0].loaders
const loader = loaders.includes('fabric')
const loader = loaders.contains('fabric')
? 'fabric'
: loaders.includes('neoforge')
: loaders.contains('neoforge')
? 'neoforge'
: loaders.includes('forge')
: loaders.contains('forge')
? 'forge'
: loaders.includes('quilt')
: loaders.contains('quilt')
? 'quilt'
: 'vanilla'
@@ -235,23 +240,17 @@ const createInstance = async () => {
"
>
<Button
:disabled="
!isProfileCompatible(profile) || profile.installedMod || profile.installing
"
:disabled="profile.installedMod || profile.installing"
@click="install(profile)"
>
<DownloadIcon
v-if="isProfileCompatible(profile) && !profile.installedMod && !profile.installing"
/>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<CheckIcon v-else-if="profile.installedMod" />
{{
profile.installing
? 'Installing...'
: profile.installedMod
? 'Installed'
: !isProfileCompatible(profile)
? 'Incompatible'
: 'Install'
: 'Install'
}}
</Button>
</div>
@@ -1,184 +0,0 @@
<script setup lang="ts">
import {
CheckIcon,
CopyIcon,
DropdownIcon,
LogInIcon,
MessagesSquareIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { type MinecraftAuthError, minecraftAuthErrors } from './minecraft-auth-errors'
const modal = ref<InstanceType<typeof NewModal>>()
const rawError = ref<string>('')
const matchedError = ref<MinecraftAuthError | null>(null)
const debugCollapsed = ref(true)
const copied = ref(false)
const loadingSignIn = ref(false)
function show(errorVal: { message?: string }) {
rawError.value = errorVal?.message ?? String(errorVal)
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
debugCollapsed.value = true
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({
show,
hide,
})
async function signInAgain() {
try {
loadingSignIn.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id)
}
loadingSignIn.value = false
modal.value?.hide()
} catch (err) {
loadingSignIn.value = false
handleSevereError(err)
}
}
const debugInfo = computed(() => rawError.value || 'No error message.')
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
<NewModal ref="modal" header="Sign in Failed" :max-width="'548px'">
<div class="flex flex-col gap-6">
<Admonition
type="warning"
body=" We couldn't sign you into your Microsoft account. This may be due to account restrictions or
regional limitations."
>
</Admonition>
<!-- Matched error details -->
<div class="bg-surface-2 rounded-2xl p-4 px-5 flex flex-col gap-3">
<template v-if="matchedError">
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">What we think happened</h3>
<p class="text-sm text-secondary m-0">
{{ matchedError.whatHappened }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">How to fix it</h3>
<ol class="list-none flex flex-col gap-2 m-0 pl-0">
<li
v-for="(step, index) in matchedError.stepsToFix"
:key="index"
class="flex items-baseline gap-2"
>
<span
class="inline-flex items-center justify-center shrink-0 w-5 h-5 rounded-full bg-surface-4 border border-solid border-surface-5 text-xs font-medium"
>
{{ index + 1 }}
</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span
class="text-sm [&_a]:text-info [&_a]:font-medium [&_a]:underline"
v-html="step"
/>
</li>
</ol>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">Unknown error</h3>
<p class="text-sm text-secondary m-0">
We dont recognize this error and cant recommend specific steps to resolve it.
</p>
<p class="text-sm text-secondary m-0">
Try visiting
<a
class="text-info font-medium underline hover:underline"
href="https://www.minecraft.net/en-us/login"
>Minecraft Login</a
>
and signing in, as it may prompt you with the necessary steps. You can also contact
support and we can look into it further.
</p>
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<ButtonStyled>
<a href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
<LogInIcon /> Sign in again
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2">
<div class="w-full h-[1px] bg-surface-5"></div>
<!-- Debug info -->
<div class="overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
@click="debugCollapsed = !debugCollapsed"
>
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
<WrenchIcon class="h-4 w-4" />
Debug information
</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !debugCollapsed }"
/>
</button>
<Collapsible :collapsed="debugCollapsed">
<div class="p-3 bg-surface-2 rounded-2xl text-xs flex items-start">
<div class="m-0 p-0 rounded-none bg-transparent text-sm font-mono">
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
</div>
</Collapsible>
</div>
</div>
</div>
</NewModal>
</template>
@@ -1,90 +0,0 @@
export interface MinecraftAuthError {
errorCode: string
whatHappened: string
stepsToFix: string[]
}
export const minecraftAuthErrors: MinecraftAuthError[] = [
{
errorCode: '2148916222',
whatHappened:
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.',
stepsToFix: [
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in',
'Follow the instructions to verify your age',
'Once verified, try signing in again',
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>',
],
},
{
errorCode: '2148916233',
whatHappened: "This account doesn't have an Xbox profile set up or doesn't own Minecraft.",
stepsToFix: [
'Make sure Minecraft is purchased on this account',
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in',
'Complete Xbox profile setup if prompted',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916235',
whatHappened: "Xbox Live isn't available in your region, so sign-in is blocked.",
stepsToFix: [
'Xbox services must be supported in your country before you can sign in',
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions',
],
},
{
errorCode: '2148916236',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916237',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916238',
whatHappened: 'This account is underage and not linked to a Microsoft family group.',
stepsToFix: [
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>',
'Join or create a family group as instructed',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916227',
whatHappened: 'This account was suspended for violating Xbox Community Standards.',
stepsToFix: [
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details',
'Submit an appeal if one is available',
],
},
{
errorCode: '2148916229',
whatHappened: "This account is restricted and doesn't have permission to play online.",
stepsToFix: [
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>',
'Update online play permissions',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916234',
whatHappened: "This account hasn't accepted Xbox's Terms of Service.",
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Accept the Terms if prompted',
'Once finished, try signing in again',
],
},
]
@@ -0,0 +1,24 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com',
})
}
export const debugAnalytics = () => {
posthog.debug()
}
export const optOutAnalytics = () => {
posthog.opt_out_capturing()
}
export const optInAnalytics = () => {
posthog.opt_in_capturing()
}
export const trackEvent = (eventName, properties) => {
posthog.capture(eventName, properties)
}
@@ -1,80 +0,0 @@
import { posthog } from 'posthog-js'
interface InstanceProperties {
loader: string
game_version: string
}
interface ProjectProperties extends InstanceProperties {
id: string
project_type: string
}
type AnalyticsEventMap = {
Launched: { version: string; dev: boolean; onboarded: boolean }
PageView: { path: string; fromPath: string; failed: unknown }
InstanceCreate: { source: string }
InstanceCreateStart: { source: string }
InstancePlay: InstanceProperties & { source: string }
InstanceStop: Partial<InstanceProperties> & { source?: string }
InstanceDuplicate: InstanceProperties
InstanceRepair: InstanceProperties
InstanceSetIcon: Record<string, never>
InstanceRemoveIcon: Record<string, never>
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
InstanceProjectDisable: InstanceProperties & {
id: string
name: string
project_type: string
disabled: boolean
}
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
ProjectInstallStart: { source: string }
PackInstall: { id: string; version_id: string; title: string; source: string }
PackInstallStart: Record<string, never>
AccountLogIn: { source?: string }
AccountLogOut: Record<string, never>
JavaTest: { path: string; success: boolean }
JavaManualSelect: { version: string }
JavaAutoDetect: { path: string; version: string }
}
export type AnalyticsEvent = keyof AnalyticsEventMap
let initialized = false
export const initAnalytics = () => {
if (initialized) return
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com',
})
initialized = true
}
export const debugAnalytics = () => {
if (!initialized) return
posthog.debug()
}
export const optOutAnalytics = () => {
if (!initialized) return
posthog.opt_out_capturing()
}
export const optInAnalytics = () => {
initAnalytics()
posthog.opt_in_capturing()
}
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
export const trackEvent = <E extends AnalyticsEvent>(
eventName: E,
...args: OptionalArgs<AnalyticsEventMap[E]>
) => {
if (!initialized) return
posthog.capture(eventName, args[0])
}
@@ -48,7 +48,7 @@ onUnmounted(() => {
]"
/>
<template v-if="instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
<RouterView :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
-12
View File
@@ -3,24 +3,12 @@ import { defineStore } from 'pinia'
export const useError = defineStore('errorsStore', {
state: () => ({
errorModal: null,
minecraftAuthErrorModal: null,
}),
actions: {
setErrorModal(ref) {
this.errorModal = ref
},
setMinecraftAuthErrorModal(ref) {
this.minecraftAuthErrorModal = ref
},
showError(error, context, closable = true, source = null) {
if (
error.message &&
error.message.includes('Minecraft authentication error:') &&
this.minecraftAuthErrorModal
) {
this.minecraftAuthErrorModal.show(error)
return
}
this.errorModal.show(error, context, closable, source)
},
},
+1 -1
View File
@@ -1,7 +1,7 @@
import dayjs from 'dayjs'
import { defineStore } from 'pinia'
import { trackEvent } from '@/helpers/analytics'
import { trackEvent } from '@/helpers/analytics.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import {
+6 -15
View File
@@ -191,20 +191,12 @@ function computeActiveIndex(): { index: number; isSubpage: boolean } {
}
function getTabElement(index: number): HTMLElement | null {
if (index === -1) return null
if (!tabLinkElements.value?.[index]) return null
const container = scrollContainer.value as HTMLElement | undefined
if (!container) return null
const tabs = container.querySelectorAll('.button-animation')
const element = tabs[index] as HTMLElement | undefined
if (!element) return null
// In navigation mode, elements are NuxtLinks, but since we used querySelectorAll,
// we already have the raw HTMLElement ($el), so no further conversion is needed.
// In local mode, elements are already plain divs.
return element
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const element = tabLinkElements.value[index]
return props.mode === 'navigation' ? (element as any).$el : element
}
function positionSlider() {
@@ -261,8 +253,7 @@ function animateSliderTo(newPosition: {
sliderBottom.value = newPosition.bottom
}
async function updateActiveTab() {
await nextTick()
function updateActiveTab() {
const { index, isSubpage } = computeActiveIndex()
currentActiveIndex.value = index
subpageSelected.value = isSubpage
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">Loaders</span>
<span class="font-semibold text-contrast">Loaders <span class="text-red">*</span></span>
<Chips
v-model="loaderGroup"
@@ -77,13 +77,7 @@
</TagItem>
</template>
<TagItem
v-if="!draftVersionLoaders.length && projectType === 'modpack'"
class="border !border-solid border-surface-5 hover:no-underline"
>
No mod loader
</TagItem>
<span v-else-if="!draftVersionLoaders.length">No loaders selected.</span>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
@@ -100,7 +100,7 @@
/>
</div>
<div v-for="field in visibleFields" :key="field.name" class="flex flex-col gap-2.5">
<div v-for="field in selectedRail?.fields" :key="field.name" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(field.label) }}
@@ -398,26 +398,6 @@ const isBusinessEntity = computed(() => {
return providerDataValue.kycData?.type === 'business'
})
const visibleFields = computed(() => {
const rail = selectedRail.value
if (!rail) return []
return rail.fields.filter((field) => {
if (!field.dependsOn) return true
const { field: dependsOnField, value: dependsOnValue } = field.dependsOn
const currentValue = formData.value[dependsOnField]
if (!currentValue) return false
if (Array.isArray(dependsOnValue)) {
return dependsOnValue.includes(currentValue)
} else {
return currentValue === dependsOnValue
}
})
})
const allRequiredFieldsFilled = computed(() => {
const rail = selectedRail.value
if (!rail) return false
@@ -427,7 +407,7 @@ const allRequiredFieldsFilled = computed(() => {
if (rail.requiresBankName && !formData.value.bankName) return false
const requiredFields = visibleFields.value.filter((f) => f.required)
const requiredFields = rail.fields.filter((f) => f.required)
const allRequiredPresent = requiredFields.every((f) => {
const value = formData.value[f.name]
return value !== undefined && value !== null && value !== ''
@@ -5,7 +5,6 @@ import {
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardCopyIcon,
CodeIcon,
CopyIcon,
@@ -160,15 +159,6 @@ watch(selectedFile, (newFile) => {
const client = injectModrinthClient()
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
await client.request('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
const detailDecisions = reactive<Map<string, 'safe' | 'malware'>>(new Map())
@@ -403,7 +393,11 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
isBatchUpdating.value = true
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
await Promise.all(
detailIds.map((detailId) =>
client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }),
),
)
const decision = verdict === 'safe' ? 'safe' : 'malware'
for (const detailId of detailIds) {
@@ -451,7 +445,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
updatingDetails.add(detailId)
try {
await updateIssueDetails([{ detail_id: detailId, verdict }])
await client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict })
const decision = verdict === 'safe' ? 'safe' : 'malware'
@@ -490,7 +484,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
for (const classGroup of groupedByClass.value) {
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.key)
expandedClasses.delete(classGroup.filePath)
break
}
}
@@ -539,8 +533,6 @@ const expandedClasses = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
interface ClassGroup {
key: string
jar: string | null
filePath: string
flags: Array<{
issueId: string
@@ -551,26 +543,6 @@ interface ClassGroup {
}>
}
interface JarGroup {
key: string
jar: string | null
segments: string[]
classes: ClassGroup[]
}
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
if (!jar) return []
const segments = jar
.split('#')
.map((s) => decodeURIComponent(s.trim()))
.filter((s) => s.length > 0)
// Skip the first segment if it matches the current file tab (it's already shown in the file list)
if (segments.length > 0 && currentFileName && segments[0] === currentFileName) {
return segments.slice(1)
}
return segments
}
const groupedByClass = computed<ClassGroup[]>(() => {
if (!selectedFile.value) return []
@@ -578,20 +550,14 @@ const groupedByClass = computed<ClassGroup[]>(() => {
for (const issue of selectedFile.value.issues) {
for (const detail of issue.details) {
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
if (!classMap.has(classKey)) {
classMap.set(classKey, {
key: classKey,
jar: detail.jar ?? null,
filePath: detail.file_path,
flags: [],
})
if (!classMap.has(detail.file_path)) {
classMap.set(detail.file_path, { filePath: detail.file_path, flags: [] })
}
// Cast detail to include status (backend will provide this field)
const detailWithStatus = detail as Labrinth.TechReview.Internal.ReportIssueDetail & {
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}
classMap.get(classKey)!.flags.push({
classMap.get(detail.file_path)!.flags.push({
issueId: issue.id,
issueType: issue.issue_type,
detail: detailWithStatus,
@@ -619,35 +585,12 @@ const groupedByClass = computed<ClassGroup[]>(() => {
})
})
const groupedByJar = computed<JarGroup[]>(() => {
const jarMap = new Map<string, JarGroup>()
for (const classItem of groupedByClass.value) {
const jarKey = classItem.jar ?? ''
if (!jarMap.has(jarKey)) {
jarMap.set(jarKey, {
key: jarKey,
jar: classItem.jar,
segments: splitJarSegments(classItem.jar, selectedFile.value?.file_name ?? null),
classes: [],
})
}
jarMap.get(jarKey)!.classes.push(classItem)
}
return Array.from(jarMap.values()).sort((a, b) => {
const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
})
})
// Auto-expand if there's only one class in the file
watch(
groupedByClass,
(classes) => {
if (classes.length === 1) {
expandedClasses.add(classes[0].key)
expandedClasses.add(classes[0].filePath)
}
},
{ immediate: true },
@@ -665,11 +608,11 @@ function getHighestSeverityInClass(
)
}
function toggleClass(classKey: string) {
if (expandedClasses.has(classKey)) {
expandedClasses.delete(classKey)
function toggleClass(filePath: string) {
if (expandedClasses.has(filePath)) {
expandedClasses.delete(filePath)
} else {
expandedClasses.add(classKey)
expandedClasses.add(filePath)
}
}
@@ -1154,234 +1097,204 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</ButtonStyled>
</div>
<div
v-for="jarGroup in groupedByJar"
:key="jarGroup.key"
class="border-x border-b-0 border-t-0 border-solid border-surface-3 bg-surface-2"
v-for="(classItem, idx) in groupedByClass"
:key="classItem.filePath"
class="border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2"
:class="{
'rounded-bl-2xl rounded-br-2xl':
idx === groupedByClass.length - 1 && remainingUnmarkedCount === 0,
}"
>
<div
v-if="jarGroup.segments.length > 0"
class="border-b border-solid border-surface-1 px-4 py-3"
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.filePath)"
>
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
<button
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(classItem.filePath) }"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
<ChevronDownIcon class="h-5 w-5 text-contrast" />
</button>
</ButtonStyled>
<span v-tooltip="classItem.filePath" class="font-mono font-semibold">{{
truncateMiddle(classItem.filePath)
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
>
<span class="text-sm font-medium">{{
capitalizeString(getHighestSeverityInClass(classItem.flags))
}}</span>
</div>
<div
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
:class="
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
? 'border-green/60 bg-highlight-green text-green'
: 'border-red/60 bg-highlight-red text-red'
"
>
<CheckIcon
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
class="size-4"
/>
</template>
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
</div>
<Transition name="fade">
<div
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</span>
</div>
</Transition>
</div>
</div>
<div
v-for="classItem in jarGroup.classes"
:key="classItem.key"
class="border-b border-solid border-surface-1 last:border-b-0"
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.key)"
>
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
<button
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(classItem.key) }"
<Collapsible :collapsed="!expandedClasses.has(classItem.filePath)">
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
class="flex items-center gap-2"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<ChevronDownIcon class="h-5 w-5 text-contrast" />
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
}}</span>
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
>
Pass
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
>
Fail
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
>
<div
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
a.localeCompare(b),
)"
:key="key"
class="flex items-center gap-1.5"
>
<span class="text-secondary">{{ key }}:</span>
<a
v-if="typeof value === 'string' && value.startsWith('http')"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="text-brand-blue hover:underline"
>
{{ value }}
</a>
<span v-else class="font-mono text-contrast">{{ value }}</span>
</div>
</div>
</div>
<div
v-if="getClassDecompiledSource(classItem)"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<ButtonStyled circular type="transparent">
<button
v-tooltip="`Copy code`"
class="absolute right-2 top-2 border-[1px]"
@click="
copyToClipboard(getClassDecompiledSource(classItem)!, classItem.filePath)
"
>
<CopyIcon v-if="!showCopyFeedback.get(classItem.filePath)" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<span v-tooltip="classItem.filePath" class="font-mono font-semibold">{{
truncateMiddle(classItem.filePath)
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
>
<span class="text-sm font-medium">{{
capitalizeString(getHighestSeverityInClass(classItem.flags))
}}</span>
</div>
<div
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
:class="
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
? 'border-green/60 bg-highlight-green text-green'
: 'border-red/60 bg-highlight-red text-red'
"
>
<CheckIcon
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
class="size-4"
/>
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
</div>
<Transition name="fade">
<div class="overflow-x-auto bg-surface-3 py-3">
<div
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</span>
<div
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
style="min-width: 3.5rem"
>
{{ n + 1 }}
</div>
<div class="flex-1 px-4 py-0 text-primary">
<pre v-html="line || ' '"></pre>
</div>
</div>
</Transition>
</div>
</div>
<div v-else class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4">
<p class="text-sm text-secondary">
Source code not available or failed to decompile for this file.
</p>
</div>
</div>
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
class="flex items-center gap-2"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
}}</span>
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
>
Pass
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
>
Fail
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
>
<div
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
a.localeCompare(b),
)"
:key="key"
class="flex items-center gap-1.5"
>
<span class="text-secondary">{{ key }}:</span>
<a
v-if="typeof value === 'string' && value.startsWith('http')"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="text-brand-blue hover:underline"
>
{{ value }}
</a>
<span v-else class="font-mono text-contrast">{{ value }}</span>
</div>
</div>
</div>
<div
v-if="getClassDecompiledSource(classItem)"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<ButtonStyled circular type="transparent">
<button
v-tooltip="`Copy code`"
class="absolute right-2 top-2 border-[1px]"
@click="copyToClipboard(getClassDecompiledSource(classItem)!, classItem.key)"
>
<CopyIcon v-if="!showCopyFeedback.get(classItem.key)" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<div class="overflow-x-auto bg-surface-3 py-3">
<div
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
>
<div
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
style="min-width: 3.5rem"
>
{{ n + 1 }}
</div>
<div class="flex-1 px-4 py-0 text-primary">
<pre v-html="line || ' '"></pre>
</div>
</div>
</div>
</div>
<div
v-else
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
>
<p class="text-sm text-secondary">
Source code not available or failed to decompile for this file.
</p>
</div>
</div>
</Collapsible>
</div>
</Collapsible>
</div>
</template>
</div>
@@ -32,7 +32,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
projectBackground: false,
searchBackground: false,
advancedDebugInfo: false,
FilesRefreshButton: false,
showProjectPageDownloadModalServersPromo: false,
showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false,
+7 -49
View File
@@ -97,8 +97,6 @@
ref="downloadModal"
:on-show="
() => {
debug('on-show fired')
loadVersions()
navigateTo({ query: route.query, hash: '#download' })
}
"
@@ -390,9 +388,7 @@
currentGameVersion &&
!filteredRelease &&
!filteredBeta &&
!filteredAlpha &&
!versionsLoading &&
versions.length > 0
!filteredAlpha
"
>
{{
@@ -990,7 +986,6 @@ import {
ServersPromo,
StyledInput,
TagItem,
useDebugLogger,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -1037,8 +1032,6 @@ const cosmetics = useCosmetics()
const { locale, formatMessage } = useVIntl()
const debug = useDebugLogger('DownloadModal')
const settingsModal = ref()
const downloadModal = ref()
const overTheTopDownloadAnimation = ref()
@@ -1082,10 +1075,9 @@ const currentPlatform = computed(() => {
)
})
const currentPlatformText = computed(() => {
if (!currentPlatform.value) return null
return formatMessage(getTagMessage(currentPlatform.value, 'loader'))
})
const currentPlatformText = computed(() =>
formatMessage(getTagMessage(currentPlatform.value, 'loader')),
)
const releaseVersions = computed(() => {
const set = new Set()
@@ -1418,21 +1410,11 @@ async function getLicenseData(event) {
}
const filteredVersions = computed(() => {
const result = versions.value.filter(
return versions.value.filter(
(x) =>
x.game_versions?.includes(currentGameVersion.value) &&
(x.loaders?.includes(currentPlatform.value) || project.value.project_type === 'resourcepack'),
x.game_versions.includes(currentGameVersion.value) &&
(x.loaders.includes(currentPlatform.value) || project.value.project_type === 'resourcepack'),
)
debug('filteredVersions', {
total: versions.value.length,
filtered: result.length,
currentGameVersion: currentGameVersion.value,
currentPlatform: currentPlatform.value,
versionsEnabled: versionsEnabled.value,
versionsLoading: versionsV3Loading.value,
sampleLoaders: versions.value.slice(0, 3).map((v) => v.loaders),
})
return result
})
const filteredRelease = computed(() => {
@@ -1625,10 +1607,6 @@ const versionsLoading = computed(() => versionsV3Loading.value)
// Load versions on demand (client-side only)
function loadVersions() {
debug('loadVersions called', {
projectId: projectId.value,
alreadyEnabled: versionsEnabled.value,
})
versionsEnabled.value = true
}
@@ -2092,35 +2070,15 @@ if (project.value && loader !== undefined && project.value.loaders.includes(load
userSelectedPlatform.value = loader
}
if (route.hash === '#download' || version !== undefined || loader !== undefined) {
debug('eager loadVersions from setup', { hash: route.hash, version, loader })
loadVersions()
}
watch(downloadModal, (modal) => {
if (!modal) return
// route.hash returns everything in the hash string, including the # itself
if (route.hash === '#download') {
debug('hash #download watch fired, opening modal')
loadVersions()
modal.show()
}
})
watch(
[versionsV3, _versionsV3Error],
([data, error]) => {
debug('versionsV3 query changed', {
hasData: !!data,
count: data?.length ?? 0,
error: error?.message ?? null,
projectId: projectId.value,
})
},
{ immediate: true },
)
async function setProcessing() {
// Guard against multiple submissions while mutation is pending
if (patchStatusMutation.isPending.value) return
@@ -10,8 +10,5 @@ useHead({
</script>
<template>
<ServersManageFilesPage
:show-debug-info="flags.advancedDebugInfo"
:show-refresh-button="flags.FilesRefreshButton"
/>
<ServersManageFilesPage :show-debug-info="flags.advancedDebugInfo" />
</template>
@@ -421,6 +421,10 @@ export function createManageVersionContext(
inferred.loaders = ['datapack']
}
if (noLoaders && projectType.value === 'modpack') {
inferred.loaders = ['minecraft']
}
inferredVersionData.value = inferred
return inferred
@@ -10,14 +10,8 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-loaders',
stageContent: markRaw(LoadersStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit loaders' : 'Loaders'),
skip: (ctx) => {
const inferredLoadersLength = ctx.inferredVersionData.value?.loaders?.length ?? 0
return (
inferredLoadersLength > 0 ||
ctx.editingVersion.value ||
(inferredLoadersLength === 0 && ctx.projectType.value === 'modpack')
)
},
skip: (ctx) =>
(ctx.inferredVersionData.value?.loaders?.length ?? 0) > 0 || ctx.editingVersion.value,
hideStageInBreadcrumb: (ctx) => !ctx.primaryFile.value || ctx.handlingNewFiles.value,
cannotNavigateForward: (ctx) => ctx.draftVersion.value.loaders.length === 0,
leftButtonConfig: (ctx) => ({
@@ -42,22 +36,20 @@ export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue>
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.draftVersion.value.loaders.length === 0 && ctx.projectType.value !== 'modpack',
disabled: ctx.draftVersion.value.loaders.length === 0,
onClick: () => ctx.modal.value?.setStage('metadata'),
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
...ctx.saveButtonConfig(),
disabled:
ctx.draftVersion.value.loaders.length === 0 && ctx.projectType.value !== 'modpack',
disabled: ctx.draftVersion.value.loaders.length === 0,
}
: {
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
disabled:
ctx.draftVersion.value.loaders.length === 0 && ctx.projectType.value !== 'modpack',
disabled: ctx.draftVersion.value.loaders.length === 0,
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}
-20
View File
@@ -13,10 +13,6 @@ export interface FieldConfig {
pattern?: string
validate?: (value: string) => string | null
autocomplete?: string
dependsOn?: {
field: string
value?: string | string[]
}
}
export interface RailConfig {
@@ -334,10 +330,6 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter PIX email',
}),
autocomplete: 'email',
dependsOn: {
field: 'pixAccountType',
value: 'EMAIL',
},
},
{
name: 'pixPhone',
@@ -349,10 +341,6 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: '+55...',
}),
autocomplete: 'tel',
dependsOn: {
field: 'pixAccountType',
value: 'PHONE',
},
},
{
name: 'branchCode',
@@ -364,10 +352,6 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter branch code',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'BANK_ACCOUNT',
},
},
{
name: 'documentNumber',
@@ -383,10 +367,6 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Brazilian tax identification number',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'DOCUMENT',
},
},
],
},
+1 -3
View File
@@ -8,8 +8,6 @@ SITE_URL=http://localhost:3000
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
LABRINTH_MEDAL_KEY=
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
RATE_LIMIT_IGNORE_KEY=feedbeef
DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
@@ -154,6 +152,6 @@ ARCHON_URL=none
MURALPAY_API_URL=https://api.muralpay.com
MURALPAY_API_KEY=none
MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
MURALPAY_SOURCE_ACCOUNT_ID=none
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
+1 -2
View File
@@ -8,7 +8,6 @@ SITE_URL=http://localhost:3000
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
LABRINTH_MEDAL_KEY=
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
RATE_LIMIT_IGNORE_KEY=feedbeef
@@ -164,6 +163,6 @@ ARCHON_URL=none
MURALPAY_API_URL=https://api-staging.muralpay.com
MURALPAY_API_KEY=none
MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
MURALPAY_SOURCE_ACCOUNT_ID=none
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id!: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Int4Array",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id AS \"thread_id: DBThreadId\"\n FROM threads\n WHERE mod_id = $1\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.jar AS \"jar?: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.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 m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ",
"query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.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 m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ",
"describe": {
"columns": [
{
@@ -20,21 +20,16 @@
},
{
"ordinal": 3,
"name": "jar?: String",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "file_path!: String",
"type_info": "Text"
},
{
"ordinal": 5,
"ordinal": 4,
"name": "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"ordinal": 5,
"name": "severity!: DelphiSeverity",
"type_info": {
"Custom": {
@@ -51,7 +46,7 @@
}
},
{
"ordinal": 7,
"ordinal": 6,
"name": "status!: DelphiStatus",
"type_info": {
"Custom": {
@@ -76,12 +71,11 @@
false,
false,
false,
true,
false,
false,
false,
null
]
},
"hash": "263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a"
"hash": "80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT 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 = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ) AS \"is_in_tech_review!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_in_tech_review!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_unflagged_issue_details!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
"query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
"describe": {
"columns": [
{
@@ -15,7 +15,6 @@
"Text",
"Text",
"Text",
"Text",
"Jsonb",
{
"Custom": {
@@ -36,5 +35,5 @@
false
]
},
"hash": "9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2"
"hash": "b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT\n didws.project_id,\n didws.key,\n $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.id = $2\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
},
"Int8"
]
},
"nullable": []
},
"hash": "b767ca57e4d8abf164a951ce77f1e721b955fc4f2a4d4ac196611bc8d6b04706"
}
@@ -1,29 +0,0 @@
{
"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::delphi_report_issue_status AS 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 upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\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 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 ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "all_found!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "upserted_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (m.id)\n m.id AS \"project_id: DBProjectId\",\n t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id, t.id\n ORDER BY m.id,\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MAX(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Int4Array",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
false
]
},
"hash": "f10a09a0fb0774dad4933e78db94bfb231020b356edbc58bdb6c5a11ad0fb4ac"
}
@@ -1,83 +1,83 @@
BEGIN TRANSACTION;
COPY public.categories (id, category, project_type, icon, header, ordering) FROM stdin;
10060 cursed 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7.5" width="10" height="14" rx="5"/><polyline points="2 12.5 4 14.5 7 14.5"/><polyline points="22 12.5 20 14.5 17 14.5"/><polyline points="3 21.5 5 18.5 7 17.5"/><polyline points="21 21.5 19 18.5 17 17.5"/><polyline points="3 8.5 5 10.5 7 11.5"/><polyline points="21 8.5 19 10.5 17 11.5"/><line x1="12" y1="7.5" x2="12" y2="21.5"/><path d="M15.38,8.82A3,3,0,0,0,16,7h0a3,3,0,0,0-3-3H11A3,3,0,0,0,8,7H8a3,3,0,0,0,.61,1.82"/><line x1="9" y1="4.5" x2="8" y2="2.5"/><line x1="15" y1="4.5" x2="16" y2="2.5"/></svg> categories 0
10061 locale 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg> features 0
10062 48x 3 resolutions 0
1001 technology 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg> categories 0
10016 challenging 2 <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> categories 0
1005 decoration 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> categories 0
1006 library 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> categories 0
1007 cursed 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7.5" width="10" height="14" rx="5"/><polyline points="2 12.5 4 14.5 7 14.5"/><polyline points="22 12.5 20 14.5 17 14.5"/><polyline points="3 21.5 5 18.5 7 17.5"/><polyline points="21 21.5 19 18.5 17 17.5"/><polyline points="3 8.5 5 10.5 7 11.5"/><polyline points="21 8.5 19 10.5 17 11.5"/><line x1="12" y1="7.5" x2="12" y2="21.5"/><path d="M15.38,8.82A3,3,0,0,0,16,7h0a3,3,0,0,0-3-3H11A3,3,0,0,0,8,7H8a3,3,0,0,0,.61,1.82"/><line x1="9" y1="4.5" x2="8" y2="2.5"/><line x1="15" y1="4.5" x2="16" y2="2.5"/></svg> categories 0
1002 adventure 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg> categories 0
10056 64x 3 resolutions 0
10055 32x 3 resolutions 0
10058 256x 3 resolutions 0
10059 512x+ 3 resolutions 0
10054 16x 3 resolutions 0
10063 path-tracing 4 <svg viewBox="0 0 24 24" style="" fill="none" stroke="currentColor" stroke-width="2"><path d="M2.977 19.17h16.222" style="" transform="translate(-.189 -.328) scale(1.09932)"/><path d="M3.889 3.259 12 19.17l5.749-11.277" style="" transform="translate(-1.192 -.328) scale(1.09932)"/><path d="M9.865 6.192h4.623v4.623" style="" transform="scale(1.09931) rotate(-18 20.008 .02)"/></svg> features 0
10064 realistic 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg> categories 0
10065 medium 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"></path><path d="M7 20v-4"></path><path d="M12 20v-8"></path></svg> performance impact 0
10066 low 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"></path><path d="M7 20v-4"></path></svg> performance impact 0
10067 high 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"></path><path d="M7 20v-4"></path><path d="M12 20v-8"></path><path d="M17 20V8"></path></svg> performance impact 0
10068 atmosphere 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M3 20a5 5 0 1 1 8.9-4H13a3 3 0 0 1 2 5.24"/><path d="M11 20v2"/><path d="M7 19v2"/></svg> features 0
10069 fantasy 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg> categories 0
10070 foliage 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="M12 22v-7l-2-2"/><path d="M17 8v.8A6 6 0 0 1 13.8 20v0H10v0A6.5 6.5 0 0 1 7 8h0a5 5 0 0 1 10 0Z"/><path d="m14 14-2 2"/></svg> features 0
10071 bloom 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2h8l4 10H4L8 2Z"/><path d="M12 12v6"/><path d="M8 22v-2c0-1.1.9-2 2-2h4a2 2 0 0 1 2 2v2H8Z"/></svg> features 0
10072 vanilla-like 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11"/><path d="M17 7A5 5 0 0 0 7 7"/><path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4"/></svg> categories 0
10073 cartoon 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg> categories 0
10074 potato 4 <svg viewBox="0 0 512 512" fill="currentColor" stroke="currentColor"><g><g><path d="M218.913,116.8c-6.4-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-6.4,4.8-11.2S222.113,120,218.913,116.8z"/></g></g><g><g><path d="M170.913,372.8c-6.4-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C175.713,379.2,174.113,376,170.913,372.8z"/></g></g><g><g><path d="M250.913,228.8c-4.8-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C255.713,235.2,254.113,232,250.913,228.8z"/></g></g><g><g><path d="M410.913,212.8c-4.8-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C415.713,219.2,414.113,216,410.913,212.8z"/></g></g><g><g><path d="M346.913,308.8c-4.8-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C351.713,315.2,350.113,312,346.913,308.8z"/></g></g><g><g><path d="M346.913,100.8c-6.4-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8s4.8-6.4,4.8-11.2S350.113,104,346.913,100.8z"/></g></g><g><g><path d="M503.713,142.4c-28.8-136-179.2-142.4-208-142.4c-4.8,0-9.6,0-16,0c-67.2,1.6-132.8,36.8-187.2,97.6 c-60.8,67.2-96,155.2-91.2,227.2c8,126.4,70.4,187.2,192,187.2c115.2,0,201.6-33.6,256-100.8 C513.313,331.2,519.713,219.2,503.713,142.4z M423.713,392c-48,59.2-126.4,89.6-230.4,89.6s-152-48-160-158.4 c-4.8-64,28.8-144,83.2-203.2c48-54.4,107.2-84.8,164.8-88c4.8,0,9.6,0,14.4,0c140.8,0,171.2,89.6,176,116.8 C486.113,219.2,481.313,320,423.713,392z"/></g></g></svg> performance impact 0
10075 shadows 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg> features 0
10076 pbr 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg> features 0
10077 semi-realistic 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> categories 0
10078 cursed 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7.5" width="10" height="14" rx="5"/><polyline points="2 12.5 4 14.5 7 14.5"/><polyline points="22 12.5 20 14.5 17 14.5"/><polyline points="3 21.5 5 18.5 7 17.5"/><polyline points="21 21.5 19 18.5 17 17.5"/><polyline points="3 8.5 5 10.5 7 11.5"/><polyline points="21 8.5 19 10.5 17 11.5"/><line x1="12" y1="7.5" x2="12" y2="21.5"/><path d="M15.38,8.82A3,3,0,0,0,16,7h0a3,3,0,0,0-3-3H11A3,3,0,0,0,8,7H8a3,3,0,0,0,.61,1.82"/><line x1="9" y1="4.5" x2="8" y2="2.5"/><line x1="15" y1="4.5" x2="16" y2="2.5"/></svg> categories 0
10079 reflections 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style=""><path d="m3 7 5 5-5 5V7"/><path d="m21 7-5 5 5 5V7"/><path d="M12 20v2"/><path d="M12 14v2"/><path d="M12 8v2"/><path d="M12 2v2"/></svg> features 0
10080 screenshot 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="9" cy="9" r="2"></circle><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path></svg> performance impact 0
10081 colored-lighting 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="7.618" cy="6.578" r="5.422" style="" transform="translate(3.143 .726) scale(1.16268)"/><circle cx="7.618" cy="6.578" r="5.422" style="" transform="translate(-.862 7.796) scale(1.16268)"/><circle cx="7.618" cy="6.578" r="5.422" style="" transform="translate(7.148 7.796) scale(1.16268)"/></svg> features 0
10057 128x 3 resolutions 0
10030 economy 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg> categories 0
10031 management 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> categories 0
10026 optimization 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> categories 0
10027 mobs 1 <svg xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" clip-rule="evenodd" viewBox="0 0 24 24">\n <path fill="none" d="M0 0h24v24H0z"/>\n <path fill="none" stroke="currentColor" stroke-width="2" d="M3 3h18v18H3z"/>\n <path stroke="currentColor" fill="currentColor" d="M6 6h4v4H6zm8 0h4v4h-4zm-4 4h4v2h2v6h-2v-2h-4v2H8v-6h2v-2Z"/>\n</svg> categories 0
10028 transportation 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg> categories 0
10024 kitchen-sink 2 <svg viewBox="0 0 24 24" xml:space="preserve"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19.9 14-1.4 4.9c-.3 1-1.1 1.7-2.1 1.7H7.6c-.9 0-1.8-.7-2.1-1.7L4.1 14h15.8zM12 10V4.5M12 4.5c0-1.2.9-2.1 2.1-2.1M14.1 2.4c1.2 0 2.1.9 2.1 2.1M22.2 12c0 .6-.2 1.1-.6 1.4-.4.4-.9.6-1.4.6H3.8c-1.1 0-2-.9-2-2 0-.6.2-1.1.6-1.4.4-.4.9-.6 1.4-.6h16.4c1.1 0 2 .9 2 2z"/></g><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M16.2 7.2h0"/></svg> categories 0
10044 blocks 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> features 0
10043 audio 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg> features 0
10021 combat 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.573 20.038L3.849 7.913 2.753 2.755 7.838 4.06 19.47 18.206l-1.898 1.832z"/><path d="M7.45 14.455l-3.043 3.661 1.887 1.843 3.717-3.25"/><path d="M16.75 10.82l3.333-2.913 1.123-5.152-5.091 1.28-2.483 2.985"/><path d="M21.131 16.602l-5.187 5.01 2.596-2.508 2.667 2.761"/><path d="M2.828 16.602l5.188 5.01-2.597-2.508-2.667 2.761"/></svg> categories 0
10022 adventure 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg> categories 0
10023 technology 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg> categories 0
10033 minigame 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg> categories 0
10034 combat 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.573 20.038L3.849 7.913 2.753 2.755 7.838 4.06 19.47 18.206l-1.898 1.832z"/><path d="M7.45 14.455l-3.043 3.661 1.887 1.843 3.717-3.25"/><path d="M16.75 10.82l3.333-2.913 1.123-5.152-5.091 1.28-2.483 2.985"/><path d="M21.131 16.602l-5.187 5.01 2.596-2.508 2.667 2.761"/><path d="M2.828 16.602l5.188 5.01-2.597-2.508-2.667 2.761"/></svg> categories 0
10035 decoration 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> categories 0
10036 modded 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"\\><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> categories 0
10047 environment 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"]><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> features 0
10046 entities 3 <svg xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" clip-rule="evenodd" viewBox="0 0 24 24">\n <path fill="none" d="M0 0h24v24H0z"/>\n <path fill="none" stroke="currentColor" stroke-width="2" d="M3 3h18v18H3z"/>\n <path stroke="currentColor" fill="currentColor" d="M6 6h4v4H6zm8 0h4v4h-4zm-4 4h4v2h2v6h-2v-2h-4v2H8v-6h2v-2Z"/>\n</svg> features 0
10032 game-mechanics 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg> categories 0
10041 utility 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg> categories 0
10045 core-shaders 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg> features 0
10040 tweaks 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> categories 0
10051 items 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> features 0
10052 models 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> features 0
10048 equipment 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> features 0
10049 fonts 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> features 0
10037 simplistic 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg> categories 0
10038 realistic 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> categories 0
10039 themed 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg> categories 0
1003 magic 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg> categories 0
1004 utility 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg> categories 0
10015 optimization 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> categories 0
1009 storage 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg> categories 0
10053 8x- 3 resolutions 0
10042 vanilla-like 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11"/><path d="M17 7A5 5 0 0 0 7 7"/><path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4"/></svg> categories 0
10010 food 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.27 21.7s9.87-3.5 12.73-6.36a4.5 4.5 0 0 0-6.36-6.37C5.77 11.84 2.27 21.7 2.27 21.7zM8.64 14l-2.05-2.04M15.34 15l-2.46-2.46"></path><path d="M22 9s-1.33-2-3.5-2C16.86 7 15 9 15 9s1.33 2 3.5 2S22 9 22 9z"></path><path d="M15 2s-2 1.33-2 3.5S15 9 15 9s2-1.84 2-3.5C17 3.33 15 2 15 2z"></path></svg> categories 0
10011 equipment 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.573 20.038L3.849 7.913 2.753 2.755 7.838 4.06 19.47 18.206l-1.898 1.832z"/><path d="M7.45 14.455l-3.043 3.661 1.887 1.843 3.717-3.25"/><path d="M16.75 10.82l3.333-2.913 1.123-5.152-5.091 1.28-2.483 2.985"/><path d="M21.131 16.602l-5.187 5.01 2.596-2.508 2.667 2.761"/><path d="M2.828 16.602l5.188 5.01-2.597-2.508-2.667 2.761"/></svg> categories 0
10050 gui 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> features 0
1008 worldgen 1 <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> categories 0
10017 multiplayer 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg> categories 0
10018 quests 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6"></rect><rect x="16" y="16" width="6" height="6"></rect><rect x="2" y="16" width="6" height="6"></rect><path d="M12 8v4m0 0H5v4m7-4h7v4"></path></svg> categories 0
10019 magic 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg> categories 0
10020 lightweight 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"></path><line x1="16" y1="8" x2="2" y2="22"></line><line x1="17.5" y1="15" x2="9" y2="15"></line></svg>\n categories 0
10029 social 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg> categories 0
60 cursed 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7.5" width="10" height="14" rx="5"/><polyline points="2 12.5 4 14.5 7 14.5"/><polyline points="22 12.5 20 14.5 17 14.5"/><polyline points="3 21.5 5 18.5 7 17.5"/><polyline points="21 21.5 19 18.5 17 17.5"/><polyline points="3 8.5 5 10.5 7 11.5"/><polyline points="21 8.5 19 10.5 17 11.5"/><line x1="12" y1="7.5" x2="12" y2="21.5"/><path d="M15.38,8.82A3,3,0,0,0,16,7h0a3,3,0,0,0-3-3H11A3,3,0,0,0,8,7H8a3,3,0,0,0,.61,1.82"/><line x1="9" y1="4.5" x2="8" y2="2.5"/><line x1="15" y1="4.5" x2="16" y2="2.5"/></svg> categories 0
61 locale 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg> features 0
62 48x 3 resolutions 0
1 technology 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg> categories 0
16 challenging 2 <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> categories 0
5 decoration 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> categories 0
6 library 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> categories 0
7 cursed 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7.5" width="10" height="14" rx="5"/><polyline points="2 12.5 4 14.5 7 14.5"/><polyline points="22 12.5 20 14.5 17 14.5"/><polyline points="3 21.5 5 18.5 7 17.5"/><polyline points="21 21.5 19 18.5 17 17.5"/><polyline points="3 8.5 5 10.5 7 11.5"/><polyline points="21 8.5 19 10.5 17 11.5"/><line x1="12" y1="7.5" x2="12" y2="21.5"/><path d="M15.38,8.82A3,3,0,0,0,16,7h0a3,3,0,0,0-3-3H11A3,3,0,0,0,8,7H8a3,3,0,0,0,.61,1.82"/><line x1="9" y1="4.5" x2="8" y2="2.5"/><line x1="15" y1="4.5" x2="16" y2="2.5"/></svg> categories 0
2 adventure 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg> categories 0
56 64x 3 resolutions 0
55 32x 3 resolutions 0
58 256x 3 resolutions 0
59 512x+ 3 resolutions 0
54 16x 3 resolutions 0
63 path-tracing 4 <svg viewBox="0 0 24 24" style="" fill="none" stroke="currentColor" stroke-width="2"><path d="M2.977 19.17h16.222" style="" transform="translate(-.189 -.328) scale(1.09932)"/><path d="M3.889 3.259 12 19.17l5.749-11.277" style="" transform="translate(-1.192 -.328) scale(1.09932)"/><path d="M9.865 6.192h4.623v4.623" style="" transform="scale(1.09931) rotate(-18 20.008 .02)"/></svg> features 0
64 realistic 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg> categories 0
65 medium 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"></path><path d="M7 20v-4"></path><path d="M12 20v-8"></path></svg> performance impact 0
66 low 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"></path><path d="M7 20v-4"></path></svg> performance impact 0
67 high 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"></path><path d="M7 20v-4"></path><path d="M12 20v-8"></path><path d="M17 20V8"></path></svg> performance impact 0
68 atmosphere 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M3 20a5 5 0 1 1 8.9-4H13a3 3 0 0 1 2 5.24"/><path d="M11 20v2"/><path d="M7 19v2"/></svg> features 0
69 fantasy 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg> categories 0
70 foliage 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="M12 22v-7l-2-2"/><path d="M17 8v.8A6 6 0 0 1 13.8 20v0H10v0A6.5 6.5 0 0 1 7 8h0a5 5 0 0 1 10 0Z"/><path d="m14 14-2 2"/></svg> features 0
71 bloom 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2h8l4 10H4L8 2Z"/><path d="M12 12v6"/><path d="M8 22v-2c0-1.1.9-2 2-2h4a2 2 0 0 1 2 2v2H8Z"/></svg> features 0
72 vanilla-like 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11"/><path d="M17 7A5 5 0 0 0 7 7"/><path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4"/></svg> categories 0
73 cartoon 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg> categories 0
74 potato 4 <svg viewBox="0 0 512 512" fill="currentColor" stroke="currentColor"><g><g><path d="M218.913,116.8c-6.4-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-6.4,4.8-11.2S222.113,120,218.913,116.8z"/></g></g><g><g><path d="M170.913,372.8c-6.4-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C175.713,379.2,174.113,376,170.913,372.8z"/></g></g><g><g><path d="M250.913,228.8c-4.8-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C255.713,235.2,254.113,232,250.913,228.8z"/></g></g><g><g><path d="M410.913,212.8c-4.8-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C415.713,219.2,414.113,216,410.913,212.8z"/></g></g><g><g><path d="M346.913,308.8c-4.8-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8c3.2-3.2,4.8-8,4.8-11.2C351.713,315.2,350.113,312,346.913,308.8z"/></g></g><g><g><path d="M346.913,100.8c-6.4-6.4-16-6.4-22.4,0c-3.2,3.2-4.8,6.4-4.8,11.2s1.6,8,4.8,11.2c3.2,3.2,8,4.8,11.2,4.8 c4.8,0,8-1.6,11.2-4.8s4.8-6.4,4.8-11.2S350.113,104,346.913,100.8z"/></g></g><g><g><path d="M503.713,142.4c-28.8-136-179.2-142.4-208-142.4c-4.8,0-9.6,0-16,0c-67.2,1.6-132.8,36.8-187.2,97.6 c-60.8,67.2-96,155.2-91.2,227.2c8,126.4,70.4,187.2,192,187.2c115.2,0,201.6-33.6,256-100.8 C513.313,331.2,519.713,219.2,503.713,142.4z M423.713,392c-48,59.2-126.4,89.6-230.4,89.6s-152-48-160-158.4 c-4.8-64,28.8-144,83.2-203.2c48-54.4,107.2-84.8,164.8-88c4.8,0,9.6,0,14.4,0c140.8,0,171.2,89.6,176,116.8 C486.113,219.2,481.313,320,423.713,392z"/></g></g></svg> performance impact 0
75 shadows 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg> features 0
76 pbr 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg> features 0
77 semi-realistic 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> categories 0
78 cursed 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7.5" width="10" height="14" rx="5"/><polyline points="2 12.5 4 14.5 7 14.5"/><polyline points="22 12.5 20 14.5 17 14.5"/><polyline points="3 21.5 5 18.5 7 17.5"/><polyline points="21 21.5 19 18.5 17 17.5"/><polyline points="3 8.5 5 10.5 7 11.5"/><polyline points="21 8.5 19 10.5 17 11.5"/><line x1="12" y1="7.5" x2="12" y2="21.5"/><path d="M15.38,8.82A3,3,0,0,0,16,7h0a3,3,0,0,0-3-3H11A3,3,0,0,0,8,7H8a3,3,0,0,0,.61,1.82"/><line x1="9" y1="4.5" x2="8" y2="2.5"/><line x1="15" y1="4.5" x2="16" y2="2.5"/></svg> categories 0
79 reflections 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style=""><path d="m3 7 5 5-5 5V7"/><path d="m21 7-5 5 5 5V7"/><path d="M12 20v2"/><path d="M12 14v2"/><path d="M12 8v2"/><path d="M12 2v2"/></svg> features 0
80 screenshot 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="9" cy="9" r="2"></circle><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path></svg> performance impact 0
81 colored-lighting 4 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="7.618" cy="6.578" r="5.422" style="" transform="translate(3.143 .726) scale(1.16268)"/><circle cx="7.618" cy="6.578" r="5.422" style="" transform="translate(-.862 7.796) scale(1.16268)"/><circle cx="7.618" cy="6.578" r="5.422" style="" transform="translate(7.148 7.796) scale(1.16268)"/></svg> features 0
57 128x 3 resolutions 0
30 economy 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg> categories 0
31 management 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> categories 0
26 optimization 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> categories 0
27 mobs 1 <svg xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" clip-rule="evenodd" viewBox="0 0 24 24">\n <path fill="none" d="M0 0h24v24H0z"/>\n <path fill="none" stroke="currentColor" stroke-width="2" d="M3 3h18v18H3z"/>\n <path stroke="currentColor" fill="currentColor" d="M6 6h4v4H6zm8 0h4v4h-4zm-4 4h4v2h2v6h-2v-2h-4v2H8v-6h2v-2Z"/>\n</svg> categories 0
28 transportation 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg> categories 0
24 kitchen-sink 2 <svg viewBox="0 0 24 24" xml:space="preserve"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19.9 14-1.4 4.9c-.3 1-1.1 1.7-2.1 1.7H7.6c-.9 0-1.8-.7-2.1-1.7L4.1 14h15.8zM12 10V4.5M12 4.5c0-1.2.9-2.1 2.1-2.1M14.1 2.4c1.2 0 2.1.9 2.1 2.1M22.2 12c0 .6-.2 1.1-.6 1.4-.4.4-.9.6-1.4.6H3.8c-1.1 0-2-.9-2-2 0-.6.2-1.1.6-1.4.4-.4.9-.6 1.4-.6h16.4c1.1 0 2 .9 2 2z"/></g><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M16.2 7.2h0"/></svg> categories 0
44 blocks 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> features 0
43 audio 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg> features 0
21 combat 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.573 20.038L3.849 7.913 2.753 2.755 7.838 4.06 19.47 18.206l-1.898 1.832z"/><path d="M7.45 14.455l-3.043 3.661 1.887 1.843 3.717-3.25"/><path d="M16.75 10.82l3.333-2.913 1.123-5.152-5.091 1.28-2.483 2.985"/><path d="M21.131 16.602l-5.187 5.01 2.596-2.508 2.667 2.761"/><path d="M2.828 16.602l5.188 5.01-2.597-2.508-2.667 2.761"/></svg> categories 0
22 adventure 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg> categories 0
23 technology 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg> categories 0
33 minigame 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg> categories 0
34 combat 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.573 20.038L3.849 7.913 2.753 2.755 7.838 4.06 19.47 18.206l-1.898 1.832z"/><path d="M7.45 14.455l-3.043 3.661 1.887 1.843 3.717-3.25"/><path d="M16.75 10.82l3.333-2.913 1.123-5.152-5.091 1.28-2.483 2.985"/><path d="M21.131 16.602l-5.187 5.01 2.596-2.508 2.667 2.761"/><path d="M2.828 16.602l5.188 5.01-2.597-2.508-2.667 2.761"/></svg> categories 0
35 decoration 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> categories 0
36 modded 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"\\><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> categories 0
47 environment 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"]><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> features 0
46 entities 3 <svg xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" clip-rule="evenodd" viewBox="0 0 24 24">\n <path fill="none" d="M0 0h24v24H0z"/>\n <path fill="none" stroke="currentColor" stroke-width="2" d="M3 3h18v18H3z"/>\n <path stroke="currentColor" fill="currentColor" d="M6 6h4v4H6zm8 0h4v4h-4zm-4 4h4v2h2v6h-2v-2h-4v2H8v-6h2v-2Z"/>\n</svg> features 0
32 game-mechanics 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg> categories 0
41 utility 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg> categories 0
45 core-shaders 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg> features 0
40 tweaks 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> categories 0
51 items 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> features 0
52 models 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> features 0
48 equipment 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> features 0
49 fonts 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> features 0
37 simplistic 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg> categories 0
38 realistic 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> categories 0
39 themed 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg> categories 0
3 magic 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg> categories 0
4 utility 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg> categories 0
15 optimization 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> categories 0
9 storage 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg> categories 0
53 8x- 3 resolutions 0
42 vanilla-like 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="" data-darkreader-inline-stroke=""><path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11"/><path d="M17 7A5 5 0 0 0 7 7"/><path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4"/></svg> categories 0
10 food 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.27 21.7s9.87-3.5 12.73-6.36a4.5 4.5 0 0 0-6.36-6.37C5.77 11.84 2.27 21.7 2.27 21.7zM8.64 14l-2.05-2.04M15.34 15l-2.46-2.46"></path><path d="M22 9s-1.33-2-3.5-2C16.86 7 15 9 15 9s1.33 2 3.5 2S22 9 22 9z"></path><path d="M15 2s-2 1.33-2 3.5S15 9 15 9s2-1.84 2-3.5C17 3.33 15 2 15 2z"></path></svg> categories 0
11 equipment 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.573 20.038L3.849 7.913 2.753 2.755 7.838 4.06 19.47 18.206l-1.898 1.832z"/><path d="M7.45 14.455l-3.043 3.661 1.887 1.843 3.717-3.25"/><path d="M16.75 10.82l3.333-2.913 1.123-5.152-5.091 1.28-2.483 2.985"/><path d="M21.131 16.602l-5.187 5.01 2.596-2.508 2.667 2.761"/><path d="M2.828 16.602l5.188 5.01-2.597-2.508-2.667 2.761"/></svg> categories 0
50 gui 3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> features 0
8 worldgen 1 <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> categories 0
17 multiplayer 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg> categories 0
18 quests 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6"></rect><rect x="16" y="16" width="6" height="6"></rect><rect x="2" y="16" width="6" height="6"></rect><path d="M12 8v4m0 0H5v4m7-4h7v4"></path></svg> categories 0
19 magic 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg> categories 0
20 lightweight 2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"></path><line x1="16" y1="8" x2="2" y2="22"></line><line x1="17.5" y1="15" x2="9" y2="15"></line></svg>\n categories 0
29 social 1 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg> categories 0
\.
@@ -1,2 +0,0 @@
ALTER TABLE mods
ADD COLUMN components JSONB NOT NULL DEFAULT '{}';
@@ -1,8 +0,0 @@
ALTER TABLE versions
ADD COLUMN components JSONB NOT NULL DEFAULT '{}';
-- extra metadata for the `minecraft_java_server` version component
CREATE TABLE minecraft_java_server_versions (
id bigint PRIMARY KEY REFERENCES versions(id),
modpack_id bigint REFERENCES versions(id)
);
@@ -1,60 +0,0 @@
INSERT INTO link_platforms (name, donation) VALUES ('store', false);
INSERT INTO project_types (id, name)
VALUES (7, 'minecraft_java_server');
INSERT INTO categories (header, category, project_type)
VALUES
('minecraft_server_gameplay', 'skyblock', 7),
('minecraft_server_gameplay', 'oneblock', 7),
('minecraft_server_gameplay', 'gens', 7),
('minecraft_server_gameplay', 'prison', 7),
('minecraft_server_gameplay', 'factions', 7),
('minecraft_server_gameplay', 'lifesteal', 7),
('minecraft_server_gameplay', 'anarchy', 7),
('minecraft_server_gameplay', 'towns', 7),
('minecraft_server_gameplay', 'vanilla-like', 7),
('minecraft_server_gameplay', 'rpg', 7),
('minecraft_server_gameplay', 'minigames', 7),
('minecraft_server_gameplay', 'racing', 7),
('minecraft_server_gameplay', 'battle-royale', 7),
('minecraft_server_gameplay', 'parkour', 7),
('minecraft_server_gameplay', 'bedwars', 7),
('minecraft_server_gameplay', 'kitpvp', 7),
('minecraft_server_gameplay', 'microgames', 7),
('minecraft_server_features', 'pokemon', 7),
('minecraft_server_features', 'teams', 7),
('minecraft_server_features', 'pvp', 7),
('minecraft_server_features', 'pve', 7),
('minecraft_server_features', 'op', 7),
('minecraft_server_features', 'economy', 7),
('minecraft_server_features', 'plots', 7),
('minecraft_server_features', 'questing', 7),
('minecraft_server_features', 'personal-worlds', 7),
('minecraft_server_features', 'media', 7),
('minecraft_server_features', 'bosses', 7),
('minecraft_server_features', 'classes', 7),
('minecraft_server_features', 'custom-content', 7),
('minecraft_server_features', 'dungeons', 7),
('minecraft_server_meta', 'crossplay', 7),
('minecraft_server_meta', 'offline-mode', 7),
('minecraft_server_meta', 'whitelisted', 7),
('minecraft_server_meta', 'keep-inventory', 7),
('minecraft_server_meta', 'network', 7),
('minecraft_server_meta', 'world-resets', 7),
('minecraft_server_meta', 'creative-mode', 7),
('minecraft_server_meta', 'hardcore-mode', 7),
('minecraft_server_meta', 'survival-mode', 7),
('minecraft_server_meta', 'adventure-mode', 7),
('minecraft_server_community', 'adventure-mode', 7),
('minecraft_server_community', 'smp', 7),
('minecraft_server_community', 'mmo', 7),
('minecraft_server_community', 'roleplay', 7),
('minecraft_server_community', 'social', 7),
('minecraft_server_community', 'creator-community', 7),
('minecraft_server_community', 'recording-smp', 7),
('minecraft_server_community', 'competitive', 7),
('minecraft_server_community', 'technical', 7);
@@ -1,2 +0,0 @@
ALTER TABLE delphi_report_issue_details
ADD COLUMN jar TEXT;
+1 -2
View File
@@ -2,7 +2,6 @@ use super::AuthProvider;
use crate::auth::AuthenticationError;
use crate::database::models::{DBUser, user_item};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::pats::Scopes;
use crate::models::users::User;
use crate::queue::session::AuthQueue;
@@ -147,7 +146,7 @@ where
user_item::DBUser::get_id(session.user_id, executor, redis)
.await?;
let rate_limit_ignore = &ENV.RATE_LIMIT_IGNORE_KEY;
let rate_limit_ignore = dotenvy::var("RATE_LIMIT_IGNORE_KEY")?;
if req
.headers()
.get("x-ratelimit-key")
+7 -7
View File
@@ -5,10 +5,9 @@ mod fetch;
pub use fetch::*;
use crate::env::ENV;
pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
init_client_with_database(&ENV.CLICKHOUSE_DATABASE).await
init_client_with_database(&dotenvy::var("CLICKHOUSE_DATABASE").unwrap())
.await
}
pub async fn init_client_with_database(
@@ -25,9 +24,9 @@ pub async fn init_client_with_database(
.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_url(dotenvy::var("CLICKHOUSE_URL").unwrap())
.with_user(dotenvy::var("CLICKHOUSE_USER").unwrap())
.with_password(dotenvy::var("CLICKHOUSE_PASSWORD").unwrap())
.with_validation(false)
};
@@ -36,7 +35,8 @@ pub async fn init_client_with_database(
.execute()
.await?;
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
let clickhouse_replicated =
dotenvy::var("CLICKHOUSE_REPLICATED").unwrap() == "true";
let cluster_line = if clickhouse_replicated {
"ON cluster '{cluster}'"
} else {
@@ -221,9 +221,6 @@ pub struct ReportIssueDetail {
/// This acts as a stable identifier for an issue detail, even across
/// different versions of the same file.
pub key: String,
/// If this detail was found inside a JAR embedded inside the scanned JAR,
/// this will point to the path of that JAR inside the outer JAR.
pub jar: Option<String>,
/// Name of the Java class path in which this issue was found.
pub file_path: String,
/// Decompiled, pretty-printed source of the Java class.
@@ -244,13 +241,12 @@ impl ReportIssueDetail {
) -> Result<DelphiReportIssueDetailsId, DatabaseError> {
Ok(DelphiReportIssueDetailsId(sqlx::query_scalar!(
"
INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
",
self.issue_id as DelphiReportIssueId,
self.key,
self.jar,
self.file_path,
self.decompiled_source,
sqlx::types::Json(&self.data) as Json<&HashMap<String, serde_json::Value>>,
+41 -12
View File
@@ -13,8 +13,6 @@ pub type PgTransaction<'c> = sqlx_tracing::Transaction<'c, Postgres>;
pub use sqlx_tracing::Acquire;
pub use sqlx_tracing::Executor;
use crate::env::ENV;
// pub type PgPool = sqlx::PgPool;
// pub type PgTransaction<'c> = sqlx::Transaction<'c, Postgres>;
// pub use sqlx::Acquire;
@@ -52,27 +50,57 @@ impl DerefMut for ReadOnlyPgPool {
pub async fn connect_all() -> Result<(PgPool, ReadOnlyPgPool), sqlx::Error> {
info!("Initializing database connection");
let database_url = &ENV.DATABASE_URL;
let database_url =
dotenvy::var("DATABASE_URL").expect("`DATABASE_URL` not in .env");
let acquire_timeout =
Duration::from_millis(ENV.DATABASE_ACQUIRE_TIMEOUT_MS);
dotenvy::var("DATABASE_ACQUIRE_TIMEOUT_MS")
.ok()
.map_or_else(
|| Duration::from_millis(30000),
|x| {
Duration::from_millis(x.parse::<u64>().expect(
"DATABASE_ACQUIRE_TIMEOUT_MS must be a valid u64",
))
},
);
let pool = PgPoolOptions::new()
.acquire_timeout(acquire_timeout)
.min_connections(ENV.DATABASE_MIN_CONNECTIONS)
.max_connections(ENV.DATABASE_MAX_CONNECTIONS)
.min_connections(
dotenvy::var("DATABASE_MIN_CONNECTIONS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(0),
)
.max_connections(
dotenvy::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(16),
)
.max_lifetime(Some(Duration::from_secs(60 * 60)))
.connect(database_url)
.connect(&database_url)
.await?;
let pool = PgPool::from(pool);
if !ENV.READONLY_DATABASE_URL.is_empty() {
if let Ok(url) = dotenvy::var("READONLY_DATABASE_URL") {
let ro_pool = PgPoolOptions::new()
.acquire_timeout(acquire_timeout)
.min_connections(ENV.READONLY_DATABASE_MIN_CONNECTIONS)
.max_connections(ENV.READONLY_DATABASE_MAX_CONNECTIONS)
.min_connections(
dotenvy::var("READONLY_DATABASE_MIN_CONNECTIONS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(0),
)
.max_connections(
dotenvy::var("READONLY_DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(1),
)
.max_lifetime(Some(Duration::from_secs(60 * 60)))
.connect(&ENV.READONLY_DATABASE_URL)
.connect(&url)
.await?;
let ro_pool = PgPool::from(ro_pool);
@@ -84,7 +112,8 @@ pub async fn connect_all() -> Result<(PgPool, ReadOnlyPgPool), sqlx::Error> {
}
pub async fn check_for_migrations() -> eyre::Result<()> {
let uri = &ENV.DATABASE_URL;
let uri =
dotenvy::var("DATABASE_URL").wrap_err("`DATABASE_URL` not in .env")?;
let uri = uri.as_str();
if !Postgres::database_exists(uri)
.await
+23 -7
View File
@@ -1,5 +1,3 @@
use crate::env::ENV;
use super::models::DatabaseError;
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{TimeZone, Utc};
@@ -44,26 +42,44 @@ impl RedisPool {
// testing pool uses a hashmap to mimic redis behaviour for very small data sizes (ie: tests)
// PANICS: production pool will panic if redis url is not set
pub fn new(meta_namespace: impl Into<Arc<str>>) -> Self {
let wait_timeout = Duration::from_millis(ENV.REDIS_WAIT_TIMEOUT_MS);
let wait_timeout =
dotenvy::var("REDIS_WAIT_TIMEOUT_MS").ok().map_or_else(
|| Duration::from_millis(15000),
|x| {
Duration::from_millis(
x.parse::<u64>().expect(
"REDIS_WAIT_TIMEOUT_MS must be a valid u64",
),
)
},
);
let url = &ENV.REDIS_URL;
let url = dotenvy::var("REDIS_URL").expect("Redis URL not set");
let pool = Config::from_url(url.clone())
.builder()
.expect("Error building Redis pool")
.max_size(ENV.REDIS_MAX_CONNECTIONS as usize)
.max_size(
dotenvy::var("REDIS_MAX_CONNECTIONS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(10000),
)
.wait_timeout(Some(wait_timeout))
.runtime(Runtime::Tokio1)
.build()
.expect("Redis connection failed");
let pool = RedisPool {
url: url.clone(),
url,
pool,
cache_list: Arc::new(DashMap::with_capacity(2048)),
meta_namespace: meta_namespace.into(),
};
let redis_min_connections = ENV.REDIS_MIN_CONNECTIONS;
let redis_min_connections = dotenvy::var("REDIS_MIN_CONNECTIONS")
.ok()
.and_then(|x| x.parse::<usize>().ok())
.unwrap_or(0);
let spawn_min_connections = (0..redis_min_connections)
.map(|_| {
let pool = pool.clone();
-280
View File
@@ -1,280 +0,0 @@
use std::{any::type_name, convert::Infallible, str::FromStr, sync::LazyLock};
use derive_more::{Deref, DerefMut};
use eyre::{Context, eyre};
use rust_decimal::Decimal;
use serde::de::DeserializeOwned;
macro_rules! vars {
(
$(
$field:ident: $ty:ty $(= $default:expr)?;
)*
) => {
#[derive(Debug)]
#[allow(
non_snake_case,
reason = "environment variables are UPPER_SNAKE_CASE",
)]
pub struct EnvVars {
$(
pub $field: $ty,
)*
}
impl EnvVars {
pub fn from_env() -> eyre::Result<Self> {
let mut err = eyre!("failed to read environment variables");
$(
#[expect(
non_snake_case,
reason = "environment variables are UPPER_SNAKE_CASE",
)]
#[allow(
unused_assignments,
unused_mut,
reason = "`default` is not used if there is no default",
)]
let $field: Option<$ty> = {
let mut default = None::<$ty>;
$( default = Some({ $default }.into()); )?
match parse_value::<$ty>(stringify!($field), default) {
Ok(value) => Some(value),
Err(source) => {
err = err.wrap_err(eyre!("{source:#}"));
None
}
}
};
)*
Ok(EnvVars {
$(
$field: match $field {
Some(value) => value,
None => return Err(err),
},
)*
})
}
}
};
}
pub static ENV: LazyLock<EnvVars> = LazyLock::new(|| {
EnvVars::from_env().unwrap_or_else(|err| panic!("{err:?}"))
});
fn parse_value<T>(key: &str, default: Option<T>) -> eyre::Result<T>
where
T: FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
match (dotenvy::var(key), default) {
(Ok(value), _) => value.parse::<T>().wrap_err_with(|| {
eyre!("`{key}` is not a valid `{}`", type_name::<T>())
}),
(Err(_), Some(default)) => Ok(default),
(Err(_), None) => Err(eyre!("`{key}` missing")),
}
}
pub fn init() -> eyre::Result<()> {
EnvVars::from_env()?;
LazyLock::force(&ENV);
Ok(())
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deref, DerefMut,
)]
pub struct Json<T: DeserializeOwned>(pub T);
impl<T: DeserializeOwned> FromStr for Json<T> {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s).map(Self)
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deref, DerefMut,
)]
pub struct StringCsv(pub Vec<String>);
impl FromStr for StringCsv {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v = s
.split(',')
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.collect::<Vec<_>>();
Ok(Self(v))
}
}
vars! {
SENTRY_ENVIRONMENT: String;
SENTRY_TRACES_SAMPLE_RATE: f32;
SITE_URL: String;
CDN_URL: String;
LABRINTH_ADMIN_KEY: String;
LABRINTH_MEDAL_KEY: String;
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String;
RATE_LIMIT_IGNORE_KEY: String;
DATABASE_URL: String;
MEILISEARCH_READ_ADDR: String;
MEILISEARCH_WRITE_ADDRS: StringCsv;
MEILISEARCH_KEY: String;
REDIS_URL: String;
BIND_ADDR: String;
SELF_ADDR: String;
LOCAL_INDEX_INTERVAL: u64;
VERSION_INDEX_INTERVAL: u64;
WHITELISTED_MODPACK_DOMAINS: Json<Vec<String>>;
ALLOWED_CALLBACK_URLS: Json<Vec<String>>;
ANALYTICS_ALLOWED_ORIGINS: Json<Vec<String>>;
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind;
// s3
S3_PUBLIC_BUCKET_NAME: String = "";
S3_PUBLIC_USES_PATH_STYLE_BUCKET: bool = false;
S3_PUBLIC_REGION: String = "";
S3_PUBLIC_URL: String = "";
S3_PUBLIC_ACCESS_TOKEN: String = "";
S3_PUBLIC_SECRET: String = "";
S3_PRIVATE_BUCKET_NAME: String = "";
S3_PRIVATE_USES_PATH_STYLE_BUCKET: bool = false;
S3_PRIVATE_REGION: String = "";
S3_PRIVATE_URL: String = "";
S3_PRIVATE_ACCESS_TOKEN: String = "";
S3_PRIVATE_SECRET: String = "";
// local
MOCK_FILE_PATH: String = "";
GITHUB_CLIENT_ID: String;
GITHUB_CLIENT_SECRET: String;
GITLAB_CLIENT_ID: String;
GITLAB_CLIENT_SECRET: String;
DISCORD_CLIENT_ID: String;
DISCORD_CLIENT_SECRET: String;
MICROSOFT_CLIENT_ID: String;
MICROSOFT_CLIENT_SECRET: String;
GOOGLE_CLIENT_ID: String;
GOOGLE_CLIENT_SECRET: String;
STEAM_API_KEY: String;
TREMENDOUS_API_URL: String;
TREMENDOUS_API_KEY: String;
TREMENDOUS_PRIVATE_KEY: String;
PAYPAL_API_URL: String;
PAYPAL_WEBHOOK_ID: String;
PAYPAL_CLIENT_ID: String;
PAYPAL_CLIENT_SECRET: String;
PAYPAL_NVP_USERNAME: String;
PAYPAL_NVP_PASSWORD: String;
PAYPAL_NVP_SIGNATURE: String;
PAYPAL_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
BREX_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
TREMENDOUS_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
MURAL_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
HCAPTCHA_SECRET: String;
SMTP_USERNAME: String;
SMTP_PASSWORD: String;
SMTP_HOST: String;
SMTP_PORT: u16;
SMTP_TLS: String;
SMTP_FROM_NAME: String;
SMTP_FROM_ADDRESS: String;
SITE_VERIFY_EMAIL_PATH: String;
SITE_RESET_PASSWORD_PATH: String;
SITE_BILLING_PATH: String;
SENDY_URL: String;
SENDY_LIST_ID: String;
SENDY_API_KEY: String;
CLICKHOUSE_REPLICATED: bool;
CLICKHOUSE_URL: String;
CLICKHOUSE_USER: String;
CLICKHOUSE_PASSWORD: String;
CLICKHOUSE_DATABASE: String;
FLAME_ANVIL_URL: String;
GOTENBERG_URL: String;
GOTENBERG_CALLBACK_BASE: String;
GOTENBERG_TIMEOUT: u64;
STRIPE_API_KEY: String;
STRIPE_WEBHOOK_SECRET: String;
ADITUDE_API_KEY: String;
PYRO_API_KEY: String;
BREX_API_URL: String;
BREX_API_KEY: String;
DELPHI_URL: String;
AVALARA_1099_API_URL: String;
AVALARA_1099_API_KEY: String;
AVALARA_1099_API_TEAM_ID: String;
AVALARA_1099_COMPANY_ID: String;
ANROK_API_URL: String;
ANROK_API_KEY: String;
COMPLIANCE_PAYOUT_THRESHOLD: String;
PAYOUT_ALERT_SLACK_WEBHOOK: String;
CLOUDFLARE_INTEGRATION: bool = false;
ARCHON_URL: String;
MURALPAY_API_URL: String;
MURALPAY_API_KEY: String;
MURALPAY_TRANSFER_API_KEY: String;
MURALPAY_SOURCE_ACCOUNT_ID: muralpay::AccountId = muralpay::AccountId(uuid::Uuid::nil());
DEFAULT_AFFILIATE_REVENUE_SPLIT: Decimal;
DATABASE_ACQUIRE_TIMEOUT_MS: u64 = 30000u64;
DATABASE_MIN_CONNECTIONS: u32 = 0u32;
DATABASE_MAX_CONNECTIONS: u32 = 16u32;
READONLY_DATABASE_URL: String = "";
READONLY_DATABASE_MIN_CONNECTIONS: u32 = 0u32;
READONLY_DATABASE_MAX_CONNECTIONS: u32 = 1u32;
REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64;
REDIS_MAX_CONNECTIONS: u32 = 10000u32;
REDIS_MIN_CONNECTIONS: usize = 0usize;
SEARCH_OPERATION_TIMEOUT: u64 = 300000u64;
SMTP_REPLY_TO_NAME: String = "";
SMTP_REPLY_TO_ADDRESS: String = "";
PUBLIC_DISCORD_WEBHOOK: String = "";
MODERATION_SLACK_WEBHOOK: String = "";
DELPHI_SLACK_WEBHOOK: String = "";
TREMENDOUS_CAMPAIGN_ID: String = "";
}
+3 -4
View File
@@ -9,8 +9,6 @@ use hex::ToHex;
use sha2::Digest;
use std::path::PathBuf;
use crate::env::ENV;
#[derive(Default)]
pub struct MockHost(());
@@ -56,7 +54,8 @@ impl FileHost for MockHost {
file_name: &str,
_expiry_secs: u32,
) -> Result<String, FileHostingError> {
Ok(format!("{}/private/{file_name}", ENV.CDN_URL))
let cdn_url = dotenvy::var("CDN_URL").unwrap();
Ok(format!("{cdn_url}/private/{file_name}"))
}
async fn delete_file(
@@ -78,7 +77,7 @@ fn get_file_path(
file_name: &str,
file_publicity: FileHostPublicity,
) -> PathBuf {
let mut path = PathBuf::from(ENV.MOCK_FILE_PATH.clone());
let mut path = PathBuf::from(dotenvy::var("MOCK_FILE_PATH").unwrap());
if matches!(file_publicity, FileHostPublicity::Private) {
path.push("private");
-24
View File
@@ -1,5 +1,3 @@
use std::str::FromStr;
use async_trait::async_trait;
use thiserror::Error;
@@ -65,25 +63,3 @@ pub trait FileHost {
file_publicity: FileHostPublicity,
) -> Result<DeleteFileData, FileHostingError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FileHostKind {
S3,
Local,
}
#[derive(Debug, Error)]
#[error("invalid file host kind")]
pub struct InvalidFileHostKind;
impl FromStr for FileHostKind {
type Err = InvalidFileHostKind;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"s3" => Self::S3,
"local" => Self::Local,
_ => return Err(InvalidFileHostKind),
})
}
}
+196 -7
View File
@@ -16,11 +16,11 @@ use util::gotenberg::GotenbergClient;
use crate::background_task::update_versions;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::env::ENV;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::util::anrok;
use crate::util::archon::ArchonClient;
use crate::util::env::{parse_strings_from_var, parse_var};
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
use sync::friends::handle_pubsub;
@@ -28,7 +28,6 @@ pub mod auth;
pub mod background_task;
pub mod clickhouse;
pub mod database;
pub mod env;
pub mod file_hosting;
pub mod models;
pub mod queue;
@@ -84,7 +83,10 @@ pub fn app_setup(
gotenberg_client: GotenbergClient,
enable_background_tasks: bool,
) -> LabrinthConfig {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
info!(
"Starting labrinth on {}",
dotenvy::var("BIND_ADDR").unwrap()
);
let automated_moderation_queue =
web::Data::new(AutomatedModerationQueue::default());
@@ -110,8 +112,9 @@ pub fn app_setup(
if enable_background_tasks {
// The interval in seconds at which the local database is indexed
// for searching. Defaults to 1 hour if unset.
let local_index_interval =
Duration::from_secs(ENV.LOCAL_INDEX_INTERVAL);
let local_index_interval = Duration::from_secs(
parse_var("LOCAL_INDEX_INTERVAL").unwrap_or(3600),
);
let pool_ref = pool.clone();
let search_config_ref = search_config.clone();
let redis_pool_ref = redis_pool.clone();
@@ -139,8 +142,9 @@ pub fn app_setup(
}
});
let version_index_interval =
Duration::from_secs(ENV.VERSION_INDEX_INTERVAL);
let version_index_interval = Duration::from_secs(
parse_var("VERSION_INDEX_INTERVAL").unwrap_or(1800),
);
let pool_ref = pool.clone();
let redis_pool_ref = redis_pool.clone();
scheduler.run(version_index_interval, move || {
@@ -345,3 +349,188 @@ pub fn utoipa_app_config(
.configure(routes::v3::utoipa_config)
.configure(routes::internal::utoipa_config);
}
// This is so that env vars not used immediately don't panic at runtime
pub fn check_env_vars() -> bool {
let mut failed = false;
fn check_var<T: std::str::FromStr>(var: &str) -> bool {
let check = parse_var::<T>(var).is_none();
if check {
warn!(
"Variable `{}` missing in dotenv or not of type `{}`",
var,
std::any::type_name::<T>()
);
}
check
}
failed |= check_var::<String>("SENTRY_ENVIRONMENT");
failed |= check_var::<String>("SENTRY_TRACES_SAMPLE_RATE");
failed |= check_var::<String>("SITE_URL");
failed |= check_var::<String>("CDN_URL");
failed |= check_var::<String>("LABRINTH_ADMIN_KEY");
failed |= check_var::<String>("LABRINTH_EXTERNAL_NOTIFICATION_KEY");
failed |= check_var::<String>("RATE_LIMIT_IGNORE_KEY");
failed |= check_var::<String>("DATABASE_URL");
failed |= check_var::<String>("MEILISEARCH_READ_ADDR");
failed |= check_var::<String>("MEILISEARCH_WRITE_ADDRS");
failed |= check_var::<String>("MEILISEARCH_KEY");
failed |= check_var::<String>("REDIS_URL");
failed |= check_var::<String>("BIND_ADDR");
failed |= check_var::<String>("SELF_ADDR");
failed |= check_var::<String>("STORAGE_BACKEND");
let storage_backend = dotenvy::var("STORAGE_BACKEND").ok();
match storage_backend.as_deref() {
Some("s3") => {
let mut check_var_set = |var_prefix| {
failed |= check_var::<String>(&format!(
"S3_{var_prefix}_BUCKET_NAME"
));
failed |= check_var::<bool>(&format!(
"S3_{var_prefix}_USES_PATH_STYLE_BUCKET"
));
failed |=
check_var::<String>(&format!("S3_{var_prefix}_REGION"));
failed |= check_var::<String>(&format!("S3_{var_prefix}_URL"));
failed |= check_var::<String>(&format!(
"S3_{var_prefix}_ACCESS_TOKEN"
));
failed |=
check_var::<String>(&format!("S3_{var_prefix}_SECRET"));
};
check_var_set("PUBLIC");
check_var_set("PRIVATE");
}
Some("local") => {
failed |= check_var::<String>("MOCK_FILE_PATH");
}
Some(backend) => {
warn!(
"Variable `STORAGE_BACKEND` contains an invalid value: {backend}. Expected \"s3\" or \"local\"."
);
failed |= true;
}
_ => {
warn!("Variable `STORAGE_BACKEND` is not set!");
failed |= true;
}
}
failed |= check_var::<usize>("LOCAL_INDEX_INTERVAL");
failed |= check_var::<usize>("VERSION_INDEX_INTERVAL");
if parse_strings_from_var("WHITELISTED_MODPACK_DOMAINS").is_none() {
warn!(
"Variable `WHITELISTED_MODPACK_DOMAINS` missing in dotenv or not a json array of strings"
);
failed |= true;
}
if parse_strings_from_var("ALLOWED_CALLBACK_URLS").is_none() {
warn!(
"Variable `ALLOWED_CALLBACK_URLS` missing in dotenv or not a json array of strings"
);
failed |= true;
}
failed |= check_var::<String>("GITHUB_CLIENT_ID");
failed |= check_var::<String>("GITHUB_CLIENT_SECRET");
failed |= check_var::<String>("GITLAB_CLIENT_ID");
failed |= check_var::<String>("GITLAB_CLIENT_SECRET");
failed |= check_var::<String>("DISCORD_CLIENT_ID");
failed |= check_var::<String>("DISCORD_CLIENT_SECRET");
failed |= check_var::<String>("MICROSOFT_CLIENT_ID");
failed |= check_var::<String>("MICROSOFT_CLIENT_SECRET");
failed |= check_var::<String>("GOOGLE_CLIENT_ID");
failed |= check_var::<String>("GOOGLE_CLIENT_SECRET");
failed |= check_var::<String>("STEAM_API_KEY");
failed |= check_var::<String>("TREMENDOUS_API_URL");
failed |= check_var::<String>("TREMENDOUS_API_KEY");
failed |= check_var::<String>("TREMENDOUS_PRIVATE_KEY");
failed |= check_var::<String>("PAYPAL_API_URL");
failed |= check_var::<String>("PAYPAL_WEBHOOK_ID");
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
failed |= check_var::<String>("PAYPAL_NVP_USERNAME");
failed |= check_var::<String>("PAYPAL_NVP_PASSWORD");
failed |= check_var::<String>("PAYPAL_NVP_SIGNATURE");
failed |= check_var::<String>("HCAPTCHA_SECRET");
failed |= check_var::<String>("SMTP_USERNAME");
failed |= check_var::<String>("SMTP_PASSWORD");
failed |= check_var::<String>("SMTP_HOST");
failed |= check_var::<u16>("SMTP_PORT");
failed |= check_var::<String>("SMTP_TLS");
failed |= check_var::<String>("SMTP_FROM_NAME");
failed |= check_var::<String>("SMTP_FROM_ADDRESS");
failed |= check_var::<String>("SITE_VERIFY_EMAIL_PATH");
failed |= check_var::<String>("SITE_RESET_PASSWORD_PATH");
failed |= check_var::<String>("SITE_BILLING_PATH");
failed |= check_var::<String>("SENDY_URL");
failed |= check_var::<String>("SENDY_LIST_ID");
failed |= check_var::<String>("SENDY_API_KEY");
if parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS").is_none() {
warn!(
"Variable `ANALYTICS_ALLOWED_ORIGINS` missing in dotenv or not a json array of strings"
);
failed |= true;
}
failed |= check_var::<bool>("CLICKHOUSE_REPLICATED");
failed |= check_var::<String>("CLICKHOUSE_URL");
failed |= check_var::<String>("CLICKHOUSE_USER");
failed |= check_var::<String>("CLICKHOUSE_PASSWORD");
failed |= check_var::<String>("CLICKHOUSE_DATABASE");
failed |= check_var::<String>("FLAME_ANVIL_URL");
failed |= check_var::<String>("GOTENBERG_URL");
failed |= check_var::<String>("GOTENBERG_CALLBACK_BASE");
failed |= check_var::<String>("GOTENBERG_TIMEOUT");
failed |= check_var::<String>("STRIPE_API_KEY");
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
failed |= check_var::<String>("ADITUDE_API_KEY");
failed |= check_var::<String>("PYRO_API_KEY");
failed |= check_var::<String>("BREX_API_URL");
failed |= check_var::<String>("BREX_API_KEY");
failed |= check_var::<String>("DELPHI_URL");
failed |= check_var::<String>("AVALARA_1099_API_URL");
failed |= check_var::<String>("AVALARA_1099_API_KEY");
failed |= check_var::<String>("AVALARA_1099_API_TEAM_ID");
failed |= check_var::<String>("AVALARA_1099_COMPANY_ID");
failed |= check_var::<String>("ANROK_API_URL");
failed |= check_var::<String>("ANROK_API_KEY");
failed |= check_var::<String>("COMPLIANCE_PAYOUT_THRESHOLD");
failed |= check_var::<String>("PAYOUT_ALERT_SLACK_WEBHOOK");
failed |= check_var::<String>("ARCHON_URL");
failed |= check_var::<String>("MURALPAY_API_URL");
failed |= check_var::<String>("MURALPAY_API_KEY");
failed |= check_var::<String>("MURALPAY_TRANSFER_API_KEY");
failed |= check_var::<String>("MURALPAY_SOURCE_ACCOUNT_ID");
failed |= check_var::<String>("DEFAULT_AFFILIATE_REVENUE_SPLIT");
failed
}
+47 -40
View File
@@ -4,21 +4,21 @@ use actix_web::{App, HttpServer};
use actix_web_prom::PrometheusMetricsBuilder;
use clap::Parser;
use labrinth::app_config;
use labrinth::background_task::BackgroundTask;
use labrinth::database::redis::RedisPool;
use labrinth::env::ENV;
use labrinth::file_hosting::{FileHostKind, S3BucketConfig, S3Host};
use labrinth::file_hosting::{S3BucketConfig, S3Host};
use labrinth::queue::email::EmailQueue;
use labrinth::search;
use labrinth::util::anrok;
use labrinth::util::env::parse_var;
use labrinth::util::gotenberg::GotenbergClient;
use labrinth::util::ratelimit::rate_limit_middleware;
use labrinth::utoipa_app_config;
use labrinth::{app_config, env};
use labrinth::{clickhouse, database, file_hosting};
use labrinth::{check_env_vars, clickhouse, database, file_hosting};
use std::ffi::CStr;
use std::sync::Arc;
use tracing::{Instrument, info, info_span};
use tracing::{Instrument, error, info, info_span};
use tracing_actix_web::TracingLogger;
use utoipa::OpenApi;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
@@ -58,7 +58,11 @@ fn main() -> std::io::Result<()> {
color_eyre::install().expect("failed to install `color-eyre`");
dotenvy::dotenv().ok();
modrinth_util::log::init().expect("failed to initialize logging");
env::init().expect("failed to initialize environment variables");
if check_env_vars() {
error!("Some environment variables are missing!");
std::process::exit(1);
}
// Sentry must be set up before the async runtime is started
// <https://docs.sentry.io/platforms/rust/guides/actix-web/>
@@ -66,8 +70,11 @@ fn main() -> std::io::Result<()> {
// Has no effect if not set.
let sentry = sentry::init(sentry::ClientOptions {
release: sentry::release_name!(),
traces_sample_rate: ENV.SENTRY_TRACES_SAMPLE_RATE,
environment: Some((&ENV.SENTRY_ENVIRONMENT).into()),
traces_sample_rate: dotenvy::var("SENTRY_TRACES_SAMPLE_RATE")
.unwrap()
.parse()
.expect("failed to parse `SENTRY_TRACES_SAMPLE_RATE` as number"),
environment: Some(dotenvy::var("SENTRY_ENVIRONMENT").unwrap().into()),
..Default::default()
});
if sentry.is_enabled() {
@@ -92,7 +99,10 @@ async fn app() -> std::io::Result<()> {
.unwrap();
if args.run_background_task.is_none() {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
info!(
"Starting labrinth on {}",
dotenvy::var("BIND_ADDR").unwrap()
);
if !args.no_migrations {
database::check_for_migrations()
@@ -109,44 +119,40 @@ async fn app() -> std::io::Result<()> {
// Redis connector
let redis_pool = RedisPool::new("");
let storage_backend = ENV.STORAGE_BACKEND;
let storage_backend =
dotenvy::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string());
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
match storage_backend {
FileHostKind::S3 => {
let not_empty = |v: &str| -> String {
assert!(!v.is_empty(), "S3 env var is empty");
v.to_string()
match storage_backend.as_str() {
"s3" => {
let config_from_env = |bucket_type| S3BucketConfig {
name: parse_var(&format!("S3_{bucket_type}_BUCKET_NAME"))
.unwrap(),
uses_path_style: parse_var(&format!(
"S3_{bucket_type}_USES_PATH_STYLE_BUCKET"
))
.unwrap(),
region: parse_var(&format!("S3_{bucket_type}_REGION"))
.unwrap(),
url: parse_var(&format!("S3_{bucket_type}_URL")).unwrap(),
access_token: parse_var(&format!(
"S3_{bucket_type}_ACCESS_TOKEN"
))
.unwrap(),
secret: parse_var(&format!("S3_{bucket_type}_SECRET"))
.unwrap(),
};
Arc::new(
S3Host::new(
S3BucketConfig {
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
uses_path_style: ENV
.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
region: not_empty(&ENV.S3_PUBLIC_REGION),
url: not_empty(&ENV.S3_PUBLIC_URL),
access_token: not_empty(
&ENV.S3_PUBLIC_ACCESS_TOKEN,
),
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
},
S3BucketConfig {
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
uses_path_style: ENV
.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
region: not_empty(&ENV.S3_PRIVATE_REGION),
url: not_empty(&ENV.S3_PRIVATE_URL),
access_token: not_empty(
&ENV.S3_PRIVATE_ACCESS_TOKEN,
),
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
},
config_from_env("PUBLIC"),
config_from_env("PRIVATE"),
)
.unwrap(),
)
}
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
"local" => Arc::new(file_hosting::MockHost::new()),
_ => panic!("Invalid storage backend specified. Aborting startup!"),
};
info!("Initializing clickhouse connection");
@@ -154,7 +160,8 @@ async fn app() -> std::io::Result<()> {
let search_config = search::SearchConfig::new(None);
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
let stripe_client =
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
let anrok_client = anrok::Client::from_env().unwrap();
let email_queue =
@@ -265,7 +272,7 @@ async fn app() -> std::io::Result<()> {
.into_app()
.configure(|cfg| app_config(cfg, labrinth_config.clone()))
})
.bind(&ENV.BIND_ADDR)?
.bind(dotenvy::var("BIND_ADDR").unwrap())?
.run()
.await
}
+6 -2
View File
@@ -1,4 +1,6 @@
use crate::{env::ENV, models::v2::projects::LegacySideType};
use crate::{
models::v2::projects::LegacySideType, util::env::parse_strings_from_var,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -42,7 +44,9 @@ fn validate_download_url(
return Err(validator::ValidationError::new("invalid URL"));
}
if !ENV.WHITELISTED_MODPACK_DOMAINS.contains(
let domains = parse_strings_from_var("WHITELISTED_MODPACK_DOMAINS")
.unwrap_or_default();
if !domains.contains(
&url.domain()
.ok_or_else(|| validator::ValidationError::new("invalid URL"))?
.to_string(),
+2 -3
View File
@@ -12,7 +12,6 @@ use crate::database::models::{
};
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::billing::{
ChargeStatus, ChargeType, PaymentPlatform, Price, PriceDuration,
ProductMetadata, SubscriptionMetadata, SubscriptionStatus,
@@ -914,10 +913,10 @@ async fn unprovision_subscriptions(
let res = reqwest::Client::new()
.post(format!(
"{}/modrinth/v0/servers/{}/suspend",
ENV.ARCHON_URL,
dotenvy::var("ARCHON_URL")?,
server_id
))
.header("X-Master-Key", &ENV.PYRO_API_KEY)
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.json(&serde_json::json!({
"reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring {
"cancelled"
+9 -10
View File
@@ -5,7 +5,6 @@ use crate::database::models::notifications_template_item::NotificationTemplate;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::notifications::{NotificationBody, NotificationType};
use crate::models::v3::notifications::{
NotificationChannel, NotificationDeliveryStatus,
@@ -37,16 +36,16 @@ impl Mailer {
) -> Result<Arc<AsyncSmtpTransport<Tokio1Executor>>, MailError> {
let maybe_transport = match self {
Mailer::Uninitialized => {
let username = &ENV.SMTP_USERNAME;
let password = &ENV.SMTP_PASSWORD;
let host = &ENV.SMTP_HOST;
let port = ENV.SMTP_PORT;
let username = dotenvy::var("SMTP_USERNAME")?;
let password = dotenvy::var("SMTP_PASSWORD")?;
let host = dotenvy::var("SMTP_HOST")?;
let port =
dotenvy::var("SMTP_PORT")?.parse::<u16>().unwrap_or(465);
let creds = (!username.is_empty()).then(|| {
Credentials::new(username.clone(), password.clone())
});
let creds = (!username.is_empty())
.then(|| Credentials::new(username, password));
let tls_setting = match ENV.SMTP_TLS.as_str() {
let tls_setting = match dotenvy::var("SMTP_TLS")?.as_str() {
"none" => Tls::None,
"opportunistic_start_tls" => Tls::Opportunistic(
TlsParameters::new(host.to_string())?,
@@ -66,7 +65,7 @@ impl Mailer {
};
let mut mailer =
AsyncSmtpTransport::<Tokio1Executor>::relay(host)?
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
.port(port)
.tls(tls_setting);
+17 -17
View File
@@ -8,7 +8,6 @@ use crate::database::models::{
DBOrganization, DBProject, DBUser, DatabaseError,
};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::v3::notifications::NotificationBody;
use crate::routes::ApiError;
use crate::util::error::Context;
@@ -97,18 +96,10 @@ pub struct MailingIdentity {
impl MailingIdentity {
pub fn from_env() -> dotenvy::Result<Self> {
Ok(Self {
from_name: ENV.SMTP_FROM_NAME.clone(),
from_address: ENV.SMTP_FROM_ADDRESS.clone(),
reply_name: if ENV.SMTP_REPLY_TO_NAME.is_empty() {
None
} else {
Some(ENV.SMTP_REPLY_TO_NAME.clone())
},
reply_address: if ENV.SMTP_REPLY_TO_ADDRESS.is_empty() {
None
} else {
Some(ENV.SMTP_REPLY_TO_ADDRESS.clone())
},
from_name: dotenvy::var("SMTP_FROM_NAME")?,
from_address: dotenvy::var("SMTP_FROM_ADDRESS")?,
reply_name: dotenvy::var("SMTP_REPLY_TO_NAME").ok(),
reply_address: dotenvy::var("SMTP_REPLY_TO_ADDRESS").ok(),
})
}
}
@@ -567,7 +558,9 @@ async fn collect_template_variables(
NotificationBody::ResetPassword { flow } => {
let url = format!(
"{}/{}?flow={}",
ENV.SITE_URL, ENV.SITE_RESET_PASSWORD_PATH, flow
dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_RESET_PASSWORD_PATH")?,
flow
);
map.insert(RESETPASSWORD_URL, url);
@@ -578,7 +571,9 @@ async fn collect_template_variables(
NotificationBody::VerifyEmail { flow } => {
let url = format!(
"{}/{}?flow={}",
ENV.SITE_URL, ENV.SITE_VERIFY_EMAIL_PATH, flow
dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_VERIFY_EMAIL_PATH")?,
flow
);
map.insert(VERIFYEMAIL_URL, url);
@@ -608,7 +603,11 @@ async fn collect_template_variables(
}
NotificationBody::PaymentFailed { amount, service } => {
let url = format!("{}/{}", ENV.SITE_URL, ENV.SITE_BILLING_PATH,);
let url = format!(
"{}/{}",
dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_BILLING_PATH")?,
);
let mut map = HashMap::new();
map.insert(PAYMENTFAILED_AMOUNT, amount.clone());
@@ -749,7 +748,8 @@ async fn dynamic_email_body(
key: &str,
) -> Result<String, ApiError> {
get_or_set_cached_dynamic_html(redis, key, || async {
let site_url = &ENV.SITE_URL;
let site_url = dotenvy::var("SITE_URL")
.wrap_internal_err("SITE_URL is not set")?;
let site_url = site_url.trim_end_matches('/');
let url = format!("{site_url}/_internal/templates/email/dynamic");
+9 -10
View File
@@ -4,7 +4,6 @@ use crate::database::PgPool;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::ids::ProjectId;
use crate::models::notifications::NotificationBody;
use crate::models::pack::{PackFile, PackFileHash, PackFormat};
@@ -455,7 +454,7 @@ impl AutomatedModerationQueue {
let client = reqwest::Client::new();
let res = client
.post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL))
.post(format!("{}/v1/fingerprints", dotenvy::var("FLAME_ANVIL_URL")?))
.json(&serde_json::json!({
"fingerprints": hashes.iter().filter_map(|x| x.3).collect::<Vec<u32>>()
}))
@@ -554,11 +553,11 @@ impl AutomatedModerationQueue {
continue;
}
let flame_projects = if flame_files.is_empty() {
Vec::new()
} else {
let res = client
.post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
let flame_projects = if flame_files.is_empty() {
Vec::new()
} else {
let res = client
.post(format!("{}v1/mods", dotenvy::var("FLAME_ANVIL_URL")?))
.json(&serde_json::json!({
"modIds": flame_files.iter().map(|x| x.1).collect::<Vec<_>>()
}))
@@ -665,16 +664,16 @@ impl AutomatedModerationQueue {
.insert_many(members.into_iter().map(|x| x.user_id).collect(), &mut transaction, &redis)
.await?;
if !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
if let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") {
crate::util::webhook::send_slack_project_webhook(
project.inner.id.into(),
&pool,
&redis,
&ENV.MODERATION_SLACK_WEBHOOK,
webhook_url,
Some(
format!(
"*<{}/user/AutoMod|AutoMod>* changed project status from *{}* to *Rejected*",
ENV.SITE_URL,
dotenvy::var("SITE_URL")?,
&project.inner.status.as_friendly_str(),
)
.to_string(),
+5 -2
View File
@@ -1,5 +1,4 @@
use crate::database::PgPool;
use crate::env::ENV;
use chrono::{Datelike, Duration, TimeZone, Utc};
use eyre::{Context, Result, eyre};
use rust_decimal::{Decimal, dec};
@@ -63,7 +62,11 @@ pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> {
.await
.wrap_err("failed to fetch charges awaiting affiliate payout")?;
let default_affiliate_revenue_split = ENV.DEFAULT_AFFILIATE_REVENUE_SPLIT;
let default_affiliate_revenue_split =
dotenvy::var("DEFAULT_AFFILIATE_REVENUE_SPLIT")
.wrap_err("no env var `DEFAULT_AFFILIATE_REVENUE_SPLIT`")?
.parse::<Decimal>()
.wrap_err("`DEFAULT_AFFILIATE_REVENUE_SPLIT` is not a decimal")?;
let (
mut insert_usap_charges,
@@ -8,7 +8,6 @@ use serde_json::json;
use crate::{
database::models::payout_item::DBPayout,
env::ENV,
models::payouts::{
PayoutMethod, PayoutMethodFee, PayoutMethodType, PayoutStatus,
TremendousCurrency, TremendousDetails, TremendousForexResponse,
@@ -211,7 +210,7 @@ pub(super) async fn execute(
"products": [
method_id,
],
"campaign_id": ENV.TREMENDOUS_CAMPAIGN_ID.as_str(),
"campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?,
}]
});
+43 -31
View File
@@ -2,13 +2,13 @@ use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::payouts_values_notifications;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::payouts::{
PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodType,
TremendousForexResponse,
};
use crate::models::projects::MonetizationStatus;
use crate::routes::ApiError;
use crate::util::env::env_var;
use crate::util::error::Context;
use crate::util::webhook::{
PayoutSourceAlertType, send_slack_payout_source_alert_webhook,
@@ -76,18 +76,21 @@ impl Default for PayoutsQueue {
}
pub fn create_muralpay_client() -> Result<muralpay::Client> {
Ok(muralpay::Client::new(
&ENV.MURALPAY_API_URL,
ENV.MURALPAY_API_KEY.as_str(),
ENV.MURALPAY_TRANSFER_API_KEY.as_str(),
))
let api_url = env_var("MURALPAY_API_URL")?;
let api_key = env_var("MURALPAY_API_KEY")?;
let transfer_api_key = env_var("MURALPAY_TRANSFER_API_KEY")?;
Ok(muralpay::Client::new(api_url, api_key, transfer_api_key))
}
pub fn create_muralpay() -> Result<MuralPayConfig> {
let client = create_muralpay_client()?;
let source_account_id = env_var("MURALPAY_SOURCE_ACCOUNT_ID")?
.parse::<muralpay::AccountId>()
.wrap_err("failed to parse source account ID")?;
Ok(MuralPayConfig {
client,
source_account_id: ENV.MURALPAY_SOURCE_ACCOUNT_ID,
source_account_id,
})
}
@@ -182,8 +185,11 @@ impl PayoutsQueue {
let mut creds = self.credential.write().await;
let client = reqwest::Client::new();
let combined_key =
format!("{}:{}", ENV.PAYPAL_CLIENT_ID, ENV.PAYPAL_CLIENT_SECRET);
let combined_key = format!(
"{}:{}",
dotenvy::var("PAYPAL_CLIENT_ID")?,
dotenvy::var("PAYPAL_CLIENT_SECRET")?
);
let formatted_key = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(combined_key)
@@ -200,7 +206,7 @@ impl PayoutsQueue {
}
let credential: PaypalCredential = client
.post(format!("{}oauth2/token", ENV.PAYPAL_API_URL))
.post(format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?))
.header("Accept", "application/json")
.header("Accept-Language", "en_US")
.header("Authorization", formatted_key)
@@ -268,7 +274,7 @@ impl PayoutsQueue {
if no_api_prefix.unwrap_or(false) {
path.to_string()
} else {
format!("{}{path}", ENV.PAYPAL_API_URL)
format!("{}{path}", dotenvy::var("PAYPAL_API_URL")?)
},
)
.header(
@@ -349,10 +355,13 @@ impl PayoutsQueue {
) -> Result<X, ApiError> {
let client = reqwest::Client::new();
let mut request = client
.request(method, format!("{}{path}", ENV.TREMENDOUS_API_URL))
.request(
method,
format!("{}{path}", dotenvy::var("TREMENDOUS_API_URL")?),
)
.header(
"Authorization",
format!("Bearer {}", ENV.TREMENDOUS_API_KEY),
format!("Bearer {}", dotenvy::var("TREMENDOUS_API_KEY")?),
);
if let Some(body) = body {
@@ -502,8 +511,8 @@ impl PayoutsQueue {
let client = reqwest::Client::new();
let res = client
.get(format!("{}accounts/cash", ENV.BREX_API_URL))
.bearer_auth(&ENV.BREX_API_KEY)
.get(format!("{}accounts/cash", dotenvy::var("BREX_API_URL")?))
.bearer_auth(&dotenvy::var("BREX_API_KEY")?)
.send()
.await?
.json::<BrexResponse>()
@@ -529,16 +538,16 @@ impl PayoutsQueue {
pub async fn get_paypal_balance() -> Result<Option<AccountBalance>, ApiError>
{
let api_username = &ENV.PAYPAL_NVP_USERNAME;
let api_password = &ENV.PAYPAL_NVP_PASSWORD;
let api_signature = &ENV.PAYPAL_NVP_SIGNATURE;
let api_username = dotenvy::var("PAYPAL_NVP_USERNAME")?;
let api_password = dotenvy::var("PAYPAL_NVP_PASSWORD")?;
let api_signature = dotenvy::var("PAYPAL_NVP_SIGNATURE")?;
let mut params = HashMap::new();
params.insert("METHOD", "GetBalance");
params.insert("VERSION", "204");
params.insert("USER", api_username);
params.insert("PWD", api_password);
params.insert("SIGNATURE", api_signature);
params.insert("USER", &api_username);
params.insert("PWD", &api_password);
params.insert("SIGNATURE", &api_signature);
params.insert("RETURNALLCURRENCIES", "1");
let endpoint = "https://api-3t.paypal.com/nvp";
@@ -861,7 +870,7 @@ pub async fn make_aditude_request(
) -> Result<Vec<AditudePoints>, ApiError> {
let request = reqwest::Client::new()
.post("https://cloud.aditude.io/api/public/insights/metrics")
.bearer_auth(&ENV.ADITUDE_API_KEY)
.bearer_auth(&dotenvy::var("ADITUDE_API_KEY")?)
.json(&serde_json::json!({
"metrics": metrics,
"range": range,
@@ -1317,25 +1326,25 @@ pub async fn insert_bank_balances_and_webhook(
if inserted {
check_balance_with_webhook(
"paypal",
ENV.PAYPAL_BALANCE_ALERT_THRESHOLD,
"PAYPAL_BALANCE_ALERT_THRESHOLD",
paypal_result,
)
.await?;
check_balance_with_webhook(
"brex",
ENV.BREX_BALANCE_ALERT_THRESHOLD,
"BREX_BALANCE_ALERT_THRESHOLD",
brex_result,
)
.await?;
check_balance_with_webhook(
"tremendous",
ENV.TREMENDOUS_BALANCE_ALERT_THRESHOLD,
"TREMENDOUS_BALANCE_ALERT_THRESHOLD",
tremendous_result,
)
.await?;
check_balance_with_webhook(
"mural",
ENV.MURAL_BALANCE_ALERT_THRESHOLD,
"MURAL_BALANCE_ALERT_THRESHOLD",
mural_result,
)
.await?;
@@ -1348,11 +1357,14 @@ pub async fn insert_bank_balances_and_webhook(
async fn check_balance_with_webhook(
source: &str,
threshold: u64,
threshold_env_var_name: &str,
result: Result<Option<AccountBalance>, ApiError>,
) -> Result<Option<AccountBalance>, ApiError> {
let maybe_threshold = if threshold > 0 { Some(threshold) } else { None };
let payout_alert_webhook = &ENV.PAYOUT_ALERT_SLACK_WEBHOOK;
let maybe_threshold = dotenvy::var(threshold_env_var_name)
.ok()
.and_then(|x| x.parse::<u64>().ok())
.filter(|x| *x != 0);
let payout_alert_webhook = dotenvy::var("PAYOUT_ALERT_SLACK_WEBHOOK")?;
match &result {
Ok(Some(account_balance)) => {
@@ -1367,7 +1379,7 @@ async fn check_balance_with_webhook(
threshold,
current_balance: available,
},
payout_alert_webhook,
&payout_alert_webhook,
)
.await?;
}
@@ -1382,7 +1394,7 @@ async fn check_balance_with_webhook(
source: source.to_owned(),
display_error: error.to_string(),
},
payout_alert_webhook,
&payout_alert_webhook,
)
.await?;
}
+6 -5
View File
@@ -1,13 +1,13 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::analytics::{PageView, Playtime};
use crate::models::pats::Scopes;
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::env::parse_strings_from_var;
use actix_web::{HttpRequest, HttpResponse};
use actix_web::{post, web};
use serde::Deserialize;
@@ -73,10 +73,11 @@ pub async fn page_view_ingest(
})?;
let url_origin = url.origin().ascii_serialization();
let is_valid_url_origin = ENV
.ANALYTICS_ALLOWED_ORIGINS
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
let is_valid_url_origin =
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
.unwrap_or_default()
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
if !is_valid_url_origin {
return Err(ApiError::InvalidInput(
@@ -1,7 +1,6 @@
use std::{collections::HashMap, net::Ipv4Addr, sync::Arc};
use crate::database::PgPool;
use crate::env::ENV;
use crate::{
auth::get_user_from_headers,
database::{
@@ -14,7 +13,10 @@ use crate::{
},
queue::{analytics::AnalyticsQueue, session::AuthQueue},
routes::analytics::FILTERED_HEADERS,
util::{date::get_current_tenths_of_ms, error::Context},
util::{
date::get_current_tenths_of_ms, env::parse_strings_from_var,
error::Context,
},
};
use actix_web::{HttpRequest, delete, get, patch, post, put, web};
use ariadne::ids::UserId;
@@ -68,10 +70,11 @@ async fn ingest_click(
})?;
let url_origin = url.origin().ascii_serialization();
let is_valid_url_origin = ENV
.ANALYTICS_ALLOWED_ORIGINS
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
let is_valid_url_origin =
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
.unwrap_or_default()
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
if !is_valid_url_origin {
return Err(ApiError::InvalidInput(
+11 -12
View File
@@ -12,7 +12,6 @@ use crate::database::models::{
};
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::billing::{
Charge, ChargeStatus, ChargeType, PaymentPlatform, Price, PriceDuration,
Product, ProductMetadata, ProductPrice, SubscriptionMetadata,
@@ -1438,7 +1437,7 @@ pub async fn active_servers(
pool: web::Data<PgPool>,
query: web::Query<ActiveServersQuery>,
) -> Result<HttpResponse, ApiError> {
let master_key = &ENV.PYRO_API_KEY;
let master_key = dotenvy::var("PYRO_API_KEY")?;
if req
.head()
@@ -1627,7 +1626,7 @@ pub async fn stripe_webhook(
if let Ok(event) = Webhook::construct_event(
&payload,
stripe_signature,
&ENV.STRIPE_WEBHOOK_SECRET,
&dotenvy::var("STRIPE_WEBHOOK_SECRET")?,
) {
struct PaymentIntentMetadata {
pub user_item: crate::database::models::user_item::DBUser,
@@ -2037,23 +2036,23 @@ pub async fn stripe_webhook(
client
.post(format!(
"{}/modrinth/v0/servers/{}/unsuspend",
ENV.ARCHON_URL,
dotenvy::var("ARCHON_URL")?,
id
))
.header("X-Master-Key", &ENV.PYRO_API_KEY)
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.send()
.await?
.error_for_status()?;
client
.post(format!(
"{}/modrinth/v0/servers/{}/reallocate",
ENV.ARCHON_URL,
id
))
"{}/modrinth/v0/servers/{}/reallocate",
dotenvy::var("ARCHON_URL")?,
id
))
.header(
"X-Master-Key",
&ENV.PYRO_API_KEY,
dotenvy::var("PYRO_API_KEY")?,
)
.json(&body)
.send()
@@ -2115,9 +2114,9 @@ pub async fn stripe_webhook(
let res = client
.post(format!(
"{}/modrinth/v0/servers/create",
ENV.ARCHON_URL,
dotenvy::var("ARCHON_URL")?,
))
.header("X-Master-Key", &ENV.PYRO_API_KEY)
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.json(&serde_json::json!({
"user_id": to_base62(metadata.user_item.id.0 as u64),
"name": server_name,
+14 -148
View File
@@ -1,7 +1,6 @@
use std::{collections::HashMap, fmt::Write, sync::LazyLock, time::Instant};
use crate::database::PgPool;
use crate::env::ENV;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
@@ -59,42 +58,15 @@ static DELPHI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.unwrap()
});
/// Type of [`DelphiReportIssueDetails::key`].
///
/// Delphi may provide `null` for the key, but we require a key for storing
/// issue details in the database, since detail verdicts are keyed by
/// (project id, issue detail key). Keys are opaque strings generated by Delphi
/// which refer to some "unique location" in a JAR file, such that subsequent
/// Delphi scans of different JARs with the same issue detail will result in
/// having the same key.
///
/// If Delphi doesn't provide us with a key, we generate a random one.
#[derive(Debug, Clone)]
pub struct IssueDetailKey(pub String);
impl<'de> Deserialize<'de> for IssueDetailKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
let value = value.unwrap_or_else(|| {
format!("<no-key-{:016x}>", rand::random::<u64>())
});
Ok(Self(value))
}
}
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
struct DelphiReportIssueDetails {
pub file: String,
pub key: IssueDetailKey,
pub jar: Option<String>,
pub key: String,
pub data: HashMap<String, serde_json::Value>,
pub severity: DelphiSeverity,
}
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
struct DelphiReport {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
@@ -117,7 +89,7 @@ impl DelphiReport {
pool: &PgPool,
redis: &RedisPool,
) -> Result<(), ApiError> {
let webhook_url = ENV.DELPHI_SLACK_WEBHOOK.clone();
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let mut message_header =
format!("⚠️ Suspicious traces found at {}", self.url);
@@ -143,7 +115,7 @@ impl DelphiReport {
self.project_id,
pool,
redis,
&webhook_url,
webhook_url,
Some(message_header),
)
.await
@@ -232,34 +204,11 @@ async fn ingest_report_deserialized(
.await
.wrap_internal_err("failed to check if pending issue details exist")?;
let issue_detail_keys = report
.issues
.values()
.flatten()
.map(|issue_detail| issue_detail.key.0.clone())
.collect::<Vec<_>>();
let has_unflagged_issue_details = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1
FROM unnest($2::text[]) AS incoming(detail_key)
LEFT JOIN delphi_issue_detail_verdicts didv
ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key
WHERE didv.project_id IS NULL
) AS "has_unflagged_issue_details!"
"#,
DBProjectId::from(report.project_id) as _,
&issue_detail_keys
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("failed to check if report has unflagged issue details")?;
let should_enter_tech_review = !record.pending_issue_details_exist
&& has_unflagged_issue_details.has_unflagged_issue_details;
if should_enter_tech_review {
if record.pending_issue_details_exist {
info!(
"File's project already has pending issue details, is not entering tech review queue"
);
} else {
info!("File's project is entering tech review queue");
ThreadMessageBuilder {
@@ -271,10 +220,6 @@ async fn ingest_report_deserialized(
.insert(&mut transaction)
.await
.wrap_internal_err("failed to add entering tech review message")?;
} else {
info!(
"File's project is not entering tech review queue (already pending or no new unflagged issue details)"
);
}
// TODO: Currently, the way we determine if an issue is in tech review or not
@@ -286,7 +231,7 @@ async fn ingest_report_deserialized(
// This is undesirable, but we can't rework the database schema to fix it
// right now. As a hack, we add a dummy report issue which blocks the
// project from exiting the tech review queue.
if should_enter_tech_review {
{
let dummy_issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
report_id,
@@ -299,7 +244,6 @@ async fn ingest_report_deserialized(
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id: dummy_issue_id,
key: "".into(),
jar: None,
file_path: "".into(),
decompiled_source: None,
data: HashMap::new(),
@@ -330,8 +274,7 @@ async fn ingest_report_deserialized(
ReportIssueDetail {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id,
key: issue_detail.key.0,
jar: issue_detail.jar,
key: issue_detail.key,
file_path: issue_detail.file,
decompiled_source: decompiled_source.cloned().flatten(),
data: issue_detail.data,
@@ -374,7 +317,7 @@ pub async fn run(
);
DELPHI_CLIENT
.post(&ENV.DELPHI_URL)
.post(dotenvy::var("DELPHI_URL")?)
.json(&serde_json::json!({
"url": file_data.url,
"project_id": ProjectId(file_data.project_id.0 as u64),
@@ -389,83 +332,6 @@ pub async fn run(
Ok(HttpResponse::NoContent().finish())
}
pub async fn is_project_in_tech_review(
project_id: DBProjectId,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, ApiError> {
let row = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.project_id = $1
AND didws.status = 'pending'
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
) AS "is_in_tech_review!"
"#,
project_id as _,
)
.fetch_one(exec)
.await
.wrap_internal_err("failed to fetch project tech review state")?;
Ok(row.is_in_tech_review)
}
pub async fn send_tech_review_exit_file_deleted_message(
project_id: DBProjectId,
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<(), ApiError> {
let thread = sqlx::query!(
r#"
SELECT id AS "thread_id: DBThreadId"
FROM threads
WHERE mod_id = $1
LIMIT 1
"#,
project_id as _,
)
.fetch_optional(&mut *txn)
.await
.wrap_internal_err("failed to fetch thread for tech review exit message")?;
if let Some(thread) = thread {
ThreadMessageBuilder {
author_id: None,
body: MessageBody::TechReviewExitFileDeleted,
thread_id: thread.thread_id,
hide_identity: false,
}
.insert(txn)
.await
.wrap_internal_err("failed to add tech review exit message")?;
}
Ok(())
}
pub async fn send_tech_review_exit_file_deleted_message_if_exited(
project_id: DBProjectId,
was_in_tech_review: bool,
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<(), ApiError> {
if !was_in_tech_review {
return Ok(());
}
let is_still_in_tech_review =
is_project_in_tech_review(project_id, &mut *txn).await?;
if !is_still_in_tech_review {
send_tech_review_exit_file_deleted_message(project_id, txn).await?;
}
Ok(())
}
#[post("run")]
async fn _run(
req: HttpRequest,
@@ -541,7 +407,7 @@ async fn issue_type_schema(
&cache_entry
.insert((
DELPHI_CLIENT
.get(format!("{}/schema", ENV.DELPHI_URL))
.get(format!("{}/schema", dotenvy::var("DELPHI_URL")?))
.send()
.await
.and_then(|res| res.error_for_status())
+45 -43
View File
@@ -8,7 +8,6 @@ use crate::database::models::flow_item::DBFlow;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::{DBUser, DBUserId};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
@@ -18,6 +17,7 @@ use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::session::issue_session;
use crate::util::captcha::check_hcaptcha;
use crate::util::env::parse_strings_from_var;
use crate::util::error::Context;
use crate::util::ext::get_image_ext;
use crate::util::img::upload_image_optimized;
@@ -257,41 +257,41 @@ impl AuthProvider {
&self,
state: String,
) -> Result<String, AuthenticationError> {
let self_addr = &ENV.SELF_ADDR;
let self_addr = dotenvy::var("SELF_ADDR")?;
let raw_redirect_uri = format!("{self_addr}/v2/auth/callback");
let redirect_uri = urlencoding::encode(&raw_redirect_uri);
Ok(match self {
AuthProvider::GitHub => {
let client_id = &ENV.GITHUB_CLIENT_ID;
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
format!(
"https://github.com/login/oauth/authorize?client_id={client_id}&prompt=select_account&state={state}&scope=read%3Auser%20user%3Aemail&redirect_uri={redirect_uri}",
)
}
AuthProvider::Discord => {
let client_id = &ENV.DISCORD_CLIENT_ID;
let client_id = dotenvy::var("DISCORD_CLIENT_ID")?;
format!(
"https://discord.com/api/oauth2/authorize?client_id={client_id}&state={state}&response_type=code&scope=identify%20email&redirect_uri={redirect_uri}"
)
}
AuthProvider::Microsoft => {
let client_id = &ENV.MICROSOFT_CLIENT_ID;
let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?;
format!(
"https://login.live.com/oauth20_authorize.srf?client_id={client_id}&response_type=code&scope=user.read&state={state}&prompt=select_account&redirect_uri={redirect_uri}"
)
}
AuthProvider::GitLab => {
let client_id = &ENV.GITLAB_CLIENT_ID;
let client_id = dotenvy::var("GITLAB_CLIENT_ID")?;
format!(
"https://gitlab.com/oauth/authorize?client_id={client_id}&state={state}&scope=read_user+profile+email&response_type=code&redirect_uri={redirect_uri}",
)
}
AuthProvider::Google => {
let client_id = &ENV.GOOGLE_CLIENT_ID;
let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?;
format!(
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&state={}&scope={}&response_type=code&redirect_uri={}",
@@ -317,8 +317,8 @@ impl AuthProvider {
)
}
AuthProvider::PayPal => {
let api_url = &ENV.PAYPAL_API_URL;
let client_id = &ENV.PAYPAL_CLIENT_ID;
let api_url = dotenvy::var("PAYPAL_API_URL")?;
let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?;
let auth_url = if api_url.contains("sandbox") {
"sandbox.paypal.com"
@@ -340,7 +340,8 @@ impl AuthProvider {
&self,
query: HashMap<String, String>,
) -> Result<String, AuthenticationError> {
let redirect_uri = format!("{}/v2/auth/callback", &ENV.SELF_ADDR);
let redirect_uri =
format!("{}/v2/auth/callback", dotenvy::var("SELF_ADDR")?);
#[derive(Deserialize)]
struct AccessToken {
@@ -352,8 +353,8 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = ENV.GITHUB_CLIENT_ID.as_str();
let client_secret = ENV.GITHUB_CLIENT_SECRET.as_str();
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?;
let url = format!(
"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={client_secret}&code={code}&redirect_uri={redirect_uri}"
@@ -373,12 +374,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = ENV.DISCORD_CLIENT_ID.as_str();
let client_secret = ENV.DISCORD_CLIENT_SECRET.as_str();
let client_id = dotenvy::var("DISCORD_CLIENT_ID")?;
let client_secret = dotenvy::var("DISCORD_CLIENT_SECRET")?;
let mut map = HashMap::new();
map.insert("client_id", client_id);
map.insert("client_secret", client_secret);
map.insert("client_id", &*client_id);
map.insert("client_secret", &*client_secret);
map.insert("code", code);
map.insert("grant_type", "authorization_code");
map.insert("redirect_uri", &redirect_uri);
@@ -398,12 +399,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = ENV.MICROSOFT_CLIENT_ID.as_str();
let client_secret = ENV.MICROSOFT_CLIENT_SECRET.as_str();
let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?;
let client_secret = dotenvy::var("MICROSOFT_CLIENT_SECRET")?;
let mut map = HashMap::new();
map.insert("client_id", client_id);
map.insert("client_secret", client_secret);
map.insert("client_id", &*client_id);
map.insert("client_secret", &*client_secret);
map.insert("code", code);
map.insert("grant_type", "authorization_code");
map.insert("redirect_uri", &redirect_uri);
@@ -423,12 +424,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = ENV.GITLAB_CLIENT_ID.as_str();
let client_secret = ENV.GITLAB_CLIENT_SECRET.as_str();
let client_id = dotenvy::var("GITLAB_CLIENT_ID")?;
let client_secret = dotenvy::var("GITLAB_CLIENT_SECRET")?;
let mut map = HashMap::new();
map.insert("client_id", client_id);
map.insert("client_secret", client_secret);
map.insert("client_id", &*client_id);
map.insert("client_secret", &*client_secret);
map.insert("code", code);
map.insert("grant_type", "authorization_code");
map.insert("redirect_uri", &redirect_uri);
@@ -448,12 +449,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = ENV.GOOGLE_CLIENT_ID.as_str();
let client_secret = ENV.GOOGLE_CLIENT_SECRET.as_str();
let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?;
let client_secret = dotenvy::var("GOOGLE_CLIENT_SECRET")?;
let mut map = HashMap::new();
map.insert("client_id", client_id);
map.insert("client_secret", client_secret);
map.insert("client_id", &*client_id);
map.insert("client_secret", &*client_secret);
map.insert("code", code);
map.insert("grant_type", "authorization_code");
map.insert("redirect_uri", &redirect_uri);
@@ -528,9 +529,9 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let api_url = ENV.PAYPAL_API_URL.as_str();
let client_id = ENV.PAYPAL_CLIENT_ID.as_str();
let client_secret = ENV.PAYPAL_CLIENT_SECRET.as_str();
let api_url = dotenvy::var("PAYPAL_API_URL")?;
let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?;
let client_secret = dotenvy::var("PAYPAL_CLIENT_SECRET")?;
let mut map = HashMap::new();
map.insert("code", code.as_str());
@@ -579,7 +580,9 @@ impl AuthProvider {
.get("x-oauth-client-id")
.and_then(|x| x.to_str().ok());
if client_id != Some(ENV.GITHUB_CLIENT_ID.as_str()) {
if client_id
!= Some(&*dotenvy::var("GITHUB_CLIENT_ID").unwrap())
{
return Err(AuthenticationError::InvalidClientId);
}
}
@@ -729,7 +732,7 @@ impl AuthProvider {
}
}
AuthProvider::Steam => {
let api_key = &ENV.STEAM_API_KEY;
let api_key = dotenvy::var("STEAM_API_KEY")?;
#[derive(Deserialize)]
struct SteamResponse {
@@ -794,7 +797,7 @@ impl AuthProvider {
pub country: String,
}
let api_url = &ENV.PAYPAL_API_URL;
let api_url = dotenvy::var("PAYPAL_API_URL")?;
let paypal_user: PayPalUser = reqwest::Client::new()
.get(format!(
@@ -1097,11 +1100,10 @@ pub async fn init(
let url =
url::Url::parse(&info.url).map_err(|_| AuthenticationError::Url)?;
let allowed_callback_urls =
parse_strings_from_var("ALLOWED_CALLBACK_URLS").unwrap_or_default();
let domain = url.host_str().ok_or(AuthenticationError::Url)?;
if !ENV
.ALLOWED_CALLBACK_URLS
.iter()
.any(|x| domain.ends_with(x))
if !allowed_callback_urls.iter().any(|x| domain.ends_with(x))
&& domain != "modrinth.com"
{
return Err(AuthenticationError::Url);
@@ -1394,9 +1396,9 @@ pub async fn delete_auth_provider(
pub async fn check_sendy_subscription(
email: &str,
) -> Result<bool, AuthenticationError> {
let url = &ENV.SENDY_URL;
let id = &ENV.SENDY_LIST_ID;
let api_key = &ENV.SENDY_API_KEY;
let url = dotenvy::var("SENDY_URL")?;
let id = dotenvy::var("SENDY_LIST_ID")?;
let api_key = dotenvy::var("SENDY_API_KEY")?;
if url.is_empty() || url == "none" {
tracing::info!(
@@ -1406,9 +1408,9 @@ pub async fn check_sendy_subscription(
}
let mut form = HashMap::new();
form.insert("api_key", api_key.as_str());
form.insert("api_key", &*api_key);
form.insert("email", email);
form.insert("list_id", id.as_str());
form.insert("list_id", &*id);
let client = reqwest::Client::new();
let response = client
+1 -3
View File
@@ -1,6 +1,5 @@
use std::{collections::HashMap, sync::LazyLock};
use crate::env::ENV;
use actix_web::{get, web};
use serde::{Deserialize, Serialize};
@@ -29,8 +28,7 @@ static GLOBALS: LazyLock<Globals> = LazyLock::new(|| Globals {
tax_compliance_thresholds: [(2025, 600), (2026, 2000)]
.into_iter()
.collect(),
captcha_enabled: !ENV.HCAPTCHA_SECRET.is_empty()
&& ENV.HCAPTCHA_SECRET != "none",
captcha_enabled: dotenvy::var("HCAPTCHA_SECRET").is_ok_and(|x| x != "none"),
});
/// Gets configured global non-secret variables for this backend instance.
@@ -42,7 +42,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.service(get_report)
.service(get_issue)
.service(submit_report)
.service(update_issue_details)
.service(update_issue_detail)
.service(add_report);
}
@@ -388,8 +388,6 @@ pub struct ProjectModerationInfo {
pub thread_id: ThreadId,
/// Project name.
pub name: String,
/// Current project status.
pub status: ProjectStatus,
/// The aggregated project typos of the versions of this project
#[serde(default)]
pub project_types: Vec<String>,
@@ -500,7 +498,6 @@ async fn fetch_project_reports(
drid.id AS "id!: DelphiReportIssueDetailsId",
drid.issue_id AS "issue_id!: DelphiReportIssueId",
drid.key AS "key!: String",
drid.jar AS "jar?: String",
drid.file_path AS "file_path!: String",
drid.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
drid.severity AS "severity!: DelphiSeverity",
@@ -564,7 +561,6 @@ async fn fetch_project_reports(
id: d.id,
issue_id: d.issue_id,
key: d.key,
jar: d.jar,
file_path: d.file_path,
decompiled_source: None,
data: d.data.0,
@@ -702,9 +698,9 @@ async fn search_projects(
let rows = sqlx::query!(
r#"
SELECT
SELECT DISTINCT ON (m.id)
m.id AS "project_id: DBProjectId",
MIN(t.id) AS "thread_id!: DBThreadId"
t.id AS "thread_id: DBThreadId"
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
INNER JOIN versions v ON v.mod_id = m.id
@@ -738,14 +734,12 @@ async fn search_projects(
OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))
OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))
)
GROUP BY m.id
ORDER BY
CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,
CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,
CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,
CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,
-- tie-breaker: oldest reports
MIN(dr.created) ASC
GROUP BY m.id, t.id
ORDER BY m.id,
CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,
CASE WHEN $3 = 'created_desc' THEN MAX(dr.created) ELSE TO_TIMESTAMP(0) END DESC,
CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,
CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC
LIMIT $1 OFFSET $2
"#,
limit,
@@ -837,7 +831,6 @@ async fn search_projects(
id,
thread_id: project.thread_id,
name: project.name,
status: project.status,
project_types: project.project_types,
icon_url: project.icon_url,
},
@@ -1126,8 +1119,6 @@ async fn submit_report(
/// See [`update_issue`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateIssue {
/// ID of the issue detail to update.
pub detail_id: DelphiReportIssueDetailsId,
/// What the moderator has decided the outcome of this issue is.
pub verdict: DelphiVerdict,
}
@@ -1140,13 +1131,14 @@ pub struct UpdateIssue {
security(("bearer_auth" = [])),
responses((status = NO_CONTENT))
)]
#[patch("/issue-detail")]
async fn update_issue_details(
#[patch("/issue-detail/{id}")]
async fn update_issue_detail(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
update_reqs: web::Json<Vec<UpdateIssue>>,
update_req: web::Json<UpdateIssue>,
path: web::Path<(DelphiReportIssueDetailsId,)>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
@@ -1156,76 +1148,44 @@ async fn update_issue_details(
Scopes::PROJECT_WRITE,
)
.await?;
let (issue_detail_id,) = path.into_inner();
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
let updates = update_reqs.into_inner();
let detail_ids = updates.iter().map(|u| u.detail_id.0).collect::<Vec<_>>();
let verdicts = updates
.iter()
.map(|u| match u.verdict {
DelphiVerdict::Safe => "safe".to_string(),
DelphiVerdict::Unsafe => "unsafe".to_string(),
})
.collect::<Vec<_>>();
let record = sqlx::query!(
let status = match update_req.verdict {
DelphiVerdict::Safe => DelphiStatus::Safe,
DelphiVerdict::Unsafe => DelphiStatus::Unsafe,
};
let results = sqlx::query!(
r#"
WITH incoming AS (
SELECT *
FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY
AS u(detail_id, verdict, ord)
),
resolved AS (
SELECT
i.ord,
didws.project_id,
didws.key AS detail_key,
i.verdict::delphi_report_issue_status AS verdict
FROM incoming i
INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
-- see delphi.rs todo comment
dri.issue_type != '__dummy'
),
validated AS (
SELECT
(SELECT COUNT(*) FROM incoming) AS incoming_count,
(SELECT COUNT(*) FROM resolved) AS resolved_count
),
upserted AS (
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
)
SELECT DISTINCT ON (project_id, detail_key)
project_id,
detail_key,
verdict
FROM resolved
ORDER BY project_id, detail_key, ord DESC
ON CONFLICT (project_id, detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
RETURNING 1
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
)
SELECT
(v.incoming_count = v.resolved_count) AS "all_found!",
(SELECT COUNT(*) FROM upserted) AS "upserted_count!"
FROM validated v
didws.project_id,
didws.key,
$1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.id = $2
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
ON CONFLICT (project_id, detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
"#,
&detail_ids,
&verdicts,
status as _,
issue_detail_id as _,
)
.fetch_one(&mut txn)
.execute(&mut txn)
.await
.wrap_internal_err("failed to update issue details")?;
if !record.all_found {
.wrap_internal_err("failed to update issue detail")?;
if results.rows_affected() == 0 {
return Err(ApiError::Request(eyre!("issue detail does not exist")));
}
+2 -2
View File
@@ -4,11 +4,11 @@ use crate::database::models::session_item::DBSession;
use crate::database::models::session_item::SessionBuilder;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::pats::Scopes;
use crate::models::sessions::Session;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::env::parse_var;
use actix_web::http::header::AUTHORIZATION;
use actix_web::web::{Data, ServiceConfig, scope};
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
@@ -41,7 +41,7 @@ pub async fn get_session_metadata(
req: &HttpRequest,
) -> Result<SessionMetadata, AuthenticationError> {
let conn_info = req.connection_info().clone();
let ip_addr = if ENV.CLOUDFLARE_INTEGRATION {
let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {
+5 -2
View File
@@ -1,8 +1,8 @@
use crate::database::models::DelphiReportIssueDetailsId;
use crate::env::ENV;
use crate::file_hosting::FileHostingError;
use crate::routes::analytics::{page_view_ingest, playtime_ingest};
use crate::util::cors::default_cors;
use crate::util::env::parse_strings_from_var;
use actix_cors::Cors;
use actix_files::Files;
use actix_web::http::StatusCode;
@@ -40,7 +40,10 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
.wrap(
Cors::default()
.allowed_origin_fn(|origin, _req_head| {
let allowed_origins = &ENV.ANALYTICS_ALLOWED_ORIGINS;
let allowed_origins =
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
.unwrap_or_default();
allowed_origins.contains(&"*".to_string())
|| allowed_origins.contains(
&origin
+5 -2
View File
@@ -1,7 +1,6 @@
use std::collections::HashMap;
use crate::database::PgPool;
use crate::env::ENV;
use actix_web::{HttpRequest, HttpResponse, get, web};
use serde::{Deserialize, Serialize};
@@ -95,7 +94,11 @@ pub async fn forge_updates(
}
let mut response = ForgeUpdates {
homepage: format!("{}/mod/{}", ENV.SITE_URL, id),
homepage: format!(
"{}/mod/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
id
),
promos: HashMap::new(),
};
+5 -4
View File
@@ -4,7 +4,6 @@ use crate::database::PgPool;
use crate::database::models::DBUserId;
use crate::database::models::{generate_payout_id, users_compliance};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::ids::PayoutId;
use crate::models::pats::Scopes;
use crate::models::payouts::{PayoutMethodType, PayoutStatus, Withdrawal};
@@ -213,7 +212,7 @@ pub async fn paypal_webhook(
\"webhook_id\": \"{}\",
\"webhook_event\": {body}
}}",
ENV.PAYPAL_WEBHOOK_ID,
dotenvy::var("PAYPAL_WEBHOOK_ID")?
)),
None,
)
@@ -323,7 +322,7 @@ pub async fn tremendous_webhook(
})?;
let mut mac: Hmac<Sha256> = Hmac::new_from_slice(
ENV.TREMENDOUS_PRIVATE_KEY.as_bytes(),
dotenvy::var("TREMENDOUS_PRIVATE_KEY")?.as_bytes(),
)
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
mac.update(body.as_bytes());
@@ -1115,7 +1114,9 @@ async fn update_compliance_status(
}
fn tax_compliance_payout_threshold() -> Option<Decimal> {
ENV.COMPLIANCE_PAYOUT_THRESHOLD.parse().ok()
dotenvy::var("COMPLIANCE_PAYOUT_THRESHOLD")
.ok()
.and_then(|s| s.parse().ok())
}
#[derive(Deserialize)]
+7 -19
View File
@@ -12,7 +12,6 @@ use crate::database::models::{
use crate::database::redis::RedisPool;
use crate::database::{self, models as db_models};
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models;
use crate::models::ids::{ProjectId, VersionId};
@@ -28,7 +27,6 @@ use crate::models::threads::MessageBody;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::delphi;
use crate::search::indexing::remove_documents;
use crate::search::{SearchConfig, SearchError, search_for_project};
use crate::util::error::Context;
@@ -429,13 +427,13 @@ pub async fn project_edit(
if status.is_searchable()
&& !project_item.inner.webhook_sent
&& !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty()
&& let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK")
{
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&pool,
&redis,
&ENV.PUBLIC_DISCORD_WEBHOOK,
webhook_url,
None,
)
.await
@@ -453,16 +451,18 @@ pub async fn project_edit(
.await?;
}
if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
if user.role.is_mod()
&& let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK")
{
crate::util::webhook::send_slack_project_webhook(
project_item.inner.id.into(),
&pool,
&redis,
&ENV.MODERATION_SLACK_WEBHOOK,
webhook_url,
Some(
format!(
"*<{}/user/{}|{}>* changed project status from *{}* to *{}*",
ENV.SITE_URL,
dotenvy::var("SITE_URL")?,
user.username,
user.username,
&project_item.inner.status.as_friendly_str(),
@@ -2219,18 +2219,6 @@ pub async fn project_delete(
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
let was_in_tech_review =
delphi::is_project_in_tech_review(project.inner.id, &mut transaction)
.await?;
if was_in_tech_review {
delphi::send_tech_review_exit_file_deleted_message(
project.inner.id,
&mut transaction,
)
.await?;
}
let context = ImageContext::Project {
project_id: Some(project.inner.id.into()),
};
+2 -2
View File
@@ -7,7 +7,6 @@ use crate::database::models::image_item;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::ids::{ThreadId, ThreadMessageId};
use crate::models::images::{Image, ImageContext};
@@ -632,8 +631,9 @@ pub async fn message_delete(
let images =
database::DBImage::get_many_contexted(context, &mut transaction)
.await?;
let cdn_url = dotenvy::var("CDN_URL")?;
for image in images {
let name = image.url.split(&format!("{}/", ENV.CDN_URL)).nth(1);
let name = image.url.split(&format!("{cdn_url}/")).nth(1);
if let Some(icon_path) = name {
file_host
.delete_file(
@@ -11,7 +11,6 @@ use crate::database::models::version_item::{
};
use crate::database::models::{self, DBOrganization, image_item};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::ids::{ImageId, ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
@@ -975,7 +974,7 @@ pub async fn upload_file(
version_files.push(VersionFileBuilder {
filename: file_name.to_string(),
url: format!("{}/{file_path_encode}", ENV.CDN_URL),
url: format!("{}/{file_path_encode}", dotenvy::var("CDN_URL")?),
hashes: vec![
models::version_item::HashBuilder {
algorithm: "sha1".to_string(),
@@ -9,7 +9,6 @@ use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::internal::delphi;
use crate::{database, models};
use actix_web::{HttpRequest, HttpResponse, web};
use dashmap::DashMap;
@@ -689,9 +688,6 @@ pub async fn delete_file(
}
let mut transaction = pool.begin().await?;
let was_in_tech_review =
delphi::is_project_in_tech_review(row.project_id, &mut transaction)
.await?;
sqlx::query!(
"
@@ -713,13 +709,6 @@ pub async fn delete_file(
.execute(&mut transaction)
.await?;
delphi::send_tech_review_exit_file_deleted_message_if_exited(
row.project_id,
was_in_tech_review,
&mut transaction,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
-15
View File
@@ -25,7 +25,6 @@ use crate::models::projects::{
use crate::models::projects::{Loader, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::internal::delphi;
use crate::search::SearchConfig;
use crate::search::indexing::remove_documents;
use crate::util::error::Context;
@@ -960,12 +959,6 @@ pub async fn version_delete(
}
let mut transaction = pool.begin().await?;
let was_in_tech_review = delphi::is_project_in_tech_review(
version.inner.project_id,
&mut transaction,
)
.await?;
let context = ImageContext::Version {
version_id: Some(version.inner.id.into()),
};
@@ -984,14 +977,6 @@ pub async fn version_delete(
&mut transaction,
)
.await?;
delphi::send_tech_review_exit_file_deleted_message_if_exited(
version.inner.project_id,
was_in_tech_review,
&mut transaction,
)
.await?;
transaction.commit().await?;
database::models::DBProject::clear_cache(
+6 -2
View File
@@ -5,7 +5,6 @@ use std::time::Duration;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::search::{SearchConfig, UploadSearchProject};
use crate::util::error::Context;
use ariadne::ids::base62_impl::to_base62;
@@ -44,7 +43,12 @@ pub enum IndexingError {
const MEILISEARCH_CHUNK_SIZE: usize = 50000; // 10_000_000
fn search_operation_timeout() -> std::time::Duration {
std::time::Duration::from_millis(ENV.SEARCH_OPERATION_TIMEOUT)
let default_ms = 5 * 60 * 1000; // 5 minutes
let ms = dotenvy::var("SEARCH_OPERATION_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(default_ms);
std::time::Duration::from_millis(ms)
}
pub async fn remove_documents(
+18 -4
View File
@@ -1,4 +1,3 @@
use crate::env::ENV;
use crate::models::projects::SearchRequest;
use crate::{models::error::ApiError, search::indexing::IndexingError};
use actix_web::HttpResponse;
@@ -135,11 +134,26 @@ impl SearchConfig {
// Panics if the environment variables are not set,
// but these are already checked for on startup.
pub fn new(meta_namespace: Option<String>) -> Self {
let address_many = dotenvy::var("MEILISEARCH_WRITE_ADDRS")
.expect("MEILISEARCH_WRITE_ADDRS not set");
let read_lb_address = dotenvy::var("MEILISEARCH_READ_ADDR")
.expect("MEILISEARCH_READ_ADDR not set");
let addresses = address_many
.split(',')
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.collect::<Vec<String>>();
let key =
dotenvy::var("MEILISEARCH_KEY").expect("MEILISEARCH_KEY not set");
Self {
addresses: ENV.MEILISEARCH_WRITE_ADDRS.0.clone(),
key: ENV.MEILISEARCH_KEY.clone(),
addresses,
key,
meta_namespace: meta_namespace.unwrap_or_default(),
read_lb_address: ENV.MEILISEARCH_READ_ADDR.clone(),
read_lb_address,
}
}
+4 -2
View File
@@ -3,7 +3,6 @@ use super::{
environment::LocalService,
};
use crate::LabrinthConfig;
use crate::env::ENV;
use actix_web::{App, dev::ServiceResponse, test};
use async_trait::async_trait;
use std::rc::Rc;
@@ -47,7 +46,10 @@ impl Api for ApiV2 {
async fn reset_search_index(&self) -> ServiceResponse {
let req = actix_web::test::TestRequest::post()
.uri("/v2/admin/_force_reindex")
.append_header(("Modrinth-Admin", ENV.LABRINTH_ADMIN_KEY.clone()))
.append_header((
"Modrinth-Admin",
dotenvy::var("LABRINTH_ADMIN_KEY").unwrap(),
))
.to_request();
self.call(req).await
}
+4 -2
View File
@@ -3,7 +3,6 @@ use super::{
environment::LocalService,
};
use crate::LabrinthConfig;
use crate::env::ENV;
use actix_web::{App, dev::ServiceResponse, test};
use async_trait::async_trait;
use std::rc::Rc;
@@ -52,7 +51,10 @@ impl Api for ApiV3 {
async fn reset_search_index(&self) -> ServiceResponse {
let req = actix_web::test::TestRequest::post()
.uri("/_internal/admin/_force_reindex")
.append_header(("Modrinth-Admin", ENV.LABRINTH_ADMIN_KEY.clone()))
.append_header((
"Modrinth-Admin",
dotenvy::var("LABRINTH_ADMIN_KEY").unwrap(),
))
.to_request();
self.call(req).await
}
+11 -8
View File
@@ -1,7 +1,6 @@
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::database::{MIGRATOR, ReadOnlyPgPool};
use crate::env::ENV;
use crate::search;
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
@@ -58,14 +57,15 @@ impl TemporaryDatabase {
let temp_database_name = generate_random_name("labrinth_tests_db_");
println!("Creating temporary database: {}", &temp_database_name);
let database_url = &ENV.DATABASE_URL;
let database_url =
dotenvy::var("DATABASE_URL").expect("No database URL");
// Create the temporary (and template database, if needed)
Self::create_temporary(database_url, &temp_database_name).await;
Self::create_temporary(&database_url, &temp_database_name).await;
// Pool to the temporary database
let mut temporary_url =
Url::parse(database_url).expect("Invalid database URL");
Url::parse(&database_url).expect("Invalid database URL");
temporary_url.set_path(&format!("/{}", &temp_database_name));
let temp_db_url = temporary_url.to_string();
@@ -139,8 +139,10 @@ impl TemporaryDatabase {
}
// Switch to template
let mut template_url = Url::parse(&ENV.DATABASE_URL)
.expect("Invalid database URL");
let url =
dotenvy::var("DATABASE_URL").expect("No database URL");
let mut template_url =
Url::parse(&url).expect("Invalid database URL");
template_url.set_path(&format!("/{TEMPLATE_DATABASE_NAME}"));
let pool = sqlx::PgPool::connect(template_url.as_str())
@@ -232,10 +234,11 @@ impl TemporaryDatabase {
// If a temporary db is created, it must be cleaned up with cleanup.
// This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise.
pub async fn cleanup(mut self) {
let database_url = &ENV.DATABASE_URL;
let database_url =
dotenvy::var("DATABASE_URL").expect("No database URL");
self.pool.close().await;
self.pool = sqlx::PgPool::connect(database_url)
self.pool = sqlx::PgPool::connect(&database_url)
.await
.map(PgPool::from)
.expect("Connection to main database failed");
+8 -4
View File
@@ -1,9 +1,8 @@
use crate::env::ENV;
use crate::queue::email::EmailQueue;
use crate::util::anrok;
use crate::util::gotenberg::GotenbergClient;
use crate::{LabrinthConfig, file_hosting};
use crate::{clickhouse, env};
use crate::{check_env_vars, clickhouse};
use std::sync::Arc;
pub mod api_common;
@@ -23,8 +22,12 @@ pub mod search;
// If making a test, you should probably use environment::TestEnvironment::build() (which calls this)
pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
println!("Setting up labrinth config");
dotenvy::dotenv().ok();
env::init().expect("failed to initialize environment variables");
if check_env_vars() {
println!("Some environment variables are missing!");
}
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
@@ -36,7 +39,8 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
Arc::new(file_hosting::MockHost::new());
let mut clickhouse = clickhouse::init_client().await.unwrap();
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
let stripe_client =
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
let anrok_client = anrok::Client::from_env().unwrap();
let email_queue =
+8 -6
View File
@@ -7,9 +7,6 @@ use serde_with::{DisplayFromStr, serde_as};
use thiserror::Error;
use tracing::trace;
use crate::env::ENV;
use crate::routes::ApiError;
pub fn transaction_id_stripe_pi(pi: &stripe::PaymentIntentId) -> String {
format!("stripe:charge:{pi}")
}
@@ -157,14 +154,19 @@ pub struct Client {
}
impl Client {
pub fn from_env() -> Result<Self, ApiError> {
pub fn from_env() -> Result<Self, dotenvy::Error> {
let api_key = dotenvy::var("ANROK_API_KEY")?;
let api_url = dotenvy::var("ANROK_API_URL")?
.trim_start_matches('/')
.to_owned();
Ok(Self {
client: reqwest::Client::builder()
.user_agent("Modrinth")
.build()
.expect("AnrokClient to build"),
api_key: ENV.ANROK_API_KEY.clone(),
api_url: ENV.ANROK_API_URL.trim_start_matches('/').to_owned(),
api_key,
api_url,
})
}
+3 -3
View File
@@ -2,7 +2,6 @@ use reqwest::header::HeaderName;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::env::ENV;
use crate::routes::ApiError;
const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key");
@@ -43,12 +42,13 @@ impl ArchonClient {
pub fn from_env() -> Result<Self, ApiError> {
let client = reqwest::Client::new();
let base_url = ENV.ARCHON_URL.trim_end_matches('/').to_owned();
let base_url =
dotenvy::var("ARCHON_URL")?.trim_end_matches('/').to_owned();
Ok(Self {
client,
base_url,
pyro_api_key: ENV.PYRO_API_KEY.clone(),
pyro_api_key: dotenvy::var("PYRO_API_KEY")?,
})
}
+6 -7
View File
@@ -1,5 +1,4 @@
use crate::database::models::{DBUserId, users_compliance::FormType};
use crate::env::ENV;
use crate::routes::ApiError;
use ariadne::ids::base62_impl::to_base62;
use chrono::Datelike;
@@ -132,10 +131,10 @@ fn team_request(
method: reqwest::Method,
route: &str,
) -> Result<(reqwest::RequestBuilder, String), ApiError> {
let key = &ENV.AVALARA_1099_API_KEY;
let url = &ENV.AVALARA_1099_API_URL;
let team = &ENV.AVALARA_1099_API_TEAM_ID;
let company = &ENV.AVALARA_1099_COMPANY_ID;
let key = dotenvy::var("AVALARA_1099_API_KEY")?;
let url = dotenvy::var("AVALARA_1099_API_URL")?;
let team = dotenvy::var("AVALARA_1099_API_TEAM_ID")?;
let company = dotenvy::var("AVALARA_1099_COMPANY_ID")?;
let url = url.trim_end_matches('/');
@@ -145,8 +144,8 @@ fn team_request(
client
.request(method, format!("{url}/v1/{team}{route}"))
.header(reqwest::header::USER_AGENT, "Modrinth")
.bearer_auth(key),
company.to_string(),
.bearer_auth(&key),
company,
))
}
+4 -4
View File
@@ -1,5 +1,5 @@
use crate::env::ENV;
use crate::routes::ApiError;
use crate::util::env::parse_var;
use actix_web::HttpRequest;
use serde::Deserialize;
use std::collections::HashMap;
@@ -8,7 +8,7 @@ pub async fn check_hcaptcha(
req: &HttpRequest,
challenge: &str,
) -> Result<bool, ApiError> {
let secret = &ENV.HCAPTCHA_SECRET;
let secret = dotenvy::var("HCAPTCHA_SECRET")?;
if secret.is_empty() || secret == "none" {
tracing::info!("hCaptcha secret not set, skipping check");
@@ -16,7 +16,7 @@ pub async fn check_hcaptcha(
}
let conn_info = req.connection_info().clone();
let ip_addr = if ENV.CLOUDFLARE_INTEGRATION {
let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {
@@ -38,7 +38,7 @@ pub async fn check_hcaptcha(
let mut form = HashMap::new();
form.insert("response", challenge);
form.insert("secret", secret);
form.insert("secret", &*secret);
form.insert("remoteip", ip_addr);
let val: Response = client
+17
View File
@@ -0,0 +1,17 @@
use std::str::FromStr;
use eyre::{Context, eyre};
pub fn env_var(key: &str) -> eyre::Result<String> {
dotenvy::var(key)
.wrap_err_with(|| eyre!("missing environment variable `{key}`"))
}
pub fn parse_var<T: FromStr>(var: &str) -> Option<T> {
dotenvy::var(var).ok().and_then(|i| i.parse().ok())
}
pub fn parse_strings_from_var(var: &'static str) -> Option<Vec<String>> {
dotenvy::var(var)
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
}
+14 -8
View File
@@ -1,8 +1,8 @@
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::ids::PayoutId;
use crate::routes::ApiError;
use crate::routes::internal::gotenberg::{GotenbergDocument, GotenbergError};
use crate::util::env::env_var;
use crate::util::error::Context;
use actix_web::http::header::HeaderName;
use chrono::{DateTime, Datelike, Utc};
@@ -71,14 +71,15 @@ impl GotenbergClient {
.build()
.wrap_err("failed to build reqwest client")?;
let gotenberg_url = env_var("GOTENBERG_URL")?;
let site_url = env_var("SITE_URL")?;
let callback_base = env_var("GOTENBERG_CALLBACK_BASE")?;
Ok(Self {
client,
gotenberg_url: ENV.GOTENBERG_URL.trim_end_matches('/').to_owned(),
site_url: ENV.SITE_URL.trim_end_matches('/').to_owned(),
callback_base: ENV
.GOTENBERG_CALLBACK_BASE
.trim_end_matches('/')
.to_owned(),
gotenberg_url: gotenberg_url.trim_end_matches('/').to_owned(),
site_url: site_url.trim_end_matches('/').to_owned(),
callback_base: callback_base.trim_end_matches('/').to_owned(),
redis,
})
}
@@ -188,7 +189,12 @@ impl GotenbergClient {
self.generate_payment_statement(statement).await?;
let timeout_ms = ENV.GOTENBERG_TIMEOUT;
let timeout_ms = env_var("GOTENBERG_TIMEOUT")
.map_err(ApiError::Internal)?
.parse::<u64>()
.wrap_internal_err(
"`GOTENBERG_TIMEOUT` is not a valid number of milliseconds",
)?;
let [_key, document] = tokio::time::timeout(
Duration::from_millis(timeout_ms),
+27 -13
View File
@@ -1,33 +1,47 @@
use actix_web::guard::GuardContext;
use actix_web::http::header::X_FORWARDED_FOR;
use crate::env::ENV;
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
pub const EXTERNAL_NOTIFICATION_KEY_HEADER: &str = "External-Notification-Key";
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
"No admin key provided, this should have been caught by check_env_vars",
);
ctx.head()
.headers()
.get(ADMIN_KEY_HEADER)
.is_some_and(|it| it.as_bytes() == ENV.LABRINTH_ADMIN_KEY.as_bytes())
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
}
pub fn medal_key_guard(ctx: &GuardContext) -> bool {
ctx.head()
.headers()
.get(MEDAL_KEY_HEADER)
.is_some_and(|it| it.as_bytes() == ENV.LABRINTH_MEDAL_KEY.as_bytes())
let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok();
match maybe_medal_key {
None => false,
Some(medal_key) => ctx
.head()
.headers()
.get(MEDAL_KEY_HEADER)
.is_some_and(|it| it.as_bytes() == medal_key.as_bytes()),
}
}
pub fn external_notification_key_guard(ctx: &GuardContext) -> bool {
ctx.head()
.headers()
.get(EXTERNAL_NOTIFICATION_KEY_HEADER)
.is_some_and(|it| {
it.as_bytes() == ENV.LABRINTH_EXTERNAL_NOTIFICATION_KEY.as_bytes()
})
let maybe_external_notification_key =
dotenvy::var("LABRINTH_EXTERNAL_NOTIFICATION_KEY").ok();
match maybe_external_notification_key {
None => false,
Some(external_notification_key) => ctx
.head()
.headers()
.get(EXTERNAL_NOTIFICATION_KEY_HEADER)
.is_some_and(|it| {
it.as_bytes() == external_notification_key.as_bytes()
}),
}
}
pub fn internal_network_guard(ctx: &GuardContext) -> bool {
+2 -3
View File
@@ -1,7 +1,6 @@
use crate::database::models::image_item;
use crate::database::redis::RedisPool;
use crate::database::{self, PgTransaction};
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::images::ImageContext;
use crate::routes::ApiError;
@@ -60,7 +59,7 @@ pub async fn upload_image_optimized(
))
})?;
let cdn_url = &ENV.CDN_URL;
let cdn_url = dotenvy::var("CDN_URL")?;
let hash = sha1::Sha1::digest(&bytes).encode_hex::<String>();
let (processed_image, processed_image_ext) = process_image(
@@ -176,7 +175,7 @@ pub async fn delete_old_images(
publicity: FileHostPublicity,
file_host: &dyn FileHost,
) -> Result<(), ApiError> {
let cdn_url = &ENV.CDN_URL;
let cdn_url = dotenvy::var("CDN_URL")?;
let cdn_url_start = format!("{cdn_url}/");
if let Some(image_url) = image_url {
let name = image_url.split(&cdn_url_start).nth(1);
+1
View File
@@ -6,6 +6,7 @@ pub mod bitflag;
pub mod captcha;
pub mod cors;
pub mod date;
pub mod env;
pub mod error;
pub mod ext;
pub mod gotenberg;
+5 -3
View File
@@ -1,5 +1,6 @@
use crate::database::redis::RedisPool;
use crate::routes::ApiError;
use crate::{database::redis::RedisPool, env::ENV};
use crate::util::env::parse_var;
use actix_web::{
Error, ResponseError,
body::{EitherBody, MessageBody},
@@ -133,13 +134,14 @@ pub async fn rate_limit_middleware(
.clone();
if let Some(key) = req.headers().get("x-ratelimit-key")
&& key.to_str().ok() == Some(&ENV.RATE_LIMIT_IGNORE_KEY)
&& key.to_str().ok()
== dotenvy::var("RATE_LIMIT_IGNORE_KEY").ok().as_deref()
{
return Ok(next.call(req).await?.map_into_left_body());
}
let conn_info = req.connection_info().clone();
let ip = if ENV.CLOUDFLARE_INTEGRATION {
let ip = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {
+8 -8
View File
@@ -1,8 +1,8 @@
use crate::database::PgPool;
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use crate::models::ids::ProjectId;
use crate::routes::ApiError;
use crate::{database::PgPool, env::ENV};
use ariadne::ids::base62_impl::to_base62;
use chrono::{DateTime, Utc};
use serde::Serialize;
@@ -69,7 +69,7 @@ async fn get_webhook_metadata(
name: organization.name,
url: format!(
"{}/organization/{}",
ENV.SITE_URL,
dotenvy::var("SITE_URL").unwrap_or_default(),
to_base62(organization.id.0 as u64)
),
icon_url: organization.icon_url,
@@ -95,7 +95,7 @@ async fn get_webhook_metadata(
owner = Some(WebhookAuthor {
url: format!(
"{}/user/{}",
ENV.SITE_URL,
dotenvy::var("SITE_URL").unwrap_or_default(),
to_base62(user.id.0 as u64)
),
name: user.username,
@@ -142,7 +142,7 @@ async fn get_webhook_metadata(
Ok(Some(WebhookMetadata {
project_url: format!(
"{}/{}/{}",
ENV.SITE_URL,
dotenvy::var("SITE_URL").unwrap_or_default(),
project_type,
to_base62(project.inner.id.0 as u64)
),
@@ -251,7 +251,7 @@ pub async fn send_slack_project_webhook(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
webhook_url: &str,
webhook_url: String,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
@@ -350,7 +350,7 @@ pub async fn send_slack_project_webhook(
let client = reqwest::Client::new();
client
.post(webhook_url)
.post(&webhook_url)
.json(&serde_json::json!({
"blocks": blocks,
}))
@@ -422,7 +422,7 @@ pub async fn send_discord_webhook(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
webhook_url: &str,
webhook_url: String,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
@@ -482,7 +482,7 @@ pub async fn send_discord_webhook(
let client = reqwest::Client::new();
client
.post(webhook_url)
.post(&webhook_url)
.json(&DiscordWebhook {
avatar_url: Some(
"https://cdn.modrinth.com/Modrinth_Dark_Logo.png"
+15 -12
View File
@@ -21,18 +21,21 @@ async fn get_tags() {
.into_iter()
.map(|x| x.name)
.collect::<HashSet<_>>();
for name in [
"combat",
"economy",
"food",
"optimization",
"decoration",
"mobs",
"magic",
] {
assert!(category_names.contains(name));
}
assert_eq!(
category_names,
[
"combat",
"economy",
"food",
"optimization",
"decoration",
"mobs",
"magic"
]
.iter()
.map(|s| s.to_string())
.collect()
);
})
.await;
}
+1 -1
View File
@@ -122,7 +122,7 @@ services:
delphi:
profiles:
- with-delphi
image: ghcr.io/modrinth/delphi:main
image: ghcr.io/modrinth/delphi:feature-schema-rework
container_name: labrinth-delphi
ports:
- '127.0.0.1:59999:59999'
+2 -4
View File
@@ -3,8 +3,6 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
export type IconComponent = FunctionalComponent<SVGAttributes>
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
@@ -144,7 +142,6 @@ import _ManageIcon from './icons/manage.svg?component'
import _MaximizeIcon from './icons/maximize.svg?component'
import _MemoryStickIcon from './icons/memory-stick.svg?component'
import _MessageIcon from './icons/message.svg?component'
import _MessagesSquareIcon from './icons/messages-square.svg?component'
import _MicrophoneIcon from './icons/microphone.svg?component'
import _MinimizeIcon from './icons/minimize.svg?component'
import _MinusIcon from './icons/minus.svg?component'
@@ -328,6 +325,8 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export type IconComponent = FunctionalComponent<SVGAttributes>
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
@@ -467,7 +466,6 @@ export const ManageIcon = _ManageIcon
export const MaximizeIcon = _MaximizeIcon
export const MemoryStickIcon = _MemoryStickIcon
export const MessageIcon = _MessageIcon
export const MessagesSquareIcon = _MessagesSquareIcon
export const MicrophoneIcon = _MicrophoneIcon
export const MinimizeIcon = _MinimizeIcon
export const MinusIcon = _MinusIcon
-16
View File
@@ -1,16 +0,0 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-messages-square"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
<path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1" />
</svg>

Before

Width:  |  Height:  |  Size: 544 B

@@ -8,13 +8,13 @@ const props = defineProps<{
watch(
() => props.shown,
(shown) => {
document?.body.classList.toggle('floating-action-bar-shown', shown)
document.body.classList.toggle('floating-action-bar-shown', shown)
},
{ immediate: true },
)
onUnmounted(() => {
document?.body.classList.remove('floating-action-bar-shown')
document.body.classList.remove('floating-action-bar-shown')
})
</script>
@@ -80,17 +80,6 @@
@update:model-value="$emit('update:searchQuery', $event)"
/>
<ButtonStyled v-if="showRefreshButton" type="outlined">
<button
type="button"
class="flex h-10 items-center gap-2 !border-[1px] !border-surface-5"
@click="$emit('refresh')"
>
<RefreshCwIcon aria-hidden="true" class="h-5 w-5" />
Refresh
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
@@ -189,7 +178,6 @@ const props = defineProps<{
editingFilePath?: string
isEditingImage?: boolean
searchQuery: string
showRefreshButton?: boolean
baseId: string
}>()
@@ -202,7 +190,6 @@ defineEmits<{
upload: []
uploadZip: []
unzipFromUrl: [cf: boolean]
refresh: []
save: []
saveAs: []
saveRestart: []
+1 -1
View File
@@ -22,7 +22,7 @@ export function useDebugLogger(namespace: string) {
// eslint-disable-next-line
return (...args: any[]) => {
const location = getCallerLocation()
const prefix = location ? `[${namespace}] ${location}` : `[${namespace}]`
const prefix = location ? `[${namespace}] [${location}]` : `[${namespace}]`
console.debug(prefix, ...args)
}
}
@@ -30,7 +30,6 @@
:editing-file-path="editingFile?.path"
:is-editing-image="fileEditorRef?.isEditingImage"
:search-query="searchQuery"
:show-refresh-button="showRefreshButton"
:base-id="baseId"
@navigate="navigateToSegment"
@navigate-home="() => navigateToSegment(-1)"
@@ -40,7 +39,6 @@
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@refresh="refreshList"
@save="() => fileEditorRef?.saveFileContent(true)"
@save-as="() => fileEditorRef?.saveFileContent(false)"
@save-restart="() => fileEditorRef?.saveAndRestart()"
@@ -305,7 +303,6 @@ import {
defineProps<{
showDebugInfo?: boolean
showRefreshButton?: boolean
}>()
const notifications = injectNotificationManager()