diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java index 5a9a450..1847189 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java @@ -1,68 +1,233 @@ package eu.mhsl.minecraft.pixelpics.assets; import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; -import java.net.URLConnection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; /** - * Player skins by URL as 64x64 ARGB grids (legacy 64x32 skins converted). Downloaded off the main thread, from + * Player skins by profile texture URL as 64x64 ARGB grids, processed like vanilla's + * {@code SkinTextureDownloader.processLegacySkin} (legacy 64x32 skins converted). Downloaded off the main thread, from * the baking step. + * + *

Profile textures of heads and NPCs are not signed, so the URL is untrusted: only Mojang's texture server is + * contacted ({@code http(s)://textures.minecraft.net/texture/…}, fetched over https without redirects), the + * download is capped at {@link #MAX_BYTES} and the PNG header must declare 64x64 or 64x32 (the only sizes vanilla + * accepts) before anything is decoded. Concurrent requests for one URL share a single download; other URLs are not + * blocked meanwhile. Failed downloads are retried after a growing backoff. The cache keeps the {@link #CAPACITY} + * most recently used skins. */ -public final class SkinCache extends ImageCache { +public final class SkinCache { - @Override + static final String HOST = "textures.minecraft.net"; + static final int MAX_BYTES = 128 * 1024; + private static final int CAPACITY = 512; + private static final int TIMEOUT_MILLIS = 5000; + private static final long FIRST_BACKOFF_MILLIS = 60_000; + private static final long MAX_BACKOFF_MILLIS = 60 * 60_000; + + private record Failure(long retryAt, long backoff) {} + + private final Map skins = new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > CAPACITY; + } + }; + private final Map failures = new LinkedHashMap<>(); + private final Map> downloads = new ConcurrentHashMap<>(); + + /** The skin at {@code url}, or empty (no/disallowed URL, download failed or backing off, invalid image). */ public Optional get(String url) { - if (url == null || url.isEmpty()) return Optional.empty(); - return super.get(url); + String fetch = httpsUrl(url); + if (fetch == null) return Optional.empty(); + synchronized (this) { + int[][] cached = skins.get(fetch); + if (cached != null) return Optional.of(cached); + Failure failed = failures.get(fetch); + if (failed != null && System.currentTimeMillis() < failed.retryAt()) return Optional.empty(); + } + CompletableFuture mine = new CompletableFuture<>(); + CompletableFuture running = downloads.putIfAbsent(fetch, mine); + if (running != null) return Optional.ofNullable(running.join()); + + int[][] skin = null; + try { + synchronized (this) { + skin = skins.get(fetch); // finished between the cache check and putIfAbsent + } + if (skin == null) skin = load(fetch); + } finally { + synchronized (this) { + if (skin != null) { + skins.put(fetch, skin); + failures.remove(fetch); + } else { + Failure before = failures.get(fetch); + long backoff = + before == null ? FIRST_BACKOFF_MILLIS : Math.min(MAX_BACKOFF_MILLIS, before.backoff() * 2); + failures.put(fetch, new Failure(System.currentTimeMillis() + backoff, backoff)); + pruneFailures(); + } + } + downloads.remove(fetch); + mine.complete(skin); + } + return Optional.ofNullable(skin); } - @Override - protected int[][] load(String url) { + /** Drops failures whose backoff ran out, and the oldest ones beyond the capacity. */ + private void pruneFailures() { + long now = System.currentTimeMillis(); + Iterator it = failures.values().iterator(); + while (it.hasNext()) { + if (it.next().retryAt() <= now || failures.size() > CAPACITY) it.remove(); + } + } + + /** + * The https form of a texture URL on {@link #HOST} ({@code /texture/}, Mojang profiles use http), or null + * for any other URL. + */ + static String httpsUrl(String url) { + if (url == null || url.isEmpty()) return null; try { - URL u = URI.create(url).toURL(); - URLConnection conn = u.openConnection(); - conn.setConnectTimeout(5000); - conn.setReadTimeout(5000); - conn.setRequestProperty("User-Agent", "PixelPics"); - BufferedImage img; - try (InputStream in = conn.getInputStream()) { - img = ImageIO.read(in); - } - if (img == null) return null; - return toModern(img); + URI u = new URI(url); + String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) return null; + if (u.getRawUserInfo() != null || u.getPort() != -1 || u.getRawQuery() != null) return null; + if (u.getHost() == null || !u.getHost().equalsIgnoreCase(HOST)) return null; + String path = u.getRawPath(); + if (path == null || !path.matches("/texture/[0-9a-zA-Z]+")) return null; + return "https://" + HOST + path; } catch (Exception e) { return null; } } - /** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */ - private int[][] toModern(BufferedImage img) { - int w = Math.min(img.getWidth(), 64); - int h = Math.min(img.getHeight(), 64); - int[][] out = new int[64][64]; - int[][] src = pixels(img, w, h); - for (int y = 0; y < h; y++) System.arraycopy(src[y], 0, out[y], 0, w); - if (h <= 32) { - // Legacy skin: mirrored copies of the right-arm region (40,16) into the modern left-arm slot (32,48). - copyMirrored(img, out, 44, 16, 36, 48); - copyMirrored(img, out, 44, 16, 36, 52); - copyMirrored(img, out, 40, 16, 32, 48); + private static int[][] load(String url) { + try { + HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); + try { + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(TIMEOUT_MILLIS); + conn.setReadTimeout(TIMEOUT_MILLIS); + conn.setRequestProperty("User-Agent", "PixelPics"); + if (conn.getResponseCode() / 100 != 2) return null; + if (conn.getContentLengthLong() > MAX_BYTES) return null; + byte[] data; + try (InputStream in = conn.getInputStream()) { + data = readLimited(in); + } + return data == null ? null : decode(data); + } finally { + conn.disconnect(); + } + } catch (IOException | RuntimeException e) { + return null; } - return out; } - /** Mirror-copies a 16x12 block (whole block flipped horizontally, not per face); coarse approximation. */ - private void copyMirrored(BufferedImage img, int[][] out, int srcX, int srcY, int dstX, int dstY) { - for (int y = 0; y < 12 && srcY + y < img.getHeight(); y++) { - for (int x = 0; x < 16 && srcX + x < img.getWidth(); x++) { - int px = img.getRGB(srcX + (15 - x), srcY + y); - int ox = dstX + x, oy = dstY + y; - if (ox < 64 && oy < 64) out[oy][ox] = px; + /** At most {@link #MAX_BYTES} bytes of {@code in}; null when there are more. */ + static byte[] readLimited(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int total = 0; + for (int n; (n = in.read(buf)) != -1; ) { + total += n; + if (total > MAX_BYTES) return null; + out.write(buf, 0, n); + } + return out.toByteArray(); + } + + /** Decodes a PNG skin whose header declares 64x64 or 64x32 (checked before decoding); null otherwise. */ + static int[][] decode(byte[] png) throws IOException { + try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(png))) { + Iterator readers = ImageIO.getImageReadersByFormatName("png"); + if (in == null || !readers.hasNext()) return null; + ImageReader reader = readers.next(); + try { + reader.setInput(in, true, true); + int w = reader.getWidth(0), h = reader.getHeight(0); + if (w != 64 || (h != 64 && h != 32)) return null; // vanilla discards other sizes + BufferedImage img = reader.read(0); + int[][] argb = new int[h][]; + for (int y = 0; y < h; y++) argb[y] = img.getRGB(0, y, w, 1, null, 0, w); + return processSkin(argb); + } finally { + reader.dispose(); } } } + + /** + * SkinTextureDownloader.processLegacySkin (26.3) on a 64x64 or 64x32 ARGB grid: a legacy skin is extended to + * 64x64 with the left arm and leg made from the right ones (each face mirrored in place), its hat layer gets the + * "Notch transparency hack", and the base layers are made opaque. + */ + static int[][] processSkin(int[][] src) { + boolean legacy = src.length == 32; + int[][] img = new int[64][64]; + for (int y = 0; y < src.length; y++) System.arraycopy(src[y], 0, img[y], 0, 64); + if (legacy) { + copyRect(img, 4, 16, 16, 32, 4, 4); + copyRect(img, 8, 16, 16, 32, 4, 4); + copyRect(img, 0, 20, 24, 32, 4, 12); + copyRect(img, 4, 20, 16, 32, 4, 12); + copyRect(img, 8, 20, 8, 32, 4, 12); + copyRect(img, 12, 20, 16, 32, 4, 12); + copyRect(img, 44, 16, -8, 32, 4, 4); + copyRect(img, 48, 16, -8, 32, 4, 4); + copyRect(img, 40, 20, 0, 32, 4, 12); + copyRect(img, 44, 20, -8, 32, 4, 12); + copyRect(img, 48, 20, -16, 32, 4, 12); + copyRect(img, 52, 20, -8, 32, 4, 12); + } + setNoAlpha(img, 0, 0, 32, 16); + if (legacy) notchTransparencyHack(img, 32, 0, 64, 32); + setNoAlpha(img, 0, 16, 64, 32); + setNoAlpha(img, 16, 48, 48, 64); + return img; + } + + /** NativeImage.copyRect(startX, startY, offsetX, offsetY, sizeX, sizeY, swapX = true, swapY = false). */ + private static void copyRect(int[][] img, int sx, int sy, int offX, int offY, int w, int h) { + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + img[sy + offY + y][sx + offX + (w - 1 - x)] = img[sy + y][sx + x]; + } + } + } + + /** Clears the alpha of the whole region only if every texel in it has alpha >= 128 (doNotchTransparencyHack). */ + private static void notchTransparencyHack(int[][] img, int x0, int y0, int x1, int y1) { + for (int y = y0; y < y1; y++) { + for (int x = x0; x < x1; x++) { + if ((img[y][x] >>> 24) < 128) return; + } + } + for (int y = y0; y < y1; y++) { + for (int x = x0; x < x1; x++) img[y][x] &= 0xFFFFFF; + } + } + + private static void setNoAlpha(int[][] img, int x0, int y0, int x1, int y1) { + for (int y = y0; y < y1; y++) { + for (int x = x0; x < x1; x++) img[y][x] |= 0xFF000000; + } + } }