Panel: Yggdrasil auth server (authlib-injector), skins and capes
- Yggdrasil API per the authlib-injector spec: metadata with signing key and skin domains, authenticate/refresh/validate/invalidate/signout, join/hasJoined, profile lookup, texture upload, and the minecraftservices endpoints (chat certificates, publickeys, attributes, blocklist) - 4096-bit signing key generated once into the data volume; textures and chat certificates signed SHA1withRSA (verified with Java's own crypto) - Skins/capes stored content-addressed after validation and re-encoding; cape library with public/group/private visibility; head avatars API - Launcher login returns a game session; launcher sessions recorded for launcher-only servers; authlib-injector download mirror - Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens, sessions, chat keys, game server tables - Remove Microsoft sign-in from the engine and panel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
29 files changed
+2436
-400
No files matched your search
@@ -0,0 +1,124 @@
|
||||
//! Skins and capes: validation, storage (content-addressed PNGs under
|
||||
//! `data/textures/`) and rendering of player heads for avatars.
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use image::{imageops, ImageFormat, RgbaImage};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Skin,
|
||||
Cape,
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> AppResult<RgbaImage> {
|
||||
if bytes.len() > 2 * 1024 * 1024 {
|
||||
return Err(AppError::bad_request("image too large (2 MB max)"));
|
||||
}
|
||||
let img =
|
||||
image::load_from_memory_with_format(bytes, ImageFormat::Png).map_err(|_| AppError::bad_request("that isn't a valid PNG image"))?;
|
||||
Ok(img.to_rgba8())
|
||||
}
|
||||
|
||||
fn check_size(kind: Kind, w: u32, h: u32) -> AppResult<()> {
|
||||
let ok = match kind {
|
||||
// The vanilla client only accepts these two layouts.
|
||||
Kind::Skin => (w, h) == (64, 64) || (w, h) == (64, 32),
|
||||
// 64x32 is standard; HD capes are multiples of it; 22x17 is the old format.
|
||||
Kind::Cape => (w % 64 == 0 && h * 2 == w && w <= 1024) || (w, h) == (22, 17),
|
||||
};
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::bad_request(match kind {
|
||||
Kind::Skin => format!("skins must be 64×64 (or legacy 64×32) pixels — this one is {w}×{h}"),
|
||||
Kind::Cape => format!("capes must be 64×32 pixels — this one is {w}×{h}"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate, re-encode (strips metadata and anything smuggled inside the
|
||||
/// file) and store a texture. Returns its hash.
|
||||
pub fn store(dir: &Path, kind: Kind, bytes: &[u8]) -> AppResult<String> {
|
||||
let img = decode(bytes)?;
|
||||
check_size(kind, img.width(), img.height())?;
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||
.map_err(|e| AppError::bad_request(format!("couldn't process image: {e}")))?;
|
||||
let hash = hex::encode(Sha256::digest(&out));
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = dir.join(format!("{hash}.png"));
|
||||
if !path.exists() {
|
||||
std::fs::write(&path, &out)?;
|
||||
}
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn path(dir: &Path, hash: &str) -> Option<PathBuf> {
|
||||
// Hashes are hex only — never let a request escape the directory.
|
||||
(hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| dir.join(format!("{hash}.png")))
|
||||
}
|
||||
|
||||
/// Front view of the head (face + hat layer), scaled with nearest-neighbour
|
||||
/// so pixels stay crisp.
|
||||
pub fn render_head(skin_png: &[u8], size: u32) -> AppResult<Vec<u8>> {
|
||||
let skin = decode(skin_png)?;
|
||||
let mut face = imageops::crop_imm(&skin, 8, 8, 8, 8).to_image();
|
||||
if skin.height() >= 16 && skin.width() >= 48 {
|
||||
let hat = imageops::crop_imm(&skin, 40, 8, 8, 8).to_image();
|
||||
// Some skins fill the hat layer with an opaque colour; ignore it then.
|
||||
let opaque_hat = hat.pixels().all(|p| p[3] == 255);
|
||||
if !opaque_hat {
|
||||
imageops::overlay(&mut face, &hat, 0, 0);
|
||||
}
|
||||
}
|
||||
let size = size.clamp(8, 512);
|
||||
let scaled = imageops::resize(&face, size, size, imageops::FilterType::Nearest);
|
||||
let mut out = Vec::new();
|
||||
scaled.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| AppError::bad_request(e.to_string()))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
||||
pub fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let img = RgbaImage::from_pixel(w, h, image::Rgba(rgba));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_and_stores() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let h1 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap();
|
||||
let h2 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap();
|
||||
assert_eq!(h1, h2, "content-addressed");
|
||||
assert!(path(dir.path(), &h1).unwrap().exists());
|
||||
assert!(store(dir.path(), Kind::Skin, &png(32, 32, [0, 0, 0, 255])).is_err());
|
||||
assert!(store(dir.path(), Kind::Cape, &png(64, 32, [0, 0, 0, 255])).is_ok());
|
||||
assert!(store(dir.path(), Kind::Skin, b"not a png").is_err());
|
||||
assert!(path(dir.path(), "../../etc/passwd").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_heads() {
|
||||
let mut skin = RgbaImage::from_pixel(64, 64, image::Rgba([0, 0, 0, 0]));
|
||||
for x in 8..16 {
|
||||
for y in 8..16 {
|
||||
skin.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
}
|
||||
}
|
||||
skin.put_pixel(40, 8, image::Rgba([0, 0, 255, 255])); // one hat pixel
|
||||
let mut bytes = Vec::new();
|
||||
skin.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap();
|
||||
let head = image::load_from_memory(&render_head(&bytes, 64).unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!(head.dimensions(), (64, 64));
|
||||
assert_eq!(head.get_pixel(0, 0).0, [0, 0, 255, 255], "hat overlays the face");
|
||||
assert_eq!(head.get_pixel(63, 63).0, [255, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user