Compare commits
2 Commits
63121afe32
...
a03bac8f1d
Author | SHA1 | Date | |
---|---|---|---|
a03bac8f1d | |||
655ee50a21 |
@ -24,11 +24,14 @@
|
|||||||
package me.braydon.mc.common;
|
package me.braydon.mc.common;
|
||||||
|
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
import lombok.experimental.UtilityClass;
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.awt.geom.AffineTransform;
|
import java.awt.geom.AffineTransform;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Braydon
|
* @author Braydon
|
||||||
@ -65,4 +68,19 @@ public final class ImageUtils {
|
|||||||
graphics.dispose();
|
graphics.dispose();
|
||||||
return flipped;
|
return flipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the byte array from the given image.
|
||||||
|
*
|
||||||
|
* @param image the image to extract from
|
||||||
|
* @return the byte array of the image
|
||||||
|
*/
|
||||||
|
@SneakyThrows
|
||||||
|
public static byte[] toByteArray(@NonNull BufferedImage image) {
|
||||||
|
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||||
|
ImageIO.write(image, "png", outputStream);
|
||||||
|
outputStream.flush();
|
||||||
|
return outputStream.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -32,7 +32,7 @@ import me.braydon.mc.model.skin.Skin;
|
|||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.net.URL;
|
import java.io.ByteArrayInputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A renderer for a {@link ISkinPart}.
|
* A renderer for a {@link ISkinPart}.
|
||||||
@ -63,17 +63,17 @@ public abstract class SkinRenderer<T extends ISkinPart> {
|
|||||||
*/
|
*/
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
protected final BufferedImage getVanillaSkinPart(@NonNull Skin skin, @NonNull ISkinPart.Vanilla part, double size) {
|
protected final BufferedImage getVanillaSkinPart(@NonNull Skin skin, @NonNull ISkinPart.Vanilla part, double size) {
|
||||||
BufferedImage skinImage = ImageIO.read(new URL(skin.getUrl())); // The skin texture
|
|
||||||
ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part
|
ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part
|
||||||
|
|
||||||
// The skin texture is legacy, use legacy coordinates
|
// The skin texture is legacy, use legacy coordinates
|
||||||
if (skinImage.getHeight() == 32 && part.hasLegacyCoordinates()) {
|
if (skin.isLegacy() && part.hasLegacyCoordinates()) {
|
||||||
coordinates = part.getLegacyCoordinates();
|
coordinates = part.getLegacyCoordinates();
|
||||||
}
|
}
|
||||||
int width = part.getWidth(); // The width of the part
|
int width = part.getWidth(); // The width of the part
|
||||||
if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) {
|
if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) {
|
||||||
width--;
|
width--;
|
||||||
}
|
}
|
||||||
|
BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); // The skin texture
|
||||||
BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size);
|
BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size);
|
||||||
if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) {
|
if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) {
|
||||||
partTexture = ImageUtils.flip(partTexture);
|
partTexture = ImageUtils.flip(partTexture);
|
||||||
|
@ -63,6 +63,7 @@ public final class PlayerController {
|
|||||||
* A GET route to get a player by their username or UUID.
|
* A GET route to get a player by their username or UUID.
|
||||||
*
|
*
|
||||||
* @param query the query to search for the player by
|
* @param query the query to search for the player by
|
||||||
|
* @param signed whether the profile is signed
|
||||||
* @return the player response
|
* @return the player response
|
||||||
* @throws BadRequestException if the UUID or username is invalid
|
* @throws BadRequestException if the UUID or username is invalid
|
||||||
* @throws ResourceNotFoundException if the player is not found
|
* @throws ResourceNotFoundException if the player is not found
|
||||||
@ -71,9 +72,10 @@ public final class PlayerController {
|
|||||||
@GetMapping("/{query}")
|
@GetMapping("/{query}")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public ResponseEntity<CachedPlayer> getPlayer(
|
public ResponseEntity<CachedPlayer> getPlayer(
|
||||||
@Parameter(description = "The player username or UUID to get", example = "Rainnny") @PathVariable @NonNull String query
|
@Parameter(description = "The player username or UUID to get", example = "Rainnny") @PathVariable @NonNull String query,
|
||||||
|
@Parameter(description = "Whether the profile is signed by Mojang") @RequestParam(required = false) boolean signed
|
||||||
) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException {
|
) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException {
|
||||||
return ResponseEntity.ofNullable(mojangService.getPlayer(query));
|
return ResponseEntity.ofNullable(mojangService.getPlayer(query, signed));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,7 +26,6 @@ package me.braydon.mc.model;
|
|||||||
import lombok.*;
|
import lombok.*;
|
||||||
import me.braydon.mc.model.skin.Skin;
|
import me.braydon.mc.model.skin.Skin;
|
||||||
import me.braydon.mc.model.token.MojangProfileToken;
|
import me.braydon.mc.model.token.MojangProfileToken;
|
||||||
import org.springframework.data.annotation.Id;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -40,7 +39,7 @@ public class Player {
|
|||||||
/**
|
/**
|
||||||
* The unique id of this player.
|
* The unique id of this player.
|
||||||
*/
|
*/
|
||||||
@Id @EqualsAndHashCode.Include @NonNull private final UUID uniqueId;
|
@EqualsAndHashCode.Include @NonNull private final UUID uniqueId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The username of this player.
|
* The username of this player.
|
||||||
|
@ -38,7 +38,7 @@ import java.io.Serializable;
|
|||||||
@RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds)
|
@RedisHash(value = "server", timeToLive = 60L) // 1 minute (in seconds)
|
||||||
public final class CachedMinecraftServer implements Serializable {
|
public final class CachedMinecraftServer implements Serializable {
|
||||||
/**
|
/**
|
||||||
* The id of this cached server.
|
* The id of this cache element.
|
||||||
*/
|
*/
|
||||||
@Id @JsonIgnore @NonNull private final String id;
|
@Id @JsonIgnore @NonNull private final String id;
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
*/
|
*/
|
||||||
package me.braydon.mc.model.cache;
|
package me.braydon.mc.model.cache;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
@ -32,6 +33,7 @@ import me.braydon.mc.model.Player;
|
|||||||
import me.braydon.mc.model.ProfileAction;
|
import me.braydon.mc.model.ProfileAction;
|
||||||
import me.braydon.mc.model.skin.Skin;
|
import me.braydon.mc.model.skin.Skin;
|
||||||
import me.braydon.mc.model.token.MojangProfileToken;
|
import me.braydon.mc.model.token.MojangProfileToken;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
import org.springframework.data.redis.core.RedisHash;
|
import org.springframework.data.redis.core.RedisHash;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
@ -46,16 +48,26 @@ import java.util.UUID;
|
|||||||
@ToString(callSuper = true)
|
@ToString(callSuper = true)
|
||||||
@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds)
|
@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds)
|
||||||
public final class CachedPlayer extends Player implements Serializable {
|
public final class CachedPlayer extends Player implements Serializable {
|
||||||
|
/**
|
||||||
|
* The id of this cache element.
|
||||||
|
* <p>
|
||||||
|
* This ID is in the given format:
|
||||||
|
* player:<uniqueId>-<signed>
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Id @NonNull @JsonIgnore private final String cacheId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The unix timestamp of when this
|
* The unix timestamp of when this
|
||||||
* player was cached, -1 if not cached.
|
* player was cached, -1 if not cached.
|
||||||
*/
|
*/
|
||||||
private long cached;
|
private long cached;
|
||||||
|
|
||||||
public CachedPlayer(@NonNull UUID uniqueId, @NonNull String username, @NonNull Skin skin, Cape cape,
|
public CachedPlayer(@NonNull String cacheId, @NonNull UUID uniqueId, @NonNull String username, @NonNull Skin skin,
|
||||||
@NonNull MojangProfileToken.ProfileProperty[] properties, ProfileAction[] profileActions,
|
Cape cape, @NonNull MojangProfileToken.ProfileProperty[] properties, ProfileAction[] profileActions,
|
||||||
long cached) {
|
long cached) {
|
||||||
super(uniqueId, username, skin, cape, properties, profileActions);
|
super(uniqueId, username, skin, cape, properties, profileActions);
|
||||||
|
this.cacheId = cacheId;
|
||||||
this.cached = cached;
|
this.cached = cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -23,12 +23,17 @@
|
|||||||
*/
|
*/
|
||||||
package me.braydon.mc.model.skin;
|
package me.braydon.mc.model.skin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
import me.braydon.mc.common.ImageUtils;
|
||||||
import me.braydon.mc.config.AppConfig;
|
import me.braydon.mc.config.AppConfig;
|
||||||
import me.braydon.mc.model.Player;
|
import me.braydon.mc.model.Player;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.net.URL;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -37,9 +42,9 @@ import java.util.Map;
|
|||||||
*
|
*
|
||||||
* @author Braydon
|
* @author Braydon
|
||||||
*/
|
*/
|
||||||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE) @Setter @Getter @ToString
|
@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @ToString
|
||||||
public final class Skin {
|
public final class Skin {
|
||||||
public static final Skin DEFAULT_STEVE = new Skin("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1", Model.DEFAULT);
|
public static final Skin DEFAULT_STEVE = create("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1", Model.DEFAULT);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The texture URL of this skin.
|
* The texture URL of this skin.
|
||||||
@ -51,10 +56,20 @@ public final class Skin {
|
|||||||
*/
|
*/
|
||||||
@NonNull private final Model model;
|
@NonNull private final Model model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image data of this skin.
|
||||||
|
*/
|
||||||
|
@JsonIgnore private final byte[] skinImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this skin legacy?
|
||||||
|
*/
|
||||||
|
private final boolean legacy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URLs to the parts of this skin.
|
* URLs to the parts of this skin.
|
||||||
*/
|
*/
|
||||||
@NonNull @JsonProperty("parts") private Map<String, String> partUrls = new HashMap<>();
|
@NonNull @JsonProperty("parts") private final Map<String, String> partUrls;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populate the part URLs for this skin.
|
* Populate the part URLs for this skin.
|
||||||
@ -88,7 +103,22 @@ public final class Skin {
|
|||||||
if (metadataJsonObject != null) { // Parse the skin model
|
if (metadataJsonObject != null) { // Parse the skin model
|
||||||
model = Model.valueOf(metadataJsonObject.get("model").getAsString().toUpperCase());
|
model = Model.valueOf(metadataJsonObject.get("model").getAsString().toUpperCase());
|
||||||
}
|
}
|
||||||
return new Skin(jsonObject.get("url").getAsString(), model);
|
return create(jsonObject.get("url").getAsString(), model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a skin from the given URL and model.
|
||||||
|
*
|
||||||
|
* @param url the skin url
|
||||||
|
* @param model the skin model
|
||||||
|
* @return the constructed skin
|
||||||
|
*/
|
||||||
|
@NonNull @SneakyThrows
|
||||||
|
private static Skin create(@NonNull String url, @NonNull Model model) {
|
||||||
|
BufferedImage image = ImageIO.read(new URL(url)); // Get the skin image
|
||||||
|
byte[] bytes = ImageUtils.toByteArray(image); // Convert the image into bytes
|
||||||
|
boolean legacy = image.getWidth() == 64 && image.getHeight() == 32; // Is the skin legacy?
|
||||||
|
return new Skin(url, model, bytes, legacy, new HashMap<>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,11 +26,9 @@ package me.braydon.mc.repository;
|
|||||||
import me.braydon.mc.model.cache.CachedPlayer;
|
import me.braydon.mc.model.cache.CachedPlayer;
|
||||||
import org.springframework.data.repository.CrudRepository;
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A cache repository for {@link CachedPlayer}'s.
|
* A cache repository for {@link CachedPlayer}'s.
|
||||||
*
|
*
|
||||||
* @author Braydon
|
* @author Braydon
|
||||||
*/
|
*/
|
||||||
public interface PlayerCacheRepository extends CrudRepository<CachedPlayer, UUID> { }
|
public interface PlayerCacheRepository extends CrudRepository<CachedPlayer, String> { }
|
@ -61,9 +61,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
@ -216,7 +214,7 @@ public final class MojangService {
|
|||||||
Skin skin = null; // The target skin to get the skin part of
|
Skin skin = null; // The target skin to get the skin part of
|
||||||
long before = System.currentTimeMillis();
|
long before = System.currentTimeMillis();
|
||||||
try {
|
try {
|
||||||
CachedPlayer player = getPlayer(query); // Retrieve the player
|
CachedPlayer player = getPlayer(query, false); // Retrieve the player
|
||||||
skin = player.getSkin(); // Use the player's skin
|
skin = player.getSkin(); // Use the player's skin
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
// Simply ignore, and fallback to the default skin
|
// Simply ignore, and fallback to the default skin
|
||||||
@ -231,16 +229,10 @@ public final class MojangService {
|
|||||||
BufferedImage texture = part.render(skin, overlays, size); // Render the skin part
|
BufferedImage texture = part.render(skin, overlays, size); // Render the skin part
|
||||||
log.info("Render of skin part took {}ms: {}", System.currentTimeMillis() - before, id);
|
log.info("Render of skin part took {}ms: {}", System.currentTimeMillis() - before, id);
|
||||||
|
|
||||||
// Convert BufferedImage to byte array
|
byte[] bytes = ImageUtils.toByteArray(texture); // Convert the image into a byte array
|
||||||
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
skinPartTextureCache.save(new CachedSkinPartTexture(id, bytes)); // Cache the texture
|
||||||
ImageIO.write(texture, "png", outputStream);
|
log.info("Cached skin part texture: {}", id);
|
||||||
outputStream.flush();
|
return bytes;
|
||||||
|
|
||||||
byte[] bytes = outputStream.toByteArray();
|
|
||||||
skinPartTextureCache.save(new CachedSkinPartTexture(id, bytes)); // Cache the texture
|
|
||||||
log.info("Cached skin part texture: {}", id);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -252,14 +244,15 @@ public final class MojangService {
|
|||||||
* and then return the response.
|
* and then return the response.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* @param query the query to search for the player by
|
* @param query the query to search for the player by
|
||||||
|
* @param signed whether the profile is signed
|
||||||
* @return the player
|
* @return the player
|
||||||
* @throws BadRequestException if the UUID or username is invalid
|
* @throws BadRequestException if the UUID or username is invalid
|
||||||
* @throws ResourceNotFoundException if the player is not found
|
* @throws ResourceNotFoundException if the player is not found
|
||||||
* @throws MojangRateLimitException if the Mojang API rate limit is reached
|
* @throws MojangRateLimitException if the Mojang API rate limit is reached
|
||||||
*/
|
*/
|
||||||
@NonNull
|
@NonNull
|
||||||
public CachedPlayer getPlayer(@NonNull String query) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException {
|
public CachedPlayer getPlayer(@NonNull String query, boolean signed) throws BadRequestException, ResourceNotFoundException, MojangRateLimitException {
|
||||||
log.info("Requesting player with query: {}", query);
|
log.info("Requesting player with query: {}", query);
|
||||||
|
|
||||||
UUID uuid; // The player UUID to lookup
|
UUID uuid; // The player UUID to lookup
|
||||||
@ -278,10 +271,11 @@ public final class MojangService {
|
|||||||
uuid = usernameToUUID(query);
|
uuid = usernameToUUID(query);
|
||||||
log.info("Found UUID for username {}: {}", query, uuid);
|
log.info("Found UUID for username {}: {}", query, uuid);
|
||||||
}
|
}
|
||||||
|
String cacheId = "%s-%s".formatted(uuid, signed); // The cache id of the player
|
||||||
|
|
||||||
// Check the cache for the player
|
// Check the cache for the player
|
||||||
// and return it if it's present
|
// and return it if it's present
|
||||||
Optional<CachedPlayer> cached = playerCache.findById(uuid);
|
Optional<CachedPlayer> cached = playerCache.findById(cacheId);
|
||||||
if (cached.isPresent()) { // Respond with the cache if present
|
if (cached.isPresent()) { // Respond with the cache if present
|
||||||
log.info("Found player in cache: {}", uuid);
|
log.info("Found player in cache: {}", uuid);
|
||||||
return cached.get();
|
return cached.get();
|
||||||
@ -291,14 +285,13 @@ public final class MojangService {
|
|||||||
// the player profile by their UUID
|
// the player profile by their UUID
|
||||||
try {
|
try {
|
||||||
log.info("Retrieving player profile for UUID: {}", uuid);
|
log.info("Retrieving player profile for UUID: {}", uuid);
|
||||||
MojangProfileToken token = JsonWebRequest.makeRequest(
|
String endpoint = UUID_TO_PROFILE.formatted(uuid) + (signed ? "?unsigned=false" : "");
|
||||||
UUID_TO_PROFILE.formatted(uuid), HttpMethod.GET
|
MojangProfileToken token = JsonWebRequest.makeRequest(endpoint, HttpMethod.GET).execute(MojangProfileToken.class);
|
||||||
).execute(MojangProfileToken.class);
|
|
||||||
MojangProfileToken.SkinProperties skinProperties = token.getSkinProperties(); // Get the skin and cape
|
MojangProfileToken.SkinProperties skinProperties = token.getSkinProperties(); // Get the skin and cape
|
||||||
ProfileAction[] profileActions = token.getProfileActions();
|
ProfileAction[] profileActions = token.getProfileActions();
|
||||||
|
|
||||||
// Build our player model, cache it, and then return it
|
// Build our player model, cache it, and then return it
|
||||||
CachedPlayer player = new CachedPlayer(uuid, token.getName(),
|
CachedPlayer player = new CachedPlayer(cacheId, uuid, token.getName(),
|
||||||
skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(),
|
skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(),
|
||||||
skinProperties.getCape(), token.getProperties(), profileActions.length == 0 ? null : profileActions,
|
skinProperties.getCape(), token.getProperties(), profileActions.length == 0 ? null : profileActions,
|
||||||
System.currentTimeMillis()
|
System.currentTimeMillis()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user