fix: skins QA problems + flow change (#6216)

* fix: skins backend bugs + apply flow

* fix: caching structure

* feat: collapse already duplicated skins + fix moj api spam

* fix: doc

* fix: flatten migrations

* feat: remove default cape/cape override concept

* fix: fmt + lint

* feat: remove SelectCapeModal for inline cape list

* feat: qa

* feat: virtualisation of skins sections + fix texture/model cache

* fix: lint

* fix: virt bugs + renderer fixes

* fix: qa bugs

* fix: doc

* fix: re-add click impulse anim from prototypes + re-add interact anim length cap

* fix: regressions

* devex: split up SkinPreviewrenderer

* fix: lint

* fix: introduce dynamic mode in virtual-scroll.ts

* feat: qa

* fix: nametag bug + remove minecon skin pack suffix

* feat: pain (literally)

* feat: user agent on moj reqs

* feat: impl per account flush queue for operations

* fix: breadcrumb

* chore: i18n pass

* fix: lint + prep + check

* fix: misalignments
This commit is contained in:
Calum H.
2026-05-27 23:22:24 +01:00
committed by GitHub
parent 64edf2ddeb
commit 84b91f32f8
55 changed files with 5651 additions and 2138 deletions
-5
View File
@@ -48,11 +48,6 @@ pub(crate) async fn connect(
async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query!(
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
+158 -85
View File
@@ -20,7 +20,6 @@ use serde_json::json;
use sha2::Digest;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::future::Future;
use std::hash::{BuildHasherDefault, DefaultHasher};
use std::io;
@@ -217,6 +216,34 @@ pub(super) static PROFILE_CACHE: Mutex<
HashMap<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
const ONLINE_PROFILE_CACHE_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(60);
const ONLINE_PROFILE_LIVE_STATE_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(5);
const ONLINE_PROFILE_AUTH_ERROR_BACKOFF: std::time::Duration =
std::time::Duration::from_secs(60);
#[derive(Debug, Clone, Copy)]
enum OnlineProfileCacheIntent {
NormalRead,
LiveStateRead,
RefreshFromMojang,
}
impl OnlineProfileCacheIntent {
fn max_age(self) -> std::time::Duration {
match self {
Self::NormalRead => ONLINE_PROFILE_CACHE_MAX_AGE,
Self::LiveStateRead => ONLINE_PROFILE_LIVE_STATE_MAX_AGE,
Self::RefreshFromMojang => std::time::Duration::ZERO,
}
}
fn can_use_stale_on_fetch_error(self) -> bool {
matches!(self, Self::LiveStateRead)
}
}
impl Credentials {
/// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration.
@@ -268,92 +295,133 @@ impl Credentials {
Ok(())
}
/// Returns online profile data when the cached copy is still recent enough.
#[tracing::instrument(skip(self))]
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
let mut profile_cache = PROFILE_CACHE.lock().await;
self.online_profile_with_cache_intent(
OnlineProfileCacheIntent::NormalRead,
)
.await
}
loop {
match profile_cache.entry(self.offline_profile.id) {
Entry::Occupied(entry) => {
match entry.get() {
ProfileCacheEntry::Hit(profile)
if profile.is_fresh() =>
{
return Some(Arc::clone(profile));
}
ProfileCacheEntry::Hit(_) => {
// The profile is stale, so remove it and try again
entry.remove();
continue;
}
// Auth errors must be handled with a backoff strategy because it
// has been experimentally found that Mojang quickly rate limits
// the profile data endpoint on repeated attempts with bad auth
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token,
last_attempt,
} if &self.access_token != likely_expired_token
|| Instant::now()
.saturating_duration_since(*last_attempt)
> std::time::Duration::from_secs(60) =>
{
entry.remove();
continue;
}
ProfileCacheEntry::AuthErrorBackoff { .. } => {
return None;
}
/// Returns profile data recent enough for skin and cape state.
///
/// Reuses a profile read from the last few seconds so opening the skins page
/// does not send several identical Mojang requests.
#[tracing::instrument(skip(self))]
pub async fn online_profile_fresh(&self) -> Option<Arc<MinecraftProfile>> {
self.online_profile_with_cache_intent(
OnlineProfileCacheIntent::LiveStateRead,
)
.await
}
/// Fetches the online profile from Mojang after a skin or cape change.
#[tracing::instrument(skip(self))]
pub async fn refresh_online_profile(
&self,
) -> Option<Arc<MinecraftProfile>> {
self.online_profile_with_cache_intent(
OnlineProfileCacheIntent::RefreshFromMojang,
)
.await
}
async fn online_profile_with_cache_intent(
&self,
cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> {
let max_age = cache_intent.max_age();
let stale_profile = {
let mut profile_cache = PROFILE_CACHE.lock().await;
let mut remove_cached_entry = false;
let stale_profile = if let Some(cache_entry) =
profile_cache.get(&self.offline_profile.id)
{
match cache_entry {
ProfileCacheEntry::Hit(profile)
if profile.is_fresh(max_age) =>
{
return Some(Arc::clone(profile));
}
ProfileCacheEntry::Hit(profile) => {
Some(Arc::clone(profile))
}
// Auth errors must be handled with a backoff strategy because it
// has been experimentally found that Mojang quickly rate limits
// the profile data endpoint on repeated attempts with bad auth
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token,
last_attempt,
} if &self.access_token != likely_expired_token
|| Instant::now()
.saturating_duration_since(*last_attempt)
> ONLINE_PROFILE_AUTH_ERROR_BACKOFF =>
{
remove_cached_entry = true;
None
}
ProfileCacheEntry::AuthErrorBackoff { .. } => {
return None;
}
}
Entry::Vacant(entry) => {
match minecraft_profile(&self.access_token).await {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry =
ProfileCacheEntry::Hit(Arc::clone(&profile));
} else {
None
};
// When fetching a profile for the first time, the player UUID may
// be unknown (i.e., set to a dummy value), so make sure we don't
// cache it in the wrong place
if entry.key() != &profile.id {
profile_cache.insert(profile.id, cache_entry);
} else {
entry.insert(cache_entry);
}
if remove_cached_entry {
profile_cache.remove(&self.offline_profile.id);
}
return Some(profile);
}
Err(
err @ MinecraftAuthenticationError::DeserializeResponse {
status_code: StatusCode::UNAUTHORIZED,
..
},
) => {
tracing::warn!(
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
self.offline_profile.id
);
stale_profile
};
// We have to assume the player UUID key we have is correct here, which
// should always be the case assuming a non-adversarial server. In any
// case, any cache poisoning is inconsequential due to the entry expiration
// and the fact that we use at most one single dummy UUID
entry.insert(ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token: self.access_token.clone(),
last_attempt: Instant::now(),
});
match minecraft_profile(&self.access_token).await {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile));
return None;
}
Err(err) => {
tracing::warn!(
"Failed to fetch online profile for UUID {}: {err}",
self.offline_profile.id
);
let mut profile_cache = PROFILE_CACHE.lock().await;
if self.offline_profile.id != profile.id {
profile_cache.remove(&self.offline_profile.id);
}
profile_cache.insert(profile.id, cache_entry);
return None;
}
}
Some(profile)
}
Err(
err @ MinecraftAuthenticationError::DeserializeResponse {
status_code: StatusCode::UNAUTHORIZED,
..
},
) => {
tracing::warn!(
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
self.offline_profile.id
);
let mut profile_cache = PROFILE_CACHE.lock().await;
profile_cache.insert(
self.offline_profile.id,
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token: self.access_token.clone(),
last_attempt: Instant::now(),
},
);
None
}
Err(err) => {
tracing::warn!(
"Failed to fetch online profile for UUID {}: {err}",
self.offline_profile.id
);
if cache_intent.can_use_stale_on_fetch_error() {
stale_profile
} else {
None
}
}
}
@@ -717,6 +785,8 @@ impl DeviceTokenPair {
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
pub const MINECRAFT_SERVICES_USER_AGENT: &str =
"Modrinth App (support@modrinth.com; https://modrinth.com/app)";
pub struct RequestWithDate<T> {
pub date: DateTime<Utc>,
@@ -1051,6 +1121,7 @@ async fn minecraft_token(
INSECURE_REQWEST_CLIENT
.post("https://api.minecraftservices.com/launcher/login")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.json(&json!({
"platform": "PC_LAUNCHER",
"xtoken": format!("XBL3.0 x={uhs};{token}"),
@@ -1224,10 +1295,10 @@ impl MinecraftProfile {
/// from the Mojang API: the vanilla launcher was seen refreshing profile
/// data every 60 seconds when re-entering the skin selection screen, and
/// external applications may change this data at any time.
fn is_fresh(&self) -> bool {
fn is_fresh(&self, max_age: std::time::Duration) -> bool {
self.fetch_time.is_some_and(|last_profile_fetch_time| {
Instant::now().saturating_duration_since(last_profile_fetch_time)
< std::time::Duration::from_secs(60)
< max_age
})
}
@@ -1279,6 +1350,7 @@ async fn minecraft_profile(
INSECURE_REQWEST_CLIENT
.get("https://api.minecraftservices.com/minecraft/profile")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(token)
// Profiles may be refreshed periodically in response to user actions,
// so we want each refresh to be fast
@@ -1327,12 +1399,13 @@ async fn minecraft_entitlements(
token: &str,
) -> Result<MinecraftEntitlements, MinecraftAuthenticationError> {
let res = auth_retry(|| {
INSECURE_REQWEST_CLIENT
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
.header("Accept", "application/json")
.bearer_auth(token)
.send()
})
INSECURE_REQWEST_CLIENT
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(token)
.send()
})
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
let status = res.status();
@@ -5,81 +5,31 @@ use super::MinecraftSkinVariant;
pub mod mojang_api;
/// Represents the default cape for a Minecraft player.
#[derive(Debug, Clone)]
pub struct DefaultMinecraftCape {
/// The UUID of a cape for a Minecraft player, which comes from its profile.
///
/// This UUID may or may not be different for every player, even if they refer to the same cape.
pub id: Uuid,
}
impl DefaultMinecraftCape {
pub async fn set(
minecraft_user_id: Uuid,
cape_id: Uuid,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
let cape_id = cape_id.as_hyphenated();
sqlx::query!(
"INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
minecraft_user_id, cape_id
)
.execute(&mut *db.acquire().await?)
.await?;
Ok(())
}
pub async fn get(
minecraft_user_id: Uuid,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Self>> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
Ok(sqlx::query_as!(
Self,
"SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.fetch_optional(&mut *db.acquire().await?)
.await?)
}
pub async fn remove(
minecraft_user_id: Uuid,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
sqlx::query!(
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.execute(&mut *db.acquire().await?)
.await?;
Ok(())
}
}
/// Represents a custom skin for a Minecraft player.
/// Represents a saved skin row for a Minecraft player.
///
/// The same player and `texture_key` always point to the same saved skin.
/// Changing the model variant or cape updates that saved skin instead of
/// creating a second copy. Bundled default skins with a cape are also stored
/// here so the cape can stay associated with the default skin card.
#[derive(Debug, Clone)]
pub struct CustomMinecraftSkin {
/// The key for the texture skin, which is akin to a hash that identifies it.
/// The key for the skin texture, which is akin to a hash that identifies it.
pub texture_key: String,
/// The variant of the skin model.
pub variant: MinecraftSkinVariant,
/// The UUID of the cape that this skin uses, which should match one of the
/// cape UUIDs the player has in its profile.
///
/// If `None`, the skin does not have an explicit cape set, and the default
/// cape for this player, if any, should be used.
/// If `None`, the skin is saved without a cape.
pub cape_id: Option<Uuid>,
}
struct CustomMinecraftSkinRow {
texture_key: String,
variant: MinecraftSkinVariant,
cape_id: Option<Hyphenated>,
}
impl CustomMinecraftSkin {
pub async fn add(
minecraft_user_id: Uuid,
@@ -95,24 +45,59 @@ impl CustomMinecraftSkin {
let mut transaction = db.begin().await?;
sqlx::query!(
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
texture_key, texture
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
texture_key
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
minecraft_user_id, texture_key, variant, cape_id
)
.execute(&mut *transaction)
.await?;
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
texture_key, texture
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
minecraft_user_id, texture_key, variant, cape_id
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
pub async fn get_by_texture(
minecraft_user_id: Uuid,
texture_key: &str,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Self>> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
sqlx::query_as!(
CustomMinecraftSkinRow,
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
texture_key
)
.fetch_optional(&mut *db.acquire().await?)
.await?
.map(|row| {
Ok(Self {
texture_key: row.texture_key,
variant: row.variant,
cape_id: row.cape_id.map(Uuid::from),
})
})
.transpose()
}
pub async fn get_many(
minecraft_user_id: Uuid,
offset: u32,
@@ -165,12 +150,11 @@ impl CustomMinecraftSkin {
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
let cape_id = self.cape_id.map(|id| id.hyphenated());
sqlx::query!(
"DELETE FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
minecraft_user_id, self.texture_key, self.variant, cape_id
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
self.texture_key
)
.execute(&mut *db.acquire().await?)
.await?;
@@ -10,7 +10,10 @@ use super::MinecraftSkinVariant;
use crate::{
ErrorKind,
data::Credentials,
state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry},
state::{
MINECRAFT_SERVICES_USER_AGENT, MinecraftProfile, PROFILE_CACHE,
ProfileCacheEntry,
},
util::fetch::INSECURE_REQWEST_CLIENT,
};
@@ -24,12 +27,13 @@ impl MinecraftCapeOperation {
) -> crate::Result<()> {
update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.bearer_auth(&credentials.access_token)
.json(&json!({
"capeId": cape_id.hyphenated(),
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.json(&json!({
"capeId": cape_id.hyphenated(),
}))
.send()
.await
@@ -42,12 +46,13 @@ impl MinecraftCapeOperation {
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Accept", "application/json")
.bearer_auth(&credentials.access_token)
.send()
.await
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.send()
.await
.and_then(|response| response.error_for_status())?
)
.await;
@@ -64,7 +69,7 @@ impl MinecraftSkinOperation {
credentials: &Credentials,
texture: TextureStream,
variant: MinecraftSkinVariant,
) -> crate::Result<()>
) -> crate::Result<Option<Arc<MinecraftProfile>>>
where
TextureStream: TryStream + Send + 'static,
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
@@ -91,12 +96,13 @@ impl MinecraftSkinOperation {
.file_name("skin.png"),
);
update_profile_cache_from_response(
let profile = update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.post(
"https://api.minecraftservices.com/minecraft/profile/skins",
)
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.multipart(form)
.send()
@@ -105,17 +111,18 @@ impl MinecraftSkinOperation {
)
.await;
Ok(())
Ok(profile)
}
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
.header("Accept", "application/json")
.bearer_auth(&credentials.access_token)
.send()
.await
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.send()
.await
.and_then(|response| response.error_for_status())?
)
.await;
@@ -124,19 +131,24 @@ impl MinecraftSkinOperation {
}
}
async fn update_profile_cache_from_response(response: reqwest::Response) {
async fn update_profile_cache_from_response(
response: reqwest::Response,
) -> Option<Arc<MinecraftProfile>> {
let Some(mut profile) = response.json::<MinecraftProfile>().await.ok()
else {
tracing::warn!(
"Failed to parse player profile from skin or cape operation response, not updating profile cache"
);
return;
return None;
};
profile.fetch_time = Some(Instant::now());
let profile = Arc::new(profile);
PROFILE_CACHE
.lock()
.await
.insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile)));
.insert(profile.id, ProfileCacheEntry::Hit(Arc::clone(&profile)));
Some(profile)
}