You've already forked AstralRinth
Merge tag 'v0.10.21' into beta
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -5099,10 +5099,13 @@ dependencies = [
|
|||||||
"derive_more 2.0.1",
|
"derive_more 2.0.1",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"eyre",
|
"eyre",
|
||||||
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-ecs",
|
"tracing-ecs",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -301,11 +301,13 @@ import {
|
|||||||
get_organization_many,
|
get_organization_many,
|
||||||
get_project_many,
|
get_project_many,
|
||||||
get_team_many,
|
get_team_many,
|
||||||
|
get_version,
|
||||||
get_version_many,
|
get_version_many,
|
||||||
} from '@/helpers/cache.js'
|
} from '@/helpers/cache.js'
|
||||||
import { profile_listener } from '@/helpers/events.js'
|
import { profile_listener } from '@/helpers/events.js'
|
||||||
import {
|
import {
|
||||||
add_project_from_path,
|
add_project_from_path,
|
||||||
|
get,
|
||||||
get_projects,
|
get_projects,
|
||||||
remove_project,
|
remove_project,
|
||||||
toggle_disable_project,
|
toggle_disable_project,
|
||||||
@@ -314,6 +316,7 @@ import {
|
|||||||
} from '@/helpers/profile.js'
|
} from '@/helpers/profile.js'
|
||||||
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
|
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
|
||||||
import { highlightModInProfile } from '@/helpers/utils.js'
|
import { highlightModInProfile } from '@/helpers/utils.js'
|
||||||
|
import { installVersionDependencies } from '@/store/install'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
@@ -627,10 +630,15 @@ const sortProjects = (filter: string) => {
|
|||||||
|
|
||||||
const updateAll = async () => {
|
const updateAll = async () => {
|
||||||
const setProjects = []
|
const setProjects = []
|
||||||
|
const outdatedProjects = []
|
||||||
|
|
||||||
for (const [i, project] of projects.value.entries()) {
|
for (const [i, project] of projects.value.entries()) {
|
||||||
if (project.outdated) {
|
if (project.outdated) {
|
||||||
project.updating = true
|
project.updating = true
|
||||||
setProjects.push(i)
|
setProjects.push(i)
|
||||||
|
if (project.updateVersion) {
|
||||||
|
outdatedProjects.push(project.updateVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +654,21 @@ const updateAll = async () => {
|
|||||||
projects.value[index].updateVersion = undefined
|
projects.value[index].updateVersion = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outdatedProjects.length > 0) {
|
||||||
|
const profile = await get(props.instance.path).catch(handleError)
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
for (const versionId of outdatedProjects) {
|
||||||
|
const versionData = await get_version(versionId, 'must_revalidate').catch(handleError)
|
||||||
|
|
||||||
|
if (versionData) {
|
||||||
|
await installVersionDependencies(profile, versionData).catch(handleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const project of setProjects) {
|
for (const project of setProjects) {
|
||||||
projects.value[project].updating = false
|
projects.value[project].updating = false
|
||||||
}
|
}
|
||||||
@@ -662,6 +685,19 @@ const updateProject = async (mod: ProjectListEntry) => {
|
|||||||
mod.updating = true
|
mod.updating = true
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
|
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
|
||||||
|
|
||||||
|
if (mod.updateVersion) {
|
||||||
|
const versionData = await get_version(mod.updateVersion, 'must_revalidate').catch(handleError)
|
||||||
|
|
||||||
|
if (versionData) {
|
||||||
|
const profile = await get(props.instance.path).catch(handleError)
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
await installVersionDependencies(profile, versionData).catch(handleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod.updating = false
|
mod.updating = false
|
||||||
|
|
||||||
mod.outdated = false
|
mod.outdated = false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
all(not(debug_assertions), target_os = "windows"),
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
use native_dialog::{DialogBuilder, MessageLevel};
|
use native_dialog::{DialogBuilder, MessageLevel};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
<ModpackPermissionsFlow
|
<ModpackPermissionsFlow
|
||||||
v-model="modpackJudgements"
|
v-model="modpackJudgements"
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
|
:project-updated="project.updated"
|
||||||
@complete="handleModpackPermissionsComplete"
|
@complete="handleModpackPermissionsComplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,6 +447,11 @@ function resetProgress() {
|
|||||||
|
|
||||||
localStorage.removeItem(`modpack-permissions-${props.project.id}`)
|
localStorage.removeItem(`modpack-permissions-${props.project.id}`)
|
||||||
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`)
|
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`)
|
||||||
|
|
||||||
|
sessionStorage.removeItem(`modpack-permissions-data-${props.project.id}`)
|
||||||
|
sessionStorage.removeItem(`modpack-permissions-permanent-no-${props.project.id}`)
|
||||||
|
sessionStorage.removeItem(`modpack-permissions-updated-${props.project.id}`)
|
||||||
|
|
||||||
modpackPermissionsComplete.value = false
|
modpackPermissionsComplete.value = false
|
||||||
modpackJudgements.value = {}
|
modpackJudgements.value = {}
|
||||||
|
|
||||||
@@ -1331,6 +1337,11 @@ function clearProjectLocalStorage() {
|
|||||||
localStorage.removeItem(`moderation-actions-${props.project.slug}`)
|
localStorage.removeItem(`moderation-actions-${props.project.slug}`)
|
||||||
localStorage.removeItem(`moderation-inputs-${props.project.slug}`)
|
localStorage.removeItem(`moderation-inputs-${props.project.slug}`)
|
||||||
localStorage.removeItem(`moderation-stage-${props.project.slug}`)
|
localStorage.removeItem(`moderation-stage-${props.project.slug}`)
|
||||||
|
|
||||||
|
sessionStorage.removeItem(`modpack-permissions-data-${props.project.id}`)
|
||||||
|
sessionStorage.removeItem(`modpack-permissions-permanent-no-${props.project.id}`)
|
||||||
|
sessionStorage.removeItem(`modpack-permissions-updated-${props.project.id}`)
|
||||||
|
|
||||||
actionStates.value = {}
|
actionStates.value = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: string
|
projectId: string
|
||||||
|
projectUpdated: string
|
||||||
modelValue?: ModerationJudgements
|
modelValue?: ModerationJudgements
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -202,6 +203,10 @@ const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
const cachedProjectUpdated = useSessionStorage<string | null>(
|
||||||
|
`modpack-permissions-updated-${props.projectId}`,
|
||||||
|
null,
|
||||||
|
)
|
||||||
const currentIndex = ref(0)
|
const currentIndex = ref(0)
|
||||||
|
|
||||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||||
@@ -363,11 +368,13 @@ async function fetchModPackData(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modPackData.value = sortedData
|
modPackData.value = sortedData
|
||||||
|
cachedProjectUpdated.value = props.projectUpdated
|
||||||
persistAll()
|
persistAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch modpack data:', error)
|
console.error('Failed to fetch modpack data:', error)
|
||||||
modPackData.value = []
|
modPackData.value = []
|
||||||
permanentNoFiles.value = []
|
permanentNoFiles.value = []
|
||||||
|
cachedProjectUpdated.value = props.projectUpdated
|
||||||
persistAll()
|
persistAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,7 +464,10 @@ function getJudgements(): ModerationJudgements {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPersistedData()
|
loadPersistedData()
|
||||||
if (!modPackData.value) {
|
|
||||||
|
const isStale = cachedProjectUpdated.value !== props.projectUpdated
|
||||||
|
|
||||||
|
if (!modPackData.value || isStale) {
|
||||||
fetchModPackData()
|
fetchModPackData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -477,6 +487,7 @@ watch(
|
|||||||
() => props.projectId,
|
() => props.projectId,
|
||||||
() => {
|
() => {
|
||||||
clearPersistedData()
|
clearPersistedData()
|
||||||
|
cachedProjectUpdated.value = null
|
||||||
loadPersistedData()
|
loadPersistedData()
|
||||||
if (!modPackData.value) {
|
if (!modPackData.value) {
|
||||||
fetchModPackData()
|
fetchModPackData()
|
||||||
|
|||||||
@@ -84,16 +84,19 @@
|
|||||||
@change="uploadFile"
|
@change="uploadFile"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 hidden size-[6rem] flex-col items-center justify-center rounded-xl bg-button-bg p-2 opacity-80 group-hover:flex"
|
class="absolute top-0 hidden size-24 flex-col items-center justify-center rounded-xl bg-button-bg p-2 opacity-80 group-hover:flex"
|
||||||
>
|
>
|
||||||
<EditIcon class="h-8 w-8 text-contrast" />
|
<EditIcon class="h-8 w-8 text-contrast" />
|
||||||
</div>
|
</div>
|
||||||
<ServerIcon :image="icon" />
|
<ServerIcon class="size-24" :image="icon" />
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
|
<button
|
||||||
<TransferIcon class="h-6 w-6" />
|
v-tooltip="'Synchronize icon with installed modpack'"
|
||||||
<span>Sync icon</span>
|
class="my-auto"
|
||||||
|
@click="resetIcon"
|
||||||
|
>
|
||||||
|
<TransferIcon /> Sync icon
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,8 +115,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditIcon, ServerIcon, TransferIcon } from '@modrinth/assets'
|
import { EditIcon, TransferIcon } from '@modrinth/assets'
|
||||||
import { injectNotificationManager } from '@modrinth/ui'
|
import { injectNotificationManager, ServerIcon } from '@modrinth/ui'
|
||||||
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||||
|
|||||||
@@ -304,7 +304,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="route.params.projectType !== 'collections'" class="error">
|
<div
|
||||||
|
v-else-if="
|
||||||
|
(route.params.projectType && route.params.projectType !== 'collections') ||
|
||||||
|
(!route.params.projectType && collections.length === 0)
|
||||||
|
"
|
||||||
|
class="error"
|
||||||
|
>
|
||||||
<UpToDate class="icon" />
|
<UpToDate class="icon" />
|
||||||
<br />
|
<br />
|
||||||
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ json-patch = { workspace = true }
|
|||||||
lettre = { workspace = true }
|
lettre = { workspace = true }
|
||||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||||
modrinth-maxmind = { workspace = true }
|
modrinth-maxmind = { workspace = true }
|
||||||
modrinth-util = { workspace = true }
|
modrinth-util = { workspace = true, features = ["decimal", "utoipa"] }
|
||||||
muralpay = { workspace = true, features = ["utoipa", "mock"] }
|
muralpay = { workspace = true, features = ["mock", "utoipa"] }
|
||||||
murmur2 = { workspace = true }
|
murmur2 = { workspace = true }
|
||||||
paste = { workspace = true }
|
paste = { workspace = true }
|
||||||
path-util = { workspace = true }
|
path-util = { workspace = true }
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ pub async fn payouts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_payout_statuses(pool: sqlx::Pool<Postgres>, mural: MuralPay) {
|
pub async fn sync_payout_statuses(pool: sqlx::Pool<Postgres>, mural: MuralPay) {
|
||||||
const LIMIT: u32 = 1000;
|
// Mural sets a max limit of 100 for search payouts endpoint
|
||||||
|
const LIMIT: u32 = 100;
|
||||||
|
|
||||||
info!("Started syncing payout statuses");
|
info!("Started syncing payout statuses");
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use queue::{
|
|||||||
session::AuthQueue, socket::ActiveSockets,
|
session::AuthQueue, socket::ActiveSockets,
|
||||||
};
|
};
|
||||||
use sqlx::Postgres;
|
use sqlx::Postgres;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
extern crate clickhouse as clickhouse_crate;
|
extern crate clickhouse as clickhouse_crate;
|
||||||
use clickhouse_crate::Client;
|
use clickhouse_crate::Client;
|
||||||
@@ -240,14 +240,14 @@ pub fn app_setup(
|
|||||||
let redis_ref = redis_ref.clone();
|
let redis_ref = redis_ref.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
info!("Indexing analytics queue");
|
debug!("Indexing analytics queue");
|
||||||
let result = analytics_queue_ref
|
let result = analytics_queue_ref
|
||||||
.index(client_ref, &redis_ref, &pool_ref)
|
.index(client_ref, &redis_ref, &pool_ref)
|
||||||
.await;
|
.await;
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
warn!("Indexing analytics queue failed: {:?}", e);
|
warn!("Indexing analytics queue failed: {:?}", e);
|
||||||
}
|
}
|
||||||
info!("Done indexing analytics queue");
|
debug!("Done indexing analytics queue");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,9 +252,9 @@ pub struct PayoutMethodFee {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PayoutMethodFee {
|
impl PayoutMethodFee {
|
||||||
pub fn compute_fee(&self, value: Decimal) -> Decimal {
|
pub fn compute_fee(&self, value: impl Into<Decimal>) -> Decimal {
|
||||||
cmp::min(
|
cmp::min(
|
||||||
cmp::max(self.min, self.percentage * value),
|
cmp::max(self.min, self.percentage * value.into()),
|
||||||
self.max.unwrap_or(Decimal::MAX),
|
self.max.unwrap_or(Decimal::MAX),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc};
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use eyre::{Result, eyre};
|
use eyre::{Result, eyre};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
|
use modrinth_util::decimal::Decimal2dp;
|
||||||
use muralpay::MuralPay;
|
use muralpay::MuralPay;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rust_decimal::prelude::ToPrimitive;
|
use rust_decimal::prelude::ToPrimitive;
|
||||||
use rust_decimal::{Decimal, dec};
|
use rust_decimal::{Decimal, RoundingStrategy, dec};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -618,7 +619,7 @@ impl PayoutsQueue {
|
|||||||
&self,
|
&self,
|
||||||
request: &PayoutMethodRequest,
|
request: &PayoutMethodRequest,
|
||||||
method_id: &str,
|
method_id: &str,
|
||||||
amount: Decimal,
|
amount: Decimal2dp,
|
||||||
) -> Result<PayoutFees, ApiError> {
|
) -> Result<PayoutFees, ApiError> {
|
||||||
const MURAL_FEE: Decimal = dec!(0.01);
|
const MURAL_FEE: Decimal = dec!(0.01);
|
||||||
|
|
||||||
@@ -641,8 +642,9 @@ impl PayoutsQueue {
|
|||||||
..
|
..
|
||||||
},
|
},
|
||||||
} => PayoutFees {
|
} => PayoutFees {
|
||||||
method_fee: dec!(0),
|
method_fee: Decimal2dp::ZERO,
|
||||||
platform_fee: amount * MURAL_FEE,
|
platform_fee: amount
|
||||||
|
.mul_round(MURAL_FEE, RoundingStrategy::AwayFromZero),
|
||||||
exchange_rate: None,
|
exchange_rate: None,
|
||||||
},
|
},
|
||||||
PayoutMethodRequest::MuralPay {
|
PayoutMethodRequest::MuralPay {
|
||||||
@@ -667,8 +669,14 @@ impl PayoutsQueue {
|
|||||||
fee_total,
|
fee_total,
|
||||||
..
|
..
|
||||||
} => PayoutFees {
|
} => PayoutFees {
|
||||||
method_fee: fee_total.token_amount,
|
method_fee: Decimal2dp::rounded(
|
||||||
platform_fee: amount * MURAL_FEE,
|
fee_total.token_amount,
|
||||||
|
RoundingStrategy::AwayFromZero,
|
||||||
|
),
|
||||||
|
platform_fee: amount.mul_round(
|
||||||
|
MURAL_FEE,
|
||||||
|
RoundingStrategy::AwayFromZero,
|
||||||
|
),
|
||||||
exchange_rate: Some(exchange_rate),
|
exchange_rate: Some(exchange_rate),
|
||||||
},
|
},
|
||||||
muralpay::TokenPayoutFee::Error { message, .. } => {
|
muralpay::TokenPayoutFee::Error { message, .. } => {
|
||||||
@@ -680,16 +688,22 @@ impl PayoutsQueue {
|
|||||||
}
|
}
|
||||||
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
|
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
|
||||||
let method = get_method.await?;
|
let method = get_method.await?;
|
||||||
let fee = method.fee.compute_fee(amount);
|
let fee = Decimal2dp::rounded(
|
||||||
|
method.fee.compute_fee(amount),
|
||||||
|
RoundingStrategy::AwayFromZero,
|
||||||
|
);
|
||||||
PayoutFees {
|
PayoutFees {
|
||||||
method_fee: fee,
|
method_fee: fee,
|
||||||
platform_fee: dec!(0),
|
platform_fee: Decimal2dp::ZERO,
|
||||||
exchange_rate: None,
|
exchange_rate: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PayoutMethodRequest::Tremendous { method_details } => {
|
PayoutMethodRequest::Tremendous { method_details } => {
|
||||||
let method = get_method.await?;
|
let method = get_method.await?;
|
||||||
let fee = method.fee.compute_fee(amount);
|
let fee = Decimal2dp::rounded(
|
||||||
|
method.fee.compute_fee(amount),
|
||||||
|
RoundingStrategy::AwayFromZero,
|
||||||
|
);
|
||||||
|
|
||||||
let forex: TremendousForexResponse = self
|
let forex: TremendousForexResponse = self
|
||||||
.make_tremendous_request(Method::GET, "forex", None::<()>)
|
.make_tremendous_request(Method::GET, "forex", None::<()>)
|
||||||
@@ -718,7 +732,7 @@ impl PayoutsQueue {
|
|||||||
// we send the request to Tremendous. Afterwards, the method
|
// we send the request to Tremendous. Afterwards, the method
|
||||||
// (Tremendous) will take 0% off the top of our $10.
|
// (Tremendous) will take 0% off the top of our $10.
|
||||||
PayoutFees {
|
PayoutFees {
|
||||||
method_fee: dec!(0),
|
method_fee: Decimal2dp::ZERO,
|
||||||
platform_fee: fee,
|
platform_fee: fee,
|
||||||
exchange_rate,
|
exchange_rate,
|
||||||
}
|
}
|
||||||
@@ -736,19 +750,19 @@ pub struct PayoutFees {
|
|||||||
/// For example, if a user withdraws $10.00 and the method takes a
|
/// For example, if a user withdraws $10.00 and the method takes a
|
||||||
/// 10% cut, then we submit a payout request of $10.00 to the method,
|
/// 10% cut, then we submit a payout request of $10.00 to the method,
|
||||||
/// and only $9.00 will be sent to the recipient.
|
/// and only $9.00 will be sent to the recipient.
|
||||||
pub method_fee: Decimal,
|
pub method_fee: Decimal2dp,
|
||||||
/// Fee which we keep and don't pass to the underlying method.
|
/// Fee which we keep and don't pass to the underlying method.
|
||||||
///
|
///
|
||||||
/// For example, if a user withdraws $10.00 and the method takes a
|
/// For example, if a user withdraws $10.00 and the method takes a
|
||||||
/// 10% cut, then we submit a payout request of $9.00, and the $1.00 stays
|
/// 10% cut, then we submit a payout request of $9.00, and the $1.00 stays
|
||||||
/// in our account.
|
/// in our account.
|
||||||
pub platform_fee: Decimal,
|
pub platform_fee: Decimal2dp,
|
||||||
/// How much is 1 USD worth in the target currency?
|
/// How much is 1 USD worth in the target currency?
|
||||||
pub exchange_rate: Option<Decimal>,
|
pub exchange_rate: Option<Decimal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PayoutFees {
|
impl PayoutFees {
|
||||||
pub fn total_fee(&self) -> Decimal {
|
pub fn total_fee(&self) -> Decimal2dp {
|
||||||
self.method_fee + self.platform_fee
|
self.method_fee + self.platform_fee
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ use ariadne::ids::UserId;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use eyre::{Result, eyre};
|
use eyre::{Result, eyre};
|
||||||
use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered};
|
use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered};
|
||||||
|
use modrinth_util::decimal::Decimal2dp;
|
||||||
use muralpay::{MuralError, MuralPay, TokenFeeRequest};
|
use muralpay::{MuralError, MuralPay, TokenFeeRequest};
|
||||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tracing::warn;
|
use tracing::{info, trace, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::models::DBPayoutId,
|
database::models::DBPayoutId,
|
||||||
@@ -35,7 +36,7 @@ pub enum MuralPayoutRequest {
|
|||||||
impl PayoutsQueue {
|
impl PayoutsQueue {
|
||||||
pub async fn compute_muralpay_fees(
|
pub async fn compute_muralpay_fees(
|
||||||
&self,
|
&self,
|
||||||
amount: Decimal,
|
amount: Decimal2dp,
|
||||||
fiat_and_rail_code: muralpay::FiatAndRailCode,
|
fiat_and_rail_code: muralpay::FiatAndRailCode,
|
||||||
) -> Result<muralpay::TokenPayoutFee, ApiError> {
|
) -> Result<muralpay::TokenPayoutFee, ApiError> {
|
||||||
let muralpay = self.muralpay.load();
|
let muralpay = self.muralpay.load();
|
||||||
@@ -48,7 +49,7 @@ impl PayoutsQueue {
|
|||||||
.get_fees_for_token_amount(&[TokenFeeRequest {
|
.get_fees_for_token_amount(&[TokenFeeRequest {
|
||||||
amount: muralpay::TokenAmount {
|
amount: muralpay::TokenAmount {
|
||||||
token_symbol: muralpay::USDC.into(),
|
token_symbol: muralpay::USDC.into(),
|
||||||
token_amount: amount,
|
token_amount: amount.get(),
|
||||||
},
|
},
|
||||||
fiat_and_rail_code,
|
fiat_and_rail_code,
|
||||||
}])
|
}])
|
||||||
@@ -65,7 +66,7 @@ impl PayoutsQueue {
|
|||||||
&self,
|
&self,
|
||||||
payout_id: DBPayoutId,
|
payout_id: DBPayoutId,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
gross_amount: Decimal,
|
gross_amount: Decimal2dp,
|
||||||
fees: PayoutFees,
|
fees: PayoutFees,
|
||||||
payout_details: MuralPayoutRequest,
|
payout_details: MuralPayoutRequest,
|
||||||
recipient_info: muralpay::PayoutRecipientInfo,
|
recipient_info: muralpay::PayoutRecipientInfo,
|
||||||
@@ -107,9 +108,9 @@ impl PayoutsQueue {
|
|||||||
|
|
||||||
let recipient_address = recipient_info.physical_address();
|
let recipient_address = recipient_info.physical_address();
|
||||||
let recipient_email = recipient_info.email().to_string();
|
let recipient_email = recipient_info.email().to_string();
|
||||||
let gross_amount_cents = gross_amount * Decimal::from(100);
|
let gross_amount_cents = gross_amount.get() * Decimal::from(100);
|
||||||
let net_amount_cents = net_amount * Decimal::from(100);
|
let net_amount_cents = net_amount.get() * Decimal::from(100);
|
||||||
let fees_cents = fees.total_fee() * Decimal::from(100);
|
let fees_cents = fees.total_fee().get() * Decimal::from(100);
|
||||||
let address_line_3 = format!(
|
let address_line_3 = format!(
|
||||||
"{}, {}, {}",
|
"{}, {}, {}",
|
||||||
recipient_address.city,
|
recipient_address.city,
|
||||||
@@ -153,7 +154,7 @@ impl PayoutsQueue {
|
|||||||
|
|
||||||
let payout = muralpay::CreatePayout {
|
let payout = muralpay::CreatePayout {
|
||||||
amount: muralpay::TokenAmount {
|
amount: muralpay::TokenAmount {
|
||||||
token_amount: sent_to_method,
|
token_amount: sent_to_method.get(),
|
||||||
token_symbol: muralpay::USDC.into(),
|
token_symbol: muralpay::USDC.into(),
|
||||||
},
|
},
|
||||||
payout_details,
|
payout_details,
|
||||||
@@ -271,6 +272,8 @@ pub async fn sync_pending_payouts_from_mural(
|
|||||||
status: PayoutStatus,
|
status: PayoutStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("Syncing pending payouts from Mural");
|
||||||
|
|
||||||
let mut txn = db
|
let mut txn = db
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
@@ -299,6 +302,8 @@ pub async fn sync_pending_payouts_from_mural(
|
|||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to fetch incomplete Mural payouts")?;
|
.wrap_internal_err("failed to fetch incomplete Mural payouts")?;
|
||||||
|
|
||||||
|
info!("Found {} incomplete Mural payouts", rows.len());
|
||||||
|
|
||||||
let futs = rows.into_iter().map(|row| async move {
|
let futs = rows.into_iter().map(|row| async move {
|
||||||
let platform_id = row.platform_id.wrap_err("no platform ID")?;
|
let platform_id = row.platform_id.wrap_err("no platform ID")?;
|
||||||
let payout_request_id = platform_id.parse::<muralpay::PayoutRequestId>()
|
let payout_request_id = platform_id.parse::<muralpay::PayoutRequestId>()
|
||||||
@@ -369,6 +374,8 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
|
|||||||
mural: &MuralPay,
|
mural: &MuralPay,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
|
info!("Syncing failed Mural payouts to Labrinth");
|
||||||
|
|
||||||
let mut next_id = None;
|
let mut next_id = None;
|
||||||
loop {
|
loop {
|
||||||
let search_resp = mural
|
let search_resp = mural
|
||||||
@@ -393,8 +400,22 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut payout_platform_id = Vec::<String>::new();
|
let num_canceled = search_resp
|
||||||
let mut payout_new_status = Vec::<String>::new();
|
.results
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.status == muralpay::PayoutStatus::Canceled)
|
||||||
|
.count();
|
||||||
|
let num_failed = search_resp
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.status == muralpay::PayoutStatus::Failed)
|
||||||
|
.count();
|
||||||
|
info!(
|
||||||
|
"Found {num_canceled} canceled and {num_failed} failed Mural payouts"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut payout_platform_ids = Vec::<String>::new();
|
||||||
|
let mut payout_new_statuses = Vec::<String>::new();
|
||||||
|
|
||||||
for payout_req in search_resp.results {
|
for payout_req in search_resp.results {
|
||||||
let new_payout_status = match payout_req.status {
|
let new_payout_status = match payout_req.status {
|
||||||
@@ -408,12 +429,17 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let payout_platform_id = payout_req.id;
|
||||||
|
|
||||||
payout_platform_id.push(payout_req.id.to_string());
|
trace!(
|
||||||
payout_new_status.push(new_payout_status.to_string());
|
"- Payout {payout_platform_id} set to {new_payout_status:?}",
|
||||||
|
);
|
||||||
|
|
||||||
|
payout_platform_ids.push(payout_platform_id.to_string());
|
||||||
|
payout_new_statuses.push(new_payout_status.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"
|
"
|
||||||
UPDATE payouts
|
UPDATE payouts
|
||||||
SET status = u.status
|
SET status = u.status
|
||||||
@@ -422,14 +448,20 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
|
|||||||
payouts.method = $3
|
payouts.method = $3
|
||||||
AND payouts.platform_id = u.platform_id
|
AND payouts.platform_id = u.platform_id
|
||||||
",
|
",
|
||||||
&payout_platform_id,
|
&payout_platform_ids,
|
||||||
&payout_new_status,
|
&payout_new_statuses,
|
||||||
PayoutMethodType::MuralPay.as_str(),
|
PayoutMethodType::MuralPay.as_str(),
|
||||||
)
|
)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to update payout statuses")?;
|
.wrap_internal_err("failed to update payout statuses")?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Attempted to update {} payouts in database from Mural info, {} rows affected",
|
||||||
|
payout_platform_ids.len(),
|
||||||
|
result.rows_affected()
|
||||||
|
);
|
||||||
|
|
||||||
if next_id.is_none() {
|
if next_id.is_none() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ use chrono::{DateTime, Duration, Utc};
|
|||||||
use eyre::eyre;
|
use eyre::eyre;
|
||||||
use hex::ToHex;
|
use hex::ToHex;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
|
use modrinth_util::decimal::Decimal2dp;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::{Decimal, RoundingStrategy};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
@@ -423,8 +424,7 @@ pub async fn tremendous_webhook(
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct Withdrawal {
|
pub struct Withdrawal {
|
||||||
#[serde(with = "rust_decimal::serde::float")]
|
amount: Decimal2dp,
|
||||||
amount: Decimal,
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
method: PayoutMethodRequest,
|
method: PayoutMethodRequest,
|
||||||
method_id: String,
|
method_id: String,
|
||||||
@@ -432,7 +432,7 @@ pub struct Withdrawal {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct WithdrawalFees {
|
pub struct WithdrawalFees {
|
||||||
pub fee: Decimal,
|
pub fee: Decimal2dp,
|
||||||
pub exchange_rate: Option<Decimal>,
|
pub exchange_rate: Option<Decimal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +583,8 @@ pub async fn create_payout(
|
|||||||
|
|
||||||
let fees = payouts_queue
|
let fees = payouts_queue
|
||||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
.calculate_fees(&body.method, &body.method_id, body.amount)
|
||||||
.await?;
|
.await
|
||||||
|
.wrap_internal_err("failed to compute fees")?;
|
||||||
|
|
||||||
// fees are a bit complicated here, since we have 2 types:
|
// fees are a bit complicated here, since we have 2 types:
|
||||||
// - method fees - this is what Tremendous, Mural, etc. will take from us
|
// - method fees - this is what Tremendous, Mural, etc. will take from us
|
||||||
@@ -595,14 +596,18 @@ pub async fn create_payout(
|
|||||||
// then we issue a payout request with `amount - platform fees`
|
// then we issue a payout request with `amount - platform fees`
|
||||||
|
|
||||||
let amount_minus_fee = body.amount - fees.total_fee();
|
let amount_minus_fee = body.amount - fees.total_fee();
|
||||||
if amount_minus_fee.round_dp(2) <= Decimal::ZERO {
|
if amount_minus_fee <= Decimal::ZERO {
|
||||||
return Err(ApiError::InvalidInput(
|
return Err(ApiError::InvalidInput(
|
||||||
"You need to withdraw more to cover the fee!".to_string(),
|
"You need to withdraw more to cover the fee!".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let sent_to_method = (body.amount - fees.platform_fee).round_dp(2);
|
let sent_to_method = body.amount - fees.platform_fee;
|
||||||
assert!(sent_to_method > Decimal::ZERO);
|
if sent_to_method <= Decimal::ZERO {
|
||||||
|
return Err(ApiError::InvalidInput(
|
||||||
|
"You need to withdraw more to cover the fee!".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let payout_id = generate_payout_id(&mut transaction)
|
let payout_id = generate_payout_id(&mut transaction)
|
||||||
.await
|
.await
|
||||||
@@ -653,13 +658,13 @@ struct PayoutContext<'a> {
|
|||||||
body: &'a Withdrawal,
|
body: &'a Withdrawal,
|
||||||
user: &'a DBUser,
|
user: &'a DBUser,
|
||||||
payout_id: DBPayoutId,
|
payout_id: DBPayoutId,
|
||||||
gross_amount: Decimal,
|
gross_amount: Decimal2dp,
|
||||||
fees: PayoutFees,
|
fees: PayoutFees,
|
||||||
/// Set as the [`DBPayout::amount`] field.
|
/// Set as the [`DBPayout::amount`] field.
|
||||||
amount_minus_fee: Decimal,
|
amount_minus_fee: Decimal2dp,
|
||||||
/// Set as the [`DBPayout::fee`] field.
|
/// Set as the [`DBPayout::fee`] field.
|
||||||
total_fee: Decimal,
|
total_fee: Decimal2dp,
|
||||||
sent_to_method: Decimal,
|
sent_to_method: Decimal2dp,
|
||||||
payouts_queue: &'a PayoutsQueue,
|
payouts_queue: &'a PayoutsQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +726,10 @@ async fn tremendous_payout(
|
|||||||
forex.forex.get(¤cy_code).wrap_internal_err_with(|| {
|
forex.forex.get(¤cy_code).wrap_internal_err_with(|| {
|
||||||
eyre!("no Tremendous forex data for {currency}")
|
eyre!("no Tremendous forex data for {currency}")
|
||||||
})?;
|
})?;
|
||||||
(sent_to_method * *exchange_rate, Some(currency_code))
|
(
|
||||||
|
sent_to_method.mul_round(*exchange_rate, RoundingStrategy::ToZero),
|
||||||
|
Some(currency_code),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(sent_to_method, None)
|
(sent_to_method, None)
|
||||||
};
|
};
|
||||||
@@ -770,8 +778,8 @@ async fn tremendous_payout(
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
created: Utc::now(),
|
created: Utc::now(),
|
||||||
status: PayoutStatus::InTransit,
|
status: PayoutStatus::InTransit,
|
||||||
amount: amount_minus_fee,
|
amount: amount_minus_fee.get(),
|
||||||
fee: Some(total_fee),
|
fee: Some(total_fee.get()),
|
||||||
method: Some(PayoutMethodType::Tremendous),
|
method: Some(PayoutMethodType::Tremendous),
|
||||||
method_id: Some(body.method_id.clone()),
|
method_id: Some(body.method_id.clone()),
|
||||||
method_address: Some(user_email.to_string()),
|
method_address: Some(user_email.to_string()),
|
||||||
@@ -825,8 +833,8 @@ async fn mural_pay_payout(
|
|||||||
// after the payout has been successfully executed,
|
// after the payout has been successfully executed,
|
||||||
// we wait for Mural's confirmation that the funds have been delivered
|
// we wait for Mural's confirmation that the funds have been delivered
|
||||||
status: PayoutStatus::InTransit,
|
status: PayoutStatus::InTransit,
|
||||||
amount: amount_minus_fee,
|
amount: amount_minus_fee.get(),
|
||||||
fee: Some(total_fee),
|
fee: Some(total_fee.get()),
|
||||||
method: Some(PayoutMethodType::MuralPay),
|
method: Some(PayoutMethodType::MuralPay),
|
||||||
method_id: Some(method_id),
|
method_id: Some(method_id),
|
||||||
method_address: Some(user_email.to_string()),
|
method_address: Some(user_email.to_string()),
|
||||||
@@ -962,8 +970,8 @@ async fn paypal_payout(
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
created: Utc::now(),
|
created: Utc::now(),
|
||||||
status: PayoutStatus::InTransit,
|
status: PayoutStatus::InTransit,
|
||||||
amount: amount_minus_fee,
|
amount: amount_minus_fee.get(),
|
||||||
fee: Some(total_fee),
|
fee: Some(total_fee.get()),
|
||||||
method: Some(body.method.method_type()),
|
method: Some(body.method.method_type()),
|
||||||
method_id: Some(body.method_id.clone()),
|
method_id: Some(body.method_id.clone()),
|
||||||
method_address: Some(display_address.clone()),
|
method_address: Some(display_address.clone()),
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
name: labrinth
|
name: labrinth
|
||||||
services:
|
services:
|
||||||
postgres_db:
|
postgres_db:
|
||||||
image: postgres:alpine
|
# staging/prod Labrinth are currently using this version of Postgres
|
||||||
|
image: postgres:15-alpine
|
||||||
container_name: labrinth-postgres
|
container_name: labrinth-postgres
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- '5432:5432'
|
- '127.0.0.1:5432:5432'
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: labrinth
|
POSTGRES_USER: labrinth
|
||||||
POSTGRES_PASSWORD: labrinth
|
POSTGRES_PASSWORD: labrinth
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'pg_isready']
|
test: ['CMD', 'pg_isready', '-U', 'labrinth']
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -21,7 +22,7 @@ services:
|
|||||||
container_name: labrinth-meilisearch
|
container_name: labrinth-meilisearch
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- '7700:7700'
|
- '127.0.0.1:7700:7700'
|
||||||
volumes:
|
volumes:
|
||||||
- meilisearch-data:/data.ms
|
- meilisearch-data:/data.ms
|
||||||
environment:
|
environment:
|
||||||
@@ -37,7 +38,7 @@ services:
|
|||||||
container_name: labrinth-redis
|
container_name: labrinth-redis
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- '6379:6379'
|
- '127.0.0.1:6379:6379'
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -49,7 +50,7 @@ services:
|
|||||||
image: clickhouse/clickhouse-server
|
image: clickhouse/clickhouse-server
|
||||||
container_name: labrinth-clickhouse
|
container_name: labrinth-clickhouse
|
||||||
ports:
|
ports:
|
||||||
- '8123:8123'
|
- '127.0.0.1:8123:8123'
|
||||||
environment:
|
environment:
|
||||||
CLICKHOUSE_USER: default
|
CLICKHOUSE_USER: default
|
||||||
CLICKHOUSE_PASSWORD: default
|
CLICKHOUSE_PASSWORD: default
|
||||||
@@ -62,8 +63,8 @@ services:
|
|||||||
image: axllent/mailpit:v1.27
|
image: axllent/mailpit:v1.27
|
||||||
container_name: labrinth-mail
|
container_name: labrinth-mail
|
||||||
ports:
|
ports:
|
||||||
- '1025:1025'
|
- '127.0.0.1:1025:1025'
|
||||||
- '8025:8025'
|
- '127.0.0.1:8025:8025'
|
||||||
environment:
|
environment:
|
||||||
MP_ENABLE_SPAMASSASSIN: postmark
|
MP_ENABLE_SPAMASSASSIN: postmark
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -82,7 +83,7 @@ services:
|
|||||||
image: gotenberg/gotenberg:8
|
image: gotenberg/gotenberg:8
|
||||||
container_name: labrinth-gotenberg
|
container_name: labrinth-gotenberg
|
||||||
ports:
|
ports:
|
||||||
- '13000:3000'
|
- '127.0.0.1:13000:3000'
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Gotenberg must send a message on a webhook to our backend,
|
# Gotenberg must send a message on a webhook to our backend,
|
||||||
# so it must have access to our local network
|
# so it must have access to our local network
|
||||||
@@ -95,7 +96,7 @@ services:
|
|||||||
dockerfile: ./apps/labrinth/Dockerfile
|
dockerfile: ./apps/labrinth/Dockerfile
|
||||||
container_name: labrinth
|
container_name: labrinth
|
||||||
ports:
|
ports:
|
||||||
- '8000:8000'
|
- '127.0.0.1:8000:8000'
|
||||||
env_file: ./apps/labrinth/.env.docker-compose
|
env_file: ./apps/labrinth/.env.docker-compose
|
||||||
volumes:
|
volumes:
|
||||||
- labrinth-cdn-data:/tmp/modrinth
|
- labrinth-cdn-data:/tmp/modrinth
|
||||||
@@ -114,6 +115,30 @@ services:
|
|||||||
watch:
|
watch:
|
||||||
- path: ./apps/labrinth
|
- path: ./apps/labrinth
|
||||||
action: rebuild
|
action: rebuild
|
||||||
|
delphi:
|
||||||
|
profiles:
|
||||||
|
- with-delphi
|
||||||
|
image: ghcr.io/modrinth/delphi:feature-schema-rework
|
||||||
|
container_name: labrinth-delphi
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:59999:59999'
|
||||||
|
environment:
|
||||||
|
LABRINTH_ENDPOINT: http://host.docker.internal:8000/_internal/delphi/ingest
|
||||||
|
LABRINTH_ADMIN_KEY: feedbeef
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
['CMD', 'wget', '-q', '-O/dev/null', 'http://localhost:59999/health']
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
volumes:
|
||||||
|
# Labrinth deposits version files here;
|
||||||
|
# Delphi reads them from here
|
||||||
|
- /tmp/modrinth:/tmp/modrinth:ro
|
||||||
|
extra_hosts:
|
||||||
|
# Delphi must send a message on a webhook to our backend,
|
||||||
|
# so it must have access to our local network
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
volumes:
|
volumes:
|
||||||
meilisearch-data:
|
meilisearch-data:
|
||||||
db-data:
|
db-data:
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use crate::util::fetch::REQWEST_CLIENT;
|
|||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn check_reachable() -> crate::Result<()> {
|
pub async fn check_reachable() -> crate::Result<()> {
|
||||||
let resp = REQWEST_CLIENT
|
let resp = REQWEST_CLIENT
|
||||||
.get("https://api.minecraftservices.com/entitlements/mcstore")
|
.get("https://sessionserver.mojang.com/session/minecraft/hasJoined")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
if resp.status() == StatusCode::UNAUTHORIZED {
|
if resp.status() == StatusCode::NO_CONTENT {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
resp.error_for_status()?;
|
resp.error_for_status()?;
|
||||||
|
|||||||
@@ -395,17 +395,36 @@ pub async fn download_libraries(
|
|||||||
.unwrap_or("https://libraries.minecraft.net/")
|
.unwrap_or("https://libraries.minecraft.net/")
|
||||||
);
|
);
|
||||||
|
|
||||||
let bytes =
|
|
||||||
fetch(&url, None, &st.fetch_semaphore, &st.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
write(&path, &bytes, &st.io_semaphore).await?;
|
|
||||||
|
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
"Fetched library {} to path {:?}",
|
"Attempting to fetch {} from {url}",
|
||||||
&library.name,
|
library.name,
|
||||||
&path
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// It's OK for this fetch to fail, since the URL might not even be valid.
|
||||||
|
// We're constructing a download URL basically out of thin air, and hoping
|
||||||
|
// that it's valid. Since PrismLauncher ignores the library (see above), a
|
||||||
|
// failed download here is not a fatal condition.
|
||||||
|
//
|
||||||
|
// See DEV-479.
|
||||||
|
match fetch(&url, None, &st.fetch_semaphore, &st.pool).await
|
||||||
|
{
|
||||||
|
Ok(bytes) => {
|
||||||
|
write(&path, &bytes, &st.io_semaphore).await?;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Fetched library {} to path {:?}",
|
||||||
|
&library.name,
|
||||||
|
&path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"Failed to download library {} from {url} - \
|
||||||
|
this is not necessarily an error: {err:#?}",
|
||||||
|
&library.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ actix-web = { workspace = true }
|
|||||||
derive_more = { workspace = true, features = ["display", "error", "from"] }
|
derive_more = { workspace = true, features = ["display", "error", "from"] }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
eyre = { workspace = true }
|
eyre = { workspace = true }
|
||||||
|
rust_decimal = { workspace = true, features = ["macros"], optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-ecs = { workspace = true }
|
tracing-ecs = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
utoipa = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
decimal = ["dep:rust_decimal", "utoipa?/decimal"]
|
||||||
|
utoipa = ["dep:utoipa"]
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
226
packages/modrinth-util/src/decimal.rs
Normal file
226
packages/modrinth-util/src/decimal.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
use std::{
|
||||||
|
cmp,
|
||||||
|
ops::{Add, Sub},
|
||||||
|
};
|
||||||
|
|
||||||
|
use derive_more::{Deref, Display, Error};
|
||||||
|
use rust_decimal::{Decimal, RoundingStrategy};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Display,
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
PartialOrd,
|
||||||
|
Ord,
|
||||||
|
Hash,
|
||||||
|
Deref,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
)]
|
||||||
|
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||||
|
#[serde(try_from = "Decimal")]
|
||||||
|
pub struct DecimalDp<const DP: u32>(Decimal);
|
||||||
|
|
||||||
|
pub type Decimal2dp = DecimalDp<2>;
|
||||||
|
|
||||||
|
#[derive(Debug, Display, Clone, Error)]
|
||||||
|
#[display("decimal is not rounded to {dp} decimal places")]
|
||||||
|
pub struct NotRounded {
|
||||||
|
pub dp: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> DecimalDp<DP> {
|
||||||
|
pub const ZERO: Self = Self(Decimal::ZERO);
|
||||||
|
|
||||||
|
pub fn rounded(v: Decimal, strategy: RoundingStrategy) -> Self {
|
||||||
|
Self(v.round_dp_with_strategy(DP, strategy))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(v: Decimal) -> Result<Self, NotRounded> {
|
||||||
|
if v.round_dp(DP) == v {
|
||||||
|
Ok(Self(v))
|
||||||
|
} else {
|
||||||
|
Err(NotRounded { dp: DP })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(self) -> Decimal {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mul_round(
|
||||||
|
self,
|
||||||
|
other: impl Into<Decimal>,
|
||||||
|
strategy: RoundingStrategy,
|
||||||
|
) -> Self {
|
||||||
|
Self::rounded(self.0 * other.into(), strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversion
|
||||||
|
|
||||||
|
impl<const DP: u32> TryFrom<Decimal> for DecimalDp<DP> {
|
||||||
|
type Error = NotRounded;
|
||||||
|
|
||||||
|
fn try_from(value: Decimal) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> From<DecimalDp<DP>> for Decimal {
|
||||||
|
fn from(value: DecimalDp<DP>) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ord
|
||||||
|
|
||||||
|
impl<const DP: u32> PartialOrd<Decimal> for DecimalDp<DP> {
|
||||||
|
fn partial_cmp(&self, other: &Decimal) -> Option<cmp::Ordering> {
|
||||||
|
self.0.partial_cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> PartialOrd<DecimalDp<DP>> for Decimal {
|
||||||
|
fn partial_cmp(&self, other: &DecimalDp<DP>) -> Option<cmp::Ordering> {
|
||||||
|
self.partial_cmp(&other.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eq
|
||||||
|
|
||||||
|
impl<const DP: u32> PartialEq<Decimal> for DecimalDp<DP> {
|
||||||
|
fn eq(&self, other: &Decimal) -> bool {
|
||||||
|
self.0.eq(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> PartialEq<DecimalDp<DP>> for Decimal {
|
||||||
|
fn eq(&self, other: &DecimalDp<DP>) -> bool {
|
||||||
|
self.eq(&other.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add
|
||||||
|
|
||||||
|
impl<const DP: u32> Add for DecimalDp<DP> {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn add(self, rhs: DecimalDp<DP>) -> Self::Output {
|
||||||
|
let v = self.0 + rhs.0;
|
||||||
|
debug_assert!(Self::new(v).is_ok());
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> Add<Decimal> for DecimalDp<DP> {
|
||||||
|
type Output = Decimal;
|
||||||
|
|
||||||
|
fn add(self, rhs: Decimal) -> Self::Output {
|
||||||
|
self.0 + rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> Add<DecimalDp<DP>> for Decimal {
|
||||||
|
type Output = Decimal;
|
||||||
|
|
||||||
|
fn add(self, rhs: DecimalDp<DP>) -> Self::Output {
|
||||||
|
self + rhs.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sub
|
||||||
|
|
||||||
|
impl<const DP: u32> Sub for DecimalDp<DP> {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn sub(self, rhs: Self) -> Self::Output {
|
||||||
|
let v = self.0 - rhs.0;
|
||||||
|
debug_assert!(Self::new(v).is_ok());
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> Sub<Decimal> for DecimalDp<DP> {
|
||||||
|
type Output = Decimal;
|
||||||
|
|
||||||
|
fn sub(self, rhs: Decimal) -> Self::Output {
|
||||||
|
self.0 - rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const DP: u32> Sub<DecimalDp<DP>> for Decimal {
|
||||||
|
type Output = Decimal;
|
||||||
|
|
||||||
|
fn sub(self, rhs: DecimalDp<DP>) -> Self::Output {
|
||||||
|
self - rhs.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use rust_decimal::dec;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new() {
|
||||||
|
Decimal2dp::new(dec!(1)).unwrap();
|
||||||
|
Decimal2dp::new(dec!(1.0)).unwrap();
|
||||||
|
Decimal2dp::new(dec!(1.1)).unwrap();
|
||||||
|
Decimal2dp::new(dec!(1.01)).unwrap();
|
||||||
|
Decimal2dp::new(dec!(1.00)).unwrap();
|
||||||
|
Decimal2dp::new(dec!(1.000)).unwrap();
|
||||||
|
Decimal2dp::new(dec!(1.001)).unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounded() {
|
||||||
|
assert_eq!(
|
||||||
|
dec!(1),
|
||||||
|
Decimal2dp::rounded(dec!(1), RoundingStrategy::ToZero)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dec!(1),
|
||||||
|
Decimal2dp::rounded(dec!(1.001), RoundingStrategy::ToZero)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dec!(1),
|
||||||
|
Decimal2dp::rounded(dec!(1.005), RoundingStrategy::ToZero)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dec!(1),
|
||||||
|
Decimal2dp::rounded(dec!(1.009), RoundingStrategy::ToZero)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dec!(1.01),
|
||||||
|
Decimal2dp::rounded(dec!(1.010), RoundingStrategy::ToZero)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize() {
|
||||||
|
serde_json::from_str::<Decimal2dp>("1").unwrap();
|
||||||
|
serde_json::from_str::<Decimal2dp>("1.0").unwrap();
|
||||||
|
serde_json::from_str::<Decimal2dp>("1.00").unwrap();
|
||||||
|
serde_json::from_str::<Decimal2dp>("1.000").unwrap();
|
||||||
|
serde_json::from_str::<Decimal2dp>("1.001").unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ops() {
|
||||||
|
assert_eq!(
|
||||||
|
Decimal2dp::new(dec!(1.23)).unwrap()
|
||||||
|
+ Decimal2dp::new(dec!(0.27)).unwrap(),
|
||||||
|
dec!(1.50)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Decimal2dp::new(dec!(1.23)).unwrap()
|
||||||
|
- Decimal2dp::new(dec!(0.23)).unwrap(),
|
||||||
|
dec!(1.00)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
mod error;
|
mod error;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
|
|
||||||
|
#[cfg(feature = "decimal")]
|
||||||
|
pub mod decimal;
|
||||||
|
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
|
|
||||||
use eyre::{Result, eyre};
|
use eyre::{Result, eyre};
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export type VersionEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VERSIONS: VersionEntry[] = [
|
const VERSIONS: VersionEntry[] = [
|
||||||
|
{
|
||||||
|
date: `2025-11-19T15:15:00-08:00`,
|
||||||
|
product: 'app',
|
||||||
|
version: '0.10.20',
|
||||||
|
body: `## Improvements
|
||||||
|
- Improved contrast, visibility, and consistency of UI elements, especially in light mode.
|
||||||
|
- Fixed ads showing up in the loading screen, even when you have Modrinth+.
|
||||||
|
- Added a warning banner when Minecraft's authentication servers are detected to be down.
|
||||||
|
- Fixed icon when creating an instance not being saved.`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: `2025-11-14T12:15:00-08:00`,
|
date: `2025-11-14T12:15:00-08:00`,
|
||||||
product: 'servers',
|
product: 'servers',
|
||||||
|
|||||||
Reference in New Issue
Block a user