OneLauncher-Cracked/packages/oneclient_mc/src/profile.rs

429 lines
12 KiB
Rust

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<String>,
pub cape_url: Option<String>,
}
#[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<String>,
pub cape_url: Option<String>,
pub skins: Vec<MojangSkin>,
pub capes: Vec<MojangCape>,
}
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<MojangPlayerProfile> 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<MojangFullPlayerProfile> 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<MojangSkin>,
pub capes: Vec<MojangCape>,
}
#[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<MojangPlayerProfile> {
#[derive(Deserialize)]
struct Properties {
name: String,
value: String,
}
#[derive(Deserialize)]
struct Response {
id: String,
name: String,
properties: Vec<Properties>,
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct TextureMetadata {
model: Option<String>,
}
#[derive(Deserialize)]
struct Texture {
url: String,
#[serde(default)]
metadata: TextureMetadata,
}
#[derive(Deserialize)]
#[serde(rename_all = "UPPERCASE")]
struct Textures {
skin: Option<Texture>,
cape: Option<Texture>,
}
#[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::<Response>(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::<DecodedProperties>(&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<MojangFullPlayerProfile> {
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::<MojangFullPlayerProfile>(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<Utc>,
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<PlayerProfileView> {
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<PlayerProfileView> {
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<std::path::PathBuf> {
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<CachedProfile> {
let path = profile_cache_path(uuid)?;
let bytes = polyio::read(&path).await.ok()?;
if let Ok(entry) = serde_json::from_slice::<CachedProfile>(&bytes) {
return Some(entry);
}
let view = serde_json::from_slice::<PlayerProfileView>(&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"
);
}
}