diff --git a/src/main/java/xyz/mcutils/backend/common/ColorUtils.java b/src/main/java/xyz/mcutils/backend/common/ColorUtils.java index 8e628c6..38a1715 100644 --- a/src/main/java/xyz/mcutils/backend/common/ColorUtils.java +++ b/src/main/java/xyz/mcutils/backend/common/ColorUtils.java @@ -3,6 +3,7 @@ package xyz.mcutils.backend.common; import lombok.NonNull; import lombok.experimental.UtilityClass; +import java.awt.*; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; @@ -93,4 +94,20 @@ public final class ColorUtils { return builder.toString(); } + + /** + * Gets a {@link Color} from a Minecraft color code. + * + * @param colorCode the color code to get the color from + * @return the color + */ + public static Color getMinecraftColor(char colorCode) { + String color = COLOR_MAP.getOrDefault(colorCode, null); + if (color == null) { + System.out.println("Unknown color code: " + colorCode); + return Color.WHITE; + } + return Color.decode(color); + } + } \ No newline at end of file diff --git a/src/main/java/xyz/mcutils/backend/common/Fonts.java b/src/main/java/xyz/mcutils/backend/common/Fonts.java new file mode 100644 index 0000000..216aab2 --- /dev/null +++ b/src/main/java/xyz/mcutils/backend/common/Fonts.java @@ -0,0 +1,23 @@ +package xyz.mcutils.backend.common; + +import xyz.mcutils.backend.Main; + +import java.awt.*; +import java.io.IOException; +import java.io.InputStream; + +public class Fonts { + + public static final Font MINECRAFT; + public static final Font MINECRAFT_BOLD; + + static { + InputStream stream = Main.class.getResourceAsStream("/fonts/minecraft-font.ttf"); + try { + MINECRAFT = Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(18f); + MINECRAFT_BOLD = MINECRAFT.deriveFont(Font.BOLD); + } catch (FontFormatException | IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/xyz/mcutils/backend/common/ImageUtils.java b/src/main/java/xyz/mcutils/backend/common/ImageUtils.java index fd9dc90..5aa86d4 100644 --- a/src/main/java/xyz/mcutils/backend/common/ImageUtils.java +++ b/src/main/java/xyz/mcutils/backend/common/ImageUtils.java @@ -8,21 +8,23 @@ import javax.imageio.ImageIO; import java.awt.*; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.util.Base64; @Log4j2(topic = "Image Utils") public class ImageUtils { /** - * Scale the given image to the provided size. + * Scale the given image to the provided scale. * * @param image the image to scale - * @param size the size to scale the image to + * @param scale the scale to scale the image to * @return the scaled image */ - public static BufferedImage resize(BufferedImage image, double size) { - BufferedImage scaled = new BufferedImage((int) (image.getWidth() * size), (int) (image.getHeight() * size), BufferedImage.TYPE_INT_ARGB); + public static BufferedImage resize(BufferedImage image, double scale) { + BufferedImage scaled = new BufferedImage((int) (image.getWidth() * scale), (int) (image.getHeight() * scale), BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = scaled.createGraphics(); - graphics.drawImage(image, AffineTransform.getScaleInstance(size, size), null); + graphics.drawImage(image, AffineTransform.getScaleInstance(scale, scale), null); graphics.dispose(); return scaled; } @@ -56,4 +58,21 @@ public class ImageUtils { throw new Exception("Failed to convert image to bytes", e); } } + + /** + * Convert a base64 string to an image. + * + * @param base64 the base64 string to convert + * @return the image + */ + @SneakyThrows + public static BufferedImage base64ToImage(String base64) { + String favicon = base64.contains("data:image/png;base64,") ? base64.split(",")[1] : base64; + + try { + return ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(favicon))); + } catch (Exception e) { + throw new Exception("Failed to convert base64 to image", e); + } + } } diff --git a/src/main/java/xyz/mcutils/backend/common/renderer/Renderer.java b/src/main/java/xyz/mcutils/backend/common/renderer/Renderer.java new file mode 100644 index 0000000..c64324d --- /dev/null +++ b/src/main/java/xyz/mcutils/backend/common/renderer/Renderer.java @@ -0,0 +1,14 @@ +package xyz.mcutils.backend.common.renderer; + +import java.awt.image.BufferedImage; + +public abstract class Renderer { + + /** + * Renders the object to the specified size. + * + * @param input The object to render. + * @param size The size to render the object to. + */ + public abstract BufferedImage render(T input, int size); +} diff --git a/src/main/java/xyz/mcutils/backend/common/renderer/impl/misc/ServerPreviewRenderer.java b/src/main/java/xyz/mcutils/backend/common/renderer/impl/misc/ServerPreviewRenderer.java new file mode 100644 index 0000000..38ee70e --- /dev/null +++ b/src/main/java/xyz/mcutils/backend/common/renderer/impl/misc/ServerPreviewRenderer.java @@ -0,0 +1,167 @@ +package xyz.mcutils.backend.common.renderer.impl.misc; + +import lombok.extern.log4j.Log4j2; +import xyz.mcutils.backend.Main; +import xyz.mcutils.backend.common.ColorUtils; +import xyz.mcutils.backend.common.Fonts; +import xyz.mcutils.backend.common.ImageUtils; +import xyz.mcutils.backend.common.renderer.Renderer; +import xyz.mcutils.backend.model.server.JavaMinecraftServer; +import xyz.mcutils.backend.model.server.MinecraftServer; +import xyz.mcutils.backend.service.ServerService; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; + +@Log4j2 +public class ServerPreviewRenderer extends Renderer { + public static final ServerPreviewRenderer INSTANCE = new ServerPreviewRenderer(); + private static BufferedImage SERVER_BACKGROUND; + private static BufferedImage PING_ICON; + static { + try { + SERVER_BACKGROUND = ImageIO.read(new ByteArrayInputStream(Main.class.getResourceAsStream("/icons/server_background.png").readAllBytes())); + PING_ICON = ImageIO.read(new ByteArrayInputStream(Main.class.getResourceAsStream("/icons/ping.png").readAllBytes())); + } catch (Exception ex) { + log.error("Failed to load server preview assets", ex); + } + } + + private final int fontSize = Fonts.MINECRAFT.getSize(); + private final int width = 560; + private final int height = 64 + 3 + 3; + private final int padding = 3; + + @Override + public BufferedImage render(MinecraftServer server, int size) { + BufferedImage texture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); // The texture to return + BufferedImage favicon = getServerFavicon(server); + BufferedImage background = SERVER_BACKGROUND; + + // Create the graphics for drawing + Graphics2D graphics = texture.createGraphics(); + + // Set up the font + graphics.setFont(Fonts.MINECRAFT); + + // Draw the background + for (int backgroundX = 0; backgroundX < width + background.getWidth(); backgroundX += background.getWidth()) { + for (int backgroundY = 0; backgroundY < height + background.getHeight(); backgroundY += background.getHeight()) { + graphics.drawImage(background, backgroundX, backgroundY, null); + } + } + + int y = fontSize + 1; + int x = 64 + 8; + int initialX = x; // Store the initial value of x + + // Draw the favicon + graphics.drawImage(favicon, padding, padding, null); + + // Draw the server hostname + graphics.setColor(Color.WHITE); + graphics.drawString(server.getHostname(), x, y); + + // Draw the server motd + y += fontSize + (padding * 2); + for (String line : server.getMotd().getRaw()) { + int index = 0; + int colorIndex = line.indexOf("§"); + while (colorIndex != -1) { + // Draw text before color code + String textBeforeColor = line.substring(index, colorIndex); + graphics.drawString(textBeforeColor, x, y); + // Calculate width of text before color code + int textWidth = graphics.getFontMetrics().stringWidth(textBeforeColor); + // Move x position to after the drawn text + x += textWidth; + // Set color based on color code + char colorCode = line.charAt(colorIndex + 1); + + if (colorCode == 'l') { + graphics.setFont(Fonts.MINECRAFT_BOLD); + } else { + Color color = ColorUtils.getMinecraftColor(colorCode); + graphics.setColor(color); + graphics.setFont(Fonts.MINECRAFT); + } + + // Move index to after the color code + index = colorIndex + 2; + // Find next color code + colorIndex = line.indexOf("§", index); + } + // Draw remaining text + String remainingText = line.substring(index); + graphics.drawString(remainingText, x, y); + // Move to the next line + y += fontSize + padding; + // Reset x position for the next line + x = initialX; // Reset x to its initial value + } + + // Ensure the font is reset + graphics.setFont(Fonts.MINECRAFT); + + // Render the ping + BufferedImage pingIcon = ImageUtils.resize(PING_ICON, 2); + x = width - pingIcon.getWidth() - padding; + graphics.drawImage(pingIcon, x, padding, null); + + // Reset the y position + y = fontSize + 1; + + // Render the player count + MinecraftServer.Players players = server.getPlayers(); + String playersOnline = players.getOnline() + ""; + String playersMax = players.getMax() + ""; + + // Calculate the width of each player count element + int maxWidth = graphics.getFontMetrics().stringWidth(playersMax); + int slashWidth = graphics.getFontMetrics().stringWidth("/"); + int onlineWidth = graphics.getFontMetrics().stringWidth(playersOnline); + + // Calculate the total width of the player count string + int totalWidth = maxWidth + slashWidth + onlineWidth; + + // Calculate the starting x position + int startX = (width - totalWidth) - pingIcon.getWidth() - 6; + + // Render the player count elements + graphics.setColor(Color.LIGHT_GRAY); + graphics.drawString(playersOnline, startX, y); + startX += onlineWidth; + graphics.setColor(Color.DARK_GRAY); + graphics.drawString("/", startX, y); + startX += slashWidth; + graphics.setColor(Color.LIGHT_GRAY); + graphics.drawString(playersMax, startX, y); + + return ImageUtils.resize(texture, (double) size / width); + } + + /** + * Get the favicon of a server. + * + * @param server the server to get the favicon of + * @return the server favicon + */ + public BufferedImage getServerFavicon(MinecraftServer server) { + String favicon = null; + + // Get the server favicon + if (server instanceof JavaMinecraftServer javaServer) { + if (javaServer.getFavicon() != null) { + favicon = javaServer.getFavicon().getBase64(); + } + } + + // Fallback to the default server icon + if (favicon == null) { + favicon = ServerService.DEFAULT_SERVER_ICON; + } + return ImageUtils.base64ToImage(favicon); + } +} diff --git a/src/main/java/xyz/mcutils/backend/common/renderer/impl/BodyRenderer.java b/src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/BodyRenderer.java similarity index 97% rename from src/main/java/xyz/mcutils/backend/common/renderer/impl/BodyRenderer.java rename to src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/BodyRenderer.java index 50f6d35..1aab626 100644 --- a/src/main/java/xyz/mcutils/backend/common/renderer/impl/BodyRenderer.java +++ b/src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/BodyRenderer.java @@ -1,4 +1,4 @@ -package xyz.mcutils.backend.common.renderer.impl; +package xyz.mcutils.backend.common.renderer.impl.skin; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/xyz/mcutils/backend/common/renderer/impl/IsometricHeadRenderer.java b/src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/IsometricHeadRenderer.java similarity index 97% rename from src/main/java/xyz/mcutils/backend/common/renderer/impl/IsometricHeadRenderer.java rename to src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/IsometricHeadRenderer.java index f87c1e2..66074bb 100644 --- a/src/main/java/xyz/mcutils/backend/common/renderer/impl/IsometricHeadRenderer.java +++ b/src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/IsometricHeadRenderer.java @@ -1,4 +1,4 @@ -package xyz.mcutils.backend.common.renderer.impl; +package xyz.mcutils.backend.common.renderer.impl.skin; import xyz.mcutils.backend.common.renderer.IsometricSkinRenderer; import xyz.mcutils.backend.model.skin.ISkinPart; diff --git a/src/main/java/xyz/mcutils/backend/common/renderer/impl/SquareRenderer.java b/src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/SquareRenderer.java similarity index 95% rename from src/main/java/xyz/mcutils/backend/common/renderer/impl/SquareRenderer.java rename to src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/SquareRenderer.java index 214111e..9049b18 100644 --- a/src/main/java/xyz/mcutils/backend/common/renderer/impl/SquareRenderer.java +++ b/src/main/java/xyz/mcutils/backend/common/renderer/impl/skin/SquareRenderer.java @@ -1,4 +1,4 @@ -package xyz.mcutils.backend.common.renderer.impl; +package xyz.mcutils.backend.common.renderer.impl.skin; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/xyz/mcutils/backend/controller/ServerController.java b/src/main/java/xyz/mcutils/backend/controller/ServerController.java index 9fb1127..244df7d 100644 --- a/src/main/java/xyz/mcutils/backend/controller/ServerController.java +++ b/src/main/java/xyz/mcutils/backend/controller/ServerController.java @@ -56,6 +56,23 @@ public class ServerController { .body(favicon); } + @ResponseBody + @GetMapping(value = "/preview/{platform}/{hostname}", produces = MediaType.IMAGE_PNG_VALUE) + public ResponseEntity getServerPreview( + @Parameter(description = "The platform of the server", example = "java") @PathVariable String platform, + @Parameter(description = "The hostname and port of the server", example = "aetheria.cc") @PathVariable String hostname, + @Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download, + @Parameter(description = "The size of the image", example = "1024") @RequestParam(required = false, defaultValue = "1024") int size) { + String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png"; + CachedMinecraftServer server = serverService.getServer(platform, hostname); + + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) + .contentType(MediaType.IMAGE_PNG) + .header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(hostname)) + .body(serverService.getServerPreview(server, platform, size)); + } + @ResponseBody @GetMapping(value = "/blocked/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity getServerBlockedStatus( diff --git a/src/main/java/xyz/mcutils/backend/model/cache/CachedServerPreview.java b/src/main/java/xyz/mcutils/backend/model/cache/CachedServerPreview.java new file mode 100644 index 0000000..2c1134e --- /dev/null +++ b/src/main/java/xyz/mcutils/backend/model/cache/CachedServerPreview.java @@ -0,0 +1,21 @@ +package xyz.mcutils.backend.model.cache; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@AllArgsConstructor +@Setter @Getter @EqualsAndHashCode +@RedisHash(value = "serverPreview", timeToLive = 60L * 5) // 5 minutes (in seconds) +public class CachedServerPreview { + + /** + * The ID of the server preview + */ + @Id @NonNull private String id; + + /** + * The server preview bytes + */ + private byte[] bytes; +} diff --git a/src/main/java/xyz/mcutils/backend/model/skin/ISkinPart.java b/src/main/java/xyz/mcutils/backend/model/skin/ISkinPart.java index 17ebca5..2286123 100644 --- a/src/main/java/xyz/mcutils/backend/model/skin/ISkinPart.java +++ b/src/main/java/xyz/mcutils/backend/model/skin/ISkinPart.java @@ -3,9 +3,9 @@ package xyz.mcutils.backend.model.skin; import lombok.AllArgsConstructor; import lombok.Getter; import xyz.mcutils.backend.common.renderer.SkinRenderer; -import xyz.mcutils.backend.common.renderer.impl.BodyRenderer; -import xyz.mcutils.backend.common.renderer.impl.IsometricHeadRenderer; -import xyz.mcutils.backend.common.renderer.impl.SquareRenderer; +import xyz.mcutils.backend.common.renderer.impl.skin.BodyRenderer; +import xyz.mcutils.backend.common.renderer.impl.skin.IsometricHeadRenderer; +import xyz.mcutils.backend.common.renderer.impl.skin.SquareRenderer; import java.awt.image.BufferedImage; diff --git a/src/main/java/xyz/mcutils/backend/repository/redis/ServerPreviewCacheRepository.java b/src/main/java/xyz/mcutils/backend/repository/redis/ServerPreviewCacheRepository.java new file mode 100644 index 0000000..13ad103 --- /dev/null +++ b/src/main/java/xyz/mcutils/backend/repository/redis/ServerPreviewCacheRepository.java @@ -0,0 +1,9 @@ +package xyz.mcutils.backend.repository.redis; + +import org.springframework.data.repository.CrudRepository; +import xyz.mcutils.backend.model.cache.CachedServerPreview; + +/** + * A cache repository for server previews. + */ +public interface ServerPreviewCacheRepository extends CrudRepository { } \ No newline at end of file diff --git a/src/main/java/xyz/mcutils/backend/service/PlayerService.java b/src/main/java/xyz/mcutils/backend/service/PlayerService.java index 649cf38..94d27ce 100644 --- a/src/main/java/xyz/mcutils/backend/service/PlayerService.java +++ b/src/main/java/xyz/mcutils/backend/service/PlayerService.java @@ -135,12 +135,10 @@ public class PlayerService { */ public CachedPlayerSkinPart getSkinPart(Player player, String partName, boolean renderOverlay, int size) { if (size > 512) { - log.info("Size {} is too large, setting to 512", size); - size = 512; + throw new BadRequestException("Size cannot be larger than 512"); } if (size < 32) { - log.info("Size {} is too small, setting to 32", size); - size = 32; + throw new BadRequestException("Size cannot be smaller than 32"); } ISkinPart part = ISkinPart.getByName(partName); // The skin part to get diff --git a/src/main/java/xyz/mcutils/backend/service/ServerService.java b/src/main/java/xyz/mcutils/backend/service/ServerService.java index a18b861..05e3802 100644 --- a/src/main/java/xyz/mcutils/backend/service/ServerService.java +++ b/src/main/java/xyz/mcutils/backend/service/ServerService.java @@ -6,15 +6,19 @@ import org.springframework.stereotype.Service; import xyz.mcutils.backend.common.AppConfig; import xyz.mcutils.backend.common.DNSUtils; import xyz.mcutils.backend.common.EnumUtils; +import xyz.mcutils.backend.common.ImageUtils; +import xyz.mcutils.backend.common.renderer.impl.misc.ServerPreviewRenderer; import xyz.mcutils.backend.exception.impl.BadRequestException; import xyz.mcutils.backend.exception.impl.ResourceNotFoundException; import xyz.mcutils.backend.model.cache.CachedMinecraftServer; +import xyz.mcutils.backend.model.cache.CachedServerPreview; import xyz.mcutils.backend.model.dns.DNSRecord; import xyz.mcutils.backend.model.dns.impl.ARecord; import xyz.mcutils.backend.model.dns.impl.SRVRecord; import xyz.mcutils.backend.model.server.JavaMinecraftServer; import xyz.mcutils.backend.model.server.MinecraftServer; import xyz.mcutils.backend.repository.redis.MinecraftServerCacheRepository; +import xyz.mcutils.backend.repository.redis.ServerPreviewCacheRepository; import xyz.mcutils.backend.service.metric.metrics.UniqueServerLookupsMetric; import java.net.InetSocketAddress; @@ -25,17 +29,20 @@ import java.util.Optional; @Service @Log4j2(topic = "Server Service") public class ServerService { - private static final String DEFAULT_SERVER_ICON = "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"; + public static final String DEFAULT_SERVER_ICON = "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"; private final MojangService mojangService; private final MetricService metricService; private final MinecraftServerCacheRepository serverCacheRepository; + private final ServerPreviewCacheRepository serverPreviewCacheRepository; @Autowired - public ServerService(MojangService mojangService, MetricService metricService, MinecraftServerCacheRepository serverCacheRepository) { + public ServerService(MojangService mojangService, MetricService metricService, MinecraftServerCacheRepository serverCacheRepository, + ServerPreviewCacheRepository serverPreviewCacheRepository) { this.mojangService = mojangService; this.metricService = metricService; this.serverCacheRepository = serverCacheRepository; + this.serverPreviewCacheRepository = serverPreviewCacheRepository; } /** @@ -131,4 +138,39 @@ public class ServerService { } return Base64.getDecoder().decode(icon); // Return the decoded favicon } + + /** + * Gets the server list preview image. + * + * @param cachedServer the server to get the preview of + * @param platform the platform of the server + * @param size the size of the preview + * @return the server preview + */ + public byte[] getServerPreview(CachedMinecraftServer cachedServer, String platform, int size) { + if (size > 2048) { + throw new BadRequestException("Size cannot be greater than 2048"); + } + if (size < 256) { + throw new BadRequestException("Size cannot be smaller than 256"); + } + MinecraftServer server = cachedServer.getServer(); + log.info("Getting preview for server: {}:{} (size {})", server.getHostname(), server.getPort(), size); + String key = "%s-%s:%s".formatted(platform, server.getHostname(), server.getPort()); + + // Check if the server preview is cached + Optional cached = serverPreviewCacheRepository.findById(key); + if (cached.isPresent() && AppConfig.isProduction()) { + log.info("Server preview for {}:{} is cached", server.getHostname(), server.getPort()); + return cached.get().getBytes(); + } + + long start = System.currentTimeMillis(); + byte[] preview = ImageUtils.imageToBytes(ServerPreviewRenderer.INSTANCE.render(server, size)); + log.info("Took {}ms to render preview for server: {}:{}", System.currentTimeMillis() - start, server.getHostname(), server.getPort()); + + CachedServerPreview serverPreview = new CachedServerPreview(key, preview); + serverPreviewCacheRepository.save(serverPreview); + return preview; + } } diff --git a/src/main/resources/fonts/minecraft-font.ttf b/src/main/resources/fonts/minecraft-font.ttf new file mode 100644 index 0000000..d1cd525 Binary files /dev/null and b/src/main/resources/fonts/minecraft-font.ttf differ diff --git a/src/main/resources/icons/ping.png b/src/main/resources/icons/ping.png new file mode 100644 index 0000000..f152a0a Binary files /dev/null and b/src/main/resources/icons/ping.png differ diff --git a/src/main/resources/icons/server_background.png b/src/main/resources/icons/server_background.png new file mode 100644 index 0000000..91b485d Binary files /dev/null and b/src/main/resources/icons/server_background.png differ