Files
SCOPENET-MC/panel/server/src/textures.rs
T
Claude 77a15cab8b Launcher: panel accounts via authlib-injector, skin & cape editor
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
2026-09-28 08:58:27 +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.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]);
}
}