mirror of
https://github.com/ViaVersion/ViaFabricPlus.git
synced 2024-11-25 12:25:22 +01:00
Merge pull request #31 from allinkdev/feat/classicube-auth
Add classes relating to ClassiCube API
This commit is contained in:
commit
8ea2e6922f
@ -0,0 +1,58 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.ClassiCubeAccount;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.process.ILoginProcessHandler;
|
||||
import de.florianmichael.viafabricplus.util.FileSaver;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.security.auth.login.LoginException;
|
||||
import java.util.Map;
|
||||
|
||||
public class ClassiCubeAccountHandler extends FileSaver implements ILoginProcessHandler {
|
||||
public final static Logger LOGGER = LoggerFactory.getLogger("ViaFabricPlus/ClassiCube Account Handler");
|
||||
public static ClassiCubeAccountHandler INSTANCE;
|
||||
private ClassiCubeAccount account;
|
||||
|
||||
public ClassiCubeAccountHandler() {
|
||||
super("classicube.account");
|
||||
}
|
||||
|
||||
public static void create() {
|
||||
ClassiCubeAccountHandler.INSTANCE = new ClassiCubeAccountHandler();
|
||||
ClassiCubeAccountHandler.INSTANCE.init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(JsonObject object) {
|
||||
for (Map.Entry<String, JsonElement> entry : account.toJson().entrySet()) {
|
||||
object.add(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void read(JsonObject object) {
|
||||
try {
|
||||
account = ClassiCubeAccount.fromJson(object);
|
||||
account.login(this);
|
||||
} catch (LoginException e) {
|
||||
LOGGER.error("Failed to log into ClassiCube account!", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMfa(ClassiCubeAccount account) {
|
||||
LOGGER.error("Failed to log into ClassiCube account due to MFA request.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleSuccessfulLogin(ClassiCubeAccount account) {
|
||||
LOGGER.info("Successfully logged into ClassiCube!");
|
||||
}
|
||||
|
||||
public ClassiCubeAccount getAccountClone() {
|
||||
return new ClassiCubeAccount(account.token, account.username, account.password);
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.auth;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.process.ILoginProcessHandler;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.auth.ClassiCubeAuthenticationLoginRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.auth.ClassiCubeAuthenticationTokenRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.auth.ClassiCubeAuthenticationResponse;
|
||||
|
||||
import javax.security.auth.login.LoginException;
|
||||
|
||||
public class ClassiCubeAccount {
|
||||
public String token;
|
||||
public String username;
|
||||
public String password;
|
||||
|
||||
public ClassiCubeAccount(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public ClassiCubeAccount(String token, String username, String password) {
|
||||
this(username, password);
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public JsonObject toJson() {
|
||||
final JsonObject object = new JsonObject();
|
||||
|
||||
object.addProperty("token", this.token);
|
||||
object.addProperty("username", this.username);
|
||||
object.addProperty("password", this.password);
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
public static ClassiCubeAccount fromJson(JsonObject jsonObject) {
|
||||
final String token = jsonObject.getAsString();
|
||||
final String username = jsonObject.getAsString();
|
||||
final String password = jsonObject.getAsString();
|
||||
|
||||
return new ClassiCubeAccount(token, username, password);
|
||||
}
|
||||
|
||||
public void login(ILoginProcessHandler processHandler) throws LoginException {
|
||||
final ClassiCubeAuthenticationTokenRequest initialTokenRequest = new ClassiCubeAuthenticationTokenRequest();
|
||||
final ClassiCubeAuthenticationResponse initialTokenResponse = initialTokenRequest.send()
|
||||
.join();
|
||||
|
||||
// There should NEVER be any errors on the initial token response!
|
||||
if (initialTokenResponse.shouldError()) {
|
||||
final String errorDisplay = initialTokenResponse.getErrorDisplay();
|
||||
|
||||
throw new LoginException(errorDisplay);
|
||||
}
|
||||
|
||||
final ClassiCubeAuthenticationLoginRequest loginRequest = new ClassiCubeAuthenticationLoginRequest(initialTokenResponse, this.username, this.password);
|
||||
final ClassiCubeAuthenticationResponse loginResponse = loginRequest.send()
|
||||
.join();
|
||||
|
||||
if (loginResponse.shouldError()) {
|
||||
final String errorDisplay = loginResponse.getErrorDisplay();
|
||||
|
||||
throw new LoginException(errorDisplay);
|
||||
}
|
||||
|
||||
this.token = loginResponse.token;
|
||||
|
||||
if (initialTokenResponse.mfaRequired()) {
|
||||
processHandler.handleMfa(this);
|
||||
return;
|
||||
}
|
||||
|
||||
processHandler.handleSuccessfulLogin(this);
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.auth;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Contains the authentication data that will be used in the URL parameters of the /api/login request.
|
||||
* Taken from <a href="https://www.classicube.net/api/">the official ClassiCube API documentation</a>
|
||||
*/
|
||||
public class ClassiCubeAuthenticationData {
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final String previousToken;
|
||||
@Nullable private final String loginCode;
|
||||
|
||||
public ClassiCubeAuthenticationData(String username, String password, String previousToken) {
|
||||
this(username, password, previousToken, null);
|
||||
}
|
||||
|
||||
public ClassiCubeAuthenticationData(String username, String password, String previousToken, @Nullable String loginCode) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.previousToken = previousToken;
|
||||
this.loginCode = loginCode;
|
||||
}
|
||||
|
||||
public ClassiCubeAuthenticationData getWithLoginToken(final String loginCode) {
|
||||
return new ClassiCubeAuthenticationData(this.username, this.password, this.previousToken, loginCode);
|
||||
}
|
||||
|
||||
public String getRequestBody() {
|
||||
final StringBuilder builder = new StringBuilder("username=")
|
||||
.append(username);
|
||||
|
||||
builder.append("&password=");
|
||||
builder.append(password);
|
||||
builder.append("&token=");
|
||||
builder.append(previousToken);
|
||||
|
||||
if (loginCode != null) {
|
||||
builder.append("&login_code=");
|
||||
builder.append(loginCode);
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.auth;
|
||||
|
||||
import com.google.gson.JsonDeserializationContext;
|
||||
import com.google.gson.JsonDeserializer;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParseException;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum ClassiCubeError implements JsonDeserializer<ClassiCubeError> {
|
||||
TOKEN("Incorrect token. Is your ViaFabricPlus out of date?"),
|
||||
USERNAME("Invalid username."),
|
||||
PASSWORD("Invalid password."),
|
||||
VERIFICATION("User hasn't verified their E-mail address yet.", false),
|
||||
LOGIN_CODE("Multi-factor authentication requested. Please check your E-mail.");
|
||||
|
||||
public final String description;
|
||||
public final boolean fatal;
|
||||
|
||||
ClassiCubeError(String description) {
|
||||
this(description, true);
|
||||
}
|
||||
|
||||
ClassiCubeError(String description, boolean fatal) {
|
||||
this.description = description;
|
||||
this.fatal = fatal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassiCubeError deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return Arrays.stream(ClassiCubeError.values())
|
||||
.filter(e -> e.name().toLowerCase().equals(json.getAsString()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.process;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.ClassiCubeAccount;
|
||||
|
||||
public interface ILoginProcessHandler {
|
||||
void handleMfa(final ClassiCubeAccount account);
|
||||
void handleSuccessfulLogin(final ClassiCubeAccount account);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.data;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public class ClassiCubeServerInfo {
|
||||
public final String hash;
|
||||
public final int maxplayers;
|
||||
public final String name;
|
||||
public final int players;
|
||||
public final String software;
|
||||
public final long uptime;
|
||||
@SerializedName("country_abbr")
|
||||
public final String countryCode;
|
||||
public final boolean web;
|
||||
public final boolean featured;
|
||||
public final String ip;
|
||||
public final int port;
|
||||
public final String mppass;
|
||||
|
||||
public ClassiCubeServerInfo(String hash, int maxplayers, String name, int players, String software, long uptime, String countryCode, boolean web, boolean featured, String ip, int port, String mppass) {
|
||||
this.hash = hash;
|
||||
this.maxplayers = maxplayers;
|
||||
this.name = name;
|
||||
this.players = players;
|
||||
this.software = software;
|
||||
this.uptime = uptime;
|
||||
this.countryCode = countryCode;
|
||||
this.web = web;
|
||||
this.featured = featured;
|
||||
this.ip = ip;
|
||||
this.port = port;
|
||||
this.mppass = mppass;
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.request;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.ClassiCubeAccountHandler;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.ClassiCubeAccount;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
public abstract class ClassiCubeRequest {
|
||||
|
||||
private final static URI CLASSICUBE_ROOT_URI = URI.create("https://www.classicube.net");
|
||||
protected final static URI AUTHENTICATION_URI = CLASSICUBE_ROOT_URI.resolve("/api/login/");
|
||||
protected final static URI SERVER_INFO_URI = CLASSICUBE_ROOT_URI.resolve("/api/server/");
|
||||
protected final static URI SERVER_LIST_INFO_URI = CLASSICUBE_ROOT_URI.resolve("/api/servers/");
|
||||
protected final static HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
|
||||
|
||||
public HttpRequest buildWithTokenHeader(final HttpRequest.Builder builder) {
|
||||
final ClassiCubeAccountHandler accountHandler = ClassiCubeAccountHandler.INSTANCE;
|
||||
final ClassiCubeAccount account = accountHandler.getAccountClone();
|
||||
|
||||
builder.header("Cookie", "session=" + account.token);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.request.auth;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.ClassiCubeAuthenticationData;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.ClassiCubeRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.auth.ClassiCubeAuthenticationResponse;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class ClassiCubeAuthenticationLoginRequest extends ClassiCubeAuthenticationRequest {
|
||||
private final ClassiCubeAuthenticationData authenticationData;
|
||||
|
||||
public ClassiCubeAuthenticationLoginRequest(ClassiCubeAuthenticationResponse previousResponse, String username, String password) {
|
||||
this(previousResponse, username, password, null);
|
||||
}
|
||||
|
||||
public ClassiCubeAuthenticationLoginRequest(ClassiCubeAuthenticationResponse previousResponse, String username, String password, @Nullable String loginCode) {
|
||||
this.authenticationData = new ClassiCubeAuthenticationData(username, password, previousResponse.token, loginCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ClassiCubeAuthenticationResponse> send() {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
final String requestBody = authenticationData.getRequestBody();
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||
.uri(ClassiCubeRequest.AUTHENTICATION_URI)
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> response = HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.join();
|
||||
|
||||
final String responseBody = response.body();
|
||||
return ClassiCubeAuthenticationResponse.fromJson(responseBody);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.request.auth;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.ClassiCubeRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.auth.ClassiCubeAuthenticationResponse;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public abstract class ClassiCubeAuthenticationRequest extends ClassiCubeRequest {
|
||||
|
||||
public abstract CompletableFuture<ClassiCubeAuthenticationResponse> send();
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.request.auth;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.ClassiCubeRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.auth.ClassiCubeAuthenticationResponse;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class ClassiCubeAuthenticationTokenRequest extends ClassiCubeAuthenticationRequest {
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ClassiCubeAuthenticationResponse> send() {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.GET()
|
||||
.uri(ClassiCubeRequest.AUTHENTICATION_URI)
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> response = HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.join();
|
||||
|
||||
final String responseBody = response.body();
|
||||
return ClassiCubeAuthenticationResponse.fromJson(responseBody);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.request.server;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.ClassiCubeRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.server.ClassiCubeServerInfoResponse;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class ClassiCubeServerInfoRequest extends ClassiCubeRequest {
|
||||
private final Set<String> serverHashes;
|
||||
|
||||
public ClassiCubeServerInfoRequest(final String serverHash) {
|
||||
this(Set.of(serverHash));
|
||||
}
|
||||
|
||||
public ClassiCubeServerInfoRequest(final Set<String> serverHashes) {
|
||||
this.serverHashes = serverHashes;
|
||||
}
|
||||
|
||||
private URI generateUri() {
|
||||
final String joined = String.join(",", serverHashes);
|
||||
|
||||
return SERVER_INFO_URI.resolve(joined);
|
||||
}
|
||||
|
||||
public CompletableFuture<ClassiCubeServerInfoResponse> send() {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
final URI uri = this.generateUri();
|
||||
final HttpRequest request = buildWithTokenHeader(HttpRequest.newBuilder()
|
||||
.GET()
|
||||
.uri(uri));
|
||||
final HttpResponse<String> response = HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.join();
|
||||
|
||||
final String body = response.body();
|
||||
|
||||
return ClassiCubeServerInfoResponse.fromJson(body);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.request.server;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.request.ClassiCubeRequest;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.server.ClassiCubeServerInfoResponse;
|
||||
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class ClassiCubeServerListRequest extends ClassiCubeRequest {
|
||||
public CompletableFuture<ClassiCubeServerInfoResponse> send() {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
final HttpRequest request = buildWithTokenHeader(HttpRequest.newBuilder()
|
||||
.GET()
|
||||
.uri(SERVER_LIST_INFO_URI));
|
||||
final HttpResponse<String> response = HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.join();
|
||||
|
||||
final String body = response.body();
|
||||
|
||||
return ClassiCubeServerInfoResponse.fromJson(body);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.response;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
public abstract class ClassiCubeResponse {
|
||||
protected final static Gson GSON = new GsonBuilder()
|
||||
.serializeNulls()
|
||||
.create();
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.response.auth;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.auth.ClassiCubeError;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.ClassiCubeResponse;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The class containing the response from the ClassiCube authentication service.
|
||||
* Most fields, except for authenticated and errors, are null in the first request.
|
||||
* As such, they are annotated as {@link Nullable}.
|
||||
*/
|
||||
public class ClassiCubeAuthenticationResponse extends ClassiCubeResponse {
|
||||
@Nullable public final String token;
|
||||
@Nullable public final String username;
|
||||
public final boolean authenticated;
|
||||
|
||||
public ClassiCubeAuthenticationResponse(@Nullable String token, @Nullable String username, boolean authenticated, Set<ClassiCubeError> errors) {
|
||||
this.token = token;
|
||||
this.username = username;
|
||||
this.authenticated = authenticated;
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public final Set<ClassiCubeError> errors;
|
||||
|
||||
public boolean shouldError() {
|
||||
return errors.size() > 0 &&
|
||||
errors.stream().anyMatch(e -> e.fatal);
|
||||
}
|
||||
|
||||
public String getErrorDisplay() {
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
|
||||
for (ClassiCubeError error : this.errors) {
|
||||
builder.append(error.description)
|
||||
.append("\n");
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
public boolean mfaRequired() {
|
||||
return this.errors.stream().anyMatch(e -> e == ClassiCubeError.LOGIN_CODE);
|
||||
}
|
||||
|
||||
public boolean isJustMfaError() {
|
||||
return mfaRequired() &&
|
||||
this.errors.stream().anyMatch(e -> e != ClassiCubeError.LOGIN_CODE);
|
||||
}
|
||||
|
||||
public static ClassiCubeAuthenticationResponse fromJson(final String json) {
|
||||
return GSON.fromJson(json, ClassiCubeAuthenticationResponse.class);
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package de.florianmichael.viafabricplus.definition.c0_30.classicube.response.server;
|
||||
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.data.ClassiCubeServerInfo;
|
||||
import de.florianmichael.viafabricplus.definition.c0_30.classicube.response.ClassiCubeResponse;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public class ClassiCubeServerInfoResponse extends ClassiCubeResponse {
|
||||
private final Set<ClassiCubeServerInfo> servers;
|
||||
|
||||
public ClassiCubeServerInfoResponse(Set<ClassiCubeServerInfo> servers) {
|
||||
this.servers = servers;
|
||||
}
|
||||
|
||||
public static ClassiCubeServerInfoResponse fromJson(final String json) {
|
||||
return GSON.fromJson(json, ClassiCubeServerInfoResponse.class);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user