diff --git a/Cargo.lock b/Cargo.lock index 5860469..a10a528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "async-trait" version = "0.1.89" @@ -25,12 +31,30 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -43,7 +67,9 @@ version = "0.1.0" dependencies = [ "base64", "bytes", + "image", "serde", + "serde_json", "utoipa", "worker", "worker-macros", @@ -66,6 +92,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -83,6 +118,25 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -281,6 +335,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -329,6 +396,26 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -385,6 +472,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -403,6 +503,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quote" version = "1.0.45" @@ -490,6 +596,12 @@ dependencies = [ "serde", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 27d147c..ca65ffb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,11 @@ crate-type = ["cdylib", "rlib"] base64 = "0.22.1" bytes = "1.11.1" serde = "1.0.228" +serde_json = "1.0" utoipa = { version = "5.4.0", features = ["openapi_extensions"] } worker = { version = "0.7" } worker-macros = { version = "0.7" } +image = { version = "0.25.10", default-features = false, features = ["png"] } [workspace] members = ["openapi"] diff --git a/me.sh b/me.sh index c175fab..8147067 100755 --- a/me.sh +++ b/me.sh @@ -2,6 +2,6 @@ KEY="your-secret-key" PLAYER="hyriik" -URL="http://localhost:8787/v1/$PLAYER" +URL="http://localhost:8787/v1/players/$PLAYER" curl -s -H "Authorization: Bearer $KEY" "$URL" | jq diff --git a/src/lib.rs b/src/lib.rs index def54df..be4d38f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,191 +1,26 @@ mod middleware; mod models; +mod mojang; +mod routes; mod utils; -use bytes::{BufMut, BytesMut}; use models::*; -use utils::json_error; use utoipa::OpenApi; use worker::*; #[derive(OpenApi)] #[openapi( - paths(get_player), + paths( + routes::players::profile::get_player_profile, + routes::players::head::get_player_head, + routes::players::skin::get_player_skin, + routes::players::cape::get_player_cape, + routes::players::batch::get_player_batch + ), components(schemas(CalfResponse, TextureData, ErrorResponse)) )] pub struct ApiDoc; -/// Fetches the uuid for a given minecraft username. -/// Returns `Ok(Ok(uuid))` on success, or `Ok(Err(Response))` if an error occurs. -async fn fetch_mojang_uuid(username: &str) -> Result> { - let url = format!( - "https://api.mojang.com/users/profiles/minecraft/{}", - username - ); - let mut res = Fetch::Url(url.parse()?).send().await?; - - if res.status_code() == 204 || res.status_code() == 404 { - return Ok(Err(json_error("player not found", 404)?)); - } - - match res.json::().await { - Ok(data) => Ok(Ok(data.id)), - Err(_) => Ok(Err(json_error( - "failed to parse mojang uuid response", - 502, - )?)), - } -} - -/// Fetches the profile data (including textures) for a given uuid. -async fn fetch_mojang_profile( - uuid: &str, -) -> Result> { - let url = format!( - "https://sessionserver.mojang.com/session/minecraft/profile/{}?unsigned=false", - uuid - ); - let mut res = Fetch::Url(url.parse()?).send().await?; - - if res.status_code() != 200 { - return Ok(Err(json_error("failed to fetch profile from mojang", 502)?)); - } - - match res.json::().await { - Ok(data) => Ok(Ok(data)), - Err(_) => Ok(Err(json_error("failed to parse mojang profile data", 502)?)), - } -} - -fn build_raw_response( - uuid: Option<&str>, - username: Option<&str>, - textures: &TextureData, -) -> Result { - let mut buffer = BytesMut::new(); - - let write_str = |buf: &mut BytesMut, s: &str| { - buf.put_u16(s.len() as u16); - buf.put_slice(s.as_bytes()); - }; - - if let Some(u) = uuid { - write_str(&mut buffer, u); - } - - if let Some(name) = username { - write_str(&mut buffer, name); - } - - write_str(&mut buffer, &textures.value); - write_str(&mut buffer, &textures.signature); - - let mut res = Response::from_bytes(buffer.to_vec())?; - res.headers_mut() - .set("Content-Type", "application/octet-stream")?; - Ok(res) -} - -fn build_json_response( - uuid: Option, - username: Option, - textures: TextureData, -) -> Result { - Response::from_json(&CalfResponse { - uuid, - username, - textures, - }) -} - -#[utoipa::path( - get, - path = "/v1/{username}", - responses( - (status = 200, description = "Player textures (JSON or Raw Binary)"), - (status = 400, description = "Username is required", body = ErrorResponse), - (status = 404, description = "Player not found", body = ErrorResponse), - (status = 502, description = "Mojang API error", body = ErrorResponse), - ), - params( - ("username" = String, Path, description = "Minecraft username"), - ("format" = Option, Query, description = "Response format: 'json' (default) or 'raw'"), - ("exclude" = Option, Query, description = "Comma-separated fields to exclude, e.g. 'uuid,username'") - ) -)] -async fn get_player(req: Request, ctx: RouteContext<()>) -> Result { - let username = match ctx.param("username") { - Some(u) => u, - None => return json_error("Username is required", 400), - }; - - let req_url = req.url()?; - - let is_raw = req_url - .query_pairs() - .any(|(k, v)| k == "format" && v == "raw"); - - let exclude_param = req_url - .query_pairs() - .find(|(k, _)| k == "exclude") - .map(|(_, v)| v.to_string()) - .unwrap_or_default(); - - let exclude_uuid = exclude_param.contains("uuid"); - let exclude_username = exclude_param.contains("username"); - - let uuid = match fetch_mojang_uuid(username).await? { - Ok(id) => id, - Err(error_response) => return Ok(error_response), - }; - - let profile = match fetch_mojang_profile(&uuid).await? { - Ok(p) => p, - Err(error_response) => return Ok(error_response), - }; - - let textures_prop = profile - .properties - .into_iter() - .find(|p| p.name == "textures"); - let tex = match textures_prop { - Some(t) => t, - None => return json_error("texture data missing on profile", 404), - }; - - let clean_textures = TextureData { - value: tex.value, - signature: tex.signature, - }; - - let opt_uuid = if exclude_uuid { - None - } else { - Some(uuid.clone()) - }; - let opt_username = if exclude_username { - None - } else { - Some(profile.name.clone()) - }; - - let mut response = if is_raw { - build_raw_response( - opt_uuid.as_deref(), - opt_username.as_deref(), - &clean_textures, - )? - } else { - build_json_response(opt_uuid, opt_username, clean_textures)? - }; - - response - .headers_mut() - .set("Cache-Control", "public, max-age=300")?; - - Ok(response) -} - #[event(fetch)] pub async fn main(req: Request, env: Env, _ctx: Context) -> Result { if let Some(auth_rejection) = middleware::check_auth(&req, &env)? { @@ -193,9 +28,7 @@ pub async fn main(req: Request, env: Env, _ctx: Context) -> Result { } let router = Router::new(); + let router = routes::register_routes(router); - router - .get_async("/v1/:username", get_player) - .run(req, env) - .await + router.run(req, env).await } diff --git a/src/models.rs b/src/models.rs index 067e8b1..b4358fa 100644 --- a/src/models.rs +++ b/src/models.rs @@ -41,3 +41,21 @@ pub struct ProfileProperty { pub value: String, pub signature: String, } + +#[derive(Deserialize)] +pub struct TexturesDecoded { + pub textures: TexturesMap, +} + +#[derive(Deserialize)] +pub struct TexturesMap { + #[serde(rename = "SKIN")] + pub skin: Option, + #[serde(rename = "CAPE")] + pub cape: Option, +} + +#[derive(Deserialize)] +pub struct SkinInfo { + pub url: String, +} diff --git a/src/mojang.rs b/src/mojang.rs new file mode 100644 index 0000000..025f299 --- /dev/null +++ b/src/mojang.rs @@ -0,0 +1,45 @@ +use crate::models::{MojangSessionResponse, MojangUuidResponse}; +use crate::utils::json_error; +use worker::*; + +/// Fetches the uuid for a given minecraft username. +/// Returns `Ok(Ok(uuid))` on success, or `Ok(Err(Response))` if an error occurs. +pub async fn fetch_mojang_uuid(username: &str) -> Result> { + let url = format!( + "https://api.mojang.com/users/profiles/minecraft/{}", + username + ); + let mut res = Fetch::Url(url.parse()?).send().await?; + + if res.status_code() == 204 || res.status_code() == 404 { + return Ok(Err(json_error("player not found", 404)?)); + } + + match res.json::().await { + Ok(data) => Ok(Ok(data.id)), + Err(_) => Ok(Err(json_error( + "failed to parse mojang uuid response", + 502, + )?)), + } +} + +/// Fetches the profile data (including textures) for a given uuid. +pub async fn fetch_mojang_profile( + uuid: &str, +) -> Result> { + let url = format!( + "https://sessionserver.mojang.com/session/minecraft/profile/{}?unsigned=false", + uuid + ); + let mut res = Fetch::Url(url.parse()?).send().await?; + + if res.status_code() != 200 { + return Ok(Err(json_error("failed to fetch profile from mojang", 502)?)); + } + + match res.json::().await { + Ok(data) => Ok(Ok(data)), + Err(_) => Ok(Err(json_error("failed to parse mojang profile data", 502)?)), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..37e3b90 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,12 @@ +pub mod players; + +use worker::Router; + +pub fn register_routes<'a>(router: Router<'a, ()>) -> Router<'a, ()> { + router + .get_async("/v1/players/:identifier", players::get_player_profile) + .get_async("/v1/players/:identifier/head", players::get_player_head) + .get_async("/v1/players/:identifier/skin", players::get_player_skin) + .get_async("/v1/players/:identifier/cape", players::get_player_cape) + .get_async("/v1/players/:identifier/batch", players::get_player_batch) +} diff --git a/src/routes/players.rs b/src/routes/players.rs new file mode 100644 index 0000000..9c8b44f --- /dev/null +++ b/src/routes/players.rs @@ -0,0 +1,12 @@ +pub mod batch; +pub mod cape; +pub mod head; +pub mod profile; +pub mod skin; +pub mod utils; + +pub use batch::get_player_batch; +pub use cape::get_player_cape; +pub use head::get_player_head; +pub use profile::get_player_profile; +pub use skin::get_player_skin; diff --git a/src/routes/players/batch.rs b/src/routes/players/batch.rs new file mode 100644 index 0000000..93171eb --- /dev/null +++ b/src/routes/players/batch.rs @@ -0,0 +1,159 @@ +use worker::*; + +use crate::models::ErrorResponse; +use crate::utils::json_error; + +use super::utils::{ + decode_textures_b64, extract_head_rgb, extract_image_rgb, fetch_image_bytes, + fetch_profile_and_textures, resolve_uuid, BatchBuilder, +}; + +#[utoipa::path( + get, + path = "/v1/players/{identifier}/batch", + responses( + (status = 200, description = "Dynamically batched player data (profile, head, skin, cape)"), + (status = 400, description = "Identifier is required", body = ErrorResponse), + (status = 404, description = "Player not found or missing requested data", body = ErrorResponse), + (status = 502, description = "Mojang API error", body = ErrorResponse), + ), + params( + ("identifier" = String, Path, description = "Minecraft username or UUID"), + ("parts" = Option, Query, description = "Comma-separated parts to include: profile, texture, head, skin, cape. Default: profile,head") + ) +)] +pub async fn get_player_batch(req: Request, ctx: RouteContext<()>) -> Result { + let identifier = match ctx.param("identifier") { + Some(i) => i, + None => return json_error("Identifier is required", 400), + }; + + let req_url = req.url()?; + let parts_param = req_url + .query_pairs() + .find(|(k, _)| k == "parts") + .map(|(_, v)| v.to_string()) + .unwrap_or_else(|| "profile,head".to_string()); + + let parts: Vec<&str> = parts_param.split(',').map(|s| s.trim()).collect(); + + let uuid = match resolve_uuid(identifier).await? { + Ok(id) => id, + Err(error_response) => return Ok(error_response), + }; + + let (profile, tex) = match fetch_profile_and_textures(&uuid).await? { + Ok(res) => res, + Err(error_response) => return Ok(error_response), + }; + + let needs_images = + parts.contains(&"head") || parts.contains(&"skin") || parts.contains(&"cape"); + let tex_json = if needs_images { + match decode_textures_b64(&tex.value)? { + Ok(t) => Some(t), + Err(error_response) => return Ok(error_response), + } + } else { + None + }; + + let mut builder = BatchBuilder::new(); + let mut cached_skin_bytes: Option> = None; + + for part in parts { + match part { + "profile" => { + builder = builder + .add_string(&uuid) + .add_string(&profile.name) + .add_string(&tex.value) + .add_string(&tex.signature); + } + "texture" => { + builder = builder.add_string(&tex.value).add_string(&tex.signature); + } + "head" => { + let textures = tex_json.as_ref().unwrap(); + let skin_url = match &textures.textures.skin { + Some(s) => &s.url, + None => return json_error("player has no skin", 404), + }; + + let skin_bytes = if let Some(bytes) = &cached_skin_bytes { + bytes.clone() + } else { + let bytes = + match fetch_image_bytes(skin_url, "failed to fetch skin from mojang") + .await? + { + Ok(b) => b, + Err(e) => return Ok(e), + }; + cached_skin_bytes = Some(bytes.clone()); + bytes + }; + + let head_rgb = match extract_head_rgb(&skin_bytes)? { + Ok(h) => h, + Err(e) => return Ok(e), + }; + builder = builder.add_bytes(&head_rgb); + } + "skin" => { + let textures = tex_json.as_ref().unwrap(); + let skin_url = match &textures.textures.skin { + Some(s) => &s.url, + None => return json_error("player has no skin", 404), + }; + + let skin_bytes = if let Some(bytes) = &cached_skin_bytes { + bytes.clone() + } else { + let bytes = + match fetch_image_bytes(skin_url, "failed to fetch skin from mojang") + .await? + { + Ok(b) => b, + Err(e) => return Ok(e), + }; + cached_skin_bytes = Some(bytes.clone()); + bytes + }; + + let skin_rgb = match extract_image_rgb(&skin_bytes)? { + Ok(s) => s, + Err(e) => return Ok(e), + }; + builder = builder.add_bytes(&skin_rgb); + } + "cape" => { + let textures = tex_json.as_ref().unwrap(); + let cape_url = match &textures.textures.cape { + Some(c) => &c.url, + None => return json_error("player has no cape", 404), + }; + + let cape_bytes = + match fetch_image_bytes(cape_url, "failed to fetch cape from mojang").await? { + Ok(b) => b, + Err(e) => return Ok(e), + }; + let cape_rgb = match extract_image_rgb(&cape_bytes)? { + Ok(c) => c, + Err(e) => return Ok(e), + }; + builder = builder.add_bytes(&cape_rgb); + } + _ => { + // ignore + } + } + } + + let mut res = builder.build()?; + res.headers_mut() + .set("Cache-Control", "public, max-age=300")?; + + Ok(res) +} diff --git a/src/routes/players/cape.rs b/src/routes/players/cape.rs new file mode 100644 index 0000000..30009df --- /dev/null +++ b/src/routes/players/cape.rs @@ -0,0 +1,54 @@ +use worker::*; + +use crate::models::ErrorResponse; +use crate::utils::json_error; + +use super::utils::{fetch_image_bytes, get_decoded_textures, resolve_uuid}; + +#[utoipa::path( + get, + path = "/v1/players/{identifier}/cape", + responses( + (status = 200, description = "Player cape in png"), + (status = 400, description = "Identifier is required", body = ErrorResponse), + (status = 404, description = "Player not found or no cape", body = ErrorResponse), + (status = 502, description = "Mojang API error", body = ErrorResponse), + ), + params( + ("identifier" = String, Path, description = "Minecraft username or UUID") + ) +)] +pub async fn get_player_cape(_req: Request, ctx: RouteContext<()>) -> Result { + let identifier = match ctx.param("identifier") { + Some(i) => i, + None => return json_error("Identifier is required", 400), + }; + + let uuid = match resolve_uuid(identifier).await? { + Ok(id) => id, + Err(error_response) => return Ok(error_response), + }; + + let tex_json = match get_decoded_textures(&uuid).await? { + Ok(t) => t, + Err(error_response) => return Ok(error_response), + }; + + let cape_url = match tex_json.textures.cape { + Some(c) => c.url, + None => return json_error("player has no cape", 404), + }; + + let cape_bytes = match fetch_image_bytes(&cape_url, "failed to fetch cape from mojang").await? { + Ok(b) => b, + Err(e) => return Ok(e), + }; + + let mut response = Response::from_bytes(cape_bytes)?; + response.headers_mut().set("Content-Type", "image/png")?; + response + .headers_mut() + .set("Cache-Control", "public, max-age=300")?; + + Ok(response) +} diff --git a/src/routes/players/head.rs b/src/routes/players/head.rs new file mode 100644 index 0000000..a14416a --- /dev/null +++ b/src/routes/players/head.rs @@ -0,0 +1,59 @@ +use worker::*; + +use crate::models::ErrorResponse; +use crate::utils::json_error; + +use super::utils::{extract_head_png, fetch_image_bytes, get_decoded_textures, resolve_uuid}; + +#[utoipa::path( + get, + path = "/v1/players/{identifier}/head", + responses( + (status = 200, description = "Player head in png"), + (status = 400, description = "Identifier is required", body = ErrorResponse), + (status = 404, description = "Player not found or no skin", body = ErrorResponse), + (status = 502, description = "Mojang API error", body = ErrorResponse), + ), + params( + ("identifier" = String, Path, description = "Minecraft username or UUID") + ) +)] +pub async fn get_player_head(_req: Request, ctx: RouteContext<()>) -> Result { + let identifier = match ctx.param("identifier") { + Some(i) => i, + None => return json_error("Identifier is required", 400), + }; + + let uuid = match resolve_uuid(identifier).await? { + Ok(id) => id, + Err(error_response) => return Ok(error_response), + }; + + let tex_json = match get_decoded_textures(&uuid).await? { + Ok(t) => t, + Err(error_response) => return Ok(error_response), + }; + + let skin_url = match tex_json.textures.skin { + Some(s) => s.url, + None => return json_error("player has no skin", 404), + }; + + let skin_bytes = match fetch_image_bytes(&skin_url, "failed to fetch skin from mojang").await? { + Ok(b) => b, + Err(e) => return Ok(e), + }; + + let head_png = match extract_head_png(&skin_bytes)? { + Ok(h) => h, + Err(e) => return Ok(e), + }; + + let mut response = Response::from_bytes(head_png)?; + response.headers_mut().set("Content-Type", "image/png")?; + response + .headers_mut() + .set("Cache-Control", "public, max-age=300")?; + + Ok(response) +} diff --git a/src/routes/players/profile.rs b/src/routes/players/profile.rs new file mode 100644 index 0000000..7081d32 --- /dev/null +++ b/src/routes/players/profile.rs @@ -0,0 +1,90 @@ +use worker::*; + +use crate::models::{ErrorResponse, TextureData}; +use crate::utils::json_error; + +use super::utils::{build_json_response, fetch_profile_and_textures, resolve_uuid, BatchBuilder}; + +#[utoipa::path( + get, + path = "/v1/players/{identifier}", + responses( + (status = 200, description = "Player textures (json or raw binary)"), + (status = 400, description = "Identifier is required", body = ErrorResponse), + (status = 404, description = "Player not found", body = ErrorResponse), + (status = 502, description = "Mojang API error", body = ErrorResponse), + ), + params( + ("identifier" = String, Path, description = "Minecraft username or UUID"), + ("format" = Option, Query, description = "Response format: 'json' (default) or 'raw'"), + ("exclude" = Option, Query, description = "Comma-separated fields to exclude, e.g. 'uuid,username'") + ) +)] +pub async fn get_player_profile(req: Request, ctx: RouteContext<()>) -> Result { + let identifier = match ctx.param("identifier") { + Some(i) => i, + None => return json_error("Identifier is required", 400), + }; + + let req_url = req.url()?; + + let is_raw = req_url + .query_pairs() + .any(|(k, v)| k == "format" && v == "raw"); + + let exclude_param = req_url + .query_pairs() + .find(|(k, _)| k == "exclude") + .map(|(_, v)| v.to_string()) + .unwrap_or_default(); + + let exclude_uuid = exclude_param.contains("uuid"); + let exclude_username = exclude_param.contains("username"); + + let uuid = match resolve_uuid(identifier).await? { + Ok(id) => id, + Err(error_response) => return Ok(error_response), + }; + + let (profile, tex) = match fetch_profile_and_textures(&uuid).await? { + Ok(res) => res, + Err(error_response) => return Ok(error_response), + }; + + let clean_textures = TextureData { + value: tex.value, + signature: tex.signature, + }; + + let opt_uuid = if exclude_uuid { + None + } else { + Some(uuid.clone()) + }; + let opt_username = if exclude_username { + None + } else { + Some(profile.name.clone()) + }; + + let mut response = if is_raw { + let mut builder = BatchBuilder::new(); + if let Some(u) = &opt_uuid { + builder = builder.add_string(u); + } + if let Some(name) = &opt_username { + builder = builder.add_string(name); + } + builder = builder.add_string(&clean_textures.value); + builder = builder.add_string(&clean_textures.signature); + builder.build()? + } else { + build_json_response(opt_uuid, opt_username, clean_textures)? + }; + + response + .headers_mut() + .set("Cache-Control", "public, max-age=300")?; + + Ok(response) +} diff --git a/src/routes/players/skin.rs b/src/routes/players/skin.rs new file mode 100644 index 0000000..4243c11 --- /dev/null +++ b/src/routes/players/skin.rs @@ -0,0 +1,54 @@ +use worker::*; + +use crate::models::ErrorResponse; +use crate::utils::json_error; + +use super::utils::{fetch_image_bytes, get_decoded_textures, resolve_uuid}; + +#[utoipa::path( + get, + path = "/v1/players/{identifier}/skin", + responses( + (status = 200, description = "Player skin in png"), + (status = 400, description = "Identifier is required", body = ErrorResponse), + (status = 404, description = "Player not found or no skin", body = ErrorResponse), + (status = 502, description = "Mojang API error", body = ErrorResponse), + ), + params( + ("identifier" = String, Path, description = "Minecraft username or UUID") + ) +)] +pub async fn get_player_skin(_req: Request, ctx: RouteContext<()>) -> Result { + let identifier = match ctx.param("identifier") { + Some(i) => i, + None => return json_error("Identifier is required", 400), + }; + + let uuid = match resolve_uuid(identifier).await? { + Ok(id) => id, + Err(error_response) => return Ok(error_response), + }; + + let tex_json = match get_decoded_textures(&uuid).await? { + Ok(t) => t, + Err(error_response) => return Ok(error_response), + }; + + let skin_url = match tex_json.textures.skin { + Some(s) => s.url, + None => return json_error("player has no skin", 404), + }; + + let skin_bytes = match fetch_image_bytes(&skin_url, "failed to fetch skin from mojang").await? { + Ok(b) => b, + Err(e) => return Ok(e), + }; + + let mut response = Response::from_bytes(skin_bytes)?; + response.headers_mut().set("Content-Type", "image/png")?; + response + .headers_mut() + .set("Cache-Control", "public, max-age=300")?; + + Ok(response) +} diff --git a/src/routes/players/utils.rs b/src/routes/players/utils.rs new file mode 100644 index 0000000..0f1ec44 --- /dev/null +++ b/src/routes/players/utils.rs @@ -0,0 +1,160 @@ +use base64::prelude::*; +use bytes::{BufMut, BytesMut}; +use worker::*; + +use crate::models::{self, CalfResponse, TextureData}; +use crate::mojang; +use crate::utils::json_error; + +pub async fn resolve_uuid(identifier: &str) -> Result> { + if identifier.len() > 16 { + Ok(Ok(identifier.replace("-", ""))) + } else { + mojang::fetch_mojang_uuid(identifier).await + } +} + +pub async fn fetch_profile_and_textures( + uuid: &str, +) -> Result> +{ + let mut profile = match mojang::fetch_mojang_profile(uuid).await? { + Ok(p) => p, + Err(e) => return Ok(Err(e)), + }; + + let tex_idx = profile.properties.iter().position(|p| p.name == "textures"); + let tex = match tex_idx { + Some(idx) => profile.properties.remove(idx), + None => return Ok(Err(json_error("texture data missing on profile", 404)?)), + }; + + Ok(Ok((profile, tex))) +} + +pub async fn get_decoded_textures( + uuid: &str, +) -> Result> { + let (_, tex) = match fetch_profile_and_textures(uuid).await? { + Ok(res) => res, + Err(e) => return Ok(Err(e)), + }; + + decode_textures_b64(&tex.value) +} + +pub fn decode_textures_b64( + base64_value: &str, +) -> Result> { + let decoded = match BASE64_STANDARD.decode(base64_value) { + Ok(d) => d, + Err(_) => return Ok(Err(json_error("failed to decode textures base64", 502)?)), + }; + + let tex_json: models::TexturesDecoded = match serde_json::from_slice(&decoded) { + Ok(j) => j, + Err(_) => return Ok(Err(json_error("failed to parse textures json", 502)?)), + }; + + Ok(Ok(tex_json)) +} + +pub async fn fetch_image_bytes( + url: &str, + error_msg: &str, +) -> Result, Response>> { + let mut res = Fetch::Url(url.parse()?).send().await?; + if res.status_code() != 200 { + return Ok(Err(json_error(error_msg, 502)?)); + } + Ok(Ok(res.bytes().await?)) +} + +pub fn extract_head_png(skin_bytes: &[u8]) -> Result, Response>> { + let img = match image::load_from_memory_with_format(skin_bytes, image::ImageFormat::Png) { + Ok(i) => i, + Err(_) => return Ok(Err(json_error("failed to parse skin image", 502)?)), + }; + + let mut head = img.crop_imm(8, 8, 8, 8); + let hat = img.crop_imm(40, 8, 8, 8); + + image::imageops::overlay(&mut head, &hat, 0, 0); + + let mut out_bytes = std::io::Cursor::new(Vec::new()); + if head + .write_to(&mut out_bytes, image::ImageFormat::Png) + .is_err() + { + return Ok(Err(json_error("failed to encode head image", 500)?)); + } + + Ok(Ok(out_bytes.into_inner())) +} + +pub fn extract_head_rgb(skin_bytes: &[u8]) -> Result, Response>> { + let img = match image::load_from_memory_with_format(skin_bytes, image::ImageFormat::Png) { + Ok(i) => i, + Err(_) => return Ok(Err(json_error("failed to parse skin image", 502)?)), + }; + + let mut head = img.crop_imm(8, 8, 8, 8); + let hat = img.crop_imm(40, 8, 8, 8); + + image::imageops::overlay(&mut head, &hat, 0, 0); + + Ok(Ok(head.into_rgb8().into_raw())) +} + +pub fn extract_image_rgb(image_bytes: &[u8]) -> Result, Response>> { + let img = match image::load_from_memory_with_format(image_bytes, image::ImageFormat::Png) { + Ok(i) => i, + Err(_) => return Ok(Err(json_error("failed to parse image", 502)?)), + }; + + Ok(Ok(img.into_rgb8().into_raw())) +} + +/// A builder to construct raw binary responses with length-prefixed parts. +pub struct BatchBuilder { + buffer: BytesMut, +} + +impl BatchBuilder { + pub fn new() -> Self { + Self { + buffer: BytesMut::new(), + } + } + + pub fn add_string(mut self, s: &str) -> Self { + self.buffer.put_u16(s.len() as u16); + self.buffer.put_slice(s.as_bytes()); + self + } + + pub fn add_bytes(mut self, b: &[u8]) -> Self { + self.buffer.put_u16(b.len() as u16); + self.buffer.put_slice(b); + self + } + + pub fn build(self) -> Result { + let mut res = Response::from_bytes(self.buffer.to_vec())?; + res.headers_mut() + .set("Content-Type", "application/octet-stream")?; + Ok(res) + } +} + +pub fn build_json_response( + uuid: Option, + username: Option, + textures: TextureData, +) -> Result { + Response::from_json(&CalfResponse { + uuid, + username, + textures, + }) +}