Compare commits

..

No commits in common. "a03bac8f1d486f40d2094d6299acdce4b12fa184" and "63121afe3253cf73e8a57694125314cf638b9d37" have entirely different histories.

9 changed files with 37 additions and 89 deletions

View File

@ -24,14 +24,11 @@
package me.braydon.mc.common; package me.braydon.mc.common;
import lombok.NonNull; import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import javax.imageio.ImageIO;
import java.awt.*; import java.awt.*;
import java.awt.geom.AffineTransform; import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
/** /**
* @author Braydon * @author Braydon
@ -68,19 +65,4 @@ public final class ImageUtils {
graphics.dispose(); graphics.dispose();
return flipped; return flipped;
} }
/**
* Get the byte array from the given image.
*
* @param image the image to extract from
* @return the byte array of the image
*/
@SneakyThrows
public static byte[] toByteArray(@NonNull BufferedImage image) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", outputStream);
outputStream.flush();
return outputStream.toByteArray();
}
}
} }

View File

@ -32,7 +32,7 @@ import me.braydon.mc.model.skin.Skin;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream; import java.net.URL;
/** /**
* A renderer for a {@link ISkinPart}. * A renderer for a {@link ISkinPart}.
@ -63,17 +63,17 @@ public abstract class SkinRenderer<T extends ISkinPart> {
*/ */
@SneakyThrows @SneakyThrows
protected final BufferedImage getVanillaSkinPart(@NonNull Skin skin, @NonNull ISkinPart.Vanilla part, double size) { protected final BufferedImage getVanillaSkinPart(@NonNull Skin skin, @NonNull ISkinPart.Vanilla part, double size) {
BufferedImage skinImage = ImageIO.read(new URL(skin.getUrl())); // The skin texture
ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part
// The skin texture is legacy, use legacy coordinates // The skin texture is legacy, use legacy coordinates
if (skin.isLegacy() && part.hasLegacyCoordinates()) { if (skinImage.getHeight() == 32 && part.hasLegacyCoordinates()) {
coordinates = part.getLegacyCoordinates(); coordinates = part.getLegacyCoordinates();
} }
int width = part.getWidth(); // The width of the part int width = part.getWidth(); // The width of the part
if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) { if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) {
width--; width--;
} }
BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); // The skin texture
BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size); BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size);
if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) { if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) {
partTexture = ImageUtils.flip(partTexture); partTexture = ImageUtils.flip(partTexture);

View File

@ -63,7 +63,6 @@ public final class PlayerController {
* A GET route to get a player by their username or UUID. * A GET route to get a player by their username or UUID.
* *
* @param query the query to search for the player by * @param query the query to search for the player by
* @param signed whether the profile is signed
* @return the player response * @return the player response
* @throws BadRequestException if the UUID or username is invalid * @throws BadRequestException if the UUID or username is invalid
* @throws ResourceNotFoundException if the player is not found * @throws ResourceNotFoundException if the player is not found
@ -72,10 +71,9 @@ public final class PlayerController {
@GetMapping("/{query}") @GetMapping("/{query}")
@ResponseBody @ResponseBody
public ResponseEntity<CachedPlayer> getPlayer( public ResponseEntity<CachedPlayer> getPlayer(
@Parameter(description = "The player username or UUID to get", example = "Rainnny") @PathVariable @NonNull String query, @Parameter(description = "The player username or UUID to get", example = "Rainnny") @PathVariable @NonNull String query
@Parameter(description = "Whether the profile is signed by Mojang") @RequestParam(required = false) boolean signed
) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException { ) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException {
return ResponseEntity.ofNullable(mojangService.getPlayer(query, signed)); return ResponseEntity.ofNullable(mojangService.getPlayer(query));
} }
/** /**

View File

@ -26,6 +26,7 @@ package me.braydon.mc.model;
import lombok.*; import lombok.*;
import me.braydon.mc.model.skin.Skin; import me.braydon.mc.model.skin.Skin;
import me.braydon.mc.model.token.MojangProfileToken; import me.braydon.mc.model.token.MojangProfileToken;
import org.springframework.data.annotation.Id;
import java.util.UUID; import java.util.UUID;
@ -39,7 +40,7 @@ public class Player {
/** /**
* The unique id of this player. * The unique id of this player.
*/ */
@EqualsAndHashCode.Include @NonNull private final UUID uniqueId; @Id @EqualsAndHashCode.Include @NonNull private final UUID uniqueId;
/** /**
* The username of this player. * The username of this player.

View File

@ -38,7 +38,7 @@ import java.io.Serializable;
@RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds) @RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds)
public final class CachedMinecraftServer implements Serializable { public final class CachedMinecraftServer implements Serializable {
/** /**
* The id of this cache element. * The id of this cached server.
*/ */
@Id @JsonIgnore @NonNull private final String id; @Id @JsonIgnore @NonNull private final String id;

View File

@ -23,7 +23,6 @@
*/ */
package me.braydon.mc.model.cache; package me.braydon.mc.model.cache;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.Setter; import lombok.Setter;
@ -33,7 +32,6 @@ import me.braydon.mc.model.Player;
import me.braydon.mc.model.ProfileAction; import me.braydon.mc.model.ProfileAction;
import me.braydon.mc.model.skin.Skin; import me.braydon.mc.model.skin.Skin;
import me.braydon.mc.model.token.MojangProfileToken; import me.braydon.mc.model.token.MojangProfileToken;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.RedisHash;
import java.io.Serializable; import java.io.Serializable;
@ -48,26 +46,16 @@ import java.util.UUID;
@ToString(callSuper = true) @ToString(callSuper = true)
@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds) @RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds)
public final class CachedPlayer extends Player implements Serializable { public final class CachedPlayer extends Player implements Serializable {
/**
* The id of this cache element.
* <p>
* This ID is in the given format:
* player:<uniqueId>-<signed>
* </p>
*/
@Id @NonNull @JsonIgnore private final String cacheId;
/** /**
* The unix timestamp of when this * The unix timestamp of when this
* player was cached, -1 if not cached. * player was cached, -1 if not cached.
*/ */
private long cached; private long cached;
public CachedPlayer(@NonNull String cacheId, @NonNull UUID uniqueId, @NonNull String username, @NonNull Skin skin, public CachedPlayer(@NonNull UUID uniqueId, @NonNull String username, @NonNull Skin skin, Cape cape,
Cape cape, @NonNull MojangProfileToken.ProfileProperty[] properties, ProfileAction[] profileActions, @NonNull MojangProfileToken.ProfileProperty[] properties, ProfileAction[] profileActions,
long cached) { long cached) {
super(uniqueId, username, skin, cape, properties, profileActions); super(uniqueId, username, skin, cape, properties, profileActions);
this.cacheId = cacheId;
this.cached = cached; this.cached = cached;
} }
} }

View File

@ -23,17 +23,12 @@
*/ */
package me.braydon.mc.model.skin; package me.braydon.mc.model.skin;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import lombok.*; import lombok.*;
import me.braydon.mc.common.ImageUtils;
import me.braydon.mc.config.AppConfig; import me.braydon.mc.config.AppConfig;
import me.braydon.mc.model.Player; import me.braydon.mc.model.Player;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.net.URL;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -42,9 +37,9 @@ import java.util.Map;
* *
* @author Braydon * @author Braydon
*/ */
@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @ToString @RequiredArgsConstructor(access = AccessLevel.PRIVATE) @Setter @Getter @ToString
public final class Skin { public final class Skin {
public static final Skin DEFAULT_STEVE = create("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1", Model.DEFAULT); public static final Skin DEFAULT_STEVE = new Skin("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1", Model.DEFAULT);
/** /**
* The texture URL of this skin. * The texture URL of this skin.
@ -56,20 +51,10 @@ public final class Skin {
*/ */
@NonNull private final Model model; @NonNull private final Model model;
/**
* The image data of this skin.
*/
@JsonIgnore private final byte[] skinImage;
/**
* Is this skin legacy?
*/
private final boolean legacy;
/** /**
* URLs to the parts of this skin. * URLs to the parts of this skin.
*/ */
@NonNull @JsonProperty("parts") private final Map<String, String> partUrls; @NonNull @JsonProperty("parts") private Map<String, String> partUrls = new HashMap<>();
/** /**
* Populate the part URLs for this skin. * Populate the part URLs for this skin.
@ -103,22 +88,7 @@ public final class Skin {
if (metadataJsonObject != null) { // Parse the skin model if (metadataJsonObject != null) { // Parse the skin model
model = Model.valueOf(metadataJsonObject.get("model").getAsString().toUpperCase()); model = Model.valueOf(metadataJsonObject.get("model").getAsString().toUpperCase());
} }
return create(jsonObject.get("url").getAsString(), model); return new Skin(jsonObject.get("url").getAsString(), model);
}
/**
* Create a skin from the given URL and model.
*
* @param url the skin url
* @param model the skin model
* @return the constructed skin
*/
@NonNull @SneakyThrows
private static Skin create(@NonNull String url, @NonNull Model model) {
BufferedImage image = ImageIO.read(new URL(url)); // Get the skin image
byte[] bytes = ImageUtils.toByteArray(image); // Convert the image into bytes
boolean legacy = image.getWidth() == 64 && image.getHeight() == 32; // Is the skin legacy?
return new Skin(url, model, bytes, legacy, new HashMap<>());
} }
/** /**

View File

@ -26,9 +26,11 @@ package me.braydon.mc.repository;
import me.braydon.mc.model.cache.CachedPlayer; import me.braydon.mc.model.cache.CachedPlayer;
import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.CrudRepository;
import java.util.UUID;
/** /**
* A cache repository for {@link CachedPlayer}'s. * A cache repository for {@link CachedPlayer}'s.
* *
* @author Braydon * @author Braydon
*/ */
public interface PlayerCacheRepository extends CrudRepository<CachedPlayer, String> { } public interface PlayerCacheRepository extends CrudRepository<CachedPlayer, UUID> { }

View File

@ -61,7 +61,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URL; import java.net.URL;
@ -214,7 +216,7 @@ public final class MojangService {
Skin skin = null; // The target skin to get the skin part of Skin skin = null; // The target skin to get the skin part of
long before = System.currentTimeMillis(); long before = System.currentTimeMillis();
try { try {
CachedPlayer player = getPlayer(query, false); // Retrieve the player CachedPlayer player = getPlayer(query); // Retrieve the player
skin = player.getSkin(); // Use the player's skin skin = player.getSkin(); // Use the player's skin
} catch (Exception ignored) { } catch (Exception ignored) {
// Simply ignore, and fallback to the default skin // Simply ignore, and fallback to the default skin
@ -229,10 +231,16 @@ public final class MojangService {
BufferedImage texture = part.render(skin, overlays, size); // Render the skin part BufferedImage texture = part.render(skin, overlays, size); // Render the skin part
log.info("Render of skin part took {}ms: {}", System.currentTimeMillis() - before, id); log.info("Render of skin part took {}ms: {}", System.currentTimeMillis() - before, id);
byte[] bytes = ImageUtils.toByteArray(texture); // Convert the image into a byte array // Convert BufferedImage to byte array
skinPartTextureCache.save(new CachedSkinPartTexture(id, bytes)); // Cache the texture try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
log.info("Cached skin part texture: {}", id); ImageIO.write(texture, "png", outputStream);
return bytes; outputStream.flush();
byte[] bytes = outputStream.toByteArray();
skinPartTextureCache.save(new CachedSkinPartTexture(id, bytes)); // Cache the texture
log.info("Cached skin part texture: {}", id);
return bytes;
}
} }
/** /**
@ -244,15 +252,14 @@ public final class MojangService {
* and then return the response. * and then return the response.
* </p> * </p>
* *
* @param query the query to search for the player by * @param query the query to search for the player by
* @param signed whether the profile is signed
* @return the player * @return the player
* @throws BadRequestException if the UUID or username is invalid * @throws BadRequestException if the UUID or username is invalid
* @throws ResourceNotFoundException if the player is not found * @throws ResourceNotFoundException if the player is not found
* @throws MojangRateLimitException if the Mojang API rate limit is reached * @throws MojangRateLimitException if the Mojang API rate limit is reached
*/ */
@NonNull @NonNull
public CachedPlayer getPlayer(@NonNull String query, boolean signed) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException { public CachedPlayer getPlayer(@NonNull String query) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException {
log.info("Requesting player with query: {}", query); log.info("Requesting player with query: {}", query);
UUID uuid; // The player UUID to lookup UUID uuid; // The player UUID to lookup
@ -271,11 +278,10 @@ public final class MojangService {
uuid = usernameToUUID(query); uuid = usernameToUUID(query);
log.info("Found UUID for username {}: {}", query, uuid); log.info("Found UUID for username {}: {}", query, uuid);
} }
String cacheId = "%s-%s".formatted(uuid, signed); // The cache id of the player
// Check the cache for the player // Check the cache for the player
// and return it if it's present // and return it if it's present
Optional<CachedPlayer> cached = playerCache.findById(cacheId); Optional<CachedPlayer> cached = playerCache.findById(uuid);
if (cached.isPresent()) { // Respond with the cache if present if (cached.isPresent()) { // Respond with the cache if present
log.info("Found player in cache: {}", uuid); log.info("Found player in cache: {}", uuid);
return cached.get(); return cached.get();
@ -285,13 +291,14 @@ public final class MojangService {
// the player profile by their UUID // the player profile by their UUID
try { try {
log.info("Retrieving player profile for UUID: {}", uuid); log.info("Retrieving player profile for UUID: {}", uuid);
String endpoint = UUID_TO_PROFILE.formatted(uuid) + (signed ? "?unsigned=false" : ""); MojangProfileToken token = JsonWebRequest.makeRequest(
MojangProfileToken token = JsonWebRequest.makeRequest(endpoint, HttpMethod.GET).execute(MojangProfileToken.class); UUID_TO_PROFILE.formatted(uuid), HttpMethod.GET
).execute(MojangProfileToken.class);
MojangProfileToken.SkinProperties skinProperties = token.getSkinProperties(); // Get the skin and cape MojangProfileToken.SkinProperties skinProperties = token.getSkinProperties(); // Get the skin and cape
ProfileAction[] profileActions = token.getProfileActions(); ProfileAction[] profileActions = token.getProfileActions();
// Build our player model, cache it, and then return it // Build our player model, cache it, and then return it
CachedPlayer player = new CachedPlayer(cacheId, uuid, token.getName(), CachedPlayer player = new CachedPlayer(uuid, token.getName(),
skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(), skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(),
skinProperties.getCape(), token.getProperties(), profileActions.length == 0 ? null : profileActions, skinProperties.getCape(), token.getProperties(), profileActions.length == 0 ? null : profileActions,
System.currentTimeMillis() System.currentTimeMillis()