Files
SCOPENET-MC/panel/server/src/textures.rs
T
Claude 99b45141fc 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
2026-09-28 06:57:07 +00:00

125 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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]);
}
}