Compare commits

...
Author SHA1 Message Date
François-X. T. 514a860309 chore: fixes 2026-07-22 10:49:54 -04:00
François-X. T. 0e1ad0c7c3 chore: clippy 2026-07-21 21:52:52 -04:00
François-X. T. d790bf79ec chore: fixes 2026-07-21 21:49:42 -04:00
François-X. T. 67e3dfb3f4 feat(labrinth): add /_internal/billing/subscriptions/manager/update_many 2026-07-21 21:41:11 -04:00
Prospector 68f4b5d86a changelog 2026-07-21 16:27:11 -07:00
ProspectorandGitHub 8f387aa9ea fix: bump skin render version to fix bad cache (#6809)
bump skin render version to fix bad cache
2026-07-21 16:24:15 -07:00
ThatGravyBoatandGitHub 706d4c1a1e fix: license nag showing for datapacks (#6807) 2026-07-21 23:22:54 +00:00
Truman GaoandGitHub 39fb75ebf7 fix: ads showing when sidebar is disabled on startup (#6806) 2026-07-21 22:27:27 +00:00
Prospector 6c86bb933b changelog 2026-07-21 13:11:22 -07:00
Calum H.andGitHub 4bb20e560f fix: skin uv inset (#6803) 2026-07-21 13:10:15 -07:00
mfishmaandGitHub 5302e3a987 Fix deep link installation for mods containing + (plus sign) (#6464)
* ffix: preserve literal '+' in deep link URI thru API call

* chore: check for missing projects during installs

* chore: remove accidental test files and quote error ID

* fix rustfmt formatting for the change
2026-07-21 19:32:13 +00:00
Prospector 9b4c84ddd8 changelog 2026-07-21 12:37:00 -07:00
ProspectorandGitHub 528a9b76bc fix: devtools opening on ads init (#6800) 2026-07-21 12:36:37 -07:00
16 changed files with 214 additions and 81 deletions
@@ -210,7 +210,7 @@ function getModelUrlForVariant(variant: string): string {
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
const SKIN_PREVIEW_RENDER_VERSION = 'ears-2'
const SKIN_PREVIEW_RENDER_VERSION = 'ears-2-fixed-uvs'
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
@@ -807,6 +807,11 @@ export function createContentInstall(opts: {
) {
const project: Labrinth.Projects.v2.Project = await get_project(projectId, 'must_revalidate')
if (!project) {
opts.handleError(`Project not found: '${projectId}'`)
return
}
if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
+8 -9
View File
@@ -582,8 +582,6 @@ pub async fn init_ads_window<R: Runtime>(
}
})?;
webview.open_devtools();
Some(webview)
} else {
None
@@ -701,15 +699,16 @@ pub async fn hide_ads_window<R: Runtime>(
reset: Option<bool>,
) -> crate::api::Result<()> {
let reset = reset.unwrap_or(false);
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
if reset {
state.shown = false;
state.consent_overlay_shown = false;
}
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
if reset {
state.shown = false;
state.consent_overlay_shown = false;
} else {
if !reset {
state.modal_shown = true;
if state.consent_overlay_shown {
+1
View File
@@ -10,6 +10,7 @@ CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
LABRINTH_MEDAL_KEY=
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
LABRINTH_SUBSCRIPTIONS_KEY=
RATE_LIMIT_IGNORE_KEY=feedbeef
DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
+1
View File
@@ -10,6 +10,7 @@ CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
LABRINTH_MEDAL_KEY=
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
LABRINTH_SUBSCRIPTIONS_KEY=
RATE_LIMIT_IGNORE_KEY=feedbeef
DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
@@ -1,4 +1,3 @@
use crate::database::PgTransaction;
use crate::database::models::{
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
};
@@ -131,7 +130,7 @@ impl DBUserSubscription {
pub async fn upsert(
&self,
transaction: &mut PgTransaction<'_>,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
@@ -156,7 +155,7 @@ impl DBUserSubscription {
self.status.as_str(),
serde_json::to_value(&self.metadata)?,
)
.execute(&mut *transaction)
.execute(exec)
.await?;
Ok(())
+1
View File
@@ -127,6 +127,7 @@ vars! {
LABRINTH_ADMIN_KEY: String = "";
LABRINTH_MEDAL_KEY: String = "";
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String = "";
LABRINTH_SUBSCRIPTIONS_KEY: String = "";
RATE_LIMIT_IGNORE_KEY: String = "";
DATABASE_URL: String = "postgresql://labrinth:labrinth@localhost/labrinth";
REDIS_URL: String = "redis://localhost";
+20 -17
View File
@@ -1,4 +1,5 @@
use self::payments::*;
use self::update_subscriptions::*;
use crate::auth::get_user_from_headers;
use crate::database::models::charge_item::DBCharge;
use crate::database::models::ids::DBUserSubscriptionId;
@@ -56,6 +57,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.service(charges)
.service(credit)
.service(active_servers)
.service(update_many)
.service(initiate_payment)
.service(stripe_webhook)
.service(refund_charge)
@@ -63,7 +65,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
);
}
/// List products.
/// List products.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -113,7 +115,7 @@ struct UserSubscriptionWithNextChargeTaxAmount {
pub next_charge_tax_amount: Option<i64>,
}
/// List subscriptions.
/// List subscriptions.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -187,7 +189,7 @@ pub struct ChargeRefund {
pub unprovision: Option<bool>,
}
/// Refund a charge.
/// Refund a charge.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -457,7 +459,7 @@ pub async fn refund_charge(
Ok(HttpResponse::NoContent().finish())
}
/// Reprocess tax for a charge.
/// Reprocess tax for a charge.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -646,7 +648,7 @@ pub struct SubscriptionEditQuery {
pub dry: Option<bool>,
}
/// Update a subscription.
/// Update a subscription.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1146,7 +1148,7 @@ pub async fn edit_subscription(
}
}
/// Get the current customer.
/// Get the current customer.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1190,7 +1192,7 @@ pub struct ChargesQuery {
pub user_id: Option<ariadne::ids::UserId>,
}
/// List payments.
/// List payments.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1256,7 +1258,7 @@ pub async fn charges(
))
}
/// Start a payment method flow.
/// Start a payment method flow.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1315,7 +1317,7 @@ pub struct EditPaymentMethod {
pub primary: bool,
}
/// Update a payment method.
/// Update a payment method.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1385,7 +1387,7 @@ pub async fn edit_payment_method(
}
}
/// Remove a payment method.
/// Remove a payment method.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1474,7 +1476,7 @@ pub async fn remove_payment_method(
}
}
/// List payment methods.
/// List payment methods.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1537,7 +1539,7 @@ struct ActiveServerResponse {
pub region: Option<String>,
}
/// List active servers.
/// List active servers.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1651,7 +1653,7 @@ pub struct PaymentRequest {
pub metadata: Option<PaymentRequestMetadata>,
}
/// Initiate a payment.
/// Initiate a payment.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1721,7 +1723,7 @@ pub async fn initiate_payment(
}
}
/// Receive a Stripe webhook.
/// Receive a Stripe webhook.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1967,7 +1969,7 @@ pub async fn stripe_webhook(
if charge_status != ChargeStatus::Failed {
subscription
.upsert(transaction)
.upsert(&mut *transaction)
.await?;
}
@@ -2009,7 +2011,7 @@ pub async fn stripe_webhook(
};
if charge_status != ChargeStatus::Failed {
charge.upsert(transaction).await?;
charge.upsert(&mut *transaction).await?;
}
(charge, price, product, subscription, new_region)
@@ -2644,7 +2646,7 @@ pub enum CreditTarget {
},
}
/// Credit subscriptions.
/// Credit subscriptions.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -2773,3 +2775,4 @@ pub async fn credit(
}
pub mod payments;
pub mod update_subscriptions;
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use actix_web::{post, web};
use ariadne::ids::UserId as Id;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::database::PgPool;
use crate::database::models::user_subscription_item::DBUserSubscription;
use crate::models::billing::SubscriptionMetadata;
use crate::routes::ApiError;
use crate::util::error::Context;
use crate::util::guards::subscriptions_key_guard;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateManySubscriptions {
pub subscriptions: Vec<SubscriptionUpdate>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SubscriptionUpdate {
pub target: SubscriptionTarget,
pub update_region: Option<String>,
pub ignore_if_missing: bool,
}
#[derive(
Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, ToSchema,
)]
pub enum SubscriptionTarget {
Pyro { user_id: Id, server_id: Uuid },
}
/// Update multiple managed subscriptions.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
request_body = UpdateManySubscriptions,
responses((status = OK))
)]
#[post(
"/subscriptions/manager/update_many",
guard = "subscriptions_key_guard"
)]
pub async fn update_many(
pool: web::Data<PgPool>,
body: web::Json<UpdateManySubscriptions>,
) -> Result<(), ApiError> {
let UpdateManySubscriptions { subscriptions } = body.into_inner();
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
// Only supports hosting subscriptions now, so gather the server IDs and
// fetch the subscriptions from them
let server_ids = subscriptions
.iter()
.map(|update| match update.target {
SubscriptionTarget::Pyro { server_id, .. } => server_id.to_string(),
})
.collect::<Vec<_>>();
let found_subscriptions =
DBUserSubscription::get_many_by_server_ids(&server_ids, &mut txn)
.await
.wrap_internal_err("failed to fetch subscriptions to update")?;
let mut subscriptions_by_target =
HashMap::<SubscriptionTarget, DBUserSubscription>::new();
// Creates a map of subscription "key" -> it's DB representation.
for subscription in found_subscriptions {
let Some(SubscriptionMetadata::Pyro { id, .. }) =
subscription.metadata.as_ref()
else {
continue;
};
let Ok(server_id) = Uuid::parse_str(id) else {
continue;
};
let target = SubscriptionTarget::Pyro {
user_id: subscription.user_id.into(),
server_id,
};
subscriptions_by_target.insert(target, subscription);
}
for update in subscriptions {
let Some(subscription) =
subscriptions_by_target.get_mut(&update.target)
else {
continue;
};
// Update the subscription region
if let Some(SubscriptionMetadata::Pyro { region, .. }) =
subscription.metadata.as_mut()
&& let Some(new_region) = update.update_region.clone()
{
*region = Some(new_region);
}
subscription.upsert(&mut txn).await?;
}
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
+1
View File
@@ -157,6 +157,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
billing::remove_payment_method,
billing::payment_methods,
billing::active_servers,
billing::update_subscriptions::update_many,
billing::initiate_payment,
billing::stripe_webhook,
billing::credit,
+15
View File
@@ -6,6 +6,7 @@ use crate::env::ENV;
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
pub const EXTERNAL_NOTIFICATION_KEY_HEADER: &str = "External-Notification-Key";
pub const SUBSCRIPTIONS_KEY_HEADER: &str = "Modrinth-Subscriptions-Key";
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
ctx.head()
@@ -30,6 +31,20 @@ pub fn external_notification_key_guard(ctx: &GuardContext) -> bool {
})
}
pub fn subscriptions_key_guard(ctx: &GuardContext) -> bool {
// Ensure the subs key is set and at least 32 characters
if ENV.LABRINTH_SUBSCRIPTIONS_KEY.chars().count() < 32 {
return false;
}
ctx.head()
.headers()
.get(SUBSCRIPTIONS_KEY_HEADER)
.is_some_and(|it| {
it.as_bytes() == ENV.LABRINTH_SUBSCRIPTIONS_KEY.as_bytes()
})
}
pub fn internal_network_guard(ctx: &GuardContext) -> bool {
ctx.head()
.peer_addr
+9 -2
View File
@@ -1105,8 +1105,15 @@ impl CachedEntry {
.collect::<Vec<_>>()
.chunks(MAX_REQUEST_SIZE)
.map(|chunk| {
serde_json::to_string(&chunk)
.map(|keys| format!("{api_url}{url}{keys}"))
serde_json::to_string(&chunk).map(|keys| {
format!(
"{api_url}{url}{}",
url::form_urlencoded::byte_serialize(
keys.as_bytes()
)
.collect::<String>()
)
})
})
.collect::<Result<Vec<_>, _>>()?;
+32
View File
@@ -10,6 +10,38 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-21T23:25:29+00:00`,
product: 'web',
body: `## Fixed
- Fixed license source nag showing on Data Pack projects.`,
},
{
date: `2026-07-21T23:25:29+00:00`,
product: 'app',
version: '0.15.17',
body: `## Fixed
- Fixed skin selector serving old cached skin previews with bad UVs.
- Fixed ads showing when sidebar is disabled.`,
},
{
date: `2026-07-21T20:10:55+00:00`,
product: 'app',
version: '0.15.16',
body: `## Fixed
- Fixed skins having a massive UV offset.
- Fixed deeplink issues for slugs containing + characters.`,
},
{
date: `2026-07-21T19:36:51+00:00`,
product: 'app',
version: '0.15.15',
body: `## Changed
- Updated the "Minecraft required" popup to a new style.
## Fixed
- Fixed devtools opening when ads are initialized.`,
},
{
date: `2026-07-21T18:43:00+00:00`,
product: 'web',
@@ -326,6 +326,8 @@ export const linksNags: Nag[] = [
},
status: 'required',
shouldShow: (context: NagContext) => {
if (context.projectV3.project_types.includes('datapack')) return false
const hasSourceUrl = !!context.project.source_url
const hasAdditionalFiles = (context: NagContext) => {
let hasAdditional = true
@@ -131,7 +131,7 @@ const SKIN_SIZE = 64
const EARS_V0_MAGIC = 0x3f23d8
const EARS_V1_MAGIC = 0xea2501
const ALFALFA_MAGIC = 0xea1fa1fa
const UV_TEXEL_INSET = 0.5
const UV_TEXEL_INSET = 1 / 32
const EAR_MODES: EarMode[] = [
'NONE',
@@ -13,8 +13,6 @@ const modelCache: Map<string, GLTF> = new Map()
const modelPromiseCache: Map<string, Promise<GLTF>> = new Map()
const textureCache: Map<string, THREE.Texture> = new Map()
const texturePromiseCache: Map<string, Promise<THREE.Texture>> = new Map()
const SKIN_TEXTURE_SIZE = 64
const SKIN_UV_INSET_VERSION = 1
export async function loadModel(modelUrl: string): Promise<GLTF> {
if (modelCache.has(modelUrl)) {
@@ -155,58 +153,12 @@ function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
}
}
function insetSkinGeometryUvs(geometry: THREE.BufferGeometry): void {
if (geometry.userData.skinUvInsetVersion === SKIN_UV_INSET_VERSION) return
const uv = geometry.getAttribute('uv')
if (!uv || uv.itemSize < 2 || uv.count % 4 !== 0) return
for (let faceStart = 0; faceStart < uv.count; faceStart += 4) {
let minU = Infinity
let maxU = -Infinity
let minV = Infinity
let maxV = -Infinity
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
const u = uv.getX(vertex)
const v = uv.getY(vertex)
minU = Math.min(minU, u)
maxU = Math.max(maxU, u)
minV = Math.min(minV, v)
maxV = Math.max(maxV, v)
}
const insetMinU = (Math.round(minU * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
const insetMaxU = (Math.round(maxU * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
const insetMinV = (Math.round(minV * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
const insetMaxV = (Math.round(maxV * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
const u = uv.getX(vertex)
const v = uv.getY(vertex)
const insetU = u === minU ? insetMinU : u === maxU ? insetMaxU : u
const insetV = v === minV ? insetMinV : v === maxV ? insetMaxV : v
uv.setXY(vertex, insetU, insetV)
}
}
uv.needsUpdate = true
geometry.userData.skinUvInsetVersion = SKIN_UV_INSET_VERSION
}
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const isSkinLayer = mesh.name.endsWith('_Layer')
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const usesSkinTexture = materials.some(
(mat) => mat instanceof THREE.MeshStandardMaterial && mat.name !== 'cape',
)
if (usesSkinTexture) {
insetSkinGeometryUvs(mesh.geometry)
}
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {