Compare commits

...
Author SHA1 Message Date
aecsocket bdb5d6deac fix search indexing not showing fields 2026-02-24 18:23:46 +00:00
aecsocket 7c3ab22956 fix serde deser flatten 2026-02-24 18:23:46 +00:00
aecsocket 8c68de0ee0 sqlx 2026-02-24 18:23:46 +00:00
aecsocket 46427f6a24 fix more defaults stuff 2026-02-24 18:23:46 +00:00
aecsocket 46bc137a9e wip: massive component query refactor 2026-02-24 18:23:46 +00:00
aecsocket e3cf5c6a0a wip: server modpack content query, components in search 2026-02-24 18:23:46 +00:00
aecsocket d005efa815 fix migration to sync with main, propagate background task errors 2026-02-24 18:23:46 +00:00
aecsocket a42fbb42c1 wip: clean up server ping, background tasks 2026-02-24 18:23:46 +00:00
aecsocket 22e31f9099 synthetic server project version for search indexing 2026-02-24 18:23:46 +00:00
aecsocket 4d64fd175b Improve server messages for app pinging 2026-02-24 18:23:46 +00:00
aecsocket 09290b452d Schema changes 2026-02-24 18:23:46 +00:00
aecsocket c3f76bfd64 clear cache on project ping, add server store link 2026-02-24 18:23:46 +00:00
aecsocket adc85f24bd fix compile error 2026-02-24 18:23:46 +00:00
aecsocket 3c527778a5 tombi fmt 2026-02-24 18:23:46 +00:00
aecsocket 3182c28027 Switch to Redis for server pings 2026-02-24 18:23:46 +00:00
aecsocket 515d1b3196 wip: resolve comments with pings 2026-02-24 18:23:46 +00:00
aecsocket 194873a5e7 fix shear 2026-02-24 18:23:46 +00:00
aecsocket 3acff84465 allow querying server ping data 2026-02-24 18:23:46 +00:00
aecsocket 1455f71f62 tombi fmt 2026-02-24 18:23:46 +00:00
aecsocket 49438d33a1 tombi fmt 2026-02-24 18:23:46 +00:00
aecsocket 6d3139f254 sqlx prepare 2026-02-24 18:23:46 +00:00
aecsocket 3a14e4fd46 mc ping server info + timeout 2026-02-24 18:23:46 +00:00
aecsocket c3a6f4d416 ping queue with tests 2026-02-24 18:23:46 +00:00
aecsocket df7ac297af wip: ping queue 2026-02-24 18:23:46 +00:00
aecsocket f3be9397c9 wip: ping minecraft servers queue 2026-02-24 18:23:46 +00:00
aecsocket 117257bcf9 clean up component edit code 2026-02-24 18:23:46 +00:00
aecsocket 6e011b043f Allow editing components 2026-02-24 18:23:46 +00:00
aecsocket 9aed50d50c docs 2026-02-24 18:23:46 +00:00
aecsocket 4fd747e6aa utoipa support for projects 2026-02-24 18:23:46 +00:00
aecsocket 7ce09d65be game version fields 2026-02-24 18:23:45 +00:00
aecsocket 7cddcee8e1 Version component creation 2026-02-24 18:23:45 +00:00
aecsocket b1c914015b Version components backend API 2026-02-24 18:23:45 +00:00
aecsocket 884907ad0c wip: version components 2026-02-24 18:23:45 +00:00
aecsocket 416598bf6d Update project component structure for servers 2026-02-24 18:23:45 +00:00
aecsocket e04a60e582 New project component metadata schema 2026-02-24 18:23:45 +00:00
aecsocket 4f24641296 No more six seven 2026-02-24 18:23:45 +00:00
aecsocket 55173e9b5a fix up rebase 2026-02-24 18:23:45 +00:00
aecsocket dbacbdafad revert accidental change 2026-02-24 18:23:45 +00:00
aecsocket 487e1e1bdd wip: project components API 2026-02-24 18:23:45 +00:00
aecsocket 7d9e58d877 wip: v67 project creation endpoint 2026-02-24 18:23:45 +00:00
aecsocket 4e5d234701 wip: server listing API 2026-02-24 18:23:45 +00:00
aecsocket 9f814ead27 update frontend to work with new versions list route 2026-02-24 18:23:45 +00:00
e362de45fb Fix library routerview race condition (#5412)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-24 16:52:50 +00:00
Truman GaoandGitHub bacc10d2f5 feat: better auth error handling (#5403)
* add log

* add log

* Revert "add log"

This reverts commit 2412a3de5f.

* add new minecraft auth error modal

* add other auth errors

* polish the styles

* update link text

* add unknown error state

* pnpm prepr

* fix link

* fix lint
2026-02-21 01:39:27 +00:00
aecsocketandGitHub 5b49af1fe8 Fix creating projects from mod install flow (#5402)
* Explicitly state if a mod is incompatible when installing

* wip: debug create instance modal

* Fix mod install createInstance
2026-02-21 00:22:06 +00:00
f052ecd702 Batch of tech review backend fixes (#5398)
* Don't enter project into tech review if no new traces

* Send tech review exited message if files are deleted

* change PATCH /issue-detail/{id} to batch update details

* Fix sorting

* store delphi jar in backend

* show jar in tech review card

* improve jar display in frontend

* Fix live/in review label for tech review cards

* sqlx prepare

* polish: decode segments + code qual fix

* fix: skip first seg

* fix: only slice if needed

* Fix tech rev card styling

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-02-20 12:54:36 +00:00
Truman GaoandGitHub 33ff2a0759 fix: trying to get formatMessage with no current platform (#5397) 2026-02-19 17:40:53 +00:00
aecsocketandGitHub ec81bcb13c Improve environment variable handling and reading (#5389)
* wip: better env var reading

* move most env vars to env.rs

* migrate more env vars

* more migration

* more migrations

* More migration

* 🦀 dotenvy is gone (almost)

* 🦀 dotenvy is gone 🦀

* Fix mural source account env var handling

* Remove defaults from admin key vars

* dummy commit to update github pr

* fix ci
2026-02-19 17:33:41 +00:00
aecsocketandGitHub b6b4bc21f1 Cherry-pick migrations from server projects into main (#5395)
* Cherry-pick migrations from server projects into main

* Fix up project types and seed data

* fix tag test
2026-02-19 17:32:58 +00:00
9a83db2e67 fix: ensure NavTabs slider aligns correctly with dynamic tabs (#5377)
* fix(ui): ensure NavTabs slider aligns correctly with dynamic tabs

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-02-19 16:57:17 +00:00
30c48718e2 fix: single loader projects download button not showing (#5391)
* fix: single loader projects downlaod button not showing

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-02-18 19:45:28 +00:00
Calum H.andGitHub 8328a0d61a fix: gdpr issue posthog + type improvements (#5392) 2026-02-18 18:21:21 +00:00
Truman GaoandGitHub b62bc6f3b8 fix cannot read "body" from undefined (#5387) 2026-02-18 17:43:14 +00:00
Truman GaoandGitHub 0e752ab415 feat: handling mrpack with no loaders (#5363)
* handle modpack upload with no loaders

* restrict loaders for modpack

* actually, dont allow modpack loaders to be editable

* revert loader picker changes
2026-02-18 17:43:07 +00:00
aecsocketandGitHub 9f558404bd Improve error logging in project delete route (#5388)
* Improve error logging in project delete route

* remove_documents more error logging

* fix ci

* try fix ci? idk man
2026-02-18 05:06:50 +00:00
131 changed files with 5245 additions and 1458 deletions
Generated
+58
View File
@@ -640,6 +640,19 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-minecraft-ping"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89"
dependencies = [
"async-trait",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -2167,6 +2180,16 @@ dependencies = [
"darling_macro 0.21.3",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
@@ -2195,6 +2218,19 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
@@ -2217,6 +2253,17 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.106",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -4730,6 +4777,7 @@ dependencies = [
"arc-swap",
"argon2",
"ariadne",
"async-minecraft-ping",
"async-stripe",
"async-trait",
"base64 0.22.1",
@@ -4811,6 +4859,16 @@ dependencies = [
"zxcvbn",
]
[[package]]
name = "labrinth-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "language-tags"
version = "0.3.2"
+6
View File
@@ -8,6 +8,7 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
@@ -32,6 +33,7 @@ arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false }
async-minecraft-ping = { version = "0.8.0" }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
@@ -58,6 +60,7 @@ color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
@@ -121,9 +124,11 @@ paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.38.3"
quote = { version = "1.0" }
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.7"
@@ -166,6 +171,7 @@ spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false }
sqlx-tracing = { path = "packages/sqlx-tracing" }
strum = "0.27.2"
syn = { version = "2.0" }
sysinfo = { version = "0.37.2", default-features = false }
tar = "0.4.44"
tauri = "2.8.5"
+9 -6
View File
@@ -65,6 +65,7 @@ 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'
@@ -77,7 +78,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, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { debugAnalytics, initAnalytics, 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'
@@ -271,12 +272,11 @@ async function setupApp() {
isMaximized.value = await getCurrentWindow().isMaximized()
})
initAnalytics()
if (!telemetry) {
optOutAnalytics()
if (telemetry) {
initAnalytics()
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev, onboarded })
}
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev, onboarded })
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
@@ -389,6 +389,7 @@ loading.setEnabled(false)
const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const install = useInstall()
const modInstallModal = ref()
@@ -467,6 +468,7 @@ onMounted(() => {
invoke('show_window')
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
@@ -1132,6 +1134,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" />
@@ -33,6 +33,7 @@ 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,19 +49,14 @@ const creatingInstance = ref(false)
const profiles = ref([])
const shownProfiles = computed(() =>
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)
}),
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
const isProfileCompatible = (profile) =>
versions.value?.some((version) => isVersionCompatible(version, project.value, profile))
const onInstall = ref(() => {})
defineExpose({
@@ -164,13 +159,13 @@ const createInstance = async () => {
const gameVersion = gameVersions[0]
const loaders = versions.value[0].loaders
const loader = loaders.contains('fabric')
const loader = loaders.includes('fabric')
? 'fabric'
: loaders.contains('neoforge')
: loaders.includes('neoforge')
? 'neoforge'
: loaders.contains('forge')
: loaders.includes('forge')
? 'forge'
: loaders.contains('quilt')
: loaders.includes('quilt')
? 'quilt'
: 'vanilla'
@@ -240,17 +235,23 @@ const createInstance = async () => {
"
>
<Button
:disabled="profile.installedMod || profile.installing"
:disabled="
!isProfileCompatible(profile) || profile.installedMod || profile.installing
"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<DownloadIcon
v-if="isProfileCompatible(profile) && !profile.installedMod && !profile.installing"
/>
<CheckIcon v-else-if="profile.installedMod" />
{{
profile.installing
? 'Installing...'
: profile.installedMod
? 'Installed'
: 'Install'
: !isProfileCompatible(profile)
? 'Incompatible'
: 'Install'
}}
</Button>
</div>
@@ -0,0 +1,184 @@
<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>
@@ -0,0 +1,90 @@
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',
],
},
]
@@ -1,24 +0,0 @@
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)
}
@@ -0,0 +1,80 @@
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 :instances="instances" />
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
+12
View File
@@ -3,12 +3,24 @@ 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.js'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import {
+15 -6
View File
@@ -191,12 +191,20 @@ function computeActiveIndex(): { index: number; isSubpage: boolean } {
}
function getTabElement(index: number): HTMLElement | null {
if (!tabLinkElements.value?.[index]) return null
if (index === -1) return null
// 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
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
}
function positionSlider() {
@@ -253,7 +261,8 @@ function animateSliderTo(newPosition: {
sliderBottom.value = newPosition.bottom
}
function updateActiveTab() {
async function updateActiveTab() {
await nextTick()
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 class="text-red">*</span></span>
<span class="font-semibold text-contrast">Loaders</span>
<Chips
v-model="loaderGroup"
@@ -77,7 +77,13 @@
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
<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>
</div>
</div>
</div>
@@ -5,6 +5,7 @@ import {
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardCopyIcon,
CodeIcon,
CopyIcon,
@@ -159,6 +160,15 @@ 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())
@@ -393,11 +403,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
isBatchUpdating.value = true
try {
await Promise.all(
detailIds.map((detailId) =>
client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }),
),
)
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
const decision = verdict === 'safe' ? 'safe' : 'malware'
for (const detailId of detailIds) {
@@ -445,7 +451,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
updatingDetails.add(detailId)
try {
await client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict })
await updateIssueDetails([{ detail_id: detailId, verdict }])
const decision = verdict === 'safe' ? 'safe' : 'malware'
@@ -484,7 +490,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.filePath)
expandedClasses.delete(classGroup.key)
break
}
}
@@ -533,6 +539,8 @@ 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
@@ -543,6 +551,26 @@ 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 []
@@ -550,14 +578,20 @@ const groupedByClass = computed<ClassGroup[]>(() => {
for (const issue of selectedFile.value.issues) {
for (const detail of issue.details) {
if (!classMap.has(detail.file_path)) {
classMap.set(detail.file_path, { filePath: detail.file_path, flags: [] })
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: [],
})
}
// 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(detail.file_path)!.flags.push({
classMap.get(classKey)!.flags.push({
issueId: issue.id,
issueType: issue.issue_type,
detail: detailWithStatus,
@@ -585,12 +619,35 @@ 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].filePath)
expandedClasses.add(classes[0].key)
}
},
{ immediate: true },
@@ -608,11 +665,11 @@ function getHighestSeverityInClass(
)
}
function toggleClass(filePath: string) {
if (expandedClasses.has(filePath)) {
expandedClasses.delete(filePath)
function toggleClass(classKey: string) {
if (expandedClasses.has(classKey)) {
expandedClasses.delete(classKey)
} else {
expandedClasses.add(filePath)
expandedClasses.add(classKey)
}
}
@@ -1097,204 +1154,234 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</ButtonStyled>
</div>
<div
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,
}"
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"
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.filePath)"
v-if="jarGroup.segments.length > 0"
class="border-b border-solid border-surface-1 px-4 py-3"
>
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
<button
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(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'
"
>
<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"
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
{{ 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>
</template>
</div>
</div>
<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),
}"
>
<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">
<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
v-tooltip="`Copy code`"
class="absolute right-2 top-2 border-[1px]"
@click="
copyToClipboard(getClassDecompiledSource(classItem)!, classItem.filePath)
"
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(classItem.key) }"
>
<CopyIcon v-if="!showCopyFeedback.get(classItem.filePath)" />
<CheckIcon v-else />
<ChevronDownIcon class="h-5 w-5 text-contrast" />
</button>
</ButtonStyled>
<div class="overflow-x-auto bg-surface-3 py-3">
<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
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
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>
<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
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"
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"
>
{{ n + 1 }}
</div>
<div class="flex-1 px-4 py-0 text-primary">
<pre v-html="line || ' '"></pre>
<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>
<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>
</Collapsible>
</div>
</div>
</template>
</div>
+49 -7
View File
@@ -97,6 +97,8 @@
ref="downloadModal"
:on-show="
() => {
debug('on-show fired')
loadVersions()
navigateTo({ query: route.query, hash: '#download' })
}
"
@@ -388,7 +390,9 @@
currentGameVersion &&
!filteredRelease &&
!filteredBeta &&
!filteredAlpha
!filteredAlpha &&
!versionsLoading &&
versions.length > 0
"
>
{{
@@ -986,6 +990,7 @@ import {
ServersPromo,
StyledInput,
TagItem,
useDebugLogger,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -1032,6 +1037,8 @@ const cosmetics = useCosmetics()
const { locale, formatMessage } = useVIntl()
const debug = useDebugLogger('DownloadModal')
const settingsModal = ref()
const downloadModal = ref()
const overTheTopDownloadAnimation = ref()
@@ -1075,9 +1082,10 @@ const currentPlatform = computed(() => {
)
})
const currentPlatformText = computed(() =>
formatMessage(getTagMessage(currentPlatform.value, 'loader')),
)
const currentPlatformText = computed(() => {
if (!currentPlatform.value) return null
return formatMessage(getTagMessage(currentPlatform.value, 'loader'))
})
const releaseVersions = computed(() => {
const set = new Set()
@@ -1410,11 +1418,21 @@ async function getLicenseData(event) {
}
const filteredVersions = computed(() => {
return versions.value.filter(
const result = 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(() => {
@@ -1607,6 +1625,10 @@ 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
}
@@ -2070,15 +2092,35 @@ 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
@@ -421,10 +421,6 @@ export function createManageVersionContext(
inferred.loaders = ['datapack']
}
if (noLoaders && projectType.value === 'modpack') {
inferred.loaders = ['minecraft']
}
inferredVersionData.value = inferred
return inferred
@@ -10,8 +10,14 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-loaders',
stageContent: markRaw(LoadersStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit loaders' : 'Loaders'),
skip: (ctx) =>
(ctx.inferredVersionData.value?.loaders?.length ?? 0) > 0 || ctx.editingVersion.value,
skip: (ctx) => {
const inferredLoadersLength = ctx.inferredVersionData.value?.loaders?.length ?? 0
return (
inferredLoadersLength > 0 ||
ctx.editingVersion.value ||
(inferredLoadersLength === 0 && ctx.projectType.value === 'modpack')
)
},
hideStageInBreadcrumb: (ctx) => !ctx.primaryFile.value || ctx.handlingNewFiles.value,
cannotNavigateForward: (ctx) => ctx.draftVersion.value.loaders.length === 0,
leftButtonConfig: (ctx) => ({
@@ -36,20 +42,22 @@ export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue>
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.draftVersion.value.loaders.length === 0,
disabled: ctx.draftVersion.value.loaders.length === 0 && ctx.projectType.value !== 'modpack',
onClick: () => ctx.modal.value?.setStage('metadata'),
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
...ctx.saveButtonConfig(),
disabled: ctx.draftVersion.value.loaders.length === 0,
disabled:
ctx.draftVersion.value.loaders.length === 0 && ctx.projectType.value !== 'modpack',
}
: {
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: ctx.draftVersion.value.loaders.length === 0,
disabled:
ctx.draftVersion.value.loaders.length === 0 && ctx.projectType.value !== 'modpack',
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}
+5 -1
View File
@@ -8,6 +8,8 @@ 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
@@ -152,6 +154,8 @@ ARCHON_URL=none
MURALPAY_API_URL=https://api.muralpay.com
MURALPAY_API_KEY=none
MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
+5 -1
View File
@@ -8,6 +8,7 @@ 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
@@ -163,6 +164,9 @@ ARCHON_URL=none
MURALPAY_API_URL=https://api-staging.muralpay.com
MURALPAY_API_KEY=none
MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
SERVER_PING_MIN_INTERVAL_SEC=1800
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -16,10 +16,11 @@
"Varchar",
"Bool",
"Varchar",
"Int4"
"Int4",
"Jsonb"
]
},
"nullable": []
},
"hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce"
"hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c"
}
@@ -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.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.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 ",
"describe": {
"columns": [
{
@@ -20,16 +20,21 @@
},
{
"ordinal": 3,
"name": "file_path!: String",
"name": "jar?: String",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "file_path!: String",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"ordinal": 6,
"name": "severity!: DelphiSeverity",
"type_info": {
"Custom": {
@@ -46,7 +51,7 @@
}
},
{
"ordinal": 6,
"ordinal": 7,
"name": "status!: DelphiStatus",
"type_info": {
"Custom": {
@@ -71,11 +76,12 @@
false,
false,
false,
true,
false,
false,
false,
null
]
},
"hash": "80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0"
"hash": "263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a"
}
@@ -0,0 +1,34 @@
{
"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"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET components = $1\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Int8"
]
},
"nullable": []
},
"hash": "46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e"
}
@@ -0,0 +1,22 @@
{
"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"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, components AS \"components: Json<exp::ProjectSerial>\"\n FROM mods\n WHERE components ? 'minecraft_java_server'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "components: Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "591bf29b883b66b111496f8c07ebd2619cd3bcf92f637ca99876c14bd44c85f6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id\n ",
"describe": {
"columns": [
{
@@ -137,6 +137,11 @@
"ordinal": 26,
"name": "additional_categories",
"type_info": "VarcharArray"
},
{
"ordinal": 27,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -172,8 +177,9 @@
false,
false,
null,
null
null,
false
]
},
"hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62"
"hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ",
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json<exp::VersionSerial>\"\n FROM versions v\n WHERE v.id = ANY($1);\n ",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"ordinal": 12,
"name": "ordering",
"type_info": "Int4"
},
{
"ordinal": 13,
"name": "components: sqlx::types::Json<exp::VersionSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -87,8 +92,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6"
"hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860"
}
@@ -0,0 +1,22 @@
{
"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,6 +1,6 @@
{
"db_name": "PostgreSQL",
"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 ",
"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 ",
"describe": {
"columns": [
{
@@ -15,6 +15,7 @@
"Text",
"Text",
"Text",
"Text",
"Jsonb",
{
"Custom": {
@@ -35,5 +36,5 @@
false
]
},
"hash": "b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4"
"hash": "9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2"
}
@@ -0,0 +1,23 @@
{
"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"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color\n FROM mods m\n WHERE m.status = ANY($1) AND m.id > $3\n GROUP BY m.id\n ORDER BY m.id ASC\n LIMIT $2;\n ",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n FROM mods m\n WHERE m.status = ANY($1) AND m.id > $3\n GROUP BY m.id\n ORDER BY m.id ASC\n LIMIT $2;\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 11,
"name": "color",
"type_info": "Int4"
},
{
"ordinal": 12,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -83,8 +88,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "702a2826d5857dc51b1a7a79c9043ae8987441bb5e89c9ea62d347e47899e3c2"
"hash": "b760951120c1885530f3fb8208a5874fc472ec160a673abeeee10c778b9cf23f"
}
@@ -1,26 +0,0 @@
{
"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"
}
@@ -0,0 +1,29 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18,\n $19\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -22,10 +22,11 @@
"Int4",
"Varchar",
"Int8",
"Varchar"
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702"
"hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192"
}
@@ -1,34 +0,0 @@
{
"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"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n v.id AS \"version_id: DBVersionId\",\n m.id AS \"project_id: DBProjectId\",\n m.name AS \"project_name!\",\n COALESCE(m.icon_url, '') AS \"project_icon!\"\n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n WHERE v.id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "project_name!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "project_icon!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "fc99699ff89c3245c7288718ff15d16058b00bf5df286923c0c6b1db2dbbde4f"
}
+1
View File
@@ -21,6 +21,7 @@ actix-ws = { workspace = true }
arc-swap = { workspace = true }
argon2 = { workspace = true }
ariadne = { workspace = true }
async-minecraft-ping = { workspace = true }
async-stripe = { workspace = true, features = [
"billing",
"checkout",
@@ -1,83 +1,83 @@
BEGIN TRANSACTION;
COPY public.categories (id, category, project_type, icon, header, ordering) FROM stdin;
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
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
\.
@@ -0,0 +1,2 @@
ALTER TABLE mods
ADD COLUMN components JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,8 @@
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)
);
@@ -0,0 +1,60 @@
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);
@@ -0,0 +1,2 @@
ALTER TABLE delphi_report_issue_details
ADD COLUMN jar TEXT;
+2 -1
View File
@@ -2,6 +2,7 @@ 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;
@@ -146,7 +147,7 @@ where
user_item::DBUser::get_id(session.user_id, executor, redis)
.await?;
let rate_limit_ignore = dotenvy::var("RATE_LIMIT_IGNORE_KEY")?;
let rate_limit_ignore = &ENV.RATE_LIMIT_IGNORE_KEY;
if req
.headers()
.get("x-ratelimit-key")
+99 -87
View File
@@ -11,7 +11,8 @@ use crate::search::indexing::index_projects;
use crate::util::anrok;
use crate::{database, search};
use clap::ValueEnum;
use tracing::{error, info, warn};
use eyre::WrapErr;
use tracing::info;
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
#[clap(rename_all = "kebab_case")]
@@ -25,6 +26,7 @@ pub enum BackgroundTask {
IndexSubscriptions,
Migrations,
Mail,
PingMinecraftJavaServers,
}
impl BackgroundTask {
@@ -40,7 +42,7 @@ impl BackgroundTask {
anrok_client: anrok::Client,
email_queue: EmailQueue,
mural_client: muralpay::Client,
) {
) -> eyre::Result<()> {
use BackgroundTask::*;
match self {
Migrations => run_migrations().await,
@@ -62,7 +64,7 @@ impl BackgroundTask {
)
.await;
update_bank_balances(pool).await;
update_bank_balances(pool).await
}
IndexSubscriptions => {
index_subscriptions(
@@ -71,71 +73,66 @@ impl BackgroundTask {
stripe_client,
anrok_client,
)
.await
.await;
Ok(())
}
Mail => {
run_email(email_queue).await;
Mail => run_email(email_queue).await,
PingMinecraftJavaServers => {
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
}
}
}
}
pub async fn run_email(email_queue: EmailQueue) {
pub async fn run_email(email_queue: EmailQueue) -> eyre::Result<()> {
// Only index for 5 emails at a time, to reduce transaction length,
// for a total of 100 emails.
for _ in 0..20 {
let then = std::time::Instant::now();
match email_queue.index(5).await {
Ok(true) => {
info!(
"Indexed email queue in {}ms",
then.elapsed().as_millis()
);
}
Ok(false) => {
info!("No more emails to index");
break;
}
Err(error) => {
error!(%error, "Failed to index email queue");
}
let indexed = email_queue
.index(5)
.await
.wrap_err("failed to index email queue")?;
if indexed {
info!("Indexed email queue in {}ms", then.elapsed().as_millis());
} else {
info!("No more emails to index");
break;
}
}
Ok(())
}
pub async fn update_bank_balances(pool: PgPool) {
pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
let payouts_queue = PayoutsQueue::new();
match insert_bank_balances_and_webhook(&payouts_queue, &pool).await {
Ok(_) => info!("Bank balances updated successfully"),
Err(error) => error!(%error, "Bank balances update failed"),
}
insert_bank_balances_and_webhook(&payouts_queue, &pool)
.await
.wrap_err("failed to update bank balances")?;
info!("Bank balances updated successfully");
Ok(())
}
pub async fn run_migrations() {
database::check_for_migrations()
.await
.expect("An error occurred while running migrations.");
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
Ok(())
}
pub async fn index_search(
ro_pool: PgPool,
redis_pool: RedisPool,
search_config: search::SearchConfig,
) {
) -> eyre::Result<()> {
info!("Indexing local database");
let result = index_projects(ro_pool, redis_pool, &search_config).await;
if let Err(e) = result {
warn!("Local project indexing failed: {:?}", e);
}
info!("Done indexing local database");
index_projects(ro_pool, redis_pool, &search_config).await
}
pub async fn release_scheduled(pool: PgPool) {
pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
info!("Releasing scheduled versions/projects!");
let projects_results = sqlx::query!(
sqlx::query!(
"
UPDATE mods
SET status = requested_status
@@ -143,14 +140,11 @@ pub async fn release_scheduled(pool: PgPool) {
",
crate::models::projects::ProjectStatus::Scheduled.as_str(),
)
.execute(&pool)
.await;
.execute(&pool)
.await
.wrap_err("failed syncing scheduled releases for projects")?;
if let Err(e) = projects_results {
warn!("Syncing scheduled releases for projects failed: {:?}", e);
}
let versions_results = sqlx::query!(
sqlx::query!(
"
UPDATE versions
SET status = requested_status
@@ -158,78 +152,96 @@ pub async fn release_scheduled(pool: PgPool) {
",
crate::models::projects::VersionStatus::Scheduled.as_str(),
)
.execute(&pool)
.await;
if let Err(e) = versions_results {
warn!("Syncing scheduled releases for versions failed: {:?}", e);
}
.execute(&pool)
.await
.wrap_err("failed syncing scheduled releases for versions")?;
info!("Finished releasing scheduled versions/projects");
Ok(())
}
pub async fn update_versions(pool: PgPool, redis_pool: RedisPool) {
pub async fn update_versions(
pool: PgPool,
redis_pool: RedisPool,
) -> eyre::Result<()> {
info!("Indexing game versions list from Mojang");
let result = version_updater::update_versions(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Version update failed: {}", e);
}
version_updater::update_versions(&pool, &redis_pool)
.await
.wrap_err("failed to update game versions")?;
info!("Done indexing game versions");
Ok(())
}
pub async fn payouts(
pool: PgPool,
clickhouse: clickhouse::Client,
redis_pool: RedisPool,
) {
) -> eyre::Result<()> {
info!("Started running payouts");
let result = process_payout(&pool, &clickhouse).await;
if let Err(e) = result {
warn!("Payouts run failed: {e:#?}");
}
process_payout(&pool, &clickhouse)
.await
.wrap_err("payout processing failed")?;
let result = index_payouts_notifications(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Payouts notifications indexing failed: {e:#?}");
}
index_payouts_notifications(&pool, &redis_pool)
.await
.wrap_err("payout notifications indexing failed")?;
let result = process_affiliate_payouts(&pool).await;
if let Err(e) = result {
warn!("Affiliate payouts run failed: {e:#?}");
}
process_affiliate_payouts(&pool)
.await
.wrap_err("affiliate payouts processing failed")?;
let result = remove_payouts_for_refunded_charges(&pool).await;
if let Err(e) = result {
warn!("Removing affiliate payouts for refunded charges failed: {e:#?}");
}
remove_payouts_for_refunded_charges(&pool)
.await
.wrap_err("removing payouts for refunded charges failed")?;
info!("Done running payouts");
Ok(())
}
pub async fn sync_payout_statuses(pool: PgPool, mural: muralpay::Client) {
pub async fn sync_payout_statuses(
pool: PgPool,
mural: muralpay::Client,
) -> eyre::Result<()> {
// Mural sets a max limit of 100 for search payouts endpoint
const LIMIT: u32 = 100;
info!("Started syncing payout statuses");
let result = crate::queue::payouts::mural::sync_pending_payouts_from_mural(
crate::queue::payouts::mural::sync_pending_payouts_from_mural(
&pool, &mural, LIMIT,
)
.await;
if let Err(e) = result {
warn!("Failed to sync pending payouts from Mural: {e:?}");
}
.await
.wrap_err("failed to sync pending payouts from Mural")?;
let result =
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
&pool, &mural, LIMIT,
)
.await;
if let Err(e) = result {
warn!("Failed to sync failed Mural payouts to Labrinth: {e:?}");
}
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
&pool, &mural, LIMIT,
)
.await
.wrap_err("failed to sync failed Mural payouts to Labrinth")?;
info!("Done syncing payout statuses");
Ok(())
}
pub async fn ping_minecraft_java_servers(
pool: PgPool,
redis_pool: RedisPool,
clickhouse: clickhouse::Client,
) -> eyre::Result<()> {
info!("Started pinging Minecraft Java servers");
let server_ping_queue = crate::queue::server_ping::ServerPingQueue::new(
pool, redis_pool, clickhouse,
);
server_ping_queue
.ping_minecraft_java_servers()
.await
.wrap_err("failed to ping Minecraft Java servers")?;
info!("Successfully pinged Minecraft Java servers");
info!("Done pinging Minecraft Java servers");
Ok(())
}
mod version_updater {
+55 -7
View File
@@ -5,14 +5,19 @@ mod fetch;
pub use fetch::*;
use crate::env::ENV;
use crate::queue::server_ping;
pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
init_client_with_database(&dotenvy::var("CLICKHOUSE_DATABASE").unwrap())
.await
init_client_with_database(&ENV.CLICKHOUSE_DATABASE).await
}
pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result<clickhouse::Client> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
const MINECRAFT_JAVA_SERVER_PLAYS: &str = "minecraft_java_server_plays";
let client = {
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
@@ -24,9 +29,9 @@ pub async fn init_client_with_database(
.build(https_connector);
clickhouse::Client::with_http_client(hyper_client)
.with_url(dotenvy::var("CLICKHOUSE_URL").unwrap())
.with_user(dotenvy::var("CLICKHOUSE_USER").unwrap())
.with_password(dotenvy::var("CLICKHOUSE_PASSWORD").unwrap())
.with_url(&ENV.CLICKHOUSE_URL)
.with_user(&ENV.CLICKHOUSE_USER)
.with_password(&ENV.CLICKHOUSE_PASSWORD)
.with_validation(false)
};
@@ -35,8 +40,7 @@ pub async fn init_client_with_database(
.execute()
.await?;
let clickhouse_replicated =
dotenvy::var("CLICKHOUSE_REPLICATED").unwrap() == "true";
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
let cluster_line = if clickhouse_replicated {
"ON cluster '{cluster}'"
} else {
@@ -160,5 +164,49 @@ pub async fn init_client_with_database(
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_JAVA_SERVER_PINGS} {cluster_line}
(
recorded DateTime64(4),
project_id UInt64,
address String,
port UInt16,
online Bool,
latency_ms Nullable(UInt32),
description Nullable(String),
version_name Nullable(String),
version_protocol Nullable(UInt32),
players_online Nullable(UInt32),
players_max Nullable(UInt32)
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_JAVA_SERVER_PLAYS} {cluster_line}
(
recorded DateTime64(4),
user_id UInt64,
project_id UInt64
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
Ok(client.with_database(database))
}
@@ -221,6 +221,9 @@ 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.
@@ -241,12 +244,13 @@ impl ReportIssueDetail {
) -> Result<DelphiReportIssueDetailsId, DatabaseError> {
Ok(DelphiReportIssueDetailsId(sqlx::query_scalar!(
"
INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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>>,
+2
View File
@@ -58,6 +58,8 @@ pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error(transparent)]
Internal(#[from] eyre::Report),
#[error("Error while interacting with the database: {0}")]
Database(#[from] sqlx::Error),
#[error("Error while trying to generate random ID")]
+116 -39
View File
@@ -6,9 +6,13 @@ use super::{DBUser, ids::*};
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::models::exp;
use crate::models::ids::ProjectId;
use crate::models::projects::{
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
};
use crate::routes::ApiError;
use crate::util::error::Context;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
@@ -176,6 +180,7 @@ pub struct ProjectBuilder {
pub gallery_items: Vec<DBGalleryItem>,
pub color: Option<u32>,
pub monetization_status: MonetizationStatus,
pub components: exp::ProjectSerial,
}
impl ProjectBuilder {
@@ -215,6 +220,7 @@ impl ProjectBuilder {
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec![],
components: self.components,
};
project_struct.insert(&mut *transaction).await?;
@@ -294,6 +300,7 @@ pub struct DBProject {
pub monetization_status: MonetizationStatus,
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
pub loaders: Vec<String>,
pub components: exp::ProjectSerial,
}
impl DBProject {
@@ -308,14 +315,16 @@ impl DBProject {
published, downloads, icon_url, raw_icon_url, status, requested_status,
license_url, license,
slug, color, monetization_status, organization_id,
side_types_migration_review_status
side_types_migration_review_status,
components
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13,
LOWER($14), $15, $16, $17,
$18
$18,
$19
)
",
self.id as DBProjectId,
@@ -335,7 +344,8 @@ impl DBProject {
self.color.map(|x| x as i32),
self.monetization_status.as_str(),
self.organization_id.map(|x| x.0 as i64),
self.side_types_migration_review_status.as_str()
self.side_types_migration_review_status.as_str(),
serde_json::to_value(&self.components).expect("serialization shouldn't fail"),
)
.execute(&mut *transaction)
.await?;
@@ -347,12 +357,15 @@ impl DBProject {
id: DBProjectId,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Option<()>, DatabaseError> {
let project = Self::get_id(id, &mut *transaction, redis).await?;
) -> Result<Option<()>, ApiError> {
let project = Self::get_id(id, &mut *transaction, redis)
.await
.wrap_internal_err("failed to fetch project by ID")?;
if let Some(project) = project {
DBProject::clear_cache(id, project.inner.slug, Some(true), redis)
.await?;
.await
.wrap_internal_err("failed to clear project cache")?;
sqlx::query!(
"
@@ -362,7 +375,8 @@ impl DBProject {
id as DBProjectId
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project followers")?;
sqlx::query!(
"
@@ -372,7 +386,8 @@ impl DBProject {
id as DBProjectId
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project gallery items")?;
sqlx::query!(
"
@@ -382,7 +397,10 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err(
"failed to delete duplicate project followers",
)?;
sqlx::query!(
"
@@ -393,7 +411,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to clear report project references")?;
sqlx::query!(
"
@@ -403,7 +422,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project categories")?;
sqlx::query!(
"
@@ -413,11 +433,13 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project links")?;
for version in project.versions {
super::DBVersion::remove_full(version, redis, transaction)
.await?;
.await
.wrap_internal_err("failed to remove project version")?;
}
sqlx::query!(
@@ -427,7 +449,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete dependency references")?;
sqlx::query!(
"
@@ -438,7 +461,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to clear payout project references")?;
sqlx::query!(
"
@@ -448,10 +472,12 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project row")?;
models::DBTeamMember::clear_cache(project.inner.team_id, redis)
.await?;
.await
.wrap_internal_err("failed to clear team member cache")?;
let affected_user_ids = sqlx::query!(
"
@@ -464,9 +490,12 @@ impl DBProject {
.fetch(&mut *transaction)
.map_ok(|x| DBUserId(x.user_id))
.try_collect::<Vec<_>>()
.await?;
.await
.wrap_internal_err("failed to delete team members")?;
DBUser::clear_project_cache(&affected_user_ids, redis).await?;
DBUser::clear_project_cache(&affected_user_ids, redis)
.await
.wrap_internal_err("failed to clear user project cache")?;
sqlx::query!(
"
@@ -476,7 +505,8 @@ impl DBProject {
project.inner.team_id as DBTeamId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete team")?;
Ok(Some(()))
} else {
@@ -488,7 +518,7 @@ impl DBProject {
string: &str,
executor: E,
redis: &RedisPool,
) -> Result<Option<ProjectQueryResult>, DatabaseError>
) -> Result<Option<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -501,7 +531,7 @@ impl DBProject {
id: DBProjectId,
executor: E,
redis: &RedisPool,
) -> Result<Option<ProjectQueryResult>, DatabaseError>
) -> Result<Option<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -518,7 +548,7 @@ impl DBProject {
project_ids: &[DBProjectId],
exec: E,
redis: &RedisPool,
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
) -> Result<Vec<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -537,7 +567,7 @@ impl DBProject {
project_strings: &[T],
exec: E,
redis: &RedisPool,
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
) -> Result<Vec<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -547,7 +577,9 @@ impl DBProject {
false,
project_strings,
|ids| async move {
let mut exec = exec.acquire().await?;
let mut exec = exec
.acquire()
.await?;
let project_ids_parsed: Vec<i64> = ids
.iter()
.filter_map(|x| parse_base62(&x.to_string()).ok())
@@ -668,7 +700,8 @@ impl DBProject {
});
async move { Ok(acc) }
}
).await?;
)
.await?;
let links: DashMap<DBProjectId, Vec<LinkUrl>> = sqlx::query!(
"
@@ -692,7 +725,8 @@ impl DBProject {
});
async move { Ok(acc) }
}
).await?;
)
.await?;
#[derive(Default)]
struct VersionLoaderData {
@@ -743,7 +777,9 @@ impl DBProject {
(project_id, version_loader_data)
}
).try_collect().await?;
)
.try_collect()
.await?;
let loader_fields: Vec<QueryLoaderField> = sqlx::query!(
"
@@ -766,8 +802,8 @@ impl DBProject {
.try_collect()
.await?;
let projects = sqlx::query!(
"
let project_rows = sqlx::query!(
r#"
SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,
m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,
m.approved approved, m.queued, m.status status, m.requested_status requested_status,
@@ -777,24 +813,48 @@ impl DBProject {
t.id thread_id, m.monetization_status monetization_status,
m.side_types_migration_review_status side_types_migration_review_status,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
m.components AS "components: sqlx::types::Json<exp::ProjectSerial>"
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
LEFT JOIN categories c ON mc.joining_category_id = c.id
WHERE m.id = ANY($1) OR m.slug = ANY($2)
GROUP BY t.id, m.id;
",
GROUP BY t.id, m.id
"#,
&project_ids_parsed,
&slugs,
)
.fetch(&mut exec)
.try_fold(DashMap::new(), |acc, m| {
.fetch_all(&mut exec)
.await?;
let project_components = project_rows
.iter()
.map(|row| {
(
ProjectId::from(DBProjectId(row.id)),
&row.components.0,
)
})
.collect::<Vec<_>>();
let project_query_context = exp::project::fetch_query_context(
&project_components,
&mut exec,
redis,
)
.await
.wrap_err("failed to fetch project query context")?;
let projects = project_rows
.into_iter()
.try_fold(DashMap::new(), |acc, m| -> Result<_, DatabaseError> {
let id = m.id;
let project_id = DBProjectId(id);
let VersionLoaderData {
loaders,
project_types,
mut project_types,
games,
loader_loader_field_ids,
} = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default();
@@ -809,6 +869,17 @@ impl DBProject {
.filter(|x| loader_loader_field_ids.contains(&x.id))
.collect::<Vec<_>>();
let components_serial = m.components.0;
exp::compat::correct_project_types(
&components_serial,
&mut project_types,
);
let components = components_serial.clone().into_query(
ProjectId::from(project_id),
&project_query_context,
)
.wrap_err("failed to populate query components")?;
let project = ProjectQueryResult {
inner: DBProject {
id: DBProjectId(id),
@@ -845,6 +916,7 @@ impl DBProject {
&m.side_types_migration_review_status,
),
loaders,
components: components_serial,
},
categories: m.categories.unwrap_or_default(),
additional_categories: m.additional_categories.unwrap_or_default(),
@@ -858,16 +930,19 @@ impl DBProject {
urls,
aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true),
thread_id: DBThreadId(m.thread_id),
components,
};
acc.insert(m.id, (m.slug, project));
async move { Ok(acc) }
Ok(acc)
})
.await?;
?;
Ok(projects)
},
).await?;
)
.await
.wrap_internal_err("failed to fetch cached projects")?;
Ok(val)
}
@@ -983,4 +1058,6 @@ pub struct ProjectQueryResult {
pub gallery_items: Vec<DBGalleryItem>,
pub thread_id: DBThreadId,
pub aggregate_version_fields: Vec<VersionField>,
#[serde(flatten)]
pub components: exp::ProjectQuery,
}
@@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
};
use crate::database::redis::RedisPool;
use crate::models::exp;
use crate::models::projects::{FileType, VersionStatus};
use crate::routes::internal::delphi::DelphiRunParameters;
use chrono::{DateTime, Utc};
@@ -37,6 +38,7 @@ pub struct VersionBuilder {
pub status: VersionStatus,
pub requested_status: Option<VersionStatus>,
pub ordering: Option<i32>,
pub components: exp::VersionSerial,
}
#[derive(Clone)]
@@ -206,6 +208,7 @@ impl VersionBuilder {
status: self.status,
requested_status: self.requested_status,
ordering: self.ordering,
components: self.components,
};
version.insert(transaction).await?;
@@ -285,7 +288,7 @@ impl DBLoaderVersion {
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Deserialize, Serialize)]
pub struct DBVersion {
pub id: DBVersionId,
pub project_id: DBProjectId,
@@ -300,8 +303,17 @@ pub struct DBVersion {
pub status: VersionStatus,
pub requested_status: Option<VersionStatus>,
pub ordering: Option<i32>,
pub components: exp::VersionSerial,
}
impl PartialEq for DBVersion {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for DBVersion {}
impl DBVersion {
pub async fn insert(
&self,
@@ -312,12 +324,14 @@ impl DBVersion {
INSERT INTO versions (
id, mod_id, author_id, name, version_number,
changelog, date_published, downloads,
version_type, featured, status, ordering
version_type, featured, status, ordering,
components
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10, $11, $12
$9, $10, $11, $12,
$13
)
",
self.id as DBVersionId,
@@ -331,7 +345,9 @@ impl DBVersion {
&self.version_type,
self.featured,
self.status.as_str(),
self.ordering
self.ordering,
serde_json::to_value(&self.components)
.expect("serialization shouldn't fail"),
)
.execute(&mut *transaction)
.await?;
@@ -719,13 +735,14 @@ impl DBVersion {
).await?;
let res = sqlx::query!(
"
r#"
SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,
v.changelog changelog, v.date_published date_published, v.downloads downloads,
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,
v.components AS "components: sqlx::types::Json<exp::VersionSerial>"
FROM versions v
WHERE v.id = ANY($1);
",
"#,
&version_ids
)
.fetch(&mut exec)
@@ -746,6 +763,7 @@ impl DBVersion {
.filter(|x| loader_loader_field_ids.contains(&x.id))
.collect::<Vec<_>>();
let components_serial = v.components.0;
let query_version = VersionQueryResult {
inner: DBVersion {
id: DBVersionId(v.id),
@@ -762,6 +780,7 @@ impl DBVersion {
requested_status: v.requested_status
.map(|x| VersionStatus::from_string(&x)),
ordering: v.ordering,
components: components_serial,
},
files: {
let mut files = files.into_iter().map(|x| {
@@ -804,6 +823,8 @@ impl DBVersion {
project_types,
games,
dependencies,
// TODO populate
components: exp::VersionQuery::default(),
};
acc.insert(v.id, query_version);
@@ -937,7 +958,7 @@ impl DBVersion {
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Deserialize, Serialize)]
pub struct VersionQueryResult {
pub inner: DBVersion,
@@ -947,6 +968,8 @@ pub struct VersionQueryResult {
pub project_types: Vec<String>,
pub games: Vec<String>,
pub dependencies: Vec<DependencyQueryResult>,
#[serde(flatten)]
pub components: exp::VersionQuery,
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
@@ -993,6 +1016,14 @@ impl std::cmp::PartialOrd for VersionQueryResult {
}
}
impl std::cmp::PartialEq for VersionQueryResult {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
impl std::cmp::Eq for VersionQueryResult {}
impl std::cmp::Ord for DBVersion {
fn cmp(&self, other: &Self) -> Ordering {
let ordering_order = match (self.ordering, other.ordering) {
@@ -1061,6 +1092,7 @@ mod tests {
featured: false,
status: VersionStatus::Listed,
requested_status: None,
components: exp::VersionSerial::default(),
}
}
}
+12 -41
View File
@@ -13,6 +13,8 @@ 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;
@@ -50,57 +52,27 @@ impl DerefMut for ReadOnlyPgPool {
pub async fn connect_all() -> Result<(PgPool, ReadOnlyPgPool), sqlx::Error> {
info!("Initializing database connection");
let database_url =
dotenvy::var("DATABASE_URL").expect("`DATABASE_URL` not in .env");
let database_url = &ENV.DATABASE_URL;
let acquire_timeout =
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",
))
},
);
Duration::from_millis(ENV.DATABASE_ACQUIRE_TIMEOUT_MS);
let pool = PgPoolOptions::new()
.acquire_timeout(acquire_timeout)
.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),
)
.min_connections(ENV.DATABASE_MIN_CONNECTIONS)
.max_connections(ENV.DATABASE_MAX_CONNECTIONS)
.max_lifetime(Some(Duration::from_secs(60 * 60)))
.connect(&database_url)
.connect(database_url)
.await?;
let pool = PgPool::from(pool);
if let Ok(url) = dotenvy::var("READONLY_DATABASE_URL") {
if !ENV.READONLY_DATABASE_URL.is_empty() {
let ro_pool = PgPoolOptions::new()
.acquire_timeout(acquire_timeout)
.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),
)
.min_connections(ENV.READONLY_DATABASE_MIN_CONNECTIONS)
.max_connections(ENV.READONLY_DATABASE_MAX_CONNECTIONS)
.max_lifetime(Some(Duration::from_secs(60 * 60)))
.connect(&url)
.connect(&ENV.READONLY_DATABASE_URL)
.await?;
let ro_pool = PgPool::from(ro_pool);
@@ -112,8 +84,7 @@ pub async fn connect_all() -> Result<(PgPool, ReadOnlyPgPool), sqlx::Error> {
}
pub async fn check_for_migrations() -> eyre::Result<()> {
let uri =
dotenvy::var("DATABASE_URL").wrap_err("`DATABASE_URL` not in .env")?;
let uri = &ENV.DATABASE_URL;
let uri = uri.as_str();
if !Postgres::database_exists(uri)
.await
+7 -23
View File
@@ -1,3 +1,5 @@
use crate::env::ENV;
use super::models::DatabaseError;
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{TimeZone, Utc};
@@ -42,44 +44,26 @@ 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 =
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 wait_timeout = Duration::from_millis(ENV.REDIS_WAIT_TIMEOUT_MS);
let url = dotenvy::var("REDIS_URL").expect("Redis URL not set");
let url = &ENV.REDIS_URL;
let pool = Config::from_url(url.clone())
.builder()
.expect("Error building Redis pool")
.max_size(
dotenvy::var("REDIS_MAX_CONNECTIONS")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(10000),
)
.max_size(ENV.REDIS_MAX_CONNECTIONS as usize)
.wait_timeout(Some(wait_timeout))
.runtime(Runtime::Tokio1)
.build()
.expect("Redis connection failed");
let pool = RedisPool {
url,
url: url.clone(),
pool,
cache_list: Arc::new(DashMap::with_capacity(2048)),
meta_namespace: meta_namespace.into(),
};
let redis_min_connections = dotenvy::var("REDIS_MIN_CONNECTIONS")
.ok()
.and_then(|x| x.parse::<usize>().ok())
.unwrap_or(0);
let redis_min_connections = ENV.REDIS_MIN_CONNECTIONS;
let spawn_min_connections = (0..redis_min_connections)
.map(|_| {
let pool = pool.clone();
+286
View File
@@ -0,0 +1,286 @@
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 = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
SERVER_PING_RETRIES: usize = 3usize;
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
}
+4 -3
View File
@@ -9,6 +9,8 @@ use hex::ToHex;
use sha2::Digest;
use std::path::PathBuf;
use crate::env::ENV;
#[derive(Default)]
pub struct MockHost(());
@@ -54,8 +56,7 @@ impl FileHost for MockHost {
file_name: &str,
_expiry_secs: u32,
) -> Result<String, FileHostingError> {
let cdn_url = dotenvy::var("CDN_URL").unwrap();
Ok(format!("{cdn_url}/private/{file_name}"))
Ok(format!("{}/private/{file_name}", ENV.CDN_URL))
}
async fn delete_file(
@@ -77,7 +78,7 @@ fn get_file_path(
file_name: &str,
file_publicity: FileHostPublicity,
) -> PathBuf {
let mut path = PathBuf::from(dotenvy::var("MOCK_FILE_PATH").unwrap());
let mut path = PathBuf::from(ENV.MOCK_FILE_PATH.clone());
if matches!(file_publicity, FileHostPublicity::Private) {
path.push("private");
+24
View File
@@ -1,3 +1,5 @@
use std::str::FromStr;
use async_trait::async_trait;
use thiserror::Error;
@@ -63,3 +65,25 @@ 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),
})
}
}
+26 -201
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,6 +28,7 @@ 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;
@@ -83,10 +84,7 @@ pub fn app_setup(
gotenberg_client: GotenbergClient,
enable_background_tasks: bool,
) -> LabrinthConfig {
info!(
"Starting labrinth on {}",
dotenvy::var("BIND_ADDR").unwrap()
);
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
let automated_moderation_queue =
web::Data::new(AutomatedModerationQueue::default());
@@ -112,9 +110,8 @@ 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(
parse_var("LOCAL_INDEX_INTERVAL").unwrap_or(3600),
);
let local_index_interval =
Duration::from_secs(ENV.LOCAL_INDEX_INTERVAL);
let pool_ref = pool.clone();
let search_config_ref = search_config.clone();
let redis_pool_ref = redis_pool.clone();
@@ -123,12 +120,15 @@ pub fn app_setup(
let redis_pool_ref = redis_pool_ref.clone();
let search_config_ref = search_config_ref.clone();
async move {
background_task::index_search(
if let Err(e) = background_task::index_search(
pool_ref,
redis_pool_ref,
search_config_ref,
)
.await;
.await
{
warn!("Local project indexing failed: {e:#}");
}
}
});
@@ -138,20 +138,25 @@ pub fn app_setup(
scheduler.run(Duration::from_secs(60 * 5), move || {
let pool_ref = pool_ref.clone();
async move {
background_task::release_scheduled(pool_ref).await;
if let Err(e) =
background_task::release_scheduled(pool_ref).await
{
warn!("Syncing scheduled releases failed: {e:#}");
}
}
});
let version_index_interval = Duration::from_secs(
parse_var("VERSION_INDEX_INTERVAL").unwrap_or(1800),
);
let version_index_interval =
Duration::from_secs(ENV.VERSION_INDEX_INTERVAL);
let pool_ref = pool.clone();
let redis_pool_ref = redis_pool.clone();
scheduler.run(version_index_interval, move || {
let pool_ref = pool_ref.clone();
let redis = redis_pool_ref.clone();
async move {
update_versions(pool_ref, redis).await;
if let Err(e) = update_versions(pool_ref, redis).await {
warn!("Version update failed: {e:#}");
}
}
});
@@ -163,7 +168,12 @@ pub fn app_setup(
let client_ref = client_ref.clone();
let redis_ref = redis_pool_ref.clone();
async move {
background_task::payouts(pool_ref, client_ref, redis_ref).await;
if let Err(e) =
background_task::payouts(pool_ref, client_ref, redis_ref)
.await
{
warn!("Payout task failed: {e:#}");
}
}
});
@@ -349,188 +359,3 @@ 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
}
+42 -48
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::file_hosting::{S3BucketConfig, S3Host};
use labrinth::env::ENV;
use labrinth::file_hosting::{FileHostKind, 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::{check_env_vars, clickhouse, database, file_hosting};
use labrinth::{app_config, env};
use labrinth::{clickhouse, database, file_hosting};
use std::ffi::CStr;
use std::sync::Arc;
use tracing::{Instrument, error, info, info_span};
use tracing::{Instrument, info, info_span};
use tracing_actix_web::TracingLogger;
use utoipa::OpenApi;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
@@ -58,11 +58,7 @@ 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");
if check_env_vars() {
error!("Some environment variables are missing!");
std::process::exit(1);
}
env::init().expect("failed to initialize environment variables");
// Sentry must be set up before the async runtime is started
// <https://docs.sentry.io/platforms/rust/guides/actix-web/>
@@ -70,11 +66,8 @@ fn main() -> std::io::Result<()> {
// Has no effect if not set.
let sentry = sentry::init(sentry::ClientOptions {
release: sentry::release_name!(),
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()),
traces_sample_rate: ENV.SENTRY_TRACES_SAMPLE_RATE,
environment: Some((&ENV.SENTRY_ENVIRONMENT).into()),
..Default::default()
});
if sentry.is_enabled() {
@@ -99,10 +92,7 @@ async fn app() -> std::io::Result<()> {
.unwrap();
if args.run_background_task.is_none() {
info!(
"Starting labrinth on {}",
dotenvy::var("BIND_ADDR").unwrap()
);
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
if !args.no_migrations {
database::check_for_migrations()
@@ -119,40 +109,44 @@ async fn app() -> std::io::Result<()> {
// Redis connector
let redis_pool = RedisPool::new("");
let storage_backend =
dotenvy::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string());
let storage_backend = ENV.STORAGE_BACKEND;
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
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(),
match storage_backend {
FileHostKind::S3 => {
let not_empty = |v: &str| -> String {
assert!(!v.is_empty(), "S3 env var is empty");
v.to_string()
};
Arc::new(
S3Host::new(
config_from_env("PUBLIC"),
config_from_env("PRIVATE"),
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),
},
)
.unwrap(),
)
}
"local" => Arc::new(file_hosting::MockHost::new()),
_ => panic!("Invalid storage backend specified. Aborting startup!"),
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
};
info!("Initializing clickhouse connection");
@@ -160,8 +154,7 @@ async fn app() -> std::io::Result<()> {
let search_config = search::SearchConfig::new(None);
let stripe_client =
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
let anrok_client = anrok::Client::from_env().unwrap();
let email_queue =
@@ -185,7 +178,8 @@ async fn app() -> std::io::Result<()> {
email_queue,
muralpay,
)
.await;
.await
.map_err(std::io::Error::other)?;
return Ok(());
}
@@ -272,7 +266,7 @@ async fn app() -> std::io::Result<()> {
.into_app()
.configure(|cfg| app_config(cfg, labrinth_config.clone()))
})
.bind(dotenvy::var("BIND_ADDR").unwrap())?
.bind(&ENV.BIND_ADDR)?
.run()
.await
}
+33
View File
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::models::{ids::OrganizationId, projects::ProjectStatus};
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Project {
/// Human-readable friendly name of the project.
#[validate(
length(min = 3, max = 64),
custom(function = "crate::util::validate::validate_name")
)]
pub name: String,
/// Slug of the project, used in vanity URLs.
#[validate(
length(min = 3, max = 64),
regex(path = *crate::util::validate::RE_URL_SAFE)
)]
pub slug: String,
/// Short description of the project.
#[validate(length(min = 3, max = 255))]
pub summary: String,
/// A long description of the project, in markdown.
#[validate(length(max = 65536))]
pub description: String,
/// What status the user would like the project to be in after review.
pub requested_status: ProjectStatus,
/// What organization the project belongs to.
pub organization_id: Option<OrganizationId>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Version {}
+20
View File
@@ -0,0 +1,20 @@
//! Compatibility utilities for V3 API.
use crate::models::exp::ProjectSerial;
const MINECRAFT_JAVA_SERVER: &str = "minecraft_java_server";
/// Adjusts V3 project types based on a project's components.
///
/// The experimental API does not have a concept of project types; instead, a
/// project's "type" is implicit based on what components it has.
/// To reflect this in the V3 API, we manually add `project_types` values
/// for compatibility with stuff like searching.
pub fn correct_project_types(
components: &ProjectSerial,
project_types: &mut Vec<String>,
) {
if components.minecraft_server.is_some() {
project_types.push(MINECRAFT_JAVA_SERVER.into());
}
}
+335
View File
@@ -0,0 +1,335 @@
macro_rules! define {
() => {};
(
$(#[$meta:meta])*
$vis:vis struct $name:ident {
$(
#[base(
$($field_base_meta:meta),*
)]
#[edit(
$($field_edit_meta:meta),*
)]
#[create($create:ident)]
$(#[$field_meta:meta])*
$field_vis:vis $field:ident: $field_ty:ty
),* $(,)?
}
$($rest:tt)*
) => { paste::paste! {
$(#[$meta])*
$vis struct $name {
$(
$(#[$field_meta])*
$(#[$field_base_meta])*
$field_vis $field: $field_ty,
)*
}
$(#[$meta])*
$vis struct [< $name Edit >] {
$(
$(#[$field_meta])*
$(#[$field_edit_meta])*
$field_vis $field: Option<$field_ty>,
)*
}
impl $crate::models::exp::component::Component for $name {
type EntityId = $crate::models::ids::ProjectId;
type Query = $name;
type Edit = [< $name Edit >];
}
impl $crate::models::exp::component::ComponentQuery for $name {
type Component = $name;
type Context = $crate::models::exp::project::ProjectQueryContext;
type Requirements = $crate::models::exp::project::ProjectQueryRequirements;
fn collect_requirements(
_serial: &Self::Component,
_entity_id: <Self::Component as Component>::EntityId,
_requirements: &mut Self::Requirements,
) {}
fn populate(
serial: Self::Component,
_entity_id: <Self::Component as Component>::EntityId,
_context: &Self::Context,
) -> Result<Self> {
Ok(serial)
}
}
impl $crate::models::exp::component::ComponentEdit for [< $name Edit >] {
type Component = $name;
fn create(self) -> Result<Self::Component> {
Ok($name {
$(
$field: $crate::models::exp::component::unwrap_edit::$create(
self.$field,
stringify!($field),
)?,
)*
})
}
async fn apply_to(
self,
#[allow(unused_variables)]
component: &mut Self::Component,
) -> Result<()> {
$(
if let Some(f) = self.$field {
component.$field = f;
}
)*
Ok(())
}
}
$crate::models::exp::component::define!($($rest)*);
}};
}
pub mod unwrap_edit {
use eyre::{Result, eyre};
pub fn required<T>(field: Option<T>, field_name: &str) -> Result<T> {
field.ok_or_else(|| eyre!("missing field `{field_name}`"))
}
pub fn optional<T>(
field: Option<Option<T>>,
field_name: &str,
) -> Result<Option<T>> {
match field {
// present value
Some(Some(t)) => Ok(Some(t)),
// value is omitted from json -> no value
None => Ok(None),
// value is in json but is null -> empty
Some(None) => Err(eyre!("missing field `{field_name}`")),
}
}
pub fn default<T: Default>(
field: Option<T>,
_field_name: &str,
) -> Result<T> {
Ok(field.unwrap_or_default())
}
}
macro_rules! relations {
($vis:vis static $name:ident: $component_kind:ty = $expr:block) => {
$vis static $name: std::sync::LazyLock<Vec<$crate::models::exp::component::ComponentRelation<$component_kind>>> = std::sync::LazyLock::new(|| {
#[allow(unused_imports)]
use $crate::models::exp::component::{ComponentKindExt, ComponentKindArrayExt};
Vec::<$crate::models::exp::component::ComponentRelation<$component_kind>>::from($expr)
});
};
}
pub(crate) use define;
use eyre::Result;
pub(crate) use relations;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{collections::HashSet, hash::Hash};
use thiserror::Error;
pub trait ComponentKind:
Clone + Send + Sync + PartialEq + Eq + Hash + 'static
{
}
/// Data attached to an entity (like a project or a version), comparable to a
/// component in the [ECS paradigm](https://en.wikipedia.org/wiki/Entity_component_system).
///
/// The struct that implements this trait is the *serial form* of the
/// component, as stored in the database. When it is queried or edited, the
/// schema may take a different form - see [`Component::Query`],
/// [`Component::Edit`].
pub trait Component: Sized + Serialize + DeserializeOwned {
/// Type of ID that entities which have this type of component use to
/// identify themselves.
///
/// - For project components, this is [`ProjectId`].
///
/// [`ProjectId`]: crate::models::ids::ProjectId
type EntityId: Clone + Copy + Eq + Hash + Send + Sync;
/// Schema of the data returned when querying a component of this type from
/// the backend.
///
/// See [`ComponentQuery`].
type Query: ComponentQuery<Component = Self>;
/// Schema of a modification that can be applied to an existing component of
/// this type.
///
/// See [`ComponentEdit`].
type Edit: ComponentEdit<Component = Self>;
}
/// Schema of the data returned when querying a component of type
/// [`Self::Component`] from the backend.
///
/// The [`Component`] stores persistent, serialized data; but when we
/// request a project, we also request its components, and we may want to
/// request extra data alongside the serialized form. For example, if our
/// component stores a project ID to another project, we may want to return
/// that project's name, icon, etc. alongside the ID. [`Component::Query`]
/// provides a way to populate this extra data.
pub trait ComponentQuery: Sized {
/// Type of serial component this [`ComponentQuery`] is queried from.
type Component: Component<Query = Self>;
/// Type of the whole set of information that a query requests from the
/// database.
///
/// - For project components, this is [`ProjectQueryRequirements`].
///
/// [`ProjectQueryRequirements`]: crate::models::exp::project::ProjectQueryRequirements
type Requirements;
/// Type of context provided during [`ComponentQuery::populate`].
///
/// - For project components, this is [`ProjectQueryContext`].
///
/// [`ProjectQueryContext`]: crate::models::exp::project::ProjectQueryContext
type Context;
/// What information does this query type require from the database to
/// populate itself (excluding the [`ComponentQuery::Component`])?
///
/// For example, if the [`ComponentQuery::Component`] has a projecet ID,
/// this will add the project ID to `requirements`. This will require the
/// fetcher to also fetch this project ID, which will be available in the
/// [`ComponentQuery::Context`] during [`ComponentQuery::populate`].
fn collect_requirements(
serial: &Self::Component,
entity_id: <Self::Component as Component>::EntityId,
requirements: &mut Self::Requirements,
);
/// Creates the final component with all queried data, using the serialized
/// form of the component ([`ComponentQuery::Component`]) and any additional
/// info requested in [`ComponentQuery::collect_requirements`]
///
/// # Errors
///
/// Errors if some required data in the `context` is missing, indicating a
/// logic bug.
fn populate(
serial: Self::Component,
entity_id: <Self::Component as Component>::EntityId,
context: &Self::Context,
) -> Result<Self>;
}
/// Schema of a modification to an existing component, or potentially creation
/// of a component.
///
/// The [`Component`] stores persistent, serialized data; but when we want to
/// edit only specific fields of an existing component, we have to be able to
/// exclude fields which are not edited by wrapping the field in an [`Option`].
/// This trait provides a schema for doing this.
pub trait ComponentEdit: Sized {
/// Type of serial component this [`ComponentQuery`] is a modification for.
type Component: Component<Edit = Self>;
/// Attempts to create a [`ComponentEdit::Component`] if this edit has all
/// of the appropriate fields set.
///
/// # Errors
///
/// Errors if a required field is missing.
fn create(self) -> Result<Self::Component>;
/// Applies this edit to an existing component.
///
/// Errors if an edit could not be applied.
// note: this is `async` because in the future this might issue db/sqlx queries
#[expect(async_fn_in_trait, reason = "internal trait")]
async fn apply_to(self, component: &mut Self::Component) -> Result<()>;
}
#[derive(Debug, Clone)]
pub enum ComponentRelation<K> {
/// If one of these components is present, then it can only be present with
/// other components from this set.
Only(HashSet<K>),
/// If component `0` is present, then `1` must also be present.
Requires(K, K),
}
pub trait ComponentKindExt<K> {
fn requires(self, other: K) -> ComponentRelation<K>;
}
impl<K> ComponentKindExt<K> for K {
fn requires(self, other: K) -> ComponentRelation<K> {
ComponentRelation::Requires(self, other)
}
}
pub trait ComponentKindArrayExt<K> {
fn only(self) -> ComponentRelation<K>;
}
impl<K: ComponentKind, const N: usize> ComponentKindArrayExt<K> for [K; N] {
fn only(self) -> ComponentRelation<K> {
ComponentRelation::Only(self.iter().cloned().collect())
}
}
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
pub enum ComponentRelationError<K: ComponentKind> {
#[error("no components")]
NoComponents,
#[error("component `{target:?}` is missing")]
Missing { target: K },
#[error(
"only components {only:?} can be together, found extra components {extra:?}"
)]
Only { only: HashSet<K>, extra: HashSet<K> },
#[error("component `{target:?}` requires `{requires:?}`")]
Requires { target: K, requires: K },
}
pub fn kinds_valid<K: ComponentKind>(
kinds: &HashSet<K>,
relations: &[ComponentRelation<K>],
) -> Result<(), ComponentRelationError<K>> {
for relation in relations {
match relation {
ComponentRelation::Only(set) => {
if kinds.iter().any(|k| set.contains(k)) {
let extra: HashSet<_> =
kinds.difference(set).cloned().collect();
if !extra.is_empty() {
return Err(ComponentRelationError::Only {
only: set.clone(),
extra,
});
}
}
}
ComponentRelation::Requires(a, b) => {
if kinds.contains(a) && !kinds.contains(b) {
return Err(ComponentRelationError::Requires {
target: a.clone(),
requires: b.clone(),
});
}
}
}
}
Ok(())
}
+388
View File
@@ -0,0 +1,388 @@
use std::time::Duration;
use chrono::{DateTime, Utc};
use eyre::Result;
use serde::{Deserialize, Serialize};
use tracing::warn;
use validator::Validate;
use crate::{
models::{
exp::{
ProjectComponentKind,
component::{self, Component, ComponentEdit, ComponentQuery},
project::{
ProjectComponent, ProjectQueryContext, ProjectQueryRequirements,
},
},
ids::{ProjectId, VersionId},
},
util::error::Context,
};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Language {
En,
Es,
Pt,
Fr,
De,
It,
Nl,
Ru,
Uk,
Pl,
Cs,
Sk,
Hu,
Ro,
Bg,
Hr,
Sr,
El,
Tr,
Ar,
He,
Hi,
Bn,
Ur,
Zh,
Ja,
Ko,
Th,
Vi,
Id,
Ms,
Tl,
Sv,
No,
Da,
Fi,
Lt,
Lv,
Et,
}
component::define! {
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ModProject {}
/// Listing for a Minecraft server.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ServerProject {
#[base(serde(default))]
#[edit(serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
))]
#[create(optional)]
/// Maximum number of players allowed on the server.
pub max_players: Option<u32>,
#[base(serde(default))]
#[edit(serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
))]
#[create(optional)]
/// Country which this server is hosted in.
#[validate(length(min = 2, max = 2))]
pub country: Option<String>,
#[base(serde(default))]
#[edit(serde(default))]
#[create(default)]
/// Languages which the owners of this server prefer.
pub languages: Vec<Language>,
#[base(serde(default))]
#[edit(serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
))]
#[create(optional)]
/// Which version of the listing this server is currently using.
pub active_version: Option<VersionId>,
}
/// Version of a Minecraft Java server listing.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct JavaServerVersion {}
/// Listing for a Minecraft Bedrock server.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct BedrockServerProject {
#[base()]
#[edit(serde(default))]
#[create(required)]
/// Address (IP or domain name) of the Bedrock server, excluding port.
#[validate(length(max = 255))]
pub address: String,
#[base()]
#[edit(serde(default))]
#[create(required)]
/// Port which the server runs on.
pub port: u16,
}
}
impl ProjectComponent for ModProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftMod
}
}
impl ProjectComponent for ServerProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftServer
}
}
impl ProjectComponent for JavaServerProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftJavaServer
}
}
impl ProjectComponent for BedrockServerProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftBedrockServer
}
}
/// Listing for a Minecraft Java server.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct JavaServerProject {
/// Address (IP or domain name) of the Java server, excluding port.
#[validate(length(max = 255))]
pub address: String,
/// Port which the server runs on.
pub port: u16,
/// What game content this server is using.
pub content: ServerContent,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct JavaServerProjectEdit {
#[validate(length(max = 255))]
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub content: Option<ServerContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerProjectQuery {
pub address: String,
pub port: u16,
pub content: ServerContentQuery,
pub ping: Option<JavaServerPing>,
}
impl Component for JavaServerProject {
type EntityId = ProjectId;
type Query = JavaServerProjectQuery;
type Edit = JavaServerProjectEdit;
}
impl ComponentQuery for JavaServerProjectQuery {
type Component = JavaServerProject;
type Requirements = ProjectQueryRequirements;
type Context = ProjectQueryContext;
fn collect_requirements(
serial: &Self::Component,
project_id: ProjectId,
requirements: &mut ProjectQueryRequirements,
) {
match serial.content {
ServerContent::Vanilla { .. } => {}
ServerContent::Modpack { version_id } => {
requirements.partial_versions.insert(version_id);
}
}
requirements.minecraft_java_server_pings.insert(project_id);
}
fn populate(
serial: Self::Component,
project_id: ProjectId,
context: &ProjectQueryContext,
) -> Result<Self> {
Ok(Self {
address: serial.address,
port: serial.port,
content: match serial.content {
ServerContent::Vanilla {
supported_game_versions,
recommended_game_version,
} => ServerContentQuery::Vanilla {
supported_game_versions,
recommended_game_version,
},
ServerContent::Modpack { version_id } => {
match context.partial_versions.get(&version_id) {
Some(version) => ServerContentQuery::Modpack {
version_id,
project_id: version.project_id,
project_name: version.project_name.clone(),
project_icon: version.project_icon.clone(),
},
None => {
// TODO: should be upgraded to an error,
// but it's too easy to fall into this illegal state right now
warn!("no modpack info for version {version_id:?}");
ServerContentQuery::Modpack {
version_id,
project_id: ProjectId(0),
project_name: String::new(),
project_icon: String::new(),
}
}
}
}
},
ping: context
.minecraft_java_server_pings
.get(&project_id)
.cloned(),
})
}
}
impl ComponentEdit for JavaServerProjectEdit {
type Component = JavaServerProject;
fn create(self) -> Result<Self::Component> {
Ok(JavaServerProject {
address: self.address.wrap_err("missing `address`")?,
port: self.port.wrap_err("missing `port`")?,
content: self.content.unwrap_or_default(),
})
}
async fn apply_to(self, component: &mut Self::Component) -> Result<()> {
if let Some(address) = self.address {
component.address = address;
}
if let Some(port) = self.port {
component.port = port;
}
if let Some(content) = self.content {
component.content = content;
}
Ok(())
}
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContent {
/// Server runs modded content with a modpack found on the Modrinth platform.
Modpack {
/// Version ID of the modpack which the server runs.
///
/// This version may or may not belong to the server project, since
/// server projects may also be treated as modpacks.
version_id: VersionId,
},
/// Server is a vanilla Minecraft server.
Vanilla {
/// List of supported Minecraft Java client versions which can join this
/// server.
supported_game_versions: Vec<String>,
/// Recommended Minecraft Java client version to use to join this server.
recommended_game_version: Option<String>,
},
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContentQuery {
/// Server runs modded content with a modpack found on the Modrinth platform.
Modpack {
version_id: VersionId,
project_id: ProjectId,
project_name: String,
project_icon: String,
},
/// Server is a vanilla Minecraft server.
Vanilla {
/// List of supported Minecraft Java client versions which can join this
/// server.
supported_game_versions: Vec<String>,
/// Recommended Minecraft Java client version to use to join this server.
recommended_game_version: Option<String>,
},
}
impl Default for ServerContent {
fn default() -> Self {
ServerContent::Vanilla {
supported_game_versions: Vec::new(),
recommended_game_version: None,
}
}
}
/// Recorded ping attempt that Labrinth made to a Minecraft Java server project.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerPing {
/// When the ping was performed.
pub when: DateTime<Utc>,
/// Address of the server at the time of the ping.
pub address: String,
/// Port of the server at the time of the ping.
pub port: u16,
/// If the ping was successful, info on the ping response.
pub data: Option<JavaServerPingData>,
}
/// Ping response data for a Minecraft Java server.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerPingData {
/// How long it took for the Labrinth worker to ping the server.
///
/// Note: this is explicitly *not* a client-side ping time, so this should
/// not be used to display to a client how much latency they have to a
/// specific server. This is purely for internal metrics.
pub latency: Duration,
/// Reported version name of the server.
pub version_name: String,
/// Reported version protocol number of the server.
pub version_protocol: u32,
/// Description/MOTD of the server as shown in the server list.
pub description: String,
/// Number of players online at the time.
pub players_online: u32,
/// Maximum number of players allowed on the server.
pub players_max: u32,
}
component::relations! {
pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = {
use ProjectComponentKind::*;
[
[MinecraftMod].only(),
[MinecraftServer, MinecraftJavaServer, MinecraftBedrockServer].only(),
MinecraftJavaServer.requires(MinecraftServer),
MinecraftBedrockServer.requires(MinecraftServer),
]
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Highly experimental and unstable API endpoint models.
//!
//! These are used for testing new API patterns and exploring future endpoints,
//! which may or may not make it into an official release.
//!
//! # Projects and versions
//!
//! Projects and versions work in an ECS-like architecture, where each project
//! is an entity (project ID), and components can be attached to that project to
//! determine the project's type, like a Minecraft mod, data pack, etc. Project
//! components *may* store extra data (like a server listing which stores the
//! server address), but typically, the version will store this data in *version
//! components*.
pub mod base;
pub mod compat;
pub mod component;
pub mod minecraft;
pub mod project;
pub mod version;
pub use project::{
PROJECT_COMPONENT_RELATIONS, ProjectComponentKind, ProjectEdit,
ProjectQuery, ProjectSerial,
};
pub use version::{
VersionComponentKind, VersionCreate, VersionEdit, VersionQuery,
VersionSerial,
};
+269
View File
@@ -0,0 +1,269 @@
use eyre::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use validator::Validate;
use crate::{
database::{
models::{DBProjectId, DBVersionId},
redis::RedisPool,
},
models::{
exp::{
component::{
self, Component, ComponentEdit, ComponentKind, ComponentQuery,
},
minecraft,
},
ids::{ProjectId, VersionId},
},
queue::server_ping,
util::error::Context,
};
pub trait ProjectComponent: Component<EntityId = ProjectId> {
fn kind() -> ProjectComponentKind;
}
macro_rules! define_project_components {
(
$(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)?
) => {
// kinds
#[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")]
const _: () = {
fn assert_implements_component<T: ProjectComponent>() {}
fn assert_components_implement_trait() {
$(assert_implements_component::<$ty>();)*
}
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProjectComponentKind {
$($variant_name,)*
}
impl ComponentKind for ProjectComponentKind {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
pub struct ProjectSerial {
$(
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
impl ProjectSerial {
#[must_use]
pub fn component_kinds(&self) -> HashSet<ProjectComponentKind> {
let mut kinds = HashSet::new();
$(
if self.$field_name.is_some() {
kinds.insert(ProjectComponentKind::$variant_name);
}
)*
kinds
}
pub fn collect_query_requirements(
&self,
project_id: ProjectId,
requirements: &mut ProjectQueryRequirements,
) {
$(
if let Some(component) = &self.$field_name {
<<$ty as Component>::Query as ComponentQuery>::collect_requirements(
component,
project_id,
requirements
);
}
)*
}
pub fn into_query(
self,
project_id: ProjectId,
context: &ProjectQueryContext,
) -> Result<ProjectQuery> {
Ok(ProjectQuery {
$(
$field_name: match self.$field_name {
Some(serial) => {
<$ty as Component>::Query::populate(
serial,
project_id,
context,
)
.map(Some)
.wrap_err(concat!("failed to populate `", stringify!($ty), "`"))?
}
None => None,
},
)*
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectQuery {
$(
pub $field_name: Option<Query<$ty>>,
)*
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ProjectEdit {
$(
#[validate(nested)]
#[serde(default)]
pub $field_name: Option<Edit<$ty>>,
)*
}
impl ProjectEdit {
#[must_use]
pub fn component_kinds(&self) -> HashSet<ProjectComponentKind> {
let mut kinds = HashSet::new();
$(
if self.$field_name.is_some() {
kinds.insert(ProjectComponentKind::$variant_name);
}
)*
kinds
}
pub fn create(self) -> Result<ProjectSerial> {
Ok(ProjectSerial {
$(
$field_name: self
.$field_name
.map(<<$ty as Component>::Edit as ComponentEdit>::create)
.transpose()?,
)*
})
}
}
};
}
// needed because the `utoipa::ToSchema` macro is broken
// when you have a `::` in the type path
type Edit<T> = <T as Component>::Edit;
type Query<T> = <T as Component>::Query;
define_project_components![
(minecraft_mod, MinecraftMod): minecraft::ModProject,
(minecraft_server, MinecraftServer): minecraft::ServerProject,
(minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerProject,
(minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerProject,
];
component::relations! {
pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = {
minecraft::PROJECT_COMPONENT_RELATIONS.clone()
}
}
// query logic
#[derive(Default)]
pub struct ProjectQueryRequirements {
pub partial_versions: HashSet<VersionId>,
pub minecraft_java_server_pings: HashSet<ProjectId>,
}
pub struct ProjectQueryContext {
pub partial_versions: HashMap<VersionId, PartialVersion>,
pub minecraft_java_server_pings:
HashMap<ProjectId, minecraft::JavaServerPing>,
}
#[derive(Clone, Debug)]
pub struct PartialVersion {
pub project_id: ProjectId,
pub project_name: String,
pub project_icon: String,
}
pub async fn fetch_query_context(
projects: &[(ProjectId, &ProjectSerial)],
db: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
redis: &RedisPool,
) -> Result<ProjectQueryContext> {
let mut requirements = ProjectQueryRequirements::default();
for (project_id, project) in projects {
project.collect_query_requirements(*project_id, &mut requirements);
}
let ProjectQueryRequirements {
partial_versions,
minecraft_java_server_pings,
} = requirements;
let partial_versions = if partial_versions.is_empty() {
HashMap::new()
} else {
sqlx::query!(
r#"
SELECT
v.id AS "version_id: DBVersionId",
m.id AS "project_id: DBProjectId",
m.name AS "project_name!",
COALESCE(m.icon_url, '') AS "project_icon!"
FROM versions v
INNER JOIN mods m ON m.id = v.mod_id
WHERE v.id = ANY($1)
"#,
&partial_versions
.iter()
.map(|id| DBVersionId::from(*id).0)
.collect::<Vec<_>>(),
)
.fetch_all(db)
.await
.wrap_err("failed to fetch partial versions")?
.into_iter()
.map(|row| {
(
VersionId::from(row.version_id),
PartialVersion {
project_id: ProjectId::from(row.project_id),
project_name: row.project_name,
project_icon: row.project_icon,
},
)
})
.collect::<HashMap<_, _>>()
};
let minecraft_java_server_pings =
minecraft_java_server_pings.into_iter().collect::<Vec<_>>();
let minecraft_java_server_pings = if minecraft_java_server_pings.is_empty()
{
HashMap::new()
} else {
let mut redis = redis.connect().await?;
redis
.get_many_deserialized_from_json::<minecraft::JavaServerPing>(
server_ping::REDIS_NAMESPACE,
&minecraft_java_server_pings
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
)
.await?
.into_iter()
.enumerate()
.filter_map(|(idx, ping)| {
ping.map(|ping| (minecraft_java_server_pings[idx], ping))
})
.collect::<HashMap<_, _>>()
};
Ok(ProjectQueryContext {
partial_versions,
minecraft_java_server_pings,
})
}
+98
View File
@@ -0,0 +1,98 @@
use crate::models::exp::{
base,
component::{Component, ComponentKind},
minecraft,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use validator::Validate;
macro_rules! define_version_components {
(
$(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)?
) => {
// kinds
#[expect(dead_code, reason = "static check so $ty implements `Component`")]
const _: () = {
fn assert_implements_component<T: Component>() {}
fn assert_components_implement_trait() {
$(assert_implements_component::<$ty>();)*
}
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VersionComponentKind {
$($variant_name,)*
}
impl ComponentKind for VersionComponentKind {}
// structs
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Version {
#[validate(nested)]
pub base: base::Version,
$(
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VersionSerial {
$(
pub $field_name: Option<$ty>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct VersionCreate {
#[validate(nested)]
pub base: Option<base::Project>,
$(
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
impl VersionCreate {
#[must_use]
pub fn component_kinds(&self) -> HashSet<VersionComponentKind> {
let mut kinds = HashSet::new();
$(if self.$field_name.is_some() {
kinds.insert(VersionComponentKind::$variant_name);
})*
kinds
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct VersionQuery {
$(
pub $field_name: Option<Query<$ty>>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct VersionEdit {
$(
#[validate(nested)]
pub $field_name: Option<Edit<$ty>>,
)*
}
};
}
// needed because the `utoipa::ToSchema` macro is broken
// when you have a `::` in the type path
type Edit<T> = <T as Component>::Edit;
type Query<T> = <T as Component>::Query;
define_version_components![
(minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion,
];
+1
View File
@@ -1,4 +1,5 @@
pub mod error;
pub mod exp;
pub mod v2;
pub mod v3;
+2 -6
View File
@@ -1,6 +1,4 @@
use crate::{
models::v2::projects::LegacySideType, util::env::parse_strings_from_var,
};
use crate::{env::ENV, models::v2::projects::LegacySideType};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -44,9 +42,7 @@ fn validate_download_url(
return Err(validator::ValidationError::new("invalid URL"));
}
let domains = parse_strings_from_var("WHITELISTED_MODPACK_DOMAINS")
.unwrap_or_default();
if !domains.contains(
if !ENV.WHITELISTED_MODPACK_DOMAINS.contains(
&url.domain()
.ok_or_else(|| validator::ValidationError::new("invalid URL"))?
.to_string(),
+27 -8
View File
@@ -4,6 +4,7 @@ use std::mem;
use crate::database::models::loader_fields::VersionField;
use crate::database::models::project_item::{LinkUrl, ProjectQueryResult};
use crate::database::models::version_item::VersionQueryResult;
use crate::models::exp;
use crate::models::ids::{
FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
};
@@ -95,6 +96,8 @@ pub struct Project {
/// The status of the manual review of the migration of side types of this project
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
#[serde(flatten)]
pub components: exp::ProjectQuery,
/// Aggregated loader-fields across its myriad of versions
#[serde(flatten)]
pub fields: HashMap<String, Vec<serde_json::Value>>,
@@ -212,6 +215,7 @@ impl From<ProjectQueryResult> for Project {
side_types_migration_review_status: m
.side_types_migration_review_status,
fields,
components: data.components,
}
}
}
@@ -642,7 +646,7 @@ impl SideTypesMigrationReviewStatus {
}
/// A specific version of a project
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct Version {
/// The ID of the version, encoded as a base62 string.
pub id: VersionId,
@@ -685,6 +689,8 @@ pub struct Version {
/// Ordering override, lower is returned first
pub ordering: Option<i32>,
#[serde(flatten)]
pub components: exp::VersionQuery,
// All other fields are loader-specific VersionFields
// These are flattened during serialization
#[serde(deserialize_with = "skip_nulls")]
@@ -761,6 +767,7 @@ impl From<VersionQueryResult> for Version {
.into_iter()
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
.collect(),
components: data.components,
}
}
}
@@ -771,7 +778,9 @@ impl From<VersionQueryResult> for Version {
/// Draft - Version is not displayed on project, and not accessible by URL
/// Unlisted - Version is not displayed on project, and accessible by URL
/// Scheduled - Version is scheduled to be released in the future
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
#[derive(
Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum VersionStatus {
Listed,
@@ -855,7 +864,7 @@ impl VersionStatus {
}
/// A single project file, with a url for the file and the file's hash
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct VersionFile {
/// The ID of the file. Every file has an ID once created, but it
/// is not known until it indeed has been created.
@@ -878,7 +887,9 @@ pub struct VersionFile {
/// A dendency which describes what versions are required, break support, or are optional to the
/// version's functionality
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
pub struct Dependency {
/// The specific version id that the dependency uses
pub version_id: Option<VersionId>,
@@ -890,7 +901,9 @@ pub struct Dependency {
pub dependency_type: DependencyType,
}
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
#[derive(
Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum VersionType {
Release,
@@ -914,7 +927,9 @@ impl VersionType {
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum DependencyType {
Required,
@@ -951,7 +966,9 @@ impl DependencyType {
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum FileType {
RequiredResourcePack,
@@ -998,7 +1015,9 @@ impl FileType {
}
/// A project loader
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
#[serde(transparent)]
pub struct Loader(pub String);
+3 -2
View File
@@ -12,6 +12,7 @@ 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,
@@ -913,10 +914,10 @@ async fn unprovision_subscriptions(
let res = reqwest::Client::new()
.post(format!(
"{}/modrinth/v0/servers/{}/suspend",
dotenvy::var("ARCHON_URL")?,
ENV.ARCHON_URL,
server_id
))
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.header("X-Master-Key", &ENV.PYRO_API_KEY)
.json(&serde_json::json!({
"reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring {
"cancelled"
+10 -9
View File
@@ -5,6 +5,7 @@ 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,
@@ -36,16 +37,16 @@ impl Mailer {
) -> Result<Arc<AsyncSmtpTransport<Tokio1Executor>>, MailError> {
let maybe_transport = match self {
Mailer::Uninitialized => {
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 username = &ENV.SMTP_USERNAME;
let password = &ENV.SMTP_PASSWORD;
let host = &ENV.SMTP_HOST;
let port = ENV.SMTP_PORT;
let creds = (!username.is_empty())
.then(|| Credentials::new(username, password));
let creds = (!username.is_empty()).then(|| {
Credentials::new(username.clone(), password.clone())
});
let tls_setting = match dotenvy::var("SMTP_TLS")?.as_str() {
let tls_setting = match ENV.SMTP_TLS.as_str() {
"none" => Tls::None,
"opportunistic_start_tls" => Tls::Opportunistic(
TlsParameters::new(host.to_string())?,
@@ -65,7 +66,7 @@ impl Mailer {
};
let mut mailer =
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
AsyncSmtpTransport::<Tokio1Executor>::relay(host)?
.port(port)
.tls(tls_setting);
+17 -17
View File
@@ -8,6 +8,7 @@ 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;
@@ -96,10 +97,18 @@ pub struct MailingIdentity {
impl MailingIdentity {
pub fn from_env() -> dotenvy::Result<Self> {
Ok(Self {
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(),
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())
},
})
}
}
@@ -558,9 +567,7 @@ async fn collect_template_variables(
NotificationBody::ResetPassword { flow } => {
let url = format!(
"{}/{}?flow={}",
dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_RESET_PASSWORD_PATH")?,
flow
ENV.SITE_URL, ENV.SITE_RESET_PASSWORD_PATH, flow
);
map.insert(RESETPASSWORD_URL, url);
@@ -571,9 +578,7 @@ async fn collect_template_variables(
NotificationBody::VerifyEmail { flow } => {
let url = format!(
"{}/{}?flow={}",
dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_VERIFY_EMAIL_PATH")?,
flow
ENV.SITE_URL, ENV.SITE_VERIFY_EMAIL_PATH, flow
);
map.insert(VERIFYEMAIL_URL, url);
@@ -603,11 +608,7 @@ async fn collect_template_variables(
}
NotificationBody::PaymentFailed { amount, service } => {
let url = format!(
"{}/{}",
dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_BILLING_PATH")?,
);
let url = format!("{}/{}", ENV.SITE_URL, ENV.SITE_BILLING_PATH,);
let mut map = HashMap::new();
map.insert(PAYMENTFAILED_AMOUNT, amount.clone());
@@ -748,8 +749,7 @@ async fn dynamic_email_body(
key: &str,
) -> Result<String, ApiError> {
get_or_set_cached_dynamic_html(redis, key, || async {
let site_url = dotenvy::var("SITE_URL")
.wrap_internal_err("SITE_URL is not set")?;
let site_url = &ENV.SITE_URL;
let site_url = site_url.trim_end_matches('/');
let url = format!("{site_url}/_internal/templates/email/dynamic");
+1
View File
@@ -3,5 +3,6 @@ pub mod billing;
pub mod email;
pub mod moderation;
pub mod payouts;
pub mod server_ping;
pub mod session;
pub mod socket;
+10 -9
View File
@@ -4,6 +4,7 @@ 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};
@@ -454,7 +455,7 @@ impl AutomatedModerationQueue {
let client = reqwest::Client::new();
let res = client
.post(format!("{}/v1/fingerprints", dotenvy::var("FLAME_ANVIL_URL")?))
.post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"fingerprints": hashes.iter().filter_map(|x| x.3).collect::<Vec<u32>>()
}))
@@ -553,11 +554,11 @@ impl AutomatedModerationQueue {
continue;
}
let flame_projects = if flame_files.is_empty() {
Vec::new()
} else {
let res = client
.post(format!("{}v1/mods", dotenvy::var("FLAME_ANVIL_URL")?))
let flame_projects = if flame_files.is_empty() {
Vec::new()
} else {
let res = client
.post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"modIds": flame_files.iter().map(|x| x.1).collect::<Vec<_>>()
}))
@@ -664,16 +665,16 @@ impl AutomatedModerationQueue {
.insert_many(members.into_iter().map(|x| x.user_id).collect(), &mut transaction, &redis)
.await?;
if let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") {
if !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
crate::util::webhook::send_slack_project_webhook(
project.inner.id.into(),
&pool,
&redis,
webhook_url,
&ENV.MODERATION_SLACK_WEBHOOK,
Some(
format!(
"*<{}/user/AutoMod|AutoMod>* changed project status from *{}* to *Rejected*",
dotenvy::var("SITE_URL")?,
ENV.SITE_URL,
&project.inner.status.as_friendly_str(),
)
.to_string(),
+2 -5
View File
@@ -1,4 +1,5 @@
use crate::database::PgPool;
use crate::env::ENV;
use chrono::{Datelike, Duration, TimeZone, Utc};
use eyre::{Context, Result, eyre};
use rust_decimal::{Decimal, dec};
@@ -62,11 +63,7 @@ pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> {
.await
.wrap_err("failed to fetch charges awaiting affiliate payout")?;
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 default_affiliate_revenue_split = ENV.DEFAULT_AFFILIATE_REVENUE_SPLIT;
let (
mut insert_usap_charges,
@@ -8,6 +8,7 @@ use serde_json::json;
use crate::{
database::models::payout_item::DBPayout,
env::ENV,
models::payouts::{
PayoutMethod, PayoutMethodFee, PayoutMethodType, PayoutStatus,
TremendousCurrency, TremendousDetails, TremendousForexResponse,
@@ -210,7 +211,7 @@ pub(super) async fn execute(
"products": [
method_id,
],
"campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?,
"campaign_id": ENV.TREMENDOUS_CAMPAIGN_ID.as_str(),
}]
});
+31 -43
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,21 +76,18 @@ impl Default for PayoutsQueue {
}
pub fn create_muralpay_client() -> Result<muralpay::Client> {
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))
Ok(muralpay::Client::new(
&ENV.MURALPAY_API_URL,
ENV.MURALPAY_API_KEY.as_str(),
ENV.MURALPAY_TRANSFER_API_KEY.as_str(),
))
}
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,
source_account_id: ENV.MURALPAY_SOURCE_ACCOUNT_ID,
})
}
@@ -185,11 +182,8 @@ impl PayoutsQueue {
let mut creds = self.credential.write().await;
let client = reqwest::Client::new();
let combined_key = format!(
"{}:{}",
dotenvy::var("PAYPAL_CLIENT_ID")?,
dotenvy::var("PAYPAL_CLIENT_SECRET")?
);
let combined_key =
format!("{}:{}", ENV.PAYPAL_CLIENT_ID, ENV.PAYPAL_CLIENT_SECRET);
let formatted_key = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(combined_key)
@@ -206,7 +200,7 @@ impl PayoutsQueue {
}
let credential: PaypalCredential = client
.post(format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?))
.post(format!("{}oauth2/token", ENV.PAYPAL_API_URL))
.header("Accept", "application/json")
.header("Accept-Language", "en_US")
.header("Authorization", formatted_key)
@@ -274,7 +268,7 @@ impl PayoutsQueue {
if no_api_prefix.unwrap_or(false) {
path.to_string()
} else {
format!("{}{path}", dotenvy::var("PAYPAL_API_URL")?)
format!("{}{path}", ENV.PAYPAL_API_URL)
},
)
.header(
@@ -355,13 +349,10 @@ impl PayoutsQueue {
) -> Result<X, ApiError> {
let client = reqwest::Client::new();
let mut request = client
.request(
method,
format!("{}{path}", dotenvy::var("TREMENDOUS_API_URL")?),
)
.request(method, format!("{}{path}", ENV.TREMENDOUS_API_URL))
.header(
"Authorization",
format!("Bearer {}", dotenvy::var("TREMENDOUS_API_KEY")?),
format!("Bearer {}", ENV.TREMENDOUS_API_KEY),
);
if let Some(body) = body {
@@ -511,8 +502,8 @@ impl PayoutsQueue {
let client = reqwest::Client::new();
let res = client
.get(format!("{}accounts/cash", dotenvy::var("BREX_API_URL")?))
.bearer_auth(&dotenvy::var("BREX_API_KEY")?)
.get(format!("{}accounts/cash", ENV.BREX_API_URL))
.bearer_auth(&ENV.BREX_API_KEY)
.send()
.await?
.json::<BrexResponse>()
@@ -538,16 +529,16 @@ impl PayoutsQueue {
pub async fn get_paypal_balance() -> Result<Option<AccountBalance>, ApiError>
{
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 api_username = &ENV.PAYPAL_NVP_USERNAME;
let api_password = &ENV.PAYPAL_NVP_PASSWORD;
let api_signature = &ENV.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";
@@ -870,7 +861,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(&dotenvy::var("ADITUDE_API_KEY")?)
.bearer_auth(&ENV.ADITUDE_API_KEY)
.json(&serde_json::json!({
"metrics": metrics,
"range": range,
@@ -1326,25 +1317,25 @@ pub async fn insert_bank_balances_and_webhook(
if inserted {
check_balance_with_webhook(
"paypal",
"PAYPAL_BALANCE_ALERT_THRESHOLD",
ENV.PAYPAL_BALANCE_ALERT_THRESHOLD,
paypal_result,
)
.await?;
check_balance_with_webhook(
"brex",
"BREX_BALANCE_ALERT_THRESHOLD",
ENV.BREX_BALANCE_ALERT_THRESHOLD,
brex_result,
)
.await?;
check_balance_with_webhook(
"tremendous",
"TREMENDOUS_BALANCE_ALERT_THRESHOLD",
ENV.TREMENDOUS_BALANCE_ALERT_THRESHOLD,
tremendous_result,
)
.await?;
check_balance_with_webhook(
"mural",
"MURAL_BALANCE_ALERT_THRESHOLD",
ENV.MURAL_BALANCE_ALERT_THRESHOLD,
mural_result,
)
.await?;
@@ -1357,14 +1348,11 @@ pub async fn insert_bank_balances_and_webhook(
async fn check_balance_with_webhook(
source: &str,
threshold_env_var_name: &str,
threshold: u64,
result: Result<Option<AccountBalance>, ApiError>,
) -> Result<Option<AccountBalance>, ApiError> {
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")?;
let maybe_threshold = if threshold > 0 { Some(threshold) } else { None };
let payout_alert_webhook = &ENV.PAYOUT_ALERT_SLACK_WEBHOOK;
match &result {
Ok(Some(account_balance)) => {
@@ -1379,7 +1367,7 @@ async fn check_balance_with_webhook(
threshold,
current_balance: available,
},
&payout_alert_webhook,
payout_alert_webhook,
)
.await?;
}
@@ -1394,7 +1382,7 @@ async fn check_balance_with_webhook(
source: source.to_owned(),
display_error: error.to_string(),
},
&payout_alert_webhook,
payout_alert_webhook,
)
.await?;
}
+307
View File
@@ -0,0 +1,307 @@
use crate::database::DBProject;
use crate::database::models::DBProjectId;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::exp;
use crate::models::ids::ProjectId;
use crate::{database::PgPool, util::error::Context};
use async_minecraft_ping::ServerDescription;
use chrono::{TimeDelta, Utc};
use clickhouse::{Client, Row};
use serde::Serialize;
use sqlx::types::Json;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::{Instrument, debug, info, info_span, warn};
pub struct ServerPingQueue {
pub db: PgPool,
pub redis: RedisPool,
pub clickhouse: Client,
}
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
pub fn new(db: PgPool, redis: RedisPool, clickhouse: Client) -> Self {
Self {
db,
redis,
clickhouse,
}
}
pub async fn ping_minecraft_java_servers(&self) -> eyre::Result<()> {
let server_projects = self.find_servers_to_ping().await?;
info!("Found {} servers to ping", server_projects.len());
let active_pings =
Arc::new(Semaphore::new(ENV.SERVER_PING_MAX_CONCURRENT));
let pings = server_projects
.into_iter()
.filter_map(|(project_id, java_server)| {
let address = java_server.address.to_string();
if address.trim().is_empty() {
debug!("Skipping ping for server project {project_id} with empty address");
return None;
}
let port = java_server.port;
let span = info_span!("ping", %project_id, %address, %port);
let active_pings = active_pings.clone();
let task = async move {
let _permit = active_pings.acquire().await.expect("semaphore should not be closed now");
let mut retries = ENV.SERVER_PING_RETRIES;
let result = loop {
match ping_server(&address, port).await {
Ok(ping) => {
debug!(?ping, "Received successful ping");
break Ok(ping);
}
Err(err) if retries == 0 => {
debug!("Failed to ping server in {:?}ms, no retries left: {err:#}", ENV.SERVER_PING_TIMEOUT_MS);
break Err(err);
}
Err(err) => {
debug!(%retries, "Failed to ping server in {:?}ms, retrying: {err:#}", ENV.SERVER_PING_TIMEOUT_MS);
retries -= 1;
continue;
}
};
};
(project_id, exp::minecraft::JavaServerPing {
when: Utc::now(),
address: address.to_string(),
port,
data: result.ok(),
})
};
Some(tokio::spawn(task.instrument(span)))
})
.collect::<JoinSet<_>>()
.join_all()
.await
.into_iter()
.filter_map(|result| result.ok())
.collect::<Vec<_>>();
if !pings.is_empty() {
let mut ch = self
.clickhouse
.insert::<ServerPingRecord>(CLICKHOUSE_TABLE)
.await
.wrap_err("failed to begin inserting ping records")?;
let mut redis = self
.redis
.connect()
.await
.wrap_err("failed to connect to redis")?;
for (project_id, ping) in &pings {
let data = ping.data.as_ref();
let row = ServerPingRecord {
recorded: ping.when.timestamp_nanos_opt().unwrap()
/ 100_000,
project_id: project_id.0,
address: ping.address.clone(),
port: ping.port,
latency_ms: data.map(|d| d.latency.as_millis() as u32),
description: data.map(|d| d.description.clone()),
version_name: data.map(|d| d.version_name.clone()),
version_protocol: data.map(|d| d.version_protocol),
players_online: data.map(|d| d.players_online),
players_max: data.map(|d| d.players_max),
};
ch.write(&row)
.await
.wrap_err("failed to write ping record")?;
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err("failed to set redis key")?;
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
}
ch.end()
.await
.wrap_err("failed to end inserting ping records")?;
}
let num_success =
pings.iter().filter(|(_, ping)| ping.data.is_some()).count();
let num_total = pings.len();
info!(
"Inserted ping results for {} servers - {num_success}/{num_total} successful",
pings.len()
);
Ok(())
}
async fn find_servers_to_ping(
&self,
) -> eyre::Result<Vec<(ProjectId, exp::minecraft::JavaServerProject)>> {
// first select all java servers
let all_server_projects = sqlx::query!(
r#"
SELECT id, components AS "components: Json<exp::ProjectSerial>"
FROM mods
WHERE components ? 'minecraft_java_server'
"#
)
.fetch_all(&self.db)
.await
.wrap_err("failed to fetch servers to ping")?;
if all_server_projects.is_empty() {
// we must early-exit, otherwise we'll run `redis.get_many()`,
// which runs `MGET` with no args; this gives:
// "ResponseError: wrong number of arguments for 'mget' command"
return Ok(Vec::new());
}
let mut redis = self
.redis
.connect()
.await
.wrap_err("failed to connect to redis")?;
// get the last ping info for all of them
// querying redis here, which is a cache, not the source of truth (clickhouse),
// but it should be fine since we don't usually flush redis
// and if we do miss an entry that we shouldn't, we just ping it again
let all_project_ids = all_server_projects
.iter()
.map(|row| ProjectId::from(DBProjectId(row.id)).to_string())
.collect::<Vec<_>>();
let all_server_last_pings = redis
.get_many_deserialized_from_json::<exp::minecraft::JavaServerPing>(
REDIS_NAMESPACE,
&all_project_ids,
)
.await
.wrap_err("failed to fetch server project last pings")?;
let now = Utc::now();
let projects_to_ping = all_server_projects
.into_iter()
.zip(all_server_last_pings)
// only include projects which:
// - have not had a ping in redis yet
// - OR their last ping was a failure
// - OR their last successful ping was more than `SERVER_PING_MIN_INTERVAL_SEC` seconds ago
.filter(|(_, ping)| {
let Some(ping) = ping else { return true };
if ping.data.is_none() {
return true;
};
ping.when.signed_duration_since(now)
> TimeDelta::seconds(
ENV.SERVER_PING_MIN_INTERVAL_SEC as i64,
)
})
.filter_map(|(row, _)| {
let server = row.components.0.minecraft_java_server?;
Some((ProjectId::from(DBProjectId(row.id)), server))
})
.collect::<Vec<_>>();
Ok(projects_to_ping)
}
}
async fn ping_server(
address: &str,
port: u16,
) -> eyre::Result<exp::minecraft::JavaServerPingData> {
let start = Instant::now();
let task = async move {
let conn = async_minecraft_ping::ConnectionConfig::build(address)
.with_port(port)
.connect()
.await
.wrap_err("failed to connect to server")?;
let status = conn
.status()
.await
.wrap_err("failed to get server status")?
.status;
Ok(exp::minecraft::JavaServerPingData {
latency: start.elapsed(),
version_name: status.version.name,
version_protocol: status.version.protocol,
description: match status.description {
ServerDescription::Plain(text)
| ServerDescription::Object { text } => text,
},
players_online: status.players.online,
players_max: status.players.max,
})
};
let timeout = Duration::from_millis(ENV.SERVER_PING_TIMEOUT_MS);
tokio::time::timeout(timeout, task)
.await
.wrap_err("server ping timed out")
.flatten()
}
#[derive(Debug, Row, Serialize, Clone)]
struct ServerPingRecord {
recorded: i64,
project_id: u64,
address: String,
port: u16,
latency_ms: Option<u32>,
description: Option<String>,
version_name: Option<String>,
version_protocol: Option<u32>,
players_online: Option<u32>,
players_max: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_rt::test]
async fn test_ping_server_success() {
let _status = ping_server("mc.hypixel.net", 25565).await.unwrap();
}
#[actix_rt::test]
async fn test_ping_server_invalid_address() {
_ = ping_server("invalid.invalid", 25565).await.unwrap_err();
}
}
+5 -6
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,11 +73,10 @@ pub async fn page_view_ingest(
})?;
let url_origin = url.origin().ascii_serialization();
let is_valid_url_origin =
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
.unwrap_or_default()
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
let is_valid_url_origin = ENV
.ANALYTICS_ALLOWED_ORIGINS
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
if !is_valid_url_origin {
return Err(ApiError::InvalidInput(
+4 -1
View File
@@ -9,6 +9,7 @@ use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchConfig;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::error::Context;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpRequest, HttpResponse, patch, post, web};
use serde::Deserialize;
@@ -156,6 +157,8 @@ pub async fn force_reindex(
) -> Result<HttpResponse, ApiError> {
use crate::search::indexing::index_projects;
let redis = redis.get_ref();
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
index_projects(pool.as_ref().clone(), redis.clone(), &config)
.await
.wrap_internal_err("failed to index projects")?;
Ok(HttpResponse::NoContent().finish())
}
@@ -1,6 +1,7 @@
use std::{collections::HashMap, net::Ipv4Addr, sync::Arc};
use crate::database::PgPool;
use crate::env::ENV;
use crate::{
auth::get_user_from_headers,
database::{
@@ -13,10 +14,7 @@ use crate::{
},
queue::{analytics::AnalyticsQueue, session::AuthQueue},
routes::analytics::FILTERED_HEADERS,
util::{
date::get_current_tenths_of_ms, env::parse_strings_from_var,
error::Context,
},
util::{date::get_current_tenths_of_ms, error::Context},
};
use actix_web::{HttpRequest, delete, get, patch, post, put, web};
use ariadne::ids::UserId;
@@ -70,11 +68,10 @@ async fn ingest_click(
})?;
let url_origin = url.origin().ascii_serialization();
let is_valid_url_origin =
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
.unwrap_or_default()
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
let is_valid_url_origin = ENV
.ANALYTICS_ALLOWED_ORIGINS
.iter()
.any(|origin| origin == "*" || url_origin == *origin);
if !is_valid_url_origin {
return Err(ApiError::InvalidInput(
+12 -11
View File
@@ -12,6 +12,7 @@ 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,
@@ -1437,7 +1438,7 @@ pub async fn active_servers(
pool: web::Data<PgPool>,
query: web::Query<ActiveServersQuery>,
) -> Result<HttpResponse, ApiError> {
let master_key = dotenvy::var("PYRO_API_KEY")?;
let master_key = &ENV.PYRO_API_KEY;
if req
.head()
@@ -1626,7 +1627,7 @@ pub async fn stripe_webhook(
if let Ok(event) = Webhook::construct_event(
&payload,
stripe_signature,
&dotenvy::var("STRIPE_WEBHOOK_SECRET")?,
&ENV.STRIPE_WEBHOOK_SECRET,
) {
struct PaymentIntentMetadata {
pub user_item: crate::database::models::user_item::DBUser,
@@ -2036,23 +2037,23 @@ pub async fn stripe_webhook(
client
.post(format!(
"{}/modrinth/v0/servers/{}/unsuspend",
dotenvy::var("ARCHON_URL")?,
ENV.ARCHON_URL,
id
))
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.header("X-Master-Key", &ENV.PYRO_API_KEY)
.send()
.await?
.error_for_status()?;
client
.post(format!(
"{}/modrinth/v0/servers/{}/reallocate",
dotenvy::var("ARCHON_URL")?,
id
))
"{}/modrinth/v0/servers/{}/reallocate",
ENV.ARCHON_URL,
id
))
.header(
"X-Master-Key",
dotenvy::var("PYRO_API_KEY")?,
&ENV.PYRO_API_KEY,
)
.json(&body)
.send()
@@ -2114,9 +2115,9 @@ pub async fn stripe_webhook(
let res = client
.post(format!(
"{}/modrinth/v0/servers/create",
dotenvy::var("ARCHON_URL")?,
ENV.ARCHON_URL,
))
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.header("X-Master-Key", &ENV.PYRO_API_KEY)
.json(&serde_json::json!({
"user_id": to_base62(metadata.user_item.id.0 as u64),
"name": server_name,
+148 -14
View File
@@ -1,6 +1,7 @@
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;
@@ -58,15 +59,42 @@ static DELPHI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.unwrap()
});
#[derive(Deserialize)]
/// 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)]
struct DelphiReportIssueDetails {
pub file: String,
pub key: String,
pub key: IssueDetailKey,
pub jar: Option<String>,
pub data: HashMap<String, serde_json::Value>,
pub severity: DelphiSeverity,
}
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
struct DelphiReport {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
@@ -89,7 +117,7 @@ impl DelphiReport {
pool: &PgPool,
redis: &RedisPool,
) -> Result<(), ApiError> {
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let webhook_url = ENV.DELPHI_SLACK_WEBHOOK.clone();
let mut message_header =
format!("⚠️ Suspicious traces found at {}", self.url);
@@ -115,7 +143,7 @@ impl DelphiReport {
self.project_id,
pool,
redis,
webhook_url,
&webhook_url,
Some(message_header),
)
.await
@@ -204,11 +232,34 @@ async fn ingest_report_deserialized(
.await
.wrap_internal_err("failed to check if pending issue details exist")?;
if record.pending_issue_details_exist {
info!(
"File's project already has pending issue details, is not entering tech review queue"
);
} else {
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 {
info!("File's project is entering tech review queue");
ThreadMessageBuilder {
@@ -220,6 +271,10 @@ 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
@@ -231,7 +286,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,
@@ -244,6 +299,7 @@ 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(),
@@ -274,7 +330,8 @@ async fn ingest_report_deserialized(
ReportIssueDetail {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id,
key: issue_detail.key,
key: issue_detail.key.0,
jar: issue_detail.jar,
file_path: issue_detail.file,
decompiled_source: decompiled_source.cloned().flatten(),
data: issue_detail.data,
@@ -317,7 +374,7 @@ pub async fn run(
);
DELPHI_CLIENT
.post(dotenvy::var("DELPHI_URL")?)
.post(&ENV.DELPHI_URL)
.json(&serde_json::json!({
"url": file_data.url,
"project_id": ProjectId(file_data.project_id.0 as u64),
@@ -332,6 +389,83 @@ 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,
@@ -407,7 +541,7 @@ async fn issue_type_schema(
&cache_entry
.insert((
DELPHI_CLIENT
.get(format!("{}/schema", dotenvy::var("DELPHI_URL")?))
.get(format!("{}/schema", ENV.DELPHI_URL))
.send()
.await
.and_then(|res| res.error_for_status())
+43 -45
View File
@@ -8,6 +8,7 @@ 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;
@@ -17,7 +18,6 @@ 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 = dotenvy::var("SELF_ADDR")?;
let self_addr = &ENV.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 = dotenvy::var("GITHUB_CLIENT_ID")?;
let client_id = &ENV.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 = dotenvy::var("DISCORD_CLIENT_ID")?;
let client_id = &ENV.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 = dotenvy::var("MICROSOFT_CLIENT_ID")?;
let client_id = &ENV.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 = dotenvy::var("GITLAB_CLIENT_ID")?;
let client_id = &ENV.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 = dotenvy::var("GOOGLE_CLIENT_ID")?;
let client_id = &ENV.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 = dotenvy::var("PAYPAL_API_URL")?;
let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?;
let api_url = &ENV.PAYPAL_API_URL;
let client_id = &ENV.PAYPAL_CLIENT_ID;
let auth_url = if api_url.contains("sandbox") {
"sandbox.paypal.com"
@@ -340,8 +340,7 @@ impl AuthProvider {
&self,
query: HashMap<String, String>,
) -> Result<String, AuthenticationError> {
let redirect_uri =
format!("{}/v2/auth/callback", dotenvy::var("SELF_ADDR")?);
let redirect_uri = format!("{}/v2/auth/callback", &ENV.SELF_ADDR);
#[derive(Deserialize)]
struct AccessToken {
@@ -353,8 +352,8 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?;
let client_id = ENV.GITHUB_CLIENT_ID.as_str();
let client_secret = ENV.GITHUB_CLIENT_SECRET.as_str();
let url = format!(
"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={client_secret}&code={code}&redirect_uri={redirect_uri}"
@@ -374,12 +373,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = dotenvy::var("DISCORD_CLIENT_ID")?;
let client_secret = dotenvy::var("DISCORD_CLIENT_SECRET")?;
let client_id = ENV.DISCORD_CLIENT_ID.as_str();
let client_secret = ENV.DISCORD_CLIENT_SECRET.as_str();
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);
@@ -399,12 +398,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?;
let client_secret = dotenvy::var("MICROSOFT_CLIENT_SECRET")?;
let client_id = ENV.MICROSOFT_CLIENT_ID.as_str();
let client_secret = ENV.MICROSOFT_CLIENT_SECRET.as_str();
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);
@@ -424,12 +423,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = dotenvy::var("GITLAB_CLIENT_ID")?;
let client_secret = dotenvy::var("GITLAB_CLIENT_SECRET")?;
let client_id = ENV.GITLAB_CLIENT_ID.as_str();
let client_secret = ENV.GITLAB_CLIENT_SECRET.as_str();
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);
@@ -449,12 +448,12 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?;
let client_secret = dotenvy::var("GOOGLE_CLIENT_SECRET")?;
let client_id = ENV.GOOGLE_CLIENT_ID.as_str();
let client_secret = ENV.GOOGLE_CLIENT_SECRET.as_str();
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);
@@ -529,9 +528,9 @@ impl AuthProvider {
let code = query
.get("code")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
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 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 mut map = HashMap::new();
map.insert("code", code.as_str());
@@ -580,9 +579,7 @@ impl AuthProvider {
.get("x-oauth-client-id")
.and_then(|x| x.to_str().ok());
if client_id
!= Some(&*dotenvy::var("GITHUB_CLIENT_ID").unwrap())
{
if client_id != Some(ENV.GITHUB_CLIENT_ID.as_str()) {
return Err(AuthenticationError::InvalidClientId);
}
}
@@ -732,7 +729,7 @@ impl AuthProvider {
}
}
AuthProvider::Steam => {
let api_key = dotenvy::var("STEAM_API_KEY")?;
let api_key = &ENV.STEAM_API_KEY;
#[derive(Deserialize)]
struct SteamResponse {
@@ -797,7 +794,7 @@ impl AuthProvider {
pub country: String,
}
let api_url = dotenvy::var("PAYPAL_API_URL")?;
let api_url = &ENV.PAYPAL_API_URL;
let paypal_user: PayPalUser = reqwest::Client::new()
.get(format!(
@@ -1100,10 +1097,11 @@ 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 !allowed_callback_urls.iter().any(|x| domain.ends_with(x))
if !ENV
.ALLOWED_CALLBACK_URLS
.iter()
.any(|x| domain.ends_with(x))
&& domain != "modrinth.com"
{
return Err(AuthenticationError::Url);
@@ -1396,9 +1394,9 @@ pub async fn delete_auth_provider(
pub async fn check_sendy_subscription(
email: &str,
) -> Result<bool, AuthenticationError> {
let url = dotenvy::var("SENDY_URL")?;
let id = dotenvy::var("SENDY_LIST_ID")?;
let api_key = dotenvy::var("SENDY_API_KEY")?;
let url = &ENV.SENDY_URL;
let id = &ENV.SENDY_LIST_ID;
let api_key = &ENV.SENDY_API_KEY;
if url.is_empty() || url == "none" {
tracing::info!(
@@ -1408,9 +1406,9 @@ pub async fn check_sendy_subscription(
}
let mut form = HashMap::new();
form.insert("api_key", &*api_key);
form.insert("api_key", api_key.as_str());
form.insert("email", email);
form.insert("list_id", &*id);
form.insert("list_id", id.as_str());
let client = reqwest::Client::new();
let response = client
+3 -1
View File
@@ -1,5 +1,6 @@
use std::{collections::HashMap, sync::LazyLock};
use crate::env::ENV;
use actix_web::{get, web};
use serde::{Deserialize, Serialize};
@@ -28,7 +29,8 @@ static GLOBALS: LazyLock<Globals> = LazyLock::new(|| Globals {
tax_compliance_thresholds: [(2025, 600), (2026, 2000)]
.into_iter()
.collect(),
captcha_enabled: dotenvy::var("HCAPTCHA_SECRET").is_ok_and(|x| x != "none"),
captcha_enabled: !ENV.HCAPTCHA_SECRET.is_empty()
&& ENV.HCAPTCHA_SECRET != "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_detail)
.service(update_issue_details)
.service(add_report);
}
@@ -388,6 +388,8 @@ 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>,
@@ -498,6 +500,7 @@ 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",
@@ -561,6 +564,7 @@ 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,
@@ -698,9 +702,9 @@ async fn search_projects(
let rows = sqlx::query!(
r#"
SELECT DISTINCT ON (m.id)
SELECT
m.id AS "project_id: DBProjectId",
t.id AS "thread_id: DBThreadId"
MIN(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
@@ -734,12 +738,14 @@ 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, 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
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
LIMIT $1 OFFSET $2
"#,
limit,
@@ -831,6 +837,7 @@ 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,
},
@@ -1119,6 +1126,8 @@ 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,
}
@@ -1131,14 +1140,13 @@ pub struct UpdateIssue {
security(("bearer_auth" = [])),
responses((status = NO_CONTENT))
)]
#[patch("/issue-detail/{id}")]
async fn update_issue_detail(
#[patch("/issue-detail")]
async fn update_issue_details(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
update_req: web::Json<UpdateIssue>,
path: web::Path<(DelphiReportIssueDetailsId,)>,
update_reqs: web::Json<Vec<UpdateIssue>>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
@@ -1148,44 +1156,76 @@ async fn update_issue_detail(
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 status = match update_req.verdict {
DelphiVerdict::Safe => DelphiStatus::Safe,
DelphiVerdict::Unsafe => DelphiStatus::Unsafe,
};
let results = sqlx::query!(
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!(
r#"
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
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
)
SELECT
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
(v.incoming_count = v.resolved_count) AS "all_found!",
(SELECT COUNT(*) FROM upserted) AS "upserted_count!"
FROM validated v
"#,
status as _,
issue_detail_id as _,
&detail_ids,
&verdicts,
)
.execute(&mut txn)
.fetch_one(&mut txn)
.await
.wrap_internal_err("failed to update issue detail")?;
if results.rows_affected() == 0 {
.wrap_internal_err("failed to update issue details")?;
if !record.all_found {
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 parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
let ip_addr = if ENV.CLOUDFLARE_INTEGRATION {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {
+5 -8
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,10 +40,7 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
.wrap(
Cors::default()
.allowed_origin_fn(|origin, _req_head| {
let allowed_origins =
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
.unwrap_or_default();
let allowed_origins = &ENV.ANALYTICS_ALLOWED_ORIGINS;
allowed_origins.contains(&"*".to_string())
|| allowed_origins.contains(
&origin
@@ -219,9 +216,9 @@ impl ApiError {
}
},
description: match self {
Self::Internal(e) => format!("{e:#?}"),
Self::Request(e) => format!("{e:#?}"),
Self::Auth(e) => format!("{e:#?}"),
Self::Internal(e) => format!("{e:#}"),
Self::Request(e) => format!("{e:#}"),
Self::Auth(e) => format!("{e:#}"),
_ => self.to_string(),
},
details: match self {
+2 -5
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use crate::database::PgPool;
use crate::env::ENV;
use actix_web::{HttpRequest, HttpResponse, get, web};
use serde::{Deserialize, Serialize};
@@ -94,11 +95,7 @@ pub async fn forge_updates(
}
let mut response = ForgeUpdates {
homepage: format!(
"{}/mod/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
id
),
homepage: format!("{}/mod/{}", ENV.SITE_URL, id),
promos: HashMap::new(),
};
+38 -29
View File
@@ -214,7 +214,7 @@ pub async fn project_get(
) -> Result<HttpResponse, ApiError> {
// Convert V2 data to V3 data
// Call V3 project creation
let response = v3::projects::project_get(
let project = match v3::projects::project_get_internal(
req,
info,
pool.clone(),
@@ -222,23 +222,21 @@ pub async fn project_get(
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)?;
{
Ok(resp) => resp.0,
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
Err(err) => return Err(err),
};
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Project>(response).await {
Ok(project) => {
let version_item = match project.versions.first() {
Some(vid) => {
version_item::DBVersion::get((*vid).into(), &**pool, &redis)
.await?
}
None => None,
};
let project = LegacyProject::from(project, version_item);
Ok(HttpResponse::Ok().json(project))
let version_item = match project.versions.first() {
Some(vid) => {
version_item::DBVersion::get((*vid).into(), &**pool, &redis).await?
}
Err(response) => Ok(response),
}
None => None,
};
let project = LegacyProject::from(project, version_item);
Ok(HttpResponse::Ok().json(project))
}
//checks the validity of a project id or slug
@@ -249,7 +247,7 @@ pub async fn project_get_check(
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
// Returns an id only, do not need to convert
v3::projects::project_get_check(info, pool, redis)
v3::projects::project_get_check_internal(info, pool, redis)
.await
.or_else(v2_reroute::flatten_404_error)
}
@@ -269,7 +267,7 @@ pub async fn dependency_list(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// TODO: tests, probably
let response = v3::projects::dependency_list(
let response = v3::projects::dependency_list_internal(
req,
info,
pool.clone(),
@@ -512,12 +510,16 @@ pub async fn project_edit(
moderation_message_body: v2_new_project.moderation_message_body,
monetization_status: v2_new_project.monetization_status,
side_types_migration_review_status: None, // Not to be exposed in v2
loader_fields: HashMap::new(), // Loader fields are not a thing in v2
// None of the below is present in v2
loader_fields: HashMap::new(),
minecraft_server: None,
minecraft_java_server: None,
minecraft_bedrock_server: None,
};
// This returns 204 or failure so we don't need to do anything with it
let project_id = info.clone().0;
let mut response = v3::projects::project_edit(
let mut response = v3::projects::project_edit_internal(
req.clone(),
info,
pool.clone(),
@@ -754,7 +756,7 @@ pub async fn project_icon_edit(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_icon_edit(
v3::projects::project_icon_edit_internal(
web::Query(v3::projects::Extension { ext: ext.ext }),
req,
info,
@@ -778,7 +780,7 @@ pub async fn delete_project_icon(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::delete_project_icon(
v3::projects::delete_project_icon_internal(
req,
info,
pool,
@@ -814,7 +816,7 @@ pub async fn add_gallery_item(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::add_gallery_item(
v3::projects::add_gallery_item_internal(
web::Query(v3::projects::Extension { ext: ext.ext }),
req,
web::Query(v3::projects::GalleryCreateQuery {
@@ -865,7 +867,7 @@ pub async fn edit_gallery_item(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::edit_gallery_item(
v3::projects::edit_gallery_item_internal(
req,
web::Query(v3::projects::GalleryEditQuery {
url: item.url,
@@ -897,7 +899,7 @@ pub async fn delete_gallery_item(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::delete_gallery_item(
v3::projects::delete_gallery_item_internal(
req,
web::Query(v3::projects::GalleryDeleteQuery { url: item.url }),
pool,
@@ -919,7 +921,7 @@ pub async fn project_delete(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_delete(
v3::projects::project_delete_internal(
req,
info,
pool,
@@ -928,6 +930,7 @@ pub async fn project_delete(
session_queue,
)
.await
.map(|()| HttpResponse::NoContent().body(""))
.or_else(v2_reroute::flatten_404_error)
}
@@ -940,7 +943,7 @@ pub async fn project_follow(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_follow(req, info, pool, redis, session_queue)
v3::projects::project_follow_internal(req, info, pool, redis, session_queue)
.await
.or_else(v2_reroute::flatten_404_error)
}
@@ -954,7 +957,13 @@ pub async fn project_unfollow(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_unfollow(req, info, pool, redis, session_queue)
.await
.or_else(v2_reroute::flatten_404_error)
v3::projects::project_unfollow_internal(
req,
info,
pool,
redis,
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub async fn team_members_get_project(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let response = v3::teams::team_members_get_project(
let response = v3::teams::team_members_get_project_internal(
req,
info,
pool,
+16 -8
View File
@@ -104,7 +104,7 @@ pub async fn version_list(
include_changelog: filters.include_changelog,
};
let response = v3::versions::version_list(
let response = v3::versions::version_list_internal(
req,
info,
web::Query(filters),
@@ -211,6 +211,7 @@ pub async fn version_get(
let response =
v3::versions::version_get_helper(req, id, pool, redis, session_queue)
.await
.map(|b| HttpResponse::Ok().json(b))
.or_else(v2_reroute::flatten_404_error)?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
@@ -277,7 +278,7 @@ pub async fn version_edit(
}
// Get the older version to get info from
let old_version = v3::versions::version_get_helper(
let old_version = match v3::versions::version_get_helper(
req.clone(),
(*info).0,
pool.clone(),
@@ -285,12 +286,19 @@ pub async fn version_edit(
session_queue.clone(),
)
.await
.or_else(v2_reroute::flatten_404_error)?;
let old_version =
match v2_reroute::extract_ok_json::<Version>(old_version).await {
Ok(version) => version,
Err(response) => return Ok(response),
};
{
Ok(resp) => resp,
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
Err(err) => return Err(err),
};
let old_version = match v2_reroute::extract_ok_json::<Version>(
HttpResponse::Ok().json(old_version.0),
)
.await
{
Ok(version) => version,
Err(response) => return Ok(response),
};
// If this has 'mrpack_loaders' as a loader field previously, this is a modpack.
// Therefore, if we are modifying the 'loader' field in this case,
+2 -1
View File
@@ -99,7 +99,8 @@ pub async fn collection_create(
&mut transaction,
&redis,
)
.await?
.await
.wrap_internal_err("failed to fetch created projects")?
.into_iter()
.map(|x| x.inner.id.into())
.collect::<Vec<ProjectId>>();
+6 -1
View File
@@ -36,7 +36,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(images::config)
.configure(notifications::config)
.configure(organizations::config)
.configure(project_creation::config)
.configure(projects::config)
.configure(reports::config)
.configure(shared_instance_version_creation::config)
@@ -65,6 +64,12 @@ pub fn utoipa_config(
.wrap(default_cors())
.configure(payouts::config),
);
cfg.service(
utoipa_actix_web::scope("/v3/project")
.wrap(default_cors())
.configure(projects::utoipa_config)
.configure(project_creation::config),
);
}
pub async fn hello_world() -> Result<HttpResponse, ApiError> {
+4 -5
View File
@@ -4,6 +4,7 @@ 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};
@@ -212,7 +213,7 @@ pub async fn paypal_webhook(
\"webhook_id\": \"{}\",
\"webhook_event\": {body}
}}",
dotenvy::var("PAYPAL_WEBHOOK_ID")?
ENV.PAYPAL_WEBHOOK_ID,
)),
None,
)
@@ -322,7 +323,7 @@ pub async fn tremendous_webhook(
})?;
let mut mac: Hmac<Sha256> = Hmac::new_from_slice(
dotenvy::var("TREMENDOUS_PRIVATE_KEY")?.as_bytes(),
ENV.TREMENDOUS_PRIVATE_KEY.as_bytes(),
)
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
mac.update(body.as_bytes());
@@ -1114,9 +1115,7 @@ async fn update_compliance_status(
}
fn tax_compliance_payout_threshold() -> Option<Decimal> {
dotenvy::var("COMPLIANCE_PAYOUT_THRESHOLD")
.ok()
.and_then(|s| s.parse().ok())
ENV.COMPLIANCE_PAYOUT_THRESHOLD.parse().ok()
}
#[derive(Deserialize)]
@@ -10,6 +10,7 @@ use crate::database::models::{self, DBUser, image_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError};
use crate::models::error::ApiError;
use crate::models::exp;
use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
@@ -43,8 +44,12 @@ use std::sync::Arc;
use thiserror::Error;
use validator::Validate;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(project_create).service(project_create_with_id);
mod new;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(project_create)
.service(project_create_with_id)
.configure(new::config);
}
#[derive(Error, Debug)]
@@ -93,6 +98,30 @@ pub enum CreateError {
LimitReached,
}
impl From<crate::routes::ApiError> for CreateError {
fn from(value: crate::routes::ApiError) -> Self {
match value {
crate::routes::ApiError::Database(err) => Self::DatabaseError(err),
crate::routes::ApiError::SqlxDatabase(err) => {
Self::SqlxDatabaseError(err)
}
crate::routes::ApiError::Authentication(err) => {
Self::Unauthorized(err)
}
crate::routes::ApiError::CustomAuthentication(err) => {
Self::CustomAuthenticationError(err)
}
crate::routes::ApiError::InvalidInput(err)
| crate::routes::ApiError::Validation(err) => {
Self::InvalidInput(err)
}
err => Self::DatabaseError(models::DatabaseError::SchemaError(
err.to_string(),
)),
}
}
}
impl actix_web::ResponseError for CreateError {
fn status_code(&self) -> StatusCode {
match self {
@@ -262,7 +291,8 @@ pub async fn undo_uploads(
Ok(())
}
#[post("/project")]
#[utoipa::path]
#[post("")]
pub async fn project_create(
req: HttpRequest,
payload: Multipart,
@@ -327,7 +357,8 @@ pub async fn project_create_internal(
/// Allows creating a project with a specific ID.
///
/// This is a testing endpoint only accessible behind an admin key.
#[post("/project/{id}", guard = "admin_key_guard")]
#[utoipa::path]
#[post("/{id}", guard = "admin_key_guard")]
pub async fn project_create_with_id(
req: HttpRequest,
mut payload: Multipart,
@@ -870,6 +901,7 @@ async fn project_create_inner(
.collect(),
color: icon_data.and_then(|x| x.2),
monetization_status: MonetizationStatus::Monetized,
components: exp::ProjectSerial::default(),
};
let project_builder = project_builder_actual.clone();
@@ -992,6 +1024,7 @@ async fn project_create_inner(
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
fields: HashMap::new(), // Fields instantiate to empty
components: exp::ProjectQuery::default(),
};
Ok(HttpResponse::Ok().json(response))
@@ -1076,6 +1109,7 @@ async fn create_initial_version(
version_type: version_data.release_channel.to_string(),
requested_status: None,
ordering: version_data.ordering,
components: exp::VersionSerial::default(),
};
Ok(version)
@@ -0,0 +1,337 @@
use actix_http::StatusCode;
use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web};
use eyre::eyre;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::{
auth::get_user_from_headers,
database::{
PgPool,
models::{
self, DBOrganization, DBTeamMember, DBUser,
project_item::ProjectBuilder, thread_item::ThreadBuilder,
version_item::VersionBuilder,
},
redis::RedisPool,
},
models::{
exp::{self, ProjectComponentKind, component::ComponentRelationError},
ids::ProjectId,
pats::Scopes,
projects::{
MonetizationStatus, ProjectStatus, VersionStatus, VersionType,
},
teams::{OrganizationPermissions, ProjectPermissions},
threads::ThreadType,
v3::user_limits::UserLimits,
},
queue::session::AuthQueue,
routes::ApiError,
util::{error::Context, validate::validation_errors_to_string},
};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(create);
}
#[derive(Debug, thiserror::Error)]
pub enum CreateError {
#[error("project limit reached")]
LimitReached,
#[error("invalid component kinds")]
ComponentKinds(ComponentRelationError<ProjectComponentKind>),
#[error("failed to validate request: {0}")]
Validation(String),
#[error("slug collision")]
SlugCollision,
#[error(transparent)]
Api(#[from] ApiError),
}
impl CreateError {
pub fn as_api_error(&self) -> crate::models::error::ApiError<'_> {
match self {
Self::LimitReached => crate::models::error::ApiError {
error: "limit_reached",
description: self.to_string(),
details: None,
},
Self::ComponentKinds(err) => crate::models::error::ApiError {
error: "component_kinds",
description: format!("{self}: {err}"),
details: Some(
serde_json::to_value(err)
.expect("should never fail to serialize"),
),
},
Self::Validation(_) => crate::models::error::ApiError {
error: "validation",
description: self.to_string(),
details: None,
},
Self::SlugCollision => crate::models::error::ApiError {
error: "slug_collision",
description: self.to_string(),
details: None,
},
Self::Api(err) => err.as_api_error(),
}
}
}
impl ResponseError for CreateError {
fn status_code(&self) -> actix_http::StatusCode {
match self {
Self::LimitReached
| Self::ComponentKinds(_)
| Self::Validation(_)
| Self::SlugCollision => StatusCode::BAD_REQUEST,
Self::Api(err) => err.status_code(),
}
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).json(self.as_api_error())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ProjectCreate {
pub base: exp::base::Project,
#[serde(flatten)]
#[validate(nested)]
pub components: exp::ProjectEdit,
}
/// Creates a new project with the given components.
///
/// Components must include `base` ([`exp::base::Project`]), and at least one
/// other component.
#[utoipa::path]
#[put("")]
pub async fn create(
req: HttpRequest,
db: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
web::Json(create): web::Json<ProjectCreate>,
) -> Result<web::Json<ProjectId>, CreateError> {
// check that the user can make a project
let (_, user) = get_user_from_headers(
&req,
&**db,
&redis,
&session_queue,
Scopes::PROJECT_CREATE,
)
.await
.map_err(ApiError::from)?;
let limits = UserLimits::get_for_projects(&user, &db)
.await
.map_err(ApiError::from)?;
if limits.current >= limits.max {
return Err(CreateError::LimitReached);
}
// check if the given details are valid
create.validate().map_err(|err| {
CreateError::Validation(validation_errors_to_string(err, None))
})?;
let ProjectCreate { base, components } = create;
exp::component::kinds_valid(
&components.component_kinds(),
&exp::PROJECT_COMPONENT_RELATIONS,
)
.map_err(CreateError::ComponentKinds)?;
// get component-specific data
// use struct destructor syntax, so we get a compile error
// if we add a new field and don't add it here
let exp::base::Project {
name,
slug,
summary,
description,
requested_status,
organization_id,
} = base;
// check if this won't conflict with an existing project
let mut txn = db
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let same_slug_record = sqlx::query!(
"SELECT EXISTS(
SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1
)",
slug.to_lowercase()
)
.fetch_one(&mut txn)
.await
.wrap_internal_err("failed to query if slug already exists")?;
if same_slug_record.exists.unwrap_or(false) {
return Err(CreateError::SlugCollision);
}
// create project and supporting records in db
let team = if let Some(organization_id) = organization_id {
let org = DBOrganization::get_id(organization_id.into(), &**db, &redis)
.await
.wrap_internal_err("failed to get organization")?
.wrap_request_err("invalid organization ID")?;
let team_member =
DBTeamMember::get_from_user_id(org.team_id, user.id.into(), &**db)
.await
.wrap_internal_err(
"failed to get team member of user for organization",
)?;
let perms = OrganizationPermissions::get_permissions_by_role(
&user.role,
&team_member,
);
if !perms
.is_some_and(|p| p.contains(OrganizationPermissions::ADD_PROJECT))
{
return Err(ApiError::Auth(eyre!(
"no permission to create projects in this organization"
))
.into());
}
models::team_item::TeamBuilder {
members: Vec::new(),
}
} else {
let members = vec![models::team_item::TeamMemberBuilder {
user_id: user.id.into(),
role: crate::models::teams::DEFAULT_ROLE.to_owned(),
is_owner: true,
permissions: ProjectPermissions::all(),
organization_permissions: None,
accepted: true,
payouts_split: Decimal::ONE_HUNDRED,
ordering: 0,
}];
models::team_item::TeamBuilder { members }
};
let team_id = team
.insert(&mut txn)
.await
.wrap_internal_err("failed to insert team")?;
let project_id: ProjectId = models::generate_project_id(&mut txn)
.await
.wrap_internal_err("failed to generate project ID")?
.into();
// TODO: special casing certain components
let mut monetization_status = MonetizationStatus::Monetized;
let mut version_builder = None::<VersionBuilder>;
if components.minecraft_server.is_some() {
// servers are not part of the monetization pool;
// they generate no payouts for their owners
monetization_status = MonetizationStatus::ForceDemonetized;
// servers may never have a version added, if e.g. they are a vanilla server
// but we need at least 1 version on this project for certain features,
// like search indexing, to work.
// so we generate a synthetic initial version.
let version_id = models::generate_version_id(&mut txn)
.await
.wrap_internal_err("failed to generate project ID")?;
version_builder = Some(VersionBuilder {
version_id,
project_id: project_id.into(),
author_id: user.id.into(),
name: "__synthetic".into(),
version_number: String::new(),
changelog: String::new(),
files: Vec::new(),
dependencies: Vec::new(),
loaders: Vec::new(),
version_fields: Vec::new(),
version_type: VersionType::Release.to_string(),
featured: false,
status: VersionStatus::Listed,
requested_status: None,
ordering: None,
components: exp::VersionSerial::default(),
});
}
let project_builder = ProjectBuilder {
project_id: project_id.into(),
team_id,
organization_id: organization_id.map(From::from),
name: name.clone(),
summary: summary.clone(),
description: description.clone(),
icon_url: None,
raw_icon_url: None,
license_url: None,
categories: vec![],
additional_categories: vec![],
initial_versions: vec![],
status: ProjectStatus::Draft,
requested_status: Some(requested_status),
license: "LicenseRef-Unknown".into(),
slug: Some(slug.clone()),
link_urls: vec![],
gallery_items: vec![],
color: None,
monetization_status,
components: components
.create()
.wrap_request_err("failed to create components")?,
};
project_builder
.insert(&mut txn)
.await
.wrap_internal_err("failed to insert project")?;
if let Some(version_builder) = version_builder {
version_builder
.insert(&mut txn)
.await
.wrap_internal_err("failed to insert initial version")?;
}
DBUser::clear_project_cache(&[user.id.into()], &redis)
.await
.wrap_internal_err("failed to clear user project cache")?;
ThreadBuilder {
type_: ThreadType::Project,
members: vec![],
project_id: Some(project_id.into()),
report_id: None,
}
.insert(&mut txn)
.await
.wrap_internal_err("failed to insert thread")?;
// and commit!
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(web::Json(project_id))
}
+445 -85
View File
@@ -1,3 +1,4 @@
use std::any::type_name;
use std::collections::HashMap;
use std::sync::Arc;
@@ -7,13 +8,13 @@ use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::project_item::{DBGalleryItem, DBModCategory};
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::models::{
DBModerationLock, DBTeamMember, ids as db_ids, image_item,
DBModerationLock, DBProjectId, DBTeamMember, ids as db_ids, image_item,
};
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};
use crate::models::images::ImageContext;
use crate::models::notifications::NotificationBody;
@@ -24,17 +25,21 @@ use crate::models::projects::{
};
use crate::models::teams::ProjectPermissions;
use crate::models::threads::MessageBody;
use crate::models::{self, exp};
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;
use crate::util::img;
use crate::util::img::{delete_old_images, upload_image_optimized};
use crate::util::routes::read_limited_from_payload;
use crate::util::validate::validation_errors_to_string;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use chrono::Utc;
use eyre::eyre;
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
@@ -46,38 +51,27 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("projects", web::get().to(projects_get));
cfg.route("projects", web::patch().to(projects_edit));
cfg.route("projects_random", web::get().to(random_projects_get));
}
cfg.service(
web::scope("project")
.route("{id}", web::get().to(project_get))
.route("{id}/check", web::get().to(project_get_check))
.route("{id}", web::delete().to(project_delete))
.route("{id}", web::patch().to(project_edit))
.route("{id}/icon", web::patch().to(project_icon_edit))
.route("{id}/icon", web::delete().to(delete_project_icon))
.route("{id}/gallery", web::post().to(add_gallery_item))
.route("{id}/gallery", web::patch().to(edit_gallery_item))
.route("{id}/gallery", web::delete().to(delete_gallery_item))
.route("{id}/follow", web::post().to(project_follow))
.route("{id}/follow", web::delete().to(project_unfollow))
.route("{id}/organization", web::get().to(project_get_organization))
.service(
web::scope("{project_id}")
.route(
"members",
web::get().to(super::teams::team_members_get_project),
)
.route(
"version",
web::get().to(super::versions::version_list),
)
.route(
"version/{slug}",
web::get().to(super::versions::version_project_get),
)
.route("dependencies", web::get().to(dependency_list)),
),
);
pub fn utoipa_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
) {
cfg.service(project_get)
.service(project_get_check)
.service(project_delete)
.service(project_edit)
.service(project_icon_edit)
.service(delete_project_icon)
.service(add_gallery_item)
.service(edit_gallery_item)
.service(delete_gallery_item)
.service(project_follow)
.service(project_unfollow)
.service(project_get_organization)
.service(super::teams::team_members_get_project)
.service(super::versions::version_list)
.service(super::versions::version_project_get)
.service(dependency_list);
}
#[derive(Deserialize, Validate)]
@@ -161,17 +155,30 @@ pub async fn projects_get(
Ok(HttpResponse::Ok().json(projects))
}
pub async fn project_get(
#[utoipa::path]
#[get("/{id}")]
async fn project_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
) -> Result<web::Json<Project>, ApiError> {
project_get_internal(req, info, pool, redis, session_queue).await
}
let project_data =
db_models::DBProject::get(&string, &**pool, &redis).await?;
pub async fn project_get_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Project>, ApiError> {
let (string,) = info.into_inner();
let project_data = db_models::DBProject::get(&string, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch project")?;
let user_option = get_user_from_headers(
&req,
&**pool,
@@ -180,18 +187,20 @@ pub async fn project_get(
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
.map(|(_, user)| user)
.ok();
if let Some(data) = project_data
&& is_visible_project(&data.inner, &user_option, &pool, false).await?
&& is_visible_project(&data.inner, &user_option, &pool, false)
.await
.wrap_internal_err("failed to check project visibility")?
{
return Ok(HttpResponse::Ok().json(Project::from(data)));
return Ok(web::Json(Project::from(data)));
}
Err(ApiError::NotFound)
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Debug, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct EditProject {
#[validate(
length(min = 3, max = 64),
@@ -253,15 +262,61 @@ pub struct EditProject {
Option<SideTypesMigrationReviewStatus>,
#[serde(flatten)]
pub loader_fields: HashMap<String, serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub minecraft_server: Option<Option<exp::minecraft::ServerProjectEdit>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub minecraft_java_server:
Option<Option<exp::minecraft::JavaServerProjectEdit>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub minecraft_bedrock_server:
Option<Option<exp::minecraft::BedrockServerProjectEdit>>,
}
#[allow(clippy::too_many_arguments)]
pub async fn project_edit(
#[utoipa::path]
#[patch("/{id}")]
async fn project_edit(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
search_config: web::Data<SearchConfig>,
new_project: web::Json<EditProject>,
web::Json(new_project): web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
) -> Result<HttpResponse, ApiError> {
project_edit_internal(
req,
info,
pool,
search_config,
web::Json(new_project),
redis,
session_queue,
moderation_queue,
)
.await
}
pub async fn project_edit_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
search_config: web::Data<SearchConfig>,
web::Json(new_project): web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
@@ -280,7 +335,7 @@ pub async fn project_edit(
ApiError::Validation(validation_errors_to_string(err, None))
})?;
let Some(project_item) =
let Some(mut project_item) =
db_models::DBProject::get(&info.into_inner().0, &**pool, &redis)
.await?
else {
@@ -425,13 +480,13 @@ pub async fn project_edit(
if status.is_searchable()
&& !project_item.inner.webhook_sent
&& let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK")
&& !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty()
{
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&pool,
&redis,
webhook_url,
&ENV.PUBLIC_DISCORD_WEBHOOK,
None,
)
.await
@@ -449,18 +504,16 @@ pub async fn project_edit(
.await?;
}
if user.role.is_mod()
&& let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK")
{
if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
crate::util::webhook::send_slack_project_webhook(
project_item.inner.id.into(),
&pool,
&redis,
webhook_url,
&ENV.MODERATION_SLACK_WEBHOOK,
Some(
format!(
"*<{}/user/{}|{}>* changed project status from *{}* to *{}*",
dotenvy::var("SITE_URL")?,
ENV.SITE_URL,
user.username,
user.username,
&project_item.inner.status.as_friendly_str(),
@@ -937,6 +990,95 @@ pub async fn project_edit(
}
}
// components
async fn update<E: exp::component::ComponentEdit>(
_txn: &mut PgTransaction<'_>,
_project_id: DBProjectId,
edit: Option<Option<E>>,
mut component: &mut Option<E::Component>,
) -> Result<(), ApiError> {
let Some(edit) = edit else {
// component is not specified in the input JSON - leave alone
return Ok(());
};
match (&mut component, edit) {
(None, None) => {}
(Some(_), None) => {
// component is `null` in the input JSON - remove component
*component = None;
}
(None, Some(edit)) => {
// component is specified in the JSON and is non-null - create new component
*component =
Some(edit.create().wrap_request_err_with(|| {
eyre!(
"failed to create `{}` component",
type_name::<E::Component>()
)
})?);
}
(Some(component), Some(edit)) => {
// edit component
edit.apply_to(component).await.wrap_internal_err_with(
|| {
eyre!(
"failed to update `{}` component",
type_name::<E::Component>()
)
},
)?;
}
}
Ok(())
}
update(
&mut transaction,
id,
new_project.minecraft_server,
&mut project_item.inner.components.minecraft_server,
)
.await?;
update(
&mut transaction,
id,
new_project.minecraft_java_server,
&mut project_item.inner.components.minecraft_java_server,
)
.await?;
update(
&mut transaction,
id,
new_project.minecraft_bedrock_server,
&mut project_item.inner.components.minecraft_bedrock_server,
)
.await?;
let components_serial = project_item.inner.components.clone();
exp::component::kinds_valid(
&components_serial.component_kinds(),
&exp::PROJECT_COMPONENT_RELATIONS,
)
.wrap_request_err("invalid component kinds")?;
sqlx::query!(
"
UPDATE mods
SET components = $1
WHERE id = $2
",
serde_json::to_value(&components_serial)
.expect("serialization shouldn't fail"),
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to update components")?;
// check new description and body for links to associated images
// if they no longer exist in the description or body, delete them
let checkable_strings: Vec<&str> =
@@ -980,7 +1122,8 @@ pub async fn project_edit(
.collect::<Vec<_>>(),
&search_config,
)
.await?;
.await
.wrap_internal_err("failed to remove documents")?;
}
Ok(HttpResponse::NoContent().body(""))
@@ -1056,7 +1199,17 @@ pub async fn project_search(
}
//checks the validity of a project id or slug
pub async fn project_get_check(
#[utoipa::path]
#[get("/{id}/check")]
async fn project_get_check(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
project_get_check_internal(info, pool, redis).await
}
pub async fn project_get_check_internal(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -1081,12 +1234,24 @@ pub struct DependencyInfo {
pub versions: Vec<models::projects::Version>,
}
#[utoipa::path]
#[get("/{project_id}/dependencies")]
pub async fn dependency_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
dependency_list_internal(req, info, pool, redis, session_queue).await
}
pub async fn dependency_list_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
@@ -1139,7 +1304,11 @@ pub async fn dependency_list(
.collect::<Vec<db_models::DBVersionId>>();
let (projects_result, versions_result) = futures::future::try_join(
database::DBProject::get_many_ids(&project_ids, &**pool, &redis),
database::DBVersion::get_many(&dep_version_ids, &**pool, &redis),
async {
database::DBVersion::get_many(&dep_version_ids, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch dependency versions")
},
)
.await?;
@@ -1491,7 +1660,32 @@ pub struct Extension {
}
#[allow(clippy::too_many_arguments)]
pub async fn project_icon_edit(
#[utoipa::path]
#[patch("/{id}/icon")]
async fn project_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_icon_edit_internal(
web::Query(ext),
req,
info,
pool,
redis,
file_host,
payload,
session_queue,
)
.await
}
pub async fn project_icon_edit_internal(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
@@ -1606,7 +1800,28 @@ pub async fn project_icon_edit(
Ok(HttpResponse::NoContent().body(""))
}
pub async fn delete_project_icon(
#[utoipa::path]
#[delete("/{id}/icon")]
async fn delete_project_icon(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
delete_project_icon_internal(
req,
info,
pool,
redis,
file_host,
session_queue,
)
.await
}
pub async fn delete_project_icon_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -1707,7 +1922,34 @@ pub struct GalleryCreateQuery {
}
#[allow(clippy::too_many_arguments)]
#[utoipa::path]
#[post("/{id}/gallery")]
pub async fn add_gallery_item(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
web::Query(item): web::Query<GalleryCreateQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
add_gallery_item_internal(
web::Query(ext),
req,
web::Query(item),
info,
pool,
redis,
file_host,
payload,
session_queue,
)
.await
}
pub async fn add_gallery_item_internal(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
web::Query(item): web::Query<GalleryCreateQuery>,
@@ -1874,7 +2116,26 @@ pub struct GalleryEditQuery {
pub ordering: Option<i64>,
}
pub async fn edit_gallery_item(
#[utoipa::path]
#[patch("/{id}/gallery")]
async fn edit_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
edit_gallery_item_internal(
req,
web::Query(item),
pool,
redis,
session_queue,
)
.await
}
pub async fn edit_gallery_item_internal(
req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>,
pool: web::Data<PgPool>,
@@ -2040,7 +2301,28 @@ pub struct GalleryDeleteQuery {
pub url: String,
}
pub async fn delete_gallery_item(
#[utoipa::path]
#[delete("/{id}/gallery")]
async fn delete_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
delete_gallery_item_internal(
req,
web::Query(item),
pool,
redis,
file_host,
session_queue,
)
.await
}
pub async fn delete_gallery_item_internal(
req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>,
pool: web::Data<PgPool>,
@@ -2150,32 +2432,57 @@ pub async fn delete_gallery_item(
Ok(HttpResponse::NoContent().body(""))
}
pub async fn project_delete(
#[utoipa::path]
#[delete("/{id}")]
async fn project_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
search_config: web::Data<SearchConfig>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
) -> Result<(), ApiError> {
project_delete_internal(
req,
info,
pool,
redis,
search_config,
session_queue,
)
.await
}
pub async fn project_delete_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
search_config: web::Data<SearchConfig>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_DELETE,
)
.await?
.1;
.await?;
let string = info.into_inner().0;
// In two cases, we return `The specified project does not exist!`:
// - the project really doesn't exist
// - the project is hidden from the user
//
// We use an `ApiError::Auth` for this case instead of a `ApiError::Request`,
// because our permissions tests assert that failing under the 2nd use case
// gives a 401 or 404, but `Request` gives only a 400.
let project = db_models::DBProject::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
)
})?;
.await
.wrap_internal_err("failed to get project")?
.wrap_auth_err("The specified project does not exist!")?;
if !user.role.is_admin() {
let (team_member, organization_team_member) =
@@ -2184,13 +2491,14 @@ pub async fn project_delete(
user.id.into(),
&**pool,
)
.await?;
.await
.wrap_internal_err("failed to get user team member permissions")?;
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
return Err(ApiError::CustomAuthentication(
"The specified project does not exist!".to_string(),
));
return Err(ApiError::Auth(eyre!(
"The specified project does not exist!"
)));
}
let permissions = ProjectPermissions::get_permissions_by_role(
@@ -2207,15 +2515,35 @@ pub async fn project_delete(
}
}
let mut transaction = pool.begin().await?;
let mut transaction = pool
.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()),
};
let uploaded_images =
db_models::DBImage::get_many_contexted(context, &mut transaction)
.await?;
.await
.wrap_internal_err("failed to get project images")?;
for image in uploaded_images {
image_item::DBImage::remove(image.id, &mut transaction, &redis).await?;
image_item::DBImage::remove(image.id, &mut transaction, &redis)
.await
.wrap_internal_err_with(|| {
eyre!("failed to remove project image `{:?}`", image.id)
})?;
}
sqlx::query!(
@@ -2226,16 +2554,21 @@ pub async fn project_delete(
project.inner.id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await?;
.await
.wrap_internal_err("failed to delete project from collections_mods")?;
let result = db_models::DBProject::remove(
project.inner.id,
&mut transaction,
&redis,
)
.await?;
.await
.wrap_internal_err("failed to remove project")?;
transaction.commit().await?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
remove_documents(
&project
@@ -2245,16 +2578,29 @@ pub async fn project_delete(
.collect::<Vec<_>>(),
&search_config,
)
.await?;
.await
.wrap_internal_err("failed to remove project version documents")?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
Ok(())
} else {
Err(ApiError::NotFound)
}
}
pub async fn project_follow(
#[utoipa::path]
#[post("/{id}/follow")]
async fn project_follow(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_follow_internal(req, info, pool, redis, session_queue).await
}
pub async fn project_follow_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -2334,7 +2680,19 @@ pub async fn project_follow(
}
}
pub async fn project_unfollow(
#[utoipa::path]
#[delete("/{id}/follow")]
async fn project_unfollow(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_unfollow_internal(req, info, pool, redis, session_queue).await
}
pub async fn project_unfollow_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -2410,6 +2768,8 @@ pub async fn project_unfollow(
}
}
#[utoipa::path]
#[get("/{id}/organization")]
pub async fn project_get_organization(
req: HttpRequest,
info: web::Path<(String,)>,
+15 -2
View File
@@ -12,7 +12,7 @@ use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_web::{HttpRequest, HttpResponse, get, web};
use ariadne::ids::UserId;
use eyre::eyre;
use rust_decimal::Decimal;
@@ -40,7 +40,20 @@ pub fn config(cfg: &mut web::ServiceConfig) {
// also the members of the organization's team if the project is associated with an organization
// (Unlike team_members_get_project, which only returns the members of the project's team)
// They can be differentiated by the "organization_permissions" field being null or not
pub async fn team_members_get_project(
#[utoipa::path]
#[get("/{project_id}/members")]
async fn team_members_get_project(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
team_members_get_project_internal(req, info, pool, redis, session_queue)
.await
}
pub async fn team_members_get_project_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
+2 -2
View File
@@ -7,6 +7,7 @@ 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};
@@ -631,9 +632,8 @@ 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!("{cdn_url}/")).nth(1);
let name = image.url.split(&format!("{}/", ENV.CDN_URL)).nth(1);
if let Some(icon_path) = name {
file_host
.delete_file(

Some files were not shown because too many files have changed in this diff Show More