This commit is contained in:
parent
25c69e11e1
commit
fcb8ef0357
26
src/main/java/cc.fascinated/EnumUtils.java
Normal file
26
src/main/java/cc.fascinated/EnumUtils.java
Normal file
@ -0,0 +1,26 @@
|
||||
package cc.fascinated;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@UtilityClass
|
||||
public final class EnumUtils {
|
||||
/**
|
||||
* Get the enum constant of the specified enum type with the specified name.
|
||||
*
|
||||
* @param enumType the enum type
|
||||
* @param name the name of the constant to return
|
||||
* @param <T> the type of the enum
|
||||
* @return the enum constant of the specified enum type with the specified name
|
||||
*/
|
||||
public <T extends Enum<T>> T getEnumConstant(@NonNull Class<T> enumType, @NonNull String name) {
|
||||
try {
|
||||
return Enum.valueOf(enumType, name);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
35
src/main/java/cc.fascinated/Main.java
Normal file
35
src/main/java/cc.fascinated/Main.java
Normal file
@ -0,0 +1,35 @@
|
||||
package cc.fascinated;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Objects;
|
||||
|
||||
@SpringBootApplication @Log4j2
|
||||
public class Main {
|
||||
|
||||
public static final Gson GSON = new Gson();
|
||||
public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
|
||||
|
||||
@SneakyThrows
|
||||
public static void main(String[] args) {
|
||||
File config = new File("application.yml");
|
||||
if (!config.exists()) { // Saving the default config if it doesn't exist locally
|
||||
Files.copy(Objects.requireNonNull(Main.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Saved the default configuration to '{}', please re-launch the application", // Log the default config being saved
|
||||
config.getAbsolutePath()
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config
|
||||
|
||||
SpringApplication.run(Main.class, args);
|
||||
}
|
||||
}
|
59
src/main/java/cc.fascinated/common/DNSUtils.java
Normal file
59
src/main/java/cc.fascinated/common/DNSUtils.java
Normal file
@ -0,0 +1,59 @@
|
||||
package cc.fascinated.common;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import org.xbill.DNS.Record;
|
||||
import org.xbill.DNS.*;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@UtilityClass
|
||||
public final class DNSUtils {
|
||||
private static final String SRV_QUERY_PREFIX = "_minecraft._tcp.%s";
|
||||
|
||||
/**
|
||||
* Resolve the hostname to an {@link InetSocketAddress}.
|
||||
*
|
||||
* @param hostname the hostname to resolve
|
||||
* @return the resolved {@link InetSocketAddress}
|
||||
*/
|
||||
@SneakyThrows
|
||||
public static InetSocketAddress resolveSRV(@NonNull String hostname) {
|
||||
Record[] records = new Lookup(SRV_QUERY_PREFIX.formatted(hostname), Type.SRV).run(); // Resolve SRV records
|
||||
if (records == null) { // No records exist
|
||||
return null;
|
||||
}
|
||||
String host = null;
|
||||
int port = -1;
|
||||
for (Record record : records) {
|
||||
SRVRecord srv = (SRVRecord) record;
|
||||
host = srv.getTarget().toString().replaceFirst("\\.$", "");
|
||||
port = srv.getPort();
|
||||
}
|
||||
return host == null ? null : new InetSocketAddress(host, port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the hostname to an {@link InetAddress}.
|
||||
*
|
||||
* @param hostname the hostname to resolve
|
||||
* @return the resolved {@link InetAddress}
|
||||
*/
|
||||
@SneakyThrows
|
||||
public static InetAddress resolveA(@NonNull String hostname) {
|
||||
Record[] records = new Lookup(hostname, Type.A).run(); // Resolve A records
|
||||
if (records == null) { // No records exist
|
||||
return null;
|
||||
}
|
||||
InetAddress address = null;
|
||||
for (Record record : records) {
|
||||
address = ((ARecord) record).getAddress();
|
||||
}
|
||||
return address;
|
||||
}
|
||||
}
|
42
src/main/java/cc.fascinated/common/IPUtils.java
Normal file
42
src/main/java/cc.fascinated/common/IPUtils.java
Normal file
@ -0,0 +1,42 @@
|
||||
package cc.fascinated.common;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
@UtilityClass
|
||||
public class IPUtils {
|
||||
/**
|
||||
* The headers that contain the IP.
|
||||
*/
|
||||
private static final String[] IP_HEADERS = new String[] {
|
||||
"CF-Connecting-IP",
|
||||
"X-Forwarded-For"
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the real IP from the given request.
|
||||
*
|
||||
* @param request the request
|
||||
* @return the real IP
|
||||
*/
|
||||
public static String getRealIp(HttpServletRequest request) {
|
||||
String ip = request.getRemoteAddr();
|
||||
for (String headerName : IP_HEADERS) {
|
||||
String header = request.getHeader(headerName);
|
||||
if (header == null) {
|
||||
continue;
|
||||
}
|
||||
if (!header.contains(",")) { // Handle single IP
|
||||
ip = header;
|
||||
break;
|
||||
}
|
||||
// Handle multiple IPs
|
||||
String[] ips = header.split(",");
|
||||
for (String ipHeader : ips) {
|
||||
ip = ipHeader;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
90
src/main/java/cc.fascinated/common/PlayerUtils.java
Normal file
90
src/main/java/cc.fascinated/common/PlayerUtils.java
Normal file
@ -0,0 +1,90 @@
|
||||
package cc.fascinated.common;
|
||||
|
||||
import cc.fascinated.Main;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.model.player.Skin;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.UUID;
|
||||
|
||||
@UtilityClass @Log4j2
|
||||
public class PlayerUtils {
|
||||
|
||||
/**
|
||||
* Gets the UUID from the string.
|
||||
*
|
||||
* @param id the id string
|
||||
* @return the UUID
|
||||
*/
|
||||
public static UUID getUuidFromString(String id) {
|
||||
UUID uuid;
|
||||
boolean isFullUuid = id.length() == 36;
|
||||
if (id.length() == 32 || isFullUuid) {
|
||||
try {
|
||||
uuid = isFullUuid ? UUID.fromString(id) : UUIDUtils.addDashes(id);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BadRequestException("Invalid UUID provided: %s".formatted(id));
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the skin data from the URL.
|
||||
*
|
||||
* @return the skin data
|
||||
*/
|
||||
@SneakyThrows
|
||||
@JsonIgnore
|
||||
public static byte[] getSkinImage(String url) {
|
||||
HttpResponse<byte[]> response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder(URI.create(url)).build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
return response.body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the part data from the skin.
|
||||
*
|
||||
* @return the part data
|
||||
*/
|
||||
public static byte[] getSkinPartBytes(Skin skin, Skin.Parts part, int size) {
|
||||
if (size == -1) {
|
||||
size = part.getDefaultSize();
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage()));
|
||||
if (image == null) {
|
||||
image = ImageIO.read(new ByteArrayInputStream(Skin.DEFAULT_SKIN.getSkinImage())); // Fallback to the default skin
|
||||
}
|
||||
// Get the part of the image (e.g. the head)
|
||||
BufferedImage partImage = image.getSubimage(part.getX(), part.getY(), part.getWidth(), part.getHeight());
|
||||
|
||||
// Scale the image
|
||||
BufferedImage scaledImage = new BufferedImage(size, size, partImage.getType());
|
||||
Graphics2D graphics2D = scaledImage.createGraphics();
|
||||
graphics2D.drawImage(partImage, 0, 0, size, size, null);
|
||||
graphics2D.dispose();
|
||||
partImage = scaledImage;
|
||||
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
ImageIO.write(partImage, "png", byteArrayOutputStream);
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to get {} part bytes for {}", part.name(), skin.getUrl(), ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
18
src/main/java/cc.fascinated/common/Tuple.java
Normal file
18
src/main/java/cc.fascinated/common/Tuple.java
Normal file
@ -0,0 +1,18 @@
|
||||
package cc.fascinated.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter @AllArgsConstructor
|
||||
public class Tuple<L, R> {
|
||||
|
||||
/**
|
||||
* The left value of the tuple.
|
||||
*/
|
||||
private final L left;
|
||||
|
||||
/**
|
||||
* The right value of the tuple.
|
||||
*/
|
||||
private final R right;
|
||||
}
|
25
src/main/java/cc.fascinated/common/UUIDUtils.java
Normal file
25
src/main/java/cc.fascinated/common/UUIDUtils.java
Normal file
@ -0,0 +1,25 @@
|
||||
package cc.fascinated.common;
|
||||
|
||||
import io.micrometer.common.lang.NonNull;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@UtilityClass
|
||||
public class UUIDUtils {
|
||||
|
||||
/**
|
||||
* Add dashes to a UUID.
|
||||
*
|
||||
* @param trimmed the UUID without dashes
|
||||
* @return the UUID with dashes
|
||||
*/
|
||||
@NonNull
|
||||
public static UUID addDashes(@NonNull String trimmed) {
|
||||
StringBuilder builder = new StringBuilder(trimmed);
|
||||
for (int i = 0, pos = 20; i < 4; i++, pos -= 4) {
|
||||
builder.insert(pos, "-");
|
||||
}
|
||||
return UUID.fromString(builder.toString());
|
||||
}
|
||||
}
|
41
src/main/java/cc.fascinated/common/WebRequest.java
Normal file
41
src/main/java/cc.fascinated/common/WebRequest.java
Normal file
@ -0,0 +1,41 @@
|
||||
package cc.fascinated.common;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@UtilityClass
|
||||
public class WebRequest {
|
||||
|
||||
/**
|
||||
* The web client.
|
||||
*/
|
||||
private static final RestClient CLIENT = RestClient.builder()
|
||||
.requestFactory(new HttpComponentsClientHttpRequestFactory())
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Gets a response from the given URL.
|
||||
*
|
||||
* @param url the url
|
||||
* @return the response
|
||||
* @param <T> the type of the response
|
||||
*/
|
||||
public static <T> T getAsEntity(String url, Class<T> clazz) {
|
||||
try {
|
||||
ResponseEntity<T> profile = CLIENT.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.toEntity(clazz);
|
||||
|
||||
if (profile.getStatusCode().isError()) {
|
||||
return null;
|
||||
}
|
||||
return profile.getBody();
|
||||
} catch (HttpClientErrorException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
package cc.fascinated.common.packet;
|
||||
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Represents a packet in the
|
||||
* Minecraft Java protocol.
|
||||
*
|
||||
* @author Braydon
|
||||
* @see <a href="https://wiki.vg/Protocol">Protocol Docs</a>
|
||||
*/
|
||||
public abstract class MinecraftJavaPacket {
|
||||
/**
|
||||
* Process this packet.
|
||||
*
|
||||
* @param inputStream the input stream to read from
|
||||
* @param outputStream the output stream to write to
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public abstract void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException;
|
||||
|
||||
/**
|
||||
* Write a variable integer to the output stream.
|
||||
*
|
||||
* @param outputStream the output stream to write to
|
||||
* @param paramInt the integer to write
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
protected final void writeVarInt(DataOutputStream outputStream, int paramInt) throws IOException {
|
||||
while (true) {
|
||||
if ((paramInt & 0xFFFFFF80) == 0) {
|
||||
outputStream.writeByte(paramInt);
|
||||
return;
|
||||
}
|
||||
outputStream.writeByte(paramInt & 0x7F | 0x80);
|
||||
paramInt >>>= 7;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a variable integer from the input stream.
|
||||
*
|
||||
* @param inputStream the input stream to read from
|
||||
* @return the integer that was read
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
protected final int readVarInt(@NonNull DataInputStream inputStream) throws IOException {
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
while (true) {
|
||||
int k = inputStream.readByte();
|
||||
i |= (k & 0x7F) << j++ * 7;
|
||||
if (j > 5) {
|
||||
throw new RuntimeException("VarInt too big");
|
||||
}
|
||||
if ((k & 0x80) != 128) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
}
|
64
src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java
Normal file
64
src/main/java/cc.fascinated/common/packet/impl/java/JavaPacketHandshakingInSetProtocol.java
Normal file
@ -0,0 +1,64 @@
|
||||
package cc.fascinated.common.packet.impl.java;
|
||||
|
||||
import cc.fascinated.common.packet.MinecraftJavaPacket;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* This packet is sent by the client to the server to set
|
||||
* the hostname, port, and protocol version of the client.
|
||||
*
|
||||
* @author Braydon
|
||||
* @see <a href="https://wiki.vg/Protocol#Handshake">Protocol Docs</a>
|
||||
*/
|
||||
@AllArgsConstructor @ToString
|
||||
public final class JavaPacketHandshakingInSetProtocol extends MinecraftJavaPacket {
|
||||
private static final byte ID = 0x00; // The ID of the packet
|
||||
private static final int STATUS_HANDSHAKE = 1; // The status handshake ID
|
||||
|
||||
/**
|
||||
* The hostname of the server.
|
||||
*/
|
||||
@NonNull private final String hostname;
|
||||
|
||||
/**
|
||||
* The port of the server.
|
||||
*/
|
||||
private final int port;
|
||||
|
||||
/**
|
||||
* The protocol version of the server.
|
||||
*/
|
||||
private final int protocolVersion;
|
||||
|
||||
/**
|
||||
* Process this packet.
|
||||
*
|
||||
* @param inputStream the input stream to read from
|
||||
* @param outputStream the output stream to write to
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Override
|
||||
public void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException {
|
||||
try (ByteArrayOutputStream handshakeBytes = new ByteArrayOutputStream();
|
||||
DataOutputStream handshake = new DataOutputStream(handshakeBytes)
|
||||
) {
|
||||
handshake.writeByte(ID); // Write the ID of the packet
|
||||
writeVarInt(handshake, protocolVersion); // Write the protocol version
|
||||
writeVarInt(handshake, hostname.length()); // Write the length of the hostname
|
||||
handshake.writeBytes(hostname); // Write the hostname
|
||||
handshake.writeShort(port); // Write the port
|
||||
writeVarInt(handshake, STATUS_HANDSHAKE); // Write the status handshake ID
|
||||
|
||||
// Write the handshake bytes to the output stream
|
||||
writeVarInt(outputStream, handshakeBytes.size());
|
||||
outputStream.write(handshakeBytes.toByteArray());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
package cc.fascinated.common.packet.impl.java;
|
||||
|
||||
import cc.fascinated.common.packet.MinecraftJavaPacket;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* This packet is sent by the client to the server to request the
|
||||
* status of the server. The server will respond with a json object
|
||||
* containing the server's status.
|
||||
*
|
||||
* @author Braydon
|
||||
* @see <a href="https://wiki.vg/Protocol#Status_Request">Protocol Docs</a>
|
||||
*/
|
||||
@Getter
|
||||
public final class JavaPacketStatusInStart extends MinecraftJavaPacket {
|
||||
private static final byte ID = 0x00; // The ID of the packet
|
||||
|
||||
/**
|
||||
* The response json from the server, null if none.
|
||||
*/
|
||||
private String response;
|
||||
|
||||
/**
|
||||
* Process this packet.
|
||||
*
|
||||
* @param inputStream the input stream to read from
|
||||
* @param outputStream the output stream to write to
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Override
|
||||
public void process(@NonNull DataInputStream inputStream, @NonNull DataOutputStream outputStream) throws IOException {
|
||||
// Send the status request
|
||||
outputStream.writeByte(0x01); // Size of packet
|
||||
outputStream.writeByte(ID);
|
||||
|
||||
// Read the status response
|
||||
readVarInt(inputStream); // Size of the response
|
||||
int id = readVarInt(inputStream);
|
||||
if (id == -1) { // The stream was prematurely ended
|
||||
throw new IOException("Server prematurely ended stream.");
|
||||
} else if (id != ID) { // Invalid packet ID
|
||||
throw new IOException("Server returned invalid packet ID.");
|
||||
}
|
||||
|
||||
int length = readVarInt(inputStream); // Length of the response
|
||||
if (length == -1) { // The stream was prematurely ended
|
||||
throw new IOException("Server prematurely ended stream.");
|
||||
} else if (length == 0) {
|
||||
throw new IOException("Server returned unexpected value.");
|
||||
}
|
||||
|
||||
// Get the json response
|
||||
byte[] data = new byte[length];
|
||||
inputStream.readFully(data);
|
||||
response = new String(data);
|
||||
}
|
||||
}
|
20
src/main/java/cc.fascinated/config/Config.java
Normal file
20
src/main/java/cc.fascinated/config/Config.java
Normal file
@ -0,0 +1,20 @@
|
||||
package cc.fascinated.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@Getter
|
||||
public class Config {
|
||||
public static Config INSTANCE;
|
||||
|
||||
@Value("${public-url}")
|
||||
private String webPublicUrl;
|
||||
|
||||
@PostConstruct
|
||||
public void onInitialize() {
|
||||
INSTANCE = this;
|
||||
}
|
||||
}
|
73
src/main/java/cc.fascinated/config/RedisConfig.java
Normal file
73
src/main/java/cc.fascinated/config/RedisConfig.java
Normal file
@ -0,0 +1,73 @@
|
||||
package cc.fascinated.config;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@Configuration
|
||||
@Log4j2(topic = "Redis")
|
||||
public class RedisConfig {
|
||||
/**
|
||||
* The Redis server host.
|
||||
*/
|
||||
@Value("${spring.data.redis.host}")
|
||||
private String host;
|
||||
|
||||
/**
|
||||
* The Redis server port.
|
||||
*/
|
||||
@Value("${spring.data.redis.port}")
|
||||
private int port;
|
||||
|
||||
/**
|
||||
* The Redis database index.
|
||||
*/
|
||||
@Value("${spring.data.redis.database}")
|
||||
private int database;
|
||||
|
||||
/**
|
||||
* The optional Redis password.
|
||||
*/
|
||||
@Value("${spring.data.redis.auth}")
|
||||
private String auth;
|
||||
|
||||
/**
|
||||
* Build the config to use for Redis.
|
||||
*
|
||||
* @return the config
|
||||
* @see RedisTemplate for config
|
||||
*/
|
||||
@Bean @NonNull
|
||||
public RedisTemplate<String, Object> redisTemplate() {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(jedisConnectionFactory());
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the connection factory to use
|
||||
* when making connections to Redis.
|
||||
*
|
||||
* @return the built factory
|
||||
* @see JedisConnectionFactory for factory
|
||||
*/
|
||||
@Bean @NonNull
|
||||
public JedisConnectionFactory jedisConnectionFactory() {
|
||||
log.info("Connecting to Redis at {}:{}/{}", host, port, database);
|
||||
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
|
||||
config.setDatabase(database);
|
||||
if (!auth.trim().isEmpty()) { // Auth with our provided password
|
||||
log.info("Using auth...");
|
||||
config.setPassword(auth);
|
||||
}
|
||||
return new JedisConnectionFactory(config);
|
||||
}
|
||||
}
|
23
src/main/java/cc.fascinated/controller/HomeController.java
Normal file
23
src/main/java/cc.fascinated/controller/HomeController.java
Normal file
@ -0,0 +1,23 @@
|
||||
package cc.fascinated.controller;
|
||||
|
||||
import cc.fascinated.config.Config;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping(value = "/")
|
||||
public class HomeController {
|
||||
|
||||
/**
|
||||
* The example UUID.
|
||||
*/
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private final String exampleUuid = "eeab5f8a-18dd-4d58-af78-2b3c4543da48";
|
||||
|
||||
@RequestMapping(value = "/")
|
||||
public String home(Model model) {
|
||||
model.addAttribute("player_example_url", Config.INSTANCE.getWebPublicUrl() + "/player/" + exampleUuid);
|
||||
return "index";
|
||||
}
|
||||
}
|
48
src/main/java/cc.fascinated/controller/PlayerController.java
Normal file
48
src/main/java/cc.fascinated/controller/PlayerController.java
Normal file
@ -0,0 +1,48 @@
|
||||
package cc.fascinated.controller;
|
||||
|
||||
import cc.fascinated.common.PlayerUtils;
|
||||
import cc.fascinated.model.player.Player;
|
||||
import cc.fascinated.model.player.Skin;
|
||||
import cc.fascinated.service.PlayerService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = "/player/")
|
||||
public class PlayerController {
|
||||
|
||||
private final CacheControl cacheControl = CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic();
|
||||
private final PlayerService playerManagerService;
|
||||
|
||||
@Autowired
|
||||
public PlayerController(PlayerService playerManagerService) {
|
||||
this.playerManagerService = playerManagerService;
|
||||
}
|
||||
|
||||
@ResponseBody
|
||||
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<?> getPlayer(@PathVariable String id) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(cacheControl)
|
||||
.body(playerManagerService.getPlayer(id));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{part}/{id}")
|
||||
public ResponseEntity<?> getPlayerHead(@PathVariable String part,
|
||||
@PathVariable String id,
|
||||
@RequestParam(required = false, defaultValue = "256") int size) {
|
||||
Player player = playerManagerService.getPlayer(id);
|
||||
Skin.Parts skinPart = Skin.Parts.fromName(part);
|
||||
|
||||
// Return the part image
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(cacheControl)
|
||||
.contentType(MediaType.IMAGE_PNG)
|
||||
.body(PlayerUtils.getSkinPartBytes(player.getSkin(), skinPart, size));
|
||||
}
|
||||
}
|
32
src/main/java/cc.fascinated/controller/ServerController.java
Normal file
32
src/main/java/cc.fascinated/controller/ServerController.java
Normal file
@ -0,0 +1,32 @@
|
||||
package cc.fascinated.controller;
|
||||
|
||||
import cc.fascinated.model.cache.CachedMinecraftServer;
|
||||
import cc.fascinated.model.server.MinecraftServer;
|
||||
import cc.fascinated.service.ServerService;
|
||||
import cc.fascinated.service.pinger.impl.JavaMinecraftServerPinger;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = "/server/")
|
||||
public class ServerController {
|
||||
|
||||
@Autowired
|
||||
private ServerService serverService;
|
||||
|
||||
@ResponseBody
|
||||
@GetMapping(value = "/{platform}/{hostnameAndPort}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public CachedMinecraftServer getServer(@PathVariable String platform, @PathVariable String hostnameAndPort) {
|
||||
String[] split = hostnameAndPort.split(":");
|
||||
String hostname = split[0];
|
||||
int port = 25565;
|
||||
if (split.length == 2) {
|
||||
try {
|
||||
port = Integer.parseInt(split[1]);
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return serverService.getServer(platform, hostname, port);
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package cc.fascinated.exception;
|
||||
|
||||
import cc.fascinated.model.response.ErrorResponse;
|
||||
import io.micrometer.common.lang.NonNull;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
@ControllerAdvice
|
||||
public final class ExceptionControllerAdvice {
|
||||
|
||||
/**
|
||||
* Handle a raised exception.
|
||||
*
|
||||
* @param ex the raised exception
|
||||
* @return the error response
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<?> handleException(@NonNull Exception ex) {
|
||||
HttpStatus status = null; // Get the HTTP status
|
||||
if (ex instanceof NoResourceFoundException) { // Not found
|
||||
status = HttpStatus.NOT_FOUND;
|
||||
} else if (ex instanceof UnsupportedOperationException) { // Not implemented
|
||||
status = HttpStatus.NOT_IMPLEMENTED;
|
||||
}
|
||||
if (ex.getClass().isAnnotationPresent(ResponseStatus.class)) { // Get from the @ResponseStatus annotation
|
||||
status = ex.getClass().getAnnotation(ResponseStatus.class).value();
|
||||
}
|
||||
String message = ex.getLocalizedMessage(); // Get the error message
|
||||
if (message == null) { // Fallback
|
||||
message = "An internal error has occurred.";
|
||||
}
|
||||
// Print the stack trace if no response status is present
|
||||
if (status == null) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
if (status == null) { // Fallback to 500
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
return new ResponseEntity<>(new ErrorResponse(status, message), status);
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package cc.fascinated.exception.impl;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadRequestException extends RuntimeException {
|
||||
|
||||
public BadRequestException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package cc.fascinated.exception.impl;
|
||||
|
||||
import lombok.experimental.StandardException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@StandardException
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class ResourceNotFoundException extends RuntimeException { }
|
78
src/main/java/cc.fascinated/log/TransactionLogger.java
Normal file
78
src/main/java/cc.fascinated/log/TransactionLogger.java
Normal file
@ -0,0 +1,78 @@
|
||||
package cc.fascinated.log;
|
||||
|
||||
import cc.fascinated.common.IPUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
|
||||
@ControllerAdvice
|
||||
@Slf4j(topic = "Req/Res Transaction")
|
||||
public class TransactionLogger implements ResponseBodyAdvice<Object> {
|
||||
@Override
|
||||
public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType,
|
||||
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType, @NonNull ServerHttpRequest rawRequest,
|
||||
@NonNull ServerHttpResponse rawResponse) {
|
||||
HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest();
|
||||
HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse();
|
||||
|
||||
// Get the request ip ip
|
||||
String ip = IPUtils.getRealIp(request);
|
||||
|
||||
// Getting params
|
||||
Map<String, String> params = new HashMap<>();
|
||||
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
params.put(entry.getKey(), Arrays.toString(entry.getValue()));
|
||||
}
|
||||
|
||||
// Getting headers
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
headers.put(headerName, request.getHeader(headerName));
|
||||
}
|
||||
|
||||
// Log the request
|
||||
log.info(String.format("[Req] %s | %s | '%s', params=%s, headers=%s",
|
||||
request.getMethod(),
|
||||
ip,
|
||||
request.getRequestURI(),
|
||||
params,
|
||||
headers
|
||||
));
|
||||
|
||||
// Getting response headers
|
||||
headers = new HashMap<>();
|
||||
for (String headerName : response.getHeaderNames()) {
|
||||
headers.put(headerName, response.getHeader(headerName));
|
||||
}
|
||||
|
||||
// Log the response
|
||||
log.info(String.format("[Res] %s, headers=%s",
|
||||
response.getStatus(),
|
||||
headers
|
||||
));
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return true;
|
||||
}
|
||||
}
|
31
src/main/java/cc.fascinated/model/cache/CachedMinecraftServer.java
vendored
Normal file
31
src/main/java/cc.fascinated/model/cache/CachedMinecraftServer.java
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
package cc.fascinated.model.cache;
|
||||
|
||||
import cc.fascinated.model.server.MinecraftServer;
|
||||
import lombok.*;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.redis.core.RedisHash;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@AllArgsConstructor @Setter @Getter @ToString
|
||||
@RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds)
|
||||
public final class CachedMinecraftServer implements Serializable {
|
||||
/**
|
||||
* The id of this cached server.
|
||||
*/
|
||||
@Id @NonNull private transient final String id;
|
||||
|
||||
/**
|
||||
* The cached server.
|
||||
*/
|
||||
@NonNull private final MinecraftServer value;
|
||||
|
||||
/**
|
||||
* The unix timestamp of when this
|
||||
* server was cached, -1 if not cached.
|
||||
*/
|
||||
private long cached;
|
||||
}
|
34
src/main/java/cc.fascinated/model/cache/CachedPlayer.java
vendored
Normal file
34
src/main/java/cc.fascinated/model/cache/CachedPlayer.java
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
package cc.fascinated.model.cache;
|
||||
|
||||
import cc.fascinated.model.mojang.MojangProfile;
|
||||
import cc.fascinated.model.player.Cape;
|
||||
import cc.fascinated.model.player.Player;
|
||||
import cc.fascinated.model.player.Skin;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import org.springframework.data.redis.core.RedisHash;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A cacheable {@link Player}.
|
||||
*
|
||||
* @author Braydon
|
||||
*/
|
||||
@Setter @Getter
|
||||
@ToString(callSuper = true)
|
||||
@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds)
|
||||
public final class CachedPlayer extends Player implements Serializable {
|
||||
/**
|
||||
* The unix timestamp of when this
|
||||
* player was cached, -1 if not cached.
|
||||
*/
|
||||
private long cached;
|
||||
|
||||
public CachedPlayer(UUID uuid, String username, Skin skin, Cape cape, long cached) {
|
||||
super(uuid, username, skin, cape);
|
||||
this.cached = cached;
|
||||
}
|
||||
}
|
27
src/main/java/cc.fascinated/model/cache/CachedPlayerName.java
vendored
Normal file
27
src/main/java/cc.fascinated/model/cache/CachedPlayerName.java
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
package cc.fascinated.model.cache;
|
||||
|
||||
import lombok.*;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.redis.core.RedisHash;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString
|
||||
@RedisHash(value = "playerName", timeToLive = 60L * 60L) // 1 hour (in seconds)
|
||||
public final class CachedPlayerName {
|
||||
/**
|
||||
* The username of the player.
|
||||
*/
|
||||
@Id @NonNull private String username;
|
||||
|
||||
/**
|
||||
* The unique id of the player.
|
||||
*/
|
||||
@NonNull private UUID uniqueId;
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package cc.fascinated.model.mojang;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@AllArgsConstructor @Getter @ToString
|
||||
public final class JavaServerStatusToken {
|
||||
private final String description;
|
||||
}
|
111
src/main/java/cc.fascinated/model/mojang/MojangProfile.java
Normal file
111
src/main/java/cc.fascinated/model/mojang/MojangProfile.java
Normal file
@ -0,0 +1,111 @@
|
||||
package cc.fascinated.model.mojang;
|
||||
|
||||
import cc.fascinated.Main;
|
||||
import cc.fascinated.common.Tuple;
|
||||
import cc.fascinated.common.UUIDUtils;
|
||||
import cc.fascinated.model.player.Cape;
|
||||
import cc.fascinated.model.player.Skin;
|
||||
import com.google.gson.JsonObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
@Getter @NoArgsConstructor
|
||||
public class MojangProfile {
|
||||
|
||||
/**
|
||||
* The UUID of the player.
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* The name of the player.
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* The properties of the player.
|
||||
*/
|
||||
private final List<ProfileProperty> properties = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Get the skin and cape of the player.
|
||||
*
|
||||
* @return the skin and cape of the player
|
||||
*/
|
||||
public Tuple<Skin, Cape> getSkinAndCape() {
|
||||
ProfileProperty textureProperty = getProfileProperty("textures");
|
||||
if (textureProperty == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonObject json = Main.GSON.fromJson(textureProperty.getDecodedValue(), JsonObject.class); // Decode the texture property
|
||||
JsonObject texturesJson = json.getAsJsonObject("textures"); // Parse the decoded JSON and get the textures object
|
||||
|
||||
return new Tuple<>(Skin.fromJson(texturesJson.getAsJsonObject("SKIN")).populatePartUrls(this.getFormattedUuid()),
|
||||
Cape.fromJson(texturesJson.getAsJsonObject("CAPE")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the formatted UUID of the player.
|
||||
*
|
||||
* @return the formatted UUID
|
||||
*/
|
||||
public String getFormattedUuid() {
|
||||
return id.length() == 32 ? UUIDUtils.addDashes(id).toString() : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a profile property for the player
|
||||
*
|
||||
* @return the profile property
|
||||
*/
|
||||
public ProfileProperty getProfileProperty(String name) {
|
||||
for (ProfileProperty property : properties) {
|
||||
if (property.getName().equals(name)) {
|
||||
return property;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Getter @AllArgsConstructor
|
||||
public static class ProfileProperty {
|
||||
/**
|
||||
* The name of the property.
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* The base64 value of the property.
|
||||
*/
|
||||
private String value;
|
||||
|
||||
/**
|
||||
* The signature of the property.
|
||||
*/
|
||||
private String signature;
|
||||
|
||||
/**
|
||||
* Decodes the value for this property.
|
||||
*
|
||||
* @return the decoded value
|
||||
*/
|
||||
public String getDecodedValue() {
|
||||
return new String(Base64.getDecoder().decode(this.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the property is signed.
|
||||
*
|
||||
* @return true if the property is signed, false otherwise
|
||||
*/
|
||||
public boolean isSigned() {
|
||||
return signature != null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package cc.fascinated.model.mojang;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter @NoArgsConstructor
|
||||
public class MojangUsernameToUuid {
|
||||
|
||||
/**
|
||||
* The UUID of the player.
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* The name of the player.
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Check if the profile is valid.
|
||||
*
|
||||
* @return if the profile is valid
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return id != null && name != null;
|
||||
}
|
||||
}
|
27
src/main/java/cc.fascinated/model/player/Cape.java
Normal file
27
src/main/java/cc.fascinated/model/player/Cape.java
Normal file
@ -0,0 +1,27 @@
|
||||
package cc.fascinated.model.player;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter @AllArgsConstructor
|
||||
public class Cape {
|
||||
|
||||
/**
|
||||
* The URL of the cape
|
||||
*/
|
||||
private final String url;
|
||||
|
||||
/**
|
||||
* Gets the cape from a {@link JsonObject}.
|
||||
*
|
||||
* @param json the JSON object
|
||||
* @return the cape
|
||||
*/
|
||||
public static Cape fromJson(JsonObject json) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
return new Cape(json.get("url").getAsString());
|
||||
}
|
||||
}
|
49
src/main/java/cc.fascinated/model/player/Player.java
Normal file
49
src/main/java/cc.fascinated/model/player/Player.java
Normal file
@ -0,0 +1,49 @@
|
||||
package cc.fascinated.model.player;
|
||||
|
||||
import cc.fascinated.common.Tuple;
|
||||
import cc.fascinated.common.UUIDUtils;
|
||||
import cc.fascinated.model.mojang.MojangProfile;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter @AllArgsConstructor
|
||||
public class Player {
|
||||
|
||||
/**
|
||||
* The UUID of the player
|
||||
*/
|
||||
@Id private final UUID uuid;
|
||||
|
||||
/**
|
||||
* The username of the player
|
||||
*/
|
||||
private final String username;
|
||||
|
||||
/**
|
||||
* The skin of the player, null if the
|
||||
* player does not have a skin
|
||||
*/
|
||||
private Skin skin;
|
||||
|
||||
/**
|
||||
* The cape of the player, null if the
|
||||
* player does not have a cape
|
||||
*/
|
||||
private Cape cape;
|
||||
|
||||
public Player(MojangProfile profile) {
|
||||
this.uuid = UUIDUtils.addDashes(profile.getId());
|
||||
this.username = profile.getName();
|
||||
|
||||
// Get the skin and cape
|
||||
Tuple<Skin, Cape> skinAndCape = profile.getSkinAndCape();
|
||||
if (skinAndCape != null) {
|
||||
this.skin = skinAndCape.getLeft();
|
||||
this.cape = skinAndCape.getRight();
|
||||
}
|
||||
}
|
||||
}
|
158
src/main/java/cc.fascinated/model/player/Skin.java
Normal file
158
src/main/java/cc.fascinated/model/player/Skin.java
Normal file
@ -0,0 +1,158 @@
|
||||
package cc.fascinated.model.player;
|
||||
|
||||
import cc.fascinated.common.PlayerUtils;
|
||||
import cc.fascinated.config.Config;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.gson.JsonObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@AllArgsConstructor @NoArgsConstructor
|
||||
@Getter @Log4j2
|
||||
public class Skin {
|
||||
/**
|
||||
* The default skin, usually used when the skin is not found.
|
||||
*/
|
||||
public static final Skin DEFAULT_SKIN = new Skin("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1",
|
||||
Model.DEFAULT);
|
||||
|
||||
/**
|
||||
* The URL for the skin
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* The model for the skin
|
||||
*/
|
||||
private Model model;
|
||||
|
||||
/**
|
||||
* The skin image for the skin
|
||||
*/
|
||||
@JsonIgnore
|
||||
private byte[] skinImage;
|
||||
|
||||
/**
|
||||
* The part URLs of the skin
|
||||
*/
|
||||
@JsonProperty("parts")
|
||||
private Map<String, String> partUrls = new HashMap<>();
|
||||
|
||||
public Skin(String url, Model model) {
|
||||
this.url = url;
|
||||
this.model = model;
|
||||
|
||||
this.skinImage = PlayerUtils.getSkinImage(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the skin from a {@link JsonObject}.
|
||||
*
|
||||
* @param json the JSON object
|
||||
* @return the skin
|
||||
*/
|
||||
public static Skin fromJson(JsonObject json) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
String url = json.get("url").getAsString();
|
||||
JsonObject metadata = json.getAsJsonObject("metadata");
|
||||
Model model = Model.fromName(metadata == null ? "slim" : // Fall back to slim if the model is not found
|
||||
metadata.get("model").getAsString());
|
||||
return new Skin(url, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the part URLs for the skin.
|
||||
*
|
||||
* @param playerUuid the player's UUID
|
||||
*/
|
||||
public Skin populatePartUrls(String playerUuid) {
|
||||
for (Parts part : Parts.values()) {
|
||||
String partName = part.name().toLowerCase();
|
||||
this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid + "?size=" + part.getDefaultSize());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skin part enum that contains the
|
||||
* information about the part.
|
||||
*/
|
||||
@Getter @AllArgsConstructor
|
||||
public enum Parts {
|
||||
|
||||
HEAD(8, 8, 8, 8, 256);
|
||||
|
||||
/**
|
||||
* The x and y position of the part.
|
||||
*/
|
||||
private final int x, y;
|
||||
|
||||
/**
|
||||
* The width and height of the part.
|
||||
*/
|
||||
private final int width, height;
|
||||
|
||||
/**
|
||||
* The scale of the part.
|
||||
*/
|
||||
private final int defaultSize;
|
||||
|
||||
/**
|
||||
* Gets the name of the part.
|
||||
*
|
||||
* @return the name of the part
|
||||
*/
|
||||
public String getName() {
|
||||
return this.name().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the skin part from its name.
|
||||
*
|
||||
* @param name the name of the part
|
||||
* @return the skin part
|
||||
* @throws BadRequestException if the part is not found
|
||||
*/
|
||||
public static Parts fromName(String name) throws BadRequestException {
|
||||
for (Parts part : values()) {
|
||||
if (part.name().equalsIgnoreCase(name)) {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException("Invalid part name: " + name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of the skin.
|
||||
*/
|
||||
public enum Model {
|
||||
DEFAULT,
|
||||
SLIM;
|
||||
|
||||
/**
|
||||
* Gets the model from its name.
|
||||
*
|
||||
* @param name the name of the model
|
||||
* @return the model
|
||||
*/
|
||||
public static Model fromName(String name) {
|
||||
for (Model model : values()) {
|
||||
if (model.name().equalsIgnoreCase(name)) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package cc.fascinated.model.response;
|
||||
|
||||
import io.micrometer.common.lang.NonNull;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
public class ErrorResponse {
|
||||
/**
|
||||
* The status code of this error.
|
||||
*/
|
||||
@NonNull
|
||||
private final HttpStatus status;
|
||||
|
||||
/**
|
||||
* The HTTP code of this error.
|
||||
*/
|
||||
private final int code;
|
||||
|
||||
/**
|
||||
* The message of this error.
|
||||
*/
|
||||
@NonNull private final String message;
|
||||
|
||||
/**
|
||||
* The timestamp this error occurred.
|
||||
*/
|
||||
@NonNull private final Date timestamp;
|
||||
|
||||
public ErrorResponse(@NonNull HttpStatus status, @NonNull String message) {
|
||||
this.status = status;
|
||||
code = status.value();
|
||||
this.message = message;
|
||||
timestamp = new Date();
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package cc.fascinated.model.server;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
public final class JavaMinecraftServer extends MinecraftServer {
|
||||
public JavaMinecraftServer(String hostname, String ip, int port, String motd) {
|
||||
super(hostname, ip, port, motd);
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package cc.fascinated.model.server;
|
||||
|
||||
import cc.fascinated.service.pinger.MinecraftServerPinger;
|
||||
import cc.fascinated.service.pinger.impl.JavaMinecraftServerPinger;
|
||||
import io.micrometer.common.lang.NonNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@AllArgsConstructor @Getter @ToString
|
||||
public class MinecraftServer {
|
||||
private final String hostname;
|
||||
private final String ip;
|
||||
private final int port;
|
||||
private final String motd;
|
||||
|
||||
/**
|
||||
* A platform a Minecraft
|
||||
* server can operate on.
|
||||
*/
|
||||
@AllArgsConstructor @Getter
|
||||
public enum Platform {
|
||||
/**
|
||||
* The Java edition of Minecraft.
|
||||
*/
|
||||
JAVA(new JavaMinecraftServerPinger(), 25565);
|
||||
|
||||
/**
|
||||
* The server pinger for this platform.
|
||||
*/
|
||||
@NonNull
|
||||
private final MinecraftServerPinger<?> pinger;
|
||||
|
||||
/**
|
||||
* The default server port for this platform.
|
||||
*/
|
||||
private final int defaultPort;
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package cc.fascinated.repository;
|
||||
|
||||
import cc.fascinated.model.cache.CachedMinecraftServer;
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
/**
|
||||
* A cache repository for {@link CachedMinecraftServer}'s.
|
||||
*
|
||||
* @author Braydon
|
||||
*/
|
||||
public interface MinecraftServerCacheRepository extends CrudRepository<CachedMinecraftServer, String> { }
|
@ -0,0 +1,13 @@
|
||||
package cc.fascinated.repository;
|
||||
|
||||
import cc.fascinated.model.cache.CachedPlayer;
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A cache repository for {@link CachedPlayer}'s.
|
||||
*
|
||||
* @author Braydon
|
||||
*/
|
||||
public interface PlayerCacheRepository extends CrudRepository<CachedPlayer, UUID> { }
|
@ -0,0 +1,18 @@
|
||||
package cc.fascinated.repository;
|
||||
|
||||
import cc.fascinated.model.cache.CachedPlayer;
|
||||
import cc.fascinated.model.cache.CachedPlayerName;
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A cache repository for player usernames.
|
||||
* <p>
|
||||
* This will allow us to easily lookup a
|
||||
* player's username and get their uuid.
|
||||
* </p>
|
||||
*
|
||||
* @author Braydon
|
||||
*/
|
||||
public interface PlayerNameCacheRepository extends CrudRepository<CachedPlayerName, String> { }
|
40
src/main/java/cc.fascinated/service/MojangAPIService.java
Normal file
40
src/main/java/cc.fascinated/service/MojangAPIService.java
Normal file
@ -0,0 +1,40 @@
|
||||
package cc.fascinated.service;
|
||||
|
||||
import cc.fascinated.common.WebRequest;
|
||||
import cc.fascinated.model.mojang.MojangProfile;
|
||||
import cc.fascinated.model.mojang.MojangUsernameToUuid;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service @Log4j2
|
||||
public class MojangAPIService {
|
||||
|
||||
@Value("${mojang.session-server}")
|
||||
private String mojangSessionServerUrl;
|
||||
|
||||
@Value("${mojang.api}")
|
||||
private String mojangApiUrl;
|
||||
|
||||
/**
|
||||
* Gets the Session Server profile of the
|
||||
* player with the given UUID.
|
||||
*
|
||||
* @param id the uuid or name of the player
|
||||
* @return the profile
|
||||
*/
|
||||
public MojangProfile getProfile(String id) {
|
||||
return WebRequest.getAsEntity(mojangSessionServerUrl + "/session/minecraft/profile/" + id, MojangProfile.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the player using
|
||||
* the name of the player.
|
||||
*
|
||||
* @param id the name of the player
|
||||
* @return the profile
|
||||
*/
|
||||
public MojangUsernameToUuid getUuidFromUsername(String id) {
|
||||
return WebRequest.getAsEntity(mojangApiUrl + "/users/profiles/minecraft/" + id, MojangUsernameToUuid.class);
|
||||
}
|
||||
}
|
93
src/main/java/cc.fascinated/service/PlayerService.java
Normal file
93
src/main/java/cc.fascinated/service/PlayerService.java
Normal file
@ -0,0 +1,93 @@
|
||||
package cc.fascinated.service;
|
||||
|
||||
import cc.fascinated.common.PlayerUtils;
|
||||
import cc.fascinated.common.Tuple;
|
||||
import cc.fascinated.common.UUIDUtils;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.exception.impl.ResourceNotFoundException;
|
||||
import cc.fascinated.model.cache.CachedPlayer;
|
||||
import cc.fascinated.model.cache.CachedPlayerName;
|
||||
import cc.fascinated.model.mojang.MojangProfile;
|
||||
import cc.fascinated.model.mojang.MojangUsernameToUuid;
|
||||
import cc.fascinated.model.player.Cape;
|
||||
import cc.fascinated.model.player.Player;
|
||||
import cc.fascinated.model.player.Skin;
|
||||
import cc.fascinated.repository.PlayerCacheRepository;
|
||||
import cc.fascinated.repository.PlayerNameCacheRepository;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import net.jodah.expiringmap.ExpirationPolicy;
|
||||
import net.jodah.expiringmap.ExpiringMap;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service @Log4j2
|
||||
public class PlayerService {
|
||||
|
||||
private final MojangAPIService mojangAPIService;
|
||||
private final PlayerCacheRepository playerCacheRepository;
|
||||
private final PlayerNameCacheRepository playerNameCacheRepository;
|
||||
|
||||
@Autowired
|
||||
public PlayerService(MojangAPIService mojangAPIService, PlayerCacheRepository playerCacheRepository, PlayerNameCacheRepository playerNameCacheRepository) {
|
||||
this.mojangAPIService = mojangAPIService;
|
||||
this.playerCacheRepository = playerCacheRepository;
|
||||
this.playerNameCacheRepository = playerNameCacheRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
Optional<CachedPlayer> cachedPlayer = playerCacheRepository.findById(uuid);
|
||||
if (cachedPlayer.isPresent()) { // Return the cached player if it exists
|
||||
return cachedPlayer.get();
|
||||
}
|
||||
|
||||
MojangProfile mojangProfile = mojangAPIService.getProfile(uuid.toString());
|
||||
Tuple<Skin, Cape> skinAndCape = mojangProfile.getSkinAndCape();
|
||||
CachedPlayer player = new CachedPlayer(
|
||||
uuid,
|
||||
mojangProfile.getName(),
|
||||
skinAndCape.getLeft(), // Skin
|
||||
skinAndCape.getRight(), // Cape
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
|
||||
playerCacheRepository.save(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player's uuid from their username.
|
||||
*
|
||||
* @param username the username of the player
|
||||
* @return the uuid of the player
|
||||
*/
|
||||
private UUID usernameToUuid(String username) {
|
||||
Optional<CachedPlayerName> cachedPlayerName = playerNameCacheRepository.findById(username);
|
||||
if (cachedPlayerName.isPresent()) {
|
||||
return cachedPlayerName.get().getUniqueId();
|
||||
}
|
||||
MojangUsernameToUuid mojangUsernameToUuid = mojangAPIService.getUuidFromUsername(username);
|
||||
if (mojangUsernameToUuid == null) {
|
||||
throw new ResourceNotFoundException("Player with username '%s' not found".formatted(username));
|
||||
}
|
||||
UUID uuid = UUIDUtils.addDashes(mojangUsernameToUuid.getId());
|
||||
playerNameCacheRepository.save(new CachedPlayerName(username, uuid));
|
||||
return uuid;
|
||||
}
|
||||
}
|
64
src/main/java/cc.fascinated/service/ServerService.java
Normal file
64
src/main/java/cc.fascinated/service/ServerService.java
Normal file
@ -0,0 +1,64 @@
|
||||
package cc.fascinated.service;
|
||||
|
||||
import cc.fascinated.EnumUtils;
|
||||
import cc.fascinated.common.DNSUtils;
|
||||
import cc.fascinated.common.WebRequest;
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.model.cache.CachedMinecraftServer;
|
||||
import cc.fascinated.model.mojang.MojangProfile;
|
||||
import cc.fascinated.model.mojang.MojangUsernameToUuid;
|
||||
import cc.fascinated.model.server.MinecraftServer;
|
||||
import cc.fascinated.repository.MinecraftServerCacheRepository;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service @Log4j2
|
||||
public class ServerService {
|
||||
|
||||
private final MinecraftServerCacheRepository serverCacheRepository;
|
||||
|
||||
@Autowired
|
||||
public ServerService(MinecraftServerCacheRepository serverCacheRepository) {
|
||||
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
|
||||
* @param port the port of the server
|
||||
* @return the server
|
||||
*/
|
||||
public CachedMinecraftServer getServer(String platformName, String hostname, int port) {
|
||||
MinecraftServer.Platform platform = EnumUtils.getEnumConstant(MinecraftServer.Platform.class, platformName.toUpperCase());
|
||||
if (platform == null) {
|
||||
throw new BadRequestException("Invalid platform: %s".formatted(platformName));
|
||||
}
|
||||
String key = "%s-%s:%s".formatted(platformName, hostname, port);
|
||||
|
||||
Optional<CachedMinecraftServer> cached = serverCacheRepository.findById(key);
|
||||
if (cached.isPresent()) {
|
||||
return cached.get();
|
||||
}
|
||||
|
||||
InetSocketAddress address = platform == MinecraftServer.Platform.JAVA ? DNSUtils.resolveSRV(hostname) : null;
|
||||
if (address != null) {
|
||||
port = port != -1 ? port : platform.getDefaultPort(); // If the port is -1, set it to the default port
|
||||
hostname = address.getHostName();
|
||||
}
|
||||
|
||||
CachedMinecraftServer server = new CachedMinecraftServer(
|
||||
key,
|
||||
platform.getPinger().ping(hostname, port),
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
serverCacheRepository.save(server);
|
||||
return server;
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package cc.fascinated.service.pinger;
|
||||
|
||||
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, int port);
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
package cc.fascinated.service.pinger.impl;
|
||||
|
||||
import cc.fascinated.Main;
|
||||
import cc.fascinated.common.DNSUtils;
|
||||
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.mojang.JavaServerStatusToken;
|
||||
import cc.fascinated.model.server.JavaMinecraftServer;
|
||||
import cc.fascinated.service.pinger.MinecraftServerPinger;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
|
||||
/**
|
||||
* @author Braydon
|
||||
*/
|
||||
@Log4j2(topic = "Java Pinger")
|
||||
public final class JavaMinecraftServerPinger implements MinecraftServerPinger<JavaMinecraftServer> {
|
||||
public static final JavaMinecraftServerPinger INSTANCE = new JavaMinecraftServerPinger();
|
||||
|
||||
private static final int TIMEOUT = 3000; // The timeout for the socket
|
||||
|
||||
@Override
|
||||
public JavaMinecraftServer ping(String hostname, int port) {
|
||||
InetAddress inetAddress = DNSUtils.resolveA(hostname); // Resolve the hostname to an IP address
|
||||
String ip = inetAddress == null ? null : inetAddress.getHostAddress(); // Get the IP address
|
||||
if (ip != null) { // Was the IP resolved?
|
||||
log.info("Resolved hostname: {} -> {}", hostname, ip);
|
||||
}
|
||||
log.info("Pinging {}:{}...", hostname, port);
|
||||
|
||||
// Open a socket connection to the server
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.setTcpNoDelay(true);
|
||||
socket.connect(new InetSocketAddress(hostname, port), TIMEOUT);
|
||||
|
||||
// Open data streams to begin packet transaction
|
||||
try (DataInputStream inputStream = new DataInputStream(socket.getInputStream());
|
||||
DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream())) {
|
||||
// Begin handshaking with the server
|
||||
new JavaPacketHandshakingInSetProtocol(hostname, port, 47).process(inputStream, outputStream);
|
||||
|
||||
// Send the status request to the server, and await back the response
|
||||
JavaPacketStatusInStart packetStatusInStart = new JavaPacketStatusInStart();
|
||||
packetStatusInStart.process(inputStream, outputStream);
|
||||
JavaServerStatusToken token = Main.GSON.fromJson(packetStatusInStart.getResponse(), JavaServerStatusToken.class);
|
||||
return new JavaMinecraftServer(hostname, ip, port, token.getDescription());
|
||||
}
|
||||
} 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:%s:".formatted(hostname, port), ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user