mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdb5d6deac | ||
|
|
7c3ab22956 | ||
|
|
8c68de0ee0 | ||
|
|
46427f6a24 | ||
|
|
46bc137a9e | ||
|
|
e3cf5c6a0a | ||
|
|
d005efa815 | ||
|
|
a42fbb42c1 | ||
|
|
22e31f9099 | ||
|
|
4d64fd175b | ||
|
|
09290b452d | ||
|
|
c3f76bfd64 | ||
|
|
adc85f24bd | ||
|
|
3c527778a5 | ||
|
|
3182c28027 | ||
|
|
515d1b3196 | ||
|
|
194873a5e7 | ||
|
|
3acff84465 | ||
|
|
1455f71f62 | ||
|
|
49438d33a1 | ||
|
|
6d3139f254 | ||
|
|
3a14e4fd46 | ||
|
|
c3a6f4d416 | ||
|
|
df7ac297af | ||
|
|
f3be9397c9 | ||
|
|
117257bcf9 | ||
|
|
6e011b043f | ||
|
|
9aed50d50c | ||
|
|
4fd747e6aa | ||
|
|
7ce09d65be | ||
|
|
7cddcee8e1 | ||
|
|
b1c914015b | ||
|
|
884907ad0c | ||
|
|
416598bf6d | ||
|
|
e04a60e582 | ||
|
|
4f24641296 | ||
|
|
55173e9b5a | ||
|
|
dbacbdafad | ||
|
|
487e1e1bdd | ||
|
|
7d9e58d877 | ||
|
|
4e5d234701 | ||
|
|
9f814ead27 | ||
|
|
e362de45fb | ||
|
|
bacc10d2f5 | ||
|
|
5b49af1fe8 | ||
|
|
f052ecd702 | ||
|
|
33ff2a0759 | ||
|
|
ec81bcb13c | ||
|
|
b6b4bc21f1 | ||
|
|
9a83db2e67 | ||
|
|
30c48718e2 | ||
|
|
8328a0d61a | ||
|
|
b62bc6f3b8 | ||
|
|
0e752ab415 | ||
|
|
9f558404bd |
Generated
+58
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
+184
@@ -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 don’t recognize this error and can’t 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>
|
||||
+90
@@ -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">
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+11
-5
@@ -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"
|
||||
}
|
||||
Generated
+34
@@ -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"
|
||||
}
|
||||
Generated
+15
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
+26
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
Generated
+23
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
Generated
-26
@@ -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"
|
||||
}
|
||||
Generated
+29
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
Generated
-34
@@ -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"
|
||||
}
|
||||
Generated
+40
@@ -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"
|
||||
}
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod exp;
|
||||
pub mod v2;
|
||||
pub mod v3;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
}]
|
||||
});
|
||||
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
@@ -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,)>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user