use base64::Engine; use chrono::{DateTime, TimeDelta, Utc}; use reqwest::Method; use serde::{Deserialize, Serialize}; use crate::error::{McError, McResult}; use oneclient_net::RequestClient; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangPlayerProfile { pub uuid: String, pub username: String, pub is_slim: bool, pub skin_url: Option, pub cape_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct PlayerProfileView { pub uuid: String, pub username: String, pub is_slim: bool, pub skin_url: Option, pub cape_url: Option, pub skins: Vec, pub capes: Vec, } impl PlayerProfileView { pub fn placeholder() -> Self { Self { uuid: "00000000-0000-0000-0000-000000000000".into(), username: "Player".into(), is_slim: false, skin_url: None, cape_url: None, skins: Vec::new(), capes: Vec::new(), } } } impl From for PlayerProfileView { fn from(profile: MojangPlayerProfile) -> Self { Self { uuid: profile.uuid, username: profile.username, is_slim: profile.is_slim, skin_url: profile.skin_url, cape_url: profile.cape_url, skins: Vec::new(), capes: Vec::new(), } } } impl From for PlayerProfileView { fn from(profile: MojangFullPlayerProfile) -> Self { let active_skin = profile .skins .iter() .find(|skin| skin.state.eq_ignore_ascii_case("ACTIVE")); let active_cape = profile .capes .iter() .find(|cape| cape.state.eq_ignore_ascii_case("ACTIVE")); Self { uuid: profile.id, username: profile.username, is_slim: active_skin.is_some_and(|skin| skin.variant == SkinVariant::Slim), skin_url: active_skin.map(|skin| skin.url.clone()), cape_url: active_cape.map(|cape| cape.url.clone()), skins: profile.skins, capes: profile.capes, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SkinVariant { #[serde(alias = "CLASSIC")] Classic, #[serde(alias = "SLIM")] Slim, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangFullPlayerProfile { pub id: String, #[serde(alias = "name")] pub username: String, pub skins: Vec, pub capes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangSkin { pub id: String, pub state: String, pub url: String, pub variant: SkinVariant, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangCape { pub id: String, pub state: String, pub url: String, pub alias: String, } fn normalize_texture_url(url: &str) -> String { url.replace( "http://textures.minecraft.net", "https://textures.minecraft.net", ) } fn normalize_uuid(uuid: &str) -> &str { uuid.trim() .trim_matches(|c: char| c == '-' || c.is_whitespace()) } fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { [("Authorization", format!("Bearer {access_token}"))] } #[tracing::instrument(level = "debug", skip(client))] pub async fn fetch_player_profile( client: &RequestClient, uuid: &str, ) -> McResult { #[derive(Deserialize)] struct Properties { name: String, value: String, } #[derive(Deserialize)] struct Response { id: String, name: String, properties: Vec, } #[derive(Default, Deserialize)] #[serde(default)] struct TextureMetadata { model: Option, } #[derive(Deserialize)] struct Texture { url: String, #[serde(default)] metadata: TextureMetadata, } #[derive(Deserialize)] #[serde(rename_all = "UPPERCASE")] struct Textures { skin: Option, cape: Option, } #[derive(Deserialize)] struct DecodedProperties { textures: Textures, } let uuid = normalize_uuid(uuid); let url = format!("https://sessionserver.mojang.com/session/minecraft/profile/{uuid}") .parse() .map_err(McError::Url)?; let response = client .send_as::(reqwest::Request::new(Method::GET, url)) .await?; let texture_property = response .properties .iter() .find(|p| p.name == "textures") .ok_or_else(|| McError::Minecraft("no textures in player profile".into()))?; let decoded = base64::prelude::BASE64_STANDARD .decode(texture_property.value.as_bytes()) .map_err(|_| McError::Minecraft("failed to decode profile texture property".into()))?; let decoded = serde_json::from_slice::(&decoded) .map_err(McError::ParseError)? .textures; let is_slim = decoded .skin .as_ref() .is_some_and(|s| s.metadata.model.as_deref() == Some("slim")); Ok(MojangPlayerProfile { uuid: response.id, username: response.name, is_slim, skin_url: decoded.skin.as_ref().map(|s| normalize_texture_url(&s.url)), cape_url: decoded.cape.as_ref().map(|c| normalize_texture_url(&c.url)), }) } #[tracing::instrument(level = "debug", skip(client, access_token))] pub async fn fetch_logged_in_profile( client: &RequestClient, access_token: &str, ) -> McResult { let url = "https://api.minecraftservices.com/minecraft/profile" .parse() .map_err(McError::Url)?; let headers = auth_headers(access_token); let header_refs: Vec<(&str, &str)> = headers .iter() .map(|(name, value)| (*name, value.as_str())) .collect(); let mut profile = client .send_json::(Method::GET, url, None, &header_refs) .await?; for skin in &mut profile.skins { skin.url = normalize_texture_url(&skin.url); } for cape in &mut profile.capes { cape.url = normalize_texture_url(&cape.url); } Ok(profile) } /// Served without revalidating so cold start doesn't wait on a Mojang round /// trip before the first avatar paints const PROFILE_CACHE_TTL_HOURS: i64 = 6; #[derive(Debug, Serialize, Deserialize)] struct CachedProfile { fetched_at: DateTime, view: PlayerProfileView, } impl CachedProfile { fn is_fresh(&self) -> bool { Utc::now() - self.fetched_at < TimeDelta::hours(PROFILE_CACHE_TTL_HOURS) } } async fn fetch_profile_view( client: &RequestClient, uuid: &str, access_token: Option<&str>, ) -> McResult { if let Some(token) = access_token.filter(|t| !t.is_empty()) { let full = fetch_logged_in_profile(client, token).await?; return Ok(PlayerProfileView::from(full)); } let summary = fetch_player_profile(client, uuid).await?; Ok(PlayerProfileView::from(summary)) } #[tracing::instrument(level = "debug", skip(client, access_token))] pub async fn fetch_player_profile_view( client: &RequestClient, uuid: &str, access_token: Option<&str>, ) -> McResult { let authenticated = access_token.is_some_and(|t| !t.is_empty()); if !authenticated && let Some(cached) = read_cached_profile(uuid).await { if cached.is_fresh() { return Ok(cached.view); } return match fetch_profile_view(client, uuid, access_token).await { Ok(view) => { write_cached_profile(uuid, &view).await; Ok(view) } Err(err) => { tracing::warn!("serving stale cached profile for {uuid}: {err}"); Ok(cached.view) } }; } match fetch_profile_view(client, uuid, access_token).await { Ok(view) => { if !authenticated { write_cached_profile(uuid, &view).await; } Ok(view) } Err(err) => match read_cached_profile(uuid).await { Some(cached) => { tracing::warn!("using cached player profile for {uuid}: {err}"); Ok(cached.view) } None => Err(err), }, } } fn profile_cache_path(uuid: &str) -> Option { let key = polyio::sha1_bytes(normalize_uuid(uuid).as_bytes()); Some( oneclient_common::paths::profiles_cache_dir() .ok()? .join(format!("{key}.json")), ) } async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { let Some(path) = profile_cache_path(uuid) else { return; }; let entry = CachedProfile { fetched_at: Utc::now(), view: view.clone(), }; let Ok(bytes) = serde_json::to_vec(&entry) else { return; }; if let Some(parent) = path.parent() { let _ = polyio::create_dir_all(parent).await; } if let Err(err) = polyio::write(&path, &bytes).await { tracing::warn!("failed to cache player profile for {uuid}: {err}"); } } async fn read_cached_profile(uuid: &str) -> Option { let path = profile_cache_path(uuid)?; let bytes = polyio::read(&path).await.ok()?; if let Ok(entry) = serde_json::from_slice::(&bytes) { return Some(entry); } let view = serde_json::from_slice::(&bytes).ok()?; Some(CachedProfile { fetched_at: DateTime::UNIX_EPOCH, view, }) } #[cfg(test)] mod tests { use super::*; fn temp_launcher_dir() { let dir = std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); oneclient_common::paths::set_launcher_dir(dir); } fn sample_view(username: &str) -> PlayerProfileView { PlayerProfileView { uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5".into(), username: username.into(), is_slim: true, skin_url: Some("https://textures.minecraft.net/texture/abc".into()), cape_url: None, skins: Vec::new(), capes: Vec::new(), } } #[tokio::test] async fn fresh_cache_entry_is_served_without_network() { temp_launcher_dir(); let uuid = uuid::Uuid::new_v4().to_string(); let view = sample_view("Cached"); write_cached_profile(&uuid, &view).await; let client = RequestClient::new(oneclient_net::NetConfig::default()).unwrap(); let resolved = fetch_player_profile_view(&client, &uuid, None) .await .expect("fresh cache entry should resolve offline"); assert_eq!(resolved, view); } #[tokio::test] async fn entry_past_ttl_is_not_fresh() { let stale = CachedProfile { fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS + 1), view: sample_view("Stale"), }; assert!(!stale.is_fresh()); let recent = CachedProfile { fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS - 1), view: sample_view("Recent"), }; assert!(recent.is_fresh()); } #[tokio::test] async fn legacy_cache_entry_loads_and_revalidates() { temp_launcher_dir(); let uuid = uuid::Uuid::new_v4().to_string(); let view = sample_view("Legacy"); let path = profile_cache_path(&uuid).unwrap(); polyio::create_dir_all(path.parent().unwrap()) .await .unwrap(); polyio::write(&path, &serde_json::to_vec(&view).unwrap()) .await .unwrap(); let cached = read_cached_profile(&uuid) .await .expect("legacy entry should still parse"); assert_eq!(cached.view, view); assert!( !cached.is_fresh(), "legacy entry should be due for revalidation" ); } }