Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

147 changed files with 1068 additions and 5805 deletions

@ -1,49 +1,32 @@
name: Deploy App
name: "ci"
on:
push:
branches: ["master"]
paths-ignore:
- .gitignore
- README.md
- LICENSE
- docker-compose.yml
branches:
- master
jobs:
docker:
strategy:
matrix:
arch: ["ubuntu-latest"]
git-version: ["2.44.0"]
java-version: ["17"]
maven-version: ["3.8.5"]
runs-on: ${{ matrix.arch }}
# Steps to run
deploy:
runs-on: ubuntu-latest
steps:
# Checkout the repo
- name: Checkout
- name: Cloning repo
uses: actions/checkout@v4
# Setup Java and Maven
- name: Set up JDK and Maven
uses: s4u/setup-maven-action@v1.14.0
- name: Set up JDK 17 and Maven
uses: s4u/setup-maven-action@v1.7.0
with:
java-version: ${{ matrix.java-version }}
distribution: "zulu"
maven-version: ${{ matrix.maven-version }}
java-version: '17'
distribution: 'temurin'
maven-version: 3.8.5
# Run JUnit Tests
- name: Run Tests
run: mvn --batch-mode test -q
# Re-checkout to reset the FS before deploying to Dokku
- name: Checkout - Reset FS
- name: Cloning repo
uses: actions/checkout@v4
with:
fetch-depth: 0
# Deploy to Dokku
- name: Push to dokku
uses: dokku/github-action@master
with:

4
.gitignore vendored

@ -28,7 +28,3 @@ fabric.properties
git.properties
pom.xml.versionsBackup
application.yml
target/
### MaxMind GeoIP2
data/

@ -1,8 +1,4 @@
FROM maven:3.9.8-eclipse-temurin-17-alpine
RUN apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font \
&& fc-cache -f \
&& fc-list | sort
FROM maven:3.8.5-openjdk-17-slim
# Set the working directory
WORKDIR /home/container
@ -11,14 +7,11 @@ WORKDIR /home/container
COPY . .
# Build the jar
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
RUN mvn package -q
# Make port 80 available to the world outside this container
EXPOSE 80
ENV PORT=80
# Indicate that we're running in production
ENV ENVIRONMENT=production
# Run the jar file
CMD java -jar target/Minecraft-Utilities.jar -Djava.awt.headless=true
CMD ["java", "-jar", "target/Minecraft-Helper-1.0-SNAPSHOT.jar"]

21
LICENSE

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024, Fascinated
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -1,3 +0,0 @@
# Minecraft Utilities - Backend
See [The Website](https://mcutils.xyz) or [Minecraft Utilities Documentation](https://mcutils.xyz/docs) for more information.

@ -1,7 +0,0 @@
# Useful InfluxDB commands
## Delete data from bucket
```bash
influx delete --bucket mcutils --start 2024-01-01T00:00:00Z --stop 2025-01-05T00:00:00Z --org mcutils --token setme --predicate '_measurement="requests_per_route"
```

181
pom.xml

@ -5,8 +5,8 @@
<modelVersion>4.0.0</modelVersion>
<groupId>cc.fascinated</groupId>
<artifactId>Minecraft-Utilities</artifactId>
<version>1.0.0</version>
<artifactId>Minecraft-Helper</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
@ -17,89 +17,58 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<version>3.2.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- Build Steps -->
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<!-- Spring -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>build-info</id>
<goals>
<goal>build-info</goal>
</goals>
<configuration>
<additionalProperties>
<description>${project.description}</description>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<!-- Jitpack - Used for dnsjava -->
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Websockets -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</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>
<!-- MongoDB for data storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Libraries -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.20.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.20.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
<version>2.10.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
@ -108,95 +77,37 @@
<version>0.5.11</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>net.md-5</groupId>
<artifactId>bungeecord-chat</artifactId>
<version>1.20-R0.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator-autoconfigure</artifactId>
</dependency>
<!-- Sentry -->
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry-spring-boot-starter-jakarta</artifactId>
<version>7.14.0</version>
</dependency>
<!-- InfluxDB Metrics -->
<dependency>
<groupId>com.influxdb</groupId>
<artifactId>influxdb-spring</artifactId>
<version>7.2.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.influxdb</groupId>
<artifactId>influxdb-client-java</artifactId>
<version>7.2.0</version>
</dependency>
<!-- DNS Lookup -->
<dependency>
<groupId>com.github.dnsjava</groupId>
<artifactId>dnsjava</artifactId>
<version>v3.5.2</version>
<scope>compile</scope>
</dependency>
<!-- SwaggerUI -->
<!-- Unit Tests -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
<scope>compile</scope>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- GeoIP - IP Lookups -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- Archive Utilities -->
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-archiver</artifactId>
<version>4.10.0</version>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>6.1.5</version>
<scope>test</scope>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.codemonstur</groupId>
<artifactId>embedded-redis</artifactId>
<version>1.4.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo.spring3x</artifactId>
<version>4.16.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

@ -1,7 +1,6 @@
package xyz.mcutils.backend;
package cc.fascinated;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.SpringApplication;
@ -13,12 +12,10 @@ import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
@Log4j2(topic = "Main")
@SpringBootApplication
@SpringBootApplication @Log4j2
public class Main {
public static final Gson GSON = new GsonBuilder()
.setDateFormat("MM-dd-yyyy HH:mm:ss")
.create();
public static final Gson GSON = new Gson();
public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
@SneakyThrows
@ -33,6 +30,6 @@ public class Main {
}
log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config
SpringApplication.run(Main.class, args); // Start the application
SpringApplication.run(Main.class, args);
}
}

@ -1,4 +1,4 @@
package xyz.mcutils.backend.common;
package cc.fascinated.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;

@ -0,0 +1,72 @@
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,4 +1,4 @@
package xyz.mcutils.backend.common;
package cc.fascinated.common;
import lombok.AllArgsConstructor;
import lombok.Getter;

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

@ -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 <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,4 +1,4 @@
package xyz.mcutils.backend.common.packet;
package cc.fascinated.common.packet;
import lombok.NonNull;

@ -1,9 +1,9 @@
package xyz.mcutils.backend.common.packet.impl.java;
package cc.fascinated.common.packet.impl.java;
import cc.fascinated.common.packet.MinecraftJavaPacket;
import lombok.AllArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
import xyz.mcutils.backend.common.packet.MinecraftJavaPacket;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;

@ -1,8 +1,8 @@
package xyz.mcutils.backend.common.packet.impl.java;
package cc.fascinated.common.packet.impl.java;
import cc.fascinated.common.packet.MinecraftJavaPacket;
import lombok.Getter;
import lombok.NonNull;
import xyz.mcutils.backend.common.packet.MinecraftJavaPacket;
import java.io.DataInputStream;
import java.io.DataOutputStream;

@ -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;
}
}

@ -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";
}
}

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

@ -0,0 +1,18 @@
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,14 +1,12 @@
package xyz.mcutils.backend.exception;
package cc.fascinated.exception;
import cc.fascinated.model.response.Response;
import io.micrometer.common.lang.NonNull;
import io.sentry.Sentry;
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;
import xyz.mcutils.backend.model.response.ErrorResponse;
@ControllerAdvice
public final class ExceptionControllerAdvice {
@ -21,12 +19,7 @@ public final class ExceptionControllerAdvice {
*/
@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;
}
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();
}
@ -34,14 +27,7 @@ public final class ExceptionControllerAdvice {
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;
Sentry.captureException(ex); // Capture the exception with Sentry
}
return new ResponseEntity<>(new ErrorResponse(status, message), status);
ex.printStackTrace(); // Print the stack trace
return new Response(status, message).toResponseEntity(); // Return the error response
}
}

@ -1,39 +1,35 @@
package xyz.mcutils.backend.log;
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.beans.factory.annotation.Autowired;
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 xyz.mcutils.backend.common.IPUtils;
import xyz.mcutils.backend.service.MetricService;
import xyz.mcutils.backend.service.metric.metrics.RequestsPerRouteMetric;
import xyz.mcutils.backend.service.metric.metrics.TotalRequestsMetric;
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 Transaction")
@Slf4j(topic = "Req/Res Transaction")
public class TransactionLogger implements ResponseBodyAdvice<Object> {
@Autowired
private MetricService metricService;
@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);
@ -44,17 +40,34 @@ public class TransactionLogger implements ResponseBodyAdvice<Object> {
params.put(entry.getKey(), Arrays.toString(entry.getValue()));
}
// Logging the request
log.info(String.format("[Req] %s | %s | '%s', params=%s",
// 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
params,
headers
));
// Increment the metric
((TotalRequestsMetric) metricService.getMetric(TotalRequestsMetric.class)).increment();
((RequestsPerRouteMetric) metricService.getMetric(RequestsPerRouteMetric.class)).increment(request.getRequestURI());
// 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;
}

@ -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;
}

@ -1,20 +1,21 @@
package xyz.mcutils.backend.model.token;
package cc.fascinated.model.mojang;
import com.fasterxml.jackson.annotation.JsonIgnore;
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 xyz.mcutils.backend.Main;
import xyz.mcutils.backend.common.Tuple;
import xyz.mcutils.backend.common.UUIDUtils;
import xyz.mcutils.backend.model.player.Cape;
import xyz.mcutils.backend.model.skin.Skin;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
@Getter @NoArgsConstructor @AllArgsConstructor
public class MojangProfileToken {
@Getter @NoArgsConstructor
public class MojangProfile {
/**
* The UUID of the player.
@ -29,7 +30,7 @@ public class MojangProfileToken {
/**
* The properties of the player.
*/
private ProfileProperty[] properties = new ProfileProperty[0];
private final List<ProfileProperty> properties = new ArrayList<>();
/**
* Get the skin and cape of the player.
@ -41,7 +42,10 @@ public class MojangProfileToken {
if (textureProperty == null) {
return null;
}
JsonObject texturesJson = textureProperty.getDecodedValue().getAsJsonObject("textures"); // Parse the decoded JSON and get the texture object
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")));
}
@ -52,7 +56,7 @@ public class MojangProfileToken {
* @return the formatted UUID
*/
public String getFormattedUuid() {
return id.length() == 32 ? UUIDUtils.addDashes(id).toString() : id;
return id.length() == 32 ? UUIDUtils.addUuidDashes(id) : id;
}
/**
@ -69,7 +73,7 @@ public class MojangProfileToken {
return null;
}
@Getter @NoArgsConstructor
@Getter @AllArgsConstructor
public static class ProfileProperty {
/**
* The name of the property.
@ -91,9 +95,8 @@ public class MojangProfileToken {
*
* @return the decoded value
*/
@JsonIgnore
public JsonObject getDecodedValue() {
return Main.GSON.fromJson(new String(Base64.getDecoder().decode(this.value)), JsonObject.class);
public String getDecodedValue() {
return new String(Base64.getDecoder().decode(this.value));
}
/**

@ -1,23 +1,20 @@
package xyz.mcutils.backend.model.token;
package cc.fascinated.model.mojang;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter @NoArgsConstructor
public class MojangUsernameToUuidToken {
public class MojangUsernameToUuid {
/**
* The UUID of the player.
*/
@JsonProperty("id")
private String uuid;
private String id;
/**
* The name of the player.
*/
@JsonProperty("name")
private String username;
private String name;
/**
* Check if the profile is valid.
@ -25,6 +22,6 @@ public class MojangUsernameToUuidToken {
* @return if the profile is valid
*/
public boolean isValid() {
return uuid != null && username != null;
return id != null && name != null;
}
}

@ -1,12 +1,10 @@
package xyz.mcutils.backend.model.player;
package cc.fascinated.model.player;
import com.google.gson.JsonObject;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@AllArgsConstructor
@Getter @EqualsAndHashCode
@Getter @AllArgsConstructor
public class Cape {
/**

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

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

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

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

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

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

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

@ -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);
}
}

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

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

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

@ -1,30 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.Getter;
import lombok.experimental.UtilityClass;
@UtilityClass
public final class AppConfig {
/**
* Is the app running in a production environment?
*/
@Getter
private static final boolean production;
static { // Are we running on production?
String env = System.getenv("ENVIRONMENT");
production = env != null && (env.equals("production"));
}
/**
* Is the app running in a test environment?
*/
@Getter
private static boolean isRunningTest = true;
static {
try {
Class.forName("org.junit.jupiter.engine.JupiterTestEngine");
} catch (ClassNotFoundException e) {
isRunningTest = false;
}
}
}

@ -1,59 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@AllArgsConstructor @NoArgsConstructor
@Getter
public class CachedResponse {
/**
* The cache information for this response.
*/
private Cache cache;
@AllArgsConstructor @Getter @Setter
public static class Cache {
/**
* Whether this request is cached.
*/
private boolean cached;
/**
* The unix timestamp of when this was cached.
*/
private long cachedTime;
/**
* Create a new cache information object with the default values.
* <p>
* The default values are:
* <br>
* <ul>
* <li>cached: true</li>
* <li>cachedAt: {@link System#currentTimeMillis()}</li>
* </ul>
* <br>
* </p>
*
* @return the default cache information object
*/
public static Cache defaultCache() {
return new Cache(true, System.currentTimeMillis());
}
/**
* Sets if this request is cached.
*
* @param cached the new value of if this request is cached
*/
public void setCached(boolean cached) {
this.cached = cached;
if (!cached) {
cachedTime = -1;
}
}
}
}

@ -1,112 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.NonNull;
import lombok.experimental.UtilityClass;
import java.awt.*;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* @author Braydon
*/
@UtilityClass
public final class ColorUtils {
private static final Pattern STRIP_COLOR_PATTERN = Pattern.compile("(?i)§[0-9A-FK-OR]");
private static final Map<Character, String> COLOR_MAP = new HashMap<>();
static {
// Map each color to its corresponding hex code
COLOR_MAP.put('0', "#000000"); // Black
COLOR_MAP.put('1', "#0000AA"); // Dark Blue
COLOR_MAP.put('2', "#00AA00"); // Dark Green
COLOR_MAP.put('3', "#00AAAA"); // Dark Aqua
COLOR_MAP.put('4', "#AA0000"); // Dark Red
COLOR_MAP.put('5', "#AA00AA"); // Dark Purple
COLOR_MAP.put('6', "#FFAA00"); // Gold
COLOR_MAP.put('7', "#AAAAAA"); // Gray
COLOR_MAP.put('8', "#555555"); // Dark Gray
COLOR_MAP.put('9', "#5555FF"); // Blue
COLOR_MAP.put('a', "#55FF55"); // Green
COLOR_MAP.put('b', "#55FFFF"); // Aqua
COLOR_MAP.put('c', "#FF5555"); // Red
COLOR_MAP.put('d', "#FF55FF"); // Light Purple
COLOR_MAP.put('e', "#FFFF55"); // Yellow
COLOR_MAP.put('f', "#FFFFFF"); // White
}
/**
* Strip the color codes
* from the given input.
*
* @param input the input to strip
* @return the stripped input
*/
@NonNull
public static String stripColor(@NonNull String input) {
return STRIP_COLOR_PATTERN.matcher(input).replaceAll("");
}
/**
* Convert the given input
* into HTML.
*
* @param input the input to convert
* @return the HTML converted input
*/
@NonNull
public static String toHTML(@NonNull String input) {
StringBuilder builder = new StringBuilder();
boolean nextIsColor = false; // Is the next char a color code?
// Get the leading spaces from the first line
int leadingSpaces = 0;
boolean foundNonSpace = false;
for (char character : input.toCharArray()) {
if (character == ' ' && !foundNonSpace) {
leadingSpaces++;
} else {
foundNonSpace = true;
}
}
for (char character : input.toCharArray()) {
// Found color symbol, next color is the color
if (character == '§') {
nextIsColor = true;
continue;
}
if (nextIsColor) { // Map the current color to its hex code
String color = COLOR_MAP.getOrDefault(Character.toLowerCase(character), "");
builder.append("<span style=\"color:").append(color).append("\">");
nextIsColor = false;
continue;
}
if (character == ' ') { // Preserve space character
builder.append("&nbsp;");
continue;
}
builder.append(character); // Append the char...
}
// Add leading spaces to the end of the HTML string
builder.append("&nbsp;".repeat(Math.max(0, leadingSpaces)));
return builder.toString();
}
/**
* Gets a {@link Color} from a Minecraft color code.
*
* @param colorCode the color code to get the color from
* @return the color
*/
public static Color getMinecraftColor(char colorCode) {
String color = COLOR_MAP.getOrDefault(colorCode, null);
if (color == null) {
throw new IllegalArgumentException("Invalid color code: " + colorCode);
}
return Color.decode(color);
}
}

@ -1,58 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.Type;
import xyz.mcutils.backend.model.dns.impl.ARecord;
import xyz.mcutils.backend.model.dns.impl.SRVRecord;
/**
* @author Braydon
*/
@UtilityClass
public final class DNSUtils {
private static final String SRV_QUERY_PREFIX = "_minecraft._tcp.%s";
/**
* Get the resolved address and port of the
* given hostname by resolving the SRV records.
*
* @param hostname the hostname to resolve
* @return the resolved address and port, null if none
*/
@SneakyThrows
public static SRVRecord 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;
}
SRVRecord result = null;
for (Record record : records) {
result = new SRVRecord((org.xbill.DNS.SRVRecord) record);
}
return result;
}
/**
* Get the resolved address of the given
* hostname by resolving the A records.
*
* @param hostname the hostname to resolve
* @return the resolved address, null if none
*/
@SneakyThrows
public static ARecord resolveA(@NonNull String hostname) {
Record[] records = new Lookup(hostname, Type.A).run(); // Resolve A records
if (records == null) { // No records exist
return null;
}
ARecord result = null;
for (Record record : records) {
result = new ARecord((org.xbill.DNS.ARecord) record);
}
return result;
}
}

@ -1,21 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatusCode;
import java.util.List;
@AllArgsConstructor @Getter
public class Endpoint {
/**
* The endpoint.
*/
private final String endpoint;
/**
* The statuses that indicate that the endpoint is online.
*/
private final List<HttpStatusCode> allowedStatuses;
}

@ -1,26 +0,0 @@
package xyz.mcutils.backend.common;
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 <T> the type of the enum
* @return the enum constant of the specified enum type with the specified name
*/
public <T extends Enum<T>> T getEnumConstant(@NonNull Class<T> enumType, @NonNull String name) {
try {
return Enum.valueOf(enumType, name);
} catch (IllegalArgumentException ex) {
return null;
}
}
}

@ -1,132 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.NonNull;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* A simple set that expires elements after a certain
* amount of time, utilizing the {@link ExpiringMap} library.
*
* @param <T> The type of element to store within this set
* @author Braydon
*/
public final class ExpiringSet<T> implements Iterable<T> {
/**
* The internal cache for this set.
*/
@NonNull private final ExpiringMap<T, Long> cache;
/**
* The lifetime (in millis) of the elements in this set.
*/
private final long lifetime;
public ExpiringSet(@NonNull ExpirationPolicy expirationPolicy, long duration, @NonNull TimeUnit timeUnit) {
this(expirationPolicy, duration, timeUnit, ignored -> {});
}
public ExpiringSet(@NonNull ExpirationPolicy expirationPolicy, long duration, @NonNull TimeUnit timeUnit, @NonNull Consumer<T> onExpire) {
//noinspection unchecked
this.cache = ExpiringMap.builder()
.expirationPolicy(expirationPolicy)
.expiration(duration, timeUnit)
.expirationListener((key, ignored) -> onExpire.accept((T) key))
.build();
this.lifetime = timeUnit.toMillis(duration); // Get the lifetime in millis
}
/**
* Add an element to this set.
*
* @param element the element
* @return whether the element was added
*/
public boolean add(@NonNull T element) {
boolean contains = contains(element); // Does this set already contain the element?
this.cache.put(element, System.currentTimeMillis() + this.lifetime);
return !contains;
}
/**
* Get the entry time of an element in this set.
*
* @param element the element
* @return the entry time, -1 if not contained
*/
public long getEntryTime(@NonNull T element) {
return contains(element) ? this.cache.get(element) - this.lifetime : -1L;
}
/**
* Check if an element is
* contained within this set.
*
* @param element the element
* @return whether the element is contained
*/
public boolean contains(@NonNull T element) {
Long timeout = this.cache.get(element); // Get the timeout for the element
return timeout != null && (timeout > System.currentTimeMillis());
}
/**
* Check if this set is empty.
*
* @return whether this set is empty
*/
public boolean isEmpty() {
return this.cache.isEmpty();
}
/**
* Get the size of this set.
*
* @return the size
*/
public int size() {
return this.cache.size();
}
/**
* Remove an element from this set.
*
* @param element the element
* @return whether the element was removed
*/
public boolean remove(@NonNull T element) {
return this.cache.remove(element) != null;
}
/**
* Clear this set.
*/
public void clear() {
this.cache.clear();
}
/**
* Get the elements in this set.
*
* @return the elements
*/
@NonNull
public Set<T> getElements() {
return this.cache.keySet();
}
/**
* Returns an iterator over elements of type {@code T}.
*
* @return an Iterator.
*/
@Override @NonNull
public Iterator<T> iterator() {
return this.cache.keySet().iterator();
}
}

@ -1,28 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.Main;
import java.awt.*;
import java.io.IOException;
import java.io.InputStream;
@Log4j2(topic = "Fonts")
public class Fonts {
public static final Font MINECRAFT;
public static final Font MINECRAFT_BOLD;
public static final Font MINECRAFT_ITALIC;
static {
InputStream stream = Main.class.getResourceAsStream("/fonts/minecraft-font.ttf");
try {
MINECRAFT = Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(18f);
MINECRAFT_BOLD = MINECRAFT.deriveFont(Font.BOLD);
MINECRAFT_ITALIC = MINECRAFT.deriveFont(Font.ITALIC);
} catch (FontFormatException | IOException e) {
log.error("Failed to load Minecraft font", e);
throw new RuntimeException("Failed to load Minecraft font", e);
}
}
}

@ -1,78 +0,0 @@
package xyz.mcutils.backend.common;
import jakarta.validation.constraints.NotNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
@Log4j2(topic = "Image Utils")
public class ImageUtils {
/**
* Scale the given image to the provided scale.
*
* @param image the image to scale
* @param scale the scale to scale the image to
* @return the scaled image
*/
public static BufferedImage resize(BufferedImage image, double scale) {
BufferedImage scaled = new BufferedImage((int) (image.getWidth() * scale), (int) (image.getHeight() * scale), BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = scaled.createGraphics();
graphics.drawImage(image, AffineTransform.getScaleInstance(scale, scale), null);
graphics.dispose();
return scaled;
}
/**
* Flip the given image.
*
* @param image the image to flip
* @return the flipped image
*/
public static BufferedImage flip(@NotNull final BufferedImage image) {
BufferedImage flipped = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = flipped.createGraphics();
graphics.drawImage(image, image.getWidth(), 0, 0, image.getHeight(), 0, 0, image.getWidth(), image.getHeight(), null);
graphics.dispose();
return flipped;
}
/**
* Convert an image to bytes.
*
* @param image the image to convert
* @return the image as bytes
*/
@SneakyThrows
public static byte[] imageToBytes(BufferedImage image) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new Exception("Failed to convert image to bytes", e);
}
}
/**
* Convert a base64 string to an image.
*
* @param base64 the base64 string to convert
* @return the image
*/
@SneakyThrows
public static BufferedImage base64ToImage(String base64) {
String favicon = base64.contains("data:image/png;base64,") ? base64.split(",")[1] : base64;
try {
return ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(favicon)));
} catch (Exception e) {
throw new Exception("Failed to convert base64 to image", e);
}
}
}

@ -1,202 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
/**
* @author Braydon
* @see <a href="https://wiki.vg/Protocol_version_numbers">Protocol Version Numbers</a>
* @see <a href="https://www.spigotmc.org/wiki/spigot-nms-and-minecraft-versions-1-16">Spigot NMS (1.16+)</a>
* @see <a href="https://www.spigotmc.org/wiki/spigot-nms-and-minecraft-versions-1-10-1-15">Spigot NMS (1.10 - 1.15)</a>
* @see <a href="https://www.spigotmc.org/wiki/spigot-nms-and-minecraft-versions-legacy">Spigot NMS (1.8 - 1.9)</a>
*/
@RequiredArgsConstructor @Getter @ToString @Log4j2(topic = "Minecraft Version")
public enum JavaMinecraftVersion {
V1_20_3(765, "v1_20_R3"), // 1.20.3 & 1.20.4
V1_20_2(764, "v1_20_R2"), // 1.20.2
V1_20(763, "v1_20_R1"), // 1.20 & 1.20.1
V1_19_4(762, "v1_19_R3"), // 1.19.4
V1_19_3(761, "v1_19_R2"), // 1.19.3
V1_19_1(760, "v1_19_R1"), // 1.19.1 & 1.19.2
V1_19(759, "v1_19_R1"), // 1.19
V1_18_2(758, "v1_18_R2"), // 1.18.2
V1_18(757, "v1_18_R1"), // 1.18 & 1.18.1
V1_17_1(756, "v1_17_R1"), // 1.17.1
V1_17(755, "v1_17_R1"), // 1.17
V1_16_4(754, "v1_16_R3"), // 1.16.4 & 1.16.5
V1_16_3(753, "v1_16_R2"), // 1.16.3
V1_16_2(751, "v1_16_R2"), // 1.16.2
V1_16_1(736, "v1_16_R1"), // 1.16.1
V1_16(735, "v1_16_R1"), // 1.16
V1_15_2(578, "v1_15_R1"), // 1.15.2
V1_15_1(575, "v1_15_R1"), // 1.15.1
V1_15(573, "v1_15_R1"), // 1.15
V1_14_4(498, "v1_14_R1"), // 1.14.4
V1_14_3(490, "v1_14_R1"), // 1.14.3
V1_14_2(485, "v1_14_R1"), // 1.14.2
V1_14_1(480, "v1_14_R1"), // 1.14.1
V1_14(477, "v1_14_R1"), // 1.14
V1_13_2(404, "v1_13_R2"), // 1.13.2
V1_13_1(401, "v1_13_R2"), // 1.13.1
V1_13(393, "v1_13_R1"), // 1.13
V1_12_2(340, "v1_12_R1"), // 1.12.2
V1_12_1(338, "v1_12_R1"), // 1.12.1
V1_12(335, "v1_12_R1"), // 1.12
V1_11_1(316, "v1_11_R1"), // 1.11.1 & 1.11.2
V1_11(315, "v1_11_R1"), // 1.11
V1_10(210, "v1_10_R1"), // 1.10.x
V1_9_3(110, "v1_9_R2"), // 1.9.3 & 1.9.4
V1_9_2(109, "v1_9_R1"), // 1.9.2
V1_9_1(108, "v1_9_R1"), // 1.9.1
V1_9(107, "v1_9_R1"), // 1.9
V1_8(47, "v1_8_R3"), // 1.8.x
V1_7_6(5, "v1_7_R4"), // 1.7.6 - 1.7.10
UNKNOWN(-1, "Unknown");
// Game Updates
public static final JavaMinecraftVersion TRAILS_AND_TALES = JavaMinecraftVersion.V1_20;
public static final JavaMinecraftVersion THE_WILD_UPDATE = JavaMinecraftVersion.V1_19;
public static final JavaMinecraftVersion CAVES_AND_CLIFFS_PT_2 = JavaMinecraftVersion.V1_18;
public static final JavaMinecraftVersion CAVES_AND_CLIFFS_PT_1 = JavaMinecraftVersion.V1_17;
public static final JavaMinecraftVersion NETHER_UPDATE = JavaMinecraftVersion.V1_16;
public static final JavaMinecraftVersion BUZZY_BEES = JavaMinecraftVersion.V1_15;
public static final JavaMinecraftVersion VILLAGE_AND_PILLAGE = JavaMinecraftVersion.V1_14;
public static final JavaMinecraftVersion UPDATE_AQUATIC = JavaMinecraftVersion.V1_13;
public static final JavaMinecraftVersion WORLD_OF_COLOR_UPDATE = JavaMinecraftVersion.V1_12;
public static final JavaMinecraftVersion EXPLORATION_UPDATE = JavaMinecraftVersion.V1_11;
public static final JavaMinecraftVersion FROSTBURN_UPDATE = JavaMinecraftVersion.V1_10;
public static final JavaMinecraftVersion THE_COMBAT_UPDATE = JavaMinecraftVersion.V1_9;
public static final JavaMinecraftVersion BOUNTIFUL_UPDATE = JavaMinecraftVersion.V1_8;
private static final JavaMinecraftVersion[] VALUES = JavaMinecraftVersion.values();
/**
* The protocol number of this version.
*/
private final int protocol;
/**
* The server version for this version.
*/
private final String nmsVersion;
/**
* The cached name of this version.
*/
private String name;
/**
* Get the name of this protocol version.
*
* @return the name
*/
public String getName() {
// We have a name
if (this.name != null) {
return this.name;
}
// Use the server version as the name if unknown
if (this == UNKNOWN) {
this.name = this.getNmsVersion();
} else { // Parse the name
this.name = name().substring(1);
this.name = this.name.replace("_", ".");
}
return this.name;
}
/**
* Is this version legacy?
*
* @return whether this version is legacy
*/
public boolean isLegacy() {
return this.isBelow(JavaMinecraftVersion.V1_16);
}
/**
* Check if this version is
* above the one given.
*
* @param other the other version
* @return true if above, otherwise false
*/
public boolean isAbove(JavaMinecraftVersion other) {
return this.protocol > other.getProtocol();
}
/**
* Check if this version is
* or above the one given.
*
* @param other the other version
* @return true if is or above, otherwise false
*/
public boolean isOrAbove(JavaMinecraftVersion other) {
return this.protocol >= other.getProtocol();
}
/**
* Check if this version is
* below the one given.
*
* @param other the other version
* @return true if below, otherwise false
*/
public boolean isBelow(JavaMinecraftVersion other) {
return this.protocol < other.getProtocol();
}
/**
* Check if this version is
* or below the one given.
*
* @param other the other version
* @return true if is or below, otherwise false
*/
public boolean isOrBelow(JavaMinecraftVersion other) {
return this.protocol <= other.getProtocol();
}
/**
* Get the minimum Minecraft version.
*
* @return the minimum version
*/
@NonNull
public static JavaMinecraftVersion getMinimumVersion() {
return VALUES[VALUES.length - 2];
}
/**
* Get the version from the given protocol.
*
* @param protocol the protocol to get the version for
* @return the version, null if none
*/
public static JavaMinecraftVersion byProtocol(int protocol) {
for (JavaMinecraftVersion version : values()) {
if (version.getProtocol() == protocol) {
return version;
}
}
return null;
}
}

@ -1,85 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.concurrent.TimeUnit;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@ToString
public enum MojangServer {
SESSION("Session Server", "https://sessionserver.mojang.com"),
API("Mojang API", "https://api.mojang.com"),
TEXTURES("Textures Server", "https://textures.minecraft.net"),
ASSETS("Assets Server", "https://assets.mojang.com"),
LIBRARIES("Libraries Server", "https://libraries.minecraft.net"),
SERVICES("Minecraft Services", "https://api.minecraftservices.com");
private static final long STATUS_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
/**
* The name of this server.
*/
@NonNull private final String name;
/**
* The endpoint of this service.
*/
@NonNull private final String endpoint;
/**
* Ping this service and get the status of it.
*
* @return the service status
*/
@NonNull
public Status getStatus() {
try {
InetAddress address = InetAddress.getByName(endpoint.substring(8));
long before = System.currentTimeMillis();
if (address.isReachable((int) STATUS_TIMEOUT)) {
// The time it took to reach the host is 75% of
// the timeout, consider it to be degraded.
if ((System.currentTimeMillis() - before) > STATUS_TIMEOUT * 0.75D) {
return Status.DEGRADED;
}
return Status.ONLINE;
}
} catch (UnknownHostException ex) {
ex.printStackTrace();
} catch (IOException ignored) {
// We can safely ignore any errors, we're simply checking
// if the host is reachable, if it's not, then it's offline.
}
return Status.OFFLINE;
}
/**
* The status of a service.
*/
public enum Status {
/**
* The service is online and accessible.
*/
ONLINE,
/**
* The service is online, but is experiencing degraded performance.
*/
DEGRADED,
/**
* The service is offline and inaccessible.
*/
OFFLINE
}
}

@ -1,50 +0,0 @@
package xyz.mcutils.backend.common;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.Main;
import xyz.mcutils.backend.exception.impl.BadRequestException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
@UtilityClass @Log4j2(topic = "Player Utils")
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<byte[]> response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder(URI.create(url)).build(),
HttpResponse.BodyHandlers.ofByteArray());
return response.body();
}
}

@ -1,16 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ServerUtils {
/**
* Gets the address of the server.
*
* @return the address of the server
*/
public static String getAddress(String ip, int port) {
return ip + (port == 25565 ? "" : ":" + port);
}
}

@ -1,19 +0,0 @@
package xyz.mcutils.backend.common;
public class Timer {
/**
* Schedules a task to run after a delay.
*
* @param runnable the task to run
* @param delay the delay before the task runs
*/
public static void scheduleRepeating(Runnable runnable, long delay, long period) {
new java.util.Timer().scheduleAtFixedRate(new java.util.TimerTask() {
@Override
public void run() {
runnable.run();
}
}, delay, period);
}
}

@ -1,36 +0,0 @@
package xyz.mcutils.backend.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());
}
/**
* Remove dashes from a UUID.
*
* @param dashed the UUID with dashes
* @return the UUID without dashes
*/
@NonNull
public static String removeDashes(@NonNull UUID dashed) {
return dashed.toString().replace("-", "");
}
}

@ -1,77 +0,0 @@
package xyz.mcutils.backend.common;
import lombok.experimental.UtilityClass;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import xyz.mcutils.backend.exception.impl.RateLimitException;
@UtilityClass
public class WebRequest {
/**
* The web client.
*/
private static final RestClient CLIENT;
static {
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectTimeout(2500); // 2.5 seconds
CLIENT = RestClient.builder()
.requestFactory(requestFactory)
.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) throws RateLimitException {
ResponseEntity<T> responseEntity = CLIENT.get()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
if (responseEntity.getStatusCode().isError()) {
return null;
}
if (responseEntity.getStatusCode().isSameCodeAs(HttpStatus.TOO_MANY_REQUESTS)) {
throw new RateLimitException("Rate limit reached");
}
return responseEntity.getBody();
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
*/
public static ResponseEntity<?> get(String url, Class<?> clazz) {
return CLIENT.get()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
*/
public static ResponseEntity<?> head(String url, Class<?> clazz) {
return CLIENT.head()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
}
}

@ -1,23 +0,0 @@
package xyz.mcutils.backend.common.packet;
import lombok.NonNull;
import java.io.IOException;
import java.net.DatagramSocket;
/**
* Represents a packet in the
* Minecraft Bedrock protocol.
*
* @author Braydon
* @see <a href="https://wiki.vg/Raknet_Protocol">Protocol Docs</a>
*/
public interface MinecraftBedrockPacket {
/**
* Process this packet.
*
* @param socket the socket to process the packet for
* @throws IOException if an I/O error occurs
*/
void process(@NonNull DatagramSocket socket) throws IOException;
}

@ -1,42 +0,0 @@
package xyz.mcutils.backend.common.packet.impl.bedrock;
import lombok.NonNull;
import xyz.mcutils.backend.common.packet.MinecraftBedrockPacket;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* This packet is sent by the client to the server to
* request a pong response from the server. The server
* will respond with a string containing the server's status.
*
* @author Braydon
* @see <a href="https://wiki.vg/Raknet_Protocol#Unconnected_Ping">Protocol Docs</a>
*/
public final class BedrockPacketUnconnectedPing implements MinecraftBedrockPacket {
private static final byte ID = 0x01; // The ID of the packet
private static final byte[] MAGIC = { 0, -1, -1, 0, -2, -2, -2, -2, -3, -3, -3, -3, 18, 52, 86, 120 };
/**
* Process this packet.
*
* @param socket the socket to process the packet for
* @throws IOException if an I/O error occurs
*/
@Override
public void process(@NonNull DatagramSocket socket) throws IOException {
// Construct the packet buffer
ByteBuffer buffer = ByteBuffer.allocate(33).order(ByteOrder.LITTLE_ENDIAN);;
buffer.put(ID); // Packet ID
buffer.putLong(System.currentTimeMillis()); // Timestamp
buffer.put(MAGIC); // Magic
buffer.putLong(0L); // Client GUID
// Send the packet
socket.send(new DatagramPacket(buffer.array(), 0, buffer.limit()));
}
}

@ -1,62 +0,0 @@
package xyz.mcutils.backend.common.packet.impl.bedrock;
import lombok.Getter;
import lombok.NonNull;
import xyz.mcutils.backend.common.packet.MinecraftBedrockPacket;
import xyz.mcutils.backend.model.server.BedrockMinecraftServer;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
/**
* This packet is sent by the server to the client in
* response to the {@link BedrockPacketUnconnectedPing}.
*
* @author Braydon
* @see <a href="https://wiki.vg/Raknet_Protocol#Unconnected_Pong">Protocol Docs</a>
*/
@Getter
public final class BedrockPacketUnconnectedPong implements MinecraftBedrockPacket {
private static final byte ID = 0x1C; // The ID of the packet
/**
* The response from the server, null if none.
*/
private String response;
/**
* Process this packet.
*
* @param socket the socket to process the packet for
* @throws IOException if an I/O error occurs
*/
@Override
public void process(@NonNull DatagramSocket socket) throws IOException {
// Handle receiving of the packet
byte[] receiveData = new byte[2048];
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
socket.receive(receivePacket);
// Construct a buffer from the received packet
ByteBuffer buffer = ByteBuffer.wrap(receivePacket.getData()).order(ByteOrder.LITTLE_ENDIAN);
byte id = buffer.get(); // The received packet id
if (id == ID) {
String response = new String(buffer.array(), StandardCharsets.UTF_8).trim(); // Extract the response
// Trim the length of the response (short) from the
// start of the string, which begins with the edition name
for (BedrockMinecraftServer.Edition edition : BedrockMinecraftServer.Edition.values()) {
int startIndex = response.indexOf(edition.name());
if (startIndex != -1) {
response = response.substring(startIndex);
break;
}
}
this.response = response;
}
}
}

@ -1,27 +0,0 @@
package xyz.mcutils.backend.common.renderer;
import xyz.mcutils.backend.model.skin.ISkinPart;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
public abstract class IsometricSkinRenderer<T extends ISkinPart> extends SkinRenderer<T> {
/**
* Draw a part onto the texture.
*
* @param graphics the graphics to draw to
* @param partImage the part image to draw
* @param transform the transform to apply
* @param x the x position to draw at
* @param y the y position to draw at
* @param width the part image width
* @param height the part image height
*/
protected final void drawPart(Graphics2D graphics, BufferedImage partImage, AffineTransform transform,
double x, double y, int width, int height) {
graphics.setTransform(transform);
graphics.drawImage(partImage, (int) x, (int) y, width, height, null);
}
}

@ -1,14 +0,0 @@
package xyz.mcutils.backend.common.renderer;
import java.awt.image.BufferedImage;
public abstract class Renderer<T> {
/**
* Renders the object to the specified size.
*
* @param input The object to render.
* @param size The size to render the object to.
*/
public abstract BufferedImage render(T input, int size);
}

@ -1,108 +0,0 @@
package xyz.mcutils.backend.common.renderer;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.common.ImageUtils;
import xyz.mcutils.backend.model.skin.ISkinPart;
import xyz.mcutils.backend.model.skin.Skin;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
@Log4j2(topic = "Skin Renderer")
public abstract class SkinRenderer<T extends ISkinPart> {
/**
* Get the texture of a part of the skin.
*
* @param skin the skin to get the part texture from
* @param part the part of the skin to get
* @param size the size to scale the texture to
* @param renderOverlays should the overlays be rendered
* @return the texture of the skin part
*/
@SneakyThrows
public BufferedImage getVanillaSkinPart(Skin skin, ISkinPart.Vanilla part, double size, boolean renderOverlays) {
ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part
// The skin texture is legacy, use legacy coordinates
if (skin.isLegacy() && part.hasLegacyCoordinates()) {
coordinates = part.getLegacyCoordinates();
}
int width = part.getWidth(); // The width of the part
if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) {
width--;
}
BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); // The skin texture
BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size);
if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) {
partTexture = ImageUtils.flip(partTexture);
}
// Draw part overlays
ISkinPart.Vanilla[] overlayParts = part.getOverlays();
if (overlayParts != null && renderOverlays) {
log.info("Applying overlays to part: {}", part.name());
for (ISkinPart.Vanilla overlay : overlayParts) {
applyOverlay(partTexture.createGraphics(), getVanillaSkinPart(skin, overlay, size, false));
}
}
return partTexture;
}
/**
* Get the texture of a specific part of the skin.
*
* @param skinImage the skin image to get the part from
* @param x the x position of the part
* @param y the y position of the part
* @param width the width of the part
* @param height the height of the part
* @param size the size to scale the part to
* @return the texture of the skin part
*/
@SneakyThrows
private BufferedImage getSkinPartTexture(BufferedImage skinImage, int x, int y, int width, int height, double size) {
// Create a new BufferedImage for the part of the skin texture
BufferedImage headTexture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// Crop just the part we want based on our x, y, width, and height
headTexture.getGraphics().drawImage(skinImage, 0, 0, width, height, x, y, x + width, y + height, null);
// Scale the skin part texture
if (size > 0D) {
headTexture = ImageUtils.resize(headTexture, size);
}
return headTexture;
}
/**
* Apply an overlay to a texture.
*
* @param graphics the graphics to overlay on
* @param overlayImage the part to overlay
*/
protected void applyOverlay(Graphics2D graphics, BufferedImage overlayImage) {
try {
graphics.drawImage(overlayImage, 0, 0, null);
graphics.dispose();
} catch (Exception ignored) {
// We can safely ignore this, legacy
// skins don't have overlays
}
}
/**
* Renders the skin part for the player's skin.
*
* @param skin the player's skin
* @param part the skin part to render
* @param renderOverlays should the overlays be rendered
* @param size the size of the part
* @return the rendered skin part
*/
public abstract BufferedImage render(Skin skin, T part, boolean renderOverlays, int size);
}

@ -1,176 +0,0 @@
package xyz.mcutils.backend.common.renderer.impl.server;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.Main;
import xyz.mcutils.backend.common.ColorUtils;
import xyz.mcutils.backend.common.Fonts;
import xyz.mcutils.backend.common.ImageUtils;
import xyz.mcutils.backend.common.renderer.Renderer;
import xyz.mcutils.backend.model.server.JavaMinecraftServer;
import xyz.mcutils.backend.model.server.MinecraftServer;
import xyz.mcutils.backend.service.ServerService;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
@Log4j2
public class ServerPreviewRenderer extends Renderer<MinecraftServer> {
public static final ServerPreviewRenderer INSTANCE = new ServerPreviewRenderer();
private static BufferedImage SERVER_BACKGROUND;
private static BufferedImage PING_ICON;
static {
try {
SERVER_BACKGROUND = ImageIO.read(new ByteArrayInputStream(Main.class.getResourceAsStream("/icons/server_background.png").readAllBytes()));
PING_ICON = ImageIO.read(new ByteArrayInputStream(Main.class.getResourceAsStream("/icons/ping.png").readAllBytes()));
} catch (Exception ex) {
log.error("Failed to load server preview assets", ex);
}
}
private final int fontSize = Fonts.MINECRAFT.getSize();
private final int width = 560;
private final int height = 64 + 3 + 3;
private final int padding = 3;
@Override
public BufferedImage render(MinecraftServer server, int size) {
BufferedImage texture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); // The texture to return
BufferedImage favicon = getServerFavicon(server);
BufferedImage background = SERVER_BACKGROUND;
// Create the graphics for drawing
Graphics2D graphics = texture.createGraphics();
// Set up the font
graphics.setFont(Fonts.MINECRAFT);
// Draw the background
for (int backgroundX = 0; backgroundX < width + background.getWidth(); backgroundX += background.getWidth()) {
for (int backgroundY = 0; backgroundY < height + background.getHeight(); backgroundY += background.getHeight()) {
graphics.drawImage(background, backgroundX, backgroundY, null);
}
}
int y = fontSize + 1;
int x = 64 + 8;
int initialX = x; // Store the initial value of x
// Draw the favicon
graphics.drawImage(favicon, padding, padding, null);
// Draw the server hostname
graphics.setColor(Color.WHITE);
graphics.drawString(server.getHostname(), x, y);
// Draw the server motd
y += fontSize + (padding * 2);
for (String line : server.getMotd().getRaw()) {
int index = 0;
int colorIndex = line.indexOf("§");
while (colorIndex != -1) {
// Draw text before color code
String textBeforeColor = line.substring(index, colorIndex);
graphics.drawString(textBeforeColor, x, y);
// Calculate width of text before color code
int textWidth = graphics.getFontMetrics().stringWidth(textBeforeColor);
// Move x position to after the drawn text
x += textWidth;
// Set color based on color code
char colorCode = Character.toLowerCase(line.charAt(colorIndex + 1));
// Set the color and font style
switch (colorCode) {
case 'l': graphics.setFont(Fonts.MINECRAFT_BOLD);
case 'o': graphics.setFont(Fonts.MINECRAFT_ITALIC);
default: {
try {
graphics.setFont(Fonts.MINECRAFT);
Color color = ColorUtils.getMinecraftColor(colorCode);
graphics.setColor(color);
} catch (Exception ignored) {
// Unknown color, can ignore the error
}
}
}
// Move index to after the color code
index = colorIndex + 2;
// Find next color code
colorIndex = line.indexOf("§", index);
}
// Draw remaining text
String remainingText = line.substring(index);
graphics.drawString(remainingText, x, y);
// Move to the next line
y += fontSize + padding;
// Reset x position for the next line
x = initialX; // Reset x to its initial value
}
// Ensure the font is reset
graphics.setFont(Fonts.MINECRAFT);
// Render the ping
BufferedImage pingIcon = ImageUtils.resize(PING_ICON, 2);
x = width - pingIcon.getWidth() - padding;
graphics.drawImage(pingIcon, x, padding, null);
// Reset the y position
y = fontSize + 1;
// Render the player count
MinecraftServer.Players players = server.getPlayers();
String playersOnline = players.getOnline() + "";
String playersMax = players.getMax() + "";
// Calculate the width of each player count element
int maxWidth = graphics.getFontMetrics().stringWidth(playersMax);
int slashWidth = graphics.getFontMetrics().stringWidth("/");
int onlineWidth = graphics.getFontMetrics().stringWidth(playersOnline);
// Calculate the total width of the player count string
int totalWidth = maxWidth + slashWidth + onlineWidth;
// Calculate the starting x position
int startX = (width - totalWidth) - pingIcon.getWidth() - 6;
// Render the player count elements
graphics.setColor(Color.LIGHT_GRAY);
graphics.drawString(playersOnline, startX, y);
startX += onlineWidth;
graphics.setColor(Color.DARK_GRAY);
graphics.drawString("/", startX, y);
startX += slashWidth;
graphics.setColor(Color.LIGHT_GRAY);
graphics.drawString(playersMax, startX, y);
return ImageUtils.resize(texture, (double) size / width);
}
/**
* Get the favicon of a server.
*
* @param server the server to get the favicon of
* @return the server favicon
*/
public BufferedImage getServerFavicon(MinecraftServer server) {
String favicon = null;
// Get the server favicon
if (server instanceof JavaMinecraftServer javaServer) {
if (javaServer.getFavicon() != null) {
favicon = javaServer.getFavicon().getBase64();
}
}
// Fallback to the default server icon
if (favicon == null) {
favicon = ServerService.DEFAULT_SERVER_ICON;
}
return ImageUtils.base64ToImage(favicon);
}
}

@ -1,42 +0,0 @@
package xyz.mcutils.backend.common.renderer.impl.skin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.common.ImageUtils;
import xyz.mcutils.backend.common.renderer.SkinRenderer;
import xyz.mcutils.backend.model.skin.ISkinPart;
import xyz.mcutils.backend.model.skin.Skin;
import java.awt.*;
import java.awt.image.BufferedImage;
@AllArgsConstructor @Getter @Log4j2(topic = "Skin Renderer/Body")
public class BodyRenderer extends SkinRenderer<ISkinPart.Custom> {
public static final BodyRenderer INSTANCE = new BodyRenderer();
@Override
public BufferedImage render(Skin skin, ISkinPart.Custom part, boolean renderOverlays, int size) {
BufferedImage texture = new BufferedImage(16, 32, BufferedImage.TYPE_INT_ARGB); // The texture to return
Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing
// Get the Vanilla skin parts to draw
BufferedImage face = getVanillaSkinPart(skin, ISkinPart.Vanilla.FACE, -1, renderOverlays);
BufferedImage body = getVanillaSkinPart(skin, ISkinPart.Vanilla.BODY_FRONT, -1, renderOverlays);
BufferedImage leftArm = getVanillaSkinPart(skin, ISkinPart.Vanilla.LEFT_ARM_FRONT, -1, renderOverlays);
BufferedImage rightArm = getVanillaSkinPart(skin, ISkinPart.Vanilla.RIGHT_ARM_FRONT, -1, renderOverlays);
BufferedImage leftLeg = getVanillaSkinPart(skin, ISkinPart.Vanilla.LEFT_LEG_FRONT, -1, renderOverlays);
BufferedImage rightLeg = getVanillaSkinPart(skin, ISkinPart.Vanilla.RIGHT_LEG_FRONT, -1, renderOverlays);
// Draw the body parts
graphics.drawImage(face, 4, 0, null);
graphics.drawImage(body, 4, 8, null);
graphics.drawImage(leftArm, skin.getModel() == Skin.Model.SLIM ? 1 : 0, 8, null);
graphics.drawImage(rightArm, 12, 8, null);
graphics.drawImage(leftLeg, 8, 20, null);
graphics.drawImage(rightLeg, 4, 20, null);
graphics.dispose();
return ImageUtils.resize(texture, (double) size / 32);
}
}

@ -1,48 +0,0 @@
package xyz.mcutils.backend.common.renderer.impl.skin;
import xyz.mcutils.backend.common.renderer.IsometricSkinRenderer;
import xyz.mcutils.backend.model.skin.ISkinPart;
import xyz.mcutils.backend.model.skin.Skin;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
public class IsometricHeadRenderer extends IsometricSkinRenderer<ISkinPart.Custom> {
public static final IsometricHeadRenderer INSTANCE = new IsometricHeadRenderer();
private static final double SKEW_A = 26D / 45D; // 0.57777777
private static final double SKEW_B = SKEW_A * 2D; // 1.15555555
private static final AffineTransform HEAD_TOP_TRANSFORM = new AffineTransform(1D, -SKEW_A, 1, SKEW_A, 0, 0);
private static final AffineTransform FACE_TRANSFORM = new AffineTransform(1D, -SKEW_A, 0D, SKEW_B, 0d, SKEW_A);
private static final AffineTransform HEAD_LEFT_TRANSFORM = new AffineTransform(1D, SKEW_A, 0D, SKEW_B, 0D, 0D);
@Override
public BufferedImage render(Skin skin, ISkinPart.Custom part, boolean renderOverlays, int size) {
double scale = (size / 8D) / 2.5;
double zOffset = scale * 3.5D;
double xOffset = scale * 2D;
BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); // The texture to return
Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing
// Get the Vanilla skin parts to draw
BufferedImage headTop = getVanillaSkinPart(skin, ISkinPart.Vanilla.HEAD_TOP, scale, renderOverlays);
BufferedImage face = getVanillaSkinPart(skin, ISkinPart.Vanilla.FACE, scale, renderOverlays);
BufferedImage headLeft = getVanillaSkinPart(skin, ISkinPart.Vanilla.HEAD_LEFT, scale, renderOverlays);
// Draw the top head part
drawPart(graphics, headTop, HEAD_TOP_TRANSFORM, -0.5 - zOffset, xOffset + zOffset, headTop.getWidth(), headTop.getHeight() + 2);
// Draw the face part
double x = xOffset + 8 * scale;
drawPart(graphics, face, FACE_TRANSFORM, x, x + zOffset - 0.5, face.getWidth(), face.getHeight());
// Draw the left head part
drawPart(graphics, headLeft, HEAD_LEFT_TRANSFORM, xOffset + 1, zOffset - 0.5, headLeft.getWidth(), headLeft.getHeight());
graphics.dispose();
return texture;
}
}

@ -1,32 +0,0 @@
package xyz.mcutils.backend.common.renderer.impl.skin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.common.renderer.SkinRenderer;
import xyz.mcutils.backend.model.skin.ISkinPart;
import xyz.mcutils.backend.model.skin.Skin;
import java.awt.*;
import java.awt.image.BufferedImage;
@AllArgsConstructor @Getter @Log4j2(topic = "Skin Renderer/Square")
public class SquareRenderer extends SkinRenderer<ISkinPart.Vanilla> {
public static final SquareRenderer INSTANCE = new SquareRenderer();
@Override
public BufferedImage render(Skin skin, ISkinPart.Vanilla part, boolean renderOverlays, int size) {
double scale = size / 8D;
BufferedImage partImage = getVanillaSkinPart(skin, part, scale, renderOverlays); // Get the part image
if (!renderOverlays) { // Not rendering overlays
return partImage;
}
// Create a new image, draw our skin part texture, and then apply overlays
BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); // The texture to return
Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing
graphics.drawImage(partImage, 0, 0, null);
graphics.dispose();
return texture;
}
}

@ -1,54 +0,0 @@
package xyz.mcutils.backend.config;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Getter @Log4j2(topic = "Config")
@Configuration
public class Config {
public static Config INSTANCE;
@Autowired
private Environment environment;
@Value("${public-url}")
private String webPublicUrl;
@PostConstruct
public void onInitialize() {
INSTANCE = this;
}
@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setName("etagFilter");
return filterRegistrationBean;
}
@Bean
public WebMvcConfigurer configureCors() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(@NonNull CorsRegistry registry) {
// Allow all origins to access the API
registry.addMapping("/**")
.allowedOrigins("*") // Allow all origins
.allowedMethods("*") // Allow all methods
.allowedHeaders("*"); // Allow all headers
}
};
}
}

@ -1,15 +0,0 @@
package xyz.mcutils.backend.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
@Configuration
@EnableMongoRepositories(basePackages = "xyz.mcutils.backend.repository.mongo")
public class MongoConfig {
@Autowired
void setMapKeyDotReplacement(MappingMongoConverter mappingMongoConverter) {
mappingMongoConverter.setMapKeyDotReplacement("-DOT");
}
}

@ -1,47 +0,0 @@
package xyz.mcutils.backend.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.info.BuildProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class OpenAPIConfiguration {
/**
* The build properties of the
* app, null if the app is not built.
*/
private final BuildProperties buildProperties;
@Autowired
public OpenAPIConfiguration(BuildProperties buildProperties) {
this.buildProperties = buildProperties;
}
@Bean
public OpenAPI defineOpenAPI() {
Server server = new Server();
server.setUrl(Config.INSTANCE.getWebPublicUrl());
Contact contact = new Contact();
contact.setName("Liam");
contact.setEmail("liam@fascinated.cc");
contact.setUrl("https://fascinated.cc");
Info info = new Info();
info.setTitle("Minecraft Utilities API");
info.setVersion(buildProperties == null ? "N/A" : buildProperties.getVersion());
info.setDescription("Wrapper for the Minecraft APIs to make them easier to use.");
info.setContact(contact);
info.setLicense(new License().name("MIT License").url("https://opensource.org/licenses/MIT"));
return new OpenAPI().servers(List.of(server)).info(info);
}
}

@ -1,75 +0,0 @@
package xyz.mcutils.backend.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;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
/**
* @author Braydon
*/
@Configuration
@Log4j2(topic = "Redis")
@EnableRedisRepositories(basePackages = "xyz.mcutils.backend.repository.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<String, Object> redisTemplate() {
RedisTemplate<String, Object> 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);
}
}

@ -1,20 +0,0 @@
package xyz.mcutils.backend.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Map;
@Controller
@RequestMapping(value = "/")
public class HealthController {
@GetMapping(value = "/health")
public ResponseEntity<?> home() {
return ResponseEntity.ok(Map.of(
"status", "OK"
));
}
}

@ -1,28 +0,0 @@
package xyz.mcutils.backend.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import xyz.mcutils.backend.config.Config;
@Controller
@RequestMapping(value = "/")
public class HomeController {
private final String examplePlayer = "Notch";
private final String exampleJavaServer = "aetheria.cc";
private final String exampleBedrockServer = "geo.hivebedrock.network";
@GetMapping(value = "/")
public String home(Model model) {
String publicUrl = Config.INSTANCE.getWebPublicUrl();
model.addAttribute("public_url", publicUrl);
model.addAttribute("player_example_url", publicUrl + "/player/" + examplePlayer);
model.addAttribute("java_server_example_url", publicUrl + "/server/java/" + exampleJavaServer);
model.addAttribute("bedrock_server_example_url", publicUrl + "/server/bedrock/" + exampleBedrockServer);
model.addAttribute("mojang_endpoint_status_url", publicUrl + "/mojang/status");
model.addAttribute("swagger_url", publicUrl + "/swagger-ui.html");
return "index";
}
}

@ -1,32 +0,0 @@
package xyz.mcutils.backend.controller;
import io.swagger.v3.oas.annotations.tags.Tag;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import xyz.mcutils.backend.model.cache.CachedEndpointStatus;
import xyz.mcutils.backend.service.MojangService;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping(value = "/mojang/", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Mojang Controller", description = "The Mojang Controller is used to get information about the Mojang APIs.")
public class MojangController {
@Autowired
private MojangService mojangService;
@ResponseBody
@GetMapping(value = "/status")
public ResponseEntity<?> getStatus() {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.MINUTES).cachePublic())
.body(Map.of("endpoints", mojangService.getMojangServerStatus()));
}
}

@ -1,70 +0,0 @@
package xyz.mcutils.backend.controller;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import xyz.mcutils.backend.model.cache.CachedPlayer;
import xyz.mcutils.backend.model.cache.CachedPlayerName;
import xyz.mcutils.backend.model.player.Player;
import xyz.mcutils.backend.service.PlayerService;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping(value = "/player/")
@Tag(name = "Player Controller", description = "The Player Controller is used to get information about a player.")
public class PlayerController {
private final PlayerService playerService;
@Autowired
public PlayerController(PlayerService playerManagerService) {
this.playerService = playerManagerService;
}
@ResponseBody
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> getPlayer(
@Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id) {
CachedPlayer player = playerService.getPlayer(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
.body(player);
}
@ResponseBody
@GetMapping(value = "/uuid/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CachedPlayerName> getPlayerUuid(
@Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id) {
CachedPlayerName player = playerService.usernameToUuid(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(6, TimeUnit.HOURS).cachePublic())
.body(player);
}
@GetMapping(value = "/{part}/{id}")
public ResponseEntity<?> getPlayerHead(
@Parameter(description = "The part of the skin", example = "head") @PathVariable String part,
@Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id,
@Parameter(description = "The size of the image", example = "256") @RequestParam(required = false, defaultValue = "256") int size,
@Parameter(description = "Whether to render the skin overlay (skin layers)", example = "false") @RequestParam(required = false, defaultValue = "false") boolean overlays,
@Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) {
CachedPlayer cachedPlayer = playerService.getPlayer(id);
Player player = cachedPlayer.getPlayer();
String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png";
// Return the part image
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
.contentType(MediaType.IMAGE_PNG)
.header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(player.getUsername()))
.body(playerService.getSkinPart(player, part, overlays, size).getBytes());
}
}

@ -1,86 +0,0 @@
package xyz.mcutils.backend.controller;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import xyz.mcutils.backend.model.cache.CachedMinecraftServer;
import xyz.mcutils.backend.service.MojangService;
import xyz.mcutils.backend.service.ServerService;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping(value = "/server/")
@Tag(name = "Server Controller", description = "The Server Controller is used to get information about a server.")
public class ServerController {
private final ServerService serverService;
private final MojangService mojangService;
@Autowired
public ServerController(ServerService serverService, MojangService mojangService) {
this.serverService = serverService;
this.mojangService = mojangService;
}
@ResponseBody
@GetMapping(value = "/{platform}/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CachedMinecraftServer> getServer(
@Parameter(description = "The platform of the server", example = "java") @PathVariable String platform,
@Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname) {
CachedMinecraftServer server = serverService.getServer(platform, hostname);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(server);
}
@ResponseBody
@GetMapping(value = "/icon/{hostname}", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> getServerIcon(
@Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname,
@Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) {
String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png";
byte[] favicon = serverService.getServerFavicon(hostname);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
.contentType(MediaType.IMAGE_PNG)
.header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(hostname))
.body(favicon);
}
@ResponseBody
@GetMapping(value = "/{platform}/preview/{hostname}", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> getServerPreview(
@Parameter(description = "The platform of the server", example = "java") @PathVariable String platform,
@Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname,
@Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download,
@Parameter(description = "The size of the image", example = "1024") @RequestParam(required = false, defaultValue = "1024") int size) {
String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png";
CachedMinecraftServer server = serverService.getServer(platform, hostname);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.contentType(MediaType.IMAGE_PNG)
.header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(hostname))
.body(serverService.getServerPreview(server, platform, size));
}
@ResponseBody
@GetMapping(value = "/blocked/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> getServerBlockedStatus(
@Parameter(description = "The hostname of the server", example = "aetheria.cc") @PathVariable String hostname) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
.body(Map.of(
"blocked", mojangService.isServerBlocked(hostname)
));
}
}

@ -1,12 +0,0 @@
package xyz.mcutils.backend.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);
}
}

@ -1,12 +0,0 @@
package xyz.mcutils.backend.exception.impl;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public class InternalServerErrorException extends RuntimeException {
public InternalServerErrorException(String message) {
super(message);
}
}

@ -1,9 +0,0 @@
package xyz.mcutils.backend.exception.impl;
public class MojangAPIRateLimitException extends RateLimitException {
public MojangAPIRateLimitException() {
super("Mojang API rate limit exceeded. Please try again later.");
}
}

@ -1,12 +0,0 @@
package xyz.mcutils.backend.exception.impl;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}

@ -1,9 +0,0 @@
package xyz.mcutils.backend.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 { }

@ -1,49 +0,0 @@
package xyz.mcutils.backend.model.cache;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import xyz.mcutils.backend.common.CachedResponse;
import xyz.mcutils.backend.common.MojangServer;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Setter @Getter @EqualsAndHashCode(callSuper = false)
@RedisHash(value = "mojangEndpointStatus", timeToLive = 60L) // 1 minute (in seconds)
public class CachedEndpointStatus extends CachedResponse implements Serializable {
/**
* The id for this endpoint cache.
*/
@Id @NonNull @JsonIgnore
private final String id;
/**
* The endpoint cache.
*/
private final List<Map<String, Object>> endpoints;
public CachedEndpointStatus(@NonNull String id, Map<MojangServer, MojangServer.Status> mojangServers) {
super(Cache.defaultCache());
this.id = id;
this.endpoints = new ArrayList<>();
for (Map.Entry<MojangServer, MojangServer.Status> entry : mojangServers.entrySet()) {
MojangServer server = entry.getKey();
Map<String, Object> serverStatus = new HashMap<>();
serverStatus.put("name", server.getName());
serverStatus.put("endpoint", server.getEndpoint());
serverStatus.put("status", entry.getValue().name());
endpoints.add(serverStatus);
}
}
}

@ -1,39 +0,0 @@
package xyz.mcutils.backend.model.cache;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import xyz.mcutils.backend.common.CachedResponse;
import xyz.mcutils.backend.model.server.MinecraftServer;
import java.io.Serializable;
/**
* @author Braydon
*/
@Setter @Getter @EqualsAndHashCode(callSuper = false)
@RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds)
public class CachedMinecraftServer extends CachedResponse implements Serializable {
/**
* The id of this cached server.
*/
@Id @NonNull @JsonIgnore
private String id;
/**
* The cached server.
*/
@NonNull @JsonUnwrapped
private MinecraftServer server;
public CachedMinecraftServer(@NonNull String id, @NonNull MinecraftServer server) {
super(CachedResponse.Cache.defaultCache());
this.id = id;
this.server = server;
}
}

@ -1,41 +0,0 @@
package xyz.mcutils.backend.model.cache;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import xyz.mcutils.backend.common.CachedResponse;
import xyz.mcutils.backend.model.player.Player;
import java.io.Serializable;
import java.util.UUID;
/**
* A cacheable {@link Player}.
*
* @author Braydon
*/
@Setter @Getter @EqualsAndHashCode(callSuper = false)
@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds)
public class CachedPlayer extends CachedResponse implements Serializable {
/**
* The unique id of the player.
*/
@JsonIgnore
@Id private UUID uniqueId;
/**
* The player to cache.
*/
@JsonUnwrapped
private Player player;
public CachedPlayer(UUID uniqueId, Player player) {
super(Cache.defaultCache());
this.uniqueId = uniqueId;
this.player = player;
}
}

@ -1,42 +0,0 @@
package xyz.mcutils.backend.model.cache;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import xyz.mcutils.backend.common.CachedResponse;
import java.util.UUID;
/**
* @author Braydon
*/
@Setter
@Getter @EqualsAndHashCode(callSuper = false)
@RedisHash(value = "playerName", timeToLive = 60L * 60L * 6) // 6 hours (in seconds)
public class CachedPlayerName extends CachedResponse {
/**
* The id of the player.
*/
@JsonIgnore
@Id private final String id;
/**
* The username of the player.
*/
private final String username;
/**
* The unique id of the player.
*/
private final UUID uniqueId;
public CachedPlayerName(String id, String username, UUID uniqueId) {
super(Cache.defaultCache());
this.id = id;
this.username = username;
this.uniqueId = uniqueId;
}
}

@ -1,21 +0,0 @@
package xyz.mcutils.backend.model.cache;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
@AllArgsConstructor
@Setter @Getter @EqualsAndHashCode
@RedisHash(value = "playerSkinPart", timeToLive = 60L * 60L) // 1 hour (in seconds)
public class CachedPlayerSkinPart {
/**
* The ID of the skin part
*/
@Id @NonNull private String id;
/**
* The skin part bytes
*/
private byte[] bytes;
}

@ -1,21 +0,0 @@
package xyz.mcutils.backend.model.cache;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
@AllArgsConstructor
@Setter @Getter @EqualsAndHashCode
@RedisHash(value = "serverPreview", timeToLive = 60L * 5) // 5 minutes (in seconds)
public class CachedServerPreview {
/**
* The ID of the server preview
*/
@Id @NonNull private String id;
/**
* The server preview bytes
*/
private byte[] bytes;
}

@ -1,26 +0,0 @@
package xyz.mcutils.backend.model.dns;
import io.micrometer.common.lang.NonNull;
import lombok.*;
@NoArgsConstructor @AllArgsConstructor
@Setter @Getter @EqualsAndHashCode
public abstract class DNSRecord {
/**
* The type of this record.
*/
@NonNull
private Type type;
/**
* The TTL (Time To Live) of this record.
*/
private long ttl;
/**
* Types of a record.
*/
public enum Type {
A, SRV
}
}

@ -1,24 +0,0 @@
package xyz.mcutils.backend.model.dns.impl;
import io.micrometer.common.lang.NonNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import xyz.mcutils.backend.model.dns.DNSRecord;
import java.net.InetAddress;
@Setter @Getter
@NoArgsConstructor
public final class ARecord extends DNSRecord {
/**
* The address of this record, null if unresolved.
*/
private String address;
public ARecord(@NonNull org.xbill.DNS.ARecord bootstrap) {
super(Type.A, bootstrap.getTTL());
InetAddress address = bootstrap.getAddress();
this.address = address == null ? null : address.getHostAddress();
}
}

@ -1,53 +0,0 @@
package xyz.mcutils.backend.model.dns.impl;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.micrometer.common.lang.NonNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import xyz.mcutils.backend.model.dns.DNSRecord;
import java.net.InetSocketAddress;
@Setter @Getter
@NoArgsConstructor
public final class SRVRecord extends DNSRecord {
/**
* The priority of this record.
*/
private int priority;
/**
* The weight of this record.
*/
private int weight;
/**
* The port of this record.
*/
private int port;
/**
* The target of this record.
*/
@NonNull private String target;
public SRVRecord(@NonNull org.xbill.DNS.SRVRecord bootstrap) {
super(Type.SRV, bootstrap.getTTL());
priority = bootstrap.getPriority();
weight = bootstrap.getWeight();
port = bootstrap.getPort();
target = bootstrap.getTarget().toString().replaceFirst("\\.$", "");
}
/**
* Get a socket address from
* the target and port.
*
* @return the socket address
*/
@NonNull @JsonIgnore
public InetSocketAddress getSocketAddress() {
return new InetSocketAddress(target, port);
}
}

@ -1,15 +0,0 @@
package xyz.mcutils.backend.model.metric;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Map;
@AllArgsConstructor
@Getter
public class WebsocketMetrics {
/**
* The metrics to send to the client.
*/
private final Map<String, Object> metrics;
}

@ -1,47 +0,0 @@
package xyz.mcutils.backend.model.mojang;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@RequiredArgsConstructor
@Getter @Setter @EqualsAndHashCode
public class EndpointStatus {
/**
* The name of the service.
*/
private final String name;
/**
* The hostname of the service.
*/
private final String hostname;
/**
* The status of the service.
*/
private Status status;
/**
* Statuses for the endpoint.
*/
public enum Status {
/**
* The service is online and operational.
*/
ONLINE,
/**
* The service is online, but may be experiencing issues.
* This could be due to high load or other issues.
*/
DEGRADED,
/**
* The service is offline and not operational.
*/
OFFLINE
}
}

@ -1,63 +0,0 @@
package xyz.mcutils.backend.model.player;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import xyz.mcutils.backend.common.Tuple;
import xyz.mcutils.backend.common.UUIDUtils;
import xyz.mcutils.backend.model.skin.Skin;
import xyz.mcutils.backend.model.token.MojangProfileToken;
import java.util.UUID;
@AllArgsConstructor @NoArgsConstructor
@Getter @EqualsAndHashCode
public class Player {
/**
* The UUID of the player
*/
private UUID uniqueId;
/**
* The trimmed UUID of the player
*/
private String trimmedUniqueId;
/**
* The username of the player
*/
private 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;
/**
* The raw properties of the player
*/
private MojangProfileToken.ProfileProperty[] rawProperties;
public Player(MojangProfileToken profile) {
this.uniqueId = UUIDUtils.addDashes(profile.getId());
this.trimmedUniqueId = UUIDUtils.removeDashes(this.uniqueId);
this.username = profile.getName();
this.rawProperties = profile.getProperties();
// Get the skin and cape
Tuple<Skin, Cape> skinAndCape = profile.getSkinAndCape();
if (skinAndCape != null) {
this.skin = skinAndCape.getLeft();
this.cape = skinAndCape.getRight();
}
}
}

@ -1,40 +0,0 @@
package xyz.mcutils.backend.model.response;
import io.micrometer.common.lang.NonNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.springframework.http.HttpStatus;
import java.util.Date;
@Getter @ToString @EqualsAndHashCode
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();
}
}

@ -1,125 +0,0 @@
package xyz.mcutils.backend.model.server;
import lombok.*;
import xyz.mcutils.backend.model.dns.DNSRecord;
/**
* A Bedrock edition {@link MinecraftServer}.
*
* @author Braydon
*/
@Getter @ToString(callSuper = true) @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true)
public final class BedrockMinecraftServer extends MinecraftServer {
/**
* The unique ID of this server.
*/
@EqualsAndHashCode.Include @NonNull private final String id;
/**
* The edition of this server.
*/
@NonNull private final Edition edition;
/**
* The version information of this server.
*/
@NonNull private final Version version;
/**
* The gamemode of this server.
*/
@NonNull private final GameMode gamemode;
private BedrockMinecraftServer(@NonNull String id, @NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records,
@NonNull Edition edition, @NonNull Version version, @NonNull Players players, @NonNull MOTD motd,
@NonNull GameMode gamemode, GeoLocation location) {
super(hostname, ip, port, records, motd, players, location);
this.id = id;
this.edition = edition;
this.version = version;
this.gamemode = gamemode;
}
/**
* Create a new Bedrock Minecraft server.
* <p>
* <a href="https://wiki.vg/Raknet_Protocol#Unconnected_Pong">Token Format</a>
* </p>
*
* @param hostname the hostname of the server
* @param ip the IP address of the server
* @param port the port of the server
* @param token the status token
* @return the Bedrock Minecraft server
*/
@NonNull
public static BedrockMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, GeoLocation location, @NonNull String token) {
String[] split = token.split(";"); // Split the token
Edition edition = Edition.valueOf(split[0]);
Version version = new Version(Integer.parseInt(split[2]), split[3]);
Players players = new Players(Integer.parseInt(split[4]), Integer.parseInt(split[5]), null);
MOTD motd = MOTD.create(hostname, Platform.BEDROCK, split[1] + "\n" + split[7]);
GameMode gameMode = new GameMode(split[8], split.length > 9 ? Integer.parseInt(split[9]) : -1);
return new BedrockMinecraftServer(
split[6],
hostname,
ip,
port,
records,
edition,
version,
players,
motd,
gameMode,
location
);
}
/**
* The edition of a Bedrock server.
*/
@AllArgsConstructor @Getter
public enum Edition {
/**
* Minecraft: Pocket Edition.
*/
MCPE,
/**
* Minecraft: Education Edition.
*/
MCEE
}
/**
* Version information for a server.
*/
@AllArgsConstructor @Getter @ToString
public static class Version {
/**
* The protocol version of the server.
*/
private final int protocol;
/**
* The version name of the server.
*/
@NonNull private final String name;
}
/**
* The gamemode of a server.
*/
@AllArgsConstructor @Getter @ToString
public static class GameMode {
/**
* The name of this gamemode.
*/
@NonNull private final String name;
/**
* The numeric of this gamemode.
*/
private final int numericId;
}
}

@ -1,272 +0,0 @@
package xyz.mcutils.backend.model.server;
import com.google.gson.annotations.SerializedName;
import lombok.*;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.chat.ComponentSerializer;
import xyz.mcutils.backend.Main;
import xyz.mcutils.backend.common.JavaMinecraftVersion;
import xyz.mcutils.backend.common.ServerUtils;
import xyz.mcutils.backend.config.Config;
import xyz.mcutils.backend.model.dns.DNSRecord;
import xyz.mcutils.backend.model.token.JavaServerStatusToken;
/**
* @author Braydon
*/
@Setter @Getter @EqualsAndHashCode(callSuper = false)
public final class JavaMinecraftServer extends MinecraftServer {
/**
* The version of the server.
*/
@NonNull private final Version version;
/**
* The favicon of the server.
*/
private Favicon favicon;
/**
* The mods running on this server.
*/
private ForgeModInfo modInfo;
/**
* The mods running on this server.
* <p>
* This is only used for servers
* running 1.13 and above.
* </p>
*/
private ForgeData forgeData;
/**
* Whether the server prevents chat reports.
*/
private boolean preventsChatReports;
/**
* Whether the server enforces secure chat.
*/
private boolean enforcesSecureChat;
/**
* Whether the server has previews chat enabled.
* <p>
* Chat Preview sends chat messages to the server as they are typed, even before they're sent.
* <a href="https://www.minecraft.net/es-mx/article/minecraft-snapshot-22w19a">More information</a>
* </p>
*/
private boolean previewsChat;
/**
* The mojang blocked status for the server.
*/
private boolean mojangBlocked;
public JavaMinecraftServer(String hostname, String ip, int port, MOTD motd, Players players, GeoLocation location,
DNSRecord[] records, @NonNull Version version, Favicon favicon, ForgeModInfo modInfo,
ForgeData forgeData, boolean preventsChatReports, boolean enforcesSecureChat, boolean previewsChat) {
super(hostname, ip, port, records, motd, players, location);
this.version = version;
this.favicon = favicon;
this.modInfo = modInfo;
this.forgeData = forgeData;
this.preventsChatReports = preventsChatReports;
this.enforcesSecureChat = enforcesSecureChat;
this.previewsChat = previewsChat;
}
/**
* Create a new Java Minecraft server.
*
* @param hostname the hostname of the server
* @param ip the IP address of the server
* @param port the port of the server
* @param token the status token
* @return the Java Minecraft server
*/
@NonNull
public static JavaMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, GeoLocation location, @NonNull JavaServerStatusToken token) {
String motdString = token.getDescription() instanceof String ? (String) token.getDescription() : null;
if (motdString == null) { // Not a string motd, convert from Json
motdString = new TextComponent(ComponentSerializer.parse(Main.GSON.toJson(token.getDescription()))).toLegacyText();
}
return new JavaMinecraftServer(
hostname,
ip,
port,
MinecraftServer.MOTD.create(hostname, Platform.JAVA, motdString),
token.getPlayers(),
location,
records,
token.getVersion().detailedCopy(),
JavaMinecraftServer.Favicon.create(token.getFavicon(), ServerUtils.getAddress(hostname, port)),
token.getModInfo(),
token.getForgeData(),
token.isPreventsChatReports(),
token.isEnforcesSecureChat(),
token.isPreviewsChat()
);
}
@AllArgsConstructor @Getter
public static class Version {
/**
* The version name of the server.
*/
@NonNull
private final String name;
/**
* The server platform.
*/
private String platform;
/**
* The protocol version.
*/
private final int protocol;
/**
* The name of the protocol, null if not found.
*/
private final String protocolName;
/**
* Create a more detailed
* copy of this object.
*
* @return the detailed copy
*/
@NonNull
public Version detailedCopy() {
String platform = null;
if (name.contains(" ")) { // Parse the server platform
String[] split = name.split(" ");
if (split.length == 2) {
platform = split[0];
}
}
JavaMinecraftVersion minecraftVersion = JavaMinecraftVersion.byProtocol(protocol);
return new Version(name, platform, protocol, minecraftVersion == null ? null : minecraftVersion.getName());
}
}
@Getter @AllArgsConstructor
public static class Favicon {
/**
* The raw base64 of the favicon.
*/
private final String base64;
/**
* The url to the favicon.
*/
private String url;
/**
* Create a new favicon for a server.
*
* @param base64 the base64 of the favicon
* @param address the address of the server
* @return the new favicon
*/
public static Favicon create(String base64, @NonNull String address) {
if (base64 == null) { // The server doesn't have a favicon
return null;
}
return new Favicon(base64, Config.INSTANCE.getWebPublicUrl() + "/server/icon/%s".formatted(address));
}
}
/**
* Forge mod information for a server.
*/
@AllArgsConstructor @Getter @ToString
public static class ForgeModInfo {
/**
* The type of modded server this is.
*/
@NonNull private final String type;
/**
* The list of mods on this server, null or empty if none.
*/
private final ForgeMod[] modList;
/**
* A forge mod for a server.
*/
@AllArgsConstructor @Getter @ToString
private static class ForgeMod {
/**
* The id of this mod.
*/
@NonNull @SerializedName("modid") private final String name;
/**
* The version of this mod.
*/
private final String version;
}
}
@AllArgsConstructor @Getter
public static class ForgeData {
/**
* The list of mod channels on this server, null or empty if none.
*/
private final Channel[] channels;
/**
* The list of mods on this server, null or empty if none.
*/
private final Mod[] mods;
/**
* Whether the mod list is truncated.
*/
private final boolean truncated;
/**
* The version of the FML network.
*/
private final int fmlNetworkVersion;
@AllArgsConstructor @Getter
public static class Channel {
/**
* The id of this mod channel.
*/
@NonNull @SerializedName("res") private final String name;
/**
* The version of this mod channel.
*/
private final String version;
/**
* Whether this mod channel is required to join.
*/
private boolean required;
}
@AllArgsConstructor @Getter
public static class Mod {
/**
* The id of this mod.
*/
@NonNull @SerializedName("modId") private final String name;
/**
* The version of this mod.
*/
@SerializedName("modmarker") private final String version;
}
}
}

@ -1,214 +0,0 @@
package xyz.mcutils.backend.model.server;
import com.maxmind.geoip2.model.CityResponse;
import io.micrometer.common.lang.NonNull;
import lombok.*;
import xyz.mcutils.backend.common.ColorUtils;
import xyz.mcutils.backend.config.Config;
import xyz.mcutils.backend.model.dns.DNSRecord;
import xyz.mcutils.backend.service.pinger.MinecraftServerPinger;
import xyz.mcutils.backend.service.pinger.impl.BedrockMinecraftServerPinger;
import xyz.mcutils.backend.service.pinger.impl.JavaMinecraftServerPinger;
import java.util.Arrays;
import java.util.UUID;
/**
* @author Braydon
*/
@AllArgsConstructor
@Getter @Setter @EqualsAndHashCode
public class MinecraftServer {
/**
* The hostname of the server.
*/
private final String hostname;
/**
* The IP address of the server.
*/
private final String ip;
/**
* The port of the server.
*/
private final int port;
/**
* The DNS records for the server.
*/
private final DNSRecord[] records;
/**
* The motd for the server.
*/
private final MOTD motd;
/**
* The players on the server.
*/
private final Players players;
/**
* The location of the server.
*/
private final GeoLocation location;
/**
* A platform a Minecraft
* server can operate on.
*/
@AllArgsConstructor @Getter
public enum Platform {
/**
* The Java edition of Minecraft.
*/
JAVA(new JavaMinecraftServerPinger(), 25565),
/**
* The Bedrock edition of Minecraft.
*/
BEDROCK(new BedrockMinecraftServerPinger(), 19132);
/**
* The server pinger for this platform.
*/
@NonNull
private final MinecraftServerPinger<?> pinger;
/**
* The default server port for this platform.
*/
private final int defaultPort;
}
@AllArgsConstructor @Getter
public static class MOTD {
/**
* The raw motd lines
*/
private final String[] raw;
/**
* The clean motd lines
*/
private final String[] clean;
/**
* The html motd lines
*/
private final String[] html;
/**
* The URL to the server preview image.
*/
private final String preview;
/**
* Create a new MOTD from a raw string.
*
* @param raw the raw motd string
* @return the new motd
*/
@NonNull
public static MOTD create(@NonNull String hostname, @NonNull Platform platform, @NonNull String raw) {
String[] rawLines = raw.split("\n"); // The raw lines
return new MOTD(
rawLines,
Arrays.stream(rawLines).map(ColorUtils::stripColor).toArray(String[]::new),
Arrays.stream(rawLines).map(ColorUtils::toHTML).toArray(String[]::new),
Config.INSTANCE.getWebPublicUrl() + "/server/%s/preview/%s".formatted(
platform.name().toLowerCase(),hostname)
);
}
}
/**
* Player count data for a server.
*/
@AllArgsConstructor @Getter
public static class Players {
/**
* The online players on this server.
*/
private final int online;
/**
* The maximum allowed players on this server.
*/
private final int max;
/**
* A sample of players on this server, null or empty if no sample.
*/
private final Sample[] sample;
/**
* A sample player.
*/
@AllArgsConstructor @Getter @ToString
public static class Sample {
/**
* The unique id of this player.
*/
@NonNull private final UUID id;
/**
* The name of this player.
*/
@NonNull private final String name;
}
}
/**
* The location of the server.
*/
@AllArgsConstructor @Getter
public static class GeoLocation {
/**
* The country of the server.
*/
private final String country;
/**
* The region of the server.
*/
private final String region;
/**
* The city of the server.
*/
private final String city;
/**
* The latitude of the server.
*/
private final double latitude;
/**
* The longitude of the server.
*/
private final double longitude;
/**
* Gets the location of the server from Maxmind.
*
* @param response the response from Maxmind
* @return the location of the server
*/
public static GeoLocation fromMaxMind(CityResponse response) {
if (response == null) {
return null;
}
return new GeoLocation(
response.getCountry().getName(),
response.getMostSpecificSubdivision().getName(),
response.getCity().getName(),
response.getLocation().getLatitude(),
response.getLocation().getLongitude()
);
}
}
}

@ -1,207 +0,0 @@
package xyz.mcutils.backend.model.skin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import xyz.mcutils.backend.common.renderer.SkinRenderer;
import xyz.mcutils.backend.common.renderer.impl.skin.BodyRenderer;
import xyz.mcutils.backend.common.renderer.impl.skin.IsometricHeadRenderer;
import xyz.mcutils.backend.common.renderer.impl.skin.SquareRenderer;
import java.awt.image.BufferedImage;
public interface ISkinPart {
Enum<?>[][] TYPES = { Vanilla.values(), Custom.values() };
/**
* The name of the part.
*
* @return the part name
*/
String name();
/**
* Should this part be hidden from the
* player skin part urls list?
*
* @return whether this part should be hidden
*/
boolean hidden();
/**
* Renders the skin part for the skin.
*
* @param skin the skin
* @param renderOverlays should the overlays be rendered
* @param size the size of the part
* @return the rendered skin part
*/
BufferedImage render(Skin skin, boolean renderOverlays, int size);
/**
* Get a skin part by the given name.
*
* @param name the name of the part
* @return the part, null if none
*/
static ISkinPart getByName(String name) {
name = name.toUpperCase();
for (Enum<?>[] type : TYPES) {
for (Enum<?> part : type) {
if (!part.name().equals(name)) {
continue;
}
return (ISkinPart) part;
}
}
return null;
}
/**
* The vanilla skin parts.
* <p>
* <a href="https://cdn.fascinated.cc/sXwEKAxm.png">Skin Format</a>
* </p>
*/
@Getter
enum Vanilla implements ISkinPart {
// Overlays
HEAD_OVERLAY_TOP(true, new Coordinates(40, 0), 8, 8),
HEAD_OVERLAY_FACE(true, new Coordinates(40, 8), 8, 8),
HEAD_OVERLAY_LEFT(true, new Coordinates(48, 8), 8, 8),
// Head
HEAD_TOP(true, new Coordinates(8, 0), 8, 8, HEAD_OVERLAY_TOP),
FACE(false, new Coordinates(8, 8), 8, 8, HEAD_OVERLAY_FACE),
HEAD_LEFT(true, new Coordinates(0, 8), 8, 8, HEAD_OVERLAY_LEFT),
HEAD_RIGHT(true, new Coordinates(16, 8), 8, 8),
HEAD_BOTTOM(true, new Coordinates(16, 0), 8, 8),
HEAD_BACK(true, new Coordinates(24, 8), 8, 8),
// Body
BODY_FRONT(true, new Coordinates(20, 20), 8, 12),
// Arms
LEFT_ARM_TOP(true, new Coordinates(36, 48), 4, 4),
RIGHT_ARM_TOP(true, new Coordinates(44, 16), 4, 4),
LEFT_ARM_FRONT(true, new Coordinates(44, 20), 4, 12),
RIGHT_ARM_FRONT(true, new Coordinates(36, 52), new LegacyCoordinates(44, 20, true), 4, 12),
// Legs
LEFT_LEG_FRONT(true, new Coordinates(4, 20), 4, 12), // Front
RIGHT_LEG_FRONT(true, new Coordinates(20, 52), new LegacyCoordinates(4, 20, true), 4, 12); // Front
/**
* Should this part be hidden from the
* player skin part urls list?
*/
private final boolean hidden;
/**
* The coordinates of the part.
*/
private final Coordinates coordinates;
/**
* The legacy coordinates of the part.
*/
private final LegacyCoordinates legacyCoordinates;
/**
* The width and height of the part.
*/
private final int width, height;
/**
* The overlays of the part.
*/
private final Vanilla[] overlays;
Vanilla(boolean hidden, Coordinates coordinates, int width, int height, Vanilla... overlays) {
this(hidden, coordinates, null, width, height, overlays);
}
Vanilla(boolean hidden, Coordinates coordinates, LegacyCoordinates legacyCoordinates, int width, int height, Vanilla... overlays) {
this.hidden = hidden;
this.coordinates = coordinates;
this.legacyCoordinates = legacyCoordinates;
this.width = width;
this.height = height;
this.overlays = overlays;
}
@Override
public boolean hidden() {
return this.isHidden();
}
@Override
public BufferedImage render(Skin skin, boolean renderOverlays, int size) {
return SquareRenderer.INSTANCE.render(skin, this, renderOverlays, size);
}
/**
* Is this part a front arm?
*
* @return whether this part is a front arm
*/
public boolean isFrontArm() {
return this == LEFT_ARM_FRONT || this == RIGHT_ARM_FRONT;
}
/**
* Does this part have legacy coordinates?
*
* @return whether this part has legacy coordinates
*/
public boolean hasLegacyCoordinates() {
return legacyCoordinates != null;
}
@AllArgsConstructor @Getter
public static class Coordinates {
/**
* The X and Y position of the part.
*/
private final int x, y;
}
@Getter
public static class LegacyCoordinates extends Coordinates {
/**
* Should the part be flipped horizontally?
*/
private final boolean flipped;
public LegacyCoordinates(int x, int y) {
this(x, y, false);
}
public LegacyCoordinates(int x, int y, boolean flipped) {
super(x, y);
this.flipped = flipped;
}
}
}
@AllArgsConstructor @Getter
enum Custom implements ISkinPart {
HEAD(IsometricHeadRenderer.INSTANCE),
BODY(BodyRenderer.INSTANCE);
/**
* The renderer to use for this part
*/
private final SkinRenderer<Custom> renderer;
@Override
public boolean hidden() {
return false;
}
@Override
public BufferedImage render(Skin skin, boolean renderOverlays, int size) {
return renderer.render(skin, this, renderOverlays, size);
}
}
}

@ -1,108 +0,0 @@
package xyz.mcutils.backend.model.skin;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.JsonObject;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.log4j.Log4j2;
import xyz.mcutils.backend.common.EnumUtils;
import xyz.mcutils.backend.common.PlayerUtils;
import xyz.mcutils.backend.config.Config;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Map;
@AllArgsConstructor @NoArgsConstructor
@Getter @Log4j2(topic = "Skin") @EqualsAndHashCode
public class Skin {
/**
* The URL for the skin
*/
private String url;
/**
* The model for the skin
*/
private Model model;
/**
* The legacy status of the skin
*/
private boolean legacy;
/**
* The skin image for the skin
*/
@JsonIgnore
private byte[] skinImage;
/**
* The part URLs of the skin
*/
@JsonProperty("parts")
private Map<String, String> partUrls = new HashMap<>();
public Skin(String url, Model model) {
this.url = url;
this.model = model;
this.skinImage = PlayerUtils.getSkinImage(url);
if (this.skinImage != null) {
try {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(this.skinImage));
this.legacy = image.getWidth() == 64 && image.getHeight() == 32;
} catch (Exception ignored) {}
}
}
/**
* 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");
return new Skin(
url,
EnumUtils.getEnumConstant(Model.class, metadata != null ? metadata.get("model").getAsString().toUpperCase() : "DEFAULT")
);
}
/**
* Populates the part URLs for the skin.
*
* @param playerUuid the player's UUID
*/
public Skin populatePartUrls(String playerUuid) {
for (Enum<?>[] type : ISkinPart.TYPES) {
for (Enum<?> part : type) {
ISkinPart skinPart = (ISkinPart) part;
if (skinPart.hidden()) {
continue;
}
String partName = part.name().toLowerCase();
this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid);
}
}
return this;
}
/**
* The model of the skin.
*/
public enum Model {
DEFAULT,
SLIM
}
}

@ -1,69 +0,0 @@
package xyz.mcutils.backend.model.token;
import com.google.gson.annotations.SerializedName;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
import xyz.mcutils.backend.model.server.JavaMinecraftServer;
import xyz.mcutils.backend.model.server.MinecraftServer;
/**
* @author Braydon
*/
@AllArgsConstructor @Getter @ToString
public final class JavaServerStatusToken {
/**
* The version of the server.
*/
private final JavaMinecraftServer.Version version;
/**
* The players on the server.
*/
private final MinecraftServer.Players players;
/**
* The mods running on this server.
*/
@SerializedName("modinfo")
private JavaMinecraftServer.ForgeModInfo modInfo;
/**
* The mods running on this server.
* <p>
* This is only used for servers
* running 1.13 and above.
* </p>
*/
private JavaMinecraftServer.ForgeData forgeData;
/**
* The motd of the server.
*/
private final Object description;
/**
* The favicon of the server.
*/
private final String favicon;
/**
* Whether the server prevents chat reports.
*/
private boolean preventsChatReports;
/**
* Whether the server enforces secure chat.
*/
private boolean enforcesSecureChat;
/**
* Whether the server has previews chat enabled.
* <p>
* Chat Preview sends chat messages to the server as they are typed, even before they're sent.
* <a href="https://www.minecraft.net/es-mx/article/minecraft-snapshot-22w19a">More information</a>
* </p>
*/
private boolean previewsChat;
}

@ -1,11 +0,0 @@
package xyz.mcutils.backend.repository.mongo;
import org.springframework.data.mongodb.repository.MongoRepository;
import xyz.mcutils.backend.service.metric.Metric;
/**
* A repository for {@link Metric}s.
*
* @author Braydon
*/
public interface MetricsRepository extends MongoRepository<Metric<?>, String> { }

@ -1,11 +0,0 @@
package xyz.mcutils.backend.repository.redis;
import org.springframework.data.repository.CrudRepository;
import xyz.mcutils.backend.model.cache.CachedMinecraftServer;
/**
* A cache repository for {@link CachedMinecraftServer}'s.
*
* @author Braydon
*/
public interface MinecraftServerCacheRepository extends CrudRepository<CachedMinecraftServer, String> { }

@ -1,13 +0,0 @@
package xyz.mcutils.backend.repository.redis;
import org.springframework.data.repository.CrudRepository;
import xyz.mcutils.backend.model.cache.CachedPlayer;
import java.util.UUID;
/**
* A cache repository for {@link CachedPlayer}'s.
*
* @author Braydon
*/
public interface PlayerCacheRepository extends CrudRepository<CachedPlayer, UUID> { }

Some files were not shown because too many files have changed in this diff Show More