From fcb8ef035753704d8e7bcc4eaddbab96e6814f7f Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 10 Apr 2024 07:43:38 +0100 Subject: [PATCH] add server pinger --- src/main/java/cc.fascinated/EnumUtils.java | 26 +++ src/main/java/cc.fascinated/Main.java | 35 ++++ .../java/cc.fascinated/common/DNSUtils.java | 59 +++++++ .../java/cc.fascinated/common/IPUtils.java | 42 +++++ .../cc.fascinated/common/PlayerUtils.java | 90 ++++++++++ src/main/java/cc.fascinated/common/Tuple.java | 18 ++ .../java/cc.fascinated/common/UUIDUtils.java | 25 +++ .../java/cc.fascinated/common/WebRequest.java | 41 +++++ .../common/packet/MinecraftJavaPacket.java | 66 ++++++++ .../JavaPacketHandshakingInSetProtocol.java | 64 +++++++ .../impl/java/JavaPacketStatusInStart.java | 62 +++++++ .../java/cc.fascinated/config/Config.java | 20 +++ .../cc.fascinated/config/RedisConfig.java | 73 ++++++++ .../controller/HomeController.java | 23 +++ .../controller/PlayerController.java | 48 ++++++ .../controller/ServerController.java | 32 ++++ .../exception/ExceptionControllerAdvice.java | 45 +++++ .../exception/impl/BadRequestException.java | 12 ++ .../impl/ResourceNotFoundException.java | 9 + .../cc.fascinated/log/TransactionLogger.java | 78 +++++++++ .../model/cache/CachedMinecraftServer.java | 31 ++++ .../model/cache/CachedPlayer.java | 34 ++++ .../model/cache/CachedPlayerName.java | 27 +++ .../model/mojang/JavaServerStatusToken.java | 13 ++ .../model/mojang/MojangProfile.java | 111 ++++++++++++ .../model/mojang/MojangUsernameToUuid.java | 27 +++ .../java/cc.fascinated/model/player/Cape.java | 27 +++ .../cc.fascinated/model/player/Player.java | 49 ++++++ .../java/cc.fascinated/model/player/Skin.java | 158 ++++++++++++++++++ .../model/response/ErrorResponse.java | 40 +++++ .../model/server/JavaMinecraftServer.java | 10 ++ .../model/server/MinecraftServer.java | 42 +++++ .../MinecraftServerCacheRepository.java | 11 ++ .../repository/PlayerCacheRepository.java | 13 ++ .../repository/PlayerNameCacheRepository.java | 18 ++ .../service/MojangAPIService.java | 40 +++++ .../cc.fascinated/service/PlayerService.java | 93 +++++++++++ .../cc.fascinated/service/ServerService.java | 64 +++++++ .../service/pinger/MinecraftServerPinger.java | 11 ++ .../impl/JavaMinecraftServerPinger.java | 64 +++++++ 40 files changed, 1751 insertions(+) create mode 100644 src/main/java/cc.fascinated/EnumUtils.java create mode 100644 src/main/java/cc.fascinated/Main.java create mode 100644 src/main/java/cc.fascinated/common/DNSUtils.java create mode 100644 src/main/java/cc.fascinated/common/IPUtils.java create mode 100644 src/main/java/cc.fascinated/common/PlayerUtils.java create mode 100644 src/main/java/cc.fascinated/common/Tuple.java create mode 100644 src/main/java/cc.fascinated/common/UUIDUtils.java create mode 100644 src/main/java/cc.fascinated/common/WebRequest.java create mode 100644 src/main/java/cc.fascinated/common/packet/MinecraftJavaPacket.java create mode 100644 src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java create mode 100644 src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketStatusInStart.java create mode 100644 src/main/java/cc.fascinated/config/Config.java create mode 100644 src/main/java/cc.fascinated/config/RedisConfig.java create mode 100644 src/main/java/cc.fascinated/controller/HomeController.java create mode 100644 src/main/java/cc.fascinated/controller/PlayerController.java create mode 100644 src/main/java/cc.fascinated/controller/ServerController.java create mode 100644 src/main/java/cc.fascinated/exception/ExceptionControllerAdvice.java create mode 100644 src/main/java/cc.fascinated/exception/impl/BadRequestException.java create mode 100644 src/main/java/cc.fascinated/exception/impl/ResourceNotFoundException.java create mode 100644 src/main/java/cc.fascinated/log/TransactionLogger.java create mode 100644 src/main/java/cc.fascinated/model/cache/CachedMinecraftServer.java create mode 100644 src/main/java/cc.fascinated/model/cache/CachedPlayer.java create mode 100644 src/main/java/cc.fascinated/model/cache/CachedPlayerName.java create mode 100644 src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java create mode 100644 src/main/java/cc.fascinated/model/mojang/MojangProfile.java create mode 100644 src/main/java/cc.fascinated/model/mojang/MojangUsernameToUuid.java create mode 100644 src/main/java/cc.fascinated/model/player/Cape.java create mode 100644 src/main/java/cc.fascinated/model/player/Player.java create mode 100644 src/main/java/cc.fascinated/model/player/Skin.java create mode 100644 src/main/java/cc.fascinated/model/response/ErrorResponse.java create mode 100644 src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java create mode 100644 src/main/java/cc.fascinated/model/server/MinecraftServer.java create mode 100644 src/main/java/cc.fascinated/repository/MinecraftServerCacheRepository.java create mode 100644 src/main/java/cc.fascinated/repository/PlayerCacheRepository.java create mode 100644 src/main/java/cc.fascinated/repository/PlayerNameCacheRepository.java create mode 100644 src/main/java/cc.fascinated/service/MojangAPIService.java create mode 100644 src/main/java/cc.fascinated/service/PlayerService.java create mode 100644 src/main/java/cc.fascinated/service/ServerService.java create mode 100644 src/main/java/cc.fascinated/service/pinger/MinecraftServerPinger.java create mode 100644 src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java diff --git a/src/main/java/cc.fascinated/EnumUtils.java b/src/main/java/cc.fascinated/EnumUtils.java new file mode 100644 index 0000000..689eae1 --- /dev/null +++ b/src/main/java/cc.fascinated/EnumUtils.java @@ -0,0 +1,26 @@ +package cc.fascinated; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; + +/** + * @author Braydon + */ +@UtilityClass +public final class EnumUtils { + /** + * Get the enum constant of the specified enum type with the specified name. + * + * @param enumType the enum type + * @param name the name of the constant to return + * @param the type of the enum + * @return the enum constant of the specified enum type with the specified name + */ + public > T getEnumConstant(@NonNull Class enumType, @NonNull String name) { + try { + return Enum.valueOf(enumType, name); + } catch (IllegalArgumentException ex) { + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/Main.java b/src/main/java/cc.fascinated/Main.java new file mode 100644 index 0000000..5383feb --- /dev/null +++ b/src/main/java/cc.fascinated/Main.java @@ -0,0 +1,35 @@ +package cc.fascinated; + +import com.google.gson.Gson; +import lombok.SneakyThrows; +import lombok.extern.log4j.Log4j2; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import java.io.File; +import java.net.http.HttpClient; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Objects; + +@SpringBootApplication @Log4j2 +public class Main { + + public static final Gson GSON = new Gson(); + public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + @SneakyThrows + public static void main(String[] args) { + File config = new File("application.yml"); + if (!config.exists()) { // Saving the default config if it doesn't exist locally + Files.copy(Objects.requireNonNull(Main.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.info("Saved the default configuration to '{}', please re-launch the application", // Log the default config being saved + config.getAbsolutePath() + ); + return; + } + log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config + + SpringApplication.run(Main.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/DNSUtils.java b/src/main/java/cc.fascinated/common/DNSUtils.java new file mode 100644 index 0000000..a67078b --- /dev/null +++ b/src/main/java/cc.fascinated/common/DNSUtils.java @@ -0,0 +1,59 @@ +package cc.fascinated.common; + +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import org.xbill.DNS.Record; +import org.xbill.DNS.*; + +import java.net.InetAddress; +import java.net.InetSocketAddress; + +/** + * @author Braydon + */ +@UtilityClass +public final class DNSUtils { + private static final String SRV_QUERY_PREFIX = "_minecraft._tcp.%s"; + + /** + * Resolve the hostname to an {@link InetSocketAddress}. + * + * @param hostname the hostname to resolve + * @return the resolved {@link InetSocketAddress} + */ + @SneakyThrows + public static InetSocketAddress resolveSRV(@NonNull String hostname) { + Record[] records = new Lookup(SRV_QUERY_PREFIX.formatted(hostname), Type.SRV).run(); // Resolve SRV records + if (records == null) { // No records exist + return null; + } + String host = null; + int port = -1; + for (Record record : records) { + SRVRecord srv = (SRVRecord) record; + host = srv.getTarget().toString().replaceFirst("\\.$", ""); + port = srv.getPort(); + } + return host == null ? null : new InetSocketAddress(host, port); + } + + /** + * Resolve the hostname to an {@link InetAddress}. + * + * @param hostname the hostname to resolve + * @return the resolved {@link InetAddress} + */ + @SneakyThrows + public static InetAddress resolveA(@NonNull String hostname) { + Record[] records = new Lookup(hostname, Type.A).run(); // Resolve A records + if (records == null) { // No records exist + return null; + } + InetAddress address = null; + for (Record record : records) { + address = ((ARecord) record).getAddress(); + } + return address; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/IPUtils.java b/src/main/java/cc.fascinated/common/IPUtils.java new file mode 100644 index 0000000..eed9631 --- /dev/null +++ b/src/main/java/cc.fascinated/common/IPUtils.java @@ -0,0 +1,42 @@ +package cc.fascinated.common; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class IPUtils { + /** + * The headers that contain the IP. + */ + private static final String[] IP_HEADERS = new String[] { + "CF-Connecting-IP", + "X-Forwarded-For" + }; + + /** + * Get the real IP from the given request. + * + * @param request the request + * @return the real IP + */ + public static String getRealIp(HttpServletRequest request) { + String ip = request.getRemoteAddr(); + for (String headerName : IP_HEADERS) { + String header = request.getHeader(headerName); + if (header == null) { + continue; + } + if (!header.contains(",")) { // Handle single IP + ip = header; + break; + } + // Handle multiple IPs + String[] ips = header.split(","); + for (String ipHeader : ips) { + ip = ipHeader; + break; + } + } + return ip; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/PlayerUtils.java b/src/main/java/cc.fascinated/common/PlayerUtils.java new file mode 100644 index 0000000..090ef43 --- /dev/null +++ b/src/main/java/cc.fascinated/common/PlayerUtils.java @@ -0,0 +1,90 @@ +package cc.fascinated.common; + +import cc.fascinated.Main; +import cc.fascinated.exception.impl.BadRequestException; +import cc.fascinated.model.player.Skin; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import lombok.extern.log4j.Log4j2; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; + +@UtilityClass @Log4j2 +public class PlayerUtils { + + /** + * Gets the UUID from the string. + * + * @param id the id string + * @return the UUID + */ + public static UUID getUuidFromString(String id) { + UUID uuid; + boolean isFullUuid = id.length() == 36; + if (id.length() == 32 || isFullUuid) { + try { + uuid = isFullUuid ? UUID.fromString(id) : UUIDUtils.addDashes(id); + } catch (IllegalArgumentException exception) { + throw new BadRequestException("Invalid UUID provided: %s".formatted(id)); + } + return uuid; + } + return null; + } + + /** + * Gets the skin data from the URL. + * + * @return the skin data + */ + @SneakyThrows + @JsonIgnore + public static byte[] getSkinImage(String url) { + HttpResponse response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder(URI.create(url)).build(), + HttpResponse.BodyHandlers.ofByteArray()); + return response.body(); + } + + /** + * Gets the part data from the skin. + * + * @return the part data + */ + public static byte[] getSkinPartBytes(Skin skin, Skin.Parts part, int size) { + if (size == -1) { + size = part.getDefaultSize(); + } + + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); + if (image == null) { + image = ImageIO.read(new ByteArrayInputStream(Skin.DEFAULT_SKIN.getSkinImage())); // Fallback to the default skin + } + // Get the part of the image (e.g. the head) + BufferedImage partImage = image.getSubimage(part.getX(), part.getY(), part.getWidth(), part.getHeight()); + + // Scale the image + BufferedImage scaledImage = new BufferedImage(size, size, partImage.getType()); + Graphics2D graphics2D = scaledImage.createGraphics(); + graphics2D.drawImage(partImage, 0, 0, size, size, null); + graphics2D.dispose(); + partImage = scaledImage; + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + ImageIO.write(partImage, "png", byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } catch (Exception ex) { + log.error("Failed to get {} part bytes for {}", part.name(), skin.getUrl(), ex); + return null; + } + } +} diff --git a/src/main/java/cc.fascinated/common/Tuple.java b/src/main/java/cc.fascinated/common/Tuple.java new file mode 100644 index 0000000..d51de5d --- /dev/null +++ b/src/main/java/cc.fascinated/common/Tuple.java @@ -0,0 +1,18 @@ +package cc.fascinated.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter @AllArgsConstructor +public class Tuple { + + /** + * The left value of the tuple. + */ + private final L left; + + /** + * The right value of the tuple. + */ + private final R right; +} diff --git a/src/main/java/cc.fascinated/common/UUIDUtils.java b/src/main/java/cc.fascinated/common/UUIDUtils.java new file mode 100644 index 0000000..3bf486d --- /dev/null +++ b/src/main/java/cc.fascinated/common/UUIDUtils.java @@ -0,0 +1,25 @@ +package cc.fascinated.common; + +import io.micrometer.common.lang.NonNull; +import lombok.experimental.UtilityClass; + +import java.util.UUID; + +@UtilityClass +public class UUIDUtils { + + /** + * Add dashes to a UUID. + * + * @param trimmed the UUID without dashes + * @return the UUID with dashes + */ + @NonNull + public static UUID addDashes(@NonNull String trimmed) { + StringBuilder builder = new StringBuilder(trimmed); + for (int i = 0, pos = 20; i < 4; i++, pos -= 4) { + builder.insert(pos, "-"); + } + return UUID.fromString(builder.toString()); + } +} diff --git a/src/main/java/cc.fascinated/common/WebRequest.java b/src/main/java/cc.fascinated/common/WebRequest.java new file mode 100644 index 0000000..dc5ef00 --- /dev/null +++ b/src/main/java/cc.fascinated/common/WebRequest.java @@ -0,0 +1,41 @@ +package cc.fascinated.common; + +import lombok.experimental.UtilityClass; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +@UtilityClass +public class WebRequest { + + /** + * The web client. + */ + private static final RestClient CLIENT = RestClient.builder() + .requestFactory(new HttpComponentsClientHttpRequestFactory()) + .build(); + + /** + * Gets a response from the given URL. + * + * @param url the url + * @return the response + * @param the type of the response + */ + public static T getAsEntity(String url, Class clazz) { + try { + ResponseEntity profile = CLIENT.get() + .uri(url) + .retrieve() + .toEntity(clazz); + + if (profile.getStatusCode().isError()) { + return null; + } + return profile.getBody(); + } catch (HttpClientErrorException ex) { + return null; + } + } +} diff --git a/src/main/java/cc.fascinated/common/packet/MinecraftJavaPacket.java b/src/main/java/cc.fascinated/common/packet/MinecraftJavaPacket.java new file mode 100644 index 0000000..55d4dd9 --- /dev/null +++ b/src/main/java/cc.fascinated/common/packet/MinecraftJavaPacket.java @@ -0,0 +1,66 @@ +package cc.fascinated.common.packet; + +import lombok.NonNull; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * Represents a packet in the + * Minecraft Java protocol. + * + * @author Braydon + * @see Protocol Docs + */ +public abstract class MinecraftJavaPacket { + /** + * Process this packet. + * + * @param inputStream the input stream to read from + * @param outputStream the output stream to write to + * @throws IOException if an I/O error occurs + */ + public abstract void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException; + + /** + * Write a variable integer to the output stream. + * + * @param outputStream the output stream to write to + * @param paramInt the integer to write + * @throws IOException if an I/O error occurs + */ + protected final void writeVarInt(DataOutputStream outputStream, int paramInt) throws IOException { + while (true) { + if ((paramInt & 0xFFFFFF80) == 0) { + outputStream.writeByte(paramInt); + return; + } + outputStream.writeByte(paramInt & 0x7F | 0x80); + paramInt >>>= 7; + } + } + + /** + * Read a variable integer from the input stream. + * + * @param inputStream the input stream to read from + * @return the integer that was read + * @throws IOException if an I/O error occurs + */ + protected final int readVarInt(@NonNull DataInputStream inputStream) throws IOException { + int i = 0; + int j = 0; + while (true) { + int k = inputStream.readByte(); + i |= (k & 0x7F) << j++ * 7; + if (j > 5) { + throw new RuntimeException("VarInt too big"); + } + if ((k & 0x80) != 128) { + break; + } + } + return i; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java b/src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java new file mode 100644 index 0000000..d589ad2 --- /dev/null +++ b/src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java @@ -0,0 +1,64 @@ +package cc.fascinated.common.packet.impl.java; + +import cc.fascinated.common.packet.MinecraftJavaPacket; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import lombok.ToString; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * This packet is sent by the client to the server to set + * the hostname, port, and protocol version of the client. + * + * @author Braydon + * @see Protocol Docs + */ +@AllArgsConstructor @ToString +public final class JavaPacketHandshakingInSetProtocol extends MinecraftJavaPacket { + private static final byte ID = 0x00; // The ID of the packet + private static final int STATUS_HANDSHAKE = 1; // The status handshake ID + + /** + * The hostname of the server. + */ + @NonNull private final String hostname; + + /** + * The port of the server. + */ + private final int port; + + /** + * The protocol version of the server. + */ + private final int protocolVersion; + + /** + * Process this packet. + * + * @param inputStream the input stream to read from + * @param outputStream the output stream to write to + * @throws IOException if an I/O error occurs + */ + @Override + public void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException { + try (ByteArrayOutputStream handshakeBytes = new ByteArrayOutputStream(); + DataOutputStream handshake = new DataOutputStream(handshakeBytes) + ) { + handshake.writeByte(ID); // Write the ID of the packet + writeVarInt(handshake, protocolVersion); // Write the protocol version + writeVarInt(handshake, hostname.length()); // Write the length of the hostname + handshake.writeBytes(hostname); // Write the hostname + handshake.writeShort(port); // Write the port + writeVarInt(handshake, STATUS_HANDSHAKE); // Write the status handshake ID + + // Write the handshake bytes to the output stream + writeVarInt(outputStream, handshakeBytes.size()); + outputStream.write(handshakeBytes.toByteArray()); + } + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketStatusInStart.java b/src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketStatusInStart.java new file mode 100644 index 0000000..bf3a540 --- /dev/null +++ b/src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketStatusInStart.java @@ -0,0 +1,62 @@ +package cc.fascinated.common.packet.impl.java; + +import cc.fascinated.common.packet.MinecraftJavaPacket; +import lombok.Getter; +import lombok.NonNull; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * This packet is sent by the client to the server to request the + * status of the server. The server will respond with a json object + * containing the server's status. + * + * @author Braydon + * @see Protocol Docs + */ +@Getter +public final class JavaPacketStatusInStart extends MinecraftJavaPacket { + private static final byte ID = 0x00; // The ID of the packet + + /** + * The response json from the server, null if none. + */ + private String response; + + /** + * Process this packet. + * + * @param inputStream the input stream to read from + * @param outputStream the output stream to write to + * @throws IOException if an I/O error occurs + */ + @Override + public void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException { + // Send the status request + outputStream.writeByte(0x01); // Size of packet + outputStream.writeByte(ID); + + // Read the status response + readVarInt(inputStream); // Size of the response + int id = readVarInt(inputStream); + if (id == -1) { // The stream was prematurely ended + throw new IOException("Server prematurely ended stream."); + } else if (id != ID) { // Invalid packet ID + throw new IOException("Server returned invalid packet ID."); + } + + int length = readVarInt(inputStream); // Length of the response + if (length == -1) { // The stream was prematurely ended + throw new IOException("Server prematurely ended stream."); + } else if (length == 0) { + throw new IOException("Server returned unexpected value."); + } + + // Get the json response + byte[] data = new byte[length]; + inputStream.readFully(data); + response = new String(data); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/config/Config.java b/src/main/java/cc.fascinated/config/Config.java new file mode 100644 index 0000000..2aac78f --- /dev/null +++ b/src/main/java/cc.fascinated/config/Config.java @@ -0,0 +1,20 @@ +package cc.fascinated.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +public class Config { + public static Config INSTANCE; + + @Value("${public-url}") + private String webPublicUrl; + + @PostConstruct + public void onInitialize() { + INSTANCE = this; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/config/RedisConfig.java b/src/main/java/cc.fascinated/config/RedisConfig.java new file mode 100644 index 0000000..148a496 --- /dev/null +++ b/src/main/java/cc.fascinated/config/RedisConfig.java @@ -0,0 +1,73 @@ +package cc.fascinated.config; + +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +/** + * @author Braydon + */ +@Configuration +@Log4j2(topic = "Redis") +public class RedisConfig { + /** + * The Redis server host. + */ + @Value("${spring.data.redis.host}") + private String host; + + /** + * The Redis server port. + */ + @Value("${spring.data.redis.port}") + private int port; + + /** + * The Redis database index. + */ + @Value("${spring.data.redis.database}") + private int database; + + /** + * The optional Redis password. + */ + @Value("${spring.data.redis.auth}") + private String auth; + + /** + * Build the config to use for Redis. + * + * @return the config + * @see RedisTemplate for config + */ + @Bean @NonNull + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(jedisConnectionFactory()); + return template; + } + + /** + * Build the connection factory to use + * when making connections to Redis. + * + * @return the built factory + * @see JedisConnectionFactory for factory + */ + @Bean @NonNull + public JedisConnectionFactory jedisConnectionFactory() { + log.info("Connecting to Redis at {}:{}/{}", host, port, database); + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setDatabase(database); + if (!auth.trim().isEmpty()) { // Auth with our provided password + log.info("Using auth..."); + config.setPassword(auth); + } + return new JedisConnectionFactory(config); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/controller/HomeController.java b/src/main/java/cc.fascinated/controller/HomeController.java new file mode 100644 index 0000000..854c1f6 --- /dev/null +++ b/src/main/java/cc.fascinated/controller/HomeController.java @@ -0,0 +1,23 @@ +package cc.fascinated.controller; + +import cc.fascinated.config.Config; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping(value = "/") +public class HomeController { + + /** + * The example UUID. + */ + @SuppressWarnings("FieldCanBeLocal") + private final String exampleUuid = "eeab5f8a-18dd-4d58-af78-2b3c4543da48"; + + @RequestMapping(value = "/") + public String home(Model model) { + model.addAttribute("player_example_url", Config.INSTANCE.getWebPublicUrl() + "/player/" + exampleUuid); + return "index"; + } +} diff --git a/src/main/java/cc.fascinated/controller/PlayerController.java b/src/main/java/cc.fascinated/controller/PlayerController.java new file mode 100644 index 0000000..249eba1 --- /dev/null +++ b/src/main/java/cc.fascinated/controller/PlayerController.java @@ -0,0 +1,48 @@ +package cc.fascinated.controller; + +import cc.fascinated.common.PlayerUtils; +import cc.fascinated.model.player.Player; +import cc.fascinated.model.player.Skin; +import cc.fascinated.service.PlayerService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping(value = "/player/") +public class PlayerController { + + private final CacheControl cacheControl = CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic(); + private final PlayerService playerManagerService; + + @Autowired + public PlayerController(PlayerService playerManagerService) { + this.playerManagerService = playerManagerService; + } + + @ResponseBody + @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getPlayer(@PathVariable String id) { + return ResponseEntity.ok() + .cacheControl(cacheControl) + .body(playerManagerService.getPlayer(id)); + } + + @GetMapping(value = "/{part}/{id}") + public ResponseEntity getPlayerHead(@PathVariable String part, + @PathVariable String id, + @RequestParam(required = false, defaultValue = "256") int size) { + Player player = playerManagerService.getPlayer(id); + Skin.Parts skinPart = Skin.Parts.fromName(part); + + // Return the part image + return ResponseEntity.ok() + .cacheControl(cacheControl) + .contentType(MediaType.IMAGE_PNG) + .body(PlayerUtils.getSkinPartBytes(player.getSkin(), skinPart, size)); + } +} diff --git a/src/main/java/cc.fascinated/controller/ServerController.java b/src/main/java/cc.fascinated/controller/ServerController.java new file mode 100644 index 0000000..b23416b --- /dev/null +++ b/src/main/java/cc.fascinated/controller/ServerController.java @@ -0,0 +1,32 @@ +package cc.fascinated.controller; + +import cc.fascinated.model.cache.CachedMinecraftServer; +import cc.fascinated.model.server.MinecraftServer; +import cc.fascinated.service.ServerService; +import cc.fascinated.service.pinger.impl.JavaMinecraftServerPinger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping(value = "/server/") +public class ServerController { + + @Autowired + private ServerService serverService; + + @ResponseBody + @GetMapping(value = "/{platform}/{hostnameAndPort}", produces = MediaType.APPLICATION_JSON_VALUE) + public CachedMinecraftServer getServer(@PathVariable String platform, @PathVariable String hostnameAndPort) { + String[] split = hostnameAndPort.split(":"); + String hostname = split[0]; + int port = 25565; + if (split.length == 2) { + try { + port = Integer.parseInt(split[1]); + } catch (NumberFormatException ignored) {} + } + return serverService.getServer(platform, hostname, port); + } +} diff --git a/src/main/java/cc.fascinated/exception/ExceptionControllerAdvice.java b/src/main/java/cc.fascinated/exception/ExceptionControllerAdvice.java new file mode 100644 index 0000000..74de241 --- /dev/null +++ b/src/main/java/cc.fascinated/exception/ExceptionControllerAdvice.java @@ -0,0 +1,45 @@ +package cc.fascinated.exception; + +import cc.fascinated.model.response.ErrorResponse; +import io.micrometer.common.lang.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@ControllerAdvice +public final class ExceptionControllerAdvice { + + /** + * Handle a raised exception. + * + * @param ex the raised exception + * @return the error response + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(@NonNull Exception ex) { + HttpStatus status = null; // Get the HTTP status + if (ex instanceof NoResourceFoundException) { // Not found + status = HttpStatus.NOT_FOUND; + } else if (ex instanceof UnsupportedOperationException) { // Not implemented + status = HttpStatus.NOT_IMPLEMENTED; + } + if (ex.getClass().isAnnotationPresent(ResponseStatus.class)) { // Get from the @ResponseStatus annotation + status = ex.getClass().getAnnotation(ResponseStatus.class).value(); + } + String message = ex.getLocalizedMessage(); // Get the error message + if (message == null) { // Fallback + message = "An internal error has occurred."; + } + // Print the stack trace if no response status is present + if (status == null) { + ex.printStackTrace(); + } + if (status == null) { // Fallback to 500 + status = HttpStatus.INTERNAL_SERVER_ERROR; + } + return new ResponseEntity<>(new ErrorResponse(status, message), status); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/exception/impl/BadRequestException.java b/src/main/java/cc.fascinated/exception/impl/BadRequestException.java new file mode 100644 index 0000000..9d96eb2 --- /dev/null +++ b/src/main/java/cc.fascinated/exception/impl/BadRequestException.java @@ -0,0 +1,12 @@ +package cc.fascinated.exception.impl; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/cc.fascinated/exception/impl/ResourceNotFoundException.java b/src/main/java/cc.fascinated/exception/impl/ResourceNotFoundException.java new file mode 100644 index 0000000..a4f0003 --- /dev/null +++ b/src/main/java/cc.fascinated/exception/impl/ResourceNotFoundException.java @@ -0,0 +1,9 @@ +package cc.fascinated.exception.impl; + +import lombok.experimental.StandardException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@StandardException +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { } diff --git a/src/main/java/cc.fascinated/log/TransactionLogger.java b/src/main/java/cc.fascinated/log/TransactionLogger.java new file mode 100644 index 0000000..ab9de46 --- /dev/null +++ b/src/main/java/cc.fascinated/log/TransactionLogger.java @@ -0,0 +1,78 @@ +package cc.fascinated.log; + +import cc.fascinated.common.IPUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +@ControllerAdvice +@Slf4j(topic = "Req/Res Transaction") +public class TransactionLogger implements ResponseBodyAdvice { + @Override + public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType, + @NonNull Class> selectedConverterType, @NonNull ServerHttpRequest rawRequest, + @NonNull ServerHttpResponse rawResponse) { + HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest(); + HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse(); + + // Get the request ip ip + String ip = IPUtils.getRealIp(request); + + // Getting params + Map params = new HashMap<>(); + for (Entry entry : request.getParameterMap().entrySet()) { + params.put(entry.getKey(), Arrays.toString(entry.getValue())); + } + + // Getting headers + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headers.put(headerName, request.getHeader(headerName)); + } + + // Log the request + log.info(String.format("[Req] %s | %s | '%s', params=%s, headers=%s", + request.getMethod(), + ip, + request.getRequestURI(), + params, + headers + )); + + // Getting response headers + headers = new HashMap<>(); + for (String headerName : response.getHeaderNames()) { + headers.put(headerName, response.getHeader(headerName)); + } + + // Log the response + log.info(String.format("[Res] %s, headers=%s", + response.getStatus(), + headers + )); + return body; + } + + @Override + public boolean supports(@NonNull MethodParameter returnType, @NonNull Class> converterType) { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/cache/CachedMinecraftServer.java b/src/main/java/cc.fascinated/model/cache/CachedMinecraftServer.java new file mode 100644 index 0000000..000497c --- /dev/null +++ b/src/main/java/cc.fascinated/model/cache/CachedMinecraftServer.java @@ -0,0 +1,31 @@ +package cc.fascinated.model.cache; + +import cc.fascinated.model.server.MinecraftServer; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import java.io.Serializable; + +/** + * @author Braydon + */ +@AllArgsConstructor @Setter @Getter @ToString +@RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds) +public final class CachedMinecraftServer implements Serializable { + /** + * The id of this cached server. + */ + @Id @NonNull private transient final String id; + + /** + * The cached server. + */ + @NonNull private final MinecraftServer value; + + /** + * The unix timestamp of when this + * server was cached, -1 if not cached. + */ + private long cached; +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/cache/CachedPlayer.java b/src/main/java/cc.fascinated/model/cache/CachedPlayer.java new file mode 100644 index 0000000..80441ce --- /dev/null +++ b/src/main/java/cc.fascinated/model/cache/CachedPlayer.java @@ -0,0 +1,34 @@ +package cc.fascinated.model.cache; + +import cc.fascinated.model.mojang.MojangProfile; +import cc.fascinated.model.player.Cape; +import cc.fascinated.model.player.Player; +import cc.fascinated.model.player.Skin; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.data.redis.core.RedisHash; + +import java.io.Serializable; +import java.util.UUID; + +/** + * A cacheable {@link Player}. + * + * @author Braydon + */ +@Setter @Getter +@ToString(callSuper = true) +@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds) +public final class CachedPlayer extends Player implements Serializable { + /** + * The unix timestamp of when this + * player was cached, -1 if not cached. + */ + private long cached; + + public CachedPlayer(UUID uuid, String username, Skin skin, Cape cape, long cached) { + super(uuid, username, skin, cape); + this.cached = cached; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/cache/CachedPlayerName.java b/src/main/java/cc.fascinated/model/cache/CachedPlayerName.java new file mode 100644 index 0000000..2e97368 --- /dev/null +++ b/src/main/java/cc.fascinated/model/cache/CachedPlayerName.java @@ -0,0 +1,27 @@ +package cc.fascinated.model.cache; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import java.util.UUID; + +/** + * @author Braydon + */ +@AllArgsConstructor +@Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString +@RedisHash(value = "playerName", timeToLive = 60L * 60L) // 1 hour (in seconds) +public final class CachedPlayerName { + /** + * The username of the player. + */ + @Id @NonNull private String username; + + /** + * The unique id of the player. + */ + @NonNull private UUID uniqueId; +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java b/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java new file mode 100644 index 0000000..ce98ab4 --- /dev/null +++ b/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java @@ -0,0 +1,13 @@ +package cc.fascinated.model.mojang; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +/** + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public final class JavaServerStatusToken { + private final String description; +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/mojang/MojangProfile.java b/src/main/java/cc.fascinated/model/mojang/MojangProfile.java new file mode 100644 index 0000000..51ca99b --- /dev/null +++ b/src/main/java/cc.fascinated/model/mojang/MojangProfile.java @@ -0,0 +1,111 @@ +package cc.fascinated.model.mojang; + +import cc.fascinated.Main; +import cc.fascinated.common.Tuple; +import cc.fascinated.common.UUIDUtils; +import cc.fascinated.model.player.Cape; +import cc.fascinated.model.player.Skin; +import com.google.gson.JsonObject; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +@Getter @NoArgsConstructor +public class MojangProfile { + + /** + * The UUID of the player. + */ + private String id; + + /** + * The name of the player. + */ + private String name; + + /** + * The properties of the player. + */ + private final List properties = new ArrayList<>(); + + /** + * Get the skin and cape of the player. + * + * @return the skin and cape of the player + */ + public Tuple getSkinAndCape() { + ProfileProperty textureProperty = getProfileProperty("textures"); + if (textureProperty == null) { + return null; + } + + JsonObject json = Main.GSON.fromJson(textureProperty.getDecodedValue(), JsonObject.class); // Decode the texture property + JsonObject texturesJson = json.getAsJsonObject("textures"); // Parse the decoded JSON and get the textures object + + return new Tuple<>(Skin.fromJson(texturesJson.getAsJsonObject("SKIN")).populatePartUrls(this.getFormattedUuid()), + Cape.fromJson(texturesJson.getAsJsonObject("CAPE"))); + } + + /** + * Gets the formatted UUID of the player. + * + * @return the formatted UUID + */ + public String getFormattedUuid() { + return id.length() == 32 ? UUIDUtils.addDashes(id).toString() : id; + } + + /** + * Get a profile property for the player + * + * @return the profile property + */ + public ProfileProperty getProfileProperty(String name) { + for (ProfileProperty property : properties) { + if (property.getName().equals(name)) { + return property; + } + } + return null; + } + + @Getter @AllArgsConstructor + public static class ProfileProperty { + /** + * The name of the property. + */ + private String name; + + /** + * The base64 value of the property. + */ + private String value; + + /** + * The signature of the property. + */ + private String signature; + + /** + * Decodes the value for this property. + * + * @return the decoded value + */ + public String getDecodedValue() { + return new String(Base64.getDecoder().decode(this.value)); + } + + /** + * Check if the property is signed. + * + * @return true if the property is signed, false otherwise + */ + public boolean isSigned() { + return signature != null; + } + } +} diff --git a/src/main/java/cc.fascinated/model/mojang/MojangUsernameToUuid.java b/src/main/java/cc.fascinated/model/mojang/MojangUsernameToUuid.java new file mode 100644 index 0000000..a8a2c0d --- /dev/null +++ b/src/main/java/cc.fascinated/model/mojang/MojangUsernameToUuid.java @@ -0,0 +1,27 @@ +package cc.fascinated.model.mojang; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter @NoArgsConstructor +public class MojangUsernameToUuid { + + /** + * The UUID of the player. + */ + private String id; + + /** + * The name of the player. + */ + private String name; + + /** + * Check if the profile is valid. + * + * @return if the profile is valid + */ + public boolean isValid() { + return id != null && name != null; + } +} diff --git a/src/main/java/cc.fascinated/model/player/Cape.java b/src/main/java/cc.fascinated/model/player/Cape.java new file mode 100644 index 0000000..cb26d0a --- /dev/null +++ b/src/main/java/cc.fascinated/model/player/Cape.java @@ -0,0 +1,27 @@ +package cc.fascinated.model.player; + +import com.google.gson.JsonObject; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter @AllArgsConstructor +public class Cape { + + /** + * The URL of the cape + */ + private final String url; + + /** + * Gets the cape from a {@link JsonObject}. + * + * @param json the JSON object + * @return the cape + */ + public static Cape fromJson(JsonObject json) { + if (json == null) { + return null; + } + return new Cape(json.get("url").getAsString()); + } +} diff --git a/src/main/java/cc.fascinated/model/player/Player.java b/src/main/java/cc.fascinated/model/player/Player.java new file mode 100644 index 0000000..35b405d --- /dev/null +++ b/src/main/java/cc.fascinated/model/player/Player.java @@ -0,0 +1,49 @@ +package cc.fascinated.model.player; + +import cc.fascinated.common.Tuple; +import cc.fascinated.common.UUIDUtils; +import cc.fascinated.model.mojang.MojangProfile; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; + +import java.util.UUID; + +@Getter @AllArgsConstructor +public class Player { + + /** + * The UUID of the player + */ + @Id private final UUID uuid; + + /** + * The username of the player + */ + private final String username; + + /** + * The skin of the player, null if the + * player does not have a skin + */ + private Skin skin; + + /** + * The cape of the player, null if the + * player does not have a cape + */ + private Cape cape; + + public Player(MojangProfile profile) { + this.uuid = UUIDUtils.addDashes(profile.getId()); + this.username = profile.getName(); + + // Get the skin and cape + Tuple skinAndCape = profile.getSkinAndCape(); + if (skinAndCape != null) { + this.skin = skinAndCape.getLeft(); + this.cape = skinAndCape.getRight(); + } + } +} diff --git a/src/main/java/cc.fascinated/model/player/Skin.java b/src/main/java/cc.fascinated/model/player/Skin.java new file mode 100644 index 0000000..0aa9ae0 --- /dev/null +++ b/src/main/java/cc.fascinated/model/player/Skin.java @@ -0,0 +1,158 @@ +package cc.fascinated.model.player; + +import cc.fascinated.common.PlayerUtils; +import cc.fascinated.config.Config; +import cc.fascinated.exception.impl.BadRequestException; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.JsonObject; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.log4j.Log4j2; + +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; + +@AllArgsConstructor @NoArgsConstructor +@Getter @Log4j2 +public class Skin { + /** + * The default skin, usually used when the skin is not found. + */ + public static final Skin DEFAULT_SKIN = new Skin("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1", + Model.DEFAULT); + + /** + * The URL for the skin + */ + private String url; + + /** + * The model for the skin + */ + private Model model; + + /** + * The skin image for the skin + */ + @JsonIgnore + private byte[] skinImage; + + /** + * The part URLs of the skin + */ + @JsonProperty("parts") + private Map partUrls = new HashMap<>(); + + public Skin(String url, Model model) { + this.url = url; + this.model = model; + + this.skinImage = PlayerUtils.getSkinImage(url); + } + + /** + * Gets the skin from a {@link JsonObject}. + * + * @param json the JSON object + * @return the skin + */ + public static Skin fromJson(JsonObject json) { + if (json == null) { + return null; + } + String url = json.get("url").getAsString(); + JsonObject metadata = json.getAsJsonObject("metadata"); + Model model = Model.fromName(metadata == null ? "slim" : // Fall back to slim if the model is not found + metadata.get("model").getAsString()); + return new Skin(url, model); + } + + /** + * Populates the part URLs for the skin. + * + * @param playerUuid the player's UUID + */ + public Skin populatePartUrls(String playerUuid) { + for (Parts part : Parts.values()) { + String partName = part.name().toLowerCase(); + this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid + "?size=" + part.getDefaultSize()); + } + return this; + } + + /** + * The skin part enum that contains the + * information about the part. + */ + @Getter @AllArgsConstructor + public enum Parts { + + HEAD(8, 8, 8, 8, 256); + + /** + * The x and y position of the part. + */ + private final int x, y; + + /** + * The width and height of the part. + */ + private final int width, height; + + /** + * The scale of the part. + */ + private final int defaultSize; + + /** + * Gets the name of the part. + * + * @return the name of the part + */ + public String getName() { + return this.name().toLowerCase(); + } + + /** + * Gets the skin part from its name. + * + * @param name the name of the part + * @return the skin part + * @throws BadRequestException if the part is not found + */ + public static Parts fromName(String name) throws BadRequestException { + for (Parts part : values()) { + if (part.name().equalsIgnoreCase(name)) { + return part; + } + } + throw new BadRequestException("Invalid part name: " + name); + } + } + + /** + * The model of the skin. + */ + public enum Model { + DEFAULT, + SLIM; + + /** + * Gets the model from its name. + * + * @param name the name of the model + * @return the model + */ + public static Model fromName(String name) { + for (Model model : values()) { + if (model.name().equalsIgnoreCase(name)) { + return model; + } + } + return null; + } + } +} diff --git a/src/main/java/cc.fascinated/model/response/ErrorResponse.java b/src/main/java/cc.fascinated/model/response/ErrorResponse.java new file mode 100644 index 0000000..4ff3196 --- /dev/null +++ b/src/main/java/cc.fascinated/model/response/ErrorResponse.java @@ -0,0 +1,40 @@ +package cc.fascinated.model.response; + +import io.micrometer.common.lang.NonNull; +import lombok.Getter; +import lombok.ToString; +import org.springframework.http.HttpStatus; + +import java.util.Date; + +@Getter +@ToString +public class ErrorResponse { + /** + * The status code of this error. + */ + @NonNull + private final HttpStatus status; + + /** + * The HTTP code of this error. + */ + private final int code; + + /** + * The message of this error. + */ + @NonNull private final String message; + + /** + * The timestamp this error occurred. + */ + @NonNull private final Date timestamp; + + public ErrorResponse(@NonNull HttpStatus status, @NonNull String message) { + this.status = status; + code = status.value(); + this.message = message; + timestamp = new Date(); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java b/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java new file mode 100644 index 0000000..86df0d6 --- /dev/null +++ b/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java @@ -0,0 +1,10 @@ +package cc.fascinated.model.server; + +/** + * @author Braydon + */ +public final class JavaMinecraftServer extends MinecraftServer { + public JavaMinecraftServer(String hostname, String ip, int port, String motd) { + super(hostname, ip, port, motd); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/server/MinecraftServer.java b/src/main/java/cc.fascinated/model/server/MinecraftServer.java new file mode 100644 index 0000000..5a7eb11 --- /dev/null +++ b/src/main/java/cc.fascinated/model/server/MinecraftServer.java @@ -0,0 +1,42 @@ +package cc.fascinated.model.server; + +import cc.fascinated.service.pinger.MinecraftServerPinger; +import cc.fascinated.service.pinger.impl.JavaMinecraftServerPinger; +import io.micrometer.common.lang.NonNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +/** + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public class MinecraftServer { + private final String hostname; + private final String ip; + private final int port; + private final String motd; + + /** + * A platform a Minecraft + * server can operate on. + */ + @AllArgsConstructor @Getter + public enum Platform { + /** + * The Java edition of Minecraft. + */ + JAVA(new JavaMinecraftServerPinger(), 25565); + + /** + * The server pinger for this platform. + */ + @NonNull + private final MinecraftServerPinger pinger; + + /** + * The default server port for this platform. + */ + private final int defaultPort; + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/repository/MinecraftServerCacheRepository.java b/src/main/java/cc.fascinated/repository/MinecraftServerCacheRepository.java new file mode 100644 index 0000000..3de3ccb --- /dev/null +++ b/src/main/java/cc.fascinated/repository/MinecraftServerCacheRepository.java @@ -0,0 +1,11 @@ +package cc.fascinated.repository; + +import cc.fascinated.model.cache.CachedMinecraftServer; +import org.springframework.data.repository.CrudRepository; + +/** + * A cache repository for {@link CachedMinecraftServer}'s. + * + * @author Braydon + */ +public interface MinecraftServerCacheRepository extends CrudRepository { } \ No newline at end of file diff --git a/src/main/java/cc.fascinated/repository/PlayerCacheRepository.java b/src/main/java/cc.fascinated/repository/PlayerCacheRepository.java new file mode 100644 index 0000000..3f18359 --- /dev/null +++ b/src/main/java/cc.fascinated/repository/PlayerCacheRepository.java @@ -0,0 +1,13 @@ +package cc.fascinated.repository; + +import cc.fascinated.model.cache.CachedPlayer; +import org.springframework.data.repository.CrudRepository; + +import java.util.UUID; + +/** + * A cache repository for {@link CachedPlayer}'s. + * + * @author Braydon + */ +public interface PlayerCacheRepository extends CrudRepository { } \ No newline at end of file diff --git a/src/main/java/cc.fascinated/repository/PlayerNameCacheRepository.java b/src/main/java/cc.fascinated/repository/PlayerNameCacheRepository.java new file mode 100644 index 0000000..0127aa2 --- /dev/null +++ b/src/main/java/cc.fascinated/repository/PlayerNameCacheRepository.java @@ -0,0 +1,18 @@ +package cc.fascinated.repository; + +import cc.fascinated.model.cache.CachedPlayer; +import cc.fascinated.model.cache.CachedPlayerName; +import org.springframework.data.repository.CrudRepository; + +import java.util.UUID; + +/** + * A cache repository for player usernames. + *

+ * This will allow us to easily lookup a + * player's username and get their uuid. + *

+ * + * @author Braydon + */ +public interface PlayerNameCacheRepository extends CrudRepository { } \ No newline at end of file diff --git a/src/main/java/cc.fascinated/service/MojangAPIService.java b/src/main/java/cc.fascinated/service/MojangAPIService.java new file mode 100644 index 0000000..54c6330 --- /dev/null +++ b/src/main/java/cc.fascinated/service/MojangAPIService.java @@ -0,0 +1,40 @@ +package cc.fascinated.service; + +import cc.fascinated.common.WebRequest; +import cc.fascinated.model.mojang.MojangProfile; +import cc.fascinated.model.mojang.MojangUsernameToUuid; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service @Log4j2 +public class MojangAPIService { + + @Value("${mojang.session-server}") + private String mojangSessionServerUrl; + + @Value("${mojang.api}") + private String mojangApiUrl; + + /** + * Gets the Session Server profile of the + * player with the given UUID. + * + * @param id the uuid or name of the player + * @return the profile + */ + public MojangProfile getProfile(String id) { + return WebRequest.getAsEntity(mojangSessionServerUrl + "/session/minecraft/profile/" + id, MojangProfile.class); + } + + /** + * Gets the UUID of the player using + * the name of the player. + * + * @param id the name of the player + * @return the profile + */ + public MojangUsernameToUuid getUuidFromUsername(String id) { + return WebRequest.getAsEntity(mojangApiUrl + "/users/profiles/minecraft/" + id, MojangUsernameToUuid.class); + } +} diff --git a/src/main/java/cc.fascinated/service/PlayerService.java b/src/main/java/cc.fascinated/service/PlayerService.java new file mode 100644 index 0000000..8f5e5db --- /dev/null +++ b/src/main/java/cc.fascinated/service/PlayerService.java @@ -0,0 +1,93 @@ +package cc.fascinated.service; + +import cc.fascinated.common.PlayerUtils; +import cc.fascinated.common.Tuple; +import cc.fascinated.common.UUIDUtils; +import cc.fascinated.exception.impl.BadRequestException; +import cc.fascinated.exception.impl.ResourceNotFoundException; +import cc.fascinated.model.cache.CachedPlayer; +import cc.fascinated.model.cache.CachedPlayerName; +import cc.fascinated.model.mojang.MojangProfile; +import cc.fascinated.model.mojang.MojangUsernameToUuid; +import cc.fascinated.model.player.Cape; +import cc.fascinated.model.player.Player; +import cc.fascinated.model.player.Skin; +import cc.fascinated.repository.PlayerCacheRepository; +import cc.fascinated.repository.PlayerNameCacheRepository; +import lombok.extern.log4j.Log4j2; +import net.jodah.expiringmap.ExpirationPolicy; +import net.jodah.expiringmap.ExpiringMap; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service @Log4j2 +public class PlayerService { + + private final MojangAPIService mojangAPIService; + private final PlayerCacheRepository playerCacheRepository; + private final PlayerNameCacheRepository playerNameCacheRepository; + + @Autowired + public PlayerService(MojangAPIService mojangAPIService, PlayerCacheRepository playerCacheRepository, PlayerNameCacheRepository playerNameCacheRepository) { + this.mojangAPIService = mojangAPIService; + this.playerCacheRepository = playerCacheRepository; + this.playerNameCacheRepository = playerNameCacheRepository; + } + + /** + * Get a player from the cache or + * from the Mojang API. + * + * @param id the id of the player + * @return the player + */ + public CachedPlayer getPlayer(String id) { + UUID uuid = PlayerUtils.getUuidFromString(id); + if (uuid == null) { // If the id is not a valid uuid, get the uuid from the username + uuid = usernameToUuid(id); + } + + Optional cachedPlayer = playerCacheRepository.findById(uuid); + if (cachedPlayer.isPresent()) { // Return the cached player if it exists + return cachedPlayer.get(); + } + + MojangProfile mojangProfile = mojangAPIService.getProfile(uuid.toString()); + Tuple skinAndCape = mojangProfile.getSkinAndCape(); + CachedPlayer player = new CachedPlayer( + uuid, + mojangProfile.getName(), + skinAndCape.getLeft(), // Skin + skinAndCape.getRight(), // Cape + System.currentTimeMillis() + ); + + playerCacheRepository.save(player); + return player; + } + + /** + * Gets the player's uuid from their username. + * + * @param username the username of the player + * @return the uuid of the player + */ + private UUID usernameToUuid(String username) { + Optional cachedPlayerName = playerNameCacheRepository.findById(username); + if (cachedPlayerName.isPresent()) { + return cachedPlayerName.get().getUniqueId(); + } + MojangUsernameToUuid mojangUsernameToUuid = mojangAPIService.getUuidFromUsername(username); + if (mojangUsernameToUuid == null) { + throw new ResourceNotFoundException("Player with username '%s' not found".formatted(username)); + } + UUID uuid = UUIDUtils.addDashes(mojangUsernameToUuid.getId()); + playerNameCacheRepository.save(new CachedPlayerName(username, uuid)); + return uuid; + } +} diff --git a/src/main/java/cc.fascinated/service/ServerService.java b/src/main/java/cc.fascinated/service/ServerService.java new file mode 100644 index 0000000..8a7ff32 --- /dev/null +++ b/src/main/java/cc.fascinated/service/ServerService.java @@ -0,0 +1,64 @@ +package cc.fascinated.service; + +import cc.fascinated.EnumUtils; +import cc.fascinated.common.DNSUtils; +import cc.fascinated.common.WebRequest; +import cc.fascinated.exception.impl.BadRequestException; +import cc.fascinated.model.cache.CachedMinecraftServer; +import cc.fascinated.model.mojang.MojangProfile; +import cc.fascinated.model.mojang.MojangUsernameToUuid; +import cc.fascinated.model.server.MinecraftServer; +import cc.fascinated.repository.MinecraftServerCacheRepository; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.InetSocketAddress; +import java.util.Optional; + +@Service @Log4j2 +public class ServerService { + + private final MinecraftServerCacheRepository serverCacheRepository; + + @Autowired + public ServerService(MinecraftServerCacheRepository serverCacheRepository) { + this.serverCacheRepository = serverCacheRepository; + } + + /** + * Ping a server to get the server information. + * + * @param platformName the name of the platform + * @param hostname the hostname of the server + * @param port the port of the server + * @return the server + */ + public CachedMinecraftServer getServer(String platformName, String hostname, int port) { + MinecraftServer.Platform platform = EnumUtils.getEnumConstant(MinecraftServer.Platform.class, platformName.toUpperCase()); + if (platform == null) { + throw new BadRequestException("Invalid platform: %s".formatted(platformName)); + } + String key = "%s-%s:%s".formatted(platformName, hostname, port); + + Optional cached = serverCacheRepository.findById(key); + if (cached.isPresent()) { + return cached.get(); + } + + InetSocketAddress address = platform == MinecraftServer.Platform.JAVA ? DNSUtils.resolveSRV(hostname) : null; + if (address != null) { + port = port != -1 ? port : platform.getDefaultPort(); // If the port is -1, set it to the default port + hostname = address.getHostName(); + } + + CachedMinecraftServer server = new CachedMinecraftServer( + key, + platform.getPinger().ping(hostname, port), + System.currentTimeMillis() + ); + serverCacheRepository.save(server); + return server; + } +} diff --git a/src/main/java/cc.fascinated/service/pinger/MinecraftServerPinger.java b/src/main/java/cc.fascinated/service/pinger/MinecraftServerPinger.java new file mode 100644 index 0000000..2a14ed7 --- /dev/null +++ b/src/main/java/cc.fascinated/service/pinger/MinecraftServerPinger.java @@ -0,0 +1,11 @@ +package cc.fascinated.service.pinger; + +import cc.fascinated.model.server.MinecraftServer; + +/** + * @author Braydon + * @param the type of server to ping + */ +public interface MinecraftServerPinger { + T ping(String hostname, int port); +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java b/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java new file mode 100644 index 0000000..ee9f458 --- /dev/null +++ b/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java @@ -0,0 +1,64 @@ +package cc.fascinated.service.pinger.impl; + +import cc.fascinated.Main; +import cc.fascinated.common.DNSUtils; +import cc.fascinated.common.packet.impl.java.JavaPacketHandshakingInSetProtocol; +import cc.fascinated.common.packet.impl.java.JavaPacketStatusInStart; +import cc.fascinated.exception.impl.BadRequestException; +import cc.fascinated.exception.impl.ResourceNotFoundException; +import cc.fascinated.model.mojang.JavaServerStatusToken; +import cc.fascinated.model.server.JavaMinecraftServer; +import cc.fascinated.service.pinger.MinecraftServerPinger; +import lombok.extern.log4j.Log4j2; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.*; + +/** + * @author Braydon + */ +@Log4j2(topic = "Java Pinger") +public final class JavaMinecraftServerPinger implements MinecraftServerPinger { + public static final JavaMinecraftServerPinger INSTANCE = new JavaMinecraftServerPinger(); + + private static final int TIMEOUT = 3000; // The timeout for the socket + + @Override + public JavaMinecraftServer ping(String hostname, int port) { + InetAddress inetAddress = DNSUtils.resolveA(hostname); // Resolve the hostname to an IP address + String ip = inetAddress == null ? null : inetAddress.getHostAddress(); // Get the IP address + if (ip != null) { // Was the IP resolved? + log.info("Resolved hostname: {} -> {}", hostname, ip); + } + log.info("Pinging {}:{}...", hostname, port); + + // Open a socket connection to the server + try (Socket socket = new Socket()) { + socket.setTcpNoDelay(true); + socket.connect(new InetSocketAddress(hostname, port), TIMEOUT); + + // Open data streams to begin packet transaction + try (DataInputStream inputStream = new DataInputStream(socket.getInputStream()); + DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream())) { + // Begin handshaking with the server + new JavaPacketHandshakingInSetProtocol(hostname, port, 47).process(inputStream, outputStream); + + // Send the status request to the server, and await back the response + JavaPacketStatusInStart packetStatusInStart = new JavaPacketStatusInStart(); + packetStatusInStart.process(inputStream, outputStream); + JavaServerStatusToken token = Main.GSON.fromJson(packetStatusInStart.getResponse(), JavaServerStatusToken.class); + return new JavaMinecraftServer(hostname, ip, port, token.getDescription()); + } + } catch (IOException ex) { + if (ex instanceof UnknownHostException) { + throw new BadRequestException("Unknown hostname: %s".formatted(hostname)); + } else if (ex instanceof ConnectException || ex instanceof SocketTimeoutException) { + throw new ResourceNotFoundException(ex); + } + log.error("An error occurred pinging %s:%s:".formatted(hostname, port), ex); + } + return null; + } +} \ No newline at end of file