Compare commits
23 Commits
prod
...
fetch/offe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8546efd572 | ||
|
|
2796d770f7 | ||
|
|
6d39122ca9 | ||
|
|
09e89724de | ||
|
|
7a39f5853f | ||
|
|
e8639510aa | ||
|
|
d22c9e24f4 | ||
|
|
d6ee0c42c8 | ||
|
|
c1fc072efe | ||
|
|
3f36a67bc8 | ||
|
|
b0443dc49d | ||
|
|
4981151cea | ||
|
|
360d24f2e0 | ||
|
|
e31197f649 | ||
|
|
84cfd21920 | ||
|
|
158f5171fc | ||
|
|
1fd21e99c3 | ||
|
|
da0fed3e21 | ||
|
|
b65a16adff | ||
|
|
6909f4a678 | ||
|
|
0dee21814d | ||
|
|
0657e4466f | ||
|
|
13dbb4c57e |
1
.idea/code.iml
generated
1
.idea/code.iml
generated
@@ -10,7 +10,6 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
</content>
|
</content>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"tsc:check": "vue-tsc --noEmit",
|
"tsc:check": "vue-tsc --noEmit",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fix": "eslint . --fix && prettier --write .",
|
"fix": "eslint . --fix && prettier --write .",
|
||||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||||
"test": "vue-tsc --noEmit"
|
"test": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -21,14 +21,11 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const featuredCategory = computed(() => {
|
const featuredCategory = computed(() => {
|
||||||
if (props.project.categories.includes('optimization')) {
|
if (props.project.display_categories.includes('optimization')) {
|
||||||
return 'optimization'
|
return 'optimization'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.project.categories.length > 0) {
|
return props.project.display_categories[0] ?? props.project.categories[0]
|
||||||
return props.project.categories[0]
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const toColor = computed(() => {
|
const toColor = computed(() => {
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ const installing = ref(false)
|
|||||||
const onInstall = ref(() => {})
|
const onInstall = ref(() => {})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: (instanceVal, projectVal, projectVersions, callback) => {
|
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||||
instance.value = instanceVal
|
instance.value = instanceVal
|
||||||
versions.value = projectVersions
|
versions.value = projectVersions
|
||||||
selectedVersion.value = projectVersions[0]
|
selectedVersion.value = selected ?? projectVersions[0]
|
||||||
|
|
||||||
project.value = projectVal
|
project.value = projectVal
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import type {
|
|||||||
ServerWorld,
|
ServerWorld,
|
||||||
SingleplayerWorld,
|
SingleplayerWorld,
|
||||||
World,
|
World,
|
||||||
set_world_display_status,
|
|
||||||
getWorldIdentifier,
|
|
||||||
} from '@/helpers/worlds.ts'
|
} from '@/helpers/worlds.ts'
|
||||||
|
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||||
import {
|
import {
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
@@ -61,7 +60,8 @@ const props = withDefaults(
|
|||||||
playingInstance?: boolean
|
playingInstance?: boolean
|
||||||
playingWorld?: boolean
|
playingWorld?: boolean
|
||||||
startingInstance?: boolean
|
startingInstance?: boolean
|
||||||
supportsQuickPlay?: boolean
|
supportsServerQuickPlay?: boolean
|
||||||
|
supportsWorldQuickPlay?: boolean
|
||||||
currentProtocol?: ProtocolVersion | null
|
currentProtocol?: ProtocolVersion | null
|
||||||
highlighted?: boolean
|
highlighted?: boolean
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ const props = withDefaults(
|
|||||||
playingInstance: false,
|
playingInstance: false,
|
||||||
playingWorld: false,
|
playingWorld: false,
|
||||||
startingInstance: false,
|
startingInstance: false,
|
||||||
supportsQuickPlay: false,
|
supportsServerQuickPlay: true,
|
||||||
|
supportsWorldQuickPlay: false,
|
||||||
currentProtocol: null,
|
currentProtocol: null,
|
||||||
|
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
@@ -128,9 +129,13 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.a_minecraft_server',
|
id: 'instance.worlds.a_minecraft_server',
|
||||||
defaultMessage: 'A Minecraft Server',
|
defaultMessage: 'A Minecraft Server',
|
||||||
},
|
},
|
||||||
noQuickPlay: {
|
noServerQuickPlay: {
|
||||||
id: 'instance.worlds.no_quick_play',
|
id: 'instance.worlds.no_server_quick_play',
|
||||||
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
|
||||||
|
},
|
||||||
|
noSingleplayerQuickPlay: {
|
||||||
|
id: 'instance.worlds.no_singleplayer_quick_play',
|
||||||
|
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
|
||||||
},
|
},
|
||||||
gameAlreadyOpen: {
|
gameAlreadyOpen: {
|
||||||
id: 'instance.worlds.game_already_open',
|
id: 'instance.worlds.game_already_open',
|
||||||
@@ -152,10 +157,6 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.view_instance',
|
id: 'instance.worlds.view_instance',
|
||||||
defaultMessage: 'View instance',
|
defaultMessage: 'View instance',
|
||||||
},
|
},
|
||||||
playAnyway: {
|
|
||||||
id: 'instance.worlds.play_anyway',
|
|
||||||
defaultMessage: 'Play anyway',
|
|
||||||
},
|
|
||||||
playInstance: {
|
playInstance: {
|
||||||
id: 'instance.worlds.play_instance',
|
id: 'instance.worlds.play_instance',
|
||||||
defaultMessage: 'Play instance',
|
defaultMessage: 'Play instance',
|
||||||
@@ -330,17 +331,24 @@ const messages = defineMessages({
|
|||||||
<ButtonStyled v-else>
|
<ButtonStyled v-else>
|
||||||
<button
|
<button
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
!serverStatus
|
world.type == 'server' && !supportsServerQuickPlay
|
||||||
|
? formatMessage(messages.noServerQuickPlay)
|
||||||
|
: world.type == 'singleplayer' && !supportsWorldQuickPlay
|
||||||
|
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||||
|
: playingOtherWorld || locked
|
||||||
|
? formatMessage(messages.gameAlreadyOpen)
|
||||||
|
: !serverStatus
|
||||||
? formatMessage(messages.noContact)
|
? formatMessage(messages.noContact)
|
||||||
: serverIncompatible
|
: serverIncompatible
|
||||||
? formatMessage(messages.incompatibleServer)
|
? formatMessage(messages.incompatibleServer)
|
||||||
: !supportsQuickPlay
|
|
||||||
? formatMessage(messages.noQuickPlay)
|
|
||||||
: playingOtherWorld || locked
|
|
||||||
? formatMessage(messages.gameAlreadyOpen)
|
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
:disabled="
|
||||||
|
playingOtherWorld ||
|
||||||
|
startingInstance ||
|
||||||
|
(world.type == 'server' && !supportsServerQuickPlay) ||
|
||||||
|
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
|
||||||
|
"
|
||||||
@click="emit('play')"
|
@click="emit('play')"
|
||||||
>
|
>
|
||||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||||
@@ -357,11 +365,6 @@ const messages = defineMessages({
|
|||||||
disabled: playingInstance,
|
disabled: playingInstance,
|
||||||
action: () => emit('play-instance'),
|
action: () => emit('play-instance'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'play-anyway',
|
|
||||||
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
|
||||||
action: () => emit('play'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'open-instance',
|
id: 'open-instance',
|
||||||
shown: !!instancePath,
|
shown: !!instancePath,
|
||||||
@@ -427,10 +430,6 @@ const messages = defineMessages({
|
|||||||
<PlayIcon aria-hidden="true" />
|
<PlayIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.playInstance) }}
|
{{ formatMessage(messages.playInstance) }}
|
||||||
</template>
|
</template>
|
||||||
<template #play-anyway>
|
|
||||||
<PlayIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(messages.playAnyway) }}
|
|
||||||
</template>
|
|
||||||
<template #open-instance>
|
<template #open-instance>
|
||||||
<EyeIcon aria-hidden="true" />
|
<EyeIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.viewInstance) }}
|
{{ formatMessage(messages.viewInstance) }}
|
||||||
|
|||||||
@@ -311,15 +311,24 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
|||||||
return worlds ?? []
|
return worlds ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIRST_QUICK_PLAY_VERSION = '23w14a'
|
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||||
|
if (!gameVersions.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||||
|
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
|
||||||
|
|
||||||
|
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||||
if (!gameVersions.length) {
|
if (!gameVersions.length) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||||
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
|
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
|
||||||
|
|
||||||
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,11 +383,11 @@
|
|||||||
"instance.worlds.no_contact": {
|
"instance.worlds.no_contact": {
|
||||||
"message": "Server couldn't be contacted"
|
"message": "Server couldn't be contacted"
|
||||||
},
|
},
|
||||||
"instance.worlds.no_quick_play": {
|
"instance.worlds.no_server_quick_play": {
|
||||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
|
||||||
},
|
},
|
||||||
"instance.worlds.play_anyway": {
|
"instance.worlds.no_singleplayer_quick_play": {
|
||||||
"message": "Play anyway"
|
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
|
||||||
},
|
},
|
||||||
"instance.worlds.play_instance": {
|
"instance.worlds.play_instance": {
|
||||||
"message": "Play instance"
|
"message": "Play instance"
|
||||||
|
|||||||
@@ -67,7 +67,8 @@
|
|||||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||||
:world="world"
|
:world="world"
|
||||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||||
:supports-quick-play="supportsQuickPlay"
|
:supports-server-quick-play="supportsServerQuickPlay"
|
||||||
|
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||||
:current-protocol="protocolVersion"
|
:current-protocol="protocolVersion"
|
||||||
:playing-instance="playing"
|
:playing-instance="playing"
|
||||||
:playing-world="worldsMatch(world, worldPlaying)"
|
:playing-world="worldsMatch(world, worldPlaying)"
|
||||||
@@ -150,10 +151,11 @@ import {
|
|||||||
refreshWorld,
|
refreshWorld,
|
||||||
sortWorlds,
|
sortWorlds,
|
||||||
refreshServers,
|
refreshServers,
|
||||||
hasQuickPlaySupport,
|
hasWorldQuickPlaySupport,
|
||||||
refreshWorlds,
|
refreshWorlds,
|
||||||
handleDefaultProfileUpdateEvent,
|
handleDefaultProfileUpdateEvent,
|
||||||
showWorldInFolder,
|
showWorldInFolder,
|
||||||
|
hasServerQuickPlaySupport,
|
||||||
} from '@/helpers/worlds.ts'
|
} from '@/helpers/worlds.ts'
|
||||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||||
@@ -355,8 +357,11 @@ function worldsMatch(world: World, other: World | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||||
const supportsQuickPlay = computed(() =>
|
const supportsServerQuickPlay = computed(() =>
|
||||||
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||||
|
)
|
||||||
|
const supportsWorldQuickPlay = computed(() =>
|
||||||
|
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||||
)
|
)
|
||||||
|
|
||||||
const filterOptions = computed(() => {
|
const filterOptions = computed(() => {
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
|
|||||||
setIncompatibilityWarningModal(ref) {
|
setIncompatibilityWarningModal(ref) {
|
||||||
this.incompatibilityWarningModal = ref
|
this.incompatibilityWarningModal = ref
|
||||||
},
|
},
|
||||||
showIncompatibilityWarningModal(instance, project, versions, onInstall) {
|
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
|
||||||
this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
|
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
|
||||||
},
|
},
|
||||||
setModInstallModal(ref) {
|
setModInstallModal(ref) {
|
||||||
this.modInstallModal = ref
|
this.modInstallModal = ref
|
||||||
@@ -133,7 +133,13 @@ export const install = async (
|
|||||||
callback(version.id)
|
callback(version.id)
|
||||||
} else {
|
} else {
|
||||||
const install = useInstall()
|
const install = useInstall()
|
||||||
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
|
install.showIncompatibilityWarningModal(
|
||||||
|
instance,
|
||||||
|
project,
|
||||||
|
projectVersions,
|
||||||
|
version,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
|
|||||||
// invoke('plugin:profile|profile_run', path)
|
// invoke('plugin:profile|profile_run', path)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||||
let process = profile::run(path, &QuickPlayType::None).await?;
|
let process = profile::run(path, QuickPlayType::None).await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use enumset::EnumSet;
|
|||||||
use tauri::{AppHandle, Manager, Runtime};
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
use theseus::prelude::ProcessMetadata;
|
use theseus::prelude::ProcessMetadata;
|
||||||
use theseus::profile::{QuickPlayType, get_full_path};
|
use theseus::profile::{QuickPlayType, get_full_path};
|
||||||
|
use theseus::server_address::ServerAddress;
|
||||||
use theseus::worlds::{
|
use theseus::worlds::{
|
||||||
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
||||||
WorldType, WorldWithProfile,
|
WorldType, WorldWithProfile,
|
||||||
@@ -203,7 +204,7 @@ pub async fn start_join_singleplayer_world(
|
|||||||
world: String,
|
world: String,
|
||||||
) -> Result<ProcessMetadata> {
|
) -> Result<ProcessMetadata> {
|
||||||
let process =
|
let process =
|
||||||
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
@@ -213,8 +214,11 @@ pub async fn start_join_server(
|
|||||||
path: &str,
|
path: &str,
|
||||||
address: &str,
|
address: &str,
|
||||||
) -> Result<ProcessMetadata> {
|
) -> Result<ProcessMetadata> {
|
||||||
let process =
|
let process = profile::run(
|
||||||
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
path,
|
||||||
|
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,8 +143,13 @@ export default defineNuxtConfig({
|
|||||||
state.lastGenerated &&
|
state.lastGenerated &&
|
||||||
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
|
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
|
||||||
// ...but only if the API URL is the same
|
// ...but only if the API URL is the same
|
||||||
state.apiUrl === API_URL
|
state.apiUrl === API_URL &&
|
||||||
|
// ...and if no errors were caught during the last generation
|
||||||
|
(state.errors ?? []).length === 0
|
||||||
) {
|
) {
|
||||||
|
console.log(
|
||||||
|
"Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
<div class="static w-full grid-cols-1 md:relative md:flex">
|
<div class="static w-full grid-cols-1 md:relative md:flex">
|
||||||
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
||||||
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
||||||
<div v-for="link in navLinks" :key="link.label">
|
<div
|
||||||
|
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
|
||||||
|
:key="link.label"
|
||||||
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link.href"
|
:to="link.href"
|
||||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
||||||
@@ -40,7 +43,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
|||||||
const emit = defineEmits(["reinstall"]);
|
const emit = defineEmits(["reinstall"]);
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
|
||||||
route: RouteLocationNormalized;
|
route: RouteLocationNormalized;
|
||||||
server: ModrinthServer;
|
server: ModrinthServer;
|
||||||
backupInProgress?: BackupInProgressReason;
|
backupInProgress?: BackupInProgressReason;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ServerModule } from "./base.ts";
|
|||||||
export class GeneralModule extends ServerModule implements ServerGeneral {
|
export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||||
server_id!: string;
|
server_id!: string;
|
||||||
name!: string;
|
name!: string;
|
||||||
|
owner_id!: string;
|
||||||
net!: { ip: string; port: number; domain: string };
|
net!: { ip: string; port: number; domain: string };
|
||||||
game!: string;
|
game!: string;
|
||||||
backup_quota!: number;
|
backup_quota!: number;
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import {
|
|||||||
CardIcon,
|
CardIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
|
ModrinthIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
|
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
|
||||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const serverId = route.params.id as string;
|
const serverId = route.params.id as string;
|
||||||
|
const auth = await useAuth();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ModrinthServer;
|
server: ModrinthServer;
|
||||||
@@ -32,7 +35,11 @@ useHead({
|
|||||||
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
|
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const navLinks = [
|
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
|
||||||
|
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
|
||||||
|
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
|
||||||
|
|
||||||
|
const navLinks = computed(() => [
|
||||||
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
|
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
|
||||||
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
|
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
|
||||||
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
|
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
|
||||||
@@ -48,7 +55,15 @@ const navLinks = [
|
|||||||
label: "Billing",
|
label: "Billing",
|
||||||
href: `/settings/billing#server-${serverId}`,
|
href: `/settings/billing#server-${serverId}`,
|
||||||
external: true,
|
external: true,
|
||||||
|
shown: isOwner.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ModrinthIcon,
|
||||||
|
label: "Admin Billing",
|
||||||
|
href: `/admin/billing/${ownerId.value}`,
|
||||||
|
external: true,
|
||||||
|
shown: isAdmin.value,
|
||||||
},
|
},
|
||||||
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
|
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
|
||||||
];
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
34
apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json
generated
Normal file
34
apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json
generated
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "metadata",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "unitary",
|
||||||
|
"type_info": "Bool"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8"
|
||||||
|
}
|
||||||
15
apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json
generated
Normal file
15
apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users_redeemals\n SET status = $1\n WHERE\n status = $2\n AND NOW() - last_attempt > INTERVAL '5 minutes'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -102,5 +102,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
|
"hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7"
|
||||||
}
|
}
|
||||||
59
apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json
generated
Normal file
59
apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json
generated
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "offer",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "redeemed",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "last_attempt",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "n_attempts",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286"
|
||||||
|
}
|
||||||
27
apps/labrinth/.sqlx/query-7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26.json
generated
Normal file
27
apps/labrinth/.sqlx/query-7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26.json
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO users_redeemals\n (user_id, offer, redeemed, status, last_attempt, n_attempts)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26"
|
||||||
|
}
|
||||||
19
apps/labrinth/.sqlx/query-8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5.json
generated
Normal file
19
apps/labrinth/.sqlx/query-8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5.json
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users_redeemals\n SET\n offer = $2,\n status = $3,\n redeemed = $4,\n last_attempt = $5,\n n_attempts = $6\n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4",
|
||||||
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5"
|
||||||
|
}
|
||||||
29
apps/labrinth/.sqlx/query-949da1b1e3c772f79dd1248f99774fa39f140d3943f975067799f46f2cb48a0f.json
generated
Normal file
29
apps/labrinth/.sqlx/query-949da1b1e3c772f79dd1248f99774fa39f140d3943f975067799f46f2cb48a0f.json
generated
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT\n users.id,\n users_redeemals.status AS \"status: Option<String>\"\n FROM\n users\n LEFT JOIN\n users_redeemals ON users_redeemals.user_id = users.id\n AND users_redeemals.offer = $2\n WHERE\n users.username = $1\n ORDER BY\n users_redeemals.redeemed DESC\n LIMIT 1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "status: Option<String>",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "949da1b1e3c772f79dd1248f99774fa39f140d3943f975067799f46f2cb48a0f"
|
||||||
|
}
|
||||||
23
apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json
generated
Normal file
23
apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json
generated
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n EXISTS (\n SELECT\n 1\n FROM\n users_redeemals\n WHERE\n user_id = $1\n AND offer = $2\n ) AS \"exists!\"\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "exists!",
|
||||||
|
"type_info": "Bool"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a"
|
||||||
|
}
|
||||||
41
apps/labrinth/.sqlx/query-c37fc91df7619ac5c10fd04fdc2556aa98b80ccbfc53813659464a0e5e09fae8.json
generated
Normal file
41
apps/labrinth/.sqlx/query-c37fc91df7619ac5c10fd04fdc2556aa98b80ccbfc53813659464a0e5e09fae8.json
generated
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, product_id, prices, currency_code\n FROM products_prices\n WHERE product_id = ANY($1::bigint[])\n AND public = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "product_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "prices",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "currency_code",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array",
|
||||||
|
"Bool"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c37fc91df7619ac5c10fd04fdc2556aa98b80ccbfc53813659464a0e5e09fae8"
|
||||||
|
}
|
||||||
18
apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json
generated
Normal file
18
apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users_redeemals\n SET\n status = $3,\n last_attempt = $4,\n n_attempts = $5\n WHERE id = $1 AND status = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4",
|
||||||
|
"Text",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -102,5 +102,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"
|
"hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9"
|
||||||
}
|
}
|
||||||
11
apps/labrinth/migrations/20250804221014_users-redeemals.sql
Normal file
11
apps/labrinth/migrations/20250804221014_users-redeemals.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users_redeemals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
offer VARCHAR NOT NULL,
|
||||||
|
redeemed TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
status VARCHAR NOT NULL,
|
||||||
|
last_attempt TIMESTAMP WITH TIME ZONE,
|
||||||
|
n_attempts INTEGER NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
products_prices
|
||||||
|
ADD COLUMN
|
||||||
|
public BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -197,7 +197,7 @@ impl DBCharge {
|
|||||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||||
let user_subscription_id = user_subscription_id.0;
|
let user_subscription_id = user_subscription_id.0;
|
||||||
let res = select_charges_with_predicate!(
|
let res = select_charges_with_predicate!(
|
||||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||||
user_subscription_id
|
user_subscription_id
|
||||||
)
|
)
|
||||||
.fetch_optional(exec)
|
.fetch_optional(exec)
|
||||||
@@ -240,6 +240,7 @@ impl DBCharge {
|
|||||||
charge_type = $1 AND
|
charge_type = $1 AND
|
||||||
(
|
(
|
||||||
(status = 'cancelled' AND due < NOW()) OR
|
(status = 'cancelled' AND due < NOW()) OR
|
||||||
|
(status = 'expiring' AND due < NOW()) OR
|
||||||
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
|
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod team_item;
|
|||||||
pub mod thread_item;
|
pub mod thread_item;
|
||||||
pub mod user_item;
|
pub mod user_item;
|
||||||
pub mod user_subscription_item;
|
pub mod user_subscription_item;
|
||||||
|
pub mod users_redeemals;
|
||||||
pub mod version_item;
|
pub mod version_item;
|
||||||
|
|
||||||
pub use collection_item::DBCollection;
|
pub use collection_item::DBCollection;
|
||||||
|
|||||||
@@ -57,6 +57,26 @@ impl DBProduct {
|
|||||||
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_type<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
r#type: &str,
|
||||||
|
) -> Result<Vec<Self>, DatabaseError>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let maybe_row = select_products_with_predicate!(
|
||||||
|
"WHERE metadata ->> 'type' = $1",
|
||||||
|
r#type
|
||||||
|
)
|
||||||
|
.fetch_all(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
maybe_row
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.try_into().map_err(Into::into))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_many(
|
pub async fn get_many(
|
||||||
ids: &[DBProductId],
|
ids: &[DBProductId],
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
@@ -100,10 +120,11 @@ pub struct QueryProductWithPrices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl QueryProductWithPrices {
|
impl QueryProductWithPrices {
|
||||||
pub async fn list<'a, E>(
|
/// Lists products with at least one public price.
|
||||||
|
pub async fn list_purchaseable<'a, E>(
|
||||||
exec: E,
|
exec: E,
|
||||||
redis: &RedisPool,
|
redis: &RedisPool,
|
||||||
) -> Result<Vec<QueryProductWithPrices>, DatabaseError>
|
) -> Result<Vec<Self>, DatabaseError>
|
||||||
where
|
where
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||||
{
|
{
|
||||||
@@ -118,7 +139,8 @@ impl QueryProductWithPrices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let all_products = product_item::DBProduct::get_all(exec).await?;
|
let all_products = product_item::DBProduct::get_all(exec).await?;
|
||||||
let prices = product_item::DBProductPrice::get_all_products_prices(
|
let prices =
|
||||||
|
product_item::DBProductPrice::get_all_public_products_prices(
|
||||||
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||||
exec,
|
exec,
|
||||||
)
|
)
|
||||||
@@ -126,13 +148,13 @@ impl QueryProductWithPrices {
|
|||||||
|
|
||||||
let products = all_products
|
let products = all_products
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| QueryProductWithPrices {
|
.filter_map(|x| {
|
||||||
|
Some(QueryProductWithPrices {
|
||||||
id: x.id,
|
id: x.id,
|
||||||
metadata: x.metadata,
|
metadata: x.metadata,
|
||||||
prices: prices
|
prices: prices
|
||||||
.remove(&x.id)
|
.remove(&x.id)
|
||||||
.map(|x| x.1)
|
.map(|x| x.1)?
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| DBProductPrice {
|
.map(|x| DBProductPrice {
|
||||||
id: x.id,
|
id: x.id,
|
||||||
@@ -143,6 +165,7 @@ impl QueryProductWithPrices {
|
|||||||
.collect(),
|
.collect(),
|
||||||
unitary: x.unitary,
|
unitary: x.unitary,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
redis
|
redis
|
||||||
@@ -151,6 +174,45 @@ impl QueryProductWithPrices {
|
|||||||
|
|
||||||
Ok(products)
|
Ok(products)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_product_type<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
r#type: &str,
|
||||||
|
) -> Result<Vec<Self>, DatabaseError>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a> + Copy,
|
||||||
|
{
|
||||||
|
let all_products = DBProduct::get_by_type(exec, r#type).await?;
|
||||||
|
let prices = DBProductPrice::get_all_products_prices(
|
||||||
|
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||||
|
exec,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let products = all_products
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| {
|
||||||
|
Some(QueryProductWithPrices {
|
||||||
|
id: x.id,
|
||||||
|
metadata: x.metadata,
|
||||||
|
prices: prices
|
||||||
|
.remove(&x.id)
|
||||||
|
.map(|x| x.1)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| DBProductPrice {
|
||||||
|
id: x.id,
|
||||||
|
product_id: x.product_id,
|
||||||
|
prices: x.prices,
|
||||||
|
currency_code: x.currency_code,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
unitary: x.unitary,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(products)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
@@ -169,7 +231,11 @@ struct ProductPriceQueryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! select_prices_with_predicate {
|
macro_rules! select_prices_with_predicate {
|
||||||
($predicate:tt, $param:ident) => {
|
($predicate:tt, $param1:ident) => {
|
||||||
|
select_prices_with_predicate!($predicate, $param1, )
|
||||||
|
};
|
||||||
|
|
||||||
|
($predicate:tt, $($param:ident,)+) => {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
ProductPriceQueryResult,
|
ProductPriceQueryResult,
|
||||||
r#"
|
r#"
|
||||||
@@ -177,7 +243,7 @@ macro_rules! select_prices_with_predicate {
|
|||||||
FROM products_prices
|
FROM products_prices
|
||||||
"#
|
"#
|
||||||
+ $predicate,
|
+ $predicate,
|
||||||
$param
|
$($param),+
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -231,22 +297,49 @@ impl DBProductPrice {
|
|||||||
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_public_product_prices(
|
||||||
|
product_id: DBProductId,
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<Vec<DBProductPrice>, DatabaseError> {
|
||||||
|
let res =
|
||||||
|
Self::get_all_public_products_prices(&[product_id], exec).await?;
|
||||||
|
|
||||||
|
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all public prices for the given products. If a product has no public price,
|
||||||
|
/// it won't be included in the resulting map.
|
||||||
|
pub async fn get_all_public_products_prices(
|
||||||
|
product_ids: &[DBProductId],
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||||
|
Self::get_all_products_prices_with_visibility(
|
||||||
|
product_ids,
|
||||||
|
Some(true),
|
||||||
|
exec,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_all_products_prices(
|
pub async fn get_all_products_prices(
|
||||||
product_ids: &[DBProductId],
|
product_ids: &[DBProductId],
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||||
|
Self::get_all_products_prices_with_visibility(product_ids, None, exec)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all_products_prices_with_visibility(
|
||||||
|
product_ids: &[DBProductId],
|
||||||
|
public_filter: Option<bool>,
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||||
let ids = product_ids.iter().map(|id| id.0).collect_vec();
|
let ids = product_ids.iter().map(|id| id.0).collect_vec();
|
||||||
let ids_ref: &[i64] = &ids;
|
let ids_ref: &[i64] = &ids;
|
||||||
|
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
let prices = select_prices_with_predicate!(
|
|
||||||
"WHERE product_id = ANY($1::bigint[])",
|
let predicate = |acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
|
||||||
ids_ref
|
|
||||||
)
|
|
||||||
.fetch(exec)
|
|
||||||
.try_fold(
|
|
||||||
DashMap::new(),
|
|
||||||
|acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
|
|
||||||
if let Ok(item) = <ProductPriceQueryResult as TryInto<
|
if let Ok(item) = <ProductPriceQueryResult as TryInto<
|
||||||
DBProductPrice,
|
DBProductPrice,
|
||||||
>>::try_into(x)
|
>>::try_into(x)
|
||||||
@@ -255,9 +348,31 @@ impl DBProductPrice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async move { Ok(acc) }
|
async move { Ok(acc) }
|
||||||
},
|
};
|
||||||
|
|
||||||
|
let prices = match public_filter {
|
||||||
|
None => {
|
||||||
|
select_prices_with_predicate!(
|
||||||
|
"WHERE product_id = ANY($1::bigint[])",
|
||||||
|
ids_ref,
|
||||||
)
|
)
|
||||||
.await?;
|
.fetch(exec)
|
||||||
|
.try_fold(DashMap::new(), predicate)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(public) => {
|
||||||
|
select_prices_with_predicate!(
|
||||||
|
"WHERE product_id = ANY($1::bigint[])
|
||||||
|
AND public = $2",
|
||||||
|
ids_ref,
|
||||||
|
public,
|
||||||
|
)
|
||||||
|
.fetch(exec)
|
||||||
|
.try_fold(DashMap::new(), predicate)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(prices)
|
Ok(prices)
|
||||||
}
|
}
|
||||||
|
|||||||
301
apps/labrinth/src/database/models/users_redeemals.rs
Normal file
301
apps/labrinth/src/database/models/users_redeemals.rs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
use crate::database::models::DBUserId;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{query, query_scalar};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Offer {
|
||||||
|
#[default]
|
||||||
|
Medal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Offer {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Offer::Medal => "medal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str_or_default(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"medal" => Offer::Medal,
|
||||||
|
_ => Offer::Medal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Offer {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Status {
|
||||||
|
#[default]
|
||||||
|
Pending,
|
||||||
|
Processing,
|
||||||
|
Processed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Status::Pending => "pending",
|
||||||
|
Status::Processing => "processing",
|
||||||
|
Status::Processed => "processed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str_or_default(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"pending" => Status::Pending,
|
||||||
|
"processing" => Status::Processing,
|
||||||
|
"processed" => Status::Processed,
|
||||||
|
_ => Status::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Status {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UserRedeemal {
|
||||||
|
pub id: i32,
|
||||||
|
pub user_id: DBUserId,
|
||||||
|
pub offer: Offer,
|
||||||
|
pub redeemed: DateTime<Utc>,
|
||||||
|
pub last_attempt: Option<DateTime<Utc>>,
|
||||||
|
pub n_attempts: i32,
|
||||||
|
pub status: Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRedeemal {
|
||||||
|
pub async fn get_pending<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
limit: i64,
|
||||||
|
) -> sqlx::Result<Vec<UserRedeemal>>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let redeemals = query!(
|
||||||
|
r#"SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2"#,
|
||||||
|
Status::Pending.as_str(),
|
||||||
|
limit
|
||||||
|
)
|
||||||
|
.fetch_all(exec)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| UserRedeemal {
|
||||||
|
id: row.id,
|
||||||
|
user_id: DBUserId(row.user_id),
|
||||||
|
offer: Offer::from_str_or_default(&row.offer),
|
||||||
|
redeemed: row.redeemed,
|
||||||
|
last_attempt: row.last_attempt,
|
||||||
|
n_attempts: row.n_attempts,
|
||||||
|
status: Status::from_str_or_default(&row.status),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(redeemals)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_stuck_5_minutes<'a, E>(exec: E) -> sqlx::Result<()>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
query!(
|
||||||
|
r#"
|
||||||
|
UPDATE users_redeemals
|
||||||
|
SET status = $1
|
||||||
|
WHERE
|
||||||
|
status = $2
|
||||||
|
AND NOW() - last_attempt > INTERVAL '5 minutes'
|
||||||
|
"#,
|
||||||
|
Status::Pending.as_str(),
|
||||||
|
Status::Processing.as_str(),
|
||||||
|
)
|
||||||
|
.execute(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exists_by_user_and_offer<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
user_id: DBUserId,
|
||||||
|
offer: Offer,
|
||||||
|
) -> sqlx::Result<bool>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
query_scalar!(
|
||||||
|
r#"SELECT
|
||||||
|
EXISTS (
|
||||||
|
SELECT
|
||||||
|
1
|
||||||
|
FROM
|
||||||
|
users_redeemals
|
||||||
|
WHERE
|
||||||
|
user_id = $1
|
||||||
|
AND offer = $2
|
||||||
|
) AS "exists!"
|
||||||
|
"#,
|
||||||
|
user_id.0,
|
||||||
|
offer.as_str(),
|
||||||
|
)
|
||||||
|
.fetch_one(exec)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let query = query_scalar!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users_redeemals
|
||||||
|
(user_id, offer, redeemed, status, last_attempt, n_attempts)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id"#,
|
||||||
|
self.user_id.0,
|
||||||
|
self.offer.as_str(),
|
||||||
|
self.redeemed,
|
||||||
|
self.status.as_str(),
|
||||||
|
self.last_attempt,
|
||||||
|
self.n_attempts,
|
||||||
|
);
|
||||||
|
|
||||||
|
let id = query.fetch_one(exec).await?;
|
||||||
|
|
||||||
|
self.id = id;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates `status`, `last_attempt`, and `n_attempts` only if `status` is currently pending.
|
||||||
|
/// Returns `true` if the status was updated, `false` otherwise.
|
||||||
|
pub async fn update_status_if_pending<'a, E>(
|
||||||
|
&self,
|
||||||
|
exec: E,
|
||||||
|
) -> sqlx::Result<bool>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let query = query!(
|
||||||
|
r#"
|
||||||
|
UPDATE users_redeemals
|
||||||
|
SET
|
||||||
|
status = $3,
|
||||||
|
last_attempt = $4,
|
||||||
|
n_attempts = $5
|
||||||
|
WHERE id = $1 AND status = $2
|
||||||
|
"#,
|
||||||
|
self.id,
|
||||||
|
Status::Pending.as_str(),
|
||||||
|
self.status.as_str(),
|
||||||
|
self.last_attempt,
|
||||||
|
self.n_attempts,
|
||||||
|
);
|
||||||
|
|
||||||
|
let query_result = query.execute(exec).await?;
|
||||||
|
|
||||||
|
Ok(query_result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update<'a, E>(&self, exec: E) -> sqlx::Result<()>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let query = query!(
|
||||||
|
r#"
|
||||||
|
UPDATE users_redeemals
|
||||||
|
SET
|
||||||
|
offer = $2,
|
||||||
|
status = $3,
|
||||||
|
redeemed = $4,
|
||||||
|
last_attempt = $5,
|
||||||
|
n_attempts = $6
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
self.id,
|
||||||
|
self.offer.as_str(),
|
||||||
|
self.status.as_str(),
|
||||||
|
self.redeemed,
|
||||||
|
self.last_attempt,
|
||||||
|
self.n_attempts,
|
||||||
|
);
|
||||||
|
|
||||||
|
query.execute(exec).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RedeemalLookupFields {
|
||||||
|
pub user_id: DBUserId,
|
||||||
|
pub redeemal_status: Option<Status>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedeemalLookupFields {
|
||||||
|
/// Returns the redeemal status of a user for an offer, while looking up the user
|
||||||
|
/// itself. **This expects a single redeemal per user/offer pair**.
|
||||||
|
///
|
||||||
|
/// If the returned value is `Ok(None)`, the user doesn't exist.
|
||||||
|
///
|
||||||
|
/// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`,
|
||||||
|
/// the user exists and has not redeemed the offer.
|
||||||
|
pub async fn redeemal_status_by_username_and_offer<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
user_username: &str,
|
||||||
|
offer: Offer,
|
||||||
|
) -> sqlx::Result<Option<RedeemalLookupFields>>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let maybe_row = query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
users.id,
|
||||||
|
users_redeemals.status AS "status: Option<String>"
|
||||||
|
FROM
|
||||||
|
users
|
||||||
|
LEFT JOIN
|
||||||
|
users_redeemals ON users_redeemals.user_id = users.id
|
||||||
|
AND users_redeemals.offer = $2
|
||||||
|
WHERE
|
||||||
|
users.username = $1
|
||||||
|
ORDER BY
|
||||||
|
users_redeemals.redeemed DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
user_username,
|
||||||
|
offer.as_str(),
|
||||||
|
)
|
||||||
|
.fetch_optional(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// If no row was returned, the user doesn't exist.
|
||||||
|
// If a row NULL status was returned, the user exists but has no redeemed the offer.
|
||||||
|
|
||||||
|
Ok(maybe_row.map(|row| RedeemalLookupFields {
|
||||||
|
user_id: DBUserId(row.id),
|
||||||
|
redeemal_status: row
|
||||||
|
.status
|
||||||
|
.as_deref()
|
||||||
|
.map(Status::from_str_or_default),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,13 @@ pub enum ProductMetadata {
|
|||||||
swap: u32,
|
swap: u32,
|
||||||
storage: u32,
|
storage: u32,
|
||||||
},
|
},
|
||||||
|
Medal {
|
||||||
|
cpu: u32,
|
||||||
|
ram: u32,
|
||||||
|
swap: u32,
|
||||||
|
storage: u32,
|
||||||
|
region: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -48,6 +55,7 @@ pub enum Price {
|
|||||||
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum PriceDuration {
|
pub enum PriceDuration {
|
||||||
|
FiveDays,
|
||||||
Monthly,
|
Monthly,
|
||||||
Quarterly,
|
Quarterly,
|
||||||
Yearly,
|
Yearly,
|
||||||
@@ -56,6 +64,7 @@ pub enum PriceDuration {
|
|||||||
impl PriceDuration {
|
impl PriceDuration {
|
||||||
pub fn duration(&self) -> chrono::Duration {
|
pub fn duration(&self) -> chrono::Duration {
|
||||||
match self {
|
match self {
|
||||||
|
PriceDuration::FiveDays => chrono::Duration::days(5),
|
||||||
PriceDuration::Monthly => chrono::Duration::days(30),
|
PriceDuration::Monthly => chrono::Duration::days(30),
|
||||||
PriceDuration::Quarterly => chrono::Duration::days(90),
|
PriceDuration::Quarterly => chrono::Duration::days(90),
|
||||||
PriceDuration::Yearly => chrono::Duration::days(365),
|
PriceDuration::Yearly => chrono::Duration::days(365),
|
||||||
@@ -64,6 +73,7 @@ impl PriceDuration {
|
|||||||
|
|
||||||
pub fn from_string(string: &str) -> PriceDuration {
|
pub fn from_string(string: &str) -> PriceDuration {
|
||||||
match string {
|
match string {
|
||||||
|
"five-days" => PriceDuration::FiveDays,
|
||||||
"monthly" => PriceDuration::Monthly,
|
"monthly" => PriceDuration::Monthly,
|
||||||
"quarterly" => PriceDuration::Quarterly,
|
"quarterly" => PriceDuration::Quarterly,
|
||||||
"yearly" => PriceDuration::Yearly,
|
"yearly" => PriceDuration::Yearly,
|
||||||
@@ -76,6 +86,7 @@ impl PriceDuration {
|
|||||||
PriceDuration::Monthly => "monthly",
|
PriceDuration::Monthly => "monthly",
|
||||||
PriceDuration::Quarterly => "quarterly",
|
PriceDuration::Quarterly => "quarterly",
|
||||||
PriceDuration::Yearly => "yearly",
|
PriceDuration::Yearly => "yearly",
|
||||||
|
PriceDuration::FiveDays => "five-days",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +95,7 @@ impl PriceDuration {
|
|||||||
PriceDuration::Monthly,
|
PriceDuration::Monthly,
|
||||||
PriceDuration::Quarterly,
|
PriceDuration::Quarterly,
|
||||||
PriceDuration::Yearly,
|
PriceDuration::Yearly,
|
||||||
|
PriceDuration::FiveDays,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
}
|
}
|
||||||
@@ -146,6 +158,7 @@ impl SubscriptionStatus {
|
|||||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||||
pub enum SubscriptionMetadata {
|
pub enum SubscriptionMetadata {
|
||||||
Pyro { id: String, region: Option<String> },
|
Pyro { id: String, region: Option<String> },
|
||||||
|
Medal { id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -207,6 +220,10 @@ pub enum ChargeStatus {
|
|||||||
Succeeded,
|
Succeeded,
|
||||||
Failed,
|
Failed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
// Expiring charges are charges that aren't expected to be processed
|
||||||
|
// but can be promoted to a full charge, like for trials/freebies. When
|
||||||
|
// due, the underlying subscription is unprovisioned.
|
||||||
|
Expiring,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChargeStatus {
|
impl ChargeStatus {
|
||||||
@@ -217,6 +234,7 @@ impl ChargeStatus {
|
|||||||
"failed" => ChargeStatus::Failed,
|
"failed" => ChargeStatus::Failed,
|
||||||
"open" => ChargeStatus::Open,
|
"open" => ChargeStatus::Open,
|
||||||
"cancelled" => ChargeStatus::Cancelled,
|
"cancelled" => ChargeStatus::Cancelled,
|
||||||
|
"expiring" => ChargeStatus::Expiring,
|
||||||
_ => ChargeStatus::Failed,
|
_ => ChargeStatus::Failed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +246,7 @@ impl ChargeStatus {
|
|||||||
ChargeStatus::Failed => "failed",
|
ChargeStatus::Failed => "failed",
|
||||||
ChargeStatus::Open => "open",
|
ChargeStatus::Open => "open",
|
||||||
ChargeStatus::Cancelled => "cancelled",
|
ChargeStatus::Cancelled => "cancelled",
|
||||||
|
ChargeStatus::Expiring => "expiring",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,12 +254,14 @@ impl ChargeStatus {
|
|||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PaymentPlatform {
|
pub enum PaymentPlatform {
|
||||||
Stripe,
|
Stripe,
|
||||||
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaymentPlatform {
|
impl PaymentPlatform {
|
||||||
pub fn from_string(string: &str) -> PaymentPlatform {
|
pub fn from_string(string: &str) -> PaymentPlatform {
|
||||||
match string {
|
match string {
|
||||||
"stripe" => PaymentPlatform::Stripe,
|
"stripe" => PaymentPlatform::Stripe,
|
||||||
|
"none" => PaymentPlatform::None,
|
||||||
_ => PaymentPlatform::Stripe,
|
_ => PaymentPlatform::Stripe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +269,7 @@ impl PaymentPlatform {
|
|||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
PaymentPlatform::Stripe => "stripe",
|
PaymentPlatform::Stripe => "stripe",
|
||||||
|
PaymentPlatform::None => "none",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
apps/labrinth/src/queue/servers.rs
Normal file
0
apps/labrinth/src/queue/servers.rs
Normal file
@@ -1,5 +1,8 @@
|
|||||||
use crate::auth::{get_user_from_headers, send_email};
|
use crate::auth::{get_user_from_headers, send_email};
|
||||||
use crate::database::models::charge_item::DBCharge;
|
use crate::database::models::charge_item::DBCharge;
|
||||||
|
use crate::database::models::user_item::DBUser;
|
||||||
|
use crate::database::models::user_subscription_item::DBUserSubscription;
|
||||||
|
use crate::database::models::users_redeemals::{self, UserRedeemal};
|
||||||
use crate::database::models::{
|
use crate::database::models::{
|
||||||
generate_charge_id, generate_user_subscription_id, product_item,
|
generate_charge_id, generate_user_subscription_id, product_item,
|
||||||
user_subscription_item,
|
user_subscription_item,
|
||||||
@@ -14,6 +17,7 @@ use crate::models::pats::Scopes;
|
|||||||
use crate::models::users::Badges;
|
use crate::models::users::Badges;
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
|
use crate::util::archon::{ArchonClient, CreateServerRequest, Specs};
|
||||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
@@ -59,8 +63,10 @@ pub async fn products(
|
|||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let products =
|
let products = product_item::QueryProductWithPrices::list_purchaseable(
|
||||||
product_item::QueryProductWithPrices::list(&**pool, &redis).await?;
|
&**pool, &redis,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let products = products
|
let products = products
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -182,7 +188,9 @@ pub async fn refund_charge(
|
|||||||
ChargeStatus::Open
|
ChargeStatus::Open
|
||||||
| ChargeStatus::Processing
|
| ChargeStatus::Processing
|
||||||
| ChargeStatus::Succeeded => Some(x.amount),
|
| ChargeStatus::Succeeded => Some(x.amount),
|
||||||
ChargeStatus::Failed | ChargeStatus::Cancelled => None,
|
ChargeStatus::Failed
|
||||||
|
| ChargeStatus::Cancelled
|
||||||
|
| ChargeStatus::Expiring => None,
|
||||||
})
|
})
|
||||||
.sum::<i64>();
|
.sum::<i64>();
|
||||||
|
|
||||||
@@ -256,6 +264,12 @@ pub async fn refund_charge(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PaymentPlatform::None => {
|
||||||
|
return Err(ApiError::InvalidInput(
|
||||||
|
"This charge was not processed via a payment platform."
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -370,6 +384,7 @@ pub async fn edit_subscription(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(cancelled) = &edit_subscription.cancelled {
|
if let Some(cancelled) = &edit_subscription.cancelled {
|
||||||
|
// Notably, cannot cancel/uncancel expiring charges.
|
||||||
if !matches!(
|
if !matches!(
|
||||||
open_charge.status,
|
open_charge.status,
|
||||||
ChargeStatus::Open
|
ChargeStatus::Open
|
||||||
@@ -394,6 +409,8 @@ pub async fn edit_subscription(
|
|||||||
|
|
||||||
if let Some(interval) = &edit_subscription.interval {
|
if let Some(interval) = &edit_subscription.interval {
|
||||||
if let Price::Recurring { intervals } = ¤t_price.prices {
|
if let Price::Recurring { intervals } = ¤t_price.prices {
|
||||||
|
// For expiring charges, the interval is handled in the Product branch.
|
||||||
|
if open_charge.status != ChargeStatus::Expiring {
|
||||||
if let Some(price) = intervals.get(interval) {
|
if let Some(price) = intervals.get(interval) {
|
||||||
open_charge.subscription_interval = Some(*interval);
|
open_charge.subscription_interval = Some(*interval);
|
||||||
open_charge.amount = *price as i64;
|
open_charge.amount = *price as i64;
|
||||||
@@ -405,6 +422,7 @@ pub async fn edit_subscription(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let intent = if let Some(product_id) = &edit_subscription.product {
|
let intent = if let Some(product_id) = &edit_subscription.product {
|
||||||
let product_price =
|
let product_price =
|
||||||
@@ -429,6 +447,103 @@ pub async fn edit_subscription(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the charge is an expiring charge, we need to create a payment
|
||||||
|
// intent as if the user was subscribing to the product, as opposed
|
||||||
|
// to a proration.
|
||||||
|
if open_charge.status == ChargeStatus::Expiring {
|
||||||
|
// We need a new interval when promoting the charge.
|
||||||
|
let interval = edit_subscription.interval
|
||||||
|
.ok_or_else(|| ApiError::InvalidInput("You need to specify an interval when promoting an expiring charge.".to_owned()))?;
|
||||||
|
|
||||||
|
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||||
|
|
||||||
|
let customer_id = get_or_create_customer(
|
||||||
|
user.id,
|
||||||
|
user.stripe_customer_id.as_deref(),
|
||||||
|
user.email.as_deref(),
|
||||||
|
&stripe_client,
|
||||||
|
&pool,
|
||||||
|
&redis,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let new_price_value = match product_price.prices {
|
||||||
|
Price::OneTime { ref price } => *price,
|
||||||
|
Price::Recurring { ref intervals } => {
|
||||||
|
*intervals
|
||||||
|
.get(&interval)
|
||||||
|
.ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the specified duration".to_owned()))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let currency = Currency::from_str(
|
||||||
|
¤t_price.currency_code.to_lowercase(),
|
||||||
|
)
|
||||||
|
.map_err(|_| {
|
||||||
|
ApiError::InvalidInput("Invalid currency code".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut intent =
|
||||||
|
CreatePaymentIntent::new(new_price_value as i64, currency);
|
||||||
|
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
metadata.insert(
|
||||||
|
"modrinth_user_id".to_string(),
|
||||||
|
to_base62(user.id.0),
|
||||||
|
);
|
||||||
|
metadata.insert(
|
||||||
|
"modrinth_charge_id".to_string(),
|
||||||
|
to_base62(charge_id.0 as u64),
|
||||||
|
);
|
||||||
|
metadata.insert(
|
||||||
|
"modrinth_subscription_id".to_string(),
|
||||||
|
to_base62(subscription.id.0 as u64),
|
||||||
|
);
|
||||||
|
metadata.insert(
|
||||||
|
"modrinth_price_id".to_string(),
|
||||||
|
to_base62(product_price.id.0 as u64),
|
||||||
|
);
|
||||||
|
metadata.insert(
|
||||||
|
"modrinth_subscription_interval".to_string(),
|
||||||
|
interval.as_str().to_string(),
|
||||||
|
);
|
||||||
|
metadata.insert(
|
||||||
|
"modrinth_charge_type".to_string(),
|
||||||
|
ChargeType::Subscription.as_str().to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
intent.customer = Some(customer_id);
|
||||||
|
intent.metadata = Some(metadata);
|
||||||
|
intent.receipt_email = user.email.as_deref();
|
||||||
|
intent.setup_future_usage =
|
||||||
|
Some(PaymentIntentSetupFutureUsage::OffSession);
|
||||||
|
|
||||||
|
if let Some(payment_method) = &edit_subscription.payment_method
|
||||||
|
{
|
||||||
|
let Ok(payment_method_id) =
|
||||||
|
PaymentMethodId::from_str(payment_method)
|
||||||
|
else {
|
||||||
|
return Err(ApiError::InvalidInput(
|
||||||
|
"Invalid payment method id".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
intent.payment_method = Some(payment_method_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent =
|
||||||
|
stripe::PaymentIntent::create(&stripe_client, intent)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// We do NOT update the open charge here. It will be patched to be the next
|
||||||
|
// charge of the subscription in the stripe webhook.
|
||||||
|
//
|
||||||
|
// We also shouldn't delete it, because if the payment fails, the expiring
|
||||||
|
// charge will be gone and the preview subscription will never be unprovisioned.
|
||||||
|
|
||||||
|
Some((new_price_value, 0, intent))
|
||||||
|
} else {
|
||||||
|
// The charge is not an expiring charge, need to prorate.
|
||||||
|
|
||||||
let interval = open_charge.due - Utc::now();
|
let interval = open_charge.due - Utc::now();
|
||||||
let duration = PriceDuration::Monthly;
|
let duration = PriceDuration::Monthly;
|
||||||
|
|
||||||
@@ -452,7 +567,8 @@ pub async fn edit_subscription(
|
|||||||
|
|
||||||
let complete = Decimal::from(interval.num_seconds())
|
let complete = Decimal::from(interval.num_seconds())
|
||||||
/ Decimal::from(duration.duration().num_seconds());
|
/ Decimal::from(duration.duration().num_seconds());
|
||||||
let proration = (Decimal::from(amount - current_amount) * complete)
|
let proration = (Decimal::from(amount - current_amount)
|
||||||
|
* complete)
|
||||||
.floor()
|
.floor()
|
||||||
.to_i32()
|
.to_i32()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -461,8 +577,8 @@ pub async fn edit_subscription(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// First branch: Plan downgrade, update future charge
|
// First condition: Plan downgrade, update future charge
|
||||||
// Second branch: For small transactions (under 30 cents), we make a loss on the
|
// Second condition: For small transactions (under 30 cents), we make a loss on the
|
||||||
// proration due to fees. In these situations, just give it to them for free, because
|
// proration due to fees. In these situations, just give it to them for free, because
|
||||||
// their next charge will be in a day or two anyway.
|
// their next charge will be in a day or two anyway.
|
||||||
if current_amount > amount || proration < 30 {
|
if current_amount > amount || proration < 30 {
|
||||||
@@ -471,7 +587,8 @@ pub async fn edit_subscription(
|
|||||||
|
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
let charge_id =
|
||||||
|
generate_charge_id(&mut transaction).await?;
|
||||||
|
|
||||||
let customer_id = get_or_create_customer(
|
let customer_id = get_or_create_customer(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -487,7 +604,9 @@ pub async fn edit_subscription(
|
|||||||
¤t_price.currency_code.to_lowercase(),
|
¤t_price.currency_code.to_lowercase(),
|
||||||
)
|
)
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
ApiError::InvalidInput("Invalid currency code".to_string())
|
ApiError::InvalidInput(
|
||||||
|
"Invalid currency code".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut intent =
|
let mut intent =
|
||||||
@@ -529,7 +648,8 @@ pub async fn edit_subscription(
|
|||||||
intent.setup_future_usage =
|
intent.setup_future_usage =
|
||||||
Some(PaymentIntentSetupFutureUsage::OffSession);
|
Some(PaymentIntentSetupFutureUsage::OffSession);
|
||||||
|
|
||||||
if let Some(payment_method) = &edit_subscription.payment_method
|
if let Some(payment_method) =
|
||||||
|
&edit_subscription.payment_method
|
||||||
{
|
{
|
||||||
let Ok(payment_method_id) =
|
let Ok(payment_method_id) =
|
||||||
PaymentMethodId::from_str(payment_method)
|
PaymentMethodId::from_str(payment_method)
|
||||||
@@ -547,6 +667,7 @@ pub async fn edit_subscription(
|
|||||||
|
|
||||||
Some((proration, 0, intent))
|
Some((proration, 0, intent))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -948,14 +1069,17 @@ pub async fn active_servers(
|
|||||||
let server_ids = servers
|
let server_ids = servers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|x| {
|
.filter_map(|x| {
|
||||||
x.metadata.as_ref().map(|metadata| match metadata {
|
x.metadata.as_ref().and_then(|metadata| match metadata {
|
||||||
SubscriptionMetadata::Pyro { id, region } => ActiveServer {
|
SubscriptionMetadata::Pyro { id, region } => {
|
||||||
|
Some(ActiveServer {
|
||||||
user_id: x.user_id.into(),
|
user_id: x.user_id.into(),
|
||||||
server_id: id.clone(),
|
server_id: id.clone(),
|
||||||
price_id: x.price_id.into(),
|
price_id: x.price_id.into(),
|
||||||
interval: x.interval,
|
interval: x.interval,
|
||||||
region: region.clone(),
|
region: region.clone(),
|
||||||
},
|
})
|
||||||
|
}
|
||||||
|
SubscriptionMetadata::Medal { .. } => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<ActiveServer>>();
|
.collect::<Vec<ActiveServer>>();
|
||||||
@@ -1187,7 +1311,7 @@ pub async fn initiate_payment(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut product_prices =
|
let mut product_prices =
|
||||||
product_item::DBProductPrice::get_all_product_prices(
|
product_item::DBProductPrice::get_all_public_product_prices(
|
||||||
product.id, &**pool,
|
product.id, &**pool,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1704,6 +1828,13 @@ pub async fn stripe_webhook(
|
|||||||
|
|
||||||
// Provision subscription
|
// Provision subscription
|
||||||
match metadata.product_item.metadata {
|
match metadata.product_item.metadata {
|
||||||
|
// A payment shouldn't be processed for Medal subscriptions.
|
||||||
|
ProductMetadata::Medal { .. } => {
|
||||||
|
warn!(
|
||||||
|
"A payment processed for a free subscription"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ProductMetadata::Midas => {
|
ProductMetadata::Midas => {
|
||||||
let badges =
|
let badges =
|
||||||
metadata.user_item.badges | Badges::MIDAS;
|
metadata.user_item.badges | Badges::MIDAS;
|
||||||
@@ -1833,6 +1964,7 @@ pub async fn stripe_webhook(
|
|||||||
"region": server_region,
|
"region": server_region,
|
||||||
"source": source,
|
"source": source,
|
||||||
"payment_interval": metadata.charge_item.subscription_interval.map(|x| match x {
|
"payment_interval": metadata.charge_item.subscription_interval.map(|x| match x {
|
||||||
|
PriceDuration::FiveDays => 1,
|
||||||
PriceDuration::Monthly => 1,
|
PriceDuration::Monthly => 1,
|
||||||
PriceDuration::Quarterly => 3,
|
PriceDuration::Quarterly => 3,
|
||||||
PriceDuration::Yearly => 12,
|
PriceDuration::Yearly => 12,
|
||||||
@@ -1879,10 +2011,32 @@ pub async fn stripe_webhook(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If the next open charge is actually an expiring charge,
|
||||||
|
// this means the subscription was promoted from a temporary
|
||||||
|
// free subscription to a paid subscription.
|
||||||
|
//
|
||||||
|
// In this case, we need to modify this expiring charge to be the
|
||||||
|
// next charge of the subscription, turn it into a normal open charge.
|
||||||
|
//
|
||||||
|
// Otherwise, if there *is* an open charge, the subscription was upgraded
|
||||||
|
// and the just-processed payment was the proration charge. In this case,
|
||||||
|
// the existing open charge must be updated to reflect the new product's price.
|
||||||
|
//
|
||||||
|
// If there are no open charges, the just-processed payment was a recurring
|
||||||
|
// or initial subscription charge, and we need to create the next charge.
|
||||||
if let Some(mut charge) = open_charge {
|
if let Some(mut charge) = open_charge {
|
||||||
charge.price_id = metadata.product_price_item.id;
|
if charge.status == ChargeStatus::Expiring {
|
||||||
|
charge.status = ChargeStatus::Open;
|
||||||
|
charge.due = Utc::now()
|
||||||
|
+ subscription.interval.duration();
|
||||||
|
charge.payment_platform =
|
||||||
|
PaymentPlatform::Stripe;
|
||||||
|
charge.last_attempt = None;
|
||||||
|
} else {
|
||||||
|
charge.price_id =
|
||||||
|
metadata.product_price_item.id;
|
||||||
charge.amount = new_price as i64;
|
charge.amount = new_price as i64;
|
||||||
|
}
|
||||||
charge.upsert(&mut transaction).await?;
|
charge.upsert(&mut transaction).await?;
|
||||||
} else if metadata.charge_item.status
|
} else if metadata.charge_item.status
|
||||||
!= ChargeStatus::Cancelled
|
!= ChargeStatus::Cancelled
|
||||||
@@ -2105,7 +2259,11 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
|||||||
let mut transaction = pool.begin().await?;
|
let mut transaction = pool.begin().await?;
|
||||||
let mut clear_cache_users = Vec::new();
|
let mut clear_cache_users = Vec::new();
|
||||||
|
|
||||||
// If an active subscription has a canceled charge OR a failed charge more than two days ago, it should be cancelled
|
// If an active subscription has:
|
||||||
|
// - A canceled charge due now
|
||||||
|
// - An expiring charge due now
|
||||||
|
// - A failed charge more than two days ago
|
||||||
|
// It should be unprovisioned.
|
||||||
let all_charges = DBCharge::get_unprovision(&pool).await?;
|
let all_charges = DBCharge::get_unprovision(&pool).await?;
|
||||||
|
|
||||||
let mut all_subscriptions =
|
let mut all_subscriptions =
|
||||||
@@ -2201,19 +2359,26 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
|||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
ProductMetadata::Pyro { .. } => {
|
|
||||||
if let Some(SubscriptionMetadata::Pyro { id, region: _ }) =
|
ProductMetadata::Pyro { .. }
|
||||||
&subscription.metadata
|
| ProductMetadata::Medal { .. } => 'server: {
|
||||||
{
|
let server_id = match &subscription.metadata {
|
||||||
|
Some(SubscriptionMetadata::Pyro { id, region: _ }) => {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
Some(SubscriptionMetadata::Medal { id }) => id,
|
||||||
|
_ => break 'server true,
|
||||||
|
};
|
||||||
|
|
||||||
let res = reqwest::Client::new()
|
let res = reqwest::Client::new()
|
||||||
.post(format!(
|
.post(format!(
|
||||||
"{}/modrinth/v0/servers/{}/suspend",
|
"{}/modrinth/v0/servers/{}/suspend",
|
||||||
dotenvy::var("ARCHON_URL")?,
|
dotenvy::var("ARCHON_URL")?,
|
||||||
id
|
server_id
|
||||||
))
|
))
|
||||||
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
|
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"reason": if charge.status == ChargeStatus::Cancelled {
|
"reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring {
|
||||||
"cancelled"
|
"cancelled"
|
||||||
} else {
|
} else {
|
||||||
"paymentfailed"
|
"paymentfailed"
|
||||||
@@ -2228,9 +2393,6 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
|||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2252,6 +2414,20 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
|||||||
.await?;
|
.await?;
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
|
|
||||||
|
// If an offer redeemal has been processing for over 5 minutes, it should be set pending.
|
||||||
|
UserRedeemal::update_stuck_5_minutes(&pool).await?;
|
||||||
|
|
||||||
|
// If an offer redeemal is pending, try processing it.
|
||||||
|
// Try processing it.
|
||||||
|
let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?;
|
||||||
|
for redeemal in pending_redeemals {
|
||||||
|
if let Err(error) =
|
||||||
|
try_process_user_redeemal(&pool, &redis, redeemal).await
|
||||||
|
{
|
||||||
|
warn!(%error, "Failed to process a redeemal.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok::<(), ApiError>(())
|
Ok::<(), ApiError>(())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2262,6 +2438,161 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
|||||||
info!("Done indexing subscriptions");
|
info!("Done indexing subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to process a user redeemal.
|
||||||
|
///
|
||||||
|
/// Returns `Ok` if the entry has been succesfully processed, or will not be processed.
|
||||||
|
pub async fn try_process_user_redeemal(
|
||||||
|
pool: &PgPool,
|
||||||
|
redis: &RedisPool,
|
||||||
|
mut user_redeemal: UserRedeemal,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
// Immediately update redeemal row
|
||||||
|
user_redeemal.last_attempt = Some(Utc::now());
|
||||||
|
user_redeemal.n_attempts += 1;
|
||||||
|
user_redeemal.status = users_redeemals::Status::Processing;
|
||||||
|
let updated = user_redeemal.update_status_if_pending(pool).await?;
|
||||||
|
|
||||||
|
if !updated {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = user_redeemal.user_id;
|
||||||
|
|
||||||
|
// Find the Medal product's price & metadata
|
||||||
|
|
||||||
|
let mut medal_products =
|
||||||
|
product_item::QueryProductWithPrices::list_by_product_type(
|
||||||
|
pool, "medal",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(product_item::QueryProductWithPrices {
|
||||||
|
id: _product_id,
|
||||||
|
metadata,
|
||||||
|
mut prices,
|
||||||
|
unitary: _,
|
||||||
|
}) = medal_products.pop()
|
||||||
|
else {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"Missing Medal subscription product".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let ProductMetadata::Medal {
|
||||||
|
cpu,
|
||||||
|
ram,
|
||||||
|
swap,
|
||||||
|
storage,
|
||||||
|
region,
|
||||||
|
} = metadata
|
||||||
|
else {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"Missing or incorrect metadata for Medal subscription".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(medal_price) = prices.pop() else {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"Missing price for Medal subscription".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (price_duration, price_amount) = match medal_price.prices {
|
||||||
|
Price::OneTime { price: _ } => {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"Unexpected metadata for Medal subscription price".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Price::Recurring { intervals } => {
|
||||||
|
let Some((price_duration, price_amount)) =
|
||||||
|
intervals.into_iter().next()
|
||||||
|
else {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"Missing price interval for Medal subscription".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
(price_duration, price_amount)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let price_id = medal_price.id;
|
||||||
|
|
||||||
|
// Get the user's username
|
||||||
|
|
||||||
|
let user = DBUser::get_id(user_id, pool, redis)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
// Send the provision request to Archon. On failure, the redeemal will be "stuck" processing,
|
||||||
|
// and moved back to pending by `index_subscriptions`.
|
||||||
|
|
||||||
|
let archon_client = ArchonClient::from_env()?;
|
||||||
|
let server_id = archon_client
|
||||||
|
.create_server(&CreateServerRequest {
|
||||||
|
user_id: to_base62(user_id.0 as u64),
|
||||||
|
name: format!("{}'s Medal server", user.username),
|
||||||
|
specs: Specs {
|
||||||
|
memory_mb: ram,
|
||||||
|
cpu,
|
||||||
|
swap_mb: swap,
|
||||||
|
storage_mb: storage,
|
||||||
|
},
|
||||||
|
source: crate::util::archon::Empty::default(),
|
||||||
|
region,
|
||||||
|
tags: vec!["medal".to_owned()],
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut txn = pool.begin().await?;
|
||||||
|
|
||||||
|
// Build a subscription using this price ID.
|
||||||
|
let subscription = DBUserSubscription {
|
||||||
|
id: generate_user_subscription_id(&mut txn).await?,
|
||||||
|
user_id,
|
||||||
|
price_id,
|
||||||
|
interval: PriceDuration::FiveDays,
|
||||||
|
created: Utc::now(),
|
||||||
|
status: SubscriptionStatus::Provisioned,
|
||||||
|
metadata: Some(SubscriptionMetadata::Medal {
|
||||||
|
id: server_id.to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
subscription.upsert(&mut txn).await?;
|
||||||
|
|
||||||
|
// Insert an expiring charge, `index_subscriptions` will unprovision the
|
||||||
|
// subscription when expired.
|
||||||
|
DBCharge {
|
||||||
|
id: generate_charge_id(&mut txn).await?,
|
||||||
|
user_id,
|
||||||
|
price_id,
|
||||||
|
amount: price_amount.into(),
|
||||||
|
currency_code: medal_price.currency_code,
|
||||||
|
status: ChargeStatus::Expiring,
|
||||||
|
due: Utc::now() + price_duration.duration(),
|
||||||
|
last_attempt: None,
|
||||||
|
type_: ChargeType::Subscription,
|
||||||
|
subscription_id: Some(subscription.id),
|
||||||
|
subscription_interval: Some(subscription.interval),
|
||||||
|
payment_platform: PaymentPlatform::None,
|
||||||
|
payment_platform_id: None,
|
||||||
|
parent_charge_id: None,
|
||||||
|
net: None,
|
||||||
|
}
|
||||||
|
.upsert(&mut txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update `users_redeemal`, mark subscription as redeemed.
|
||||||
|
user_redeemal.status = users_redeemals::Status::Processed;
|
||||||
|
user_redeemal.update(&mut *txn).await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn index_billing(
|
pub async fn index_billing(
|
||||||
stripe_client: stripe::Client,
|
stripe_client: stripe::Client,
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
|
|||||||
109
apps/labrinth/src/routes/internal/medal.rs
Normal file
109
apps/labrinth/src/routes/internal/medal.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use actix_web::{HttpResponse, post, web};
|
||||||
|
use ariadne::ids::UserId;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::database::models::users_redeemals::{
|
||||||
|
Offer, RedeemalLookupFields, Status, UserRedeemal,
|
||||||
|
};
|
||||||
|
use crate::database::redis::RedisPool;
|
||||||
|
use crate::routes::ApiError;
|
||||||
|
use crate::routes::internal::billing::try_process_user_redeemal;
|
||||||
|
use crate::util::guards::medal_key_guard;
|
||||||
|
|
||||||
|
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(web::scope("medal").service(verify).service(redeem));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MedalQuery {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("verify", guard = "medal_key_guard")]
|
||||||
|
pub async fn verify(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let maybe_fields =
|
||||||
|
RedeemalLookupFields::redeemal_status_by_username_and_offer(
|
||||||
|
&**pool,
|
||||||
|
&username,
|
||||||
|
Offer::Medal,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct VerifyResponse {
|
||||||
|
user_id: UserId,
|
||||||
|
redeemed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
match maybe_fields {
|
||||||
|
None => Err(ApiError::NotFound),
|
||||||
|
Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse {
|
||||||
|
user_id: fields.user_id.into(),
|
||||||
|
redeemed: fields.redeemal_status.is_some(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("redeem", guard = "medal_key_guard")]
|
||||||
|
pub async fn redeem(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
// Check the offer hasn't been redeemed yet, then insert into the table.
|
||||||
|
// In a transaction to avoid double inserts.
|
||||||
|
|
||||||
|
let mut txn = pool.begin().await?;
|
||||||
|
|
||||||
|
let maybe_fields =
|
||||||
|
RedeemalLookupFields::redeemal_status_by_username_and_offer(
|
||||||
|
&mut *txn,
|
||||||
|
&username,
|
||||||
|
Offer::Medal,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_id = match maybe_fields {
|
||||||
|
None => return Err(ApiError::NotFound),
|
||||||
|
Some(fields) => {
|
||||||
|
if fields.redeemal_status.is_some() {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"User already redeemed this offer".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.user_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Link user to offer redeemal.
|
||||||
|
let mut redeemal = UserRedeemal {
|
||||||
|
id: 0,
|
||||||
|
user_id,
|
||||||
|
offer: Offer::Medal,
|
||||||
|
redeemed: Utc::now(),
|
||||||
|
status: Status::Pending,
|
||||||
|
last_attempt: None,
|
||||||
|
n_attempts: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
redeemal.insert(&mut *txn).await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
|
// Immediately try to process the redeemal
|
||||||
|
if let Err(error) = try_process_user_redeemal(&pool, &redis, redeemal).await
|
||||||
|
{
|
||||||
|
warn!(%error, "Medal redeemal processing failed");
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().finish())
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::Created().finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub(crate) mod admin;
|
|||||||
pub mod billing;
|
pub mod billing;
|
||||||
pub mod flows;
|
pub mod flows;
|
||||||
pub mod gdpr;
|
pub mod gdpr;
|
||||||
|
pub mod medal;
|
||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
pub mod pats;
|
pub mod pats;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -24,6 +25,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
.configure(moderation::config)
|
.configure(moderation::config)
|
||||||
.configure(billing::config)
|
.configure(billing::config)
|
||||||
.configure(gdpr::config)
|
.configure(gdpr::config)
|
||||||
.configure(statuses::config),
|
.configure(statuses::config)
|
||||||
|
.configure(medal::config),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ pub enum ApiError {
|
|||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error("Resource not found")]
|
#[error("Resource not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
#[error(
|
#[error(
|
||||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||||
)]
|
)]
|
||||||
@@ -172,6 +174,7 @@ impl ApiError {
|
|||||||
ApiError::Clickhouse(..) => "clickhouse_error",
|
ApiError::Clickhouse(..) => "clickhouse_error",
|
||||||
ApiError::Reroute(..) => "reroute_error",
|
ApiError::Reroute(..) => "reroute_error",
|
||||||
ApiError::NotFound => "not_found",
|
ApiError::NotFound => "not_found",
|
||||||
|
ApiError::Conflict(..) => "conflict",
|
||||||
ApiError::Zip(..) => "zip_error",
|
ApiError::Zip(..) => "zip_error",
|
||||||
ApiError::Io(..) => "io_error",
|
ApiError::Io(..) => "io_error",
|
||||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||||
@@ -208,6 +211,7 @@ impl actix_web::ResponseError for ApiError {
|
|||||||
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
|||||||
75
apps/labrinth/src/util/archon.rs
Normal file
75
apps/labrinth/src/util/archon.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use reqwest::header::HeaderName;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::routes::ApiError;
|
||||||
|
|
||||||
|
const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key");
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Empty {}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Specs {
|
||||||
|
pub memory_mb: u32,
|
||||||
|
pub cpu: u32,
|
||||||
|
pub swap_mb: u32,
|
||||||
|
pub storage_mb: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateServerRequest {
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub specs: Specs,
|
||||||
|
// Must be included because archon doesn't accept null values, only
|
||||||
|
// an empty struct, as a source.
|
||||||
|
pub source: Empty,
|
||||||
|
pub region: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ArchonClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
pyro_api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchonClient {
|
||||||
|
/// Builds an Archon client from environment variables. Returns `None` if the
|
||||||
|
/// required environment variables are not set.
|
||||||
|
pub fn from_env() -> Result<Self, ApiError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let base_url =
|
||||||
|
dotenvy::var("ARCHON_URL")?.trim_end_matches('/').to_owned();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
base_url,
|
||||||
|
pyro_api_key: dotenvy::var("PYRO_API_KEY")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_server(
|
||||||
|
&self,
|
||||||
|
request: &CreateServerRequest,
|
||||||
|
) -> Result<Uuid, reqwest::Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateServerResponse {
|
||||||
|
uuid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/modrinth/v0/servers/create", self.base_url))
|
||||||
|
.header(X_MASTER_KEY, &self.pyro_api_key)
|
||||||
|
.json(request)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
|
Ok(response.json::<CreateServerResponse>().await?.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use actix_web::guard::GuardContext;
|
use actix_web::guard::GuardContext;
|
||||||
|
|
||||||
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
||||||
|
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
|
||||||
|
|
||||||
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||||
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
|
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
|
||||||
"No admin key provided, this should have been caught by check_env_vars",
|
"No admin key provided, this should have been caught by check_env_vars",
|
||||||
@@ -10,3 +12,16 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
|||||||
.get(ADMIN_KEY_HEADER)
|
.get(ADMIN_KEY_HEADER)
|
||||||
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn medal_key_guard(ctx: &GuardContext) -> bool {
|
||||||
|
let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok();
|
||||||
|
|
||||||
|
match maybe_medal_key {
|
||||||
|
None => false,
|
||||||
|
Some(medal_key) => ctx
|
||||||
|
.head()
|
||||||
|
.headers()
|
||||||
|
.get(MEDAL_KEY_HEADER)
|
||||||
|
.is_some_and(|it| it.as_bytes() == medal_key.as_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod actix;
|
pub mod actix;
|
||||||
|
pub mod archon;
|
||||||
pub mod bitflag;
|
pub mod bitflag;
|
||||||
pub mod captcha;
|
pub mod captcha;
|
||||||
pub mod cors;
|
pub mod cors;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ fn build_java_jars() {
|
|||||||
.arg("build")
|
.arg("build")
|
||||||
.arg("--no-daemon")
|
.arg("--no-daemon")
|
||||||
.arg("--console=rich")
|
.arg("--console=rich")
|
||||||
|
.arg("--info")
|
||||||
.current_dir(dunce::canonicalize("java").unwrap())
|
.current_dir(dunce::canonicalize("java").unwrap())
|
||||||
.status()
|
.status()
|
||||||
.expect("Failed to wait on Gradle build");
|
.expect("Failed to wait on Gradle build");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
id("com.diffplug.spotless") version "7.0.4"
|
id("com.diffplug.spotless") version "7.0.4"
|
||||||
|
id("com.gradleup.shadow") version "9.0.0-rc2"
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -8,6 +9,9 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("org.ow2.asm:asm:9.8")
|
||||||
|
implementation("org.ow2.asm:asm-tree:9.8")
|
||||||
|
|
||||||
testImplementation(libs.junit.jupiter)
|
testImplementation(libs.junit.jupiter)
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,17 @@ spotless {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.jar {
|
tasks.jar {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.shadowJar {
|
||||||
archiveFileName = "theseus.jar"
|
archiveFileName = "theseus.jar"
|
||||||
|
manifest {
|
||||||
|
attributes["Premain-Class"] = "com.modrinth.theseus.agent.TheseusAgent"
|
||||||
|
}
|
||||||
|
|
||||||
|
enableRelocation = true
|
||||||
|
relocationPrefix = "com.modrinth.theseus.shadow"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named<Test>("test") {
|
tasks.named<Test>("test") {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.modrinth.theseus.agent;
|
||||||
|
|
||||||
|
import java.util.ListIterator;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import org.objectweb.asm.Type;
|
||||||
|
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||||
|
import org.objectweb.asm.tree.FieldInsnNode;
|
||||||
|
|
||||||
|
public interface InsnPattern extends Predicate<AbstractInsnNode> {
|
||||||
|
/**
|
||||||
|
* Advances past the first match of all instructions in the pattern.
|
||||||
|
* @return {@code true} if the pattern was found, {@code false} if not
|
||||||
|
*/
|
||||||
|
static boolean findAndSkip(ListIterator<AbstractInsnNode> iterator, InsnPattern... pattern) {
|
||||||
|
if (pattern.length == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int patternIndex = 0;
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
final AbstractInsnNode insn = iterator.next();
|
||||||
|
if (insn.getOpcode() == -1) continue;
|
||||||
|
if (pattern[patternIndex].test(insn) && ++patternIndex == pattern.length) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
patternIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static InsnPattern opcode(int opcode) {
|
||||||
|
return insn -> insn.getOpcode() == opcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static InsnPattern field(int opcode, Type fieldType) {
|
||||||
|
final String typeDescriptor = fieldType.getDescriptor();
|
||||||
|
return insn -> {
|
||||||
|
if (insn.getOpcode() != opcode || !(insn instanceof FieldInsnNode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final FieldInsnNode fieldInsn = (FieldInsnNode) insn;
|
||||||
|
return typeDescriptor.equals(fieldInsn.desc);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.modrinth.theseus.agent;
|
||||||
|
|
||||||
|
// Must be kept up-to-date with quick_play_version.rs
|
||||||
|
public enum QuickPlayServerVersion {
|
||||||
|
BUILTIN,
|
||||||
|
BUILTIN_LEGACY,
|
||||||
|
INJECTED,
|
||||||
|
UNSUPPORTED;
|
||||||
|
|
||||||
|
public static final QuickPlayServerVersion CURRENT =
|
||||||
|
valueOf(System.getProperty("modrinth.internal.quickPlay.serverVersion"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.modrinth.theseus.agent;
|
||||||
|
|
||||||
|
import com.modrinth.theseus.agent.transformers.ClassTransformer;
|
||||||
|
import com.modrinth.theseus.agent.transformers.MinecraftTransformer;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.lang.instrument.Instrumentation;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.objectweb.asm.ClassReader;
|
||||||
|
import org.objectweb.asm.ClassWriter;
|
||||||
|
|
||||||
|
@SuppressWarnings({"NullableProblems", "CallToPrintStackTrace"})
|
||||||
|
public final class TheseusAgent {
|
||||||
|
private static final boolean DEBUG_AGENT = Boolean.getBoolean("modrinth.debugAgent");
|
||||||
|
|
||||||
|
public static void premain(String args, Instrumentation instrumentation) {
|
||||||
|
final Path debugPath = Paths.get("ModrinthDebugTransformed");
|
||||||
|
if (DEBUG_AGENT) {
|
||||||
|
System.out.println(
|
||||||
|
"===== Theseus agent debugging enabled. Dumping transformed classes to " + debugPath + " =====");
|
||||||
|
if (Files.exists(debugPath)) {
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(debugPath, new SimpleFileVisitor<Path>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||||
|
Files.delete(file);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||||
|
Files.delete(dir);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
new UncheckedIOException("Failed to delete " + debugPath, e).printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("===== Quick play server version: " + QuickPlayServerVersion.CURRENT + " =====");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, ClassTransformer> transformers = new HashMap<>();
|
||||||
|
transformers.put("net/minecraft/client/Minecraft", new MinecraftTransformer());
|
||||||
|
|
||||||
|
instrumentation.addTransformer((loader, className, classBeingRedefined, protectionDomain, classData) -> {
|
||||||
|
final ClassTransformer transformer = transformers.get(className);
|
||||||
|
if (transformer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final ClassReader reader = new ClassReader(classData);
|
||||||
|
final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
|
||||||
|
try {
|
||||||
|
if (!transformer.transform(reader, writer)) {
|
||||||
|
if (DEBUG_AGENT) {
|
||||||
|
System.out.println("Not writing " + className + " as its transformer returned false");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
new IllegalStateException("Failed to transform " + className, t).printStackTrace();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final byte[] result = writer.toByteArray();
|
||||||
|
if (DEBUG_AGENT) {
|
||||||
|
try {
|
||||||
|
final Path path = debugPath.resolve(className + ".class");
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.write(path, result);
|
||||||
|
System.out.println("Dumped class to " + path.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
new UncheckedIOException("Failed to dump class " + className, e).printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.modrinth.theseus.agent.transformers;
|
||||||
|
|
||||||
|
import org.objectweb.asm.ClassReader;
|
||||||
|
import org.objectweb.asm.ClassWriter;
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
|
||||||
|
public abstract class ClassNodeTransformer extends ClassTransformer {
|
||||||
|
protected abstract boolean transform(ClassNode classNode);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final boolean transform(ClassReader reader, ClassWriter writer) {
|
||||||
|
final ClassNode classNode = new ClassNode();
|
||||||
|
reader.accept(classNode, 0);
|
||||||
|
if (!transform(classNode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
classNode.accept(writer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.modrinth.theseus.agent.transformers;
|
||||||
|
|
||||||
|
import org.objectweb.asm.ClassReader;
|
||||||
|
import org.objectweb.asm.ClassWriter;
|
||||||
|
import org.objectweb.asm.Opcodes;
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
|
||||||
|
public abstract class ClassTransformer {
|
||||||
|
public abstract boolean transform(ClassReader reader, ClassWriter writer);
|
||||||
|
|
||||||
|
protected static boolean needsStackMap(ClassNode classNode) {
|
||||||
|
return (classNode.version & 0xffff) >= Opcodes.V1_6;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.modrinth.theseus.agent.transformers;
|
||||||
|
|
||||||
|
import com.modrinth.theseus.agent.InsnPattern;
|
||||||
|
import com.modrinth.theseus.agent.QuickPlayServerVersion;
|
||||||
|
import java.util.ListIterator;
|
||||||
|
import org.objectweb.asm.Opcodes;
|
||||||
|
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
import org.objectweb.asm.tree.FrameNode;
|
||||||
|
import org.objectweb.asm.tree.InsnNode;
|
||||||
|
import org.objectweb.asm.tree.JumpInsnNode;
|
||||||
|
import org.objectweb.asm.tree.LabelNode;
|
||||||
|
import org.objectweb.asm.tree.LdcInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodNode;
|
||||||
|
import org.objectweb.asm.tree.VarInsnNode;
|
||||||
|
|
||||||
|
public final class MinecraftTransformer extends ClassNodeTransformer {
|
||||||
|
private static final String SET_SERVER_NAME_DESC = "(Ljava/lang/String;I)V";
|
||||||
|
private static final InsnPattern[] INITIALIZE_THIS_PATTERN = {InsnPattern.opcode(Opcodes.INVOKESPECIAL)};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean transform(ClassNode classNode) {
|
||||||
|
if (QuickPlayServerVersion.CURRENT == QuickPlayServerVersion.INJECTED) {
|
||||||
|
return addServerJoinSupport(classNode);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean addServerJoinSupport(ClassNode classNode) {
|
||||||
|
String setServerName = null;
|
||||||
|
MethodNode constructor = null;
|
||||||
|
for (final MethodNode method : classNode.methods) {
|
||||||
|
if (constructor == null && method.name.equals("<init>")) {
|
||||||
|
constructor = method;
|
||||||
|
} else if (method.desc.equals(SET_SERVER_NAME_DESC) && method.name.indexOf('$') == -1) {
|
||||||
|
// Check for $ is because Mixin-injected methods should have $ in it
|
||||||
|
if (setServerName == null) {
|
||||||
|
setServerName = method.name;
|
||||||
|
} else {
|
||||||
|
// Already found a setServer method, but we found another one? Since we can't
|
||||||
|
// know which is real, just return so we don't call something we shouldn't.
|
||||||
|
// Note this can't happen unless some other mod is adding a method with this
|
||||||
|
// same descriptor.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constructor == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ListIterator<AbstractInsnNode> it = constructor.instructions.iterator();
|
||||||
|
if (!InsnPattern.findAndSkip(it, INITIALIZE_THIS_PATTERN)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final LabelNode noQuickPlayLabel = new LabelNode();
|
||||||
|
final LabelNode doneQuickPlayLabel = new LabelNode();
|
||||||
|
it.add(new LdcInsnNode("modrinth.internal.quickPlay.host"));
|
||||||
|
// String
|
||||||
|
it.add(new MethodInsnNode(
|
||||||
|
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
|
||||||
|
// String
|
||||||
|
it.add(new InsnNode(Opcodes.DUP));
|
||||||
|
// String String
|
||||||
|
it.add(new JumpInsnNode(Opcodes.IFNULL, noQuickPlayLabel));
|
||||||
|
// String
|
||||||
|
it.add(new VarInsnNode(Opcodes.ALOAD, 0));
|
||||||
|
// String Minecraft
|
||||||
|
it.add(new InsnNode(Opcodes.SWAP));
|
||||||
|
// Minecraft String
|
||||||
|
it.add(new LdcInsnNode("modrinth.internal.quickPlay.port"));
|
||||||
|
// Minecraft String String
|
||||||
|
it.add(new MethodInsnNode(
|
||||||
|
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
|
||||||
|
// Minecraft String String
|
||||||
|
it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I"));
|
||||||
|
// Minecraft String int
|
||||||
|
it.add(new MethodInsnNode(
|
||||||
|
Opcodes.INVOKEVIRTUAL, "net/minecraft/client/Minecraft", setServerName, SET_SERVER_NAME_DESC));
|
||||||
|
//
|
||||||
|
it.add(new JumpInsnNode(Opcodes.GOTO, doneQuickPlayLabel));
|
||||||
|
it.add(noQuickPlayLabel);
|
||||||
|
if (needsStackMap(classNode)) {
|
||||||
|
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
|
||||||
|
}
|
||||||
|
// String
|
||||||
|
it.add(new InsnNode(Opcodes.POP));
|
||||||
|
//
|
||||||
|
it.add(doneQuickPlayLabel);
|
||||||
|
if (needsStackMap(classNode)) {
|
||||||
|
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
|
||||||
|
}
|
||||||
|
//
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod mr_auth;
|
|||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod server_address;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod worlds;
|
pub mod worlds;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use serde_json::json;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::data::Settings;
|
use crate::data::Settings;
|
||||||
|
use crate::server_address::ServerAddress;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::iter::FromIterator;
|
use std::iter::FromIterator;
|
||||||
use std::{
|
use std::{
|
||||||
@@ -40,7 +41,7 @@ pub mod update;
|
|||||||
pub enum QuickPlayType {
|
pub enum QuickPlayType {
|
||||||
None,
|
None,
|
||||||
Singleplayer(String),
|
Singleplayer(String),
|
||||||
Server(String),
|
Server(ServerAddress),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a profile
|
/// Remove a profile
|
||||||
@@ -630,7 +631,7 @@ fn pack_get_relative_path(
|
|||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
path: &str,
|
path: &str,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: QuickPlayType,
|
||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
|
|
||||||
@@ -646,7 +647,7 @@ pub async fn run(
|
|||||||
async fn run_credentials(
|
async fn run_credentials(
|
||||||
path: &str,
|
path: &str,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: QuickPlayType,
|
||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let settings = Settings::get(&state.pool).await?;
|
let settings = Settings::get(&state.pool).await?;
|
||||||
|
|||||||
166
packages/app-lib/src/api/server_address.rs
Normal file
166
packages/app-lib/src/api/server_address.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
use crate::{Error, ErrorKind, Result};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::mem;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ServerAddress {
|
||||||
|
Unresolved(String),
|
||||||
|
Resolved {
|
||||||
|
original_host: String,
|
||||||
|
original_port: u16,
|
||||||
|
resolved_host: String,
|
||||||
|
resolved_port: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerAddress {
|
||||||
|
pub async fn resolve(&mut self) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Unresolved(address) => {
|
||||||
|
let (host, port) = parse_server_address(address)?;
|
||||||
|
let (resolved_host, resolved_port) =
|
||||||
|
resolve_server_address(host, port).await?;
|
||||||
|
*self = Self::Resolved {
|
||||||
|
original_host: if host.len() == address.len() {
|
||||||
|
mem::take(address)
|
||||||
|
} else {
|
||||||
|
host.to_owned()
|
||||||
|
},
|
||||||
|
original_port: port,
|
||||||
|
resolved_host,
|
||||||
|
resolved_port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Resolved { .. } => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_resolved(&self) -> Result<(&str, u16)> {
|
||||||
|
match self {
|
||||||
|
Self::Resolved {
|
||||||
|
resolved_host,
|
||||||
|
resolved_port,
|
||||||
|
..
|
||||||
|
} => Ok((resolved_host, *resolved_port)),
|
||||||
|
Self::Unresolved(address) => Err(ErrorKind::InputError(format!(
|
||||||
|
"Unexpected unresolved server address: {address}"
|
||||||
|
))
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ServerAddress {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Unresolved(address) => write!(f, "{address}"),
|
||||||
|
Self::Resolved {
|
||||||
|
resolved_host,
|
||||||
|
resolved_port,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if resolved_host.contains(':') {
|
||||||
|
write!(f, "[{resolved_host}]:{resolved_port}")
|
||||||
|
} else {
|
||||||
|
write!(f, "{resolved_host}:{resolved_port}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
|
||||||
|
parse_server_address_inner(address)
|
||||||
|
.map_err(|e| Error::from(ErrorKind::InputError(e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
|
||||||
|
fn parse_server_address_inner(
|
||||||
|
address: &str,
|
||||||
|
) -> std::result::Result<(&str, u16), String> {
|
||||||
|
let (host, port_str) = if address.starts_with("[") {
|
||||||
|
let colon_index = address.find(':');
|
||||||
|
let close_bracket_index = address.rfind(']');
|
||||||
|
if colon_index.is_none() || close_bracket_index.is_none() {
|
||||||
|
return Err(format!("Invalid bracketed host/port: {address}"));
|
||||||
|
}
|
||||||
|
let close_bracket_index = close_bracket_index.unwrap();
|
||||||
|
|
||||||
|
let host = &address[1..close_bracket_index];
|
||||||
|
if close_bracket_index + 1 == address.len() {
|
||||||
|
(host, "")
|
||||||
|
} else {
|
||||||
|
if address.as_bytes().get(close_bracket_index).copied()
|
||||||
|
!= Some(b':')
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Only a colon may follow a close bracket: {address}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let port_str = &address[close_bracket_index + 2..];
|
||||||
|
for c in port_str.chars() {
|
||||||
|
if !c.is_ascii_digit() {
|
||||||
|
return Err(format!("Port must be numeric: {address}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(host, port_str)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let colon_pos = address.find(':');
|
||||||
|
if let Some(colon_pos) = colon_pos {
|
||||||
|
(&address[..colon_pos], &address[colon_pos + 1..])
|
||||||
|
} else {
|
||||||
|
(address, "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut port = None;
|
||||||
|
if !port_str.is_empty() {
|
||||||
|
if port_str.starts_with('+') {
|
||||||
|
return Err(format!("Unparseable port number: {port_str}"));
|
||||||
|
}
|
||||||
|
port = port_str.parse::<u16>().ok();
|
||||||
|
if port.is_none() {
|
||||||
|
return Err(format!("Unparseable port number: {port_str}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((host, port.unwrap_or(25565)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_server_address(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
) -> Result<(String, u16)> {
|
||||||
|
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
|
||||||
|
|
||||||
|
if port != 25565
|
||||||
|
|| host.parse::<Ipv4Addr>().is_ok()
|
||||||
|
|| host.parse::<Ipv6Addr>().is_ok()
|
||||||
|
{
|
||||||
|
return Ok((host.to_owned(), port));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
|
||||||
|
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
|
||||||
|
Ok(
|
||||||
|
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
|
||||||
|
Err(e)
|
||||||
|
if e.proto()
|
||||||
|
.filter(|x| x.kind().is_no_records_found())
|
||||||
|
.is_some() =>
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
Ok(lookup) => lookup
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|r| (r.target().to_string(), r.port())),
|
||||||
|
}
|
||||||
|
.unwrap_or_else(|| (host.to_owned(), port)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::data::ModLoader;
|
use crate::data::ModLoader;
|
||||||
use crate::launcher::get_loader_version_from_profile;
|
use crate::launcher::get_loader_version_from_profile;
|
||||||
use crate::profile::get_full_path;
|
use crate::profile::get_full_path;
|
||||||
|
use crate::server_address::{parse_server_address, resolve_server_address};
|
||||||
use crate::state::attached_world_data::AttachedWorldData;
|
use crate::state::attached_world_data::AttachedWorldData;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
Profile, ProfileInstallStage, attached_world_data, server_join_log,
|
Profile, ProfileInstallStage, attached_world_data, server_join_log,
|
||||||
@@ -11,7 +12,7 @@ pub use crate::util::server_ping::{
|
|||||||
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
|
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
|
||||||
};
|
};
|
||||||
use crate::util::{io, server_ping};
|
use crate::util::{io, server_ping};
|
||||||
use crate::{Error, ErrorKind, Result, State, launcher};
|
use crate::{ErrorKind, Result, State, launcher};
|
||||||
use async_walkdir::WalkDir;
|
use async_walkdir::WalkDir;
|
||||||
use async_zip::{Compression, ZipEntryBuilder};
|
use async_zip::{Compression, ZipEntryBuilder};
|
||||||
use chrono::{DateTime, Local, TimeZone, Utc};
|
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||||
@@ -24,11 +25,9 @@ use regex::{Regex, RegexBuilder};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::sync::Semaphore;
|
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -433,9 +432,9 @@ async fn get_server_worlds_in_profile(
|
|||||||
let mut futures = JoinSet::new();
|
let mut futures = JoinSet::new();
|
||||||
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
|
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
|
||||||
{
|
{
|
||||||
if world.last_played.is_some() {
|
// We can't check for the profile already having a last_played, in case the user joined
|
||||||
continue;
|
// the target address directly more recently. This is often the case when using
|
||||||
}
|
// quick-play before 1.20.
|
||||||
if let WorldDetails::Server { address, .. } = &world.details
|
if let WorldDetails::Server { address, .. } = &world.details
|
||||||
&& let Ok((host, port)) = parse_server_address(address)
|
&& let Ok((host, port)) = parse_server_address(address)
|
||||||
{
|
{
|
||||||
@@ -917,93 +916,3 @@ pub async fn get_server_status(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
|
|
||||||
parse_server_address_inner(address)
|
|
||||||
.map_err(|e| Error::from(ErrorKind::InputError(e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
|
|
||||||
fn parse_server_address_inner(
|
|
||||||
address: &str,
|
|
||||||
) -> std::result::Result<(&str, u16), String> {
|
|
||||||
let (host, port_str) = if address.starts_with("[") {
|
|
||||||
let colon_index = address.find(':');
|
|
||||||
let close_bracket_index = address.rfind(']');
|
|
||||||
if colon_index.is_none() || close_bracket_index.is_none() {
|
|
||||||
return Err(format!("Invalid bracketed host/port: {address}"));
|
|
||||||
}
|
|
||||||
let close_bracket_index = close_bracket_index.unwrap();
|
|
||||||
|
|
||||||
let host = &address[1..close_bracket_index];
|
|
||||||
if close_bracket_index + 1 == address.len() {
|
|
||||||
(host, "")
|
|
||||||
} else {
|
|
||||||
if address.as_bytes().get(close_bracket_index).copied()
|
|
||||||
!= Some(b':')
|
|
||||||
{
|
|
||||||
return Err(format!(
|
|
||||||
"Only a colon may follow a close bracket: {address}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let port_str = &address[close_bracket_index + 2..];
|
|
||||||
for c in port_str.chars() {
|
|
||||||
if !c.is_ascii_digit() {
|
|
||||||
return Err(format!("Port must be numeric: {address}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(host, port_str)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let colon_pos = address.find(':');
|
|
||||||
if let Some(colon_pos) = colon_pos {
|
|
||||||
(&address[..colon_pos], &address[colon_pos + 1..])
|
|
||||||
} else {
|
|
||||||
(address, "")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut port = None;
|
|
||||||
if !port_str.is_empty() {
|
|
||||||
if port_str.starts_with('+') {
|
|
||||||
return Err(format!("Unparseable port number: {port_str}"));
|
|
||||||
}
|
|
||||||
port = port_str.parse::<u16>().ok();
|
|
||||||
if port.is_none() {
|
|
||||||
return Err(format!("Unparseable port number: {port_str}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((host, port.unwrap_or(25565)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_server_address(
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
) -> Result<(String, u16)> {
|
|
||||||
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
|
|
||||||
|
|
||||||
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
|
|
||||||
return Ok((host.to_owned(), port));
|
|
||||||
}
|
|
||||||
|
|
||||||
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
|
|
||||||
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
|
|
||||||
Ok(
|
|
||||||
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
|
|
||||||
Err(e)
|
|
||||||
if e.proto()
|
|
||||||
.filter(|x| x.kind().is_no_records_found())
|
|
||||||
.is_some() =>
|
|
||||||
{
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
Ok(lookup) => lookup
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|r| (r.target().to_string(), r.port())),
|
|
||||||
}
|
|
||||||
.unwrap_or_else(|| (host.to_owned(), port)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Minecraft CLI argument logic
|
//! Minecraft CLI argument logic
|
||||||
use crate::launcher::parse_rules;
|
use crate::launcher::quick_play_version::QuickPlayServerVersion;
|
||||||
|
use crate::launcher::{QuickPlayVersion, parse_rules};
|
||||||
use crate::profile::QuickPlayType;
|
use crate::profile::QuickPlayType;
|
||||||
use crate::state::Credentials;
|
use crate::state::Credentials;
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -115,11 +116,13 @@ pub fn get_jvm_arguments(
|
|||||||
libraries_path: &Path,
|
libraries_path: &Path,
|
||||||
log_configs_path: &Path,
|
log_configs_path: &Path,
|
||||||
class_paths: &str,
|
class_paths: &str,
|
||||||
|
agent_path: &Path,
|
||||||
version_name: &str,
|
version_name: &str,
|
||||||
memory: MemorySettings,
|
memory: MemorySettings,
|
||||||
custom_args: Vec<String>,
|
custom_args: Vec<String>,
|
||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: &QuickPlayType,
|
||||||
|
quick_play_version: QuickPlayVersion,
|
||||||
log_config: Option<&LoggingConfiguration>,
|
log_config: Option<&LoggingConfiguration>,
|
||||||
) -> crate::Result<Vec<String>> {
|
) -> crate::Result<Vec<String>> {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
@@ -155,13 +158,45 @@ pub fn get_jvm_arguments(
|
|||||||
parsed_arguments.push("-cp".to_string());
|
parsed_arguments.push("-cp".to_string());
|
||||||
parsed_arguments.push(class_paths.to_string());
|
parsed_arguments.push(class_paths.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
|
parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
|
||||||
|
|
||||||
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
|
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
|
||||||
{
|
{
|
||||||
let full_path = log_configs_path.join(&file.id);
|
let full_path = log_configs_path.join(&file.id);
|
||||||
let full_path = full_path.to_string_lossy();
|
let full_path = full_path.to_string_lossy();
|
||||||
parsed_arguments.push(argument.replace("${path}", &full_path));
|
parsed_arguments.push(argument.replace("${path}", &full_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parsed_arguments.push(format!(
|
||||||
|
"-javaagent:{}",
|
||||||
|
canonicalize(agent_path)
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::ErrorKind::LauncherError(format!(
|
||||||
|
"Specified Java Agent path {} does not exist",
|
||||||
|
libraries_path.to_string_lossy()
|
||||||
|
))
|
||||||
|
.as_error()
|
||||||
|
})?
|
||||||
|
.to_string_lossy()
|
||||||
|
));
|
||||||
|
|
||||||
|
parsed_arguments.push(format!(
|
||||||
|
"-Dmodrinth.internal.quickPlay.serverVersion={}",
|
||||||
|
serde_json::to_value(quick_play_version.server)?
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
));
|
||||||
|
if let QuickPlayType::Server(server) = quick_play_type
|
||||||
|
&& quick_play_version.server == QuickPlayServerVersion::Injected
|
||||||
|
{
|
||||||
|
let (host, port) = server.require_resolved()?;
|
||||||
|
parsed_arguments.extend_from_slice(&[
|
||||||
|
format!("-Dmodrinth.internal.quickPlay.host={host}"),
|
||||||
|
format!("-Dmodrinth.internal.quickPlay.port={port}"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
for arg in custom_args {
|
for arg in custom_args {
|
||||||
if !arg.is_empty() {
|
if !arg.is_empty() {
|
||||||
parsed_arguments.push(arg);
|
parsed_arguments.push(arg);
|
||||||
@@ -225,13 +260,13 @@ pub async fn get_minecraft_arguments(
|
|||||||
resolution: WindowSize,
|
resolution: WindowSize,
|
||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: &QuickPlayType,
|
||||||
|
quick_play_version: QuickPlayVersion,
|
||||||
) -> crate::Result<Vec<String>> {
|
) -> crate::Result<Vec<String>> {
|
||||||
let access_token = credentials.access_token.clone();
|
let access_token = credentials.access_token.clone();
|
||||||
let profile = credentials.maybe_online_profile().await;
|
let profile = credentials.maybe_online_profile().await;
|
||||||
|
|
||||||
if let Some(arguments) = arguments {
|
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
|
|
||||||
|
if let Some(arguments) = arguments {
|
||||||
parse_arguments(
|
parse_arguments(
|
||||||
arguments,
|
arguments,
|
||||||
&mut parsed_arguments,
|
&mut parsed_arguments,
|
||||||
@@ -253,10 +288,7 @@ pub async fn get_minecraft_arguments(
|
|||||||
java_arch,
|
java_arch,
|
||||||
quick_play_type,
|
quick_play_type,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(parsed_arguments)
|
|
||||||
} else if let Some(legacy_arguments) = legacy_arguments {
|
} else if let Some(legacy_arguments) = legacy_arguments {
|
||||||
let mut parsed_arguments = Vec::new();
|
|
||||||
for x in legacy_arguments.split(' ') {
|
for x in legacy_arguments.split(' ') {
|
||||||
parsed_arguments.push(parse_minecraft_argument(
|
parsed_arguments.push(parse_minecraft_argument(
|
||||||
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
||||||
@@ -272,10 +304,21 @@ pub async fn get_minecraft_arguments(
|
|||||||
quick_play_type,
|
quick_play_type,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
Ok(parsed_arguments)
|
|
||||||
} else {
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let QuickPlayType::Server(server) = quick_play_type
|
||||||
|
&& quick_play_version.server == QuickPlayServerVersion::BuiltinLegacy
|
||||||
|
{
|
||||||
|
let (host, port) = server.require_resolved()?;
|
||||||
|
parsed_arguments.extend_from_slice(&[
|
||||||
|
"--server".to_string(),
|
||||||
|
host.to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
port.to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parsed_arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -354,9 +397,9 @@ fn parse_minecraft_argument(
|
|||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"${quickPlayMultiplayer}",
|
"${quickPlayMultiplayer}",
|
||||||
match quick_play_type {
|
&match quick_play_type {
|
||||||
QuickPlayType::Server(address) => address,
|
QuickPlayType::Server(address) => address.to_string(),
|
||||||
_ => "",
|
_ => "".to_string(),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
|
|||||||
use crate::event::{LoadingBarId, LoadingBarType};
|
use crate::event::{LoadingBarId, LoadingBarType};
|
||||||
use crate::launcher::download::download_log_config;
|
use crate::launcher::download::download_log_config;
|
||||||
use crate::launcher::io::IOError;
|
use crate::launcher::io::IOError;
|
||||||
|
use crate::launcher::quick_play_version::{
|
||||||
|
QuickPlayServerVersion, QuickPlayVersion,
|
||||||
|
};
|
||||||
use crate::profile::QuickPlayType;
|
use crate::profile::QuickPlayType;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||||
@@ -25,6 +28,7 @@ use tokio::process::Command;
|
|||||||
mod args;
|
mod args;
|
||||||
|
|
||||||
pub mod download;
|
pub mod download;
|
||||||
|
pub mod quick_play_version;
|
||||||
|
|
||||||
// All nones -> disallowed
|
// All nones -> disallowed
|
||||||
// 1+ true -> allowed
|
// 1+ true -> allowed
|
||||||
@@ -457,7 +461,7 @@ pub async fn launch_minecraft(
|
|||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
post_exit_hook: Option<String>,
|
post_exit_hook: Option<String>,
|
||||||
profile: &Profile,
|
profile: &Profile,
|
||||||
quick_play_type: &QuickPlayType,
|
mut quick_play_type: QuickPlayType,
|
||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
if profile.install_stage == ProfileInstallStage::PackInstalling
|
if profile.install_stage == ProfileInstallStage::PackInstalling
|
||||||
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
||||||
@@ -589,6 +593,18 @@ pub async fn launch_minecraft(
|
|||||||
io::create_dir_all(&natives_dir).await?;
|
io::create_dir_all(&natives_dir).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let quick_play_version =
|
||||||
|
QuickPlayVersion::find_version(version_index, &minecraft.versions);
|
||||||
|
tracing::debug!(
|
||||||
|
"Found QuickPlayVersion for {}: {quick_play_version:?}",
|
||||||
|
profile.game_version
|
||||||
|
);
|
||||||
|
if let QuickPlayType::Server(address) = &mut quick_play_type
|
||||||
|
&& quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy
|
||||||
|
{
|
||||||
|
address.resolve().await?;
|
||||||
|
}
|
||||||
|
|
||||||
let (main_class_keep_alive, main_class_path) =
|
let (main_class_keep_alive, main_class_path) =
|
||||||
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
||||||
|
|
||||||
@@ -606,11 +622,13 @@ pub async fn launch_minecraft(
|
|||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
minecraft_updated,
|
minecraft_updated,
|
||||||
)?,
|
)?,
|
||||||
|
&main_class_path,
|
||||||
&version_jar,
|
&version_jar,
|
||||||
*memory,
|
*memory,
|
||||||
Vec::from(java_args),
|
Vec::from(java_args),
|
||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
quick_play_type,
|
&quick_play_type,
|
||||||
|
quick_play_version,
|
||||||
version_info
|
version_info
|
||||||
.logging
|
.logging
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -646,7 +664,8 @@ pub async fn launch_minecraft(
|
|||||||
&version.type_,
|
&version.type_,
|
||||||
*resolution,
|
*resolution,
|
||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
quick_play_type,
|
&quick_play_type,
|
||||||
|
quick_play_version,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_iter(),
|
.into_iter(),
|
||||||
|
|||||||
102
packages/app-lib/src/launcher/quick_play_version.rs
Normal file
102
packages/app-lib/src/launcher/quick_play_version.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use daedalus::minecraft::Version;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// If modified, also update QuickPlayServerVersion.java
|
||||||
|
#[derive(
|
||||||
|
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum QuickPlayServerVersion {
|
||||||
|
Builtin,
|
||||||
|
BuiltinLegacy,
|
||||||
|
Injected,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuickPlayServerVersion {
|
||||||
|
pub fn min_version(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Builtin => Some("23w14a"),
|
||||||
|
Self::BuiltinLegacy => Some("13w17a"),
|
||||||
|
Self::Injected => Some("a1.0.5_01"),
|
||||||
|
Self::Unsupported => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn older_version(&self) -> Option<Self> {
|
||||||
|
match self {
|
||||||
|
Self::Builtin => Some(Self::BuiltinLegacy),
|
||||||
|
Self::BuiltinLegacy => Some(Self::Injected),
|
||||||
|
Self::Injected => Some(Self::Unsupported),
|
||||||
|
Self::Unsupported => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If modified, also update QuickPlaySingleplayerVersion.java
|
||||||
|
#[derive(
|
||||||
|
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum QuickPlaySingleplayerVersion {
|
||||||
|
Builtin,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuickPlaySingleplayerVersion {
|
||||||
|
pub fn min_version(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Builtin => Some("23w14a"),
|
||||||
|
Self::Unsupported => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn older_version(&self) -> Option<Self> {
|
||||||
|
match self {
|
||||||
|
Self::Builtin => Some(Self::Unsupported),
|
||||||
|
Self::Unsupported => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub struct QuickPlayVersion {
|
||||||
|
pub server: QuickPlayServerVersion,
|
||||||
|
pub singleplayer: QuickPlaySingleplayerVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuickPlayVersion {
|
||||||
|
pub fn find_version(version_index: usize, versions: &[Version]) -> Self {
|
||||||
|
let mut server = QuickPlayServerVersion::Builtin;
|
||||||
|
let mut server_version = server.min_version();
|
||||||
|
|
||||||
|
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
|
||||||
|
let mut singleplayer_version = singleplayer.min_version();
|
||||||
|
|
||||||
|
for version in versions.iter().take(version_index - 1) {
|
||||||
|
if let Some(check_version) = server_version
|
||||||
|
&& version.id == check_version
|
||||||
|
{
|
||||||
|
// Safety: older_version will always be Some when min_version is Some
|
||||||
|
server = server.older_version().unwrap();
|
||||||
|
server_version = server.min_version();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(check_version) = singleplayer_version
|
||||||
|
&& version.id == check_version
|
||||||
|
{
|
||||||
|
singleplayer = singleplayer.older_version().unwrap();
|
||||||
|
singleplayer_version = singleplayer.min_version();
|
||||||
|
}
|
||||||
|
|
||||||
|
if server_version.is_none() && singleplayer_version.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
server,
|
||||||
|
singleplayer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use crate::event::emit::{emit_process, emit_profile};
|
|||||||
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
||||||
use crate::profile;
|
use crate::profile;
|
||||||
use crate::util::io::IOError;
|
use crate::util::io::IOError;
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
@@ -493,6 +493,16 @@ impl Process {
|
|||||||
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
|
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
|
||||||
tracing::warn!("Failed to write to log file: {}", e);
|
tracing::warn!("Failed to write to log file: {}", e);
|
||||||
}
|
}
|
||||||
|
if let Err(e) = Self::maybe_handle_old_server_join_logging(
|
||||||
|
profile_path,
|
||||||
|
line.trim_ascii_end(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to handle old server join logging: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
line.clear();
|
line.clear();
|
||||||
@@ -540,17 +550,6 @@ impl Process {
|
|||||||
timestamp: &str,
|
timestamp: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let Some(host_port_string) = message.strip_prefix("Connecting to ")
|
|
||||||
else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
|
|
||||||
else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let Some(port) = port_string.parse::<u16>().ok() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let timestamp = timestamp
|
let timestamp = timestamp
|
||||||
.parse::<i64>()
|
.parse::<i64>()
|
||||||
.map(|x| x / 1000)
|
.map(|x| x / 1000)
|
||||||
@@ -566,6 +565,46 @@ impl Process {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
Self::parse_and_insert_server_join(profile_path, message, timestamp)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maybe_handle_old_server_join_logging(
|
||||||
|
profile_path: &str,
|
||||||
|
line: &str,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
if let Some((timestamp, message)) = line.split_once(" [CLIENT] [INFO] ")
|
||||||
|
{
|
||||||
|
let timestamp =
|
||||||
|
NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")?
|
||||||
|
.and_local_timezone(chrono::Local)
|
||||||
|
.map(|x| x.to_utc())
|
||||||
|
.single()
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
Self::parse_and_insert_server_join(profile_path, message, timestamp)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
Self::parse_and_insert_server_join(profile_path, line, Utc::now())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_and_insert_server_join(
|
||||||
|
profile_path: &str,
|
||||||
|
message: &str,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let Some(host_port_string) = message.strip_prefix("Connecting to ")
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(port) = port_string.parse::<u16>().ok() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let state = crate::State::get().await?;
|
let state = crate::State::get().await?;
|
||||||
crate::state::server_join_log::JoinLogEntry {
|
crate::state::server_join_log::JoinLogEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user