add server pinger

This commit is contained in:
Lee 2024-04-10 07:43:13 +01:00
parent ed3b7e3064
commit 25c69e11e1
38 changed files with 104 additions and 1299 deletions

1
.gitignore vendored

@ -28,3 +28,4 @@ fabric.properties
git.properties git.properties
pom.xml.versionsBackup pom.xml.versionsBackup
application.yml application.yml
target/

38
pom.xml

@ -30,6 +30,14 @@
</plugins> </plugins>
</build> </build>
<repositories>
<!-- Jitpack - Used for dnsjava -->
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
@ -84,6 +92,30 @@
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<!-- DNS Lookup -->
<dependency>
<groupId>com.github.dnsjava</groupId>
<artifactId>dnsjava</artifactId>
<version>v3.5.2</version>
<scope>compile</scope>
</dependency>
<!-- Redis for caching -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- Unit Tests --> <!-- Unit Tests -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
@ -108,6 +140,12 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>com.github.codemonstur</groupId>
<artifactId>embedded-redis</artifactId>
<version>1.4.3</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

@ -1,35 +0,0 @@
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);
}
}

@ -1,42 +0,0 @@
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;
}
}

@ -1,72 +0,0 @@
package cc.fascinated.common;
import cc.fascinated.Main;
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;
@UtilityClass @Log4j2
public class PlayerUtils {
/**
* Gets the skin data from the URL.
*
* @return the skin data
*/
@SneakyThrows
@JsonIgnore
public static BufferedImage getSkinImage(String url) {
HttpResponse<byte[]> response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder(URI.create(url)).build(),
HttpResponse.BodyHandlers.ofByteArray());
byte[] body = response.body();
if (body == null) {
return null;
}
return ImageIO.read(new ByteArrayInputStream(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 = skin.getSkinImage();
if (image == null) {
return null;
}
// 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;
}
}
}

@ -1,18 +0,0 @@
package cc.fascinated.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter @AllArgsConstructor
public class Tuple<L, R> {
/**
* The left value of the tuple.
*/
private final L left;
/**
* The right value of the tuple.
*/
private final R right;
}

@ -1,22 +0,0 @@
package cc.fascinated.common;
import lombok.experimental.UtilityClass;
@UtilityClass
public class UUIDUtils {
/**
* Add dashes to a UUID.
*
* @param idNoDashes the UUID without dashes
* @return the UUID with dashes
*/
public static String addUuidDashes(String idNoDashes) {
StringBuilder idBuff = new StringBuilder(idNoDashes);
idBuff.insert(20, '-');
idBuff.insert(16, '-');
idBuff.insert(12, '-');
idBuff.insert(8, '-');
return idBuff.toString();
}
}

@ -1,41 +0,0 @@
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 <T> the type of the response
*/
public static <T> T getAsEntity(String url, Class<T> clazz) {
try {
ResponseEntity<T> profile = CLIENT.get()
.uri(url)
.retrieve()
.toEntity(clazz);
if (profile.getStatusCode().isError()) {
return null;
}
return profile.getBody();
} catch (HttpClientErrorException ex) {
return null;
}
}
}

@ -1,66 +0,0 @@
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 <a href="https://wiki.vg/Protocol">Protocol Docs</a>
*/
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;
}
}

@ -1,64 +0,0 @@
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 <a href="https://wiki.vg/Protocol#Handshake">Protocol Docs</a>
*/
@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());
}
}
}

@ -1,62 +0,0 @@
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 <a href="https://wiki.vg/Protocol#Status_Request">Protocol Docs</a>
*/
@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);
}
}

@ -1,20 +0,0 @@
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;
}
}

@ -1,23 +0,0 @@
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";
}
}

@ -1,65 +0,0 @@
package cc.fascinated.controller;
import cc.fascinated.common.PlayerUtils;
import cc.fascinated.model.player.Player;
import cc.fascinated.model.player.Skin;
import cc.fascinated.model.response.impl.InvalidPartResponse;
import cc.fascinated.model.response.impl.PlayerNotFoundResponse;
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) {
Player player = playerManagerService.getPlayer(id);
if (player == null) { // No player with that id was found
return new PlayerNotFoundResponse().toResponseEntity();
}
// Return the player
return ResponseEntity.ok()
.cacheControl(cacheControl)
.body(player);
}
@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);
byte[] partBytes = new byte[0];
if (player != null) { // The player exists
Skin skin = player.getSkin();
Skin.Parts skinPart = Skin.Parts.fromName(part);
if (skinPart == null) { // Unknown part name
return new InvalidPartResponse().toResponseEntity();
}
partBytes = PlayerUtils.getSkinPartBytes(skin, skinPart, size);
}
if (partBytes == null) { // Fallback to the default head
partBytes = PlayerUtils.getSkinPartBytes(Skin.DEFAULT_SKIN, Skin.Parts.HEAD, size);
}
// Return the part image
return ResponseEntity.ok()
.cacheControl(cacheControl)
.contentType(MediaType.IMAGE_PNG)
.body(partBytes);
}
}

@ -1,18 +0,0 @@
package cc.fascinated.controller;
import cc.fascinated.model.server.MinecraftServer;
import cc.fascinated.service.pinger.impl.JavaMinecraftServerPinger;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/server/")
public class ServerController {
@ResponseBody
@GetMapping(value = "/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<MinecraftServer> getServer(@PathVariable String hostname) {
return ResponseEntity.ok(JavaMinecraftServerPinger.INSTANCE.ping(hostname, 25565));
}
}

@ -1,33 +0,0 @@
package cc.fascinated.exception;
import cc.fascinated.model.response.Response;
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;
@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 = HttpStatus.INTERNAL_SERVER_ERROR; // Get the HTTP status
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.";
}
ex.printStackTrace(); // Print the stack trace
return new Response(status, message).toResponseEntity(); // Return the error response
}
}

@ -1,78 +0,0 @@
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<Object> {
@Override
public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> 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<String, String> params = new HashMap<>();
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
params.put(entry.getKey(), Arrays.toString(entry.getValue()));
}
// Getting headers
Map<String, String> headers = new HashMap<>();
Enumeration<String> 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<? extends HttpMessageConverter<?>> converterType) {
return true;
}
}

@ -1,13 +0,0 @@
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;
}

@ -1,111 +0,0 @@
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<ProfileProperty> properties = new ArrayList<>();
/**
* Get the skin and cape of the player.
*
* @return the skin and cape of the player
*/
public Tuple<Skin, Cape> 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.addUuidDashes(id) : 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;
}
}
}

@ -1,27 +0,0 @@
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;
}
}

@ -1,27 +0,0 @@
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());
}
}

@ -1,48 +0,0 @@
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.Getter;
import java.util.UUID;
@Getter
public class Player {
/**
* The UUID of the player
*/
private final UUID uuid;
/**
* The username of the player
*/
@JsonProperty("username")
private final String name;
/**
* 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 = UUID.fromString(UUIDUtils.addUuidDashes(profile.getId()));
this.name = profile.getName();
// Get the skin and cape
Tuple<Skin, Cape> skinAndCape = profile.getSkinAndCape();
if (skinAndCape != null) {
this.skin = skinAndCape.getLeft();
this.cape = skinAndCape.getRight();
}
}
}

@ -1,154 +0,0 @@
package cc.fascinated.model.player;
import cc.fascinated.common.PlayerUtils;
import cc.fascinated.config.Config;
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.extern.log4j.Log4j2;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;
@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 final String url;
/**
* The model for the skin
*/
private final Model model;
/**
* The skin image for the skin
*/
@JsonIgnore
private final BufferedImage skinImage;
/**
* The part URLs of the skin
*/
@JsonProperty("parts")
private final Map<String, String> 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
*/
public static Parts fromName(String name) {
for (Parts part : values()) {
if (part.name().equalsIgnoreCase(name)) {
return part;
}
}
return null;
}
}
/**
* 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;
}
}
}

@ -1,29 +0,0 @@
package cc.fascinated.model.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@Getter @AllArgsConstructor
public class Response {
/**
* The status code of this error.
*/
private HttpStatus status;
/**
* The message of this error.
*/
private String message;
/**
* Gets this response as a {@link ResponseEntity}.
*
* @return the response entity
*/
public ResponseEntity<?> toResponseEntity() {
return new ResponseEntity<>(this, status);
}
}

@ -1,11 +0,0 @@
package cc.fascinated.model.response.impl;
import cc.fascinated.model.response.Response;
import org.springframework.http.HttpStatus;
public class InvalidPartResponse extends Response {
public InvalidPartResponse() {
super(HttpStatus.NOT_FOUND, "Invalid part name.");
}
}

@ -1,11 +0,0 @@
package cc.fascinated.model.response.impl;
import cc.fascinated.model.response.Response;
import org.springframework.http.HttpStatus;
public class PlayerNotFoundResponse extends Response {
public PlayerNotFoundResponse() {
super(HttpStatus.NOT_FOUND, "Player not found.");
}
}

@ -1,10 +0,0 @@
package cc.fascinated.model.server;
/**
* @author Braydon
*/
public final class JavaMinecraftServer extends MinecraftServer {
public JavaMinecraftServer(String hostname, int port, String motd) {
super(hostname, port, motd);
}
}

@ -1,15 +0,0 @@
package cc.fascinated.model.server;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
/**
* @author Braydon
*/
@AllArgsConstructor @Getter @ToString
public class MinecraftServer {
private final String hostname;
private final int port;
private final String motd;
}

@ -1,40 +0,0 @@
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);
}
}

@ -1,81 +0,0 @@
package cc.fascinated.service;
import cc.fascinated.common.UUIDUtils;
import cc.fascinated.model.mojang.MojangProfile;
import cc.fascinated.model.mojang.MojangUsernameToUuid;
import cc.fascinated.model.player.Player;
import lombok.extern.log4j.Log4j2;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service @Log4j2
public class PlayerService {
/**
* The cache of players.
*/
private final Map<UUID, Player> players = ExpiringMap.builder()
.expiration(1, TimeUnit.HOURS)
.expirationPolicy(ExpirationPolicy.CREATED)
.build();
/**
* The cache of player names to UUIDs.
*/
private final Map<String, UUID> playerNameToUUIDCache = ExpiringMap.builder()
.expiration(1, TimeUnit.DAYS)
.expirationPolicy(ExpirationPolicy.CREATED)
.build();
private final MojangAPIService mojangAPIService;
public PlayerService(MojangAPIService mojangAPIService) {
this.mojangAPIService = mojangAPIService;
}
/**
* Gets a player by their UUID.
*
* @param id the uuid or name of the player
* @return the player or null if the player does not exist
*/
public Player getPlayer(String id) {
UUID uuid = null;
if (id.length() == 32 || id.length() == 36) { // Check if the id is a UUID
try {
uuid = UUID.fromString(id.length() == 32 ? UUIDUtils.addUuidDashes(id) : id);
} catch (Exception ignored) {}
} else { // Check if the id is a name
uuid = playerNameToUUIDCache.get(id.toUpperCase());
}
// Check if the player is cached
if (uuid != null && players.containsKey(uuid)) {
return players.get(uuid);
}
MojangProfile profile = uuid == null ? null : mojangAPIService.getProfile(uuid.toString());
if (profile == null) { // The player cannot be found using their UUID
MojangUsernameToUuid apiProfile = mojangAPIService.getUuidFromUsername(id); // Get the UUID of the player using their name
if (apiProfile == null || !apiProfile.isValid()) {
return null;
}
// Get the profile of the player using their UUID
profile = mojangAPIService.getProfile(apiProfile.getId().length() == 32 ?
UUIDUtils.addUuidDashes(apiProfile.getId()) : apiProfile.getId());
}
if (profile == null) { // The player cannot be found using their name or UUID
log.info("Player with id {} could not be found", id);
return null;
}
Player player = new Player(profile);
players.put(player.getUuid(), player);
playerNameToUUIDCache.put(player.getName().toUpperCase(), player.getUuid());
return player;
}
}

@ -1,9 +0,0 @@
package cc.fascinated.service.pinger;
/**
* @author Braydon
* @param <T> the type of server to ping
*/
public interface MinecraftServerPinger<T> {
T ping(String hostname, int port);
}

@ -1,53 +0,0 @@
package cc.fascinated.service.pinger.impl;
import cc.fascinated.Main;
import cc.fascinated.common.packet.impl.java.JavaPacketHandshakingInSetProtocol;
import cc.fascinated.common.packet.impl.java.JavaPacketStatusInStart;
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.InetSocketAddress;
import java.net.Socket;
/**
* @author Braydon
*/
@Log4j2(topic = "Java Pinger")
public final class JavaMinecraftServerPinger implements MinecraftServerPinger<JavaMinecraftServer> {
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) {
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);
System.out.println("packetStatusInStart.getResponse() = " + packetStatusInStart.getResponse());
JavaServerStatusToken token = Main.GSON.fromJson(packetStatusInStart.getResponse(), JavaServerStatusToken.class);
return new JavaMinecraftServer(hostname, port, token.getDescription());
}
} catch (IOException ex) {
ex.printStackTrace();
}
return null;
}
}

@ -5,6 +5,16 @@ server:
whitelabel: whitelabel:
enabled: false enabled: false
# Spring Configuration
spring:
data:
# Redis - This is used for caching
redis:
host: "localhost"
port: 6379
database: 0
auth: "" # Leave blank for no auth
public-url: http://localhost:80 public-url: http://localhost:80
mojang: mojang:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@ -13,7 +13,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc @AutoConfigureMockMvc
@SpringBootTest @SpringBootTest(classes = TestRedisConfig.class)
class PlayerControllerTests { class PlayerControllerTests {
@Autowired @Autowired

@ -0,0 +1,44 @@
package cc.fascinated;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.NonNull;
import org.springframework.boot.test.context.TestConfiguration;
import redis.embedded.RedisServer;
import java.io.IOException;
/**
* Test configuration for
* a mock Redis server.
*
* @author Braydon
*/
@TestConfiguration
public class TestRedisConfig {
@NonNull private final RedisServer server;
public TestRedisConfig() throws IOException {
server = new RedisServer(); // Construct the mock server
}
/**
* Start up the mock Redis server.
*
* @throws IOException if there was an issue starting the server
*/
@PostConstruct
public void onInitialize() throws IOException {
server.start();
}
/**
* Shutdown the running mock Redis server.
*
* @throws IOException if there was an issue stopping the server
*/
@PreDestroy
public void housekeeping() throws IOException {
server.stop();
}
}

@ -5,6 +5,16 @@ server:
whitelabel: whitelabel:
enabled: false enabled: false
# Spring Configuration
spring:
data:
# Redis - This is used for caching
redis:
host: "localhost"
port: 6379
database: 0
auth: "" # Leave blank for no auth
public-url: http://localhost:80 public-url: http://localhost:80
mojang: mojang:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB