Microsoft sign-in is gone. Server accounts now receive Yggdrasil tokens from the panel and launch with authlib-injector pointed at the panel's auth server, so online-mode servers verify players against it. - Download authlib-injector from the panel mirror (fallback: official), SHA-256 verified and cached, with prefetched metadata - New Skin & cape settings tab: upload, arm style, reset, cape picker with front/back previews - Avatars render from the panel's head endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
125 lines
4.9 KiB
Rust
125 lines
4.9 KiB
Rust
//! 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.is_multiple_of(64) && 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]);
|
||
}
|
||
}
|