From 5db3222330451cf5bdb7ae5b8202b50446bcdd06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 17 Apr 2025 19:39:28 +0200 Subject: [PATCH 1/5] feat(app): skin selector backend --- Cargo.lock | 11 + apps/app/build.rs | 16 + apps/app/capabilities/plugins.json | 1 + apps/app/src/api/minecraft_skins.rs | 82 +++ apps/app/src/api/mod.rs | 1 + apps/app/src/main.rs | 1 + ...e02a39bb04b45dbd727e5db5b5308deda4e04.json | 12 + ...6dd3eed86ba421c83e74fe284609a8020bd22.json | 12 + ...fbdbd066d51f88cd2bcfed613f756edbd2944.json | 12 + ...22546583aa19ea7088682d718c64ed5d5f1c5.json | 20 + ...b712aba58908a66dd7bbd64c293b9ee7a1523.json | 12 + ...ae58e2d7a414e76906700518806e494cd0246.json | 20 + ...3ae1b3d75e80e52529e491e86ff370d6424b3.json | 12 + ...68169cfd8226b57bacd8c270d7777fc6883ac.json | 32 ++ packages/app-lib/Cargo.toml | 7 +- .../20250413162050_skin-selector.sql | 50 ++ packages/app-lib/src/api/minecraft_skins.rs | 496 ++++++++++++++++++ .../assets/default/MissingNo.png | Bin 0 -> 435 bytes .../assets/default/default_skins.rs | 213 ++++++++ packages/app-lib/src/api/mod.rs | 1 + packages/app-lib/src/error.rs | 18 +- packages/app-lib/src/state/minecraft_auth.rs | 103 ++-- .../app-lib/src/state/minecraft_skins/mod.rs | 179 +++++++ .../src/state/minecraft_skins/mojang_api.rs | 142 +++++ packages/app-lib/src/state/mod.rs | 2 + 25 files changed, 1418 insertions(+), 37 deletions(-) create mode 100644 apps/app/src/api/minecraft_skins.rs create mode 100644 packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json create mode 100644 packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json create mode 100644 packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json create mode 100644 packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json create mode 100644 packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json create mode 100644 packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json create mode 100644 packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json create mode 100644 packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json create mode 100644 packages/app-lib/migrations/20250413162050_skin-selector.sql create mode 100644 packages/app-lib/src/api/minecraft_skins.rs create mode 100644 packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png create mode 100644 packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs create mode 100644 packages/app-lib/src/state/minecraft_skins/mod.rs create mode 100644 packages/app-lib/src/state/minecraft_skins/mojang_api.rs diff --git a/Cargo.lock b/Cargo.lock index 53f8116402..2aac10a1d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1975,6 +1975,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + [[package]] name = "dbus" version = "0.9.7" @@ -8089,6 +8095,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid 1.16.0", "webpki-roots 0.26.11", ] @@ -8172,6 +8179,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.16.0", "whoami", ] @@ -8211,6 +8219,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.16.0", "whoami", ] @@ -8237,6 +8246,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", + "uuid 1.16.0", ] [[package]] @@ -8992,6 +9002,7 @@ dependencies = [ "chrono", "daedalus", "dashmap", + "data-url", "dirs", "discord-rich-presence", "dunce", diff --git a/apps/app/build.rs b/apps/app/build.rs index 4fe82cfbc4..d415d2c2f2 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -99,6 +99,22 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "minecraft-skins", + InlinedPlugin::new() + .commands(&[ + "get_available_capes", + "get_available_skins", + "add_and_equip_custom_skin", + "set_default_cape", + "equip_skin", + "remove_custom_skin", + "unequip_skin", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "mr-auth", InlinedPlugin::new() diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index b9777b6d9f..f3e946eff0 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -25,6 +25,7 @@ "jre:default", "logs:default", "metadata:default", + "minecraft-skins:default", "mr-auth:default", "profile-create:default", "pack:default", diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs new file mode 100644 index 0000000000..42ff5ff340 --- /dev/null +++ b/apps/app/src/api/minecraft_skins.rs @@ -0,0 +1,82 @@ +use crate::api::Result; + +use theseus::minecraft_skins::{self, Bytes, Cape, MinecraftSkinVariant, Skin}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("minecraft-skins") + .invoke_handler(tauri::generate_handler![ + get_available_capes, + get_available_skins, + add_and_equip_custom_skin, + set_default_cape, + equip_skin, + remove_custom_skin, + unequip_skin, + ]) + .build() +} + +/// `invoke('plugin:minecraft-skins|get_available_capes')` +/// +/// See also: [minecraft_skins::get_available_capes] +#[tauri::command] +pub async fn get_available_capes() -> Result> { + Ok(minecraft_skins::get_available_capes().await?) +} + +/// `invoke('plugin:minecraft-skins|get_available_skins')` +/// +/// See also: [minecraft_skins::get_available_skins] +#[tauri::command] +pub async fn get_available_skins() -> Result> { + Ok(minecraft_skins::get_available_skins().await?) +} + +/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)` +/// +/// See also: [minecraft_skins::add_and_equip_custom_skin] +#[tauri::command] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> Result<()> { + Ok(minecraft_skins::add_and_equip_custom_skin( + texture_blob, + variant, + cape_override, + ) + .await?) +} + +/// `invoke('plugin:minecraft-skins|set_default_cape', cape)` +/// +/// See also: [minecraft_skins::set_default_cape] +#[tauri::command] +pub async fn set_default_cape(cape: Option) -> Result<()> { + Ok(minecraft_skins::set_default_cape(cape).await?) +} + +/// `invoke('plugin:minecraft-skins|equip_skin', skin)` +/// +/// See also: [minecraft_skins::equip_skin] +#[tauri::command] +pub async fn equip_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::equip_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)` +/// +/// See also: [minecraft_skins::remove_custom_skin] +#[tauri::command] +pub async fn remove_custom_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::remove_custom_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|unequip_skin')` +/// +/// See also: [minecraft_skins::unequip_skin] +#[tauri::command] +pub async fn unequip_skin() -> Result<()> { + Ok(minecraft_skins::unequip_skin().await?) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 09d37e87ab..294e784f64 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod import; pub mod jre; pub mod logs; pub mod metadata; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 4291431df8..3cfe87e961 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -249,6 +249,7 @@ fn main() { .plugin(api::logs::init()) .plugin(api::jre::init()) .plugin(api::metadata::init()) + .plugin(api::minecraft_skins::init()) .plugin(api::pack::init()) .plugin(api::process::init()) .plugin(api::profile::init()) diff --git a/packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json b/packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json new file mode 100644 index 0000000000..7572f50bad --- /dev/null +++ b/packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04" +} diff --git a/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json new file mode 100644 index 0000000000..26c250c785 --- /dev/null +++ b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22" +} diff --git a/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json new file mode 100644 index 0000000000..cf3645df13 --- /dev/null +++ b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944" +} diff --git a/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json new file mode 100644 index 0000000000..f34447870a --- /dev/null +++ b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + "describe": { + "columns": [ + { + "name": "texture", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5" +} diff --git a/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json new file mode 100644 index 0000000000..ee92d633c4 --- /dev/null +++ b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523" +} diff --git a/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json new file mode 100644 index 0000000000..2c946cb4ec --- /dev/null +++ b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [ + { + "name": "id: Hyphenated", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246" +} diff --git a/packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json b/packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json new file mode 100644 index 0000000000..f8365f0f44 --- /dev/null +++ b/packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3" +} diff --git a/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json new file mode 100644 index 0000000000..4d0c3892f3 --- /dev/null +++ b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "texture_key", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "variant: MinecraftSkinVariant", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "cape_id: Hyphenated", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac" +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index fc76b3f7f8..3737b1c8c8 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Jai A "] edition = "2024" [dependencies] -bytes = "1" +bytes = { version = "1", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_ini = "0.2.0" @@ -30,6 +30,7 @@ sys-info = "0.9.0" sysinfo = "0.35.0" thiserror = "2.0.12" either = "1.13" +data-url = "0.3.1" tracing = "0.1.37" tracing-subscriber = { version = "0.3.18", features = ["chrono", "env-filter"] } @@ -43,7 +44,7 @@ indicatif = { version = "0.17.3", optional = true } async-tungstenite = { version = "0.29.1", features = ["tokio-runtime", "tokio-rustls-webpki-roots"] } futures = "0.3" -reqwest = { version = "0.12.15", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false } +reqwest = { version = "0.12.15", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration", "multipart"], default-features = false } tokio = { version = "1", features = ["full"] } tokio-util = "0.7" async-recursion = "1.0.4" @@ -66,7 +67,7 @@ rand = "0.8" byteorder = "1.5.0" base64 = "0.22.1" -sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] } +sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros", "uuid" ] } quartz_nbt = { version = "0.2", features = ["serde"] } hickory-resolver = "0.25" diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql new file mode 100644 index 0000000000..5829ee33dd --- /dev/null +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -0,0 +1,50 @@ +CREATE TABLE default_minecraft_capes ( + minecraft_user_uuid TEXT NOT NULL, + id TEXT NOT NULL, + + PRIMARY KEY (minecraft_user_uuid, id), + FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE custom_minecraft_skins ( + minecraft_user_uuid TEXT NOT NULL, + texture_key TEXT NOT NULL, + variant TEXT NOT NULL CHECK (variant IN ('CLASSIC', 'SLIM', 'UNKNOWN')), + cape_id TEXT, + + PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), + FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE custom_minecraft_skin_textures ( + texture_key TEXT NOT NULL, + texture PNG BLOB NOT NULL, + + PRIMARY KEY (texture_key) +); + +-- Use triggers to emulate partial cascading foreign key constraints on the custom_minecraft_skin_textures table + +CREATE TRIGGER custom_minecraft_skin_texture_insertion_validation + BEFORE INSERT ON custom_minecraft_skin_textures FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM custom_minecraft_skins WHERE texture_key = NEW.texture_key + ) THEN RAISE(ABORT, 'Missing custom skin for the specified skin texture key') END; + END; + +CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup + AFTER DELETE ON custom_minecraft_skins FOR EACH ROW + BEGIN + DELETE FROM custom_minecraft_skin_textures WHERE texture_key NOT IN ( + SELECT texture_key FROM custom_minecraft_skins + ); + END; + +CREATE TRIGGER custom_minecraft_skin_texture_update_cleanup + AFTER UPDATE OF texture_key ON custom_minecraft_skins FOR EACH ROW + BEGIN + UPDATE custom_minecraft_skin_textures SET texture_key = NEW.texture_key WHERE texture_key = OLD.texture_key; + END; diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs new file mode 100644 index 0000000000..bb2646ae94 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -0,0 +1,496 @@ +//! Theseus skin management interface + +use std::{ + borrow::Cow, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use base64::Engine; +pub use bytes::Bytes; +use data_url::DataUrl; +use futures::{Stream, StreamExt, TryStreamExt, future::Either, stream}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +pub use crate::state::MinecraftSkinVariant; +use crate::{ + ErrorKind, State, + state::{ + MinecraftCharacterExpressionState, MinecraftProfile, + minecraft_skins::{ + CustomMinecraftSkin, DefaultMinecraftCape, mojang_api, + }, + }, + util::fetch::REQWEST_CLIENT, +}; + +use super::data::Credentials; + +mod assets { + mod default { + mod default_skins; + pub use default_skins::DEFAULT_SKINS; + } + pub use default::DEFAULT_SKINS; +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Cape { + /// An identifier for this cape, potentially unique to the owning player. + pub id: Uuid, + /// The name of the cape. + pub name: Arc, + /// The URL of the cape PNG texture. + pub texture: Arc, + /// Whether the cape is the default one, used when the currently selected cape does not + /// override it. + pub is_default: bool, + /// Whether the cape is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Skin { + /// An opaque identifier for the skin texture, which can be used to identify it. + pub texture_key: Arc, + /// The name of the skin, if available. + pub name: Option>, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, if any. + /// + /// If `None`, the skin does not have an explicit cape set, and the default cape for + /// this player, if any, should be used. + pub cape_id: Option, + /// The URL of the skin PNG texture. Can also be a data URL. + pub texture: Arc, + /// The source of the skin, which represents how the app knows about it. + pub source: SkinSource, + /// Whether the skin is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +impl Skin { + /// Resolves the skin texture URL to a stream of bytes. + pub async fn resolve_texture( + &self, + ) -> crate::Result> + use<>> + { + if self.texture.scheme() == "data" { + let data = DataUrl::process(self.texture.as_str())? + .decode_to_vec()? + .0 + .into(); + + Ok(Either::Left(stream::once(async { Ok(data) }))) + } else { + let response = REQWEST_CLIENT + .get(self.texture.as_str()) + .header("Accept", "image/png") + .send() + .await + .and_then(|response| response.error_for_status())?; + + Ok(Either::Right(response.bytes_stream())) + } + } +} + +#[derive(Deserialize, Serialize, Debug)] +pub enum SkinSource { + /// A default Minecraft skin, which may be assigned to players at random by default. + Default, + /// A skin that is not the default, but is not a custom skin managed by our app either. + CustomExternal, + /// A custom skin we have set up in our app. + Custom, +} + +/// Retrieves the available capes for the currently selected Minecraft profile. At most one cape +/// can be equipped at a time. Also, at most one cape can be set as the default cape. +#[tracing::instrument] +pub async fn get_available_capes() -> crate::Result> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + Ok(profile + .capes + .iter() + .map(|cape| Cape { + id: cape.id, + name: Arc::clone(&cape.name), + texture: Arc::clone(&cape.url), + is_default: default_cape_id + .is_some_and(|default_cape_id| default_cape_id == cape.id), + is_equipped: cape.state + == MinecraftCharacterExpressionState::Active, + }) + .collect()) +} + +/// Retrieves the available skins for the currently selected Minecraft profile. At the moment, +/// this includes custom skins stored in the app database, default Mojang skins, and the currently +/// equipped skin, if different from the previous skins. Exactly one of the returned skins is +/// marked as equipped. +#[tracing::instrument] +pub async fn get_available_skins() -> crate::Result> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let current_skin = profile.current_skin()?; + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + // Keep track of whether we have found the currently equipped skin, to potentially avoid marking + // several skins as equipped, and know if the equipped skin was found (see below) + let found_equipped_skin = Arc::new(AtomicBool::new(false)); + + let custom_skins = CustomMinecraftSkin::get_all(profile.id, &state.pool) + .await? + .then(|custom_skin| { + let found_equipped_skin = Arc::clone(&found_equipped_skin); + let state = Arc::clone(&state); + async move { + // Several custom skins may reuse the same texture for different cape or skin model + // variations, so check all attributes for correctness + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && custom_skin.texture_key == *current_skin.texture_key() + && custom_skin.variant == current_skin.variant + && custom_skin.cape_id + == if custom_skin.cape_id.is_some() { + current_cape_id + } else { + default_cape_id + }; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + name: None, + variant: custom_skin.variant, + cape_id: custom_skin.cape_id, + texture: texture_blob_to_data_url( + custom_skin.texture_blob(&state.pool).await?, + ), + source: SkinSource::Custom, + is_equipped, + texture_key: custom_skin.texture_key.into(), + }) + } + }); + + let default_skins = + stream::iter(assets::DEFAULT_SKINS.iter().map(|default_skin| { + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && default_skin.texture_key == current_skin.texture_key() + && default_skin.variant == current_skin.variant; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + texture_key: Arc::clone(&default_skin.texture_key), + name: default_skin.name.as_ref().cloned(), + variant: default_skin.variant, + cape_id: None, + texture: Arc::clone(&default_skin.texture), + source: SkinSource::Default, + is_equipped, + }) + })); + + let mut available_skins = custom_skins + .chain(default_skins) + .try_collect::>() + .await?; + + // If the currently equipped skin does not match any of the skins we know about, + // add it to the list of available skins as a custom external skin, set by an + // external service (e.g., the Minecraft launcher or website). This way we guarantee + // that the currently equipped skin is always returned as available + if !found_equipped_skin.load(Ordering::Acquire) { + available_skins.push(Skin { + texture_key: current_skin.texture_key(), + name: current_skin.name.as_deref().map(Arc::from), + variant: current_skin.variant, + cape_id: current_cape_id, + texture: Arc::clone(¤t_skin.url), + source: SkinSource::CustomExternal, + is_equipped: true, + }); + } + + Ok(available_skins) +} + +/// Adds a custom skin to the app database and equips it for the currently selected +/// Minecraft profile. +#[tracing::instrument] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> crate::Result<()> { + let cape_override = cape_override.map(|cape| cape.id); + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + // We have to equip the skin first, as it's the Mojang API backend who knows + // how to compute the texture key we require, which we can then read from the + // updated player profile. This also ensures the skin data is indeed valid + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), + variant, + ) + .await?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + sync_cape(&state, &selected_credentials, &profile, cape_override).await?; + + CustomMinecraftSkin::add( + profile.id, + &profile.current_skin()?.texture_key(), + &texture_blob, + variant, + cape_override, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Sets the default cape for the currently selected Minecraft profile. If `None`, +/// the default cape will be removed. +/// +/// This cape will be used by any custom skin that does not have a cape override +/// set. If the currently equipped skin does not have a cape override set, the equipped +/// cape will also be changed to the new default cape. When neither the equipped skin +/// defines a cape override nor the default cape is set, the player will have no +/// cape equipped. +#[tracing::instrument] +pub async fn set_default_cape(cape: Option) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + let current_skin = get_available_skins() + .await? + .into_iter() + .find(|skin| skin.is_equipped) + .unwrap(); + + if let Some(cape) = cape { + // Synchronize the equipped cape with the new default cape, if the current skin uses + // the default cape + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::equip( + &selected_credentials, + cape.id, + ) + .await?; + } + + DefaultMinecraftCape::set(profile.id, cape.id, &state.pool).await?; + } else { + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::unequip_any( + &selected_credentials, + ) + .await?; + } + + DefaultMinecraftCape::remove(profile.id, &state.pool).await?; + } + + Ok(()) +} + +/// Equips the given skin for the currently selected Minecraft profile. If the skin is already +/// equipped, it will be re-equipped. +/// +/// This function does not check that the passed skin, if custom, exists in the app database, +/// giving the caller complete freedom to equip any skin at any time. +#[tracing::instrument] +pub async fn equip_skin(skin: Skin) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + skin.resolve_texture().await?, + skin.variant, + ) + .await?; + + sync_cape(&state, &selected_credentials, &profile, skin.cape_id).await?; + + Ok(()) +} + +/// Removes a custom skin from the app database. +/// +/// The player will continue to be equipped with the same skin and cape as before, even if +/// the currently selected skin is the one being removed. This gives frontend code more options +/// to decide between unequipping strategies: falling back to other custom skin, to a default +/// skin, letting the user choose another skin, etc. +#[tracing::instrument] +pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + CustomMinecraftSkin { + texture_key: skin.texture_key.to_string(), + variant: skin.variant, + cape_id: skin.cape_id, + } + .remove( + selected_credentials.maybe_online_profile().await.id, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting +/// it to one of the default skins. The cape will be set to the default cape, or unequipped if +/// no default cape is set. +#[tracing::instrument] +pub async fn unequip_skin() -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::unequip_any(&selected_credentials) + .await?; + + sync_cape(&state, &selected_credentials, &profile, None).await?; + + Ok(()) +} + +/// Synchronizes the equipped cape with the selected cape if necessary, taking into +/// account the currently equipped cape, the default cape for the player, and if a +/// cape override is provided. +async fn sync_cape( + state: &State, + selected_credentials: &Credentials, + profile: &MinecraftProfile, + cape_override: Option, +) -> crate::Result<()> { + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let target_cape_id = match cape_override { + Some(cape_id) => Some(cape_id), + None => DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id), + }; + + if current_cape_id != target_cape_id { + match target_cape_id { + Some(cape_id) => { + mojang_api::MinecraftCapeOperation::equip( + selected_credentials, + cape_id, + ) + .await? + } + None => { + mojang_api::MinecraftCapeOperation::unequip_any( + selected_credentials, + ) + .await? + } + } + } + + Ok(()) +} + +fn texture_blob_to_data_url(texture_blob: Option>) -> Arc { + let data = texture_blob.map_or( + Cow::Borrowed( + &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], + ), + Cow::Owned, + ); + + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(data) + )) + .unwrap() + .into() +} diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png new file mode 100644 index 0000000000000000000000000000000000000000..54d69181d88c040d569fab593a962fedfa1e183a GIT binary patch literal 435 zcmV;k0ZjghP)2HGIUkZ?5 zq7;WLfl_p-cGC9=BvDb20%Vz`6Y~V$!dXxT(zXmar=uY2ngAs-(*--GJr?f;k*C1q zI*D~6Oi6a2CTQKqbh0gWTWG?PIj{}^l&^_U>-JutD1r*C0ZO38^k_VLR?lT7^!Oc5|o^c%(-JgQ?rJ1 daK``. +pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| { + vec![Skin { + texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJklEQVR4Xu2aP2sUURTF/RRir2BAsQyC4GJjaSwEg1iJqRSDgiBooSIWoggiYm0l2KiNiKCNhaWFlYVWVhYWfoAxZ8xZfnvyJtmdkN2dMBcO7++8fefc+2Zn3ps9e7awX48G1e/ng0rpjztHRlJD7a5zXqmQ43XOTMQQOSPrd7UAf18u10jCTRHiuhyvc2ZCTR5mPUVwW47XOaP3GQXON4ljMXK8zll61eRc/vn5RfXt3eOhEFwau0IAk7bXGe6CBDAkhGExcrzOmUgy7Bn+jgDh99fXI3B9jjd3lqFd8jKJu815Lgf3Yx3Hz7LyOZ+pmwmWSCbZJMd6lykCyymAr835TN048SSek1bq0E7i7M/ooLAeg7+X85m6pdecFkVYW9t/vn8arvOSCKxznoQpylzcJD0pkh/x8hpR2f4LB2rygvIyi1DyMoUkYfdT/VwIkF7mHd2EN4OFoggUIAlTpLkQwJMcPuxsRv7Dm/9YL/MvzySV2tMkTO/z93I+Uzd6ww8x+b++QYh18nlDJHGP6ehi3m1zIUDa1VdXKmJxcbHGwsJCjeyf9vDjg+r++3s1OYPEc/xEjpem8Q06I/u1tpyQiVuI7J9mAQR5ebi01kXwuOePHRxBWwFEXlGb/VpbTrBtBNy+e27kncAilMhThBwvLQXYkQjgpNpEgMjz5YjLIcffrgDbjgB5ywPefHujunjicHXt1GJ16+zxOm84MhzeBidE7/MmqQmqjktrK2gupfETTX2SZ6OJnH7M5C+fPFKTF5SnCIK95bJ+zNd7Qk+fXRr5m1S+JICuKdWRVArtPOvTIUqTZ6PJ2yIq2PMmr1R1FCD7UQBPXqlFSPIkndeZ5NMvT4ap8yyX6vLa5NloJkKiJG8xSiJZAJKxByxCkncbyyScqa5vg+TZaAxzhr8FYIQICn+WSSYnrzTJUoCSF+llgQ9khO8vCbcnz0ZbXl6uDIa4iauOfYSVlZUayjOMk0AdBWsp12aKUurPcfIJlH99FIL3GyF5NprJmJAJlgiXQELpzSREAQy2u8z6JJ4ilMhPJECJ4Orq6oY6izIYDKqlpaUaypMQiSgvUZKQ692HQrluqgIkoQzxbFfqyFB+39G9Qxw6s7Dhbm/CJqY+vIZiZKq2JJ0CcCm0EoChTpIlwu7DsgmYsAgmSYtjgdg/2wnVJ+kSeUbCxAKYLEl5CSTxFEiQp3xnF/KBJFP3cfgT9jzbk7jJ580vRUiejabne5EysVzzJi34fYBlT5Tk9M9x/fTREaiO4vCaFIJI8ikC01YR0FtvvfW20+bdIG6Jc5uMcFuO0XnL8wLXc7doVxKX2fu7luBW5tDP+nFMewqJfPdPqE+OM1NrS7633nrrrbfeeuuWeU9QaHO4qmcGb4Z0coODAgiTHq/7XUEC6FB14i2uWVseeU8aAX5n6GwEpACTRoAE8BLoRATw5NjwuSLPHQ2JUtoI1QapX5ezLTFXb5VJ0ALwcDUPWRkh3CmmALl7zH5Kcx4zMxL3wWqeMpfg43gR4nkiy1nHNOcxM+NROb8dYPjnNwXsk+/y48K/n/VG7iHkXgI5bMtSgCRrTztCEnm+n6c9JahPzmNmxuMzLoNxvi/QqZMJlU53mqA+OY+ZWekoLc8V85id/enVccgbOY+ZWRJUeZLvCzovQBLiAWuJsFJHhvK55pNoE3IeMzOGOkmWCJeQAowjwlzdA0yWpDb7viBB4rwZJmmSn6t/gc2+LxAcCULp+wKu/63I70QE/AMDdqWZ7rX6YgAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFQElEQVR4Xu2av2scRxzF9VeY9DZYEKPyEBh8pHFpuQhEhFTBqhIiEjAY4iI2JkVIMBgRUrsypHHShGCIGxcuXbhyYVepVKTwH7DR2+hzvHs7e9KelNWt2AeP+bGzc/PefGfvdubW1o7A3z9Nq/1fppXSt/c25lKo69SRVypmf4MDQqDEwaw/1wa8f7JdMwW3RQh12d/ggKC2GfZ6N4Fr2d/g4LPvUUC+zRzMyP4Gh5xVxFF+9+Jx9fqPhzMjfGmcCwMQzax7uIsyAMoIiBnZ3+AgkR72Hv5EgLj/6ukcqc/+Vg4Z2qVZduFcI+/LgXZe5/1nWfkcT+9AYElkik1xXk/ZTfByGsC9OZ7e4QNP4TlopYR2Cvf2Hh1uLH345+V4ekfOGmnRhIO1/c+b57N1XjLB68i7YDdlJR6SDMrFz83ygVDh4ueXavGi8gImlGbZjXTBtFP9ShiQs+xPdAQvIka5CW5ACnaTVsIABjn7sbNI/LPf/uNh2b/yEKmUmXbBPvv+eTme3uGzwY+Y/F5vGHEoPh+ILpw+iS7Pc20lDEh8/etXlXMymdRcX1+vme0TP/71Q/X9nw9qcdCFZ//J7C+h/p05Kdm+M3JACMeIbJ/AAFGzPFtahybQ72dXL8/xpAZIvKI323dGDnDZCPju/qdz7wSYUBLvJmR/iTYDTjUCfFDLRIDE+8uRL4fs/7QMWDoCNFt09u3vd6pbH12pvrkxqe5+cq3OQyKD8IY5IGbfH5IamOp8aR1FjaXUf4mL2qXeBiROH4b4L69v1OJF5d0EkdmirA/hfgaz9/MXc1+TypcM0D2lOhdUMpqyXytNjNLU24BmW0JFZh7xSlXnBmQ7N4DBK8WEFO+i8z4E7r18NEvJe7mt3k2inHobQIgLdfGYUTIJA1wMzmNCiueal11wprr/JEy9DXiYe/hjgEeIqPD3sovJwStNsW5AaQZzlv0HWZJnTNLbpN4Gtre3K+ghjnDVeRtxZ2enpvIeximgjoKD1NdkmlJq7/3411wyjfBnDky9DSAGQQgsCS7RBeVspiA3APp1yl6fopMYUBJ/LANKAnd3dxt1mDKdTqutra2ayrsgF6K8TElB1NPGjaLupAZ4PvU2kIIyxPO6UiJD+Q82L8z44cfrjac9ghGmNn6Pm5GprqXgpK93GZHXU28DHuousiSYNl5GAIIlMEViDgZ5+7zuVH0KahPvkdDJAMS6KJZACk+DRM0UT3Yxf4hkShvC38nM+/UU7eLz4VcyIfU2oN/3EoWwXPOIFnkf8DIDdXH65rh9c3OOqnNz/J40wpnCSyZ42jkCRowYMeL/BrtBviXu22ROrmUfg0eeF1Dvu0XnUrjA7J9bgUeB0M/640B7CiXme3+SdtnfmWBZ8SNGjBgxYsSIYYE9QXGZw1X9ZvANkcFtcLgBYtfjdd4VMEAHq522uM4aeeTdNQJ4ZxhsBKQBXSNABvgSWPkI8JNjyLminztCmVLaCNUGKa/Lea2NK/F2mQIxwA9X85DVI8R3itOA3EEubbPneHqHC+dgNU+ZS+Q4XkL8PBFS5/VeJs3x9A4/Kvf/Dnj4538KvE2+x58mcw+htJeQejojDUixzDQRksyz/TzpWUS1zfH0Dj8+82VwnP8X6NQJIW0nO4uotjme3lE6SstzxTxm9/Y+m13EwxxP70iBKnf5f8EyBnik5Hh6RwryA9aSYKVEhvK55lPsUczx9A4PdRdZElxiGtDFhJV4BiDWRS36f0HShfvDMMUmaZ/j6R2L/l8gEgli6f8FCPE0xbbxNCLgXy/vfcHbfYbuAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEuklEQVR4Xu2aMYsUQRSEhYsMTARBRBRUxMsEUUMTDw1ExESDCzQRo9NQ5EBNNDMxMjAy0L/gbzH2B2giorBSA3XUfnbP7eytM7N3W1BMT/fb2VfVr2dmZ/bQoV3w5urxybtrpyYfNs40W+2rLapd4tend3bI4y0dLDwNsCkUTvH7wgCL94znfppA4fvGAM54F/H7woDdKoCCSR5v6ZCiswrcpmCSx1s62ACbkMshK+Dbi4dT3FcG5JWAy6AkPk3g8UYHn8gs7OXFo1OzrH0bIEpYjjM2qyP7tDXdry3z6R2ZbG5phoR/f7fVUG2Oe5/Hyr5RGuAEc5Y84x6T6F+fXk9RfRmjz1iUDUiDWA2OYz69w8lIgMV5rTthijdTTH6+JDQrIw1hPr3DiViAy9slr3Hh3NrajkC1BY3lktDWBpRmPs3wPvPpHZmMSz3b319tNeTsZ7+NcluC0wRXQ5rgfebTO7I0nZhPdlz7vrxNGRGxrgYdIy+HFG1DRlEBKd6Jib6+0wSK9/V+FtqINIT5DI7DG3cnyUuXrzQ8f2G9IeMJC9P2x9uNhr8/39vhkdubreTxiJ/vbzXH+fp8faoCGTc3aICF2wjGE7mUvFWV2AyJ5HeYsxhgI2WAhC/8lptJda0Anui8lBZpwJ8vD8ddAUrS5wefR/7HElhIBTABkoZkn9pKpEQaoFnzzCUlRqJEV4naHveYDfS+xhxv+ngidVZx7P6jSRspmPtOKBMTJdblafEWkEKdcI7lOi/F5Ji/j8ejzioo+MSDJ1NUnwXbkCQFpQFJinR8TSDbpX3PehrgGOqsomTA6cfPpgyw2KwK71NQlnaJjKcoGsBxHocGeJw6q6ABWQUlwSQF1QzIONKJp+C2+GQakKTOKiioxHkMsBgK4T7HSB+vK6mzCgoqsc0AihCVQE0QY9VXqhbH+kRaY15pktRZhYVxy74a0/WSaAqzQY6nCWmSYiSGvz/4Q4y/WToZkNd2ijN5/U/qju/jjbP/bMXt7e2G3s+YUjypMRpAM5JzGeA7PHPjxs3J5uZmsxU5TuaM5qyKNonrsxRLerw08yXxNIE6q8jb3DRhFvH6LA1w4rl8ZALjKLjGkgFq59rntrMBeyHFixaf54+2SmhjzjorgMLnqoBFQ+uWff8DfX1PZ/SVWF/f0xl9JTaKB6olHDgDfE1PLiK53Z4a8zvFUTxk9SMx9s+DNkFtY4NiNC88hoLfLbD/wOD6ySMTkf0HBouuAP5U9i9G/nLMPm15nF6xSANWWGGFFVZYJvCZoR+c+CkS44l8QDqahxxdQAPy8ZnIeCKv937z2/kx15CgAV0rQML9LvFAVkAugaWoAArmO4USM5ZPgUsvVbg/+K1ugoL46oxkvIT5CbAMSNEpmIYwj8EgURakdun/BTQgKdElAyg6+0ZngMXYAP6/gMx4GlASTTNGbUBWAYWXSANmJfMYDCUDupBvgmalv1/VYFNcJbUqyrHUsCfs1QC+55uVzGMwpAE0wu3ckjaANz219uhuiiTCl7TafUCOk5zZNtHZxzwGA1+Zd/1/AQ3YzQT3M4/BQEE2oSSevwtsQL7mznf8JQMcxzwGA/8v0JU0oCSaXGQF/AXuGcKOL5bNbAAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEyElEQVR4Xu2aP4sUQRTEhYsMTARBRBRURDNB1NDEQwMRMdHgAk3ESA1FDtREM5OLDIwM9Cv4WYz9AJqIKKzUYB21v+mZ3bnb65u724Kip7vfzr6qefNnp/fQoRl4d+34ZOP6qcnH1TNNq762RW2X+O353U1yf3sOFp4G2BQKp/h9YYDF+4hnP02g8H1jAI/4EPH7woBZFUDBJPe355Ciswq8TcEk97fnYANsQp4OWQHfXz2a4r4yIO8EPA1K4tME7m908IXMwl5fOjp1lNW3AaKE5TxjszpyTK3pcbXMpzoy2WxphoT/2HjaUNucd5/7yrFRGuAE8yj5iHtOon9/fjtFjWWMPmNRNiANYjU4jvlUh5ORAIvzue6EKd5MMfn5ktCsjDSE+VSHE7EAl7dLXvPCuZWVTYHaFjSXp4RaG1A68mmG+8ynOjIZl3pu/3jztCGPfo7bKG9LcJrgakgT3Gc+1ZGl6cR8seO579vblBER62rQPvJ2SNE2ZBQVkOKdmOj7O02geN/v56GNSEOYz67j8Oq9SfLylasNz1+42JDxhIWp/fl+teGfL/c3eeTOWi+5P+LXh9ub+/r28mKrEhk/GDTAwm0E44k8ldyqSmyGRPI7zHkMSDNtgIQv7NGbSQ2tAF7ofCot0oC/Xx+NuwKUnK8Pvo7s5CmwrQpgAiQNyTFtK4kSaYCOmsg4CZEo0VWibc97LkvfY5r3Z0zv03HU28KxB48nfaRg9p1MJiVKrMvS4p1UCqWwvGjaAMZ4zPP+ztI+qbcFCj7x8NkUNWbBNiRJQWlAkiIdz4SzX5rjmI96GpAx1NtCyYDTT15MGWCxWRXuU1CWdomMpyAawHnGlgzIeeptgQZkFZQEkxTUZUDGkU46BffFk2kASb0tUFCJWzHAYiiEfc6R3t9WSb0tUFCJfQZQhKgv7hLEWI2VqsWxvpD2Me82JPW2YGFsOdbFdLskmsJskONpQpqkGIko/QbhAxB/t3ibelvIezvFmbz/J/XE9+nm2VYrrq+vN3Q/Y0rxpOZKBtCM5GAD/IRnrt68NVlbW2takfNkHtE8qqJN4nlZiiU9T0F94mnCXAbkY26aMI94fZYGOPE8fWQC4yi4i10GqJ/nPttBBmyHFC9afF4/+iqhjzzqJROypVHUu+PQecuxnULN75obNZOq+V1zo2ZSo3ipShw4A3xPTy4isXneGvN7xV1/0epXYhzfCmaJmTW/KxjNgsduwWsLHD8wuHHyyETk+IHBoiuAP5XzVyN/PeaY47i/KlikAUssscQSS+wl8J2hX5z4LRLjCb4g5UsOxo8ONCBfn4mMJ3i/96qvTWD86EADhlaAROda4oGrAJ4Co68ACuaaQokZy7fAfYsqpXHmUx0UxKUzkvHNUf7/BlgGUDBNYJ/5VIdEWZC2S/8voAFJiS4Z0GUC55hPdaQoG8D/F5AZTwO6BJcMGaUBWQUUXiINGErmUx0lA4aQK0FDqSqwGa6OvirK6lFLPYOxXQO4xjeUzKc60gAa4e1sSRtgQbnux3XA7I/mwUgifEvreg7IeZJHdJZgjjOf6uCS+dD/F9AACuzra5v5VAcF2YSSeP4usAG5xM31fRqQ44plPtXB/wsMJQ3oElziIirgH4/zgLkTkjiIAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFK0lEQVR4Xu2awYscRRjFcw9JhLiHoMIGzCVLIIRlEQJGDDkIghA9RBBBBdkl4EEFPYm5CCrkorlo9CBBCJGQu168GTx49OYf4D+QY8tr+A1vXqom3e3uzHTSDx71dVV1zfe++qpmumsOHXoErl35svnq3W9bfvHW9Zmtel1/9875hczxRgcX7UGgROgHl87M8bEJgIv3bIAl8R6EHG90SMEeBNmPfQBy3WN/+tpnT0YAEC3BiGYDVL3KFA7VluONDoi+cfXH2exLGPUqS1mgOrXleKMDYnMvoG7RV+EoMsBFlda713kWeFBK91EPsx47/Vk6So5LnK9xtX2zd3Ouz/sX9mZ2isrgsFRK/dOfpUNO5Gy7GNpv7b7U3Pnw1ZlAXXufFOdLhHtKmZL+LB04p/L3z99o6Y7KRnwSEZTc73WeNbIZGzv9WTp8diRKO3g6Luxtbc+EyxZo1/3aH3Sv2kl7SgJJHVyLbwkcUamdWwIkRs6qFHPmIe26/+ZHt9s6dn/VaZ9gbPYBD8RaBMCdUfnT7sutGJaFBP68+0or7pOLmy1lq079yACV//7z+kygxGvpqFSdbPUjELSnP0sHsylnRM2gxOC47Jx5yBJwkTD7+md4n/Rn5Xjh5LkGvnhqp3n7xLnm8sZWS9nZP3Hvrz8b8fb9P5rvf/u1pWzqNe7557dn44tel+MlfGwCu6/PHC5elPD3nttpKTv7J3BQG+KbO5stZRMExpdgJ/U5XkJjEFTE6zOy32C4+KEZ8PXduw8FQHX7kQHMvsY6kAxw8SKzD7N/Qg5KLEFAvKi2HD+Z4yV8Se1LBijqZ585/ZAji0i6iqztXPuHjx1vnnr6REvZuRfkPQTI7ezDGJB6+pAZKlNnFRK0tXFqTlgKLvFRAdCYzgxACsB5t70tA+D9s5+YOqvIGVU29AlEBgBHyAJmPx3OAGSdszTzGYy8J3VWIaGaIQQTBAKRgkVvT/H6cKWwnGIJyGYTrIktiXAuEsw14+k6dVZRm+1SnYuHpQC4Y7X1nP1LAr1vtpcCOTgAtT2gFITskwFwJ3zWkxmEvJdrvkH6MnVW4Wsekrq1AHg2yNkkM0MAfBYR56UHANHefwhTZxUuFvvvH7abBw/uzQUCuyuPHNtoH440juxs934EgaARELXle8auTJ1VpEOiHM+6Gj07fOP03xbYpYzyDdipOrWlsK5MnVWkoL7M3/SiBMt5Nkrs7Kf6zaPPzoLAbwZsteUTpf/8TXp76qwiBfUls6fSvx6pW9Qu5nOBZ4DaUjji/fyB0oOQOqtQumvNp7Ck+qgv61qU7c674C7XIpmR/cikFJ9BKAWg1xKYMGHChAkTJkyYsKbg+Z7H3Np1MscZLfw9QR/mOBPGinz4cpsHOfGXj8/OPbDlOBMmTFgO/MWHOOR0mZekg97wrBr59mfI/wsIgE59taGN6g2Pv+4amgG8Ih91BsC+/y8gACqVAbzry35rA1/vKd5ZOoFSvQt2Upellsda/dJbJNyF1s4fPeVLglM4+0P6sTKkaBeZs579agEQEUydH5n1Ovs7aKQghOcBCSc+3l4KADOcJU+A1KUfK0MGAMGlOhcPfZY91WszT7DSj5UhhZZEy85DUNryOb4r+Xyu/f0ApdPbS+MMhq9x6OeGTk99goBjfcnnky1D6VoGAbES5ueIXf9fwPd8X6YfK0MKgl3/Y+Ci8gB0EdOPlSEF9WUGoEsgRpEBXSkxfsyNnaJdvPqkHyvD//1/QQZgkfiDyID/AMdpmCl88QvjAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFPElEQVR4Xu2awatVVRTGnUcZ1BtIBk/ISY9A5PEQBA2jQSAE5sAgggriPYIGGuRIbBKo0MScqDUQEUQR5zVxZjRo2Mw/wH/A4YnvwO/yve/sc9895+npXD0LPvY6a6+z7/rWXnvfe86+e/bsID+duVRd/vrXGj9/8ctMl13X1786Ohc53tKJk/Yk0EL0+48/2IaXJgFO3qsBlMh7EnK8pZMk7EmQ/tInINc9+vlPL7waCYC0CEOaDVB2tUkcqC/HWzqB9LXvfp/NvohhV1uqAtnUl+MtnUA29wJs874Kl6ICnFRpvbvNq8CTUroPO0g7esYzuJQCFzlf4+q7unVzm8+3x7dmepLK5LBUSv4Zz+CiIHK2nQz9tzc/rO6dPTkjqGv3SXK+RLinVCkZz+BCcGofXTxdwwOVDvkEJGi5321eNdIZGz3jGVx8dkRKO3gGLtlaW58Rly6hX/drf9C96qfsaUkkNjCKbwkCUaudWwRERsGqFXLmAf26/+a5u7WN3V827ROMzT7giRhFAjwYtbc2T9RkWBYieGfzk5rcjx+t1pAum/yoALVPn3w2IyjyWjpqZZMuPxJBf8YzuDCbCkbQDIoMgUvPmQcsAScJ0tc/w30ynv9djhw4XIFjBzeqL/cdrk6trNWQnv4pD//5uxLu/vW4uvHnHzWkY9e4R99bn40vuC3HS8mxSS4/vtK/szh5QcS/eXejhvT0TyFAbYifb6zWkE4SGF+EHdhzvBSNIeK0kOez0r+zOPm+FXDlwYNGAmR7HhXA7JPo514BTl5g9kH6pygokSUJkBfUl+MncryUXFK7rgBl/dA77zcCmQfKVSCQXPuv7X2revPtfTWkZ+B5DwlyPX18T6Hf/bD7ZyXfhojQ2srBbcSScAk7JUBjOjIBSYCydt37nBh621juk3wbkjOqauiSiEwAAVMFzH4G3Ba0607Erz0p2d85ASKqGYIwSSARSVjw/iSvD1YJ68NZAtLZBNvIJslEG2G/LrXJtyFts12yOXlQSoAH1rae079E0H2zv5RIWu9Pvg2hAiDtxEtJSJ9MgAfhs57IJOS9XPMN0hfJtyG+5gGl25YArwYFm2BmFIATpB+d1hMAafffDZJvQ5ws+r+/rVfPnj3clgj0RfH63pX64UjjSM9+91OgnjQSor58z9gVybchGZCgwNPWBq8O3zj9twV6qaJ8A3bIpr4k1BXJtyFJqCvyN70gwgqejRI9/WRffWP/LAn8ZkBXXz5ROpIscJ/k25Ak1BXMnlr/esQ2r1/I5wKvAPUlaSfvZxDZLpwAlbvWfBJLyEe+rGtBugfvhBe5FqiM9KOSkngpCd52roBJJplkkkkmmWSSSUYqelLUEyKPtzzn52NvvgdY+HF47OLvCfogx5tk2SQfvrhG10Pa/R8O1ZDuD21CjjfJJJO8WPEXH0Kf02VekkrPlxzpPzrJtz99/l/gCdCJrzYzkpD+oxN/3dW3AnhFvtQVALr+v4AE8EOGc//RVoCv9yTvKJ1AyZ6EAbZsgZaJ2oxncJlH3Im2nT9mybe1EPZTpFEkIEk7yZz19GtLgABhbByZeQKEjGdwSUIQzwMSTny8v5QACGabCaDNeAaXTACESzYnD3yWvdRz5vN6NHtAEi2Rlp6HoPTlc3xXKBGul67dln3Jp7P4Ggd+bujw0icJHmQfUC19kXw6C2RFzM8RF/1/AT90+iLjGVySEFj0PwZJKA9Ad0LGM7gkoa7IBHRJwqgrYFGIhB9x5/l+G/DPeAaX3f6/IBOwCHlPQsbTVf4Dv55a+XG6bwMAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEsklEQVR4Xu2aTWoUQRzFszGEgJqETFDUEDWJCiIGVCQuXCgo2bkRv3YKHkAXuvUGbrxC8Ag5hGcaeQNv+Oc3Vd1d7aR7xuTBo6qrqmvqvfro6q5ZWKjBvUsXho83V4cKn+8MRnGFTndeLmR9c4eUqBim0v5bA2LPx1D8sb99jE5nfXOHlAE04r83IAqXaBpxKgyQ8LcPto6FNuDni70JA5R2Kgw4/PBsJDaa4Gvlsb65Aw3glJDIKrK+mYOEpFb1VNwGsHyqLMO4fsTpw/Z0DjYyXt9cWx7F1ZNHnw8mQuWpTFUdDJnP9nQO9lCMex5L7J/v70dhpOd/7FHGaQBDtqdzRMESKepawiz09+v9EXPXKqt7fD9NcOgRNZMGuKctxtcSKry5e20sWHHBJqhsNM3XFM5wZgxQY+Jw13Uc6nXUfb4nTgsKToVsT+dwY+Kzm7TQL09ujRiFk67HAmVwDJnO9nQON4o7OW5oUr3ufN4nUmguZHt6x+Dl3nDz3dMRFb8/WBreXl0chyxPcN7HuPJU787XV0kqj/URv26sDM3YGSzXGhIdGcUrZHkiGqB4HPo2IGWC01kfQQMkXiOO5VqDBrQZAaanQ0yz0BxZH0EDZnIEcEH09TQMOLwzmO4IYAOaMA7bT2vnJph6miiN5ZpQgslvG4uVVBnqzILiKLCO8YfdaMUXl5bHI2Bl/fJEPu/JkeJP3IDc4hTp6VFlgESLgyvX58cAik+ZwDUiLkputOJRvEYD880qEywm0vfkqPwiAy4+3J4QlROrsqQFRCHRAJEGpO6hEb6OZtG4FJ1PnVlQUCljo6IBEu1pIDI/xZRoCmxK6syCgkpJA9wLNsDTICfQPNq7Oi7j+NwZEBlHgKdAjhIsquHxuhMD+LwuJcW40XFR9dOC5ZoYwN9rSurMIt7EN76q116Tzosa5h4hEq/Q0yPFaByv2Za6NjmfOrM42F0fkh8fbY3I9BS5R2DP5x6nTUnhFh+32QyLDND+nrQ4pqfIBosUzGuS+45YnuJpQgxbjYB5g1+3FTLvVEDCZ+ZQpQ/YAI0C5p0aSLxMYHoviF973DssY3joRtZ9PGUdgn9nd+P8Mbo+lu8MTQ2IaV7QnMcTpVjWcL5EO03xqns6QRsDKNrDO1XWyBmQK98Z2hggKC1FlovodajnUNfwuvwSTKueBe6+ShnrqhNYl98LKKiUsa4m4pqUifCboanPXQ5TdDnWkwX35aVkfdMGX52bkvWc4QxnOEMn4LlC6eFqXMjafODQU6bXLTEPU6L4Jsfr0QCd+pZ+4pLw3I6zE9CArkeA3yVmxoC2I0CbmjYjQOI7fU/gxqgJvYtUnJ/ITe7wTOf79/lNoK73p74+UBwF1pHC2xgQ43UCvUgyvTUoiO8KzBc9PfowQKjLL0KV+JQJXCMovM4ArQ/RAGKqvdsEOvaiqJxYHqyKFC7yxYasMqC4d9lbpaSgUlJ8U1JHa1BQKSmolBTWlG4/p4jIbwikyowN4AJVSgoqJYU1pdvPdE6XHMcG/Ct4PN0V2Y7eEBvFE9x4kltC3k/OlAH8v0Dp/wskJh5zxzP+FF2O7egN/L+AWPL/AhpQJf4kRsBfkTxADPB27yIAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEu0lEQVR4Xu2awaoURxiF3XgRwUTFkYhG1KhRCCEXjASzyEIhwZ0bMepOIQ+QLHTrG7jJK0gewYfIM42chjOc+3XVdNc4t2fGew8cqrrq75o6p6uqq7vnxIkB/PjNV/Nfrp6bK310a9bllbrcdbWU7e0cSqIyLZV9sQbklc9UfPfg5gG6nO3tHEoG0Igv3oAULtE04kgYIOF/3rt2ILUB73/f7xmgsiNhwIeXDzuxaYKPVcf2dg40gFNCIpeR7W0dJKS0qpfyNoDxpVimuX7k9GF/Jgc7mcffnT/d5XUlP/71uJeqTjHL2mDKevZncvAKZd7zWGL/f/uiS5Oe/3lFmacBTNmfyZGCJVLUsYRZ6H9PH3SsHStW5/h8muDUI2orDfCVthgfS6jw7IdvF4KVF2yCYtM0H1M4060xQJ3J4a7jHOpD1Hk+J6cFBZdS9mdyuDN57yYt9O9fv++Ywkm3Y4EyOFOWsz+Tw53iTo4bmtJVdz3PEym0lrI/G8fsj/351ee/dVT+p9mp+Z1ze4uU8QTnfeZVp3Zv/fOkSNWxPeLD3dn83xtnF+RoZHwzJDqZ4pUynkgDlM+hbwNKJric7RElAyTcI4/xzaABq4wA053KMgutke0RJQO2bgRwQfTxugxIEz57BLADY5jD9vX5kz2W7iYqY9wYWjD55uLeUjqOenugOAocYv6oO6383qnTixFw9sKlXj3PqZHCD92A2uKU9PRYZoBEi7PL13fHAIovmcA1Ihckd1r5FK/RwHpzmQkpJOnzanTbowz4+uebPVE1sYolLSCFpAEiDSidQyN8nGaRFE4DROrtgYJamT+WBki0p4HI+hJLoimuldTbAwW1kgboR9MAT4OaQPPj/pVFjPM7Z0AyR4CnQI0SLKrDeTyJAbxft5Ji3OlcVH23YNwYA/h7raTeHjKYT3zcZpZIx0UNc48QiVfq6VFiGsdj9mVMvzKGent4fPvCnHx1/1pHlpfIPQKvfO12OpYUneJzq810tAHa35MWx/IS2WGRgnlMct+R8RReMiHT5hGwi/Ajt1LWHQlI+NZ8WNkEbIBGAeuODCReJrB8cuTbHl8Zxhgetsmhl6dsw/Bv3b545gDdJuMnwVgDssyLmev4RSljE46RaJcpP3TeoWIVAyjaQ7sUm6gZsOycQ8cqBggqK5FxxMaGeg1DHR+qb8Va2uLuq5XZ1pDAofqNgIJamW2NETcmJuEnw6RedTktMWPYXg/cl7eS7a0bfHRuJds7xjGOcYxDBb8rtH5czQWsW9XxkoPxhO4yG90O82NKih/zeZ0G+KuvTWA8IeG1HeckoAFTjwA/S2yNAauOAG9qWkeAxE/6jMCN0Rh6F6k8X5Gb3N2RjuP7gLFXf23rBMVR4BApfBUD3JeW9wFeLFneDAriswLrRU+PTRogjI1bimXiSyZwjaBwM9cB0uuE4tiftVzVFuizF0XVxPLDqkjhFj+GJQOaryqvVispqJUU30rqaQYFtZKCWklBreT0EPN9QIkZ01vEWklBraSgzyWnyRA5IJrhndqmyP5MjuwMv97mV9wW8vwat8IA/l+g9f8FEpGfuPl9v0bHsz+Tg/8XEFv+X0ADxohPE9ifVnwCzC7McvDpKlsAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEwElEQVR4Xu1av2sUURgMqCABQQVBBK0SCdooMQQD5jSFkNgpKdIEwSZoZ6GYJohNUmlhqrSxsUlhYZM/If/TmdncrLOz3+7drcneXtyB4f36bvNm3vd2l32ZmOiD+3eudMHZqWtJyTrbn189LqVfb+xAwZ17N1LxqLsBH17MZniuDFDBXn5b6+TEkxjz640dXLSmP8r/wgDd+74VILJoC5wLA3TFNRsgHqRQv/mh7+v64vkwwO8BSoiE4KLSrzd2UNF+QwQpdnn6aobs9+s1DpraFDZ183Im5ZkFSjVBf6PX0WtEcSh9PrUjmiBIIVpX8V7yd5Foxvn10efzqR0qwifMvr03y91fn9a7v7+8S8qf79eSvkfT13OxINvs0zpNYJ/Pp3ZwQjpRXUkIhWAIp3gSY5Go6JqsM55tn0/t0ImxxCMM4iACQoGXT3ZS0agDaCMGffhNdC0aGhnUCAN8hShIyRV/+/RBQs0AJUX5yuv9QQ1B2+dTOzgRlFx5PsO/v15MX2jwaPu4NJdw6fZkJoaPPGaCXtMN8LbPp3bwrU3FuwkoIRomsKRoGqS/5fX0zfDurckM2e/zGTkWHu50lfPz8wlnZmYSenwOR0fp9lBj0Ycxv77TL5fDwUE34f5+Znt6WGX4hCicRnh8DsciVbyacNoGqNEeVhk+oSoZcNYGTB4epgY0MgPOdAscC9ct8M8Z8Gxurwvij6N8vvAjJftYkjp+cXc3Q0/PVHxv4kXxYKafQpW9uLS9tZWlxg4KChzUAB0HMdlL29sJU0E9oUxP74/iMyK9j/2RARsbJ6QBHB8UZQI9O3wchJALm5sJUdcURel9Hu8muFkZ8WJGMlZkADgoygQWGaB1iiH7CfL4UJi0c2mupHg1gRwULpDtqE/HSBeUWXEzQFc/MiC39/f/3uh4s9N6RI67zkK4Ab7CkSHa9pRODegJcQM83jMkFd9r66OON1Rtq+hKj8XIAN8GZeMURXoK66p6bBTvv3VhKj4ygKXrLASFFQnsZwAmqWKSDNAVZTb0BHl8aIDED2oAykoZ4OmtwpUeR1KQiqNwF8cYj8sYIOIR4yvrBqgRlQxYWVnpgn5zczLOqaIiM1Sgx6YGiGgVD6rgoizQLTC0AXi97XQ6GVGrq6s5oYjxWNR1i6hhmjk0gsI8RrePxiATIwM0K9wAlq6zEHzHpzgXSOFRHOpqAKnbiCVX1OP9XsMY9pcJ1/3vRrjOFi1atGjRosX4AR9Ai5h5fT44+ViajrVo0aJFixYtWtQN/5Q29OGqfELTDyEe1li4AUMfr4sBmZPlcYEbUCUD+JrLDBhrAypnwHFZ6airbujXX//I6WM6zpjcgaefEbDtbAoGEcls8DEwPOJWA9wEtpsCNwClG6Dj/QxID0vcCK83BS7QRfq49iUGlJ3xl5HQTOmZ498NnKdqoItnO+rTMdLP9/XG5wce2u/zGBmKBPtq67jGu1h99Hm7kY/FSGC/LaB0A4pEs5+lz2NkcIEoIwMic8DofM/Fq/DGZYCnv6+09kfbJNrnkXDv93mMDDxJpsAi+nE7WSY+Et44A3hcrqKG+f+CyAAV7Qaw7fMYGaL/G1CBFB7FoQ5BRfeByAiO+zyq4g8lK5z2I+oYkQAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFG0lEQVR4Xu2az4odRRjF8wYiuHIhOoIuRAYiMkNEREWYIRLjahJGmIUaYVAkszAQgwHJMBsRwexCiDAbxYVZj27i0ieY5/AFWk/DuZz8Ul33L/dPpg8cqrq+6rp1Tn3dfW/1PXduCNbW1pr19fVmc3Oz5fb2dluqTbGHn29UyfFWDhadwvOYgkmOt3KQYGeAV911kYJJjrdy6DNghAx4dPDOE8LdxvFWDqNkgMSmCXnM8VYOpSeA6Qw4vXNxINpU21NhQD4GZQAvB4stUTGOt3RIcbmyKlN8zYSM51i+T7heOpfzmTs8SU7OE0zhXQaUhHYZwPM4n7mjtMopykzhKc5kJrjNY/FzlsaAnffON+aVDzYGLE2cxmSfPDfHFC+/9XqRinE+c8d3V99uzH8f7LXUDUzHEqa68H/Xwc1NdUF19VFf1X1+jjmMnM/c8cPuhcbMx5eOVVpUF9Xn4NKbjz0Oc0zz9sdvPEa3cz5zR+kLDB9nFnvj/RdbZqbwsVca78G1MpfiMenJkvxSw5VPwSl6XHI+C8e7B382l249aqn63t5es7W11VJ19idu3jvfrH/6xxPZsfvtc41ir175pUqOR2icL398uR0vs5D9JgYNsHiT/QlNTAbwUvCkJfL5i3eLHMUAjSMjVUr4zDOJBoybAZpYyQCv3LQGzD0DJDrJ/kReAnkP8cox5UmOR8w8AzRZfbDKGt2HBmklPvrqmUHpDNAkvTqq58pl/2zPUmOo/snNF5oPv3h2UGa/Wow6O2FxF67/PRArYaKPFUuTMs7JizbGK0VxLN0vS4+9f/RaK9Bl9qvFqLMTNIAr7FhmwNWjf1p2ZYDaLZDi2J9muFxIBqgcZoCYcYkgT05OBnVNhuKypBk+z2NL2CSkzk6UDPAKD7sEbBCZ/dIIiaZZZI6tMShsVFJnJ2yAJ50Csy0vgezXdX5eJmrLrKBZN47uDz7T2eUMyzs9v1zVYtTZCa7euLQBeQ+RcApxvzzH/WWA6qVz8llf+jqeBmSMOjtBQeOyZIAEZAZYlDOEN1XXLdznqKRoGmATJjaAqZMDsd2xrJcMKKWySQN8fp6T/Sm6ZADnPbEB/ICuD0rSgExlZ0GmdM0AmqcY5zJsTmMbwB87/s6fvwHIjPNSyNTmypf6cvXz0lGMwi0+r32WYxmguzN5enraku2lODOAokttvAekaB5TPE3IcqIM6NGjR48ePXosLa4dvdQkD3/bGLAW4zgrC+32JLWn8M29V9qyFuM4Pbrw2Z2HDXn9p79asr0U53iTQPsBbJsbdm/92pAWyvZSnONNgu9//n0m46wszrwBX9++e7YN6PE0IXd3VM+dpVFeruaLk/39/ebw8LAl+y0taAC309ifyFdpOzs7rQnHx8dDz1sa0IAznwHeQDXZn1i5DPCmqDdAu5iboGkQ3/BmBtRinMfCwF3h3AX2Md8DZJzv+DMDajHOY2GgAVxhx7q2wWurXItxHgtDGsCXHF0GOK5jvtYelf782p5ALZYapgIN0DFfjdOAvAQobFT682t7ArVYapgKNsDXN69xMw3Ifn7FldTd39RTQMw29eE8JgZ/6/P3fokZl7BpKHF+rWWh+fwn3Yc6JgZ/6/P3fokZp6BxyVW2eD37Kd5t6kMdCwNfT+cLSrY7lvVMbaZ+yRzHOY+FgeJKpAlJC8oU58qTS5UB/LHD/w+UmPHS6tZMmPk9YFrwvwP8/0CJGZcoZwDF+5rn/WCWGfAfZVbEuZ74z00AAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJElEQVR4Xu2awYscRRjF8x+I4MmD6Ap6EFmIyC4RERVhl0g0p01YYQ9qhEWR7MFADAYky16CCOYWJMJeIjkk59VLPPoX7N/hP9D6Gt7w9jfV09O7Y+9Mth88qrq+6m/qffVV90zVnDvXgqWlpWp5eblaXV2tub6+Xpdqk+3JlysTSX8LB4tO4XlNwST9LRwk2BngWXddpGCS/hYOQwZMkQFPd94bE+42+ls4TJMBEptByGv6WziU3gCmM+DwzsWRaFNtz0QA8jWoAHA5WGyJstHf3CHF5cyqTPGTgpD29OXnhOulezme3uFBcnAeYApvCkBJaFMAeB/H0ztKs5yizBSe4kxmgtvsi58zNwHY+OB8ZV75aGXE0sAZmOyT96ZP8dN33ixSNo6nd/xw9d3K/OfBVk09wHQtYaoL/3UdPdxUF1RXH/VV3fenzzZyPL3j7uaFyszXl65VWlQT1Wfn0ttHXofp07x9+a0jdDvH0ztKX2D4OrPYGx++XDMzha+9kr8H18qci9ekB0vySw1nPgWn6K7keE4d7+/8UV269bSm6ltbW9Xa2lpN1dmfuHn/fLX8+eOx7Nj8/oVKttev/DaR9Ed8/dOrtS+XzEb27wwGwOJN9ic0KAWAS8EDlsgXL94rcpoAKIjy5dLiZ5ZRDEDXDNCgSgHwrJ00AL1ngEQn2Z/IJZDPEM8aU56kP2LmGaDB6oNVTqL7MECahU++eW5UOgM0QM+K6jlr2T/bs5QP1T+7+VL18VfPj0r2pZ1+qHcMFnfh+l8jsRIm+lq2DFLaOSDRgfEscVAsOavOItW3996oxblkX9rph3rHwABwhm3LDLi693fNpgxQuwVywOzPYLg8lQxQ2RYAMe0SQR4cHIzqGggHlSWD4fvsW6JOQuodQykAnuG2JeAAkdkvAyHRDBaZvuWDgrqSesfgAHjQKTDbcglkv6b7c5moLbOCwbqx9+voM51dzrB8wvPLlTnJTr1j4Ox1pQOQzxAJpxD3y3vcXwFQvXRPvuP5dZyvwFIf6h0DBXVlKQASkBlgUc4QPlRdt3Dfo5KCyMyCYwWAKZNO2G5b1ksBKKWyyQD4/rwn+1MQWRrfsQPAm5s+JMkAZCo7CzKlJwWAwZONY5lmXJ0CwB87/s6fvwHItHMpZGpz5kt9Ofu5dGSj6BSfa5/l1AHQ05k8PDysyfaSnRlA0aU2PgNSNK8pvBSELDtnwIABAwYMGDBgbnFt75Uqufv7yhG22elv4aCdnqT2FL67/1pN1dvs9DeA+OLOk4q8/vOfNdlestPfcaD9ALb1hs1bDyvSQtlestPfcfDjL49m4mdhceYD8O3te2c7AAOeJeTujuq5szTN4SoPTnZ3d6vt7e26FNl/7sAAcDuN/QkepW1sbNRUEPb391vvP3UwAGc+A7yBarI/sXAZ4E1Rb4A2MTdBM0A83WUG0M4DVo6nd3BXOHeBfc1zgLTzfJ8ZQHvapjr//7/BAHCGbWvaBucML3QG8JCjKQC265rH2V3Zth/QZqeezmAAdM2jcQYglwAFdWXbfkCbnXo6wwHw+uYaNzMA2c/HW0k/+fMNILJdfTmezuBvff7eLzHtEnYSSpiPtCwy3/tNdF/q6Qz+1ufv/RLTTkFdyRm2eL3zKdq0TX2pp3fwaDoPJ9luW9aZ1pn2TUsg6xxP76C4EhmEpIVkanPGmzgXGcAfO/z/QIlpz9TPGaZYcmbPgJOC/x3g/wdKTLvEOAMoXmvd6730TJhFBvwLF5puRHnAy2sAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEw0lEQVR4Xu2asYsVVxjFNdgasAmCRMiSRMXCBBZsXpRYSdhmsbOxtNFyOzGk0DKFCUIgTVKk0lIw5B+QNGkD+VvWkTNwlrO//e6+nd3ne2/MHDjMnTvfu++ec787M+/ed+rUHHx85nS38cm57sK5s53KPrr83def9bx348o+up7tjQ4pvDLCQtOErGN7owPF65hZkWIrsr3RIUVz9JkBFdne6JDzXUZwGlAwyfZGB4qnCRRMsr3RIef+1U/PH7gXWOgH/RTwqNsAZgDFpwlsb+1gIZzbeaPjNOC1jMljVcfPsj9LR4pj2UeN5sPbX3WP71zvqbLqKKwyJ9tKs/0Z9mfpyM4mLTqFk75uM9IICs1jxrE/S0d29Nn9rV7YUcRXJuhcbaRwiqcp7M/S4U7qhtUSL3x78aO9c5UFx9EEtVVNBU6XtTAgO2/RaYCzoqKupQH+bJpIM9KEtXhK+HHlRxcFJXdml3v6PGPNfARWbTKW/Vk6+OZGZmYkKXYeHcvPsD8rx+tfdrrkxsbGPjKe+P6Lze7PH58fEKw6XXO7T+/d3kfXsz3iv2tbnfj3l7e6J3dv9vzp4fbczx0ZNGBzc3NPvMqMJ9KAHH0a0CLbIyz+98uzPgsXnkm/PbrfJRdtwL+vf96jRWcd2yPeewZUBiQZT9iAvEeo/D4MWEgGqAMSquM/L5/0dGd8XsWYSkUJE925N3+8OHDHlwG+7nh9VtS1irr21+ff9JTgFO9pkG05TqTOJnL+UVwa0IrRl2Un3FGZIFqMO65O0gALE11n4RaVZce4HX6/4qiziUoYMyBNYFlflp2uRirFZHwVm1Q9jTBbBrh96myiJf4wE5I0IEczhWUGHDbCFEqRRyV1NpEGULyu8V6Q8Sp7NFIAhdAQm1AJzxiR95J8i6zo69TZBEVRcEu8aTFpQiU8syGnAGNTvMoUztdwCvc16myiJUzUnX+eARZDMht4vWIVn2Jdrl7HWU+dTfC5P5Q2JsmplFNqKCvRVRZkvUidTVDQUD64canJH3591ZP1Q0jRpt4AbYCpuqUbQHIEScbPYyW8Ep8miNTZRL7ni7PZrNve3u6Pouv5q9BMUSrzHpGkCT7n9TzPdNdR7/1Oe6c+j4N+G1hoim2dV6wE5ZxXfb5TMJ4mkTTAP34sWHU+WvygDJgwYcKECRMmrC20qvR2d7ckl9AcqyPbmTBhQg3+Zh9Ktjc65C+v1i+yvMY6tjc6cESHku1NmPA/h+8PJleQGE/kErkXN0a1wEEDhm6vayncJnjnVys9jFtbcFFzqAGjz4DKgCH/L0gDRpEBSvNc4OQiqN8bWougSvnc3Z23VeZz9mNlyPluAyiSMUnu6npLjMKzrOvsx8pAYSozA9IElisDPNIp3PuFPrIfKwNHNQ1omZCkAUelv59rAlwzMP2PlYWvF6QBfE2mCVW2cH8/t7pbVAz7sTJQVAo+TLxpQRTunR7S9ezHytASRrbiaEBug1XCvVXGfhwbTNeh5HN/KC0wDeB+YEXqODZyhKoRy5HLo8sUNJRM8dzjbxmw0DdDjuhQUtBQ0gCbUBmR/w2gjpXhpP8vyPTPe4F+B9CA3BZnP1aGk/6/QMK8v5/iMwMy9W0C+3FcvANG/3mGjdf2WwAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE10lEQVR4Xu2asYsUSRjFvePSEzY5DkTB5TwVAxUWTMYVjeQwGcwuMdzkNtxMPC5YQwMVQTDRwEhDweP+ATExFe5v8fp4DW95++vqmS12bae1Hzyquvqb2nqvvurpqdpjx5bg+A/fNes/rTUn1n5sVHfp+m+XT7e8s3l+H93O/kaHFF4ywkLThGxjf6MDxavMrEixJbK/0SFFc/aZASWyv9Eh17uM4DKgYJL9jQ4UTxMomGR/o0Ou/Qsnf+48Cyz0q/4W8KzbAGYAxacJ7G/lYCFc2/mg4zLgvYzJstTGz3I8gyPFse5Ss7l981Jz7/aVlqqrjcJK5mRfabY/w/EMjhxs0qJTOOn7NiONoNAsM47jGRw50Idbt1phBxFfMkHX6iOFUzxN4XgGhwepB1afeOH6qe/3rlUXHEcT1FdpKXC5rIQBOXiLTgOcFSXqXhrgz6aJNCNNWIlvCX9d+auLgpI7s3MtfZ2xZn4FlvpkLMczOPjmRmZmJCl2GR3Lz3A8Xxxvn+40yfX19X1kPPHnmY3m7wdPOoLVpnvu9/6dm/vodvZHvP/1RvPvxVstVddk7P5+reWj7fnSzy8FDdjY2NgTrzrjiTQgZ58G9JH9ESn+xbnZXjYeWUY9v7vVJI/agI9vH+/RorON/RE24LNlQMmAJOMJG5DPCNWPyoDSEjhUBmgAEqryw+vdlh6Mr0sxptJQwkQP7N3LV50nvgzwfcfrs6Lulah7//xytWUKt3gvg+zPsSb1dpDrj+LSgL4Y/ZEcgAcqE0SL8aA1QBqQwtxm4RbkuvtxnPviGPw56u2gJIwZkCawrj+Ug2aK5uBNx5dik2qnEck+A/w3VFJvB33iF5mQpAE5myksM2DRDFMoBdaSejtIAyhe9/gsyHjVPRMpgEJoiE0oCc8Ykc+SJN818qFrUm8HFEXBfeJNi0kTSsIzG3IJMDbFq07RfBtNIzLGJfV20CdM1JN/mQEWQzIbeL/EUnwKTeGlV3K2H8gAfu/X0sYkuZRySdWSgnmdWcC4QQz4Y/NsL/969qYl22tIwUm9/dkAU22DGkByBknGLyNFW3hJfJpgI6i3g3zPF2ezWTOfz9tSdDt/FZopSnU+I5I0wde8n9cWnVmgd36nvVOfpWIOZICFpti+6xJLgnLNqz3fKRhPk8iSAf7hY8Fqc2nxB86ACRMmTJgwYcLKQrtK/3361Etuo3knSvdUsr8JEybsB3+z15L9jQ75y6vvF1neYxv7Gx04o7VkfxMmfOPw88HkDhLjCW6Re3NjNBscNKD2eF1b4WmCT329I8T4lQM3NWsN4IHK6DKgZEDN/xdwCax8BijNc4OTm6B+b+jbBFXK+yDT6c8s4HGZqWuOZ3DkercBFMmYJE9zfSRmE1I4S8VxPIODwlRnBqQJrJcMyFnO2c9zQ5ccz+DgrKYBfSYkaUAtuR/A/YIk9wqOZL8gDeBrMk0oZQvP9nm+v4iK5XgGB0Wl4EXiTQuh8DwRYnuWHM/g6BNG9sXRAB6DUTzvczzVYLrWkt/7tbS4NCBZaktSTzVyhkozljOXpesUVMucYZHn+4sMUCz1VIMzWksKqiUNsAmLjMj/D6CewXHY/y/I9M9ngc/3Swbk0TjHMzgO+/8FEuSz/RTPDEjm/wdwPLX4H8bzDGXhwa6jAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }] +}); diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 421d805c1f..2beb93ed76 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod jre; pub mod logs; pub mod metadata; pub mod minecraft_auth; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 587c9559a0..abb22a0dbc 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -1,5 +1,8 @@ //! Theseus error type +use std::sync::Arc; + use crate::{profile, util}; +use data_url::DataUrlError; use tracing_error::InstrumentError; #[derive(thiserror::Error, Debug)] @@ -125,12 +128,21 @@ pub enum ErrorKind { #[error("Error resolving DNS: {0}")] DNSError(#[from] hickory_resolver::ResolveError), + + #[error("An online profile for {user_name} is not available")] + OnlineMinecraftProfileUnavailable { user_name: String }, + + #[error("Invalid data URL: {0}")] + InvalidDataUrl(#[from] DataUrlError), + + #[error("Invalid data URL: {0}")] + InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64), } #[derive(Debug)] pub struct Error { - pub raw: std::sync::Arc, - pub source: tracing_error::TracedError>, + pub raw: Arc, + pub source: tracing_error::TracedError>, } impl std::error::Error for Error { @@ -148,7 +160,7 @@ impl std::fmt::Display for Error { impl> From for Error { fn from(source: E) -> Self { let error = Into::::into(source); - let boxed_error = std::sync::Arc::new(error); + let boxed_error = Arc::new(error); Self { raw: boxed_error.clone(), diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index 39e9e1a33a..df6d4a5c71 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -235,6 +235,30 @@ pub struct Credentials { pub active: bool, } +/// An entry in the player profile cache, keyed by player UUID. +pub(super) enum ProfileCacheEntry { + /// A cached profile that is valid, even though it may be stale. + Hit(Arc), + /// A negative profile fetch result due to an authentication error, + /// from which we're recovering by holding off from repeatedly + /// attempting to fetch the profile until the token is refreshed + /// or some time has passed. + AuthErrorBackoff { + likely_expired_token: String, + last_attempt: Instant, + }, +} + +/// A thread-safe cache of online profiles, used to avoid fetching the +/// same profile multiple times as long as they don't get too stale. +/// +/// The cache has to be static because credential objects are short lived +/// and disposable, and in the future several threads may be interested in +/// profile data. +pub(super) static PROFILE_CACHE: Mutex< + HashMap>, +> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); + impl Credentials { /// Refreshes the authentication tokens for this user if they are expired. async fn refresh( @@ -283,31 +307,8 @@ impl Credentials { Ok(()) } - /// Fetches the online profile for this user if possible. - /// - /// Even if assuming a flawless network connection and Mojang backend, this may fail - /// if the current access token has expired. To ensure that does not happen, log in or - /// call [`refresh`](Self::refresh) before this method. #[tracing::instrument(skip(self))] pub async fn online_profile(&self) -> Option> { - enum ProfileCacheEntry { - Hit(Arc), - AuthErrorBackoff { - likely_expired_token: String, - last_attempt: Instant, - }, - } - - /// A thread-safe cache of online profiles, used to avoid fetching the - /// same profile multiple times as long as they don't get too stale. - /// - /// The cache has to be static because credential objects are short lived - /// and disposable, and in the future several threads may be interested in - /// profile data. - static PROFILE_CACHE: Mutex< - HashMap>, - > = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); - let mut profile_cache = PROFILE_CACHE.lock().await; loop { @@ -1126,9 +1127,12 @@ async fn minecraft_token( }) } -#[derive(Deserialize, Serialize, Debug, Copy, Clone)] +#[derive( + sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq, +)] #[serde(rename_all(deserialize = "UPPERCASE"))] -pub enum MinecraftSkinModelVariant { +#[sqlx(rename_all = "UPPERCASE")] +pub enum MinecraftSkinVariant { /// The classic player model, with arms that are 4 pixels wide. Classic, /// The slim player model, with arms that are 3 pixels wide. @@ -1139,7 +1143,7 @@ pub enum MinecraftSkinModelVariant { // prevent breaking the entire profile parsing } -#[derive(Deserialize, Serialize, Debug, Copy, Clone)] +#[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)] #[serde(rename_all(deserialize = "UPPERCASE"))] pub enum MinecraftCharacterExpressionState { /// This expression is selected for being displayed ingame. @@ -1173,7 +1177,7 @@ pub struct MinecraftSkin { /// As of 2025-04-08, in the production Mojang profile endpoint the file /// name for this URL is a hash of the skin texture, so that different /// players using the same skin texture will share a texture URL. - pub url: Url, + pub url: Arc, /// A hash of the skin texture. /// /// As of 2025-04-08, in the production Mojang profile endpoint this @@ -1183,9 +1187,9 @@ pub struct MinecraftSkin { // prevent breaking the entire profile parsing rename = "textureKey" )] - pub texture_key: Option, + pub texture_key: Option>, /// The player model variant this skin is for. - pub variant: MinecraftSkinModelVariant, + pub variant: MinecraftSkinVariant, /// User-friendly name for the skin. /// /// As of 2025-04-08, in the production Mojang profile endpoint this is @@ -1199,6 +1203,22 @@ pub struct MinecraftSkin { pub name: Option, } +impl MinecraftSkin { + /// Robustly computes the texture key for this skin, falling back to its + /// URL file name and finally to the skin UUID when necessary. + pub fn texture_key(&self) -> Arc { + self.texture_key.as_ref().cloned().unwrap_or_else(|| { + self.url + .path_segments() + .and_then(|mut path_segments| { + path_segments.next_back().map(String::from) + }) + .unwrap_or_else(|| self.id.as_simple().to_string()) + .into() + }) + } +} + fn normalize_skin_alias_case<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { @@ -1215,10 +1235,10 @@ pub struct MinecraftCape { /// The selection state of the cape. pub state: MinecraftCharacterExpressionState, /// The URL to the cape texture. - pub url: Url, + pub url: Arc, /// The user-friendly name for the cape. #[serde(rename = "alias")] - pub name: String, + pub name: Arc, } #[derive(Deserialize, Serialize, Debug, Default, Clone)] @@ -1256,6 +1276,27 @@ impl MinecraftProfile { < std::time::Duration::from_secs(60) }) } + + /// Returns the currently selected skin for this profile. + pub fn current_skin(&self) -> crate::Result<&MinecraftSkin> { + Ok(self + .skins + .iter() + .find(|skin| { + skin.state == MinecraftCharacterExpressionState::Active + }) + // There should always be one active skin, even when the player uses their default skin + .ok_or_else(|| { + ErrorKind::OtherError("No active skin found".into()) + })?) + } + + /// Returns the currently selected cape for this profile. + pub fn current_cape(&self) -> Option<&MinecraftCape> { + self.capes.iter().find(|cape| { + cape.state == MinecraftCharacterExpressionState::Active + }) + } } pub enum MaybeOnlineMinecraftProfile<'profile> { diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs new file mode 100644 index 0000000000..ee65f03449 --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -0,0 +1,179 @@ +use futures::{Stream, StreamExt, stream}; +use uuid::{Uuid, fmt::Hyphenated}; + +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> { + 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. +#[derive(Debug, Clone)] +pub struct CustomMinecraftSkin { + /// The key for the texture skin, 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. + pub cape_id: Option, +} + +impl CustomMinecraftSkin { + pub async fn add( + minecraft_user_id: Uuid, + texture_key: &str, + texture: &[u8], + variant: MinecraftSkinVariant, + cape_id: Option, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.map(|id| id.hyphenated()); + + let mut transaction = db.begin().await?; + + sqlx::query!( + "INSERT OR IGNORE 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?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + texture_key, texture + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(()) + } + + pub async fn get_many( + minecraft_user_id: Uuid, + offset: u32, + count: u32, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(stream::iter(sqlx::query!( + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? \ + ORDER BY rowid ASC \ + LIMIT ? OFFSET ?", + minecraft_user_id, count, offset + ) + .fetch_all(&mut *db.acquire().await?) + .await?) + .map(|row| Self { + texture_key: row.texture_key, + variant: row.variant, + cape_id: row.cape_id.map(Uuid::from), + })) + } + + pub async fn get_all( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + // Limit ourselves to 2048 skins, so that memory usage even when storing base64 + // PNG data of a 64x64 texture with random pixels stays around ~150 MiB + Self::get_many(minecraft_user_id, 0, 2048, db).await + } + + pub async fn texture_blob( + &self, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result>> { + Ok(sqlx::query_scalar!( + "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + self.texture_key + ) + .fetch_optional(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + &self, + 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 custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id = ?", + minecraft_user_id, self.texture_key, self.variant, self.cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs new file mode 100644 index 0000000000..49b5249ed6 --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs @@ -0,0 +1,142 @@ +use std::{error::Error, sync::Arc, time::Instant}; + +use bytes::Bytes; +use futures::TryStream; +use reqwest::{Body, multipart::Part}; +use serde_json::json; +use uuid::Uuid; + +use super::MinecraftSkinVariant; +use crate::{ + ErrorKind, + data::Credentials, + state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry}, + util::fetch::REQWEST_CLIENT, +}; + +/// Provides operations for interacting with capes on a Minecraft player profile. +pub struct MinecraftCapeOperation; + +impl MinecraftCapeOperation { + pub async fn equip( + credentials: &Credentials, + cape_id: Uuid, + ) -> crate::Result<()> { + update_profile_cache_from_response( + 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(), + })) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +/// Provides operations for interacting with skins on a Minecraft player profile. +pub struct MinecraftSkinOperation; + +impl MinecraftSkinOperation { + pub async fn equip( + credentials: &Credentials, + texture: TextureStream, + variant: MinecraftSkinVariant, + ) -> crate::Result<()> + where + TextureStream: TryStream + Send + 'static, + TextureStream::Error: Into>, + Bytes: From, + { + let form = reqwest::multipart::Form::new() + .text( + "variant", + match variant { + MinecraftSkinVariant::Slim => "slim", + MinecraftSkinVariant::Classic => "classic", + _ => { + return Err(ErrorKind::OtherError( + "Cannot equip skin of unknown model variant".into(), + ) + .into()); + } + }, + ) + .part( + "file", + Part::stream(Body::wrap_stream(texture)) + .mime_str("image/png")? + .file_name("skin.png"), + ); + + update_profile_cache_from_response( + REQWEST_CLIENT + .post( + "https://api.minecraftservices.com/minecraft/profile/skins", + ) + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .multipart(form) + .send() + .await + .and_then(|response| response.error_for_status())?, + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +async fn update_profile_cache_from_response(response: reqwest::Response) { + let Some(mut profile) = response.json::().await.ok() + else { + tracing::warn!( + "Failed to parse player profile from skin or cape operation response, not updating profile cache" + ); + return; + }; + + profile.fetch_time = Some(Instant::now()); + + PROFILE_CACHE + .lock() + .await + .insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile))); +} diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index ec6d5426e1..ab7a5e3e94 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -28,6 +28,8 @@ pub use self::discord::*; mod minecraft_auth; pub use self::minecraft_auth::*; +pub mod minecraft_skins; + mod cache; pub use self::cache::*; From 9c8f21628901c1e07a02c61001a1c1bf5e89b026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 17 Apr 2025 21:23:59 +0200 Subject: [PATCH 2/5] enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations --- .../20250413162050_skin-selector.sql | 20 +------ packages/app-lib/src/api/minecraft_skins.rs | 55 +++++++++++++++++-- packages/app-lib/src/error.rs | 8 +++ .../app-lib/src/state/minecraft_skins/mod.rs | 4 +- 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql index 5829ee33dd..f76e667b46 100644 --- a/packages/app-lib/migrations/20250413162050_skin-selector.sql +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -15,7 +15,9 @@ CREATE TABLE custom_minecraft_skins ( PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) - ON DELETE CASCADE ON UPDATE CASCADE + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key) + ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ); CREATE TABLE custom_minecraft_skin_textures ( @@ -25,16 +27,6 @@ CREATE TABLE custom_minecraft_skin_textures ( PRIMARY KEY (texture_key) ); --- Use triggers to emulate partial cascading foreign key constraints on the custom_minecraft_skin_textures table - -CREATE TRIGGER custom_minecraft_skin_texture_insertion_validation - BEFORE INSERT ON custom_minecraft_skin_textures FOR EACH ROW - BEGIN - SELECT CASE WHEN NOT EXISTS ( - SELECT 1 FROM custom_minecraft_skins WHERE texture_key = NEW.texture_key - ) THEN RAISE(ABORT, 'Missing custom skin for the specified skin texture key') END; - END; - CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup AFTER DELETE ON custom_minecraft_skins FOR EACH ROW BEGIN @@ -42,9 +34,3 @@ CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup SELECT texture_key FROM custom_minecraft_skins ); END; - -CREATE TRIGGER custom_minecraft_skin_texture_update_cleanup - AFTER UPDATE OF texture_key ON custom_minecraft_skins FOR EACH ROW - BEGIN - UPDATE custom_minecraft_skin_textures SET texture_key = NEW.texture_key WHERE texture_key = OLD.texture_key; - END; diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index bb2646ae94..8a759d39df 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -262,6 +262,11 @@ pub async fn add_and_equip_custom_skin( variant: MinecraftSkinVariant, cape_override: Option, ) -> crate::Result<()> { + let (skin_width, skin_height) = png_dimensions(&texture_blob)?; + if skin_width != 64 || ![32, 64].contains(&skin_height) { + return Err(ErrorKind::InvalidSkinTexture)?; + } + let cape_override = cape_override.map(|cape| cape.id); let state = State::get().await?; @@ -271,7 +276,7 @@ pub async fn add_and_equip_custom_skin( // We have to equip the skin first, as it's the Mojang API backend who knows // how to compute the texture key we require, which we can then read from the - // updated player profile. This also ensures the skin data is indeed valid + // updated player profile mojang_api::MinecraftSkinOperation::equip( &selected_credentials, stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), @@ -479,13 +484,15 @@ async fn sync_cape( Ok(()) } -fn texture_blob_to_data_url(texture_blob: Option>) -> Arc { - let data = texture_blob.map_or( +fn texture_blob_to_data_url(texture_blob: Vec) -> Arc { + let data = if is_png(&texture_blob) { + Cow::Owned(texture_blob) + } else { + // Fall back to a placeholder texture if the DB somehow contains corrupt data Cow::Borrowed( &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], - ), - Cow::Owned, - ); + ) + }; Url::parse(&format!( "data:image/png;base64,{}", @@ -494,3 +501,39 @@ fn texture_blob_to_data_url(texture_blob: Option>) -> Arc { .unwrap() .into() } + +fn is_png(data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + data.starts_with(PNG_SIGNATURE) +} + +fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + data.get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + data.get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index abb22a0dbc..d38c10c3fd 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -137,6 +137,14 @@ pub enum ErrorKind { #[error("Invalid data URL: {0}")] InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64), + + #[error("Invalid PNG")] + InvalidPng, + + #[error( + "A skin texture must have a dimension of either 64x64 or 64x32 pixels" + )] + InvalidSkinTexture, } #[derive(Debug)] diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs index ee65f03449..47c64f1a29 100644 --- a/packages/app-lib/src/state/minecraft_skins/mod.rs +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -150,12 +150,12 @@ impl CustomMinecraftSkin { pub async fn texture_blob( &self, db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, - ) -> crate::Result>> { + ) -> crate::Result> { Ok(sqlx::query_scalar!( "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", self.texture_key ) - .fetch_optional(&mut *db.acquire().await?) + .fetch_one(&mut *db.acquire().await?) .await?) } From 56bdf39982d8982762d9a491977dde59ae118503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 17 Apr 2025 22:01:42 +0200 Subject: [PATCH 3/5] chore: fix comment typo spotted by Copilot --- packages/app-lib/src/api/logs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index efbd8b7ead..265d9bcb45 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -45,7 +45,7 @@ impl CensoredString { .replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/") .replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\"); for credentials in credentials_list { - // Use the offline profile to guarantee that this function is does not cause + // Use the offline profile to guarantee that this function does not cause // Mojang API request, and is never delayed by a network request. The offline // profile is optimistically updated on upsert from time to time anyway s = s From c553c62c751ad1717bf6d695f5c0f304c8f84228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Sat, 26 Apr 2025 22:00:12 +0200 Subject: [PATCH 4/5] fix: less racy auth token refresh logic This may help with issues reported by users where the access token is invalid and can't be used to join servers over long periods of time. --- packages/app-lib/src/state/minecraft_auth.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index df6d4a5c71..26a1f313f4 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -260,12 +260,16 @@ pub(super) static PROFILE_CACHE: Mutex< > = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); impl Credentials { - /// Refreshes the authentication tokens for this user if they are expired. + /// Refreshes the authentication tokens for this user if they are expired, or + /// very close to expiration. async fn refresh( &mut self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { - if self.expires > Utc::now() { + // Use a margin of 5 minutes to give e.g. Minecraft and potentially + // other operations that depend on a fresh token 5 minutes to complete + // from now, and deal with some classes of clock skew + if self.expires > Utc::now() + Duration::minutes(5) { return Ok(()); } From 1a3d910dfe8fac62f4e4371254845488e9952066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Mon, 12 May 2025 20:15:48 +0200 Subject: [PATCH 5/5] tweak(app-lib): improve consistency of skin field serialization case --- packages/app-lib/src/api/minecraft_skins.rs | 1 + packages/app-lib/src/state/minecraft_auth.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index 8a759d39df..199515166f 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -103,6 +103,7 @@ impl Skin { } #[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "snake_case")] pub enum SkinSource { /// A default Minecraft skin, which may be assigned to players at random by default. Default, diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index 26a1f313f4..da4e000191 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -1134,7 +1134,7 @@ async fn minecraft_token( #[derive( sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq, )] -#[serde(rename_all(deserialize = "UPPERCASE"))] +#[serde(rename_all = "UPPERCASE")] #[sqlx(rename_all = "UPPERCASE")] pub enum MinecraftSkinVariant { /// The classic player model, with arms that are 4 pixels wide. @@ -1148,7 +1148,7 @@ pub enum MinecraftSkinVariant { } #[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)] -#[serde(rename_all(deserialize = "UPPERCASE"))] +#[serde(rename_all = "UPPERCASE")] pub enum MinecraftCharacterExpressionState { /// This expression is selected for being displayed ingame. ///