Merge tag 'v0.10.21' into beta

This commit is contained in:
2025-11-28 22:23:28 +03:00
21 changed files with 503 additions and 85 deletions

3
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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

View File

@@ -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;

View File

@@ -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 = {}
} }

View File

@@ -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()

View File

@@ -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'

View File

@@ -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">

View File

@@ -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 }

View File

@@ -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");

View File

@@ -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");
} }
}); });
} }

View File

@@ -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),
) )
} }

View File

@@ -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
} }
} }

View File

@@ -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;
} }

View File

@@ -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(&currency_code).wrap_internal_err_with(|| { forex.forex.get(&currency_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()),

View File

@@ -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:

View File

@@ -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()?;

View File

@@ -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
);
}
}
} }
} }

View File

@@ -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

View 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)
);
}
}

View File

@@ -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};

View File

@@ -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',