├── .gitea └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── influx-commands.md ├── pom.xml ├── renovate.json └── src ├── main ├── java │ └── xyz │ │ └── mcutils │ │ └── backend │ │ ├── Main.java │ │ ├── common │ │ ├── AppConfig.java │ │ ├── CachedResponse.java │ │ ├── ColorUtils.java │ │ ├── DNSUtils.java │ │ ├── Endpoint.java │ │ ├── EnumUtils.java │ │ ├── ExpiringSet.java │ │ ├── Fonts.java │ │ ├── IPUtils.java │ │ ├── ImageUtils.java │ │ ├── JavaMinecraftVersion.java │ │ ├── MojangServer.java │ │ ├── PlayerUtils.java │ │ ├── ServerUtils.java │ │ ├── Timer.java │ │ ├── Tuple.java │ │ ├── UUIDUtils.java │ │ ├── WebRequest.java │ │ ├── packet │ │ │ ├── MinecraftBedrockPacket.java │ │ │ ├── MinecraftJavaPacket.java │ │ │ └── impl │ │ │ │ ├── bedrock │ │ │ │ ├── BedrockPacketUnconnectedPing.java │ │ │ │ └── BedrockPacketUnconnectedPong.java │ │ │ │ └── java │ │ │ │ ├── JavaPacketHandshakingInSetProtocol.java │ │ │ │ └── JavaPacketStatusInStart.java │ │ └── renderer │ │ │ ├── IsometricSkinRenderer.java │ │ │ ├── Renderer.java │ │ │ ├── SkinRenderer.java │ │ │ └── impl │ │ │ ├── server │ │ │ └── ServerPreviewRenderer.java │ │ │ └── skin │ │ │ ├── BodyRenderer.java │ │ │ ├── IsometricHeadRenderer.java │ │ │ └── SquareRenderer.java │ │ ├── config │ │ ├── Config.java │ │ ├── MongoConfig.java │ │ ├── OpenAPIConfiguration.java │ │ └── RedisConfig.java │ │ ├── controller │ │ ├── HealthController.java │ │ ├── HomeController.java │ │ ├── MojangController.java │ │ ├── PlayerController.java │ │ └── ServerController.java │ │ ├── exception │ │ ├── ExceptionControllerAdvice.java │ │ └── impl │ │ │ ├── BadRequestException.java │ │ │ ├── InternalServerErrorException.java │ │ │ ├── MojangAPIRateLimitException.java │ │ │ ├── RateLimitException.java │ │ │ └── ResourceNotFoundException.java │ │ ├── log │ │ └── TransactionLogger.java │ │ ├── model │ │ ├── cache │ │ │ ├── CachedEndpointStatus.java │ │ │ ├── CachedMinecraftServer.java │ │ │ ├── CachedPlayer.java │ │ │ ├── CachedPlayerName.java │ │ │ ├── CachedPlayerSkinPart.java │ │ │ └── CachedServerPreview.java │ │ ├── dns │ │ │ ├── DNSRecord.java │ │ │ └── impl │ │ │ │ ├── ARecord.java │ │ │ │ └── SRVRecord.java │ │ ├── metric │ │ │ └── WebsocketMetrics.java │ │ ├── mojang │ │ │ └── EndpointStatus.java │ │ ├── player │ │ │ ├── Cape.java │ │ │ └── Player.java │ │ ├── response │ │ │ └── ErrorResponse.java │ │ ├── server │ │ │ ├── BedrockMinecraftServer.java │ │ │ ├── JavaMinecraftServer.java │ │ │ └── MinecraftServer.java │ │ ├── skin │ │ │ ├── ISkinPart.java │ │ │ └── Skin.java │ │ └── token │ │ │ ├── JavaServerStatusToken.java │ │ │ ├── MojangProfileToken.java │ │ │ └── MojangUsernameToUuidToken.java │ │ ├── repository │ │ ├── mongo │ │ │ └── MetricsRepository.java │ │ └── redis │ │ │ ├── MinecraftServerCacheRepository.java │ │ │ ├── PlayerCacheRepository.java │ │ │ ├── PlayerNameCacheRepository.java │ │ │ ├── PlayerSkinPartCacheRepository.java │ │ │ └── ServerPreviewCacheRepository.java │ │ ├── service │ │ ├── MaxMindService.java │ │ ├── MetricService.java │ │ ├── MojangService.java │ │ ├── PlayerService.java │ │ ├── ServerService.java │ │ ├── metric │ │ │ ├── Metric.java │ │ │ ├── impl │ │ │ │ ├── DoubleMetric.java │ │ │ │ ├── IntegerMetric.java │ │ │ │ └── MapMetric.java │ │ │ └── metrics │ │ │ │ ├── ConnectedSocketsMetric.java │ │ │ │ ├── RequestsPerRouteMetric.java │ │ │ │ ├── TotalRequestsMetric.java │ │ │ │ ├── UniquePlayerLookupsMetric.java │ │ │ │ ├── UniqueServerLookupsMetric.java │ │ │ │ └── process │ │ │ │ ├── CpuUsageMetric.java │ │ │ │ └── MemoryMetric.java │ │ └── pinger │ │ │ ├── MinecraftServerPinger.java │ │ │ └── impl │ │ │ ├── BedrockMinecraftServerPinger.java │ │ │ └── JavaMinecraftServerPinger.java │ │ └── websocket │ │ ├── WebSocket.java │ │ ├── WebSocketManager.java │ │ └── impl │ │ └── MetricsWebSocket.java └── resources │ ├── application.yml │ ├── fonts │ └── minecraft-font.ttf │ ├── icons │ ├── ping.png │ └── server_background.png │ ├── public │ ├── favicon.ico │ └── robots.txt │ └── templates │ └── index.html └── test └── java └── xyz └── mcutils └── backend └── test ├── config └── TestRedisConfig.java └── tests ├── MojangControllerTests.java ├── PlayerControllerTests.java └── ServerControllerTests.java /.gitea/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Deploy App 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths-ignore: 7 | - .gitignore 8 | - README.md 9 | - LICENSE 10 | - docker-compose.yml 11 | 12 | jobs: 13 | docker: 14 | strategy: 15 | matrix: 16 | arch: ["ubuntu-latest"] 17 | git-version: ["2.44.0"] 18 | java-version: ["17"] 19 | maven-version: ["3.8.5"] 20 | runs-on: ${{ matrix.arch }} 21 | 22 | # Steps to run 23 | steps: 24 | # Checkout the repo 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | # Setup Java and Maven 29 | - name: Set up JDK and Maven 30 | uses: s4u/setup-maven-action@v1.14.0 31 | with: 32 | java-version: ${{ matrix.java-version }} 33 | distribution: "zulu" 34 | maven-version: ${{ matrix.maven-version }} 35 | 36 | # Run JUnit Tests 37 | - name: Run Tests 38 | run: mvn --batch-mode test -q 39 | 40 | # Re-checkout to reset the FS before deploying to Dokku 41 | - name: Checkout - Reset FS 42 | uses: actions/checkout@v4 43 | with: 44 | fetch-depth: 0 45 | 46 | # Deploy to Dokku 47 | - name: Push to dokku 48 | uses: dokku/github-action@master 49 | with: 50 | git_remote_url: "ssh://dokku@10.0.50.137:22/minecraft-helper" 51 | ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### ME template 2 | *.class 3 | *.log 4 | *.ctxt 5 | .mtj.tmp/ 6 | *.jar 7 | *.war 8 | *.nar 9 | *.ear 10 | *.zip 11 | *.tar.gz 12 | *.rar 13 | hs_err_pid* 14 | replay_pid* 15 | .idea 16 | cmake-build-*/ 17 | .idea/**/mongoSettings.xml 18 | *.iws 19 | out/ 20 | build/ 21 | work/ 22 | .idea_modules/ 23 | atlassian-ide-plugin.xml 24 | com_crashlytics_export_strings.xml 25 | crashlytics.properties 26 | crashlytics-build.properties 27 | fabric.properties 28 | git.properties 29 | pom.xml.versionsBackup 30 | application.yml 31 | target/ 32 | 33 | ### MaxMind GeoIP2 34 | data/ 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.9.8-eclipse-temurin-17-alpine 2 | 3 | RUN apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font \ 4 | && fc-cache -f \ 5 | && fc-list | sort 6 | 7 | # Set the working directory 8 | WORKDIR /home/container 9 | 10 | # Copy the current directory contents into the container at /home/container 11 | COPY . . 12 | 13 | # Build the jar 14 | RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C 15 | 16 | # Make port 80 available to the world outside this container 17 | EXPOSE 80 18 | ENV PORT=80 19 | 20 | # Indicate that we're running in production 21 | ENV ENVIRONMENT=production 22 | 23 | # Run the jar file 24 | CMD java -jar target/Minecraft-Utilities.jar -Djava.awt.headless=true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, Fascinated 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minecraft Utilities - Backend 2 | 3 | See [The Website](https://mcutils.xyz) or [Minecraft Utilities Documentation](https://mcutils.xyz/docs) for more information. -------------------------------------------------------------------------------- /influx-commands.md: -------------------------------------------------------------------------------- 1 | # Useful InfluxDB commands 2 | 3 | ## Delete data from bucket 4 | 5 | ```bash 6 | 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" 7 | ``` -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | cc.fascinated 8 | Minecraft-Utilities 9 | 1.0.0 10 | 11 | 12 | 17 13 | 17 14 | UTF-8 15 | 16 | 17 | 18 | org.springframework.boot 19 | spring-boot-starter-parent 20 | 3.3.2 21 | 22 | 23 | 24 | 25 | 26 | ${project.artifactId} 27 | 28 | 29 | 30 | org.springframework.boot 31 | spring-boot-maven-plugin 32 | 33 | 34 | build-info 35 | 36 | build-info 37 | 38 | 39 | 40 | ${project.description} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | jitpack.io 53 | https://jitpack.io 54 | 55 | 56 | 57 | 58 | 59 | 60 | org.springframework.boot 61 | spring-boot-starter-web 62 | 63 | 64 | 65 | 66 | org.springframework.boot 67 | spring-boot-starter-websocket 68 | 69 | 70 | 71 | 72 | org.springframework.boot 73 | spring-boot-starter-data-redis 74 | 75 | 76 | io.lettuce 77 | lettuce-core 78 | 79 | 80 | 81 | 82 | redis.clients 83 | jedis 84 | 85 | 86 | 87 | 88 | org.springframework.boot 89 | spring-boot-starter-data-mongodb 90 | 91 | 92 | 93 | 94 | org.projectlombok 95 | lombok 96 | 1.18.34 97 | provided 98 | 99 | 100 | com.google.code.gson 101 | gson 102 | 2.11.0 103 | compile 104 | 105 | 106 | net.jodah 107 | expiringmap 108 | 0.5.11 109 | compile 110 | 111 | 112 | net.md-5 113 | bungeecord-chat 114 | 1.20-R0.2 115 | compile 116 | 117 | 118 | org.springframework.boot 119 | spring-boot-starter-thymeleaf 120 | compile 121 | 122 | 123 | org.apache.httpcomponents.client5 124 | httpclient5 125 | 5.3.1 126 | 127 | 128 | org.springframework.boot 129 | spring-boot-actuator-autoconfigure 130 | 131 | 132 | 133 | 134 | io.sentry 135 | sentry-spring-boot-starter-jakarta 136 | 7.14.0 137 | 138 | 139 | 140 | 141 | com.influxdb 142 | influxdb-spring 143 | 7.2.0 144 | compile 145 | 146 | 147 | com.influxdb 148 | influxdb-client-java 149 | 7.2.0 150 | 151 | 152 | 153 | 154 | com.github.dnsjava 155 | dnsjava 156 | v3.5.2 157 | compile 158 | 159 | 160 | 161 | 162 | org.springdoc 163 | springdoc-openapi-starter-webmvc-ui 164 | 2.6.0 165 | compile 166 | 167 | 168 | 169 | 170 | com.maxmind.geoip2 171 | geoip2 172 | 4.2.0 173 | 174 | 175 | 176 | 177 | org.codehaus.plexus 178 | plexus-archiver 179 | 4.10.0 180 | 181 | 182 | 183 | 184 | org.springframework.boot 185 | spring-boot-starter-test 186 | test 187 | 188 | 189 | com.github.codemonstur 190 | embedded-redis 191 | 1.4.3 192 | test 193 | 194 | 195 | de.flapdoodle.embed 196 | de.flapdoodle.embed.mongo.spring3x 197 | 4.16.1 198 | test 199 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/Main.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import lombok.SneakyThrows; 6 | import lombok.extern.log4j.Log4j2; 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | 10 | import java.io.File; 11 | import java.net.http.HttpClient; 12 | import java.nio.file.Files; 13 | import java.nio.file.StandardCopyOption; 14 | import java.util.Objects; 15 | 16 | @Log4j2(topic = "Main") 17 | @SpringBootApplication 18 | public class Main { 19 | public static final Gson GSON = new GsonBuilder() 20 | .setDateFormat("MM-dd-yyyy HH:mm:ss") 21 | .create(); 22 | public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); 23 | 24 | @SneakyThrows 25 | public static void main(String[] args) { 26 | File config = new File("application.yml"); 27 | if (!config.exists()) { // Saving the default config if it doesn't exist locally 28 | Files.copy(Objects.requireNonNull(Main.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING); 29 | log.info("Saved the default configuration to '{}', please re-launch the application", // Log the default config being saved 30 | config.getAbsolutePath() 31 | ); 32 | return; 33 | } 34 | log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config 35 | 36 | SpringApplication.run(Main.class, args); // Start the application 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/AppConfig.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.Getter; 4 | import lombok.experimental.UtilityClass; 5 | 6 | @UtilityClass 7 | public final class AppConfig { 8 | /** 9 | * Is the app running in a production environment? 10 | */ 11 | @Getter 12 | private static final boolean production; 13 | static { // Are we running on production? 14 | String env = System.getenv("ENVIRONMENT"); 15 | production = env != null && (env.equals("production")); 16 | } 17 | 18 | /** 19 | * Is the app running in a test environment? 20 | */ 21 | @Getter 22 | private static boolean isRunningTest = true; 23 | static { 24 | try { 25 | Class.forName("org.junit.jupiter.engine.JupiterTestEngine"); 26 | } catch (ClassNotFoundException e) { 27 | isRunningTest = false; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/CachedResponse.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | import lombok.Setter; 7 | 8 | @AllArgsConstructor @NoArgsConstructor 9 | @Getter 10 | public class CachedResponse { 11 | 12 | /** 13 | * The cache information for this response. 14 | */ 15 | private Cache cache; 16 | 17 | @AllArgsConstructor @Getter @Setter 18 | public static class Cache { 19 | /** 20 | * Whether this request is cached. 21 | */ 22 | private boolean cached; 23 | 24 | /** 25 | * The unix timestamp of when this was cached. 26 | */ 27 | private long cachedTime; 28 | 29 | /** 30 | * Create a new cache information object with the default values. 31 | *

32 | * The default values are: 33 | *
34 | *

38 | *
39 | *

40 | * 41 | * @return the default cache information object 42 | */ 43 | public static Cache defaultCache() { 44 | return new Cache(true, System.currentTimeMillis()); 45 | } 46 | 47 | /** 48 | * Sets if this request is cached. 49 | * 50 | * @param cached the new value of if this request is cached 51 | */ 52 | public void setCached(boolean cached) { 53 | this.cached = cached; 54 | if (!cached) { 55 | cachedTime = -1; 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/ColorUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.NonNull; 4 | import lombok.experimental.UtilityClass; 5 | 6 | import java.awt.*; 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | import java.util.regex.Pattern; 10 | 11 | /** 12 | * @author Braydon 13 | */ 14 | @UtilityClass 15 | public final class ColorUtils { 16 | private static final Pattern STRIP_COLOR_PATTERN = Pattern.compile("(?i)§[0-9A-FK-OR]"); 17 | private static final Map COLOR_MAP = new HashMap<>(); 18 | static { 19 | // Map each color to its corresponding hex code 20 | COLOR_MAP.put('0', "#000000"); // Black 21 | COLOR_MAP.put('1', "#0000AA"); // Dark Blue 22 | COLOR_MAP.put('2', "#00AA00"); // Dark Green 23 | COLOR_MAP.put('3', "#00AAAA"); // Dark Aqua 24 | COLOR_MAP.put('4', "#AA0000"); // Dark Red 25 | COLOR_MAP.put('5', "#AA00AA"); // Dark Purple 26 | COLOR_MAP.put('6', "#FFAA00"); // Gold 27 | COLOR_MAP.put('7', "#AAAAAA"); // Gray 28 | COLOR_MAP.put('8', "#555555"); // Dark Gray 29 | COLOR_MAP.put('9', "#5555FF"); // Blue 30 | COLOR_MAP.put('a', "#55FF55"); // Green 31 | COLOR_MAP.put('b', "#55FFFF"); // Aqua 32 | COLOR_MAP.put('c', "#FF5555"); // Red 33 | COLOR_MAP.put('d', "#FF55FF"); // Light Purple 34 | COLOR_MAP.put('e', "#FFFF55"); // Yellow 35 | COLOR_MAP.put('f', "#FFFFFF"); // White 36 | } 37 | 38 | /** 39 | * Strip the color codes 40 | * from the given input. 41 | * 42 | * @param input the input to strip 43 | * @return the stripped input 44 | */ 45 | @NonNull 46 | public static String stripColor(@NonNull String input) { 47 | return STRIP_COLOR_PATTERN.matcher(input).replaceAll(""); 48 | } 49 | 50 | /** 51 | * Convert the given input 52 | * into HTML. 53 | * 54 | * @param input the input to convert 55 | * @return the HTML converted input 56 | */ 57 | @NonNull 58 | public static String toHTML(@NonNull String input) { 59 | StringBuilder builder = new StringBuilder(); 60 | boolean nextIsColor = false; // Is the next char a color code? 61 | 62 | // Get the leading spaces from the first line 63 | int leadingSpaces = 0; 64 | boolean foundNonSpace = false; 65 | for (char character : input.toCharArray()) { 66 | if (character == ' ' && !foundNonSpace) { 67 | leadingSpaces++; 68 | } else { 69 | foundNonSpace = true; 70 | } 71 | } 72 | 73 | for (char character : input.toCharArray()) { 74 | // Found color symbol, next color is the color 75 | if (character == '§') { 76 | nextIsColor = true; 77 | continue; 78 | } 79 | if (nextIsColor) { // Map the current color to its hex code 80 | String color = COLOR_MAP.getOrDefault(Character.toLowerCase(character), ""); 81 | builder.append(""); 82 | nextIsColor = false; 83 | continue; 84 | } 85 | if (character == ' ') { // Preserve space character 86 | builder.append(" "); 87 | continue; 88 | } 89 | builder.append(character); // Append the char... 90 | } 91 | 92 | // Add leading spaces to the end of the HTML string 93 | builder.append(" ".repeat(Math.max(0, leadingSpaces))); 94 | 95 | return builder.toString(); 96 | } 97 | 98 | /** 99 | * Gets a {@link Color} from a Minecraft color code. 100 | * 101 | * @param colorCode the color code to get the color from 102 | * @return the color 103 | */ 104 | public static Color getMinecraftColor(char colorCode) { 105 | String color = COLOR_MAP.getOrDefault(colorCode, null); 106 | if (color == null) { 107 | throw new IllegalArgumentException("Invalid color code: " + colorCode); 108 | } 109 | return Color.decode(color); 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/DNSUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.NonNull; 4 | import lombok.SneakyThrows; 5 | import lombok.experimental.UtilityClass; 6 | import org.xbill.DNS.Lookup; 7 | import org.xbill.DNS.Record; 8 | import org.xbill.DNS.Type; 9 | import xyz.mcutils.backend.model.dns.impl.ARecord; 10 | import xyz.mcutils.backend.model.dns.impl.SRVRecord; 11 | 12 | /** 13 | * @author Braydon 14 | */ 15 | @UtilityClass 16 | public final class DNSUtils { 17 | private static final String SRV_QUERY_PREFIX = "_minecraft._tcp.%s"; 18 | 19 | /** 20 | * Get the resolved address and port of the 21 | * given hostname by resolving the SRV records. 22 | * 23 | * @param hostname the hostname to resolve 24 | * @return the resolved address and port, null if none 25 | */ 26 | @SneakyThrows 27 | public static SRVRecord resolveSRV(@NonNull String hostname) { 28 | Record[] records = new Lookup(SRV_QUERY_PREFIX.formatted(hostname), Type.SRV).run(); // Resolve SRV records 29 | if (records == null) { // No records exist 30 | return null; 31 | } 32 | SRVRecord result = null; 33 | for (Record record : records) { 34 | result = new SRVRecord((org.xbill.DNS.SRVRecord) record); 35 | } 36 | return result; 37 | } 38 | 39 | /** 40 | * Get the resolved address of the given 41 | * hostname by resolving the A records. 42 | * 43 | * @param hostname the hostname to resolve 44 | * @return the resolved address, null if none 45 | */ 46 | @SneakyThrows 47 | public static ARecord resolveA(@NonNull String hostname) { 48 | Record[] records = new Lookup(hostname, Type.A).run(); // Resolve A records 49 | if (records == null) { // No records exist 50 | return null; 51 | } 52 | ARecord result = null; 53 | for (Record record : records) { 54 | result = new ARecord((org.xbill.DNS.ARecord) record); 55 | } 56 | return result; 57 | } 58 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/Endpoint.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import org.springframework.http.HttpStatusCode; 6 | 7 | import java.util.List; 8 | 9 | @AllArgsConstructor @Getter 10 | public class Endpoint { 11 | 12 | /** 13 | * The endpoint. 14 | */ 15 | private final String endpoint; 16 | 17 | /** 18 | * The statuses that indicate that the endpoint is online. 19 | */ 20 | private final List allowedStatuses; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/EnumUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.NonNull; 4 | import lombok.experimental.UtilityClass; 5 | 6 | /** 7 | * @author Braydon 8 | */ 9 | @UtilityClass 10 | public final class EnumUtils { 11 | /** 12 | * Get the enum constant of the specified enum type with the specified name. 13 | * 14 | * @param enumType the enum type 15 | * @param name the name of the constant to return 16 | * @param the type of the enum 17 | * @return the enum constant of the specified enum type with the specified name 18 | */ 19 | public > T getEnumConstant(@NonNull Class enumType, @NonNull String name) { 20 | try { 21 | return Enum.valueOf(enumType, name); 22 | } catch (IllegalArgumentException ex) { 23 | return null; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/ExpiringSet.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.NonNull; 4 | import net.jodah.expiringmap.ExpirationPolicy; 5 | import net.jodah.expiringmap.ExpiringMap; 6 | 7 | import java.util.Iterator; 8 | import java.util.Set; 9 | import java.util.concurrent.TimeUnit; 10 | import java.util.function.Consumer; 11 | 12 | /** 13 | * A simple set that expires elements after a certain 14 | * amount of time, utilizing the {@link ExpiringMap} library. 15 | * 16 | * @param The type of element to store within this set 17 | * @author Braydon 18 | */ 19 | public final class ExpiringSet implements Iterable { 20 | /** 21 | * The internal cache for this set. 22 | */ 23 | @NonNull private final ExpiringMap cache; 24 | 25 | /** 26 | * The lifetime (in millis) of the elements in this set. 27 | */ 28 | private final long lifetime; 29 | 30 | public ExpiringSet(@NonNull ExpirationPolicy expirationPolicy, long duration, @NonNull TimeUnit timeUnit) { 31 | this(expirationPolicy, duration, timeUnit, ignored -> {}); 32 | } 33 | 34 | public ExpiringSet(@NonNull ExpirationPolicy expirationPolicy, long duration, @NonNull TimeUnit timeUnit, @NonNull Consumer onExpire) { 35 | //noinspection unchecked 36 | this.cache = ExpiringMap.builder() 37 | .expirationPolicy(expirationPolicy) 38 | .expiration(duration, timeUnit) 39 | .expirationListener((key, ignored) -> onExpire.accept((T) key)) 40 | .build(); 41 | this.lifetime = timeUnit.toMillis(duration); // Get the lifetime in millis 42 | } 43 | 44 | /** 45 | * Add an element to this set. 46 | * 47 | * @param element the element 48 | * @return whether the element was added 49 | */ 50 | public boolean add(@NonNull T element) { 51 | boolean contains = contains(element); // Does this set already contain the element? 52 | this.cache.put(element, System.currentTimeMillis() + this.lifetime); 53 | return !contains; 54 | } 55 | 56 | /** 57 | * Get the entry time of an element in this set. 58 | * 59 | * @param element the element 60 | * @return the entry time, -1 if not contained 61 | */ 62 | public long getEntryTime(@NonNull T element) { 63 | return contains(element) ? this.cache.get(element) - this.lifetime : -1L; 64 | } 65 | 66 | /** 67 | * Check if an element is 68 | * contained within this set. 69 | * 70 | * @param element the element 71 | * @return whether the element is contained 72 | */ 73 | public boolean contains(@NonNull T element) { 74 | Long timeout = this.cache.get(element); // Get the timeout for the element 75 | return timeout != null && (timeout > System.currentTimeMillis()); 76 | } 77 | 78 | /** 79 | * Check if this set is empty. 80 | * 81 | * @return whether this set is empty 82 | */ 83 | public boolean isEmpty() { 84 | return this.cache.isEmpty(); 85 | } 86 | 87 | /** 88 | * Get the size of this set. 89 | * 90 | * @return the size 91 | */ 92 | public int size() { 93 | return this.cache.size(); 94 | } 95 | 96 | /** 97 | * Remove an element from this set. 98 | * 99 | * @param element the element 100 | * @return whether the element was removed 101 | */ 102 | public boolean remove(@NonNull T element) { 103 | return this.cache.remove(element) != null; 104 | } 105 | 106 | /** 107 | * Clear this set. 108 | */ 109 | public void clear() { 110 | this.cache.clear(); 111 | } 112 | 113 | /** 114 | * Get the elements in this set. 115 | * 116 | * @return the elements 117 | */ 118 | @NonNull 119 | public Set getElements() { 120 | return this.cache.keySet(); 121 | } 122 | 123 | /** 124 | * Returns an iterator over elements of type {@code T}. 125 | * 126 | * @return an Iterator. 127 | */ 128 | @Override @NonNull 129 | public Iterator iterator() { 130 | return this.cache.keySet().iterator(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/Fonts.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.extern.log4j.Log4j2; 4 | import xyz.mcutils.backend.Main; 5 | 6 | import java.awt.*; 7 | import java.io.IOException; 8 | import java.io.InputStream; 9 | 10 | @Log4j2(topic = "Fonts") 11 | public class Fonts { 12 | 13 | public static final Font MINECRAFT; 14 | public static final Font MINECRAFT_BOLD; 15 | public static final Font MINECRAFT_ITALIC; 16 | 17 | static { 18 | InputStream stream = Main.class.getResourceAsStream("/fonts/minecraft-font.ttf"); 19 | try { 20 | MINECRAFT = Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(18f); 21 | MINECRAFT_BOLD = MINECRAFT.deriveFont(Font.BOLD); 22 | MINECRAFT_ITALIC = MINECRAFT.deriveFont(Font.ITALIC); 23 | } catch (FontFormatException | IOException e) { 24 | log.error("Failed to load Minecraft font", e); 25 | throw new RuntimeException("Failed to load Minecraft font", e); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/IPUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import lombok.experimental.UtilityClass; 5 | 6 | @UtilityClass 7 | public class IPUtils { 8 | /** 9 | * The headers that contain the IP. 10 | */ 11 | private static final String[] IP_HEADERS = new String[] { 12 | "CF-Connecting-IP", 13 | "X-Forwarded-For" 14 | }; 15 | 16 | /** 17 | * Get the real IP from the given request. 18 | * 19 | * @param request the request 20 | * @return the real IP 21 | */ 22 | public static String getRealIp(HttpServletRequest request) { 23 | String ip = request.getRemoteAddr(); 24 | for (String headerName : IP_HEADERS) { 25 | String header = request.getHeader(headerName); 26 | if (header == null) { 27 | continue; 28 | } 29 | if (!header.contains(",")) { // Handle single IP 30 | ip = header; 31 | break; 32 | } 33 | // Handle multiple IPs 34 | String[] ips = header.split(","); 35 | for (String ipHeader : ips) { 36 | ip = ipHeader; 37 | break; 38 | } 39 | } 40 | return ip; 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/ImageUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import jakarta.validation.constraints.NotNull; 4 | import lombok.SneakyThrows; 5 | import lombok.extern.log4j.Log4j2; 6 | 7 | import javax.imageio.ImageIO; 8 | import java.awt.*; 9 | import java.awt.geom.AffineTransform; 10 | import java.awt.image.BufferedImage; 11 | import java.io.ByteArrayInputStream; 12 | import java.io.ByteArrayOutputStream; 13 | import java.util.Base64; 14 | 15 | @Log4j2(topic = "Image Utils") 16 | public class ImageUtils { 17 | /** 18 | * Scale the given image to the provided scale. 19 | * 20 | * @param image the image to scale 21 | * @param scale the scale to scale the image to 22 | * @return the scaled image 23 | */ 24 | public static BufferedImage resize(BufferedImage image, double scale) { 25 | BufferedImage scaled = new BufferedImage((int) (image.getWidth() * scale), (int) (image.getHeight() * scale), BufferedImage.TYPE_INT_ARGB); 26 | Graphics2D graphics = scaled.createGraphics(); 27 | graphics.drawImage(image, AffineTransform.getScaleInstance(scale, scale), null); 28 | graphics.dispose(); 29 | return scaled; 30 | } 31 | 32 | /** 33 | * Flip the given image. 34 | * 35 | * @param image the image to flip 36 | * @return the flipped image 37 | */ 38 | public static BufferedImage flip(@NotNull final BufferedImage image) { 39 | BufferedImage flipped = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB); 40 | Graphics2D graphics = flipped.createGraphics(); 41 | graphics.drawImage(image, image.getWidth(), 0, 0, image.getHeight(), 0, 0, image.getWidth(), image.getHeight(), null); 42 | graphics.dispose(); 43 | return flipped; 44 | } 45 | 46 | /** 47 | * Convert an image to bytes. 48 | * 49 | * @param image the image to convert 50 | * @return the image as bytes 51 | */ 52 | @SneakyThrows 53 | public static byte[] imageToBytes(BufferedImage image) { 54 | try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { 55 | ImageIO.write(image, "png", outputStream); 56 | return outputStream.toByteArray(); 57 | } catch (Exception e) { 58 | throw new Exception("Failed to convert image to bytes", e); 59 | } 60 | } 61 | 62 | /** 63 | * Convert a base64 string to an image. 64 | * 65 | * @param base64 the base64 string to convert 66 | * @return the image 67 | */ 68 | @SneakyThrows 69 | public static BufferedImage base64ToImage(String base64) { 70 | String favicon = base64.contains("data:image/png;base64,") ? base64.split(",")[1] : base64; 71 | 72 | try { 73 | return ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(favicon))); 74 | } catch (Exception e) { 75 | throw new Exception("Failed to convert base64 to image", e); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/JavaMinecraftVersion.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.Getter; 4 | import lombok.NonNull; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.ToString; 7 | import lombok.extern.log4j.Log4j2; 8 | 9 | /** 10 | * @author Braydon 11 | * @see Protocol Version Numbers 12 | * @see Spigot NMS (1.16+) 13 | * @see Spigot NMS (1.10 - 1.15) 14 | * @see Spigot NMS (1.8 - 1.9) 15 | */ 16 | @RequiredArgsConstructor @Getter @ToString @Log4j2(topic = "Minecraft Version") 17 | public enum JavaMinecraftVersion { 18 | V1_20_3(765, "v1_20_R3"), // 1.20.3 & 1.20.4 19 | V1_20_2(764, "v1_20_R2"), // 1.20.2 20 | V1_20(763, "v1_20_R1"), // 1.20 & 1.20.1 21 | 22 | V1_19_4(762, "v1_19_R3"), // 1.19.4 23 | V1_19_3(761, "v1_19_R2"), // 1.19.3 24 | V1_19_1(760, "v1_19_R1"), // 1.19.1 & 1.19.2 25 | V1_19(759, "v1_19_R1"), // 1.19 26 | 27 | V1_18_2(758, "v1_18_R2"), // 1.18.2 28 | V1_18(757, "v1_18_R1"), // 1.18 & 1.18.1 29 | 30 | V1_17_1(756, "v1_17_R1"), // 1.17.1 31 | V1_17(755, "v1_17_R1"), // 1.17 32 | 33 | V1_16_4(754, "v1_16_R3"), // 1.16.4 & 1.16.5 34 | V1_16_3(753, "v1_16_R2"), // 1.16.3 35 | V1_16_2(751, "v1_16_R2"), // 1.16.2 36 | V1_16_1(736, "v1_16_R1"), // 1.16.1 37 | V1_16(735, "v1_16_R1"), // 1.16 38 | 39 | V1_15_2(578, "v1_15_R1"), // 1.15.2 40 | V1_15_1(575, "v1_15_R1"), // 1.15.1 41 | V1_15(573, "v1_15_R1"), // 1.15 42 | 43 | V1_14_4(498, "v1_14_R1"), // 1.14.4 44 | V1_14_3(490, "v1_14_R1"), // 1.14.3 45 | V1_14_2(485, "v1_14_R1"), // 1.14.2 46 | V1_14_1(480, "v1_14_R1"), // 1.14.1 47 | V1_14(477, "v1_14_R1"), // 1.14 48 | 49 | V1_13_2(404, "v1_13_R2"), // 1.13.2 50 | V1_13_1(401, "v1_13_R2"), // 1.13.1 51 | V1_13(393, "v1_13_R1"), // 1.13 52 | 53 | V1_12_2(340, "v1_12_R1"), // 1.12.2 54 | V1_12_1(338, "v1_12_R1"), // 1.12.1 55 | V1_12(335, "v1_12_R1"), // 1.12 56 | 57 | V1_11_1(316, "v1_11_R1"), // 1.11.1 & 1.11.2 58 | V1_11(315, "v1_11_R1"), // 1.11 59 | 60 | V1_10(210, "v1_10_R1"), // 1.10.x 61 | 62 | V1_9_3(110, "v1_9_R2"), // 1.9.3 & 1.9.4 63 | V1_9_2(109, "v1_9_R1"), // 1.9.2 64 | V1_9_1(108, "v1_9_R1"), // 1.9.1 65 | V1_9(107, "v1_9_R1"), // 1.9 66 | 67 | V1_8(47, "v1_8_R3"), // 1.8.x 68 | 69 | V1_7_6(5, "v1_7_R4"), // 1.7.6 - 1.7.10 70 | 71 | UNKNOWN(-1, "Unknown"); 72 | 73 | // Game Updates 74 | public static final JavaMinecraftVersion TRAILS_AND_TALES = JavaMinecraftVersion.V1_20; 75 | public static final JavaMinecraftVersion THE_WILD_UPDATE = JavaMinecraftVersion.V1_19; 76 | public static final JavaMinecraftVersion CAVES_AND_CLIFFS_PT_2 = JavaMinecraftVersion.V1_18; 77 | public static final JavaMinecraftVersion CAVES_AND_CLIFFS_PT_1 = JavaMinecraftVersion.V1_17; 78 | public static final JavaMinecraftVersion NETHER_UPDATE = JavaMinecraftVersion.V1_16; 79 | public static final JavaMinecraftVersion BUZZY_BEES = JavaMinecraftVersion.V1_15; 80 | public static final JavaMinecraftVersion VILLAGE_AND_PILLAGE = JavaMinecraftVersion.V1_14; 81 | public static final JavaMinecraftVersion UPDATE_AQUATIC = JavaMinecraftVersion.V1_13; 82 | public static final JavaMinecraftVersion WORLD_OF_COLOR_UPDATE = JavaMinecraftVersion.V1_12; 83 | public static final JavaMinecraftVersion EXPLORATION_UPDATE = JavaMinecraftVersion.V1_11; 84 | public static final JavaMinecraftVersion FROSTBURN_UPDATE = JavaMinecraftVersion.V1_10; 85 | public static final JavaMinecraftVersion THE_COMBAT_UPDATE = JavaMinecraftVersion.V1_9; 86 | public static final JavaMinecraftVersion BOUNTIFUL_UPDATE = JavaMinecraftVersion.V1_8; 87 | 88 | private static final JavaMinecraftVersion[] VALUES = JavaMinecraftVersion.values(); 89 | 90 | /** 91 | * The protocol number of this version. 92 | */ 93 | private final int protocol; 94 | 95 | /** 96 | * The server version for this version. 97 | */ 98 | private final String nmsVersion; 99 | 100 | /** 101 | * The cached name of this version. 102 | */ 103 | private String name; 104 | 105 | /** 106 | * Get the name of this protocol version. 107 | * 108 | * @return the name 109 | */ 110 | public String getName() { 111 | // We have a name 112 | if (this.name != null) { 113 | return this.name; 114 | } 115 | // Use the server version as the name if unknown 116 | if (this == UNKNOWN) { 117 | this.name = this.getNmsVersion(); 118 | } else { // Parse the name 119 | this.name = name().substring(1); 120 | this.name = this.name.replace("_", "."); 121 | } 122 | return this.name; 123 | } 124 | 125 | /** 126 | * Is this version legacy? 127 | * 128 | * @return whether this version is legacy 129 | */ 130 | public boolean isLegacy() { 131 | return this.isBelow(JavaMinecraftVersion.V1_16); 132 | } 133 | 134 | /** 135 | * Check if this version is 136 | * above the one given. 137 | * 138 | * @param other the other version 139 | * @return true if above, otherwise false 140 | */ 141 | public boolean isAbove(JavaMinecraftVersion other) { 142 | return this.protocol > other.getProtocol(); 143 | } 144 | 145 | /** 146 | * Check if this version is 147 | * or above the one given. 148 | * 149 | * @param other the other version 150 | * @return true if is or above, otherwise false 151 | */ 152 | public boolean isOrAbove(JavaMinecraftVersion other) { 153 | return this.protocol >= other.getProtocol(); 154 | } 155 | 156 | /** 157 | * Check if this version is 158 | * below the one given. 159 | * 160 | * @param other the other version 161 | * @return true if below, otherwise false 162 | */ 163 | public boolean isBelow(JavaMinecraftVersion other) { 164 | return this.protocol < other.getProtocol(); 165 | } 166 | 167 | /** 168 | * Check if this version is 169 | * or below the one given. 170 | * 171 | * @param other the other version 172 | * @return true if is or below, otherwise false 173 | */ 174 | public boolean isOrBelow(JavaMinecraftVersion other) { 175 | return this.protocol <= other.getProtocol(); 176 | } 177 | 178 | /** 179 | * Get the minimum Minecraft version. 180 | * 181 | * @return the minimum version 182 | */ 183 | @NonNull 184 | public static JavaMinecraftVersion getMinimumVersion() { 185 | return VALUES[VALUES.length - 2]; 186 | } 187 | 188 | /** 189 | * Get the version from the given protocol. 190 | * 191 | * @param protocol the protocol to get the version for 192 | * @return the version, null if none 193 | */ 194 | public static JavaMinecraftVersion byProtocol(int protocol) { 195 | for (JavaMinecraftVersion version : values()) { 196 | if (version.getProtocol() == protocol) { 197 | return version; 198 | } 199 | } 200 | return null; 201 | } 202 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/MojangServer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import lombok.NonNull; 6 | import lombok.ToString; 7 | 8 | import java.io.IOException; 9 | import java.net.InetAddress; 10 | import java.net.UnknownHostException; 11 | import java.util.concurrent.TimeUnit; 12 | 13 | /** 14 | * @author Fascinated (fascinated7) 15 | */ 16 | @AllArgsConstructor 17 | @Getter 18 | @ToString 19 | public enum MojangServer { 20 | SESSION("Session Server", "https://sessionserver.mojang.com"), 21 | API("Mojang API", "https://api.mojang.com"), 22 | TEXTURES("Textures Server", "https://textures.minecraft.net"), 23 | ASSETS("Assets Server", "https://assets.mojang.com"), 24 | LIBRARIES("Libraries Server", "https://libraries.minecraft.net"), 25 | SERVICES("Minecraft Services", "https://api.minecraftservices.com"); 26 | 27 | private static final long STATUS_TIMEOUT = TimeUnit.SECONDS.toMillis(10); 28 | 29 | /** 30 | * The name of this server. 31 | */ 32 | @NonNull private final String name; 33 | 34 | /** 35 | * The endpoint of this service. 36 | */ 37 | @NonNull private final String endpoint; 38 | 39 | /** 40 | * Ping this service and get the status of it. 41 | * 42 | * @return the service status 43 | */ 44 | @NonNull 45 | public Status getStatus() { 46 | try { 47 | InetAddress address = InetAddress.getByName(endpoint.substring(8)); 48 | long before = System.currentTimeMillis(); 49 | if (address.isReachable((int) STATUS_TIMEOUT)) { 50 | // The time it took to reach the host is 75% of 51 | // the timeout, consider it to be degraded. 52 | if ((System.currentTimeMillis() - before) > STATUS_TIMEOUT * 0.75D) { 53 | return Status.DEGRADED; 54 | } 55 | return Status.ONLINE; 56 | } 57 | } catch (UnknownHostException ex) { 58 | ex.printStackTrace(); 59 | } catch (IOException ignored) { 60 | // We can safely ignore any errors, we're simply checking 61 | // if the host is reachable, if it's not, then it's offline. 62 | } 63 | return Status.OFFLINE; 64 | } 65 | 66 | /** 67 | * The status of a service. 68 | */ 69 | public enum Status { 70 | /** 71 | * The service is online and accessible. 72 | */ 73 | ONLINE, 74 | 75 | /** 76 | * The service is online, but is experiencing degraded performance. 77 | */ 78 | DEGRADED, 79 | 80 | /** 81 | * The service is offline and inaccessible. 82 | */ 83 | OFFLINE 84 | } 85 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/PlayerUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import lombok.SneakyThrows; 5 | import lombok.experimental.UtilityClass; 6 | import lombok.extern.log4j.Log4j2; 7 | import xyz.mcutils.backend.Main; 8 | import xyz.mcutils.backend.exception.impl.BadRequestException; 9 | 10 | import java.net.URI; 11 | import java.net.http.HttpRequest; 12 | import java.net.http.HttpResponse; 13 | import java.util.UUID; 14 | 15 | @UtilityClass @Log4j2(topic = "Player Utils") 16 | public class PlayerUtils { 17 | 18 | /** 19 | * Gets the UUID from the string. 20 | * 21 | * @param id the id string 22 | * @return the UUID 23 | */ 24 | public static UUID getUuidFromString(String id) { 25 | UUID uuid; 26 | boolean isFullUuid = id.length() == 36; 27 | if (id.length() == 32 || isFullUuid) { 28 | try { 29 | uuid = isFullUuid ? UUID.fromString(id) : UUIDUtils.addDashes(id); 30 | } catch (IllegalArgumentException exception) { 31 | throw new BadRequestException("Invalid UUID provided: %s".formatted(id)); 32 | } 33 | return uuid; 34 | } 35 | return null; 36 | } 37 | 38 | /** 39 | * Gets the skin data from the URL. 40 | * 41 | * @return the skin data 42 | */ 43 | @SneakyThrows 44 | @JsonIgnore 45 | public static byte[] getSkinImage(String url) { 46 | HttpResponse response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder(URI.create(url)).build(), 47 | HttpResponse.BodyHandlers.ofByteArray()); 48 | return response.body(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/ServerUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.experimental.UtilityClass; 4 | 5 | @UtilityClass 6 | public class ServerUtils { 7 | 8 | /** 9 | * Gets the address of the server. 10 | * 11 | * @return the address of the server 12 | */ 13 | public static String getAddress(String ip, int port) { 14 | return ip + (port == 25565 ? "" : ":" + port); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/Timer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | public class Timer { 4 | 5 | /** 6 | * Schedules a task to run after a delay. 7 | * 8 | * @param runnable the task to run 9 | * @param delay the delay before the task runs 10 | */ 11 | public static void scheduleRepeating(Runnable runnable, long delay, long period) { 12 | new java.util.Timer().scheduleAtFixedRate(new java.util.TimerTask() { 13 | @Override 14 | public void run() { 15 | runnable.run(); 16 | } 17 | }, delay, period); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/Tuple.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @Getter @AllArgsConstructor 7 | public class Tuple { 8 | 9 | /** 10 | * The left value of the tuple. 11 | */ 12 | private final L left; 13 | 14 | /** 15 | * The right value of the tuple. 16 | */ 17 | private final R right; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/UUIDUtils.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import io.micrometer.common.lang.NonNull; 4 | import lombok.experimental.UtilityClass; 5 | 6 | import java.util.UUID; 7 | 8 | @UtilityClass 9 | public class UUIDUtils { 10 | 11 | /** 12 | * Add dashes to a UUID. 13 | * 14 | * @param trimmed the UUID without dashes 15 | * @return the UUID with dashes 16 | */ 17 | @NonNull 18 | public static UUID addDashes(@NonNull String trimmed) { 19 | StringBuilder builder = new StringBuilder(trimmed); 20 | for (int i = 0, pos = 20; i < 4; i++, pos -= 4) { 21 | builder.insert(pos, "-"); 22 | } 23 | return UUID.fromString(builder.toString()); 24 | } 25 | 26 | /** 27 | * Remove dashes from a UUID. 28 | * 29 | * @param dashed the UUID with dashes 30 | * @return the UUID without dashes 31 | */ 32 | @NonNull 33 | public static String removeDashes(@NonNull UUID dashed) { 34 | return dashed.toString().replace("-", ""); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/WebRequest.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common; 2 | 3 | import lombok.experimental.UtilityClass; 4 | import org.springframework.http.HttpStatus; 5 | import org.springframework.http.HttpStatusCode; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 8 | import org.springframework.web.client.RestClient; 9 | import xyz.mcutils.backend.exception.impl.RateLimitException; 10 | 11 | @UtilityClass 12 | public class WebRequest { 13 | 14 | /** 15 | * The web client. 16 | */ 17 | private static final RestClient CLIENT; 18 | 19 | static { 20 | HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); 21 | requestFactory.setConnectTimeout(2500); // 2.5 seconds 22 | CLIENT = RestClient.builder() 23 | .requestFactory(requestFactory) 24 | .build(); 25 | } 26 | 27 | /** 28 | * Gets a response from the given URL. 29 | * 30 | * @param url the url 31 | * @return the response 32 | * @param the type of the response 33 | */ 34 | public static T getAsEntity(String url, Class clazz) throws RateLimitException { 35 | ResponseEntity responseEntity = CLIENT.get() 36 | .uri(url) 37 | .retrieve() 38 | .onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error 39 | .toEntity(clazz); 40 | 41 | if (responseEntity.getStatusCode().isError()) { 42 | return null; 43 | } 44 | if (responseEntity.getStatusCode().isSameCodeAs(HttpStatus.TOO_MANY_REQUESTS)) { 45 | throw new RateLimitException("Rate limit reached"); 46 | } 47 | return responseEntity.getBody(); 48 | } 49 | 50 | /** 51 | * Gets a response from the given URL. 52 | * 53 | * @param url the url 54 | * @return the response 55 | */ 56 | public static ResponseEntity get(String url, Class clazz) { 57 | return CLIENT.get() 58 | .uri(url) 59 | .retrieve() 60 | .onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error 61 | .toEntity(clazz); 62 | } 63 | 64 | /** 65 | * Gets a response from the given URL. 66 | * 67 | * @param url the url 68 | * @return the response 69 | */ 70 | public static ResponseEntity head(String url, Class clazz) { 71 | return CLIENT.head() 72 | .uri(url) 73 | .retrieve() 74 | .onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error 75 | .toEntity(clazz); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/packet/MinecraftBedrockPacket.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.packet; 2 | 3 | import lombok.NonNull; 4 | 5 | import java.io.IOException; 6 | import java.net.DatagramSocket; 7 | 8 | /** 9 | * Represents a packet in the 10 | * Minecraft Bedrock protocol. 11 | * 12 | * @author Braydon 13 | * @see Protocol Docs 14 | */ 15 | public interface MinecraftBedrockPacket { 16 | /** 17 | * Process this packet. 18 | * 19 | * @param socket the socket to process the packet for 20 | * @throws IOException if an I/O error occurs 21 | */ 22 | void process(@NonNull DatagramSocket socket) throws IOException; 23 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/packet/MinecraftJavaPacket.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.packet; 2 | 3 | import lombok.NonNull; 4 | 5 | import java.io.DataInputStream; 6 | import java.io.DataOutputStream; 7 | import java.io.IOException; 8 | 9 | /** 10 | * Represents a packet in the 11 | * Minecraft Java protocol. 12 | * 13 | * @author Braydon 14 | * @see Protocol Docs 15 | */ 16 | public abstract class MinecraftJavaPacket { 17 | /** 18 | * Process this packet. 19 | * 20 | * @param inputStream the input stream to read from 21 | * @param outputStream the output stream to write to 22 | * @throws IOException if an I/O error occurs 23 | */ 24 | public abstract void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException; 25 | 26 | /** 27 | * Write a variable integer to the output stream. 28 | * 29 | * @param outputStream the output stream to write to 30 | * @param paramInt the integer to write 31 | * @throws IOException if an I/O error occurs 32 | */ 33 | protected final void writeVarInt(DataOutputStream outputStream, int paramInt) throws IOException { 34 | while (true) { 35 | if ((paramInt & 0xFFFFFF80) == 0) { 36 | outputStream.writeByte(paramInt); 37 | return; 38 | } 39 | outputStream.writeByte(paramInt & 0x7F | 0x80); 40 | paramInt >>>= 7; 41 | } 42 | } 43 | 44 | /** 45 | * Read a variable integer from the input stream. 46 | * 47 | * @param inputStream the input stream to read from 48 | * @return the integer that was read 49 | * @throws IOException if an I/O error occurs 50 | */ 51 | protected final int readVarInt(@NonNull DataInputStream inputStream) throws IOException { 52 | int i = 0; 53 | int j = 0; 54 | while (true) { 55 | int k = inputStream.readByte(); 56 | i |= (k & 0x7F) << j++ * 7; 57 | if (j > 5) { 58 | throw new RuntimeException("VarInt too big"); 59 | } 60 | if ((k & 0x80) != 128) { 61 | break; 62 | } 63 | } 64 | return i; 65 | } 66 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/packet/impl/bedrock/BedrockPacketUnconnectedPing.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.packet.impl.bedrock; 2 | 3 | import lombok.NonNull; 4 | import xyz.mcutils.backend.common.packet.MinecraftBedrockPacket; 5 | 6 | import java.io.IOException; 7 | import java.net.DatagramPacket; 8 | import java.net.DatagramSocket; 9 | import java.nio.ByteBuffer; 10 | import java.nio.ByteOrder; 11 | 12 | /** 13 | * This packet is sent by the client to the server to 14 | * request a pong response from the server. The server 15 | * will respond with a string containing the server's status. 16 | * 17 | * @author Braydon 18 | * @see Protocol Docs 19 | */ 20 | public final class BedrockPacketUnconnectedPing implements MinecraftBedrockPacket { 21 | private static final byte ID = 0x01; // The ID of the packet 22 | private static final byte[] MAGIC = { 0, -1, -1, 0, -2, -2, -2, -2, -3, -3, -3, -3, 18, 52, 86, 120 }; 23 | 24 | /** 25 | * Process this packet. 26 | * 27 | * @param socket the socket to process the packet for 28 | * @throws IOException if an I/O error occurs 29 | */ 30 | @Override 31 | public void process(@NonNull DatagramSocket socket) throws IOException { 32 | // Construct the packet buffer 33 | ByteBuffer buffer = ByteBuffer.allocate(33).order(ByteOrder.LITTLE_ENDIAN);; 34 | buffer.put(ID); // Packet ID 35 | buffer.putLong(System.currentTimeMillis()); // Timestamp 36 | buffer.put(MAGIC); // Magic 37 | buffer.putLong(0L); // Client GUID 38 | 39 | // Send the packet 40 | socket.send(new DatagramPacket(buffer.array(), 0, buffer.limit())); 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/packet/impl/bedrock/BedrockPacketUnconnectedPong.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.packet.impl.bedrock; 2 | 3 | import lombok.Getter; 4 | import lombok.NonNull; 5 | import xyz.mcutils.backend.common.packet.MinecraftBedrockPacket; 6 | import xyz.mcutils.backend.model.server.BedrockMinecraftServer; 7 | 8 | import java.io.IOException; 9 | import java.net.DatagramPacket; 10 | import java.net.DatagramSocket; 11 | import java.nio.ByteBuffer; 12 | import java.nio.ByteOrder; 13 | import java.nio.charset.StandardCharsets; 14 | 15 | /** 16 | * This packet is sent by the server to the client in 17 | * response to the {@link BedrockPacketUnconnectedPing}. 18 | * 19 | * @author Braydon 20 | * @see Protocol Docs 21 | */ 22 | @Getter 23 | public final class BedrockPacketUnconnectedPong implements MinecraftBedrockPacket { 24 | private static final byte ID = 0x1C; // The ID of the packet 25 | 26 | /** 27 | * The response from the server, null if none. 28 | */ 29 | private String response; 30 | 31 | /** 32 | * Process this packet. 33 | * 34 | * @param socket the socket to process the packet for 35 | * @throws IOException if an I/O error occurs 36 | */ 37 | @Override 38 | public void process(@NonNull DatagramSocket socket) throws IOException { 39 | // Handle receiving of the packet 40 | byte[] receiveData = new byte[2048]; 41 | DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); 42 | socket.receive(receivePacket); 43 | 44 | // Construct a buffer from the received packet 45 | ByteBuffer buffer = ByteBuffer.wrap(receivePacket.getData()).order(ByteOrder.LITTLE_ENDIAN); 46 | byte id = buffer.get(); // The received packet id 47 | if (id == ID) { 48 | String response = new String(buffer.array(), StandardCharsets.UTF_8).trim(); // Extract the response 49 | 50 | // Trim the length of the response (short) from the 51 | // start of the string, which begins with the edition name 52 | for (BedrockMinecraftServer.Edition edition : BedrockMinecraftServer.Edition.values()) { 53 | int startIndex = response.indexOf(edition.name()); 54 | if (startIndex != -1) { 55 | response = response.substring(startIndex); 56 | break; 57 | } 58 | } 59 | this.response = response; 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.packet.impl.java; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.NonNull; 5 | import lombok.ToString; 6 | import xyz.mcutils.backend.common.packet.MinecraftJavaPacket; 7 | 8 | import java.io.ByteArrayOutputStream; 9 | import java.io.DataInputStream; 10 | import java.io.DataOutputStream; 11 | import java.io.IOException; 12 | 13 | /** 14 | * This packet is sent by the client to the server to set 15 | * the hostname, port, and protocol version of the client. 16 | * 17 | * @author Braydon 18 | * @see Protocol Docs 19 | */ 20 | @AllArgsConstructor @ToString 21 | public final class JavaPacketHandshakingInSetProtocol extends MinecraftJavaPacket { 22 | private static final byte ID = 0x00; // The ID of the packet 23 | private static final int STATUS_HANDSHAKE = 1; // The status handshake ID 24 | 25 | /** 26 | * The hostname of the server. 27 | */ 28 | @NonNull private final String hostname; 29 | 30 | /** 31 | * The port of the server. 32 | */ 33 | private final int port; 34 | 35 | /** 36 | * The protocol version of the server. 37 | */ 38 | private final int protocolVersion; 39 | 40 | /** 41 | * Process this packet. 42 | * 43 | * @param inputStream the input stream to read from 44 | * @param outputStream the output stream to write to 45 | * @throws IOException if an I/O error occurs 46 | */ 47 | @Override 48 | public void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException { 49 | try (ByteArrayOutputStream handshakeBytes = new ByteArrayOutputStream(); 50 | DataOutputStream handshake = new DataOutputStream(handshakeBytes) 51 | ) { 52 | handshake.writeByte(ID); // Write the ID of the packet 53 | writeVarInt(handshake, protocolVersion); // Write the protocol version 54 | writeVarInt(handshake, hostname.length()); // Write the length of the hostname 55 | handshake.writeBytes(hostname); // Write the hostname 56 | handshake.writeShort(port); // Write the port 57 | writeVarInt(handshake, STATUS_HANDSHAKE); // Write the status handshake ID 58 | 59 | // Write the handshake bytes to the output stream 60 | writeVarInt(outputStream, handshakeBytes.size()); 61 | outputStream.write(handshakeBytes.toByteArray()); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/packet/impl/java/JavaPacketStatusInStart.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.packet.impl.java; 2 | 3 | import lombok.Getter; 4 | import lombok.NonNull; 5 | import xyz.mcutils.backend.common.packet.MinecraftJavaPacket; 6 | 7 | import java.io.DataInputStream; 8 | import java.io.DataOutputStream; 9 | import java.io.IOException; 10 | 11 | /** 12 | * This packet is sent by the client to the server to request the 13 | * status of the server. The server will respond with a json object 14 | * containing the server's status. 15 | * 16 | * @author Braydon 17 | * @see Protocol Docs 18 | */ 19 | @Getter 20 | public final class JavaPacketStatusInStart extends MinecraftJavaPacket { 21 | private static final byte ID = 0x00; // The ID of the packet 22 | 23 | /** 24 | * The response json from the server, null if none. 25 | */ 26 | private String response; 27 | 28 | /** 29 | * Process this packet. 30 | * 31 | * @param inputStream the input stream to read from 32 | * @param outputStream the output stream to write to 33 | * @throws IOException if an I/O error occurs 34 | */ 35 | @Override 36 | public void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException { 37 | // Send the status request 38 | outputStream.writeByte(0x01); // Size of packet 39 | outputStream.writeByte(ID); 40 | 41 | // Read the status response 42 | readVarInt(inputStream); // Size of the response 43 | int id = readVarInt(inputStream); 44 | if (id == -1) { // The stream was prematurely ended 45 | throw new IOException("Server prematurely ended stream."); 46 | } else if (id != ID) { // Invalid packet ID 47 | throw new IOException("Server returned invalid packet ID."); 48 | } 49 | 50 | int length = readVarInt(inputStream); // Length of the response 51 | if (length == -1) { // The stream was prematurely ended 52 | throw new IOException("Server prematurely ended stream."); 53 | } else if (length == 0) { 54 | throw new IOException("Server returned unexpected value."); 55 | } 56 | 57 | // Get the json response 58 | byte[] data = new byte[length]; 59 | inputStream.readFully(data); 60 | response = new String(data); 61 | } 62 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/IsometricSkinRenderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer; 2 | 3 | import xyz.mcutils.backend.model.skin.ISkinPart; 4 | 5 | import java.awt.*; 6 | import java.awt.geom.AffineTransform; 7 | import java.awt.image.BufferedImage; 8 | 9 | public abstract class IsometricSkinRenderer extends SkinRenderer { 10 | 11 | /** 12 | * Draw a part onto the texture. 13 | * 14 | * @param graphics the graphics to draw to 15 | * @param partImage the part image to draw 16 | * @param transform the transform to apply 17 | * @param x the x position to draw at 18 | * @param y the y position to draw at 19 | * @param width the part image width 20 | * @param height the part image height 21 | */ 22 | protected final void drawPart(Graphics2D graphics, BufferedImage partImage, AffineTransform transform, 23 | double x, double y, int width, int height) { 24 | graphics.setTransform(transform); 25 | graphics.drawImage(partImage, (int) x, (int) y, width, height, null); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/Renderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer; 2 | 3 | import java.awt.image.BufferedImage; 4 | 5 | public abstract class Renderer { 6 | 7 | /** 8 | * Renders the object to the specified size. 9 | * 10 | * @param input The object to render. 11 | * @param size The size to render the object to. 12 | */ 13 | public abstract BufferedImage render(T input, int size); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/SkinRenderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer; 2 | 3 | import lombok.SneakyThrows; 4 | import lombok.extern.log4j.Log4j2; 5 | import xyz.mcutils.backend.common.ImageUtils; 6 | import xyz.mcutils.backend.model.skin.ISkinPart; 7 | import xyz.mcutils.backend.model.skin.Skin; 8 | 9 | import javax.imageio.ImageIO; 10 | import java.awt.*; 11 | import java.awt.image.BufferedImage; 12 | import java.io.ByteArrayInputStream; 13 | 14 | @Log4j2(topic = "Skin Renderer") 15 | public abstract class SkinRenderer { 16 | 17 | /** 18 | * Get the texture of a part of the skin. 19 | * 20 | * @param skin the skin to get the part texture from 21 | * @param part the part of the skin to get 22 | * @param size the size to scale the texture to 23 | * @param renderOverlays should the overlays be rendered 24 | * @return the texture of the skin part 25 | */ 26 | @SneakyThrows 27 | public BufferedImage getVanillaSkinPart(Skin skin, ISkinPart.Vanilla part, double size, boolean renderOverlays) { 28 | ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part 29 | 30 | // The skin texture is legacy, use legacy coordinates 31 | if (skin.isLegacy() && part.hasLegacyCoordinates()) { 32 | coordinates = part.getLegacyCoordinates(); 33 | } 34 | int width = part.getWidth(); // The width of the part 35 | if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) { 36 | width--; 37 | } 38 | BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); // The skin texture 39 | BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size); 40 | if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) { 41 | partTexture = ImageUtils.flip(partTexture); 42 | } 43 | 44 | // Draw part overlays 45 | ISkinPart.Vanilla[] overlayParts = part.getOverlays(); 46 | if (overlayParts != null && renderOverlays) { 47 | log.info("Applying overlays to part: {}", part.name()); 48 | for (ISkinPart.Vanilla overlay : overlayParts) { 49 | applyOverlay(partTexture.createGraphics(), getVanillaSkinPart(skin, overlay, size, false)); 50 | } 51 | } 52 | 53 | return partTexture; 54 | } 55 | 56 | /** 57 | * Get the texture of a specific part of the skin. 58 | * 59 | * @param skinImage the skin image to get the part from 60 | * @param x the x position of the part 61 | * @param y the y position of the part 62 | * @param width the width of the part 63 | * @param height the height of the part 64 | * @param size the size to scale the part to 65 | * @return the texture of the skin part 66 | */ 67 | @SneakyThrows 68 | private BufferedImage getSkinPartTexture(BufferedImage skinImage, int x, int y, int width, int height, double size) { 69 | // Create a new BufferedImage for the part of the skin texture 70 | BufferedImage headTexture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); 71 | 72 | // Crop just the part we want based on our x, y, width, and height 73 | headTexture.getGraphics().drawImage(skinImage, 0, 0, width, height, x, y, x + width, y + height, null); 74 | 75 | // Scale the skin part texture 76 | if (size > 0D) { 77 | headTexture = ImageUtils.resize(headTexture, size); 78 | } 79 | return headTexture; 80 | } 81 | 82 | /** 83 | * Apply an overlay to a texture. 84 | * 85 | * @param graphics the graphics to overlay on 86 | * @param overlayImage the part to overlay 87 | */ 88 | protected void applyOverlay(Graphics2D graphics, BufferedImage overlayImage) { 89 | try { 90 | graphics.drawImage(overlayImage, 0, 0, null); 91 | graphics.dispose(); 92 | } catch (Exception ignored) { 93 | // We can safely ignore this, legacy 94 | // skins don't have overlays 95 | } 96 | } 97 | 98 | /** 99 | * Renders the skin part for the player's skin. 100 | * 101 | * @param skin the player's skin 102 | * @param part the skin part to render 103 | * @param renderOverlays should the overlays be rendered 104 | * @param size the size of the part 105 | * @return the rendered skin part 106 | */ 107 | public abstract BufferedImage render(Skin skin, T part, boolean renderOverlays, int size); 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/impl/server/ServerPreviewRenderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer.impl.server; 2 | 3 | import lombok.extern.log4j.Log4j2; 4 | import xyz.mcutils.backend.Main; 5 | import xyz.mcutils.backend.common.ColorUtils; 6 | import xyz.mcutils.backend.common.Fonts; 7 | import xyz.mcutils.backend.common.ImageUtils; 8 | import xyz.mcutils.backend.common.renderer.Renderer; 9 | import xyz.mcutils.backend.model.server.JavaMinecraftServer; 10 | import xyz.mcutils.backend.model.server.MinecraftServer; 11 | import xyz.mcutils.backend.service.ServerService; 12 | 13 | import javax.imageio.ImageIO; 14 | import java.awt.*; 15 | import java.awt.image.BufferedImage; 16 | import java.io.ByteArrayInputStream; 17 | 18 | @Log4j2 19 | public class ServerPreviewRenderer extends Renderer { 20 | public static final ServerPreviewRenderer INSTANCE = new ServerPreviewRenderer(); 21 | 22 | 23 | private static BufferedImage SERVER_BACKGROUND; 24 | private static BufferedImage PING_ICON; 25 | static { 26 | try { 27 | SERVER_BACKGROUND = ImageIO.read(new ByteArrayInputStream(Main.class.getResourceAsStream("/icons/server_background.png").readAllBytes())); 28 | PING_ICON = ImageIO.read(new ByteArrayInputStream(Main.class.getResourceAsStream("/icons/ping.png").readAllBytes())); 29 | } catch (Exception ex) { 30 | log.error("Failed to load server preview assets", ex); 31 | } 32 | } 33 | 34 | private final int fontSize = Fonts.MINECRAFT.getSize(); 35 | private final int width = 560; 36 | private final int height = 64 + 3 + 3; 37 | private final int padding = 3; 38 | 39 | @Override 40 | public BufferedImage render(MinecraftServer server, int size) { 41 | BufferedImage texture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); // The texture to return 42 | BufferedImage favicon = getServerFavicon(server); 43 | BufferedImage background = SERVER_BACKGROUND; 44 | 45 | // Create the graphics for drawing 46 | Graphics2D graphics = texture.createGraphics(); 47 | 48 | // Set up the font 49 | graphics.setFont(Fonts.MINECRAFT); 50 | 51 | // Draw the background 52 | for (int backgroundX = 0; backgroundX < width + background.getWidth(); backgroundX += background.getWidth()) { 53 | for (int backgroundY = 0; backgroundY < height + background.getHeight(); backgroundY += background.getHeight()) { 54 | graphics.drawImage(background, backgroundX, backgroundY, null); 55 | } 56 | } 57 | 58 | int y = fontSize + 1; 59 | int x = 64 + 8; 60 | int initialX = x; // Store the initial value of x 61 | 62 | // Draw the favicon 63 | graphics.drawImage(favicon, padding, padding, null); 64 | 65 | // Draw the server hostname 66 | graphics.setColor(Color.WHITE); 67 | graphics.drawString(server.getHostname(), x, y); 68 | 69 | // Draw the server motd 70 | y += fontSize + (padding * 2); 71 | for (String line : server.getMotd().getRaw()) { 72 | int index = 0; 73 | int colorIndex = line.indexOf("§"); 74 | while (colorIndex != -1) { 75 | // Draw text before color code 76 | String textBeforeColor = line.substring(index, colorIndex); 77 | graphics.drawString(textBeforeColor, x, y); 78 | // Calculate width of text before color code 79 | int textWidth = graphics.getFontMetrics().stringWidth(textBeforeColor); 80 | // Move x position to after the drawn text 81 | x += textWidth; 82 | // Set color based on color code 83 | char colorCode = Character.toLowerCase(line.charAt(colorIndex + 1)); 84 | 85 | // Set the color and font style 86 | switch (colorCode) { 87 | case 'l': graphics.setFont(Fonts.MINECRAFT_BOLD); 88 | case 'o': graphics.setFont(Fonts.MINECRAFT_ITALIC); 89 | default: { 90 | try { 91 | graphics.setFont(Fonts.MINECRAFT); 92 | Color color = ColorUtils.getMinecraftColor(colorCode); 93 | graphics.setColor(color); 94 | } catch (Exception ignored) { 95 | // Unknown color, can ignore the error 96 | } 97 | } 98 | } 99 | 100 | // Move index to after the color code 101 | index = colorIndex + 2; 102 | // Find next color code 103 | colorIndex = line.indexOf("§", index); 104 | } 105 | // Draw remaining text 106 | String remainingText = line.substring(index); 107 | graphics.drawString(remainingText, x, y); 108 | // Move to the next line 109 | y += fontSize + padding; 110 | // Reset x position for the next line 111 | x = initialX; // Reset x to its initial value 112 | } 113 | 114 | // Ensure the font is reset 115 | graphics.setFont(Fonts.MINECRAFT); 116 | 117 | // Render the ping 118 | BufferedImage pingIcon = ImageUtils.resize(PING_ICON, 2); 119 | x = width - pingIcon.getWidth() - padding; 120 | graphics.drawImage(pingIcon, x, padding, null); 121 | 122 | // Reset the y position 123 | y = fontSize + 1; 124 | 125 | // Render the player count 126 | MinecraftServer.Players players = server.getPlayers(); 127 | String playersOnline = players.getOnline() + ""; 128 | String playersMax = players.getMax() + ""; 129 | 130 | // Calculate the width of each player count element 131 | int maxWidth = graphics.getFontMetrics().stringWidth(playersMax); 132 | int slashWidth = graphics.getFontMetrics().stringWidth("/"); 133 | int onlineWidth = graphics.getFontMetrics().stringWidth(playersOnline); 134 | 135 | // Calculate the total width of the player count string 136 | int totalWidth = maxWidth + slashWidth + onlineWidth; 137 | 138 | // Calculate the starting x position 139 | int startX = (width - totalWidth) - pingIcon.getWidth() - 6; 140 | 141 | // Render the player count elements 142 | graphics.setColor(Color.LIGHT_GRAY); 143 | graphics.drawString(playersOnline, startX, y); 144 | startX += onlineWidth; 145 | graphics.setColor(Color.DARK_GRAY); 146 | graphics.drawString("/", startX, y); 147 | startX += slashWidth; 148 | graphics.setColor(Color.LIGHT_GRAY); 149 | graphics.drawString(playersMax, startX, y); 150 | 151 | return ImageUtils.resize(texture, (double) size / width); 152 | } 153 | 154 | /** 155 | * Get the favicon of a server. 156 | * 157 | * @param server the server to get the favicon of 158 | * @return the server favicon 159 | */ 160 | public BufferedImage getServerFavicon(MinecraftServer server) { 161 | String favicon = null; 162 | 163 | // Get the server favicon 164 | if (server instanceof JavaMinecraftServer javaServer) { 165 | if (javaServer.getFavicon() != null) { 166 | favicon = javaServer.getFavicon().getBase64(); 167 | } 168 | } 169 | 170 | // Fallback to the default server icon 171 | if (favicon == null) { 172 | favicon = ServerService.DEFAULT_SERVER_ICON; 173 | } 174 | return ImageUtils.base64ToImage(favicon); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/BodyRenderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer.impl.skin; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import lombok.extern.log4j.Log4j2; 6 | import xyz.mcutils.backend.common.ImageUtils; 7 | import xyz.mcutils.backend.common.renderer.SkinRenderer; 8 | import xyz.mcutils.backend.model.skin.ISkinPart; 9 | import xyz.mcutils.backend.model.skin.Skin; 10 | 11 | import java.awt.*; 12 | import java.awt.image.BufferedImage; 13 | 14 | @AllArgsConstructor @Getter @Log4j2(topic = "Skin Renderer/Body") 15 | public class BodyRenderer extends SkinRenderer { 16 | public static final BodyRenderer INSTANCE = new BodyRenderer(); 17 | 18 | @Override 19 | public BufferedImage render(Skin skin, ISkinPart.Custom part, boolean renderOverlays, int size) { 20 | BufferedImage texture = new BufferedImage(16, 32, BufferedImage.TYPE_INT_ARGB); // The texture to return 21 | Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing 22 | 23 | // Get the Vanilla skin parts to draw 24 | BufferedImage face = getVanillaSkinPart(skin, ISkinPart.Vanilla.FACE, -1, renderOverlays); 25 | BufferedImage body = getVanillaSkinPart(skin, ISkinPart.Vanilla.BODY_FRONT, -1, renderOverlays); 26 | BufferedImage leftArm = getVanillaSkinPart(skin, ISkinPart.Vanilla.LEFT_ARM_FRONT, -1, renderOverlays); 27 | BufferedImage rightArm = getVanillaSkinPart(skin, ISkinPart.Vanilla.RIGHT_ARM_FRONT, -1, renderOverlays); 28 | BufferedImage leftLeg = getVanillaSkinPart(skin, ISkinPart.Vanilla.LEFT_LEG_FRONT, -1, renderOverlays); 29 | BufferedImage rightLeg = getVanillaSkinPart(skin, ISkinPart.Vanilla.RIGHT_LEG_FRONT, -1, renderOverlays); 30 | 31 | // Draw the body parts 32 | graphics.drawImage(face, 4, 0, null); 33 | graphics.drawImage(body, 4, 8, null); 34 | graphics.drawImage(leftArm, skin.getModel() == Skin.Model.SLIM ? 1 : 0, 8, null); 35 | graphics.drawImage(rightArm, 12, 8, null); 36 | graphics.drawImage(leftLeg, 8, 20, null); 37 | graphics.drawImage(rightLeg, 4, 20, null); 38 | 39 | graphics.dispose(); 40 | return ImageUtils.resize(texture, (double) size / 32); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/IsometricHeadRenderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer.impl.skin; 2 | 3 | import xyz.mcutils.backend.common.renderer.IsometricSkinRenderer; 4 | import xyz.mcutils.backend.model.skin.ISkinPart; 5 | import xyz.mcutils.backend.model.skin.Skin; 6 | 7 | import java.awt.*; 8 | import java.awt.geom.AffineTransform; 9 | import java.awt.image.BufferedImage; 10 | 11 | public class IsometricHeadRenderer extends IsometricSkinRenderer { 12 | public static final IsometricHeadRenderer INSTANCE = new IsometricHeadRenderer(); 13 | 14 | private static final double SKEW_A = 26D / 45D; // 0.57777777 15 | private static final double SKEW_B = SKEW_A * 2D; // 1.15555555 16 | 17 | private static final AffineTransform HEAD_TOP_TRANSFORM = new AffineTransform(1D, -SKEW_A, 1, SKEW_A, 0, 0); 18 | private static final AffineTransform FACE_TRANSFORM = new AffineTransform(1D, -SKEW_A, 0D, SKEW_B, 0d, SKEW_A); 19 | private static final AffineTransform HEAD_LEFT_TRANSFORM = new AffineTransform(1D, SKEW_A, 0D, SKEW_B, 0D, 0D); 20 | 21 | @Override 22 | public BufferedImage render(Skin skin, ISkinPart.Custom part, boolean renderOverlays, int size) { 23 | double scale = (size / 8D) / 2.5; 24 | double zOffset = scale * 3.5D; 25 | double xOffset = scale * 2D; 26 | 27 | BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); // The texture to return 28 | Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing 29 | 30 | // Get the Vanilla skin parts to draw 31 | BufferedImage headTop = getVanillaSkinPart(skin, ISkinPart.Vanilla.HEAD_TOP, scale, renderOverlays); 32 | BufferedImage face = getVanillaSkinPart(skin, ISkinPart.Vanilla.FACE, scale, renderOverlays); 33 | BufferedImage headLeft = getVanillaSkinPart(skin, ISkinPart.Vanilla.HEAD_LEFT, scale, renderOverlays); 34 | 35 | // Draw the top head part 36 | drawPart(graphics, headTop, HEAD_TOP_TRANSFORM, -0.5 - zOffset, xOffset + zOffset, headTop.getWidth(), headTop.getHeight() + 2); 37 | 38 | // Draw the face part 39 | double x = xOffset + 8 * scale; 40 | drawPart(graphics, face, FACE_TRANSFORM, x, x + zOffset - 0.5, face.getWidth(), face.getHeight()); 41 | 42 | // Draw the left head part 43 | drawPart(graphics, headLeft, HEAD_LEFT_TRANSFORM, xOffset + 1, zOffset - 0.5, headLeft.getWidth(), headLeft.getHeight()); 44 | 45 | graphics.dispose(); 46 | return texture; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/SquareRenderer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.common.renderer.impl.skin; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import lombok.extern.log4j.Log4j2; 6 | import xyz.mcutils.backend.common.renderer.SkinRenderer; 7 | import xyz.mcutils.backend.model.skin.ISkinPart; 8 | import xyz.mcutils.backend.model.skin.Skin; 9 | 10 | import java.awt.*; 11 | import java.awt.image.BufferedImage; 12 | 13 | @AllArgsConstructor @Getter @Log4j2(topic = "Skin Renderer/Square") 14 | public class SquareRenderer extends SkinRenderer { 15 | public static final SquareRenderer INSTANCE = new SquareRenderer(); 16 | 17 | @Override 18 | public BufferedImage render(Skin skin, ISkinPart.Vanilla part, boolean renderOverlays, int size) { 19 | double scale = size / 8D; 20 | BufferedImage partImage = getVanillaSkinPart(skin, part, scale, renderOverlays); // Get the part image 21 | if (!renderOverlays) { // Not rendering overlays 22 | return partImage; 23 | } 24 | // Create a new image, draw our skin part texture, and then apply overlays 25 | BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); // The texture to return 26 | Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing 27 | graphics.drawImage(partImage, 0, 0, null); 28 | 29 | graphics.dispose(); 30 | return texture; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/config/Config.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.config; 2 | 3 | import jakarta.annotation.PostConstruct; 4 | import lombok.Getter; 5 | import lombok.NonNull; 6 | import lombok.extern.log4j.Log4j2; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.boot.web.servlet.FilterRegistrationBean; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | import org.springframework.core.env.Environment; 13 | import org.springframework.web.filter.ShallowEtagHeaderFilter; 14 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 15 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 16 | 17 | @Getter @Log4j2(topic = "Config") 18 | @Configuration 19 | public class Config { 20 | public static Config INSTANCE; 21 | 22 | @Autowired 23 | private Environment environment; 24 | 25 | @Value("${public-url}") 26 | private String webPublicUrl; 27 | 28 | @PostConstruct 29 | public void onInitialize() { 30 | INSTANCE = this; 31 | } 32 | 33 | @Bean 34 | public FilterRegistrationBean shallowEtagHeaderFilter() { 35 | FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); 36 | filterRegistrationBean.addUrlPatterns("/*"); 37 | filterRegistrationBean.setName("etagFilter"); 38 | return filterRegistrationBean; 39 | } 40 | 41 | @Bean 42 | public WebMvcConfigurer configureCors() { 43 | return new WebMvcConfigurer() { 44 | @Override 45 | public void addCorsMappings(@NonNull CorsRegistry registry) { 46 | // Allow all origins to access the API 47 | registry.addMapping("/**") 48 | .allowedOrigins("*") // Allow all origins 49 | .allowedMethods("*") // Allow all methods 50 | .allowedHeaders("*"); // Allow all headers 51 | } 52 | }; 53 | } 54 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/config/MongoConfig.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.config; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.data.mongodb.core.convert.MappingMongoConverter; 6 | import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; 7 | 8 | @Configuration 9 | @EnableMongoRepositories(basePackages = "xyz.mcutils.backend.repository.mongo") 10 | public class MongoConfig { 11 | @Autowired 12 | void setMapKeyDotReplacement(MappingMongoConverter mappingMongoConverter) { 13 | mappingMongoConverter.setMapKeyDotReplacement("-DOT"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/config/OpenAPIConfiguration.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.config; 2 | 3 | import io.swagger.v3.oas.models.OpenAPI; 4 | import io.swagger.v3.oas.models.info.Contact; 5 | import io.swagger.v3.oas.models.info.Info; 6 | import io.swagger.v3.oas.models.info.License; 7 | import io.swagger.v3.oas.models.servers.Server; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.info.BuildProperties; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | 13 | import java.util.List; 14 | 15 | @Configuration 16 | public class OpenAPIConfiguration { 17 | /** 18 | * The build properties of the 19 | * app, null if the app is not built. 20 | */ 21 | private final BuildProperties buildProperties; 22 | 23 | @Autowired 24 | public OpenAPIConfiguration(BuildProperties buildProperties) { 25 | this.buildProperties = buildProperties; 26 | } 27 | 28 | @Bean 29 | public OpenAPI defineOpenAPI() { 30 | Server server = new Server(); 31 | server.setUrl(Config.INSTANCE.getWebPublicUrl()); 32 | 33 | Contact contact = new Contact(); 34 | contact.setName("Liam"); 35 | contact.setEmail("liam@fascinated.cc"); 36 | contact.setUrl("https://fascinated.cc"); 37 | 38 | Info info = new Info(); 39 | info.setTitle("Minecraft Utilities API"); 40 | info.setVersion(buildProperties == null ? "N/A" : buildProperties.getVersion()); 41 | info.setDescription("Wrapper for the Minecraft APIs to make them easier to use."); 42 | info.setContact(contact); 43 | info.setLicense(new License().name("MIT License").url("https://opensource.org/licenses/MIT")); 44 | 45 | return new OpenAPI().servers(List.of(server)).info(info); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/config/RedisConfig.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.config; 2 | 3 | import lombok.NonNull; 4 | import lombok.extern.log4j.Log4j2; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.data.redis.connection.RedisStandaloneConfiguration; 9 | import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; 10 | import org.springframework.data.redis.core.RedisTemplate; 11 | import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; 12 | 13 | /** 14 | * @author Braydon 15 | */ 16 | @Configuration 17 | @Log4j2(topic = "Redis") 18 | @EnableRedisRepositories(basePackages = "xyz.mcutils.backend.repository.redis") 19 | public class RedisConfig { 20 | /** 21 | * The Redis server host. 22 | */ 23 | @Value("${spring.data.redis.host}") 24 | private String host; 25 | 26 | /** 27 | * The Redis server port. 28 | */ 29 | @Value("${spring.data.redis.port}") 30 | private int port; 31 | 32 | /** 33 | * The Redis database index. 34 | */ 35 | @Value("${spring.data.redis.database}") 36 | private int database; 37 | 38 | /** 39 | * The optional Redis password. 40 | */ 41 | @Value("${spring.data.redis.auth}") 42 | private String auth; 43 | 44 | /** 45 | * Build the config to use for Redis. 46 | * 47 | * @return the config 48 | * @see RedisTemplate for config 49 | */ 50 | @Bean @NonNull 51 | public RedisTemplate redisTemplate() { 52 | RedisTemplate template = new RedisTemplate<>(); 53 | template.setConnectionFactory(jedisConnectionFactory()); 54 | return template; 55 | } 56 | 57 | /** 58 | * Build the connection factory to use 59 | * when making connections to Redis. 60 | * 61 | * @return the built factory 62 | * @see JedisConnectionFactory for factory 63 | */ 64 | @Bean @NonNull 65 | public JedisConnectionFactory jedisConnectionFactory() { 66 | log.info("Connecting to Redis at {}:{}/{}", host, port, database); 67 | RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); 68 | config.setDatabase(database); 69 | if (!auth.trim().isEmpty()) { // Auth with our provided password 70 | log.info("Using auth..."); 71 | config.setPassword(auth); 72 | } 73 | return new JedisConnectionFactory(config); 74 | } 75 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/controller/HealthController.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.controller; 2 | 3 | import org.springframework.http.ResponseEntity; 4 | import org.springframework.stereotype.Controller; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | 8 | import java.util.Map; 9 | 10 | @Controller 11 | @RequestMapping(value = "/") 12 | public class HealthController { 13 | 14 | @GetMapping(value = "/health") 15 | public ResponseEntity home() { 16 | return ResponseEntity.ok(Map.of( 17 | "status", "OK" 18 | )); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/controller/HomeController.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.ui.Model; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | import xyz.mcutils.backend.config.Config; 8 | 9 | @Controller 10 | @RequestMapping(value = "/") 11 | public class HomeController { 12 | private final String examplePlayer = "Notch"; 13 | private final String exampleJavaServer = "aetheria.cc"; 14 | private final String exampleBedrockServer = "geo.hivebedrock.network"; 15 | 16 | @GetMapping(value = "/") 17 | public String home(Model model) { 18 | String publicUrl = Config.INSTANCE.getWebPublicUrl(); 19 | 20 | model.addAttribute("public_url", publicUrl); 21 | model.addAttribute("player_example_url", publicUrl + "/player/" + examplePlayer); 22 | model.addAttribute("java_server_example_url", publicUrl + "/server/java/" + exampleJavaServer); 23 | model.addAttribute("bedrock_server_example_url", publicUrl + "/server/bedrock/" + exampleBedrockServer); 24 | model.addAttribute("mojang_endpoint_status_url", publicUrl + "/mojang/status"); 25 | model.addAttribute("swagger_url", publicUrl + "/swagger-ui.html"); 26 | return "index"; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/controller/MojangController.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.controller; 2 | 3 | import io.swagger.v3.oas.annotations.tags.Tag; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.http.CacheControl; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.ResponseBody; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import xyz.mcutils.backend.model.cache.CachedEndpointStatus; 13 | import xyz.mcutils.backend.service.MojangService; 14 | 15 | import java.util.Map; 16 | import java.util.concurrent.TimeUnit; 17 | 18 | @RestController 19 | @RequestMapping(value = "/mojang/", produces = MediaType.APPLICATION_JSON_VALUE) 20 | @Tag(name = "Mojang Controller", description = "The Mojang Controller is used to get information about the Mojang APIs.") 21 | public class MojangController { 22 | @Autowired 23 | private MojangService mojangService; 24 | 25 | @ResponseBody 26 | @GetMapping(value = "/status") 27 | public ResponseEntity getStatus() { 28 | return ResponseEntity.ok() 29 | .cacheControl(CacheControl.maxAge(1, TimeUnit.MINUTES).cachePublic()) 30 | .body(Map.of("endpoints", mojangService.getMojangServerStatus())); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/controller/PlayerController.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.controller; 2 | 3 | import io.swagger.v3.oas.annotations.Parameter; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.http.CacheControl; 7 | import org.springframework.http.HttpHeaders; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.*; 11 | import xyz.mcutils.backend.model.cache.CachedPlayer; 12 | import xyz.mcutils.backend.model.cache.CachedPlayerName; 13 | import xyz.mcutils.backend.model.player.Player; 14 | import xyz.mcutils.backend.service.PlayerService; 15 | 16 | import java.util.concurrent.TimeUnit; 17 | 18 | @RestController 19 | @RequestMapping(value = "/player/") 20 | @Tag(name = "Player Controller", description = "The Player Controller is used to get information about a player.") 21 | public class PlayerController { 22 | 23 | private final PlayerService playerService; 24 | 25 | @Autowired 26 | public PlayerController(PlayerService playerManagerService) { 27 | this.playerService = playerManagerService; 28 | } 29 | 30 | @ResponseBody 31 | @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) 32 | public ResponseEntity getPlayer( 33 | @Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id) { 34 | CachedPlayer player = playerService.getPlayer(id); 35 | 36 | return ResponseEntity.ok() 37 | .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) 38 | .body(player); 39 | } 40 | 41 | @ResponseBody 42 | @GetMapping(value = "/uuid/{id}", produces = MediaType.APPLICATION_JSON_VALUE) 43 | public ResponseEntity getPlayerUuid( 44 | @Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id) { 45 | CachedPlayerName player = playerService.usernameToUuid(id); 46 | 47 | return ResponseEntity.ok() 48 | .cacheControl(CacheControl.maxAge(6, TimeUnit.HOURS).cachePublic()) 49 | .body(player); 50 | } 51 | 52 | @GetMapping(value = "/{part}/{id}") 53 | public ResponseEntity getPlayerHead( 54 | @Parameter(description = "The part of the skin", example = "head") @PathVariable String part, 55 | @Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id, 56 | @Parameter(description = "The size of the image", example = "256") @RequestParam(required = false, defaultValue = "256") int size, 57 | @Parameter(description = "Whether to render the skin overlay (skin layers)", example = "false") @RequestParam(required = false, defaultValue = "false") boolean overlays, 58 | @Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) { 59 | CachedPlayer cachedPlayer = playerService.getPlayer(id); 60 | Player player = cachedPlayer.getPlayer(); 61 | String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png"; 62 | 63 | // Return the part image 64 | return ResponseEntity.ok() 65 | .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) 66 | .contentType(MediaType.IMAGE_PNG) 67 | .header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(player.getUsername())) 68 | .body(playerService.getSkinPart(player, part, overlays, size).getBytes()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/controller/ServerController.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.controller; 2 | 3 | import io.swagger.v3.oas.annotations.Parameter; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.http.CacheControl; 7 | import org.springframework.http.HttpHeaders; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.*; 11 | import xyz.mcutils.backend.model.cache.CachedMinecraftServer; 12 | import xyz.mcutils.backend.service.MojangService; 13 | import xyz.mcutils.backend.service.ServerService; 14 | 15 | import java.util.Map; 16 | import java.util.concurrent.TimeUnit; 17 | 18 | @RestController 19 | @RequestMapping(value = "/server/") 20 | @Tag(name = "Server Controller", description = "The Server Controller is used to get information about a server.") 21 | public class ServerController { 22 | 23 | private final ServerService serverService; 24 | private final MojangService mojangService; 25 | 26 | @Autowired 27 | public ServerController(ServerService serverService, MojangService mojangService) { 28 | this.serverService = serverService; 29 | this.mojangService = mojangService; 30 | } 31 | 32 | @ResponseBody 33 | @GetMapping(value = "/{platform}/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE) 34 | public ResponseEntity getServer( 35 | @Parameter(description = "The platform of the server", example = "java") @PathVariable String platform, 36 | @Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname) { 37 | CachedMinecraftServer server = serverService.getServer(platform, hostname); 38 | 39 | return ResponseEntity.ok() 40 | .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) 41 | .body(server); 42 | } 43 | 44 | @ResponseBody 45 | @GetMapping(value = "/icon/{hostname}", produces = MediaType.IMAGE_PNG_VALUE) 46 | public ResponseEntity getServerIcon( 47 | @Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname, 48 | @Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) { 49 | String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png"; 50 | byte[] favicon = serverService.getServerFavicon(hostname); 51 | 52 | return ResponseEntity.ok() 53 | .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) 54 | .contentType(MediaType.IMAGE_PNG) 55 | .header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(hostname)) 56 | .body(favicon); 57 | } 58 | 59 | @ResponseBody 60 | @GetMapping(value = "/{platform}/preview/{hostname}", produces = MediaType.IMAGE_PNG_VALUE) 61 | public ResponseEntity getServerPreview( 62 | @Parameter(description = "The platform of the server", example = "java") @PathVariable String platform, 63 | @Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname, 64 | @Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download, 65 | @Parameter(description = "The size of the image", example = "1024") @RequestParam(required = false, defaultValue = "1024") int size) { 66 | String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png"; 67 | CachedMinecraftServer server = serverService.getServer(platform, hostname); 68 | 69 | return ResponseEntity.ok() 70 | .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) 71 | .contentType(MediaType.IMAGE_PNG) 72 | .header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(hostname)) 73 | .body(serverService.getServerPreview(server, platform, size)); 74 | } 75 | 76 | @ResponseBody 77 | @GetMapping(value = "/blocked/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE) 78 | public ResponseEntity getServerBlockedStatus( 79 | @Parameter(description = "The hostname of the server", example = "aetheria.cc") @PathVariable String hostname) { 80 | return ResponseEntity.ok() 81 | .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) 82 | .body(Map.of( 83 | "blocked", mojangService.isServerBlocked(hostname) 84 | )); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/exception/ExceptionControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.exception; 2 | 3 | import io.micrometer.common.lang.NonNull; 4 | import io.sentry.Sentry; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.ControllerAdvice; 8 | import org.springframework.web.bind.annotation.ExceptionHandler; 9 | import org.springframework.web.bind.annotation.ResponseStatus; 10 | import org.springframework.web.servlet.resource.NoResourceFoundException; 11 | import xyz.mcutils.backend.model.response.ErrorResponse; 12 | 13 | @ControllerAdvice 14 | public final class ExceptionControllerAdvice { 15 | 16 | /** 17 | * Handle a raised exception. 18 | * 19 | * @param ex the raised exception 20 | * @return the error response 21 | */ 22 | @ExceptionHandler(Exception.class) 23 | public ResponseEntity handleException(@NonNull Exception ex) { 24 | HttpStatus status = null; // Get the HTTP status 25 | if (ex instanceof NoResourceFoundException) { // Not found 26 | status = HttpStatus.NOT_FOUND; 27 | } else if (ex instanceof UnsupportedOperationException) { // Not implemented 28 | status = HttpStatus.NOT_IMPLEMENTED; 29 | } 30 | if (ex.getClass().isAnnotationPresent(ResponseStatus.class)) { // Get from the @ResponseStatus annotation 31 | status = ex.getClass().getAnnotation(ResponseStatus.class).value(); 32 | } 33 | String message = ex.getLocalizedMessage(); // Get the error message 34 | if (message == null) { // Fallback 35 | message = "An internal error has occurred."; 36 | } 37 | // Print the stack trace if no response status is present 38 | if (status == null) { 39 | ex.printStackTrace(); 40 | } 41 | if (status == null) { // Fallback to 500 42 | status = HttpStatus.INTERNAL_SERVER_ERROR; 43 | Sentry.captureException(ex); // Capture the exception with Sentry 44 | } 45 | return new ResponseEntity<>(new ErrorResponse(status, message), status); 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/exception/impl/BadRequestException.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.exception.impl; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.BAD_REQUEST) 7 | public class BadRequestException extends RuntimeException { 8 | 9 | public BadRequestException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/exception/impl/InternalServerErrorException.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.exception.impl; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 7 | public class InternalServerErrorException extends RuntimeException { 8 | 9 | public InternalServerErrorException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/exception/impl/MojangAPIRateLimitException.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.exception.impl; 2 | 3 | 4 | public class MojangAPIRateLimitException extends RateLimitException { 5 | 6 | public MojangAPIRateLimitException() { 7 | super("Mojang API rate limit exceeded. Please try again later."); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/exception/impl/RateLimitException.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.exception.impl; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) 7 | public class RateLimitException extends RuntimeException { 8 | 9 | public RateLimitException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/exception/impl/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.exception.impl; 2 | 3 | import lombok.experimental.StandardException; 4 | import org.springframework.http.HttpStatus; 5 | import org.springframework.web.bind.annotation.ResponseStatus; 6 | 7 | @StandardException 8 | @ResponseStatus(HttpStatus.NOT_FOUND) 9 | public class ResourceNotFoundException extends RuntimeException { } 10 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/log/TransactionLogger.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.log; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import lombok.NonNull; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.core.MethodParameter; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.http.converter.HttpMessageConverter; 10 | import org.springframework.http.server.ServerHttpRequest; 11 | import org.springframework.http.server.ServerHttpResponse; 12 | import org.springframework.http.server.ServletServerHttpRequest; 13 | import org.springframework.web.bind.annotation.ControllerAdvice; 14 | import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; 15 | import xyz.mcutils.backend.common.IPUtils; 16 | import xyz.mcutils.backend.service.MetricService; 17 | import xyz.mcutils.backend.service.metric.metrics.RequestsPerRouteMetric; 18 | import xyz.mcutils.backend.service.metric.metrics.TotalRequestsMetric; 19 | 20 | import java.util.Arrays; 21 | import java.util.HashMap; 22 | import java.util.Map; 23 | import java.util.Map.Entry; 24 | 25 | @ControllerAdvice 26 | @Slf4j(topic = "Req Transaction") 27 | public class TransactionLogger implements ResponseBodyAdvice { 28 | 29 | @Autowired 30 | private MetricService metricService; 31 | 32 | @Override 33 | public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType, 34 | @NonNull Class> selectedConverterType, @NonNull ServerHttpRequest rawRequest, 35 | @NonNull ServerHttpResponse rawResponse) { 36 | HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest(); 37 | 38 | // Get the request ip ip 39 | String ip = IPUtils.getRealIp(request); 40 | 41 | // Getting params 42 | Map params = new HashMap<>(); 43 | for (Entry entry : request.getParameterMap().entrySet()) { 44 | params.put(entry.getKey(), Arrays.toString(entry.getValue())); 45 | } 46 | 47 | // Logging the request 48 | log.info(String.format("[Req] %s | %s | '%s', params=%s", 49 | request.getMethod(), 50 | ip, 51 | request.getRequestURI(), 52 | params 53 | )); 54 | 55 | // Increment the metric 56 | ((TotalRequestsMetric) metricService.getMetric(TotalRequestsMetric.class)).increment(); 57 | ((RequestsPerRouteMetric) metricService.getMetric(RequestsPerRouteMetric.class)).increment(request.getRequestURI()); 58 | return body; 59 | } 60 | 61 | @Override 62 | public boolean supports(@NonNull MethodParameter returnType, @NonNull Class> converterType) { 63 | return true; 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/cache/CachedEndpointStatus.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.cache; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NonNull; 7 | import lombok.Setter; 8 | import org.springframework.data.annotation.Id; 9 | import org.springframework.data.redis.core.RedisHash; 10 | import xyz.mcutils.backend.common.CachedResponse; 11 | import xyz.mcutils.backend.common.MojangServer; 12 | 13 | import java.io.Serializable; 14 | import java.util.ArrayList; 15 | import java.util.HashMap; 16 | import java.util.List; 17 | import java.util.Map; 18 | 19 | @Setter @Getter @EqualsAndHashCode(callSuper = false) 20 | @RedisHash(value = "mojangEndpointStatus", timeToLive = 60L) // 1 minute (in seconds) 21 | public class CachedEndpointStatus extends CachedResponse implements Serializable { 22 | 23 | /** 24 | * The id for this endpoint cache. 25 | */ 26 | @Id @NonNull @JsonIgnore 27 | private final String id; 28 | 29 | /** 30 | * The endpoint cache. 31 | */ 32 | private final List> endpoints; 33 | 34 | public CachedEndpointStatus(@NonNull String id, Map mojangServers) { 35 | super(Cache.defaultCache()); 36 | this.id = id; 37 | this.endpoints = new ArrayList<>(); 38 | 39 | for (Map.Entry entry : mojangServers.entrySet()) { 40 | MojangServer server = entry.getKey(); 41 | 42 | Map serverStatus = new HashMap<>(); 43 | serverStatus.put("name", server.getName()); 44 | serverStatus.put("endpoint", server.getEndpoint()); 45 | serverStatus.put("status", entry.getValue().name()); 46 | endpoints.add(serverStatus); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/cache/CachedMinecraftServer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.cache; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.fasterxml.jackson.annotation.JsonUnwrapped; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.NonNull; 8 | import lombok.Setter; 9 | import org.springframework.data.annotation.Id; 10 | import org.springframework.data.redis.core.RedisHash; 11 | import xyz.mcutils.backend.common.CachedResponse; 12 | import xyz.mcutils.backend.model.server.MinecraftServer; 13 | 14 | import java.io.Serializable; 15 | 16 | /** 17 | * @author Braydon 18 | */ 19 | @Setter @Getter @EqualsAndHashCode(callSuper = false) 20 | @RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds) 21 | public class CachedMinecraftServer extends CachedResponse implements Serializable { 22 | /** 23 | * The id of this cached server. 24 | */ 25 | @Id @NonNull @JsonIgnore 26 | private String id; 27 | 28 | /** 29 | * The cached server. 30 | */ 31 | @NonNull @JsonUnwrapped 32 | private MinecraftServer server; 33 | 34 | public CachedMinecraftServer(@NonNull String id, @NonNull MinecraftServer server) { 35 | super(CachedResponse.Cache.defaultCache()); 36 | this.id = id; 37 | this.server = server; 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/cache/CachedPlayer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.cache; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.fasterxml.jackson.annotation.JsonUnwrapped; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | import org.springframework.data.annotation.Id; 9 | import org.springframework.data.redis.core.RedisHash; 10 | import xyz.mcutils.backend.common.CachedResponse; 11 | import xyz.mcutils.backend.model.player.Player; 12 | 13 | import java.io.Serializable; 14 | import java.util.UUID; 15 | 16 | /** 17 | * A cacheable {@link Player}. 18 | * 19 | * @author Braydon 20 | */ 21 | @Setter @Getter @EqualsAndHashCode(callSuper = false) 22 | @RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds) 23 | public class CachedPlayer extends CachedResponse implements Serializable { 24 | /** 25 | * The unique id of the player. 26 | */ 27 | @JsonIgnore 28 | @Id private UUID uniqueId; 29 | 30 | /** 31 | * The player to cache. 32 | */ 33 | @JsonUnwrapped 34 | private Player player; 35 | 36 | public CachedPlayer(UUID uniqueId, Player player) { 37 | super(Cache.defaultCache()); 38 | this.uniqueId = uniqueId; 39 | this.player = player; 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/cache/CachedPlayerName.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.cache; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.redis.core.RedisHash; 9 | import xyz.mcutils.backend.common.CachedResponse; 10 | 11 | import java.util.UUID; 12 | 13 | /** 14 | * @author Braydon 15 | */ 16 | @Setter 17 | @Getter @EqualsAndHashCode(callSuper = false) 18 | @RedisHash(value = "playerName", timeToLive = 60L * 60L * 6) // 6 hours (in seconds) 19 | public class CachedPlayerName extends CachedResponse { 20 | /** 21 | * The id of the player. 22 | */ 23 | @JsonIgnore 24 | @Id private final String id; 25 | 26 | /** 27 | * The username of the player. 28 | */ 29 | private final String username; 30 | 31 | /** 32 | * The unique id of the player. 33 | */ 34 | private final UUID uniqueId; 35 | 36 | public CachedPlayerName(String id, String username, UUID uniqueId) { 37 | super(Cache.defaultCache()); 38 | this.id = id; 39 | this.username = username; 40 | this.uniqueId = uniqueId; 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/cache/CachedPlayerSkinPart.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.cache; 2 | 3 | import lombok.*; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.redis.core.RedisHash; 6 | 7 | @AllArgsConstructor 8 | @Setter @Getter @EqualsAndHashCode 9 | @RedisHash(value = "playerSkinPart", timeToLive = 60L * 60L) // 1 hour (in seconds) 10 | public class CachedPlayerSkinPart { 11 | 12 | /** 13 | * The ID of the skin part 14 | */ 15 | @Id @NonNull private String id; 16 | 17 | /** 18 | * The skin part bytes 19 | */ 20 | private byte[] bytes; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/cache/CachedServerPreview.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.cache; 2 | 3 | import lombok.*; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.redis.core.RedisHash; 6 | 7 | @AllArgsConstructor 8 | @Setter @Getter @EqualsAndHashCode 9 | @RedisHash(value = "serverPreview", timeToLive = 60L * 5) // 5 minutes (in seconds) 10 | public class CachedServerPreview { 11 | 12 | /** 13 | * The ID of the server preview 14 | */ 15 | @Id @NonNull private String id; 16 | 17 | /** 18 | * The server preview bytes 19 | */ 20 | private byte[] bytes; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/dns/DNSRecord.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.dns; 2 | 3 | import io.micrometer.common.lang.NonNull; 4 | import lombok.*; 5 | 6 | @NoArgsConstructor @AllArgsConstructor 7 | @Setter @Getter @EqualsAndHashCode 8 | public abstract class DNSRecord { 9 | /** 10 | * The type of this record. 11 | */ 12 | @NonNull 13 | private Type type; 14 | 15 | /** 16 | * The TTL (Time To Live) of this record. 17 | */ 18 | private long ttl; 19 | 20 | /** 21 | * Types of a record. 22 | */ 23 | public enum Type { 24 | A, SRV 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/dns/impl/ARecord.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.dns.impl; 2 | 3 | import io.micrometer.common.lang.NonNull; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | import lombok.Setter; 7 | import xyz.mcutils.backend.model.dns.DNSRecord; 8 | 9 | import java.net.InetAddress; 10 | 11 | @Setter @Getter 12 | @NoArgsConstructor 13 | public final class ARecord extends DNSRecord { 14 | /** 15 | * The address of this record, null if unresolved. 16 | */ 17 | private String address; 18 | 19 | public ARecord(@NonNull org.xbill.DNS.ARecord bootstrap) { 20 | super(Type.A, bootstrap.getTTL()); 21 | InetAddress address = bootstrap.getAddress(); 22 | this.address = address == null ? null : address.getHostAddress(); 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/dns/impl/SRVRecord.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.dns.impl; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import io.micrometer.common.lang.NonNull; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | import xyz.mcutils.backend.model.dns.DNSRecord; 9 | 10 | import java.net.InetSocketAddress; 11 | 12 | @Setter @Getter 13 | @NoArgsConstructor 14 | public final class SRVRecord extends DNSRecord { 15 | /** 16 | * The priority of this record. 17 | */ 18 | private int priority; 19 | 20 | /** 21 | * The weight of this record. 22 | */ 23 | private int weight; 24 | 25 | /** 26 | * The port of this record. 27 | */ 28 | private int port; 29 | 30 | /** 31 | * The target of this record. 32 | */ 33 | @NonNull private String target; 34 | 35 | public SRVRecord(@NonNull org.xbill.DNS.SRVRecord bootstrap) { 36 | super(Type.SRV, bootstrap.getTTL()); 37 | priority = bootstrap.getPriority(); 38 | weight = bootstrap.getWeight(); 39 | port = bootstrap.getPort(); 40 | target = bootstrap.getTarget().toString().replaceFirst("\\.$", ""); 41 | } 42 | 43 | /** 44 | * Get a socket address from 45 | * the target and port. 46 | * 47 | * @return the socket address 48 | */ 49 | @NonNull @JsonIgnore 50 | public InetSocketAddress getSocketAddress() { 51 | return new InetSocketAddress(target, port); 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/metric/WebsocketMetrics.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.metric; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | import java.util.Map; 7 | 8 | @AllArgsConstructor 9 | @Getter 10 | public class WebsocketMetrics { 11 | /** 12 | * The metrics to send to the client. 13 | */ 14 | private final Map metrics; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/mojang/EndpointStatus.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.mojang; 2 | 3 | import lombok.EqualsAndHashCode; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.Setter; 7 | 8 | @RequiredArgsConstructor 9 | @Getter @Setter @EqualsAndHashCode 10 | public class EndpointStatus { 11 | 12 | /** 13 | * The name of the service. 14 | */ 15 | private final String name; 16 | 17 | /** 18 | * The hostname of the service. 19 | */ 20 | private final String hostname; 21 | 22 | /** 23 | * The status of the service. 24 | */ 25 | private Status status; 26 | 27 | /** 28 | * Statuses for the endpoint. 29 | */ 30 | public enum Status { 31 | /** 32 | * The service is online and operational. 33 | */ 34 | ONLINE, 35 | 36 | /** 37 | * The service is online, but may be experiencing issues. 38 | * This could be due to high load or other issues. 39 | */ 40 | DEGRADED, 41 | 42 | /** 43 | * The service is offline and not operational. 44 | */ 45 | OFFLINE 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/player/Cape.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.player; 2 | 3 | import com.google.gson.JsonObject; 4 | import lombok.AllArgsConstructor; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | 8 | @AllArgsConstructor 9 | @Getter @EqualsAndHashCode 10 | public class Cape { 11 | 12 | /** 13 | * The URL of the cape 14 | */ 15 | private final String url; 16 | 17 | /** 18 | * Gets the cape from a {@link JsonObject}. 19 | * 20 | * @param json the JSON object 21 | * @return the cape 22 | */ 23 | public static Cape fromJson(JsonObject json) { 24 | if (json == null) { 25 | return null; 26 | } 27 | return new Cape(json.get("url").getAsString()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/player/Player.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.player; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import xyz.mcutils.backend.common.Tuple; 8 | import xyz.mcutils.backend.common.UUIDUtils; 9 | import xyz.mcutils.backend.model.skin.Skin; 10 | import xyz.mcutils.backend.model.token.MojangProfileToken; 11 | 12 | import java.util.UUID; 13 | 14 | @AllArgsConstructor @NoArgsConstructor 15 | @Getter @EqualsAndHashCode 16 | public class Player { 17 | 18 | /** 19 | * The UUID of the player 20 | */ 21 | private UUID uniqueId; 22 | 23 | /** 24 | * The trimmed UUID of the player 25 | */ 26 | private String trimmedUniqueId; 27 | 28 | /** 29 | * The username of the player 30 | */ 31 | private String username; 32 | 33 | /** 34 | * The skin of the player, null if the 35 | * player does not have a skin 36 | */ 37 | private Skin skin; 38 | 39 | /** 40 | * The cape of the player, null if the 41 | * player does not have a cape 42 | */ 43 | private Cape cape; 44 | 45 | /** 46 | * The raw properties of the player 47 | */ 48 | private MojangProfileToken.ProfileProperty[] rawProperties; 49 | 50 | public Player(MojangProfileToken profile) { 51 | this.uniqueId = UUIDUtils.addDashes(profile.getId()); 52 | this.trimmedUniqueId = UUIDUtils.removeDashes(this.uniqueId); 53 | this.username = profile.getName(); 54 | this.rawProperties = profile.getProperties(); 55 | 56 | // Get the skin and cape 57 | Tuple skinAndCape = profile.getSkinAndCape(); 58 | if (skinAndCape != null) { 59 | this.skin = skinAndCape.getLeft(); 60 | this.cape = skinAndCape.getRight(); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/response/ErrorResponse.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.response; 2 | 3 | import io.micrometer.common.lang.NonNull; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.ToString; 7 | import org.springframework.http.HttpStatus; 8 | 9 | import java.util.Date; 10 | 11 | @Getter @ToString @EqualsAndHashCode 12 | public class ErrorResponse { 13 | /** 14 | * The status code of this error. 15 | */ 16 | @NonNull 17 | private final HttpStatus status; 18 | 19 | /** 20 | * The HTTP code of this error. 21 | */ 22 | private final int code; 23 | 24 | /** 25 | * The message of this error. 26 | */ 27 | @NonNull private final String message; 28 | 29 | /** 30 | * The timestamp this error occurred. 31 | */ 32 | @NonNull private final Date timestamp; 33 | 34 | public ErrorResponse(@NonNull HttpStatus status, @NonNull String message) { 35 | this.status = status; 36 | code = status.value(); 37 | this.message = message; 38 | timestamp = new Date(); 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/server/BedrockMinecraftServer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.server; 2 | 3 | import lombok.*; 4 | import xyz.mcutils.backend.model.dns.DNSRecord; 5 | 6 | /** 7 | * A Bedrock edition {@link MinecraftServer}. 8 | * 9 | * @author Braydon 10 | */ 11 | @Getter @ToString(callSuper = true) @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true) 12 | public final class BedrockMinecraftServer extends MinecraftServer { 13 | /** 14 | * The unique ID of this server. 15 | */ 16 | @EqualsAndHashCode.Include @NonNull private final String id; 17 | 18 | /** 19 | * The edition of this server. 20 | */ 21 | @NonNull private final Edition edition; 22 | 23 | /** 24 | * The version information of this server. 25 | */ 26 | @NonNull private final Version version; 27 | 28 | /** 29 | * The gamemode of this server. 30 | */ 31 | @NonNull private final GameMode gamemode; 32 | 33 | private BedrockMinecraftServer(@NonNull String id, @NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records, 34 | @NonNull Edition edition, @NonNull Version version, @NonNull Players players, @NonNull MOTD motd, 35 | @NonNull GameMode gamemode, GeoLocation location) { 36 | super(hostname, ip, port, records, motd, players, location); 37 | this.id = id; 38 | this.edition = edition; 39 | this.version = version; 40 | this.gamemode = gamemode; 41 | } 42 | 43 | /** 44 | * Create a new Bedrock Minecraft server. 45 | *

46 | * Token Format 47 | *

48 | * 49 | * @param hostname the hostname of the server 50 | * @param ip the IP address of the server 51 | * @param port the port of the server 52 | * @param token the status token 53 | * @return the Bedrock Minecraft server 54 | */ 55 | @NonNull 56 | public static BedrockMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, GeoLocation location, @NonNull String token) { 57 | String[] split = token.split(";"); // Split the token 58 | Edition edition = Edition.valueOf(split[0]); 59 | Version version = new Version(Integer.parseInt(split[2]), split[3]); 60 | Players players = new Players(Integer.parseInt(split[4]), Integer.parseInt(split[5]), null); 61 | MOTD motd = MOTD.create(hostname, Platform.BEDROCK, split[1] + "\n" + split[7]); 62 | GameMode gameMode = new GameMode(split[8], split.length > 9 ? Integer.parseInt(split[9]) : -1); 63 | return new BedrockMinecraftServer( 64 | split[6], 65 | hostname, 66 | ip, 67 | port, 68 | records, 69 | edition, 70 | version, 71 | players, 72 | motd, 73 | gameMode, 74 | location 75 | ); 76 | } 77 | 78 | /** 79 | * The edition of a Bedrock server. 80 | */ 81 | @AllArgsConstructor @Getter 82 | public enum Edition { 83 | /** 84 | * Minecraft: Pocket Edition. 85 | */ 86 | MCPE, 87 | 88 | /** 89 | * Minecraft: Education Edition. 90 | */ 91 | MCEE 92 | } 93 | 94 | /** 95 | * Version information for a server. 96 | */ 97 | @AllArgsConstructor @Getter @ToString 98 | public static class Version { 99 | /** 100 | * The protocol version of the server. 101 | */ 102 | private final int protocol; 103 | 104 | /** 105 | * The version name of the server. 106 | */ 107 | @NonNull private final String name; 108 | } 109 | 110 | /** 111 | * The gamemode of a server. 112 | */ 113 | @AllArgsConstructor @Getter @ToString 114 | public static class GameMode { 115 | /** 116 | * The name of this gamemode. 117 | */ 118 | @NonNull private final String name; 119 | 120 | /** 121 | * The numeric of this gamemode. 122 | */ 123 | private final int numericId; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/server/MinecraftServer.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.server; 2 | 3 | import com.maxmind.geoip2.model.CityResponse; 4 | import io.micrometer.common.lang.NonNull; 5 | import lombok.*; 6 | import xyz.mcutils.backend.common.ColorUtils; 7 | import xyz.mcutils.backend.config.Config; 8 | import xyz.mcutils.backend.model.dns.DNSRecord; 9 | import xyz.mcutils.backend.service.pinger.MinecraftServerPinger; 10 | import xyz.mcutils.backend.service.pinger.impl.BedrockMinecraftServerPinger; 11 | import xyz.mcutils.backend.service.pinger.impl.JavaMinecraftServerPinger; 12 | 13 | import java.util.Arrays; 14 | import java.util.UUID; 15 | 16 | /** 17 | * @author Braydon 18 | */ 19 | @AllArgsConstructor 20 | @Getter @Setter @EqualsAndHashCode 21 | public class MinecraftServer { 22 | 23 | /** 24 | * The hostname of the server. 25 | */ 26 | private final String hostname; 27 | 28 | /** 29 | * The IP address of the server. 30 | */ 31 | private final String ip; 32 | 33 | /** 34 | * The port of the server. 35 | */ 36 | private final int port; 37 | 38 | /** 39 | * The DNS records for the server. 40 | */ 41 | private final DNSRecord[] records; 42 | 43 | /** 44 | * The motd for the server. 45 | */ 46 | private final MOTD motd; 47 | 48 | /** 49 | * The players on the server. 50 | */ 51 | private final Players players; 52 | 53 | /** 54 | * The location of the server. 55 | */ 56 | private final GeoLocation location; 57 | 58 | /** 59 | * A platform a Minecraft 60 | * server can operate on. 61 | */ 62 | @AllArgsConstructor @Getter 63 | public enum Platform { 64 | /** 65 | * The Java edition of Minecraft. 66 | */ 67 | JAVA(new JavaMinecraftServerPinger(), 25565), 68 | 69 | /** 70 | * The Bedrock edition of Minecraft. 71 | */ 72 | BEDROCK(new BedrockMinecraftServerPinger(), 19132); 73 | 74 | /** 75 | * The server pinger for this platform. 76 | */ 77 | @NonNull 78 | private final MinecraftServerPinger pinger; 79 | 80 | /** 81 | * The default server port for this platform. 82 | */ 83 | private final int defaultPort; 84 | } 85 | 86 | @AllArgsConstructor @Getter 87 | public static class MOTD { 88 | 89 | /** 90 | * The raw motd lines 91 | */ 92 | private final String[] raw; 93 | 94 | /** 95 | * The clean motd lines 96 | */ 97 | private final String[] clean; 98 | 99 | /** 100 | * The html motd lines 101 | */ 102 | private final String[] html; 103 | 104 | /** 105 | * The URL to the server preview image. 106 | */ 107 | private final String preview; 108 | 109 | /** 110 | * Create a new MOTD from a raw string. 111 | * 112 | * @param raw the raw motd string 113 | * @return the new motd 114 | */ 115 | @NonNull 116 | public static MOTD create(@NonNull String hostname, @NonNull Platform platform, @NonNull String raw) { 117 | String[] rawLines = raw.split("\n"); // The raw lines 118 | return new MOTD( 119 | rawLines, 120 | Arrays.stream(rawLines).map(ColorUtils::stripColor).toArray(String[]::new), 121 | Arrays.stream(rawLines).map(ColorUtils::toHTML).toArray(String[]::new), 122 | Config.INSTANCE.getWebPublicUrl() + "/server/%s/preview/%s".formatted( 123 | platform.name().toLowerCase(),hostname) 124 | ); 125 | } 126 | } 127 | 128 | /** 129 | * Player count data for a server. 130 | */ 131 | @AllArgsConstructor @Getter 132 | public static class Players { 133 | /** 134 | * The online players on this server. 135 | */ 136 | private final int online; 137 | 138 | /** 139 | * The maximum allowed players on this server. 140 | */ 141 | private final int max; 142 | 143 | /** 144 | * A sample of players on this server, null or empty if no sample. 145 | */ 146 | private final Sample[] sample; 147 | 148 | /** 149 | * A sample player. 150 | */ 151 | @AllArgsConstructor @Getter @ToString 152 | public static class Sample { 153 | /** 154 | * The unique id of this player. 155 | */ 156 | @NonNull private final UUID id; 157 | 158 | /** 159 | * The name of this player. 160 | */ 161 | @NonNull private final String name; 162 | } 163 | } 164 | 165 | /** 166 | * The location of the server. 167 | */ 168 | @AllArgsConstructor @Getter 169 | public static class GeoLocation { 170 | /** 171 | * The country of the server. 172 | */ 173 | private final String country; 174 | 175 | /** 176 | * The region of the server. 177 | */ 178 | private final String region; 179 | 180 | /** 181 | * The city of the server. 182 | */ 183 | private final String city; 184 | 185 | /** 186 | * The latitude of the server. 187 | */ 188 | private final double latitude; 189 | 190 | /** 191 | * The longitude of the server. 192 | */ 193 | private final double longitude; 194 | 195 | /** 196 | * Gets the location of the server from Maxmind. 197 | * 198 | * @param response the response from Maxmind 199 | * @return the location of the server 200 | */ 201 | public static GeoLocation fromMaxMind(CityResponse response) { 202 | if (response == null) { 203 | return null; 204 | } 205 | return new GeoLocation( 206 | response.getCountry().getName(), 207 | response.getMostSpecificSubdivision().getName(), 208 | response.getCity().getName(), 209 | response.getLocation().getLatitude(), 210 | response.getLocation().getLongitude() 211 | ); 212 | } 213 | } 214 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/skin/ISkinPart.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.skin; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import xyz.mcutils.backend.common.renderer.SkinRenderer; 6 | import xyz.mcutils.backend.common.renderer.impl.skin.BodyRenderer; 7 | import xyz.mcutils.backend.common.renderer.impl.skin.IsometricHeadRenderer; 8 | import xyz.mcutils.backend.common.renderer.impl.skin.SquareRenderer; 9 | 10 | import java.awt.image.BufferedImage; 11 | 12 | public interface ISkinPart { 13 | Enum[][] TYPES = { Vanilla.values(), Custom.values() }; 14 | 15 | /** 16 | * The name of the part. 17 | * 18 | * @return the part name 19 | */ 20 | String name(); 21 | 22 | /** 23 | * Should this part be hidden from the 24 | * player skin part urls list? 25 | * 26 | * @return whether this part should be hidden 27 | */ 28 | boolean hidden(); 29 | 30 | /** 31 | * Renders the skin part for the skin. 32 | * 33 | * @param skin the skin 34 | * @param renderOverlays should the overlays be rendered 35 | * @param size the size of the part 36 | * @return the rendered skin part 37 | */ 38 | BufferedImage render(Skin skin, boolean renderOverlays, int size); 39 | 40 | /** 41 | * Get a skin part by the given name. 42 | * 43 | * @param name the name of the part 44 | * @return the part, null if none 45 | */ 46 | static ISkinPart getByName(String name) { 47 | name = name.toUpperCase(); 48 | for (Enum[] type : TYPES) { 49 | for (Enum part : type) { 50 | if (!part.name().equals(name)) { 51 | continue; 52 | } 53 | return (ISkinPart) part; 54 | } 55 | } 56 | return null; 57 | } 58 | 59 | /** 60 | * The vanilla skin parts. 61 | *

62 | * Skin Format 63 | *

64 | */ 65 | @Getter 66 | enum Vanilla implements ISkinPart { 67 | // Overlays 68 | HEAD_OVERLAY_TOP(true, new Coordinates(40, 0), 8, 8), 69 | HEAD_OVERLAY_FACE(true, new Coordinates(40, 8), 8, 8), 70 | HEAD_OVERLAY_LEFT(true, new Coordinates(48, 8), 8, 8), 71 | 72 | // Head 73 | HEAD_TOP(true, new Coordinates(8, 0), 8, 8, HEAD_OVERLAY_TOP), 74 | FACE(false, new Coordinates(8, 8), 8, 8, HEAD_OVERLAY_FACE), 75 | HEAD_LEFT(true, new Coordinates(0, 8), 8, 8, HEAD_OVERLAY_LEFT), 76 | HEAD_RIGHT(true, new Coordinates(16, 8), 8, 8), 77 | HEAD_BOTTOM(true, new Coordinates(16, 0), 8, 8), 78 | HEAD_BACK(true, new Coordinates(24, 8), 8, 8), 79 | 80 | // Body 81 | BODY_FRONT(true, new Coordinates(20, 20), 8, 12), 82 | 83 | // Arms 84 | LEFT_ARM_TOP(true, new Coordinates(36, 48), 4, 4), 85 | RIGHT_ARM_TOP(true, new Coordinates(44, 16), 4, 4), 86 | 87 | LEFT_ARM_FRONT(true, new Coordinates(44, 20), 4, 12), 88 | RIGHT_ARM_FRONT(true, new Coordinates(36, 52), new LegacyCoordinates(44, 20, true), 4, 12), 89 | 90 | // Legs 91 | LEFT_LEG_FRONT(true, new Coordinates(4, 20), 4, 12), // Front 92 | RIGHT_LEG_FRONT(true, new Coordinates(20, 52), new LegacyCoordinates(4, 20, true), 4, 12); // Front 93 | 94 | /** 95 | * Should this part be hidden from the 96 | * player skin part urls list? 97 | */ 98 | private final boolean hidden; 99 | 100 | /** 101 | * The coordinates of the part. 102 | */ 103 | private final Coordinates coordinates; 104 | 105 | /** 106 | * The legacy coordinates of the part. 107 | */ 108 | private final LegacyCoordinates legacyCoordinates; 109 | 110 | /** 111 | * The width and height of the part. 112 | */ 113 | private final int width, height; 114 | 115 | /** 116 | * The overlays of the part. 117 | */ 118 | private final Vanilla[] overlays; 119 | 120 | Vanilla(boolean hidden, Coordinates coordinates, int width, int height, Vanilla... overlays) { 121 | this(hidden, coordinates, null, width, height, overlays); 122 | } 123 | 124 | Vanilla(boolean hidden, Coordinates coordinates, LegacyCoordinates legacyCoordinates, int width, int height, Vanilla... overlays) { 125 | this.hidden = hidden; 126 | this.coordinates = coordinates; 127 | this.legacyCoordinates = legacyCoordinates; 128 | this.width = width; 129 | this.height = height; 130 | this.overlays = overlays; 131 | } 132 | 133 | @Override 134 | public boolean hidden() { 135 | return this.isHidden(); 136 | } 137 | 138 | @Override 139 | public BufferedImage render(Skin skin, boolean renderOverlays, int size) { 140 | return SquareRenderer.INSTANCE.render(skin, this, renderOverlays, size); 141 | } 142 | 143 | /** 144 | * Is this part a front arm? 145 | * 146 | * @return whether this part is a front arm 147 | */ 148 | public boolean isFrontArm() { 149 | return this == LEFT_ARM_FRONT || this == RIGHT_ARM_FRONT; 150 | } 151 | 152 | /** 153 | * Does this part have legacy coordinates? 154 | * 155 | * @return whether this part has legacy coordinates 156 | */ 157 | public boolean hasLegacyCoordinates() { 158 | return legacyCoordinates != null; 159 | } 160 | 161 | @AllArgsConstructor @Getter 162 | public static class Coordinates { 163 | /** 164 | * The X and Y position of the part. 165 | */ 166 | private final int x, y; 167 | } 168 | 169 | @Getter 170 | public static class LegacyCoordinates extends Coordinates { 171 | /** 172 | * Should the part be flipped horizontally? 173 | */ 174 | private final boolean flipped; 175 | 176 | public LegacyCoordinates(int x, int y) { 177 | this(x, y, false); 178 | } 179 | 180 | public LegacyCoordinates(int x, int y, boolean flipped) { 181 | super(x, y); 182 | this.flipped = flipped; 183 | } 184 | } 185 | } 186 | 187 | @AllArgsConstructor @Getter 188 | enum Custom implements ISkinPart { 189 | HEAD(IsometricHeadRenderer.INSTANCE), 190 | BODY(BodyRenderer.INSTANCE); 191 | 192 | /** 193 | * The renderer to use for this part 194 | */ 195 | private final SkinRenderer renderer; 196 | 197 | @Override 198 | public boolean hidden() { 199 | return false; 200 | } 201 | 202 | @Override 203 | public BufferedImage render(Skin skin, boolean renderOverlays, int size) { 204 | return renderer.render(skin, this, renderOverlays, size); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/skin/Skin.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.skin; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.google.gson.JsonObject; 6 | import lombok.AllArgsConstructor; 7 | import lombok.EqualsAndHashCode; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | import lombok.extern.log4j.Log4j2; 11 | import xyz.mcutils.backend.common.EnumUtils; 12 | import xyz.mcutils.backend.common.PlayerUtils; 13 | import xyz.mcutils.backend.config.Config; 14 | 15 | import javax.imageio.ImageIO; 16 | import java.awt.image.BufferedImage; 17 | import java.io.ByteArrayInputStream; 18 | import java.util.HashMap; 19 | import java.util.Map; 20 | 21 | @AllArgsConstructor @NoArgsConstructor 22 | @Getter @Log4j2(topic = "Skin") @EqualsAndHashCode 23 | public class Skin { 24 | /** 25 | * The URL for the skin 26 | */ 27 | private String url; 28 | 29 | /** 30 | * The model for the skin 31 | */ 32 | private Model model; 33 | 34 | /** 35 | * The legacy status of the skin 36 | */ 37 | private boolean legacy; 38 | 39 | /** 40 | * The skin image for the skin 41 | */ 42 | @JsonIgnore 43 | private byte[] skinImage; 44 | 45 | /** 46 | * The part URLs of the skin 47 | */ 48 | @JsonProperty("parts") 49 | private Map partUrls = new HashMap<>(); 50 | 51 | public Skin(String url, Model model) { 52 | this.url = url; 53 | this.model = model; 54 | 55 | this.skinImage = PlayerUtils.getSkinImage(url); 56 | if (this.skinImage != null) { 57 | try { 58 | BufferedImage image = ImageIO.read(new ByteArrayInputStream(this.skinImage)); 59 | this.legacy = image.getWidth() == 64 && image.getHeight() == 32; 60 | } catch (Exception ignored) {} 61 | } 62 | } 63 | 64 | /** 65 | * Gets the skin from a {@link JsonObject}. 66 | * 67 | * @param json the JSON object 68 | * @return the skin 69 | */ 70 | public static Skin fromJson(JsonObject json) { 71 | if (json == null) { 72 | return null; 73 | } 74 | String url = json.get("url").getAsString(); 75 | JsonObject metadata = json.getAsJsonObject("metadata"); 76 | return new Skin( 77 | url, 78 | EnumUtils.getEnumConstant(Model.class, metadata != null ? metadata.get("model").getAsString().toUpperCase() : "DEFAULT") 79 | ); 80 | } 81 | 82 | /** 83 | * Populates the part URLs for the skin. 84 | * 85 | * @param playerUuid the player's UUID 86 | */ 87 | public Skin populatePartUrls(String playerUuid) { 88 | for (Enum[] type : ISkinPart.TYPES) { 89 | for (Enum part : type) { 90 | ISkinPart skinPart = (ISkinPart) part; 91 | if (skinPart.hidden()) { 92 | continue; 93 | } 94 | String partName = part.name().toLowerCase(); 95 | this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid); 96 | } 97 | } 98 | return this; 99 | } 100 | 101 | /** 102 | * The model of the skin. 103 | */ 104 | public enum Model { 105 | DEFAULT, 106 | SLIM 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/token/JavaServerStatusToken.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.token; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.ToString; 7 | import xyz.mcutils.backend.model.server.JavaMinecraftServer; 8 | import xyz.mcutils.backend.model.server.MinecraftServer; 9 | 10 | /** 11 | * @author Braydon 12 | */ 13 | @AllArgsConstructor @Getter @ToString 14 | public final class JavaServerStatusToken { 15 | 16 | /** 17 | * The version of the server. 18 | */ 19 | private final JavaMinecraftServer.Version version; 20 | 21 | /** 22 | * The players on the server. 23 | */ 24 | private final MinecraftServer.Players players; 25 | 26 | /** 27 | * The mods running on this server. 28 | */ 29 | @SerializedName("modinfo") 30 | private JavaMinecraftServer.ForgeModInfo modInfo; 31 | 32 | /** 33 | * The mods running on this server. 34 | *

35 | * This is only used for servers 36 | * running 1.13 and above. 37 | *

38 | */ 39 | private JavaMinecraftServer.ForgeData forgeData; 40 | 41 | /** 42 | * The motd of the server. 43 | */ 44 | private final Object description; 45 | 46 | /** 47 | * The favicon of the server. 48 | */ 49 | private final String favicon; 50 | 51 | /** 52 | * Whether the server prevents chat reports. 53 | */ 54 | private boolean preventsChatReports; 55 | 56 | /** 57 | * Whether the server enforces secure chat. 58 | */ 59 | private boolean enforcesSecureChat; 60 | 61 | /** 62 | * Whether the server has previews chat enabled. 63 | *

64 | * Chat Preview sends chat messages to the server as they are typed, even before they're sent. 65 | * More information 66 | *

67 | */ 68 | private boolean previewsChat; 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/token/MojangProfileToken.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.token; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.google.gson.JsonObject; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import xyz.mcutils.backend.Main; 9 | import xyz.mcutils.backend.common.Tuple; 10 | import xyz.mcutils.backend.common.UUIDUtils; 11 | import xyz.mcutils.backend.model.player.Cape; 12 | import xyz.mcutils.backend.model.skin.Skin; 13 | 14 | import java.util.Base64; 15 | 16 | @Getter @NoArgsConstructor @AllArgsConstructor 17 | public class MojangProfileToken { 18 | 19 | /** 20 | * The UUID of the player. 21 | */ 22 | private String id; 23 | 24 | /** 25 | * The name of the player. 26 | */ 27 | private String name; 28 | 29 | /** 30 | * The properties of the player. 31 | */ 32 | private ProfileProperty[] properties = new ProfileProperty[0]; 33 | 34 | /** 35 | * Get the skin and cape of the player. 36 | * 37 | * @return the skin and cape of the player 38 | */ 39 | public Tuple getSkinAndCape() { 40 | ProfileProperty textureProperty = getProfileProperty("textures"); 41 | if (textureProperty == null) { 42 | return null; 43 | } 44 | JsonObject texturesJson = textureProperty.getDecodedValue().getAsJsonObject("textures"); // Parse the decoded JSON and get the texture object 45 | return new Tuple<>(Skin.fromJson(texturesJson.getAsJsonObject("SKIN")).populatePartUrls(this.getFormattedUuid()), 46 | Cape.fromJson(texturesJson.getAsJsonObject("CAPE"))); 47 | } 48 | 49 | /** 50 | * Gets the formatted UUID of the player. 51 | * 52 | * @return the formatted UUID 53 | */ 54 | public String getFormattedUuid() { 55 | return id.length() == 32 ? UUIDUtils.addDashes(id).toString() : id; 56 | } 57 | 58 | /** 59 | * Get a profile property for the player 60 | * 61 | * @return the profile property 62 | */ 63 | public ProfileProperty getProfileProperty(String name) { 64 | for (ProfileProperty property : properties) { 65 | if (property.getName().equals(name)) { 66 | return property; 67 | } 68 | } 69 | return null; 70 | } 71 | 72 | @Getter @NoArgsConstructor 73 | public static class ProfileProperty { 74 | /** 75 | * The name of the property. 76 | */ 77 | private String name; 78 | 79 | /** 80 | * The base64 value of the property. 81 | */ 82 | private String value; 83 | 84 | /** 85 | * The signature of the property. 86 | */ 87 | private String signature; 88 | 89 | /** 90 | * Decodes the value for this property. 91 | * 92 | * @return the decoded value 93 | */ 94 | @JsonIgnore 95 | public JsonObject getDecodedValue() { 96 | return Main.GSON.fromJson(new String(Base64.getDecoder().decode(this.value)), JsonObject.class); 97 | } 98 | 99 | /** 100 | * Check if the property is signed. 101 | * 102 | * @return true if the property is signed, false otherwise 103 | */ 104 | public boolean isSigned() { 105 | return signature != null; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/model/token/MojangUsernameToUuidToken.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.model.token; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Getter @NoArgsConstructor 8 | public class MojangUsernameToUuidToken { 9 | 10 | /** 11 | * The UUID of the player. 12 | */ 13 | @JsonProperty("id") 14 | private String uuid; 15 | 16 | /** 17 | * The name of the player. 18 | */ 19 | @JsonProperty("name") 20 | private String username; 21 | 22 | /** 23 | * Check if the profile is valid. 24 | * 25 | * @return if the profile is valid 26 | */ 27 | public boolean isValid() { 28 | return uuid != null && username != null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/repository/mongo/MetricsRepository.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.repository.mongo; 2 | 3 | import org.springframework.data.mongodb.repository.MongoRepository; 4 | import xyz.mcutils.backend.service.metric.Metric; 5 | 6 | /** 7 | * A repository for {@link Metric}s. 8 | * 9 | * @author Braydon 10 | */ 11 | public interface MetricsRepository extends MongoRepository, String> { } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/repository/redis/MinecraftServerCacheRepository.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.repository.redis; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import xyz.mcutils.backend.model.cache.CachedMinecraftServer; 5 | 6 | /** 7 | * A cache repository for {@link CachedMinecraftServer}'s. 8 | * 9 | * @author Braydon 10 | */ 11 | public interface MinecraftServerCacheRepository extends CrudRepository { } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/repository/redis/PlayerCacheRepository.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.repository.redis; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import xyz.mcutils.backend.model.cache.CachedPlayer; 5 | 6 | import java.util.UUID; 7 | 8 | /** 9 | * A cache repository for {@link CachedPlayer}'s. 10 | * 11 | * @author Braydon 12 | */ 13 | public interface PlayerCacheRepository extends CrudRepository { } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/repository/redis/PlayerNameCacheRepository.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.repository.redis; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import xyz.mcutils.backend.model.cache.CachedPlayerName; 5 | 6 | /** 7 | * A cache repository for player usernames. 8 | *

9 | * This will allow us to easily lookup a 10 | * player's username and get their uuid. 11 | *

12 | * 13 | * @author Braydon 14 | */ 15 | public interface PlayerNameCacheRepository extends CrudRepository { } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/repository/redis/PlayerSkinPartCacheRepository.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.repository.redis; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import xyz.mcutils.backend.model.cache.CachedPlayerSkinPart; 5 | 6 | /** 7 | * A cache repository for player skin parts. 8 | *

9 | * This will allow us to easily lookup a 10 | * player skin part by it's id. 11 | *

12 | */ 13 | public interface PlayerSkinPartCacheRepository extends CrudRepository { } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/repository/redis/ServerPreviewCacheRepository.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.repository.redis; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import xyz.mcutils.backend.model.cache.CachedServerPreview; 5 | 6 | /** 7 | * A cache repository for server previews. 8 | */ 9 | public interface ServerPreviewCacheRepository extends CrudRepository { } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/MaxMindService.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service; 2 | 3 | import com.maxmind.geoip2.DatabaseReader; 4 | import com.maxmind.geoip2.exception.GeoIp2Exception; 5 | import com.maxmind.geoip2.model.CityResponse; 6 | import io.sentry.Sentry; 7 | import lombok.SneakyThrows; 8 | import lombok.extern.log4j.Log4j2; 9 | import org.codehaus.plexus.archiver.tar.TarGZipUnArchiver; 10 | import org.springframework.beans.factory.annotation.Value; 11 | import org.springframework.stereotype.Service; 12 | import xyz.mcutils.backend.Main; 13 | 14 | import java.io.File; 15 | import java.io.IOException; 16 | import java.net.InetAddress; 17 | import java.net.URI; 18 | import java.net.http.HttpRequest; 19 | import java.net.http.HttpResponse; 20 | import java.nio.file.Files; 21 | import java.nio.file.Path; 22 | 23 | @Service 24 | @Log4j2(topic = "MaxMind Service") 25 | public class MaxMindService { 26 | /** 27 | * The MaxMind database. 28 | */ 29 | private static DatabaseReader database; 30 | 31 | /** 32 | * The location of the MaxMind database. 33 | */ 34 | private final String databaseName = "maxmind.mmdb"; 35 | 36 | /** 37 | * The MaxMind license key. 38 | */ 39 | private final String maxMindLicense; 40 | 41 | public MaxMindService(@Value("${maxmind.license}") String maxMindLicense) { 42 | this.maxMindLicense = maxMindLicense; 43 | if (maxMindLicense.isBlank()) { 44 | log.error("The MaxMind license key is not set, please set it in the configuration and try again, disabling the MaxMind service..."); 45 | return; 46 | } 47 | 48 | File databaseFile = loadDatabase(); 49 | try { 50 | database = new DatabaseReader.Builder(databaseFile).build(); 51 | log.info("Loaded the MaxMind database from '{}'", databaseFile.getAbsolutePath()); 52 | } catch (Exception ex) { 53 | log.error("Failed to load the MaxMind database, please check the configuration and try again", ex); 54 | System.exit(1); 55 | } 56 | } 57 | 58 | /** 59 | * Lookup the GeoIP information for the ip. 60 | * 61 | * @param ip The query to lookup 62 | * @return The GeoIP information 63 | */ 64 | public static CityResponse lookup(String ip) { 65 | if (database == null) { // The database isn't loaded, return null 66 | return null; 67 | } 68 | try { 69 | return database.city(InetAddress.getByName(ip)); 70 | } catch (IOException | GeoIp2Exception e) { 71 | log.error("Failed to lookup the GeoIP information for '{}'", ip, e); 72 | Sentry.captureException(e); 73 | return null; 74 | } 75 | } 76 | 77 | @SneakyThrows 78 | private File loadDatabase() { 79 | File database = new File("data", databaseName); 80 | if (database.exists()) { 81 | return database; 82 | } 83 | 84 | // Ensure the parent directories exist 85 | database.getParentFile().mkdirs(); 86 | 87 | String downloadUrl = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"; 88 | HttpResponse response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder() 89 | .uri(URI.create(downloadUrl.formatted(maxMindLicense))) 90 | .build(), HttpResponse.BodyHandlers.ofFile(Files.createTempFile("maxmind", ".tar.gz"))); 91 | Path downloadedFile = response.body(); 92 | 93 | File tempDir = Files.createTempDirectory("maxmind").toFile(); 94 | 95 | TarGZipUnArchiver archiver = new TarGZipUnArchiver(); 96 | archiver.setSourceFile(downloadedFile.toFile()); 97 | archiver.setDestDirectory(tempDir); 98 | archiver.extract(); 99 | 100 | File[] files = tempDir.listFiles(); 101 | if (files == null || files.length == 0) { 102 | log.error("Failed to extract the MaxMind database"); 103 | System.exit(1); 104 | } 105 | 106 | // Search for the database file 107 | for (File file : files) { 108 | // The database is in a subdirectory 109 | if (!file.isDirectory()) { 110 | continue; 111 | } 112 | 113 | // Get the database file 114 | File databaseFile = new File(file, "GeoLite2-City.mmdb"); 115 | if (!databaseFile.exists()) { 116 | log.error("Failed to find the MaxMind database in the extracted files"); 117 | continue; 118 | } 119 | Files.copy(databaseFile.toPath(), database.toPath()); 120 | } 121 | 122 | log.info("Downloaded and extracted the MaxMind database to '{}'", database.getAbsolutePath()); 123 | return database; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/MetricService.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service; 2 | 3 | import com.influxdb.client.WriteApiBlocking; 4 | import com.influxdb.client.write.Point; 5 | import com.influxdb.spring.influx.InfluxDB2AutoConfiguration; 6 | import lombok.extern.log4j.Log4j2; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Service; 9 | import xyz.mcutils.backend.common.AppConfig; 10 | import xyz.mcutils.backend.common.Timer; 11 | import xyz.mcutils.backend.repository.mongo.MetricsRepository; 12 | import xyz.mcutils.backend.service.metric.Metric; 13 | import xyz.mcutils.backend.service.metric.metrics.*; 14 | import xyz.mcutils.backend.service.metric.metrics.process.CpuUsageMetric; 15 | import xyz.mcutils.backend.service.metric.metrics.process.MemoryMetric; 16 | 17 | import java.util.ArrayList; 18 | import java.util.HashMap; 19 | import java.util.List; 20 | import java.util.Map; 21 | import java.util.concurrent.TimeUnit; 22 | 23 | @Service @Log4j2(topic = "Metric Service") 24 | public class MetricService { 25 | /** 26 | * The metrics that are registered. 27 | */ 28 | private final Map, Metric> metrics = new HashMap<>(); 29 | 30 | /** 31 | * The interval in which the metrics are saved. 32 | */ 33 | private final long saveInterval = TimeUnit.SECONDS.toMillis(15L); 34 | 35 | private final WriteApiBlocking influxWriteApi; 36 | private final MetricsRepository metricsRepository; 37 | 38 | @Autowired 39 | public MetricService(InfluxDB2AutoConfiguration influxAutoConfiguration, MetricsRepository metricsRepository) { 40 | this.influxWriteApi = influxAutoConfiguration.influxDBClient().getWriteApiBlocking(); 41 | this.metricsRepository = metricsRepository; 42 | 43 | Map, Boolean> collectorEnabled = new HashMap<>(); 44 | 45 | // Register the metrics 46 | registerMetric(new TotalRequestsMetric()); 47 | registerMetric(new RequestsPerRouteMetric()); 48 | registerMetric(new MemoryMetric()); 49 | registerMetric(new CpuUsageMetric()); 50 | registerMetric(new ConnectedSocketsMetric()); 51 | registerMetric(new UniquePlayerLookupsMetric()); 52 | registerMetric(new UniqueServerLookupsMetric()); 53 | 54 | // please god forgive my sins; this is the worst code I've ever written 55 | for (Metric metric : metrics.values()) { 56 | collectorEnabled.put(metric, metric.isCollector()); 57 | } 58 | 59 | if (!AppConfig.isRunningTest()) { 60 | // Load the metrics from Redis 61 | loadMetrics(); 62 | 63 | for (Map.Entry, Boolean> entry : collectorEnabled.entrySet()) { 64 | entry.getKey().setCollector(entry.getValue()); 65 | } 66 | 67 | Timer.scheduleRepeating(() -> { 68 | saveMetrics(); 69 | writeToInflux(); 70 | }, saveInterval, saveInterval); 71 | } 72 | } 73 | 74 | /** 75 | * Register a metric. 76 | * 77 | * @param metric the metric to register 78 | */ 79 | public void registerMetric(Metric metric) { 80 | if (metrics.containsKey(metric.getClass())) { 81 | throw new IllegalArgumentException("A metric with the class " + metric.getClass().getName() + " is already registered"); 82 | } 83 | metrics.put(metric.getClass(), metric); 84 | } 85 | 86 | /** 87 | * Get a metric by its class. 88 | * 89 | * @param clazz the class of the metric 90 | * @return the metric 91 | * @throws IllegalArgumentException if there is no metric with the class registered 92 | */ 93 | public Metric getMetric(Class clazz) throws IllegalArgumentException { 94 | if (!metrics.containsKey(clazz)) { 95 | throw new IllegalArgumentException("No metric with the class " + clazz.getName() + " is registered"); 96 | } 97 | return metrics.get(clazz); 98 | } 99 | 100 | /** 101 | * Load all metrics from Redis. 102 | */ 103 | public void loadMetrics() { 104 | log.info("Loading metrics"); 105 | for (Metric metric : metrics.values()) { 106 | metricsRepository.findById(metric.getId()).ifPresent(loaded -> metrics.put(loaded.getClass(), loaded)); 107 | } 108 | log.info("Loaded {} metrics", metrics.size()); 109 | } 110 | 111 | /** 112 | * Save all metrics to Redis. 113 | */ 114 | private void saveMetrics() { 115 | for (Metric metric : metrics.values()) { 116 | saveMetric(metric); 117 | } 118 | log.info("Saved {} metrics to MongoDB", metrics.size()); 119 | } 120 | 121 | /** 122 | * Save a metric to Redis. 123 | * 124 | * @param metric the metric to save 125 | */ 126 | private void saveMetric(Metric metric) { 127 | try { 128 | metricsRepository.save(metric); // Save the metric to the repository 129 | } catch (Exception e) { 130 | log.error("Failed to save metric to MongoDB", e); 131 | } 132 | } 133 | 134 | /** 135 | * Push all metrics to InfluxDB. 136 | */ 137 | private void writeToInflux() { 138 | try { 139 | List points = new ArrayList<>(); 140 | for (Metric metric : metrics.values()) { 141 | if (metric.isCollector()) { 142 | metric.collect(); 143 | } 144 | Point point = metric.toPoint(); 145 | if (point != null) { 146 | points.add(point); 147 | } 148 | } 149 | influxWriteApi.writePoints(points); 150 | log.info("Wrote {} metrics to Influx", metrics.size()); 151 | } catch (Exception e) { 152 | log.error("Failed to write metrics to Influx", e); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/PlayerService.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service; 2 | 3 | import lombok.extern.log4j.Log4j2; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Service; 6 | import xyz.mcutils.backend.common.*; 7 | import xyz.mcutils.backend.exception.impl.BadRequestException; 8 | import xyz.mcutils.backend.exception.impl.MojangAPIRateLimitException; 9 | import xyz.mcutils.backend.exception.impl.RateLimitException; 10 | import xyz.mcutils.backend.exception.impl.ResourceNotFoundException; 11 | import xyz.mcutils.backend.model.cache.CachedPlayer; 12 | import xyz.mcutils.backend.model.cache.CachedPlayerName; 13 | import xyz.mcutils.backend.model.cache.CachedPlayerSkinPart; 14 | import xyz.mcutils.backend.model.player.Cape; 15 | import xyz.mcutils.backend.model.player.Player; 16 | import xyz.mcutils.backend.model.skin.ISkinPart; 17 | import xyz.mcutils.backend.model.skin.Skin; 18 | import xyz.mcutils.backend.model.token.MojangProfileToken; 19 | import xyz.mcutils.backend.model.token.MojangUsernameToUuidToken; 20 | import xyz.mcutils.backend.repository.redis.PlayerCacheRepository; 21 | import xyz.mcutils.backend.repository.redis.PlayerNameCacheRepository; 22 | import xyz.mcutils.backend.repository.redis.PlayerSkinPartCacheRepository; 23 | import xyz.mcutils.backend.service.metric.metrics.UniquePlayerLookupsMetric; 24 | 25 | import java.awt.image.BufferedImage; 26 | import java.util.Optional; 27 | import java.util.UUID; 28 | 29 | @Service @Log4j2(topic = "Player Service") 30 | public class PlayerService { 31 | 32 | private final MojangService mojangAPIService; 33 | private final MetricService metricService; 34 | private final PlayerCacheRepository playerCacheRepository; 35 | private final PlayerNameCacheRepository playerNameCacheRepository; 36 | private final PlayerSkinPartCacheRepository playerSkinPartCacheRepository; 37 | 38 | @Autowired 39 | public PlayerService(MojangService mojangAPIService, MetricService metricService, PlayerCacheRepository playerCacheRepository, 40 | PlayerNameCacheRepository playerNameCacheRepository, PlayerSkinPartCacheRepository playerSkinPartCacheRepository) { 41 | this.mojangAPIService = mojangAPIService; 42 | this.metricService = metricService; 43 | this.playerCacheRepository = playerCacheRepository; 44 | this.playerNameCacheRepository = playerNameCacheRepository; 45 | this.playerSkinPartCacheRepository = playerSkinPartCacheRepository; 46 | } 47 | 48 | /** 49 | * Get a player from the cache or 50 | * from the Mojang API. 51 | * 52 | * @param id the id of the player 53 | * @return the player 54 | */ 55 | public CachedPlayer getPlayer(String id) { 56 | // Convert the id to uppercase to prevent case sensitivity 57 | log.info("Getting player: {}", id); 58 | UUID uuid = PlayerUtils.getUuidFromString(id); 59 | if (uuid == null) { // If the id is not a valid uuid, get the uuid from the username 60 | uuid = usernameToUuid(id).getUniqueId(); 61 | } 62 | 63 | Optional cachedPlayer = playerCacheRepository.findById(uuid); 64 | if (cachedPlayer.isPresent() && AppConfig.isProduction()) { // Return the cached player if it exists 65 | log.info("Player {} is cached", id); 66 | return cachedPlayer.get(); 67 | } 68 | 69 | try { 70 | log.info("Getting player profile from Mojang: {}", id); 71 | MojangProfileToken mojangProfile = mojangAPIService.getProfile(uuid.toString()); // Get the player profile from Mojang 72 | log.info("Got player profile from Mojang: {}", id); 73 | Tuple skinAndCape = mojangProfile.getSkinAndCape(); 74 | CachedPlayer player = new CachedPlayer( 75 | uuid, // Player UUID 76 | new Player( 77 | uuid, // Player UUID 78 | UUIDUtils.removeDashes(uuid), // Trimmed UUID 79 | mojangProfile.getName(), // Player Name 80 | skinAndCape.getLeft(), // Skin 81 | skinAndCape.getRight(), // Cape 82 | mojangProfile.getProperties() // Raw properties 83 | ) 84 | ); 85 | 86 | // Add the lookup to the unique player lookups metric 87 | ((UniquePlayerLookupsMetric) metricService.getMetric(UniquePlayerLookupsMetric.class)) 88 | .addLookup(uuid); 89 | 90 | playerCacheRepository.save(player); 91 | player.getCache().setCached(false); 92 | return player; 93 | } catch (RateLimitException exception) { 94 | throw new MojangAPIRateLimitException(); 95 | } 96 | } 97 | 98 | /** 99 | * Gets the player's uuid from their username. 100 | * 101 | * @param username the username of the player 102 | * @return the uuid of the player 103 | */ 104 | public CachedPlayerName usernameToUuid(String username) { 105 | log.info("Getting UUID from username: {}", username); 106 | String id = username.toUpperCase(); 107 | Optional cachedPlayerName = playerNameCacheRepository.findById(id); 108 | if (cachedPlayerName.isPresent() && AppConfig.isProduction()) { 109 | return cachedPlayerName.get(); 110 | } 111 | try { 112 | MojangUsernameToUuidToken mojangUsernameToUuid = mojangAPIService.getUuidFromUsername(username); 113 | if (mojangUsernameToUuid == null) { 114 | log.info("Player with username '{}' not found", username); 115 | throw new ResourceNotFoundException("Player with username '%s' not found".formatted(username)); 116 | } 117 | UUID uuid = UUIDUtils.addDashes(mojangUsernameToUuid.getUuid()); 118 | CachedPlayerName player = new CachedPlayerName(id, username, uuid); 119 | playerNameCacheRepository.save(player); 120 | log.info("Got UUID from username: {} -> {}", username, uuid); 121 | player.getCache().setCached(false); 122 | return player; 123 | } catch (RateLimitException exception) { 124 | throw new MojangAPIRateLimitException(); 125 | } 126 | } 127 | 128 | /** 129 | * Gets a skin part from the player's skin. 130 | * 131 | * @param player the player 132 | * @param partName the name of the part 133 | * @param renderOverlay whether to render the overlay 134 | * @return the skin part 135 | */ 136 | public CachedPlayerSkinPart getSkinPart(Player player, String partName, boolean renderOverlay, int size) { 137 | if (size > 512) { 138 | throw new BadRequestException("Size cannot be larger than 512"); 139 | } 140 | if (size < 32) { 141 | throw new BadRequestException("Size cannot be smaller than 32"); 142 | } 143 | 144 | ISkinPart part = ISkinPart.getByName(partName); // The skin part to get 145 | if (part == null) { 146 | throw new BadRequestException("Invalid skin part: %s".formatted(partName)); 147 | } 148 | 149 | String name = part.name(); 150 | log.info("Getting skin part {} for player: {} (size: {}, overlays: {})", name, player.getUniqueId(), size, renderOverlay); 151 | String key = "%s-%s-%s-%s".formatted(player.getUniqueId(), name, size, renderOverlay); 152 | 153 | Optional cache = playerSkinPartCacheRepository.findById(key); 154 | 155 | // The skin part is cached 156 | if (cache.isPresent() && AppConfig.isProduction()) { 157 | log.info("Skin part {} for player {} is cached", name, player.getUniqueId()); 158 | return cache.get(); 159 | } 160 | 161 | long before = System.currentTimeMillis(); 162 | BufferedImage renderedPart = part.render(player.getSkin(), renderOverlay, size); // Render the skin part 163 | log.info("Took {}ms to render skin part {} for player: {}", System.currentTimeMillis() - before, name, player.getUniqueId()); 164 | 165 | CachedPlayerSkinPart skinPart = new CachedPlayerSkinPart( 166 | key, 167 | ImageUtils.imageToBytes(renderedPart) 168 | ); 169 | log.info("Fetched skin part {} for player: {}", name, player.getUniqueId()); 170 | playerSkinPartCacheRepository.save(skinPart); 171 | return skinPart; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/Metric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.influxdb.client.write.Point; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | import lombok.ToString; 9 | import org.springframework.data.annotation.Id; 10 | import org.springframework.data.annotation.Transient; 11 | import org.springframework.data.mongodb.core.mapping.Document; 12 | 13 | @AllArgsConstructor 14 | @Getter @Setter @ToString 15 | @Document("metrics") 16 | public abstract class Metric { 17 | /** 18 | * The id of the metric. 19 | */ 20 | @Id private String id; 21 | 22 | /** 23 | * The value of the metric. 24 | */ 25 | private T value; 26 | 27 | /** 28 | * Should this metric be collected 29 | * before pushing to Influx? 30 | */ 31 | @Transient @JsonIgnore 32 | private boolean collector; 33 | 34 | /** 35 | * Collects the metric. 36 | */ 37 | public void collect() {} 38 | 39 | /** 40 | * Gets this point as a {@link Point}. 41 | * 42 | * @return the point 43 | */ 44 | public abstract Point toPoint(); 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/impl/DoubleMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.impl; 2 | 3 | import com.influxdb.client.write.Point; 4 | import xyz.mcutils.backend.service.metric.Metric; 5 | 6 | public class DoubleMetric extends Metric { 7 | 8 | public DoubleMetric(String id) { 9 | super(id, 0D, false); 10 | } 11 | 12 | /** 13 | * Increment the value of this metric. 14 | * 15 | * @param amount the amount to increment by 16 | */ 17 | public void increment(double amount) { 18 | setValue(getValue() + amount); 19 | } 20 | 21 | /** 22 | * Increment the value of this metric by 1. 23 | */ 24 | public void increment() { 25 | increment(1); 26 | } 27 | 28 | /** 29 | * Decrement the value of this metric. 30 | * 31 | * @param amount the amount to decrement by 32 | */ 33 | public void decrement(double amount) { 34 | setValue(getValue() - amount); 35 | } 36 | 37 | /** 38 | * Decrement the value of this metric by 1. 39 | */ 40 | public void decrement() { 41 | decrement(1D); 42 | } 43 | 44 | @Override 45 | public Point toPoint() { 46 | return Point.measurement(getId()) 47 | .addField("value", getValue()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/impl/IntegerMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.impl; 2 | 3 | import com.influxdb.client.write.Point; 4 | import xyz.mcutils.backend.service.metric.Metric; 5 | 6 | public class IntegerMetric extends Metric { 7 | 8 | public IntegerMetric(String id) { 9 | super(id, 0, false); 10 | } 11 | 12 | /** 13 | * Increment the value of this metric. 14 | * 15 | * @param amount the amount to increment by 16 | */ 17 | public void increment(int amount) { 18 | setValue(getValue() + amount); 19 | } 20 | 21 | /** 22 | * Increment the value of this metric by 1. 23 | */ 24 | public void increment() { 25 | increment(1); 26 | } 27 | 28 | /** 29 | * Decrement the value of this metric. 30 | * 31 | * @param amount the amount to decrement by 32 | */ 33 | public void decrement(int amount) { 34 | setValue(getValue() - amount); 35 | } 36 | 37 | /** 38 | * Decrement the value of this metric by 1. 39 | */ 40 | public void decrement() { 41 | decrement(1); 42 | } 43 | 44 | @Override 45 | public Point toPoint() { 46 | return Point.measurement(getId()) 47 | .addField("value", getValue()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/impl/MapMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.impl; 2 | 3 | import com.influxdb.client.write.Point; 4 | import xyz.mcutils.backend.service.metric.Metric; 5 | 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | 9 | public class MapMetric extends Metric> { 10 | 11 | public MapMetric(String id) { 12 | super(id, new HashMap<>(), false); 13 | } 14 | 15 | @Override 16 | public Point toPoint() { 17 | if (getValue().isEmpty()) { // The map is empty 18 | return null; 19 | } 20 | Point point = Point.measurement(getId()); 21 | for (Map.Entry entry : getValue().entrySet()) { 22 | switch (entry.getValue().getClass().getSimpleName()) { 23 | case "Integer": 24 | point.addField(entry.getKey().toString(), (int) entry.getValue()); 25 | break; 26 | case "Double": 27 | point.addField(entry.getKey().toString(), (double) entry.getValue()); 28 | break; 29 | case "Long": 30 | point.addField(entry.getKey().toString(), (long) entry.getValue()); 31 | break; 32 | default: 33 | point.addField(entry.getKey().toString(), entry.getValue().toString()); 34 | break; 35 | } 36 | } 37 | return point; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/ConnectedSocketsMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics; 2 | 3 | import xyz.mcutils.backend.service.metric.impl.IntegerMetric; 4 | import xyz.mcutils.backend.websocket.WebSocketManager; 5 | 6 | public class ConnectedSocketsMetric extends IntegerMetric { 7 | 8 | public ConnectedSocketsMetric() { 9 | super("connected_sockets"); 10 | } 11 | 12 | @Override 13 | public boolean isCollector() { 14 | return true; 15 | } 16 | 17 | @Override 18 | public void collect() { 19 | setValue(WebSocketManager.getTotalConnections()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/RequestsPerRouteMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics; 2 | 3 | import xyz.mcutils.backend.service.metric.impl.MapMetric; 4 | 5 | public class RequestsPerRouteMetric extends MapMetric { 6 | 7 | public RequestsPerRouteMetric() { 8 | super("requests_per_route"); 9 | } 10 | 11 | /** 12 | * Increment the value for this route. 13 | * 14 | * @param route the route to increment 15 | */ 16 | public void increment(String route) { 17 | getValue().put(route, getValue().getOrDefault(route, 0) + 1); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/TotalRequestsMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics; 2 | 3 | import xyz.mcutils.backend.service.metric.impl.IntegerMetric; 4 | 5 | public class TotalRequestsMetric extends IntegerMetric { 6 | 7 | public TotalRequestsMetric() { 8 | super("total_requests"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/UniquePlayerLookupsMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics; 2 | 3 | import xyz.mcutils.backend.service.metric.impl.IntegerMetric; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.UUID; 8 | 9 | public class UniquePlayerLookupsMetric extends IntegerMetric { 10 | private List uniqueLookups = new ArrayList<>(); 11 | 12 | public UniquePlayerLookupsMetric() { 13 | super("unique_player_lookups"); 14 | } 15 | 16 | @Override 17 | public boolean isCollector() { 18 | return true; 19 | } 20 | 21 | /** 22 | * Adds a lookup to the list of unique lookups. 23 | * 24 | * @param uuid the query that was used to look up a player 25 | */ 26 | public void addLookup(UUID uuid) { 27 | if (!uniqueLookups.contains(uuid.toString())) { 28 | uniqueLookups.add(uuid.toString()); 29 | } 30 | } 31 | 32 | @Override 33 | public void collect() { 34 | setValue(uniqueLookups.size()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/UniqueServerLookupsMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics; 2 | 3 | import xyz.mcutils.backend.service.metric.impl.IntegerMetric; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | 8 | public class UniqueServerLookupsMetric extends IntegerMetric { 9 | private List uniqueLookups = new ArrayList<>(); 10 | 11 | public UniqueServerLookupsMetric() { 12 | super("unique_server_lookups"); 13 | } 14 | 15 | @Override 16 | public boolean isCollector() { 17 | return true; 18 | } 19 | 20 | /** 21 | * Adds a lookup to the list of unique lookups. 22 | * 23 | * @param hostname the query that was used to look up a player 24 | */ 25 | public void addLookup(String hostname) { 26 | hostname = hostname.toLowerCase(); 27 | if (!uniqueLookups.contains(hostname)) { 28 | uniqueLookups.add(hostname); 29 | } 30 | } 31 | 32 | @Override 33 | public void collect() { 34 | setValue(uniqueLookups.size()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/process/CpuUsageMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics.process; 2 | 3 | import com.sun.management.OperatingSystemMXBean; 4 | import xyz.mcutils.backend.service.metric.impl.DoubleMetric; 5 | 6 | import java.lang.management.ManagementFactory; 7 | 8 | public class CpuUsageMetric extends DoubleMetric { 9 | /** 10 | * The OperatingSystemMXBean instance to get the CPU load from. 11 | */ 12 | private static final OperatingSystemMXBean OS_BEAN = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class); 13 | 14 | public CpuUsageMetric() { 15 | super("cpu_usage"); 16 | } 17 | 18 | @Override 19 | public boolean isCollector() { 20 | return true; 21 | } 22 | 23 | @Override 24 | public void collect() { 25 | this.setValue(OS_BEAN.getProcessCpuLoad() * 100); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/metric/metrics/process/MemoryMetric.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.metric.metrics.process; 2 | 3 | import xyz.mcutils.backend.service.metric.impl.MapMetric; 4 | 5 | public class MemoryMetric extends MapMetric { 6 | 7 | public MemoryMetric() { 8 | super("memory"); 9 | } 10 | 11 | @Override 12 | public boolean isCollector() { 13 | return true; 14 | } 15 | 16 | @Override 17 | public void collect() { 18 | Runtime runtime = Runtime.getRuntime(); 19 | 20 | this.getValue().put("total", runtime.maxMemory()); 21 | this.getValue().put("allocated", runtime.totalMemory()); 22 | this.getValue().put("used", runtime.totalMemory() - runtime.freeMemory()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/pinger/MinecraftServerPinger.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.pinger; 2 | 3 | import xyz.mcutils.backend.model.dns.DNSRecord; 4 | import xyz.mcutils.backend.model.server.MinecraftServer; 5 | 6 | /** 7 | * @author Braydon 8 | * @param the type of server to ping 9 | */ 10 | public interface MinecraftServerPinger { 11 | T ping(String hostname, String ip, int port, DNSRecord[] records); 12 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/pinger/impl/BedrockMinecraftServerPinger.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.pinger.impl; 2 | 3 | import lombok.extern.log4j.Log4j2; 4 | import xyz.mcutils.backend.common.packet.impl.bedrock.BedrockPacketUnconnectedPing; 5 | import xyz.mcutils.backend.common.packet.impl.bedrock.BedrockPacketUnconnectedPong; 6 | import xyz.mcutils.backend.exception.impl.BadRequestException; 7 | import xyz.mcutils.backend.exception.impl.ResourceNotFoundException; 8 | import xyz.mcutils.backend.model.dns.DNSRecord; 9 | import xyz.mcutils.backend.model.server.BedrockMinecraftServer; 10 | import xyz.mcutils.backend.model.server.MinecraftServer; 11 | import xyz.mcutils.backend.service.MaxMindService; 12 | import xyz.mcutils.backend.service.pinger.MinecraftServerPinger; 13 | 14 | import java.io.IOException; 15 | import java.net.DatagramSocket; 16 | import java.net.InetSocketAddress; 17 | import java.net.SocketTimeoutException; 18 | import java.net.UnknownHostException; 19 | 20 | /** 21 | * The {@link MinecraftServerPinger} for pinging 22 | * {@link BedrockMinecraftServer} over UDP. 23 | * 24 | * @author Braydon 25 | */ 26 | @Log4j2(topic = "Bedrock MC Server Pinger") 27 | public final class BedrockMinecraftServerPinger implements MinecraftServerPinger { 28 | private static final int TIMEOUT = 1500; // The timeout for the socket 29 | 30 | /** 31 | * Ping the server with the given hostname and port. 32 | * 33 | * @param hostname the hostname of the server 34 | * @param port the port of the server 35 | * @return the server that was pinged 36 | */ 37 | @Override 38 | public BedrockMinecraftServer ping(String hostname, String ip, int port, DNSRecord[] records) { 39 | log.info("Pinging {}:{}...", hostname, port); 40 | long before = System.currentTimeMillis(); // Timestamp before pinging 41 | 42 | // Open a socket connection to the server 43 | try (DatagramSocket socket = new DatagramSocket()) { 44 | socket.setSoTimeout(TIMEOUT); 45 | socket.connect(new InetSocketAddress(hostname, port)); 46 | 47 | long ping = System.currentTimeMillis() - before; // Calculate the ping 48 | log.info("Pinged {}:{} in {}ms", hostname, port, ping); 49 | 50 | // Send the unconnected ping packet 51 | new BedrockPacketUnconnectedPing().process(socket); 52 | 53 | // Handle the received unconnected pong packet 54 | BedrockPacketUnconnectedPong unconnectedPong = new BedrockPacketUnconnectedPong(); 55 | unconnectedPong.process(socket); 56 | String response = unconnectedPong.getResponse(); 57 | if (response == null) { // No pong response 58 | throw new ResourceNotFoundException("Server '%s' didn't respond to ping".formatted(hostname)); 59 | } 60 | return BedrockMinecraftServer.create(hostname, ip, port, records, 61 | MinecraftServer.GeoLocation.fromMaxMind(MaxMindService.lookup(ip)), response); // Return the server 62 | } catch (IOException ex ) { 63 | if (ex instanceof UnknownHostException) { 64 | throw new BadRequestException("Unknown hostname '%s'".formatted(hostname)); 65 | } else if (ex instanceof SocketTimeoutException) { 66 | throw new ResourceNotFoundException("Server '%s' didn't respond to ping".formatted(hostname)); 67 | } else { 68 | log.error("An error occurred pinging %s:%s:".formatted(hostname, port), ex); 69 | throw new BadRequestException("An error occurred pinging '%s:%s'".formatted(hostname, port)); 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/service/pinger/impl/JavaMinecraftServerPinger.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.service.pinger.impl; 2 | 3 | import lombok.extern.log4j.Log4j2; 4 | import xyz.mcutils.backend.Main; 5 | import xyz.mcutils.backend.common.JavaMinecraftVersion; 6 | import xyz.mcutils.backend.common.packet.impl.java.JavaPacketHandshakingInSetProtocol; 7 | import xyz.mcutils.backend.common.packet.impl.java.JavaPacketStatusInStart; 8 | import xyz.mcutils.backend.exception.impl.BadRequestException; 9 | import xyz.mcutils.backend.exception.impl.ResourceNotFoundException; 10 | import xyz.mcutils.backend.model.dns.DNSRecord; 11 | import xyz.mcutils.backend.model.server.JavaMinecraftServer; 12 | import xyz.mcutils.backend.model.server.MinecraftServer; 13 | import xyz.mcutils.backend.model.token.JavaServerStatusToken; 14 | import xyz.mcutils.backend.service.MaxMindService; 15 | import xyz.mcutils.backend.service.pinger.MinecraftServerPinger; 16 | 17 | import java.io.DataInputStream; 18 | import java.io.DataOutputStream; 19 | import java.io.IOException; 20 | import java.net.*; 21 | 22 | /** 23 | * @author Braydon 24 | */ 25 | @Log4j2(topic = "Java Pinger") 26 | public final class JavaMinecraftServerPinger implements MinecraftServerPinger { 27 | private static final int TIMEOUT = 1500; // The timeout for the socket 28 | 29 | /** 30 | * Ping the server with the given hostname and port. 31 | * 32 | * @param hostname the hostname of the server 33 | * @param port the port of the server 34 | * @return the server that was pinged 35 | */ 36 | @Override 37 | public JavaMinecraftServer ping(String hostname, String ip, int port, DNSRecord[] records) { 38 | log.info("Pinging {}:{}...", hostname, port); 39 | 40 | // Open a socket connection to the server 41 | try (Socket socket = new Socket()) { 42 | socket.setTcpNoDelay(true); 43 | socket.connect(new InetSocketAddress(hostname, port), TIMEOUT); 44 | 45 | // Open data streams to begin packet transaction 46 | try (DataInputStream inputStream = new DataInputStream(socket.getInputStream()); 47 | DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream())) { 48 | // Begin handshaking with the server 49 | new JavaPacketHandshakingInSetProtocol(hostname, port, JavaMinecraftVersion.getMinimumVersion().getProtocol()).process(inputStream, outputStream); 50 | 51 | // Send the status request to the server, and await back the response 52 | JavaPacketStatusInStart packetStatusInStart = new JavaPacketStatusInStart(); 53 | packetStatusInStart.process(inputStream, outputStream); 54 | JavaServerStatusToken token = Main.GSON.fromJson(packetStatusInStart.getResponse(), JavaServerStatusToken.class); 55 | return JavaMinecraftServer.create(hostname, ip, port, records, 56 | MinecraftServer.GeoLocation.fromMaxMind(MaxMindService.lookup(ip)), token); 57 | } 58 | } catch (IOException ex) { 59 | if (ex instanceof UnknownHostException) { 60 | throw new BadRequestException("Unknown hostname: %s".formatted(hostname)); 61 | } else if (ex instanceof ConnectException || ex instanceof SocketTimeoutException) { 62 | throw new ResourceNotFoundException("Server '%s' didn't respond to ping".formatted(hostname)); 63 | } else { 64 | log.error("An error occurred pinging %s:%s:".formatted(hostname, port), ex); 65 | throw new BadRequestException("An error occurred pinging '%s:%s'".formatted(hostname, port)); 66 | } 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/websocket/WebSocket.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.websocket; 2 | 3 | import lombok.Getter; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.log4j.Log4j2; 6 | import org.jetbrains.annotations.NotNull; 7 | import org.springframework.web.socket.CloseStatus; 8 | import org.springframework.web.socket.TextMessage; 9 | import org.springframework.web.socket.WebSocketSession; 10 | import org.springframework.web.socket.handler.TextWebSocketHandler; 11 | 12 | import java.io.IOException; 13 | import java.util.ArrayList; 14 | import java.util.List; 15 | 16 | @RequiredArgsConstructor @Getter @Log4j2(topic = "WebSocket") 17 | public abstract class WebSocket extends TextWebSocketHandler { 18 | 19 | /** 20 | * The sessions that are connected to the WebSocket. 21 | */ 22 | private final List sessions = new ArrayList<>(); 23 | 24 | /** 25 | * The path of the WebSocket. 26 | *

27 | * Example: /websocket/metrics 28 | *

29 | */ 30 | public final String path; 31 | 32 | /** 33 | * Sends a message to the client. 34 | * 35 | * @param session the session to send the message to 36 | * @param message the message to send 37 | * @throws IOException if an error occurs while sending the message 38 | */ 39 | public void sendMessage(WebSocketSession session, String message) throws IOException { 40 | session.sendMessage(new TextMessage(message)); 41 | } 42 | 43 | /** 44 | * Called when a session connects to the WebSocket. 45 | * 46 | * @param session the session that connected 47 | */ 48 | abstract public void onSessionConnect(WebSocketSession session); 49 | 50 | @Override 51 | public final void afterConnectionEstablished(@NotNull WebSocketSession session) { 52 | this.sessions.add(session); 53 | log.info("Connection established: {}", session.getId()); 54 | this.onSessionConnect(session); 55 | } 56 | 57 | @Override 58 | public final void afterConnectionClosed(@NotNull WebSocketSession session, @NotNull CloseStatus status) { 59 | this.sessions.remove(session); 60 | log.info("Connection closed: {}", session.getId()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/websocket/WebSocketManager.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.websocket; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.socket.config.annotation.EnableWebSocket; 7 | import org.springframework.web.socket.config.annotation.WebSocketConfigurer; 8 | import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; 9 | import xyz.mcutils.backend.service.MetricService; 10 | import xyz.mcutils.backend.websocket.impl.MetricsWebSocket; 11 | 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | 15 | @Configuration 16 | @EnableWebSocket 17 | public class WebSocketManager implements WebSocketConfigurer { 18 | private static final List WEB_SOCKETS = new ArrayList<>(); 19 | 20 | private final MetricService metricService; 21 | 22 | @Autowired 23 | public WebSocketManager(MetricService metricService) { 24 | this.metricService = metricService; 25 | } 26 | 27 | @Override 28 | public void registerWebSocketHandlers(@NotNull WebSocketHandlerRegistry registry) { 29 | registerWebSocket(registry, new MetricsWebSocket(metricService)); 30 | } 31 | 32 | /** 33 | * Registers a WebSocket. 34 | * 35 | * @param registry the registry to register the WebSocket on 36 | * @param webSocket the WebSocket to register 37 | */ 38 | private void registerWebSocket(WebSocketHandlerRegistry registry, WebSocket webSocket) { 39 | registry.addHandler(webSocket, webSocket.getPath()).setAllowedOrigins("*"); 40 | WEB_SOCKETS.add(webSocket); 41 | } 42 | 43 | /** 44 | * Gets the total amount of connections. 45 | * 46 | * @return the total amount of connections 47 | */ 48 | public static int getTotalConnections() { 49 | return WEB_SOCKETS.stream().mapToInt(webSocket -> webSocket.getSessions().size()).sum(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/xyz/mcutils/backend/websocket/impl/MetricsWebSocket.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.websocket.impl; 2 | 3 | import lombok.extern.log4j.Log4j2; 4 | import org.springframework.web.socket.WebSocketSession; 5 | import xyz.mcutils.backend.Main; 6 | import xyz.mcutils.backend.common.Timer; 7 | import xyz.mcutils.backend.service.MetricService; 8 | import xyz.mcutils.backend.service.metric.metrics.TotalRequestsMetric; 9 | import xyz.mcutils.backend.service.metric.metrics.UniquePlayerLookupsMetric; 10 | import xyz.mcutils.backend.service.metric.metrics.UniqueServerLookupsMetric; 11 | import xyz.mcutils.backend.websocket.WebSocket; 12 | 13 | import java.util.Map; 14 | import java.util.concurrent.TimeUnit; 15 | 16 | @Log4j2(topic = "WebSocket/Metrics") 17 | public class MetricsWebSocket extends WebSocket { 18 | private final long interval = TimeUnit.SECONDS.toMillis(5); 19 | private final MetricService metricService; 20 | 21 | public MetricsWebSocket(MetricService metricService) { 22 | super("/websocket/metrics"); 23 | this.metricService = metricService; 24 | 25 | Timer.scheduleRepeating(() -> { 26 | for (WebSocketSession session : this.getSessions()) { 27 | sendMetrics(session); 28 | } 29 | }, interval, interval); 30 | } 31 | 32 | @Override 33 | public void onSessionConnect(WebSocketSession session) { 34 | sendMetrics(session); 35 | } 36 | 37 | /** 38 | * Sends the metrics to the client. 39 | * 40 | * @param session the session to send the metrics to 41 | */ 42 | private void sendMetrics(WebSocketSession session) { 43 | try { 44 | this.sendMessage(session, Main.GSON.toJson(Map.of( 45 | "totalRequests", metricService.getMetric(TotalRequestsMetric.class).getValue(), 46 | "uniqueServerLookups", metricService.getMetric(UniqueServerLookupsMetric.class).getValue(), 47 | "uniquePlayerLookups", metricService.getMetric(UniquePlayerLookupsMetric.class).getValue() 48 | ))); 49 | } catch (Exception e) { 50 | log.error("An error occurred while sending metrics to the client", e); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | address: 0.0.0.0 3 | port: 80 4 | servlet: 5 | context-path: / 6 | 7 | # Spring Configuration 8 | spring: 9 | # Don't include null properties in JSON 10 | jackson: 11 | default-property-inclusion: non_null 12 | data: 13 | # Redis - This is used for caching 14 | redis: 15 | host: "127.0.0.1" 16 | port: 6379 17 | database: 1 18 | auth: "" # Leave blank for no auth 19 | 20 | # MongoDB - This is used for general data storage 21 | mongodb: 22 | uri: mongodb://localhost:27017 23 | database: test 24 | port: 27017 25 | 26 | # Sentry Configuration 27 | sentry: 28 | dsn: "" 29 | 30 | # The URL of the API 31 | public-url: http://localhost 32 | 33 | # MaxMind Configuration 34 | # This is used for IP Geolocation 35 | maxmind: 36 | license: "" 37 | 38 | # InfluxDB Configuration 39 | influx: 40 | url: http://localhost 41 | token: token 42 | org: org 43 | bucket: bucket 44 | 45 | management: 46 | # Disable all actuator endpoints 47 | endpoints: 48 | web: 49 | exposure: 50 | exclude: 51 | - "*" 52 | # Disable default metrics 53 | influx: 54 | metrics: 55 | export: 56 | enabled: false 57 | 58 | # Set the embedded MongoDB version 59 | de: 60 | flapdoodle: 61 | mongodb: 62 | embedded: 63 | version: 7.0.8 -------------------------------------------------------------------------------- /src/main/resources/fonts/minecraft-font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealFascinated/MinecraftUtilities/0bc614ce39869e43b394eeae519a7954df809a4e/src/main/resources/fonts/minecraft-font.ttf -------------------------------------------------------------------------------- /src/main/resources/icons/ping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealFascinated/MinecraftUtilities/0bc614ce39869e43b394eeae519a7954df809a4e/src/main/resources/icons/ping.png -------------------------------------------------------------------------------- /src/main/resources/icons/server_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealFascinated/MinecraftUtilities/0bc614ce39869e43b394eeae519a7954df809a4e/src/main/resources/icons/server_background.png -------------------------------------------------------------------------------- /src/main/resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealFascinated/MinecraftUtilities/0bc614ce39869e43b394eeae519a7954df809a4e/src/main/resources/public/favicon.ico -------------------------------------------------------------------------------- /src/main/resources/public/robots.txt: -------------------------------------------------------------------------------- 1 | # Allow all robots 2 | User-agent: * 3 | Allow: / -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Minecraft Utilities API 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |

Minecraft Utilities - Hello!

37 |

Minecraft Utilities provide a convenient wrapper for the Minecraft APIs, simplifying their usage for developers.

38 | Source Code 39 | Documentation / Swagger 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
ExampleLink
Player Data???
Server Data (Java)???
Server Data (Bedrock)???
Mojang Endpoint Status???
68 | 69 | 70 | -------------------------------------------------------------------------------- /src/test/java/xyz/mcutils/backend/test/config/TestRedisConfig.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.test.config; 2 | 3 | import jakarta.annotation.PostConstruct; 4 | import jakarta.annotation.PreDestroy; 5 | import lombok.NonNull; 6 | import org.springframework.boot.test.context.TestConfiguration; 7 | import redis.embedded.RedisServer; 8 | 9 | import java.io.IOException; 10 | 11 | /** 12 | * Test configuration for 13 | * a mock Redis server. 14 | * 15 | * @author Braydon 16 | */ 17 | @TestConfiguration 18 | public class TestRedisConfig { 19 | @NonNull 20 | private final RedisServer server; 21 | 22 | public TestRedisConfig() throws IOException { 23 | server = new RedisServer(); // Construct the mock server 24 | } 25 | 26 | /** 27 | * Start up the mock Redis server. 28 | * 29 | * @throws IOException if there was an issue starting the server 30 | */ 31 | @PostConstruct 32 | public void onInitialize() throws IOException { 33 | server.start(); 34 | } 35 | 36 | /** 37 | * Shutdown the running mock Redis server. 38 | * 39 | * @throws IOException if there was an issue stopping the server 40 | */ 41 | @PreDestroy 42 | public void housekeeping() throws IOException { 43 | server.stop(); 44 | } 45 | } -------------------------------------------------------------------------------- /src/test/java/xyz/mcutils/backend/test/tests/MojangControllerTests.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.test.tests; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.http.MediaType; 8 | import org.springframework.test.web.servlet.MockMvc; 9 | import xyz.mcutils.backend.test.config.TestRedisConfig; 10 | 11 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 12 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 13 | 14 | @SpringBootTest(classes = { TestRedisConfig.class }) 15 | @AutoConfigureMockMvc 16 | class MojangControllerTests { 17 | 18 | @Autowired 19 | private MockMvc mockMvc; 20 | 21 | @Test 22 | public void ensureEndpointStatusLookupSuccess() throws Exception { 23 | mockMvc.perform(get("/mojang/status") 24 | .accept(MediaType.APPLICATION_JSON) 25 | .contentType(MediaType.APPLICATION_JSON)) 26 | .andExpect(status().isOk()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/xyz/mcutils/backend/test/tests/PlayerControllerTests.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.test.tests; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.TestInstance; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.test.web.servlet.MockMvc; 10 | import xyz.mcutils.backend.model.skin.ISkinPart; 11 | import xyz.mcutils.backend.test.config.TestRedisConfig; 12 | 13 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 14 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 15 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 16 | 17 | @SpringBootTest(classes = { TestRedisConfig.class }) 18 | @AutoConfigureMockMvc 19 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 20 | class PlayerControllerTests { 21 | 22 | private final String testPlayerUuid = "eeab5f8a-18dd-4d58-af78-2b3c4543da48"; 23 | private final String testPlayer = "ImFascinated"; 24 | private final String testInvalidPlayer = "invalidplayeromgyeswow"; 25 | 26 | @Autowired 27 | private MockMvc mockMvc; 28 | 29 | @Test 30 | public void ensurePlayerLookupUuidSuccess() throws Exception { 31 | mockMvc.perform(get("/player/" + testPlayerUuid) 32 | .accept(MediaType.APPLICATION_JSON) 33 | .contentType(MediaType.APPLICATION_JSON)) 34 | .andExpect(status().isOk()) 35 | .andExpect(jsonPath("$.username").value(testPlayer)); 36 | } 37 | 38 | @Test 39 | public void ensurePlayerLookupUsernameSuccess() throws Exception { 40 | mockMvc.perform(get("/player/" + testPlayer) 41 | .accept(MediaType.APPLICATION_JSON) 42 | .contentType(MediaType.APPLICATION_JSON)) 43 | .andExpect(status().isOk()) 44 | .andExpect(jsonPath("$.username").value(testPlayer)); 45 | } 46 | 47 | @Test 48 | public void ensurePlayerUsernameToUuidLookupSuccess() throws Exception { 49 | mockMvc.perform(get("/player/uuid/" + testPlayer) 50 | .accept(MediaType.APPLICATION_JSON) 51 | .contentType(MediaType.APPLICATION_JSON)) 52 | .andExpect(status().isOk()) 53 | .andExpect(jsonPath("$.username").value(testPlayer)) 54 | .andExpect(jsonPath("$.uniqueId").value(testPlayerUuid)); 55 | } 56 | 57 | @Test 58 | public void ensurePlayerUsernameToUuidLookupFailure() throws Exception { 59 | mockMvc.perform(get("/player/uuid/" + testInvalidPlayer) 60 | .accept(MediaType.APPLICATION_JSON) 61 | .contentType(MediaType.APPLICATION_JSON)) 62 | .andExpect(status().isNotFound()); 63 | } 64 | 65 | @Test 66 | public void ensurePlayerLookupFailure() throws Exception { 67 | mockMvc.perform(get("/player/" + testInvalidPlayer) 68 | .accept(MediaType.APPLICATION_JSON) 69 | .contentType(MediaType.APPLICATION_JSON)) 70 | .andExpect(status().isNotFound()); 71 | } 72 | 73 | @Test 74 | public void ensurePlayerSkinPartsLookupSuccess() throws Exception { 75 | for (Enum[] type : ISkinPart.TYPES) { 76 | for (Enum part : type) { 77 | mockMvc.perform(get("/player/" + part.name().toLowerCase() + "/" + testPlayer) 78 | .accept(MediaType.APPLICATION_JSON) 79 | .contentType(MediaType.APPLICATION_JSON)) 80 | .andExpect(status().isOk()); 81 | } 82 | 83 | } 84 | } 85 | 86 | @Test 87 | public void ensurePlayerSkinPartsLookupFailure() throws Exception { 88 | mockMvc.perform(get("/player/invalidpart/" + testPlayer)) 89 | .andExpect(status().isBadRequest()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/test/java/xyz/mcutils/backend/test/tests/ServerControllerTests.java: -------------------------------------------------------------------------------- 1 | package xyz.mcutils.backend.test.tests; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.TestInstance; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.test.web.servlet.MockMvc; 10 | import xyz.mcutils.backend.test.config.TestRedisConfig; 11 | 12 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 13 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 14 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 15 | 16 | @SpringBootTest(classes = { TestRedisConfig.class }) 17 | @AutoConfigureMockMvc 18 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 19 | class ServerControllerTests { 20 | 21 | private final String testServer = "play.hypixel.net"; 22 | private final String testInvalidServer = "invalidhostnamehahahahahayesslmaooo"; 23 | 24 | @Autowired 25 | private MockMvc mockMvc; 26 | 27 | @Test 28 | public void ensureServerLookupSuccess() throws Exception { 29 | mockMvc.perform(get("/server/java/" + testServer) 30 | .accept(MediaType.APPLICATION_JSON) 31 | .contentType(MediaType.APPLICATION_JSON)) 32 | .andExpect(status().isOk()) 33 | .andExpect(jsonPath("$.hostname").value(testServer)); 34 | } 35 | 36 | @Test 37 | public void ensureServerLookupFailure() throws Exception { 38 | mockMvc.perform(get("/server/java/" + testInvalidServer) 39 | .accept(MediaType.APPLICATION_JSON) 40 | .contentType(MediaType.APPLICATION_JSON)) 41 | .andExpect(status().isBadRequest()); 42 | } 43 | 44 | @Test 45 | public void ensureServerIconLookupSuccess() throws Exception { 46 | mockMvc.perform(get("/server/icon/" + testServer) 47 | .contentType(MediaType.IMAGE_PNG)) 48 | .andExpect(status().isOk()); 49 | } 50 | 51 | @Test 52 | public void ensureBlockedServerLookupSuccess() throws Exception { 53 | mockMvc.perform(get("/server/blocked/" + testServer) 54 | .accept(MediaType.APPLICATION_JSON) 55 | .contentType(MediaType.APPLICATION_JSON)) 56 | .andExpect(status().isOk()) 57 | .andExpect(jsonPath("$.blocked").value(false)); 58 | } 59 | 60 | @Test 61 | public void ensureServerPreviewLookupSuccess() throws Exception { 62 | mockMvc.perform(get("/server/java/preview/" + testServer) 63 | .contentType(MediaType.IMAGE_PNG)) 64 | .andExpect(status().isOk()); 65 | } 66 | } 67 | --------------------------------------------------------------------------------