mirror of
https://github.com/BentoBoxWorld/BentoBox.git
synced 2024-11-25 04:05:36 +01:00
Improve purge by using our own last login value stored in the db.
This commit is contained in:
parent
68d53798e9
commit
2cb0651f95
@ -207,6 +207,7 @@ public class BentoBox extends JavaPlugin implements Listener {
|
|||||||
registerListeners();
|
registerListeners();
|
||||||
|
|
||||||
// Load islands from database - need to wait until all the worlds are loaded
|
// Load islands from database - need to wait until all the worlds are loaded
|
||||||
|
log("Loading islands from database...");
|
||||||
try {
|
try {
|
||||||
islandsManager.load();
|
islandsManager.load();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
@ -5,6 +5,8 @@ import java.util.HashSet;
|
|||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
@ -21,8 +23,10 @@ import world.bentobox.bentobox.util.Util;
|
|||||||
|
|
||||||
public class AdminPurgeCommand extends CompositeCommand implements Listener {
|
public class AdminPurgeCommand extends CompositeCommand implements Listener {
|
||||||
|
|
||||||
|
private static final Long YEAR2000 = 946713600L;
|
||||||
private int count;
|
private int count;
|
||||||
private boolean inPurge;
|
private boolean inPurge;
|
||||||
|
private boolean scanning;
|
||||||
private boolean toBeConfirmed;
|
private boolean toBeConfirmed;
|
||||||
private Iterator<String> it;
|
private Iterator<String> it;
|
||||||
private User user;
|
private User user;
|
||||||
@ -47,6 +51,10 @@ public class AdminPurgeCommand extends CompositeCommand implements Listener {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean canExecute(User user, String label, List<String> args) {
|
public boolean canExecute(User user, String label, List<String> args) {
|
||||||
|
if (scanning) {
|
||||||
|
user.sendMessage("commands.admin.purge.scanning-in-progress");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (inPurge) {
|
if (inPurge) {
|
||||||
user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
|
user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
|
||||||
return false;
|
return false;
|
||||||
@ -75,13 +83,21 @@ public class AdminPurgeCommand extends CompositeCommand implements Listener {
|
|||||||
user.sendMessage("commands.admin.purge.days-one-or-more");
|
user.sendMessage("commands.admin.purge.days-one-or-more");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
islands = getOldIslands(days);
|
user.sendMessage("commands.admin.purge.scanning");
|
||||||
user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, String.valueOf(islands.size()));
|
scanning = true;
|
||||||
if (!islands.isEmpty()) {
|
getOldIslands(days).thenAccept(islandSet -> {
|
||||||
|
user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER,
|
||||||
|
String.valueOf(islandSet.size()));
|
||||||
|
if (!islandSet.isEmpty()) {
|
||||||
toBeConfirmed = true;
|
toBeConfirmed = true;
|
||||||
user.sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, this.getTopLabel());
|
user.sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, this.getTopLabel());
|
||||||
return false;
|
islands = islandSet;
|
||||||
|
} else {
|
||||||
|
user.sendMessage("commands.admin.purge.none-found");
|
||||||
}
|
}
|
||||||
|
scanning = false;
|
||||||
|
});
|
||||||
|
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
user.sendMessage("commands.admin.purge.number-error");
|
user.sendMessage("commands.admin.purge.number-error");
|
||||||
return false;
|
return false;
|
||||||
@ -125,40 +141,61 @@ public class AdminPurgeCommand extends CompositeCommand implements Listener {
|
|||||||
* @param days days
|
* @param days days
|
||||||
* @return set of islands
|
* @return set of islands
|
||||||
*/
|
*/
|
||||||
Set<String> getOldIslands(int days) {
|
CompletableFuture<Set<String>> getOldIslands(int days) {
|
||||||
long currentTimeMillis = System.currentTimeMillis();
|
CompletableFuture<Set<String>> result = new CompletableFuture<>();
|
||||||
long daysInMilliseconds = (long) days * 1000 * 3600 * 24;
|
|
||||||
Set<String> oldIslands = new HashSet<>();
|
|
||||||
|
|
||||||
// Process islands in one pass, logging and adding to the set if applicable
|
// Process islands in one pass, logging and adding to the set if applicable
|
||||||
getPlugin().getIslands().getIslands().stream()
|
getPlugin().getIslands().getIslandsASync().thenAccept(list -> {
|
||||||
|
user.sendMessage("commands.admin.purge.total-islands", TextVariables.NUMBER, String.valueOf(list.size()));
|
||||||
|
Set<String> oldIslands = new HashSet<>();
|
||||||
|
list.stream()
|
||||||
.filter(i -> !i.isSpawn()).filter(i -> !i.getPurgeProtected())
|
.filter(i -> !i.isSpawn()).filter(i -> !i.getPurgeProtected())
|
||||||
.filter(i -> i.getWorld() != null) // to handle currently unloaded world islands
|
.filter(i -> i.getWorld() != null) // to handle currently unloaded world islands
|
||||||
.filter(i -> i.getWorld().equals(this.getWorld())).filter(Island::isOwned).filter(
|
.filter(i -> i.getWorld().equals(this.getWorld())) // Island needs to be in this world
|
||||||
i -> i.getMemberSet().stream()
|
.filter(Island::isOwned) // The island needs to be owned
|
||||||
.allMatch(member -> (currentTimeMillis
|
.filter(i -> i.getMemberSet().stream().allMatch(member -> checkLastLoginTimestamp(days, member)))
|
||||||
- Bukkit.getOfflinePlayer(member).getLastPlayed()) > daysInMilliseconds))
|
|
||||||
.forEach(i -> {
|
.forEach(i -> {
|
||||||
// Add the unique island ID to the set
|
// Add the unique island ID to the set
|
||||||
oldIslands.add(i.getUniqueId());
|
oldIslands.add(i.getUniqueId());
|
||||||
BentoBox.getInstance().log("Will purge island at " + Util.xyz(i.getCenter().toVector()) + " in "
|
getPlugin().log("Will purge island at " + Util.xyz(i.getCenter().toVector()) + " in "
|
||||||
+ i.getWorld().getName());
|
+ i.getWorld().getName());
|
||||||
// Log each member's last login information
|
// Log each member's last login information
|
||||||
i.getMemberSet().forEach(member -> {
|
i.getMemberSet().forEach(member -> {
|
||||||
Date lastLogin = new Date(Bukkit.getOfflinePlayer(member).getLastPlayed());
|
Long timestamp = getPlayers().getLastLoginTimestamp(member);
|
||||||
|
Date lastLogin = new Date(timestamp);
|
||||||
BentoBox.getInstance()
|
BentoBox.getInstance()
|
||||||
.log("Player " + BentoBox.getInstance().getPlayers().getName(member)
|
.log("Player " + BentoBox.getInstance().getPlayers().getName(member)
|
||||||
+ " last logged in "
|
+ " last logged in "
|
||||||
+ (int) ((currentTimeMillis - Bukkit.getOfflinePlayer(member).getLastPlayed())
|
+ (int) ((System.currentTimeMillis() - timestamp) / 1000 / 3600 / 24)
|
||||||
/ 1000 / 3600 / 24)
|
|
||||||
+ " days ago. " + lastLogin);
|
+ " days ago. " + lastLogin);
|
||||||
});
|
});
|
||||||
BentoBox.getInstance().log("+-----------------------------------------+");
|
BentoBox.getInstance().log("+-----------------------------------------+");
|
||||||
});
|
});
|
||||||
|
result.complete(oldIslands);
|
||||||
return oldIslands;
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean checkLastLoginTimestamp(int days, UUID member) {
|
||||||
|
long daysInMilliseconds = days * 24L * 3600 * 1000; // Calculate days in milliseconds
|
||||||
|
Long lastLoginTimestamp = getPlayers().getLastLoginTimestamp(member);
|
||||||
|
// If no valid last login time is found or it's before the year 2000, try to fetch from Bukkit
|
||||||
|
if (lastLoginTimestamp == null || lastLoginTimestamp < YEAR2000) {
|
||||||
|
lastLoginTimestamp = Bukkit.getOfflinePlayer(member).getLastPlayed();
|
||||||
|
|
||||||
|
// If still invalid, set the current timestamp to mark the user for eventual purging
|
||||||
|
if (lastLoginTimestamp < YEAR2000) {
|
||||||
|
getPlayers().setLoginTimeStamp(member, System.currentTimeMillis());
|
||||||
|
return false; // User will be purged in the future
|
||||||
|
} else {
|
||||||
|
// Otherwise, update the last login timestamp with the valid value from Bukkit
|
||||||
|
getPlayers().setLoginTimeStamp(member, lastLoginTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if the difference between now and the last login is greater than the allowed days
|
||||||
|
return System.currentTimeMillis() - lastLoginTimestamp > daysInMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the inPurge
|
* @return the inPurge
|
||||||
*/
|
*/
|
||||||
|
@ -134,10 +134,31 @@ public abstract class AbstractDatabaseHandler<T> {
|
|||||||
@Nullable
|
@Nullable
|
||||||
public abstract T loadObject(@NonNull String uniqueId) throws InstantiationException, IllegalAccessException, InvocationTargetException, ClassNotFoundException, IntrospectionException, NoSuchMethodException;
|
public abstract T loadObject(@NonNull String uniqueId) throws InstantiationException, IllegalAccessException, InvocationTargetException, ClassNotFoundException, IntrospectionException, NoSuchMethodException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all the records in this table and returns a list of them async
|
||||||
|
* @return CompletableFuture List of <T>
|
||||||
|
* @since 2.7.0
|
||||||
|
*/
|
||||||
|
public CompletableFuture<List<T>> loadObjectsASync() {
|
||||||
|
CompletableFuture<List<T>> completableFuture = new CompletableFuture<>();
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(BentoBox.getInstance(), () -> {
|
||||||
|
try {
|
||||||
|
completableFuture.complete(loadObjects()); // Complete the future with the result
|
||||||
|
} catch (Exception e) {
|
||||||
|
completableFuture.completeExceptionally(e); // Complete exceptionally if an error occurs
|
||||||
|
plugin.logError("Failed to load objects asynchronously: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return completableFuture;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save T into the corresponding database
|
* Save T into the corresponding database
|
||||||
*
|
*
|
||||||
* @param instance that should be inserted into the database
|
* @param instance that should be inserted into the database
|
||||||
|
* @return completable future that is true if saved
|
||||||
*/
|
*/
|
||||||
public abstract CompletableFuture<Boolean> saveObject(T instance) throws IllegalAccessException, InvocationTargetException, IntrospectionException ;
|
public abstract CompletableFuture<Boolean> saveObject(T instance) throws IllegalAccessException, InvocationTargetException, IntrospectionException ;
|
||||||
|
|
||||||
|
@ -166,6 +166,13 @@ public class Database<T> {
|
|||||||
return dataObjects;
|
return dataObjects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all objects async
|
||||||
|
* @return CompletableFuture<List<T>>
|
||||||
|
*/
|
||||||
|
public @NonNull CompletableFuture<List<T>> loadObjectsASync() {
|
||||||
|
return handler.loadObjectsASync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -458,6 +458,16 @@ public class IslandsManager {
|
|||||||
return handler.loadObjects().stream().toList();
|
return handler.loadObjects().stream().toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all existing islands from the database without caching async
|
||||||
|
*
|
||||||
|
* @return CompletableFuture<List> of every island
|
||||||
|
* @since 2.7.0
|
||||||
|
*/
|
||||||
|
public CompletableFuture<List<Island>> getIslandsASync() {
|
||||||
|
return handler.loadObjectsASync();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an <strong>unmodifiable collection</strong> of all the islands (even
|
* Returns an <strong>unmodifiable collection</strong> of all the islands (even
|
||||||
* those who may be unowned) in the specified world.
|
* those who may be unowned) in the specified world.
|
||||||
|
@ -423,21 +423,33 @@ public class PlayersManager {
|
|||||||
/**
|
/**
|
||||||
* Records when the user last logged in. Called by the joinleave listener
|
* Records when the user last logged in. Called by the joinleave listener
|
||||||
* @param user user
|
* @param user user
|
||||||
|
* @since 2.7.0
|
||||||
*/
|
*/
|
||||||
public void setLoginTimeStamp(User user) {
|
public void setLoginTimeStamp(User user) {
|
||||||
if (user.isPlayer() && user.isOnline()) {
|
if (user.isPlayer() && user.isOnline()) {
|
||||||
Players p = this.getPlayer(user.getUniqueId());
|
setLoginTimeStamp(user.getUniqueId(), System.currentTimeMillis());
|
||||||
if (p != null) {
|
|
||||||
p.setLastLogin(System.currentTimeMillis());
|
|
||||||
this.savePlayer(user.getUniqueId());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the player's last login time to a timestamp
|
||||||
|
* @param playerUUID player UUID
|
||||||
|
* @param timestamp timestamp to set
|
||||||
|
* @since 2.7.0
|
||||||
|
*/
|
||||||
|
public void setLoginTimeStamp(UUID playerUUID, long timestamp) {
|
||||||
|
Players p = this.getPlayer(playerUUID);
|
||||||
|
if (p != null) {
|
||||||
|
p.setLastLogin(timestamp);
|
||||||
|
this.savePlayer(playerUUID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the last login time stamp for this player
|
* Get the last login time stamp for this player
|
||||||
* @param uuid player's UUID
|
* @param uuid player's UUID
|
||||||
* @return timestamp or null if unknown or not recorded yet
|
* @return timestamp or null if unknown or not recorded yet
|
||||||
|
* @since 2.7.0
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
public Long getLastLoginTimestamp(UUID uuid) {
|
public Long getLastLoginTimestamp(UUID uuid) {
|
||||||
|
@ -98,6 +98,10 @@ commands:
|
|||||||
purgable-islands: '&a Found &b [number] &a purgable islands.'
|
purgable-islands: '&a Found &b [number] &a purgable islands.'
|
||||||
purge-in-progress: '&c Purging in progress. Use &b /[label] purge stop &c to
|
purge-in-progress: '&c Purging in progress. Use &b /[label] purge stop &c to
|
||||||
cancel.'
|
cancel.'
|
||||||
|
scanning: '&a Scanning islands in the database. This may take a while depending on how many you have...'
|
||||||
|
scanning-in-progress: '&c Scanning in progress, please wait'
|
||||||
|
none-found: '&c No islands found to purge.'
|
||||||
|
total-islands: '&a You have [number] islands in your database in all worlds.'
|
||||||
number-error: '&c Argument must be a number of days'
|
number-error: '&c Argument must be a number of days'
|
||||||
confirm: '&d Type &b /[label] purge confirm &d to start purging'
|
confirm: '&d Type &b /[label] purge confirm &d to start purging'
|
||||||
completed: '&a Purging stopped.'
|
completed: '&a Purging stopped.'
|
||||||
|
@ -6,14 +6,20 @@ import static org.junit.Assert.assertTrue;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
@ -38,6 +44,7 @@ import world.bentobox.bentobox.BentoBox;
|
|||||||
import world.bentobox.bentobox.api.addons.Addon;
|
import world.bentobox.bentobox.api.addons.Addon;
|
||||||
import world.bentobox.bentobox.api.commands.CompositeCommand;
|
import world.bentobox.bentobox.api.commands.CompositeCommand;
|
||||||
import world.bentobox.bentobox.api.events.island.IslandDeletedEvent;
|
import world.bentobox.bentobox.api.events.island.IslandDeletedEvent;
|
||||||
|
import world.bentobox.bentobox.api.localization.TextVariables;
|
||||||
import world.bentobox.bentobox.api.user.User;
|
import world.bentobox.bentobox.api.user.User;
|
||||||
import world.bentobox.bentobox.database.objects.Island;
|
import world.bentobox.bentobox.database.objects.Island;
|
||||||
import world.bentobox.bentobox.managers.CommandsManager;
|
import world.bentobox.bentobox.managers.CommandsManager;
|
||||||
@ -95,6 +102,7 @@ public class AdminPurgeCommandTest {
|
|||||||
when(plugin.getIslands()).thenReturn(im);
|
when(plugin.getIslands()).thenReturn(im);
|
||||||
// No islands by default
|
// No islands by default
|
||||||
when(im.getIslands()).thenReturn(Collections.emptyList());
|
when(im.getIslands()).thenReturn(Collections.emptyList());
|
||||||
|
when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
|
||||||
|
|
||||||
// IWM
|
// IWM
|
||||||
IslandWorldManager iwm = mock(IslandWorldManager.class);
|
IslandWorldManager iwm = mock(IslandWorldManager.class);
|
||||||
@ -286,13 +294,13 @@ public class AdminPurgeCommandTest {
|
|||||||
when(island.getOwner()).thenReturn(UUID.randomUUID());
|
when(island.getOwner()).thenReturn(UUID.randomUUID());
|
||||||
when(island.isOwned()).thenReturn(true);
|
when(island.isOwned()).thenReturn(true);
|
||||||
when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID()));
|
when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID()));
|
||||||
when(im.getIslands()).thenReturn(Collections.singleton(island));
|
when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island)));
|
||||||
OfflinePlayer op = mock(OfflinePlayer.class);
|
when(pm.getLastLoginTimestamp(any())).thenReturn(962434800L);
|
||||||
when(op.getLastPlayed()).thenReturn(0L);
|
assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago
|
||||||
when(Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op);
|
verify(user).sendMessage("commands.admin.purge.scanning");
|
||||||
assertFalse(apc.execute(user, "", Collections.singletonList("10")));
|
verify(user).sendMessage("commands.admin.purge.total-islands", "[number]", "1");
|
||||||
verify(user).sendMessage(eq("commands.admin.purge.purgable-islands"), eq("[number]"), eq("1"));
|
verify(user, never()).sendMessage("commands.admin.purge.none-found");
|
||||||
verify(user).sendMessage(eq("commands.admin.purge.confirm"), eq("[label]"), eq("bsb"));
|
verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -367,13 +375,26 @@ public class AdminPurgeCommandTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#getOldIslands(int)}
|
* Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#getOldIslands(int)}
|
||||||
|
* @throws TimeoutException
|
||||||
|
* @throws ExecutionException
|
||||||
|
* @throws InterruptedException
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void testGetOldIslands() {
|
public void testGetOldIslands() throws InterruptedException, ExecutionException, TimeoutException {
|
||||||
assertTrue(apc.getOldIslands(10).isEmpty());
|
assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago
|
||||||
|
// First, ensure that the result is empty
|
||||||
|
CompletableFuture<Set<String>> result = apc.getOldIslands(10);
|
||||||
|
Set<String> set = result.join();
|
||||||
|
assertTrue(set.isEmpty());
|
||||||
|
// Mocking Islands and their retrieval
|
||||||
|
Island island1 = mock(Island.class);
|
||||||
Island island2 = mock(Island.class);
|
Island island2 = mock(Island.class);
|
||||||
when(im.getIslands()).thenReturn(Set.of(island, island2));
|
|
||||||
assertTrue(apc.getOldIslands(10).isEmpty());
|
when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island1, island2)));
|
||||||
|
// Now, check again after mocking islands
|
||||||
|
CompletableFuture<Set<String>> futureWithIslands = apc.getOldIslands(10);
|
||||||
|
assertTrue(futureWithIslands.get(5, TimeUnit.SECONDS).isEmpty()); // Adjust this assertion based on the expected behavior of getOldIslands
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user