rename the package
This commit is contained in:
261
src/main/java/xyz/mcutils/backend/service/MojangService.java
Normal file
261
src/main/java/xyz/mcutils/backend/service/MojangService.java
Normal file
@ -0,0 +1,261 @@
|
||||
package xyz.mcutils.backend.service;
|
||||
|
||||
import xyz.mcutils.backend.Main;
|
||||
import cc.fascinated.common.EndpointStatus;
|
||||
import cc.fascinated.common.ExpiringSet;
|
||||
import cc.fascinated.common.WebRequest;
|
||||
import cc.fascinated.config.Config;
|
||||
import cc.fascinated.model.cache.CachedEndpointStatus;
|
||||
import cc.fascinated.model.token.MojangProfileToken;
|
||||
import cc.fascinated.model.token.MojangUsernameToUuidToken;
|
||||
import xyz.mcutils.backend.repository.EndpointStatusRepository;
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.hash.Hashing;
|
||||
import io.micrometer.common.lang.NonNull;
|
||||
import lombok.Getter;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import net.jodah.expiringmap.ExpirationPolicy;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service @Log4j2 @Getter
|
||||
public class MojangService {
|
||||
|
||||
/**
|
||||
* The splitter and joiner for dots.
|
||||
*/
|
||||
private static final Splitter DOT_SPLITTER = Splitter.on('.');
|
||||
private static final Joiner DOT_JOINER = Joiner.on('.');
|
||||
|
||||
/**
|
||||
* The Mojang API endpoints.
|
||||
*/
|
||||
private static final String SESSION_SERVER_ENDPOINT = "https://sessionserver.mojang.com";
|
||||
private static final String API_ENDPOINT = "https://api.mojang.com";
|
||||
private static final String FETCH_BLOCKED_SERVERS = SESSION_SERVER_ENDPOINT + "/blockedservers";
|
||||
|
||||
/**
|
||||
* The interval to fetch the blocked servers from Mojang.
|
||||
*/
|
||||
private static final long FETCH_BLOCKED_SERVERS_INTERVAL = TimeUnit.HOURS.toMillis(1L);
|
||||
|
||||
/**
|
||||
* Information about the Mojang API endpoints.
|
||||
*/
|
||||
private static final String MOJANG_ENDPOINT_STATUS_KEY = "mojang";
|
||||
private static final List<EndpointStatus> MOJANG_ENDPOINTS = List.of(
|
||||
new EndpointStatus("https://textures.minecraft.net", List.of(HttpStatus.BAD_REQUEST)),
|
||||
new EndpointStatus("https://session.minecraft.net", List.of(HttpStatus.NOT_FOUND)),
|
||||
new EndpointStatus("https://libraries.minecraft.net", List.of(HttpStatus.NOT_FOUND)),
|
||||
new EndpointStatus("https://assets.mojang.com", List.of(HttpStatus.NOT_FOUND)),
|
||||
new EndpointStatus("https://api.minecraftservices.com", List.of(HttpStatus.FORBIDDEN)),
|
||||
new EndpointStatus(API_ENDPOINT, List.of(HttpStatus.OK)),
|
||||
new EndpointStatus(SESSION_SERVER_ENDPOINT, List.of(HttpStatus.FORBIDDEN))
|
||||
);
|
||||
|
||||
@Autowired
|
||||
private EndpointStatusRepository mojangEndpointStatusRepository;
|
||||
|
||||
/**
|
||||
* A list of banned server hashes provided by Mojang.
|
||||
* <p>
|
||||
* This is periodically fetched from Mojang, see
|
||||
* {@link #fetchBlockedServers()} for more info.
|
||||
* </p>
|
||||
*
|
||||
* @see <a href="https://wiki.vg/Mojang_API#Blocked_Servers">Mojang API</a>
|
||||
*/
|
||||
private List<String> bannedServerHashes;
|
||||
|
||||
/**
|
||||
* A cache of blocked server hostnames.
|
||||
*
|
||||
* @see #isServerHostnameBlocked(String) for more
|
||||
*/
|
||||
private final ExpiringSet<String> blockedServersCache = new ExpiringSet<>(ExpirationPolicy.CREATED, 10L, TimeUnit.MINUTES);
|
||||
|
||||
public MojangService() {
|
||||
new Timer().scheduleAtFixedRate(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
fetchBlockedServers();
|
||||
}
|
||||
}, 0L, FETCH_BLOCKED_SERVERS_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of blocked servers from Mojang.
|
||||
*/
|
||||
@SneakyThrows
|
||||
private void fetchBlockedServers() {
|
||||
log.info("Fetching blocked servers from Mojang");
|
||||
try (
|
||||
InputStream inputStream = new URL(FETCH_BLOCKED_SERVERS).openStream();
|
||||
Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8).useDelimiter("\n");
|
||||
) {
|
||||
List<String> hashes = new ArrayList<>();
|
||||
while (scanner.hasNext()) {
|
||||
hashes.add(scanner.next());
|
||||
}
|
||||
bannedServerHashes = Collections.synchronizedList(hashes);
|
||||
log.info("Fetched {} banned server hashes", bannedServerHashes.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server with the
|
||||
* given hostname is blocked by Mojang.
|
||||
*
|
||||
* @param hostname the server hostname to check
|
||||
* @return whether the hostname is blocked
|
||||
*/
|
||||
public boolean isServerBlocked(@NonNull String hostname) {
|
||||
// Remove trailing dots
|
||||
while (hostname.charAt(hostname.length() - 1) == '.') {
|
||||
hostname = hostname.substring(0, hostname.length() - 1);
|
||||
}
|
||||
// Is the hostname banned?
|
||||
if (isServerHostnameBlocked(hostname)) {
|
||||
return true;
|
||||
}
|
||||
List<String> splitDots = Lists.newArrayList(DOT_SPLITTER.split(hostname)); // Split the hostname by dots
|
||||
boolean isIp = splitDots.size() == 4; // Is it an IP address?
|
||||
if (isIp) {
|
||||
for (String element : splitDots) {
|
||||
try {
|
||||
int part = Integer.parseInt(element);
|
||||
if (part >= 0 && part <= 255) { // Ensure the part is within the valid range
|
||||
continue;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Safely ignore, not a number
|
||||
}
|
||||
isIp = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Check if the hostname is blocked
|
||||
if (!isIp && isServerHostnameBlocked("*." + hostname)) {
|
||||
return true;
|
||||
}
|
||||
// Additional checks for the hostname
|
||||
while (splitDots.size() > 1) {
|
||||
splitDots.remove(isIp ? splitDots.size() - 1 : 0);
|
||||
String starredPart = isIp ? DOT_JOINER.join(splitDots) + ".*" : "*." + DOT_JOINER.join(splitDots);
|
||||
if (isServerHostnameBlocked(starredPart)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the hash for the given
|
||||
* hostname is in the blocked server list.
|
||||
*
|
||||
* @param hostname the hostname to check
|
||||
* @return whether the hostname is blocked
|
||||
*/
|
||||
private boolean isServerHostnameBlocked(@NonNull String hostname) {
|
||||
// Check the cache first for the hostname
|
||||
if (blockedServersCache.contains(hostname)) {
|
||||
return true;
|
||||
}
|
||||
String hashed = Hashing.sha1().hashBytes(hostname.toLowerCase().getBytes(StandardCharsets.ISO_8859_1)).toString();
|
||||
boolean blocked = bannedServerHashes.contains(hashed); // Is the hostname blocked?
|
||||
if (blocked) { // Cache the blocked hostname
|
||||
blockedServersCache.add(hostname);
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status of the Mojang APIs.
|
||||
*
|
||||
* @return the status
|
||||
*/
|
||||
public CachedEndpointStatus getMojangApiStatus() {
|
||||
log.info("Getting Mojang API status");
|
||||
Optional<CachedEndpointStatus> endpointStatus = mojangEndpointStatusRepository.findById(MOJANG_ENDPOINT_STATUS_KEY);
|
||||
if (endpointStatus.isPresent() && Config.INSTANCE.isProduction()) {
|
||||
log.info("Got cached Mojang API status");
|
||||
return endpointStatus.get();
|
||||
}
|
||||
|
||||
// Fetch the status of the Mojang API endpoints
|
||||
List<CompletableFuture<CachedEndpointStatus.Status>> futures = new ArrayList<>();
|
||||
for (EndpointStatus endpoint : MOJANG_ENDPOINTS) {
|
||||
CompletableFuture<CachedEndpointStatus.Status> future = CompletableFuture.supplyAsync(() -> {
|
||||
boolean online = false;
|
||||
long start = System.currentTimeMillis();
|
||||
ResponseEntity<?> response = WebRequest.head(endpoint.getEndpoint(), String.class);
|
||||
if (endpoint.getAllowedStatuses().contains(response.getStatusCode())) {
|
||||
online = true;
|
||||
}
|
||||
if (online && System.currentTimeMillis() - start > 1000) { // If the response took longer than 1 second
|
||||
return CachedEndpointStatus.Status.DEGRADED;
|
||||
}
|
||||
return online ? CachedEndpointStatus.Status.ONLINE : CachedEndpointStatus.Status.OFFLINE;
|
||||
}, Main.EXECUTOR_POOL);
|
||||
|
||||
futures.add(future);
|
||||
}
|
||||
CompletableFuture<Void> allFutures = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
|
||||
try {
|
||||
allFutures.get(5, TimeUnit.SECONDS); // Wait for the futures to complete
|
||||
} catch (Exception e) {
|
||||
log.error("Timeout while fetching Mojang API status: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Process the results
|
||||
Map<String, CachedEndpointStatus.Status> endpoints = new HashMap<>();
|
||||
for (int i = 0; i < MOJANG_ENDPOINTS.size(); i++) {
|
||||
EndpointStatus endpoint = MOJANG_ENDPOINTS.get(i);
|
||||
CachedEndpointStatus.Status status = futures.get(i).join();
|
||||
endpoints.put(endpoint.getEndpoint(), status);
|
||||
}
|
||||
|
||||
log.info("Fetched Mojang API status for {} endpoints", endpoints.size());
|
||||
CachedEndpointStatus status = new CachedEndpointStatus(
|
||||
MOJANG_ENDPOINT_STATUS_KEY,
|
||||
endpoints
|
||||
);
|
||||
mojangEndpointStatusRepository.save(status);
|
||||
status.getCache().setCached(false);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Session Server profile of the
|
||||
* player with the given UUID.
|
||||
*
|
||||
* @param id the uuid or name of the player
|
||||
* @return the profile
|
||||
*/
|
||||
public MojangProfileToken getProfile(String id) {
|
||||
return WebRequest.getAsEntity(SESSION_SERVER_ENDPOINT + "/session/minecraft/profile/" + id, MojangProfileToken.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the player using
|
||||
* the name of the player.
|
||||
*
|
||||
* @param id the name of the player
|
||||
* @return the profile
|
||||
*/
|
||||
public MojangUsernameToUuidToken getUuidFromUsername(String id) {
|
||||
return WebRequest.getAsEntity(API_ENDPOINT + "/users/profiles/minecraft/" + id, MojangUsernameToUuidToken.class);
|
||||
}
|
||||
}
|
172
src/main/java/xyz/mcutils/backend/service/PlayerService.java
Normal file
172
src/main/java/xyz/mcutils/backend/service/PlayerService.java
Normal file
@ -0,0 +1,172 @@
|
||||
package xyz.mcutils.backend.service;
|
||||
|
||||
import cc.fascinated.common.ImageUtils;
|
||||
import cc.fascinated.common.PlayerUtils;
|
||||
import cc.fascinated.common.Tuple;
|
||||
import cc.fascinated.common.UUIDUtils;
|
||||
import cc.fascinated.config.Config;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.exception.impl.MojangAPIRateLimitException;
|
||||
import cc.fascinated.exception.impl.RateLimitException;
|
||||
import cc.fascinated.exception.impl.ResourceNotFoundException;
|
||||
import cc.fascinated.model.cache.CachedPlayer;
|
||||
import cc.fascinated.model.cache.CachedPlayerName;
|
||||
import cc.fascinated.model.cache.CachedPlayerSkinPart;
|
||||
import cc.fascinated.model.player.Cape;
|
||||
import cc.fascinated.model.player.Player;
|
||||
import cc.fascinated.model.skin.ISkinPart;
|
||||
import cc.fascinated.model.skin.Skin;
|
||||
import cc.fascinated.model.token.MojangProfileToken;
|
||||
import cc.fascinated.model.token.MojangUsernameToUuidToken;
|
||||
import xyz.mcutils.backend.repository.PlayerCacheRepository;
|
||||
import xyz.mcutils.backend.repository.PlayerNameCacheRepository;
|
||||
import xyz.mcutils.backend.repository.PlayerSkinPartCacheRepository;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service @Log4j2
|
||||
public class PlayerService {
|
||||
|
||||
private final MojangService mojangAPIService;
|
||||
private final PlayerCacheRepository playerCacheRepository;
|
||||
private final PlayerNameCacheRepository playerNameCacheRepository;
|
||||
private final PlayerSkinPartCacheRepository playerSkinPartCacheRepository;
|
||||
|
||||
@Autowired
|
||||
public PlayerService(MojangService mojangAPIService, PlayerCacheRepository playerCacheRepository,
|
||||
PlayerNameCacheRepository playerNameCacheRepository, PlayerSkinPartCacheRepository playerSkinPartCacheRepository) {
|
||||
this.mojangAPIService = mojangAPIService;
|
||||
this.playerCacheRepository = playerCacheRepository;
|
||||
this.playerNameCacheRepository = playerNameCacheRepository;
|
||||
this.playerSkinPartCacheRepository = playerSkinPartCacheRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a player from the cache or
|
||||
* from the Mojang API.
|
||||
*
|
||||
* @param id the id of the player
|
||||
* @return the player
|
||||
*/
|
||||
public CachedPlayer getPlayer(String id) {
|
||||
// Convert the id to uppercase to prevent case sensitivity
|
||||
log.info("Getting player: {}", id);
|
||||
UUID uuid = PlayerUtils.getUuidFromString(id);
|
||||
if (uuid == null) { // If the id is not a valid uuid, get the uuid from the username
|
||||
uuid = usernameToUuid(id).getUniqueId();
|
||||
}
|
||||
|
||||
Optional<CachedPlayer> cachedPlayer = playerCacheRepository.findById(uuid);
|
||||
if (cachedPlayer.isPresent() && Config.INSTANCE.isProduction()) { // Return the cached player if it exists
|
||||
log.info("Player {} is cached", id);
|
||||
return cachedPlayer.get();
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Getting player profile from Mojang: {}", id);
|
||||
MojangProfileToken mojangProfile = mojangAPIService.getProfile(uuid.toString()); // Get the player profile from Mojang
|
||||
log.info("Got player profile from Mojang: {}", id);
|
||||
Tuple<Skin, Cape> skinAndCape = mojangProfile.getSkinAndCape();
|
||||
CachedPlayer player = new CachedPlayer(
|
||||
uuid, // Player UUID
|
||||
new Player(
|
||||
uuid, // Player UUID
|
||||
UUIDUtils.removeDashes(uuid), // Trimmed UUID
|
||||
mojangProfile.getName(), // Player Name
|
||||
skinAndCape.getLeft(), // Skin
|
||||
skinAndCape.getRight(), // Cape
|
||||
mojangProfile.getProperties() // Raw properties
|
||||
)
|
||||
);
|
||||
|
||||
playerCacheRepository.save(player);
|
||||
player.getCache().setCached(false);
|
||||
return player;
|
||||
} catch (RateLimitException exception) {
|
||||
throw new MojangAPIRateLimitException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player's uuid from their username.
|
||||
*
|
||||
* @param username the username of the player
|
||||
* @return the uuid of the player
|
||||
*/
|
||||
public CachedPlayerName usernameToUuid(String username) {
|
||||
log.info("Getting UUID from username: {}", username);
|
||||
String id = username.toUpperCase();
|
||||
Optional<CachedPlayerName> cachedPlayerName = playerNameCacheRepository.findById(id);
|
||||
if (cachedPlayerName.isPresent() && Config.INSTANCE.isProduction()) {
|
||||
return cachedPlayerName.get();
|
||||
}
|
||||
try {
|
||||
MojangUsernameToUuidToken mojangUsernameToUuid = mojangAPIService.getUuidFromUsername(username);
|
||||
if (mojangUsernameToUuid == null) {
|
||||
log.info("Player with username '{}' not found", username);
|
||||
throw new ResourceNotFoundException("Player with username '%s' not found".formatted(username));
|
||||
}
|
||||
UUID uuid = UUIDUtils.addDashes(mojangUsernameToUuid.getUuid());
|
||||
CachedPlayerName player = new CachedPlayerName(id, username, uuid);
|
||||
playerNameCacheRepository.save(player);
|
||||
log.info("Got UUID from username: {} -> {}", username, uuid);
|
||||
player.getCache().setCached(false);
|
||||
return player;
|
||||
} catch (RateLimitException exception) {
|
||||
throw new MojangAPIRateLimitException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a skin part from the player's skin.
|
||||
*
|
||||
* @param player the player
|
||||
* @param partName the name of the part
|
||||
* @param renderOverlay whether to render the overlay
|
||||
* @return the skin part
|
||||
*/
|
||||
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;
|
||||
}
|
||||
if (size < 32) {
|
||||
log.info("Size {} is too small, setting to 32", size);
|
||||
size = 32;
|
||||
}
|
||||
|
||||
ISkinPart part = ISkinPart.getByName(partName); // The skin part to get
|
||||
if (part == null) {
|
||||
throw new BadRequestException("Invalid skin part: %s".formatted(partName));
|
||||
}
|
||||
|
||||
String name = part.name();
|
||||
log.info("Getting skin part {} for player: {} (size: {}, overlays: {})", name, player.getUniqueId(), size, renderOverlay);
|
||||
String key = "%s-%s-%s-%s".formatted(player.getUniqueId(), name, size, renderOverlay);
|
||||
|
||||
Optional<CachedPlayerSkinPart> cache = playerSkinPartCacheRepository.findById(key);
|
||||
|
||||
// The skin part is cached
|
||||
if (cache.isPresent() && Config.INSTANCE.isProduction()) {
|
||||
log.info("Skin part {} for player {} is cached", name, player.getUniqueId());
|
||||
return cache.get();
|
||||
}
|
||||
|
||||
long before = System.currentTimeMillis();
|
||||
BufferedImage renderedPart = part.render(player.getSkin(), renderOverlay, size); // Render the skin part
|
||||
log.info("Took {}ms to render skin part {} for player: {}", System.currentTimeMillis() - before, name, player.getUniqueId());
|
||||
|
||||
CachedPlayerSkinPart skinPart = new CachedPlayerSkinPart(
|
||||
key,
|
||||
ImageUtils.imageToBytes(renderedPart)
|
||||
);
|
||||
log.info("Fetched skin part {} for player: {}", name, player.getUniqueId());
|
||||
playerSkinPartCacheRepository.save(skinPart);
|
||||
return skinPart;
|
||||
}
|
||||
}
|
127
src/main/java/xyz/mcutils/backend/service/ServerService.java
Normal file
127
src/main/java/xyz/mcutils/backend/service/ServerService.java
Normal file
@ -0,0 +1,127 @@
|
||||
package xyz.mcutils.backend.service;
|
||||
|
||||
import cc.fascinated.common.DNSUtils;
|
||||
import cc.fascinated.common.EnumUtils;
|
||||
import cc.fascinated.config.Config;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.exception.impl.ResourceNotFoundException;
|
||||
import cc.fascinated.model.cache.CachedMinecraftServer;
|
||||
import cc.fascinated.model.dns.DNSRecord;
|
||||
import cc.fascinated.model.dns.impl.ARecord;
|
||||
import cc.fascinated.model.dns.impl.SRVRecord;
|
||||
import cc.fascinated.model.server.JavaMinecraftServer;
|
||||
import cc.fascinated.model.server.MinecraftServer;
|
||||
import xyz.mcutils.backend.repository.MinecraftServerCacheRepository;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service @Log4j2
|
||||
public class ServerService {
|
||||
private static final String DEFAULT_SERVER_ICON = "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";
|
||||
|
||||
private final MojangService mojangService;
|
||||
private final MinecraftServerCacheRepository serverCacheRepository;
|
||||
|
||||
@Autowired
|
||||
public ServerService(MojangService mojangService, MinecraftServerCacheRepository serverCacheRepository) {
|
||||
this.mojangService = mojangService;
|
||||
this.serverCacheRepository = serverCacheRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping a server to get the server information.
|
||||
*
|
||||
* @param platformName the name of the platform
|
||||
* @param hostname the hostname of the server
|
||||
* @return the server
|
||||
*/
|
||||
public CachedMinecraftServer getServer(String platformName, String hostname) {
|
||||
MinecraftServer.Platform platform = EnumUtils.getEnumConstant(MinecraftServer.Platform.class, platformName.toUpperCase());
|
||||
if (platform == null) {
|
||||
log.info("Invalid platform: {} for server {}", platformName, hostname);
|
||||
throw new BadRequestException("Invalid platform: %s".formatted(platformName));
|
||||
}
|
||||
int port = platform.getDefaultPort();
|
||||
if (hostname.contains(":")) {
|
||||
String[] parts = hostname.split(":");
|
||||
hostname = parts[0];
|
||||
try {
|
||||
port = Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
log.info("Invalid port: {} for server {}", parts[1], hostname);
|
||||
throw new BadRequestException("Invalid port: %s".formatted(parts[1]));
|
||||
}
|
||||
}
|
||||
String key = "%s-%s:%s".formatted(platformName, hostname, port);
|
||||
log.info("Getting server: {}:{}", hostname, port);
|
||||
|
||||
// Check if the server is cached
|
||||
Optional<CachedMinecraftServer> cached = serverCacheRepository.findById(key);
|
||||
if (cached.isPresent() && Config.INSTANCE.isProduction()) {
|
||||
log.info("Server {}:{} is cached", hostname, port);
|
||||
return cached.get();
|
||||
}
|
||||
|
||||
List<DNSRecord> records = new ArrayList<>(); // The resolved DNS records for the server
|
||||
|
||||
SRVRecord srvRecord = platform == MinecraftServer.Platform.JAVA ? DNSUtils.resolveSRV(hostname) : null; // Resolve the SRV record
|
||||
if (srvRecord != null) { // SRV was resolved, use the hostname and port
|
||||
records.add(srvRecord); // Going to need this for later
|
||||
InetSocketAddress socketAddress = srvRecord.getSocketAddress();
|
||||
hostname = socketAddress.getHostName();
|
||||
port = socketAddress.getPort();
|
||||
}
|
||||
|
||||
ARecord aRecord = DNSUtils.resolveA(hostname); // Resolve the A record so we can get the IPv4 address
|
||||
String ip = aRecord == null ? null : aRecord.getAddress(); // Get the IP address
|
||||
if (ip != null) { // Was the IP resolved?
|
||||
records.add(aRecord); // Going to need this for later
|
||||
log.info("Resolved hostname: {} -> {}", hostname, ip);
|
||||
}
|
||||
|
||||
CachedMinecraftServer server = new CachedMinecraftServer(
|
||||
key,
|
||||
platform.getPinger().ping(hostname, ip, port, records.toArray(new DNSRecord[0]))
|
||||
);
|
||||
|
||||
// Check if the server is blocked by Mojang
|
||||
if (platform == MinecraftServer.Platform.JAVA) {
|
||||
((JavaMinecraftServer) server.getServer()).setMojangBlocked(mojangService.isServerBlocked(hostname));
|
||||
}
|
||||
|
||||
log.info("Found server: {}:{}", hostname, port);
|
||||
serverCacheRepository.save(server);
|
||||
server.getCache().setCached(false);
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the server favicon.
|
||||
*
|
||||
* @param hostname the hostname of the server
|
||||
* @return the server favicon, null if not found
|
||||
*/
|
||||
public byte[] getServerFavicon(String hostname) {
|
||||
String icon = null; // The server base64 icon
|
||||
try {
|
||||
JavaMinecraftServer.Favicon favicon = ((JavaMinecraftServer) getServer(MinecraftServer.Platform.JAVA.name(), hostname).getServer()).getFavicon();
|
||||
if (favicon != null) { // Use the server's favicon
|
||||
icon = favicon.getBase64();
|
||||
icon = icon.substring(icon.indexOf(",") + 1); // Remove the data type from the server icon
|
||||
}
|
||||
} catch (BadRequestException | ResourceNotFoundException ignored) {
|
||||
// Safely ignore these, we will use the default server icon
|
||||
}
|
||||
if (icon == null) { // Use the default server icon
|
||||
icon = DEFAULT_SERVER_ICON;
|
||||
}
|
||||
return Base64.getDecoder().decode(icon); // Return the decoded favicon
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package xyz.mcutils.backend.service.pinger;
|
||||
|
||||
import cc.fascinated.model.dns.DNSRecord;
|
||||
import cc.fascinated.model.server.MinecraftServer;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
* @param <T> the type of server to ping
|
||||
*/
|
||||
public interface MinecraftServerPinger<T extends MinecraftServer> {
|
||||
T ping(String hostname, String ip, int port, DNSRecord[] records);
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package xyz.mcutils.backend.service.pinger.impl;
|
||||
|
||||
import cc.fascinated.common.packet.impl.bedrock.BedrockPacketUnconnectedPing;
|
||||
import cc.fascinated.common.packet.impl.bedrock.BedrockPacketUnconnectedPong;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.exception.impl.ResourceNotFoundException;
|
||||
import cc.fascinated.model.dns.DNSRecord;
|
||||
import cc.fascinated.model.server.BedrockMinecraftServer;
|
||||
import xyz.mcutils.backend.service.pinger.MinecraftServerPinger;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
/**
|
||||
* The {@link MinecraftServerPinger} for pinging
|
||||
* {@link BedrockMinecraftServer} over UDP.
|
||||
*
|
||||
* @author Braydon
|
||||
*/
|
||||
@Log4j2(topic = "Bedrock MC Server Pinger")
|
||||
public final class BedrockMinecraftServerPinger implements MinecraftServerPinger<BedrockMinecraftServer> {
|
||||
private static final int TIMEOUT = 1500; // The timeout for the socket
|
||||
|
||||
/**
|
||||
* Ping the server with the given hostname and port.
|
||||
*
|
||||
* @param hostname the hostname of the server
|
||||
* @param port the port of the server
|
||||
* @return the server that was pinged
|
||||
*/
|
||||
@Override
|
||||
public BedrockMinecraftServer ping(String hostname, String ip, int port, DNSRecord[] records) {
|
||||
log.info("Pinging {}:{}...", hostname, port);
|
||||
long before = System.currentTimeMillis(); // Timestamp before pinging
|
||||
|
||||
// Open a socket connection to the server
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT);
|
||||
socket.connect(new InetSocketAddress(hostname, port));
|
||||
|
||||
long ping = System.currentTimeMillis() - before; // Calculate the ping
|
||||
log.info("Pinged {}:{} in {}ms", hostname, port, ping);
|
||||
|
||||
// Send the unconnected ping packet
|
||||
new BedrockPacketUnconnectedPing().process(socket);
|
||||
|
||||
// Handle the received unconnected pong packet
|
||||
BedrockPacketUnconnectedPong unconnectedPong = new BedrockPacketUnconnectedPong();
|
||||
unconnectedPong.process(socket);
|
||||
String response = unconnectedPong.getResponse();
|
||||
if (response == null) { // No pong response
|
||||
throw new ResourceNotFoundException("Server didn't respond to ping");
|
||||
}
|
||||
return BedrockMinecraftServer.create(hostname, ip, port, records, response); // Return the server
|
||||
} catch (IOException ex) {
|
||||
if (ex instanceof UnknownHostException) {
|
||||
throw new BadRequestException("Unknown hostname: %s".formatted(hostname));
|
||||
} else if (ex instanceof SocketTimeoutException) {
|
||||
throw new ResourceNotFoundException(ex);
|
||||
}
|
||||
log.error("An error occurred pinging %s:%s:".formatted(hostname, port), ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package xyz.mcutils.backend.service.pinger.impl;
|
||||
|
||||
import xyz.mcutils.backend.Main;
|
||||
import cc.fascinated.common.JavaMinecraftVersion;
|
||||
import cc.fascinated.common.ServerUtils;
|
||||
import cc.fascinated.common.packet.impl.java.JavaPacketHandshakingInSetProtocol;
|
||||
import cc.fascinated.common.packet.impl.java.JavaPacketStatusInStart;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.exception.impl.ResourceNotFoundException;
|
||||
import cc.fascinated.model.dns.DNSRecord;
|
||||
import cc.fascinated.model.server.JavaMinecraftServer;
|
||||
import cc.fascinated.model.token.JavaServerStatusToken;
|
||||
import xyz.mcutils.backend.service.pinger.MinecraftServerPinger;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@Log4j2(topic = "Java Pinger")
|
||||
public final class JavaMinecraftServerPinger implements MinecraftServerPinger<JavaMinecraftServer> {
|
||||
private static final int TIMEOUT = 1500; // The timeout for the socket
|
||||
|
||||
@Override
|
||||
public JavaMinecraftServer ping(String hostname, String ip, int port, DNSRecord[] records) {
|
||||
log.info("Pinging {}:{}...", hostname, port);
|
||||
|
||||
// Open a socket connection to the server
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.setTcpNoDelay(true);
|
||||
socket.connect(new InetSocketAddress(hostname, port), TIMEOUT);
|
||||
|
||||
// Open data streams to begin packet transaction
|
||||
try (DataInputStream inputStream = new DataInputStream(socket.getInputStream());
|
||||
DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream())) {
|
||||
// Begin handshaking with the server
|
||||
new JavaPacketHandshakingInSetProtocol(hostname, port, JavaMinecraftVersion.getMinimumVersion().getProtocol()).process(inputStream, outputStream);
|
||||
|
||||
// Send the status request to the server, and await back the response
|
||||
JavaPacketStatusInStart packetStatusInStart = new JavaPacketStatusInStart();
|
||||
packetStatusInStart.process(inputStream, outputStream);
|
||||
JavaServerStatusToken token = Main.GSON.fromJson(packetStatusInStart.getResponse(), JavaServerStatusToken.class);
|
||||
return JavaMinecraftServer.create(hostname, ip, port, records, token);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
if (ex instanceof UnknownHostException) {
|
||||
throw new BadRequestException("Unknown hostname: %s".formatted(hostname));
|
||||
} else if (ex instanceof ConnectException || ex instanceof SocketTimeoutException) {
|
||||
throw new ResourceNotFoundException(ex);
|
||||
}
|
||||
log.error("An error occurred pinging %s".formatted(ServerUtils.getAddress(hostname, port)), ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user