Whitelist bounce gathering (#3511)

* Store bounced whitelist logins

* Add allowlist bounce endpoint

* Restore locale file indent from master branch

* Add UI for allowlist

* Update locale

* Fix sonar detected bug and implement database tests

Affects issues:
- Close #2233
This commit is contained in:
Aurora Lahtela 2024-03-10 10:25:42 +02:00 committed by GitHub
parent 24e6af2d03
commit 8116063e62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 871 additions and 32 deletions

View File

@ -28,6 +28,7 @@ import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plan.storage.database.transactions.events.BanStatusTransaction;
import com.djrapitops.plan.storage.database.transactions.events.KickStoreTransaction;
import com.djrapitops.plan.storage.database.transactions.events.StoreAllowlistBounceTransaction;
import com.djrapitops.plan.utilities.logging.ErrorContext;
import com.djrapitops.plan.utilities.logging.ErrorLogger;
import org.bukkit.event.EventHandler;
@ -82,6 +83,11 @@ public class PlayerOnlineListener implements Listener {
UUID playerUUID = event.getPlayer().getUniqueId();
ServerUUID serverUUID = serverInfo.getServerUUID();
boolean banned = PlayerLoginEvent.Result.KICK_BANNED == event.getResult();
boolean notWhitelisted = PlayerLoginEvent.Result.KICK_WHITELIST == event.getResult();
if (notWhitelisted) {
dbSystem.getDatabase().executeTransaction(new StoreAllowlistBounceTransaction(playerUUID, event.getPlayer().getName(), serverUUID, System.currentTimeMillis()));
}
String address = event.getHostname();
if (!address.isEmpty()) {

View File

@ -93,6 +93,7 @@ public enum WebPermission implements Supplier<String>, Lang {
PAGE_SERVER_PERFORMANCE_OVERVIEW("See Performance numbers"),
PAGE_SERVER_PLUGIN_HISTORY("See Plugin History"),
PAGE_SERVER_PLUGINS("See Plugins -tabs of servers"),
PAGE_SERVER_ALLOWLIST_BOUNCE("See list of Game allowlist bounces"),
PAGE_PLAYER("See all of player page"),
PAGE_PLAYER_OVERVIEW("See Player Overview -tab"),

View File

@ -0,0 +1,82 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.domain.datatransfer;
import com.djrapitops.plan.utilities.dev.Untrusted;
import java.util.Objects;
import java.util.UUID;
/**
* Represents an event where player bounced off the whitelist.
*
* @author AuroraLS3
*/
public class AllowlistBounce {
private final UUID playerUUID;
@Untrusted
private final String playerName;
private final int count;
private final long lastTime;
public AllowlistBounce(UUID playerUUID, String playerName, int count, long lastTime) {
this.playerUUID = playerUUID;
this.playerName = playerName;
this.count = count;
this.lastTime = lastTime;
}
public UUID getPlayerUUID() {
return playerUUID;
}
public String getPlayerName() {
return playerName;
}
public int getCount() {
return count;
}
public long getLastTime() {
return lastTime;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AllowlistBounce bounce = (AllowlistBounce) o;
return getCount() == bounce.getCount() && getLastTime() == bounce.getLastTime() && Objects.equals(getPlayerUUID(), bounce.getPlayerUUID()) && Objects.equals(getPlayerName(), bounce.getPlayerName());
}
@Override
public int hashCode() {
return Objects.hash(getPlayerUUID(), getPlayerName(), getCount(), getLastTime());
}
@Override
public String toString() {
return "AllowlistBounce{" +
"playerUUID=" + playerUUID +
", playerName='" + playerName + '\'' +
", count=" + count +
", lastTime=" + lastTime +
'}';
}
}

View File

@ -57,6 +57,7 @@ public enum DataID {
JOIN_ADDRESSES_BY_DAY,
PLAYER_RETENTION,
PLAYER_JOIN_ADDRESSES,
PLAYER_ALLOWLIST_BOUNCES,
;
public String of(ServerUUID serverUUID) {

View File

@ -0,0 +1,119 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.webserver.resolver.json;
import com.djrapitops.plan.delivery.domain.auth.WebPermission;
import com.djrapitops.plan.delivery.formatting.Formatter;
import com.djrapitops.plan.delivery.web.resolver.MimeType;
import com.djrapitops.plan.delivery.web.resolver.Response;
import com.djrapitops.plan.delivery.web.resolver.request.Request;
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
import com.djrapitops.plan.delivery.webserver.cache.AsyncJSONResolverService;
import com.djrapitops.plan.delivery.webserver.cache.DataID;
import com.djrapitops.plan.delivery.webserver.cache.JSONStorage;
import com.djrapitops.plan.identification.Identifiers;
import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plan.storage.database.Database;
import com.djrapitops.plan.storage.database.queries.objects.AllowlistQueries;
import com.djrapitops.plan.storage.database.queries.objects.SessionQueries;
import com.djrapitops.plan.utilities.dev.Untrusted;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.jetbrains.annotations.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Map;
import java.util.Optional;
/**
* Response resolver to get game allowlist bounces.
*
* @author AuroraLS3
*/
@Singleton
@Path("/v1/gameAllowlistBounces")
public class AllowlistJSONResolver extends JSONResolver {
private final DBSystem dbSystem;
private final Identifiers identifiers;
private final AsyncJSONResolverService jsonResolverService;
@Inject
public AllowlistJSONResolver(DBSystem dbSystem, Identifiers identifiers, AsyncJSONResolverService jsonResolverService) {
this.dbSystem = dbSystem;
this.identifiers = identifiers;
this.jsonResolverService = jsonResolverService;
}
@Override
public Formatter<Long> getHttpLastModifiedFormatter() {return jsonResolverService.getHttpLastModifiedFormatter();}
@Override
public boolean canAccess(@Untrusted Request request) {
WebUser user = request.getUser().orElse(new WebUser(""));
return user.hasPermission(WebPermission.PAGE_SERVER_ALLOWLIST_BOUNCE);
}
@GET
@Operation(
description = "Get allowlist bounce data for server",
responses = {
@ApiResponse(responseCode = "200", content = @Content(mediaType = MimeType.JSON)),
@ApiResponse(responseCode = "400", description = "If 'server' parameter is not an existing server")
},
parameters = @Parameter(in = ParameterIn.QUERY, name = "server", description = "Server identifier to get data for (optional)", examples = {
@ExampleObject("Server 1"),
@ExampleObject("1"),
@ExampleObject("1fb39d2a-eb82-4868-b245-1fad17d823b3"),
}),
requestBody = @RequestBody(content = @Content(examples = @ExampleObject()))
)
@Override
public Optional<Response> resolve(@Untrusted Request request) {
return Optional.of(getResponse(request));
}
private Response getResponse(@Untrusted Request request) {
JSONStorage.StoredJSON result = getStoredJSON(request);
return getCachedOrNewResponse(request, result);
}
@Nullable
private JSONStorage.StoredJSON getStoredJSON(Request request) {
Optional<Long> timestamp = Identifiers.getTimestamp(request);
ServerUUID serverUUID = identifiers.getServerUUID(request);
Database database = dbSystem.getDatabase();
return jsonResolverService.resolve(timestamp, DataID.PLAYER_ALLOWLIST_BOUNCES, serverUUID,
theUUID -> Map.of(
"allowlist_bounces", database.query(AllowlistQueries.getBounces(serverUUID)),
"last_seen_by_uuid", database.query(SessionQueries.lastSeen(serverUUID))
)
);
}
}

View File

@ -90,6 +90,7 @@ public class RootJSONResolver {
RetentionJSONResolver retentionJSONResolver,
PlayerJoinAddressJSONResolver playerJoinAddressJSONResolver,
PluginHistoryJSONResolver pluginHistoryJSONResolver,
AllowlistJSONResolver allowlistJSONResolver,
PreferencesJSONResolver preferencesJSONResolver,
StorePreferencesJSONResolver storePreferencesJSONResolver,
@ -129,7 +130,8 @@ public class RootJSONResolver {
.add("extensionData", extensionJSONResolver)
.add("retention", retentionJSONResolver)
.add("joinAddresses", playerJoinAddressJSONResolver)
.add("preferences", preferencesJSONResolver);
.add("preferences", preferencesJSONResolver)
.add("gameAllowlistBounces", allowlistJSONResolver);
this.webServer = webServer;
// These endpoints require authentication to be enabled.

View File

@ -285,6 +285,15 @@ public enum HtmlLang implements Lang {
LABEL_TABLE_SHOW_PER_PAGE("html.label.table.showPerPage", "Show per page"),
LABEL_EXPORT("html.label.export", "Export"),
LABEL_ALLOWLIST("html.label.allowlist", "Allowlist"),
LABEL_ALLOWLIST_BOUNCES("html.label.allowlistBounces", "Allowlist Bounces"),
LABEL_ATTEMPTS("html.label.attempts", "Attempts"),
LABEL_LAST_KNOWN_ATTEMPT("html.label.lastKnownAttempt", "Last Known Attempt"),
LABEL_PREVIOUS_ATTEMPT("html.label.lastBlocked", "Last Blocked"),
LABEL_LAST_ALLOWED_LOGIN("html.label.lastAllowed", "Last Allowed"),
LABEL_BLOCKED("html.label.blocked", "Blocked"),
LABEL_ALLOWED("html.label.allowed", "Allowed"),
LOGIN_LOGIN("html.login.login", "Login"),
LOGIN_LOGOUT("html.login.logout", "Logout"),
LOGIN_USERNAME("html.login.username", "Username"),

View File

@ -147,6 +147,10 @@ public abstract class SQLDB extends AbstractDatabase {
}
}
public static ThreadLocal<StackTraceElement[]> getTransactionOrigin() {
return TRANSACTION_ORIGIN;
}
@Override
public void init() {
List<Runnable> unfinishedTransactions = forceCloseTransactionExecutor();
@ -187,22 +191,6 @@ public abstract class SQLDB extends AbstractDatabase {
return true;
}
protected List<Runnable> forceCloseTransactionExecutor() {
if (transactionExecutor == null || transactionExecutor.isShutdown() || transactionExecutor.isTerminated()) {
return Collections.emptyList();
}
try {
List<Runnable> unfinished = transactionExecutor.shutdownNow();
int unfinishedCount = unfinished.size();
if (unfinishedCount > 0) {
logger.warn(unfinishedCount + " unfinished database transactions were not executed.");
}
return unfinished;
} finally {
logger.info(locale.getString(PluginLang.DISABLED_WAITING_TRANSACTIONS_COMPLETE));
}
}
Patch[] patches() {
return new Patch[]{
new Version10Patch(),
@ -313,6 +301,22 @@ public abstract class SQLDB extends AbstractDatabase {
*/
public abstract void setupDataSource();
protected List<Runnable> forceCloseTransactionExecutor() {
if (transactionExecutor == null || transactionExecutor.isShutdown() || transactionExecutor.isTerminated()) {
return Collections.emptyList();
}
try {
List<Runnable> unfinished = transactionExecutor.shutdownNow();
int unfinishedCount = unfinished.size();
if (unfinishedCount > 0) {
logger.warn(unfinishedCount + " unfinished database transactions were not executed.");
}
return unfinished;
} finally {
logger.info(locale.getString(PluginLang.DISABLED_WAITING_TRANSACTIONS_COMPLETE));
}
}
@Override
public void close() {
// SQLiteDB Overrides this, so any additions to this should also be reflected there.
@ -326,13 +330,6 @@ public abstract class SQLDB extends AbstractDatabase {
setState(State.CLOSED);
}
protected void unloadDriverClassloader() {
// Unloading class loader using close() causes issues when reloading.
// It is better to leak this memory than crash the plugin on reload.
driverClassLoader = null;
}
public abstract Connection getConnection() throws SQLException;
public abstract void returnToPool(Connection connection);
@ -346,8 +343,11 @@ public abstract class SQLDB extends AbstractDatabase {
return accessLock.performDatabaseOperation(() -> query.executeQuery(this), transaction);
}
public static ThreadLocal<StackTraceElement[]> getTransactionOrigin() {
return TRANSACTION_ORIGIN;
protected void unloadDriverClassloader() {
// Unloading class loader using close() causes issues when reloading.
// It is better to leak this memory than crash the plugin on reload.
driverClassLoader = null;
}
@Override

View File

@ -0,0 +1,63 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.storage.database.queries.objects;
import com.djrapitops.plan.delivery.domain.datatransfer.AllowlistBounce;
import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.queries.Query;
import com.djrapitops.plan.storage.database.sql.tables.AllowlistBounceTable;
import com.djrapitops.plan.storage.database.sql.tables.ServerTable;
import org.intellij.lang.annotations.Language;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.UUID;
import static com.djrapitops.plan.storage.database.sql.building.Sql.*;
/**
* Query against {@link AllowlistBounceTable}.
*
* @author AuroraLS3
*/
public class AllowlistQueries {
private AllowlistQueries() {
/* Static method class */
}
public static Query<List<AllowlistBounce>> getBounces(ServerUUID serverUUID) {
@Language("SQL") String sql = SELECT +
AllowlistBounceTable.UUID + ',' +
AllowlistBounceTable.USER_NAME + ',' +
AllowlistBounceTable.TIMES + ',' +
AllowlistBounceTable.LAST_BOUNCE +
FROM + AllowlistBounceTable.TABLE_NAME +
WHERE + AllowlistBounceTable.SERVER_ID + "=" + ServerTable.SELECT_SERVER_ID;
return db -> db.queryList(sql, AllowlistQueries::extract, serverUUID);
}
private static AllowlistBounce extract(ResultSet set) throws SQLException {
return new AllowlistBounce(
UUID.fromString(set.getString(AllowlistBounceTable.UUID)),
set.getString(AllowlistBounceTable.USER_NAME),
set.getInt(AllowlistBounceTable.TIMES),
set.getLong(AllowlistBounceTable.LAST_BOUNCE)
);
}
}

View File

@ -1014,4 +1014,16 @@ public class SessionQueries {
}
};
}
public static Query<Map<UUID, Long>> lastSeen(ServerUUID serverUUID) {
String sql = SELECT + UsersTable.USER_UUID + ", MAX(" + SessionsTable.SESSION_END + ") as last_seen" +
FROM + SessionsTable.TABLE_NAME + " s" +
INNER_JOIN + UsersTable.TABLE_NAME + " u ON u." + UsersTable.ID + "=s." + SessionsTable.USER_ID +
WHERE + SessionsTable.SERVER_ID + "=" + ServerTable.SELECT_SERVER_ID +
GROUP_BY + UsersTable.USER_UUID;
return db -> db.queryMap(sql, (set, to) -> to.put(
UUID.fromString(set.getString(UsersTable.USER_UUID)),
set.getLong("last_seen")
), serverUUID);
}
}

View File

@ -0,0 +1,70 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.storage.database.sql.tables;
import com.djrapitops.plan.storage.database.DBType;
import com.djrapitops.plan.storage.database.sql.building.CreateTableBuilder;
import com.djrapitops.plan.storage.database.sql.building.Sql;
import org.intellij.lang.annotations.Language;
/**
* Represents plan_allowlist_bounce table.
*
* @author AuroraLS3
*/
public class AllowlistBounceTable {
public static final String TABLE_NAME = "plan_allowlist_bounce";
public static final String ID = "id";
public static final String UUID = "uuid";
public static final String USER_NAME = "name";
public static final String SERVER_ID = "server_id";
public static final String TIMES = "times";
public static final String LAST_BOUNCE = "last_bounce";
@Language("SQL")
public static final String INSERT_STATEMENT = "INSERT INTO " + TABLE_NAME + " (" +
UUID + ',' +
USER_NAME + ',' +
SERVER_ID + ',' +
TIMES + ',' +
LAST_BOUNCE +
") VALUES (?,?," + ServerTable.SELECT_SERVER_ID + ",?,?)";
@Language("SQL")
public static final String INCREMENT_TIMES_STATEMENT = "UPDATE " + TABLE_NAME +
" SET " + TIMES + "=" + TIMES + "+1, " + LAST_BOUNCE + "=?" +
" WHERE " + UUID + "=?" +
" AND " + SERVER_ID + "=" + ServerTable.SELECT_SERVER_ID;
private AllowlistBounceTable() {
/* Static information class */
}
public static String createTableSQL(DBType dbType) {
return CreateTableBuilder.create(TABLE_NAME, dbType)
.column(ID, Sql.INT).primaryKey()
.column(UUID, Sql.varchar(36)).notNull().unique()
.column(USER_NAME, Sql.varchar(36)).notNull()
.column(SERVER_ID, Sql.INT).notNull()
.column(TIMES, Sql.INT).notNull().defaultValue("0")
.column(LAST_BOUNCE, Sql.LONG).notNull()
.foreignKey(SERVER_ID, ServerTable.TABLE_NAME, ServerTable.ID)
.toString();
}
}

View File

@ -24,6 +24,7 @@ import com.djrapitops.plan.storage.database.sql.building.Insert;
import com.djrapitops.plan.storage.database.sql.building.Sql;
import com.djrapitops.plan.storage.database.sql.building.Update;
import org.apache.commons.text.TextStringBuilder;
import org.intellij.lang.annotations.Language;
import java.util.Collection;
@ -61,6 +62,7 @@ public class ServerTable {
.where(SERVER_UUID + "=?")
.toString();
@Language("SQL")
public static final String SELECT_SERVER_ID =
'(' + SELECT + TABLE_NAME + '.' + ID +
FROM + TABLE_NAME +

View File

@ -43,6 +43,7 @@ public class RemoveEverythingTransaction extends Patch {
clearTable(WorldTimesTable.TABLE_NAME);
clearTable(SessionsTable.TABLE_NAME);
clearTable(JoinAddressTable.TABLE_NAME);
clearTable(AllowlistBounceTable.TABLE_NAME);
clearTable(WorldTable.TABLE_NAME);
clearTable(PingTable.TABLE_NAME);
clearTable(UserInfoTable.TABLE_NAME);

View File

@ -0,0 +1,72 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.storage.database.transactions.events;
import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.sql.tables.AllowlistBounceTable;
import com.djrapitops.plan.storage.database.transactions.ExecStatement;
import com.djrapitops.plan.storage.database.transactions.Transaction;
import com.djrapitops.plan.utilities.dev.Untrusted;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.UUID;
/**
* Stores a bounced allowlist login.
*
* @author AuroraLS3
*/
public class StoreAllowlistBounceTransaction extends Transaction {
private final UUID playerUUID;
@Untrusted
private final String playerName;
private final ServerUUID serverUUID;
private final long time;
public StoreAllowlistBounceTransaction(UUID playerUUID, @Untrusted String playerName, ServerUUID serverUUID, long time) {
this.playerUUID = playerUUID;
this.playerName = playerName;
this.serverUUID = serverUUID;
this.time = time;
}
@Override
protected void performOperations() {
boolean updated = execute(new ExecStatement(AllowlistBounceTable.INCREMENT_TIMES_STATEMENT) {
@Override
public void prepare(PreparedStatement statement) throws SQLException {
statement.setLong(1, time);
statement.setString(2, playerUUID.toString());
statement.setString(3, serverUUID.toString());
}
});
if (!updated) {
execute(new ExecStatement(AllowlistBounceTable.INSERT_STATEMENT) {
@Override
public void prepare(PreparedStatement statement) throws SQLException {
statement.setString(1, playerUUID.toString());
statement.setString(2, playerName);
statement.setString(3, serverUUID.toString());
statement.setInt(4, 1);
statement.setLong(5, time);
}
});
}
}
}

View File

@ -59,6 +59,7 @@ public class CreateTablesTransaction extends OperationCriticalTransaction {
executeOther(new SecurityTableIdPatch());
execute(WebUserPreferencesTable.createTableSQL(dbType));
execute(PluginVersionTable.createTableSQL(dbType));
execute(AllowlistBounceTable.createTableSQL(dbType));
// DataExtension tables
execute(ExtensionIconTable.createTableSQL(dbType));

View File

@ -294,9 +294,13 @@ html:
afkTime: "挂机时间"
all: "全部"
allTime: "所有时间"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "按字母顺序"
apply: "应用"
asNumbers: "数据"
attempts: "Attempts"
average: "平均"
averageActivePlaytime: "平均活跃时间"
averageAfkTime: "平均挂机时间"
@ -317,6 +321,7 @@ html:
banned: "已被封禁"
bestPeak: "历史最高峰值"
bestPing: "最低延迟"
blocked: "Blocked"
calendar: " 日历"
comparing7days: "对比 7 天的情况"
connectionInfo: "连接信息"
@ -430,7 +435,10 @@ html:
last24hours: "过去 24 小时"
last30days: "过去 30 天"
last7days: "过去 7 天"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "最后连接时间"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "上次在线峰值"
lastSeen: "最后在线时间"
latestJoinAddresses: "上一次加入地址"
@ -686,6 +694,7 @@ html:
page_player_sessions: "查看玩家会话 - 选项卡"
page_player_versus: "查看PvP和PvE - 选项卡"
page_server: "查看所有服务器页面"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "查看服务器地理位置 - 选项卡"
page_server_geolocations_map: "查看服务器地理位置地图"
page_server_geolocations_ping_per_country: "查看按国家划分的延迟表"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK čas"
all: "Vše"
allTime: "Celkově"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Abecední řazení"
apply: "Apply"
asNumbers: "statistiky"
attempts: "Attempts"
average: "Průměrná délka prvního připojení"
averageActivePlaytime: "Průměrná herní aktivita"
averageAfkTime: "Průměrný AFK čas"
@ -317,6 +321,7 @@ html:
banned: "Zabanován"
bestPeak: "Nejvíce hráčů"
bestPing: "Nejlepší ping"
blocked: "Blocked"
calendar: " Kalendář"
comparing7days: "Srovnání posledních 7 dní"
connectionInfo: "Informace o připojení"
@ -430,7 +435,10 @@ html:
last24hours: "Posledních 24 hodin"
last30days: "Posledních 30 dní"
last7days: "Posledních 7 dní"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Poslední připojení"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Naposledy nejvíce hráčů"
lastSeen: "Naposledy viděn"
latestJoinAddresses: "Poslední adresy pro připojení"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK Zeit"
all: "Gesamt"
allTime: "Gesamte zeit"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "als Zahlen"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Durchschnittliche aktive Spielzeit"
averageAfkTime: "Durchschnittliche AFK Zeit"
@ -317,6 +321,7 @@ html:
banned: "Gebannt"
bestPeak: "Rekord"
bestPing: "Bester Ping"
blocked: "Blocked"
calendar: " Kalender"
comparing7days: "Vergleiche 7 Tage"
connectionInfo: "Verbindungsinformationen"
@ -430,7 +435,10 @@ html:
last24hours: "Letzte 24 Stunden"
last30days: "Letzte 30 Tage"
last7days: "Letzte 7 Tage"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Letzte Verbindung"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Letzter Höchststand"
lastSeen: "Zuletzt gesehen"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK Time"
all: "All"
allTime: "All Time"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "as Numbers"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Average Active Playtime"
averageAfkTime: "Average AFK Time"
@ -317,6 +321,7 @@ html:
banned: "Banned"
bestPeak: "All Time Peak"
bestPing: "Best Ping"
blocked: "Blocked"
calendar: " Calendar"
comparing7days: "Comparing 7 days"
connectionInfo: "Connection Information"
@ -430,7 +435,10 @@ html:
last24hours: "Last 24 hours"
last30days: "Last 30 days"
last7days: "Last 7 days"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Last Connected"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Last Peak"
lastSeen: "Last Seen"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "Tiempo AFK"
all: "Todo"
allTime: "Todo el tiempo"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "como números"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Tiempo de juego activo promedio"
averageAfkTime: "Tiempo AFK promedio"
@ -317,6 +321,7 @@ html:
banned: "Baneado"
bestPeak: "Mejor pico"
bestPing: "Mejor Ping"
blocked: "Blocked"
calendar: " Calendario"
comparing7days: "Comparando 7 dias"
connectionInfo: "Información de conexión"
@ -430,7 +435,10 @@ html:
last24hours: "Últimas 24 horas"
last30days: "Últimos 30 dias"
last7days: "Últimos 7 días"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Última vez conectado"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Último pico"
lastSeen: "Última vez visto"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "Aika AFK:ina"
all: "Kaikki"
allTime: "Kaikkien aikojen"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Aakkosjärjestys"
apply: "Käytä"
asNumbers: "Numeroina"
attempts: "Attempts"
average: "Keskimäräinen"
averageActivePlaytime: "Keskimäräinen Aktiivinen peliaika"
averageAfkTime: "Keskimäräinen AFK aika"
@ -317,6 +321,7 @@ html:
banned: "Pannassa"
bestPeak: "Paras Huippu"
bestPing: "Paras Vasteaika"
blocked: "Blocked"
calendar: " Kalenteri"
comparing7days: "Verrataan 7 päivää"
connectionInfo: "Yhteyksien tiedot"
@ -430,7 +435,10 @@ html:
last24hours: "Viimeiset 24 tuntia"
last30days: "Viimeiset 30 päivää"
last7days: "Viimeiset 7 päivää"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Viimeisin yhteys"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Viimeisin huippu"
lastSeen: "Nähty Viimeksi"
latestJoinAddresses: "Viimeisimmät Liittymisosoitteet"
@ -686,6 +694,7 @@ html:
page_player_sessions: "Näkee Pelaajan Istunnot osion"
page_player_versus: "Näkee PvP & PvE osion"
page_server: "Näkee koko palvelin sivun"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "Näkee Geolokaatio osion"
page_server_geolocations_map: "Näkee Geolokaatio kartan"
page_server_geolocations_ping_per_country: "Näkee Viive per Maa -taulun"

View File

@ -294,9 +294,13 @@ html:
afkTime: "Temps AFK"
all: "Tout"
allTime: "Tout le Temps"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "en Chiffres"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Temps Actif moyen"
averageAfkTime: "Temps AFK moyen"
@ -317,6 +321,7 @@ html:
banned: "Banni(e)"
bestPeak: "Pic maximal de Joueurs en Ligne"
bestPing: "Meilleure Latence"
blocked: "Blocked"
calendar: " Calendrier"
comparing7days: "Comparaison des 7 derniers Jours"
connectionInfo: "Renseignements sur la Connexion"
@ -430,7 +435,10 @@ html:
last24hours: "24 Dernières heures"
last30days: "30 Derniers jours"
last7days: "7 Derniers jours"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Dernier Connecté"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Dernier pic de Joueurs en Ligne"
lastSeen: "Dernière Connexion"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "Tempo AFK"
all: "Tutto"
allTime: "Tutto il Tempo"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "Statistiche"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Average Active Playtime"
averageAfkTime: "Average AFK Time"
@ -317,6 +321,7 @@ html:
banned: "Bannato"
bestPeak: "Record Migliore"
bestPing: "Ping Migliore"
blocked: "Blocked"
calendar: " Calendario"
comparing7days: "Comparazione di 7 giorni"
connectionInfo: "Informazioni sulla Connessione"
@ -430,7 +435,10 @@ html:
last24hours: "Ultime 24 ore"
last30days: "Ultimi 30 giorni"
last7days: "Ultimi 7 giorni"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Ultima connessione"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Record Settimanale"
lastSeen: "Ultima Visita"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "離席時間"
all: "全て"
allTime: "全体"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "アルファベット順"
apply: "適用"
asNumbers: "の情報"
attempts: "Attempts"
average: "平均の初回セッション時間"
averageActivePlaytime: "平均アクティブプレイ時間"
averageAfkTime: "平均AFK時間"
@ -317,6 +321,7 @@ html:
banned: "BAN履歴"
bestPeak: "全体のピークタイム"
bestPing: "最高Ping値"
blocked: "Blocked"
calendar: "カレンダー"
comparing7days: "直近1週間との比較"
connectionInfo: "接続情報"
@ -430,7 +435,10 @@ html:
last24hours: "24時間"
last30days: "1ヶ月"
last7days: "1週間"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "直近の接続"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "直近のピークタイム"
lastSeen: "直近のオンライン"
latestJoinAddresses: "最後に参加したサーバーのアドレス"
@ -686,6 +694,7 @@ html:
page_player_sessions: "プレイヤーセッションタブを表示"
page_player_versus: "PvP & PvEタブを表示"
page_server: "全てのサーバーページを表示"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "ジオロケーションタブを表示"
page_server_geolocations_map: "ジオロケーションマップを表示"
page_server_geolocations_ping_per_country: "国ごとのPing表を表示"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK 시간"
all: "모두"
allTime: "모든 시간"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "숫자로"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Average Active Playtime"
averageAfkTime: "Average AFK Time"
@ -317,6 +321,7 @@ html:
banned: "Banned"
bestPeak: "최고의 피크"
bestPing: "최고 Ping"
blocked: "Blocked"
calendar: " 달력"
comparing7days: "지난 7일 비교"
connectionInfo: "연결 정보"
@ -430,7 +435,10 @@ html:
last24hours: "지난 24시간"
last30days: "지난 30일"
last7days: "지난 7일"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "마지막 연결"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "마지막 피크"
lastSeen: "마지막으로 본"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK Tijd"
all: "Alle"
allTime: "Alle Tijd"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "als nummers"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Gemiddelde Actieve Speeltijd"
averageAfkTime: "Gemiddelde AFK Tijd"
@ -317,6 +321,7 @@ html:
banned: "Verbannen"
bestPeak: "Piek aller tijden"
bestPing: "Beste ping"
blocked: "Blocked"
calendar: " Kalender"
comparing7days: "7 dagen vergelijken"
connectionInfo: "Verbindingsinformatie"
@ -430,7 +435,10 @@ html:
last24hours: "Afgelopen 24 uur"
last30days: "Afgelopen 30 dagen"
last7days: "Afgelopen 7 dagen"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Laatst verbonden"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Laatste piek"
lastSeen: "Laatste gezien"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK Time"
all: "Todos"
allTime: "All Time"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "as Numbers"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Average Active Playtime"
averageAfkTime: "Average AFK Time"
@ -317,6 +321,7 @@ html:
banned: "Banido"
bestPeak: "Pico Máximo"
bestPing: "Best Ping"
blocked: "Blocked"
calendar: " Calendário"
comparing7days: "Comparing 7 days"
connectionInfo: "Connection Information"
@ -430,7 +435,10 @@ html:
last24hours: "Últimas 24 horas"
last30days: "Últimos 30 dias"
last7days: "Últimos 7 dias"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Última Conexão"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Último Pico"
lastSeen: "Última Vez Visto"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "Время AFK"
all: "Все"
allTime: "Все время"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "В числах"
attempts: "Attempts"
average: "Средняя продолжительность первого сеанса"
averageActivePlaytime: "Среднее время активной игры"
averageAfkTime: "Среднее время AFK"
@ -317,6 +321,7 @@ html:
banned: "Забанен"
bestPeak: "Максимальный Пик"
bestPing: "Наилучший пинг"
blocked: "Blocked"
calendar: " Календарь"
comparing7days: "Сравнение 7 дней"
connectionInfo: "Информация о соединении"
@ -430,7 +435,10 @@ html:
last24hours: "Последние 24 часа"
last30days: "Последние 30 дней"
last7days: "Последние 7 дней"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Последнее подключение"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Последний Пик"
lastSeen: "Последнее посещение"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "AFK Süresi"
all: "Tamamı"
allTime: "Tüm zamanlar"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "Alphabetical"
apply: "Apply"
asNumbers: "Sayılar olarak"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "Ortalama Aktif Oyun Süresi"
averageAfkTime: "Ortalama AFK Süresi"
@ -317,6 +321,7 @@ html:
banned: "Yasaklanmış"
bestPeak: "Tüm Zamanların Zirvesi"
bestPing: "En iyi Ping"
blocked: "Blocked"
calendar: " Takvim"
comparing7days: "7 gün karşılaştırılıyor"
connectionInfo: "Bağlantı Bilgisi"
@ -430,7 +435,10 @@ html:
last24hours: "Son 24 saat"
last30days: "Son 30 gün"
last7days: "Son 7 gün"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Son bağlantı"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Son Zirve"
lastSeen: "Son Görülme"
latestJoinAddresses: "Latest Join Addresses"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "Час AFK"
all: "Всі"
allTime: "Весь час"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "За алфавітом"
apply: "Застосувати"
asNumbers: "В числах"
attempts: "Attempts"
average: "Середня тривалість першого сеансу"
averageActivePlaytime: "Середній час активної гри"
averageAfkTime: "Середній час AFK"
@ -317,6 +321,7 @@ html:
banned: "Заблокований"
bestPeak: "Максимальний Пік"
bestPing: "Найкращий пінг"
blocked: "Blocked"
calendar: "Календар"
comparing7days: "Порівняння 7 днів"
connectionInfo: "Інформація про з`єднання"
@ -430,7 +435,10 @@ html:
last24hours: "Останні 24 години"
last30days: "Останні 30 днів"
last7days: "Останні 7 днів"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "Останнє підключення"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "Останній Пік"
lastSeen: "Останнє відвідування"
latestJoinAddresses: "Останні адреси приєднання"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -294,9 +294,13 @@ html:
afkTime: "掛機時間"
all: "全部"
allTime: "所有時間"
allowed: "Allowed"
allowlist: "Allowlist"
allowlistBounces: "Allowlist Bounces"
alphabetical: "按字母順序"
apply: "確定"
asNumbers: "統計"
attempts: "Attempts"
average: "Average first session length"
averageActivePlaytime: "平均活躍時間"
averageAfkTime: "平均掛機時間"
@ -317,6 +321,7 @@ html:
banned: "已被封鎖"
bestPeak: "所有時間峰值"
bestPing: "最低延遲"
blocked: "Blocked"
calendar: " 日誌"
comparing7days: "對比 7 天的情況"
connectionInfo: "連接訊息"
@ -430,7 +435,10 @@ html:
last24hours: "過去 24 小時"
last30days: "過去 30 天"
last7days: "過去 7 天"
lastAllowed: "Last Allowed"
lastBlocked: "Last Blocked"
lastConnected: "最後連接時間"
lastKnownAttempt: "Last Known Attempt"
lastPeak: "上次線上峰值"
lastSeen: "最後線上時間"
latestJoinAddresses: "最後加入位址"
@ -686,6 +694,7 @@ html:
page_player_sessions: "See Player Sessions -tab"
page_player_versus: "See PvP & PvE -tab"
page_server: "See all of server page"
page_server_allowlist_bounce: "See list of Game allowlist bounces"
page_server_geolocations: "See Geolocations tab"
page_server_geolocations_map: "See Geolocations Map"
page_server_geolocations_ping_per_country: "See Ping Per Country table"

View File

@ -163,7 +163,8 @@ class AccessControlTest {
Arguments.of("/v1/preferences", WebPermission.ACCESS, 200, 200),
Arguments.of("/v1/storePreferences", WebPermission.ACCESS, 400, 400),
Arguments.of("/v1/pluginHistory?server=" + TestConstants.SERVER_UUID_STRING, WebPermission.PAGE_NETWORK_PLUGIN_HISTORY, 200, 403),
Arguments.of("/v1/pluginHistory?server=" + TestConstants.SERVER_UUID_STRING, WebPermission.PAGE_SERVER_PLUGIN_HISTORY, 200, 403)
Arguments.of("/v1/pluginHistory?server=" + TestConstants.SERVER_UUID_STRING, WebPermission.PAGE_SERVER_PLUGIN_HISTORY, 200, 403),
Arguments.of("/v1/gameAllowlistBounces?server=" + TestConstants.SERVER_UUID_STRING, WebPermission.PAGE_SERVER_ALLOWLIST_BOUNCE, 200, 403)
);
}

View File

@ -136,6 +136,7 @@ class AccessControlVisibilityTest {
Arguments.arguments(WebPermission.PAGE_SERVER_PLAYER_VERSUS_OVERVIEW, "pvp-pve-as-numbers", "pvppve"),
Arguments.arguments(WebPermission.PAGE_SERVER_PLAYER_VERSUS_OVERVIEW, "pvp-pve-insights", "pvppve"),
Arguments.arguments(WebPermission.PAGE_SERVER_PLAYER_VERSUS_KILL_LIST, "pvp-kills-table", "pvppve"),
Arguments.arguments(WebPermission.PAGE_SERVER_ALLOWLIST_BOUNCE, "allowlist-bounce-table", "allowlist"),
Arguments.arguments(WebPermission.PAGE_SERVER_PLAYERBASE_OVERVIEW, "playerbase-trends", "playerbase"),
Arguments.arguments(WebPermission.PAGE_SERVER_PLAYERBASE_OVERVIEW, "playerbase-insights", "playerbase"),
Arguments.arguments(WebPermission.PAGE_SERVER_PLAYERBASE_GRAPHS, "playerbase-graph", "playerbase"),

View File

@ -28,6 +28,7 @@ import com.djrapitops.plan.storage.database.transactions.patches.BadJoinAddressD
public interface DatabaseTestAggregate extends
ActivityIndexQueriesTest,
AllowlistQueriesTest,
DatabaseBackupTest,
ExtensionsDatabaseTest,
GeolocationQueriesTest,

View File

@ -0,0 +1,63 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.storage.database.queries;
import com.djrapitops.plan.delivery.domain.datatransfer.AllowlistBounce;
import com.djrapitops.plan.storage.database.DatabaseTestPreparer;
import com.djrapitops.plan.storage.database.queries.objects.AllowlistQueries;
import com.djrapitops.plan.storage.database.transactions.commands.RemoveEverythingTransaction;
import com.djrapitops.plan.storage.database.transactions.events.StoreAllowlistBounceTransaction;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import utilities.TestConstants;
import java.util.List;
import java.util.concurrent.ExecutionException;
import static org.junit.jupiter.api.Assertions.assertEquals;
public interface AllowlistQueriesTest extends DatabaseTestPreparer {
@Test
@DisplayName("plan_allowlist_bounce is empty")
default void allowListTableIsEmpty() {
List<AllowlistBounce> expected = List.of();
List<AllowlistBounce> result = db().query(AllowlistQueries.getBounces(serverUUID()));
assertEquals(expected, result);
}
@Test
@DisplayName("plan_allowlist_bounce is cleared by RemoveEverythingTransaction")
default void allowListTableIsEmptyAfterClear() throws ExecutionException, InterruptedException {
allowListBounceIsStored();
db().executeTransaction(new RemoveEverythingTransaction());
allowListTableIsEmpty();
}
@Test
@DisplayName("Allowlist bounce is stored")
default void allowListBounceIsStored() throws ExecutionException, InterruptedException {
AllowlistBounce bounce = new AllowlistBounce(TestConstants.PLAYER_ONE_UUID, TestConstants.PLAYER_ONE_NAME, 1, System.currentTimeMillis());
db().executeTransaction(new StoreAllowlistBounceTransaction(bounce.getPlayerUUID(), bounce.getPlayerName(), serverUUID(), bounce.getLastTime()))
.get();
List<AllowlistBounce> expected = List.of(bounce);
List<AllowlistBounce> result = db().query(AllowlistQueries.getBounces(serverUUID()));
assertEquals(expected, result);
}
}

View File

@ -37,6 +37,7 @@ import com.djrapitops.plan.storage.database.transactions.commands.RemoveEverythi
import com.djrapitops.plan.storage.database.transactions.events.*;
import com.djrapitops.plan.utilities.java.Maps;
import net.playeranalytics.plugin.scheduling.TimeAmount;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import utilities.RandomData;
@ -485,4 +486,21 @@ public interface SessionQueriesTest extends DatabaseTestPreparer {
Map<String, Long> results = db().query(SessionQueries.playtimePerServer(Long.MIN_VALUE, Long.MAX_VALUE));
assertEquals(expected, results);
}
@Test
@DisplayName("Last seen query by server uuid groups last seen by player")
default void lastSeenByServerIsGroupedByPlayer() {
prepareForSessionSave();
List<FinishedSession> player1Sessions = RandomData.randomSessions(serverUUID(), worlds, playerUUID, player2UUID);
List<FinishedSession> player2Sessions = RandomData.randomSessions(serverUUID(), worlds, player2UUID, playerUUID);
player1Sessions.forEach(session -> db().executeTransaction(new StoreSessionTransaction(session)));
player2Sessions.forEach(session -> db().executeTransaction(new StoreSessionTransaction(session)));
long lastSeenP1 = new SessionsMutator(player1Sessions).toLastSeen();
long lastSeenP2 = new SessionsMutator(player2Sessions).toLastSeen();
Map<UUID, Long> expected = Map.of(playerUUID, lastSeenP1, player2UUID, lastSeenP2);
Map<UUID, Long> result = db().query(SessionQueries.lastSeen(serverUUID()));
assertEquals(expected, result);
}
}

View File

@ -35,6 +35,7 @@ import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plan.storage.database.transactions.events.BanStatusTransaction;
import com.djrapitops.plan.storage.database.transactions.events.KickStoreTransaction;
import com.djrapitops.plan.storage.database.transactions.events.StoreAllowlistBounceTransaction;
import com.djrapitops.plan.utilities.logging.ErrorContext;
import com.djrapitops.plan.utilities.logging.ErrorLogger;
@ -95,6 +96,13 @@ public class PlayerOnlineListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerKick(PlayerKickEvent event) {
try {
if (event.getReasonEnum() == PlayerKickEvent.Reason.NOT_WHITELISTED) {
dbSystem.getDatabase().executeTransaction(new StoreAllowlistBounceTransaction(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
serverInfo.getServerUUID(), System.currentTimeMillis())
);
}
if (status.areKicksNotCounted() || event.isCancelled()) {
return;
}

View File

@ -32,6 +32,7 @@ const ServerOverview = React.lazy(() => import("./views/server/ServerOverview"))
const OnlineActivity = React.lazy(() => import("./views/server/OnlineActivity"));
const ServerSessions = React.lazy(() => import("./views/server/ServerSessions"));
const ServerPvpPve = React.lazy(() => import("./views/server/ServerPvpPve"));
const ServerAllowList = React.lazy(() => import("./views/server/ServerAllowList"));
const PlayerbaseOverview = React.lazy(() => import("./views/server/PlayerbaseOverview"));
const ServerPlayers = React.lazy(() => import("./views/server/ServerPlayers"));
const ServerGeolocations = React.lazy(() => import("./views/server/ServerGeolocations"));
@ -159,6 +160,7 @@ function App() {
<Route path="online-activity" element={<Lazy><OnlineActivity/></Lazy>}/>
<Route path="sessions" element={<Lazy><ServerSessions/></Lazy>}/>
<Route path="pvppve" element={<Lazy><ServerPvpPve/></Lazy>}/>
<Route path="allowlist" element={<Lazy><ServerAllowList/></Lazy>}/>
<Route path="playerbase" element={<Lazy><PlayerbaseOverview/></Lazy>}/>
<Route path="join-addresses" element={<Lazy><ServerJoinAddresses/></Lazy>}/>
<Route path="retention" element={<Lazy><ServerPlayerRetention/></Lazy>}/>

View File

@ -0,0 +1,22 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faFilterCircleXmark} from "@fortawesome/free-solid-svg-icons";
import {Card} from "react-bootstrap";
import AllowlistBounceTable from "../../../table/AllowlistBounceTable.jsx";
import {useTranslation} from "react-i18next";
const AllowlistBounceTableCard = ({bounces, lastSeen}) => {
const {t} = useTranslation();
return (
<Card id={'allowlist-table'}>
<Card.Header>
<h6 className="col-black">
<Fa icon={faFilterCircleXmark} className="col-orange"/> {t('html.label.allowlistBounces')}
</h6>
</Card.Header>
<AllowlistBounceTable bounces={bounces} lastSeen={lastSeen}/>
</Card>
)
};
export default AllowlistBounceTableCard;

View File

@ -25,10 +25,11 @@ const QueryPlayerListModal = ({open, toggle, queryData, title}) => {
<PlayerTable data={queryData?.data?.players || {players: [], extensionDescriptors: []}}
orderBy={2}/>}
<Modal.Footer>
{hasPermission('access.query') && Boolean(queryData?.data?.players.players.length) && <Link className="btn bg-theme"
to={"/query/result?timestamp=" + queryData?.timestamp}>
{t('html.query.label.showFullQuery')} <Fa icon={faArrowRight}/>
</Link>}
{hasPermission('access.query') && Boolean(queryData?.data?.players.players.length) &&
<Link className="btn bg-theme"
to={"/query/result?timestamp=" + queryData?.timestamp}>
{t('html.query.label.showFullQuery')} <Fa icon={faArrowRight}/>
</Link>}
<button className="btn bg-theme" onClick={toggle}>OK</button>
</Modal.Footer>
</Modal>

View File

@ -0,0 +1,72 @@
import React, {useCallback} from "react";
import {useTranslation} from "react-i18next";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faDoorOpen, faRepeat, faUser} from "@fortawesome/free-solid-svg-icons";
import {usePreferences} from "../../hooks/preferencesHook.jsx";
import DataTablesTable from "./DataTablesTable.jsx";
import {formatDate, useDatePreferences} from "../text/FormattedDate.jsx";
import {faCalendarCheck, faCalendarTimes} from "@fortawesome/free-regular-svg-icons";
import {Link} from "react-router-dom";
const AllowlistBounceTable = ({bounces, lastSeen}) => {
const {t} = useTranslation();
const {preferencesLoaded} = usePreferences();
const datePreferences = useDatePreferences();
const formatDateEasy = date => {
return formatDate(date, datePreferences.offset, datePreferences.pattern, false, datePreferences.recentDaysPattern, t);
}
const columns = [{
title: <><Fa icon={faUser}/> {t('html.label.player')}</>,
data: {_: "player", display: "link"}
}, {
title: <><Fa icon={faRepeat}/> {t('html.label.attempts')}</>,
data: "attempts"
}, {
title: <><Fa icon={faDoorOpen}/> {t('html.label.lastKnownAttempt')}</>,
data: "lastKnownAttempt"
}, {
title: <><Fa icon={faCalendarTimes}/> {t('html.label.lastBlocked')}</>,
data: {_: "date", display: "dateFormatted"}
}, {
title: <><Fa icon={faCalendarCheck}/> {t('html.label.lastAllowed')}</>,
data: {_: "lastSeen", display: "lastSeenFormatted"}
}];
const rows = bounces.map(bounce => {
const seenAfterBounce = bounce.lastBounce < lastSeen[bounce.playerUUID];
const playerId = bounce.playerName + ' / ' + bounce.playerUUID;
return {
player: playerId,
link: lastSeen[bounce.playerUUID] ? <Link to={"/player/" + bounce.playerUUID}>{playerId}</Link> : playerId,
date: bounce.lastTime,
dateFormatted: formatDateEasy(bounce.lastTime),
attempts: bounce.count,
lastKnownAttempt: seenAfterBounce ? t('html.label.allowed') : t('html.label.blocked'),
lastSeen: lastSeen[bounce.playerUUID],
lastSeenFormatted: formatDateEasy(lastSeen[bounce.playerUUID])
};
});
const options = {
responsive: true,
deferRender: true,
columns: columns,
data: rows,
paginationCount: 2,
order: [[1, "desc"]]
}
const rowKeyFunction = useCallback((row, column) => {
return row.player + "-" + (column ? JSON.stringify(column.data) : '');
}, []);
if (!preferencesLoaded) return <></>;
return (
<DataTablesTable id={"allowlist-bounce-table"} options={options} colorClass={"bg-orange"}
rowKeyFunction={rowKeyFunction}/>
)
};
export default AllowlistBounceTable;

View File

@ -100,6 +100,7 @@ export const fetchPlayersTable = async (timestamp, identifier) => {
return await fetchPlayersTableNetwork(timestamp);
}
}
const fetchPlayersTableServer = async (timestamp, identifier) => {
let url = `/v1/playersTable?server=${identifier}`;
if (staticSite) url = `/data/playersTable-${identifier}.json`;
@ -112,6 +113,12 @@ const fetchPlayersTableNetwork = async (timestamp) => {
return doGetRequest(url, timestamp);
}
export const fetchAllowlistBounces = async (timestamp, identifier) => {
let url = `/v1/gameAllowlistBounces?server=${identifier}`;
if (staticSite) url = `/data/gameAllowlistBounces-${identifier}.json`;
return doGetRequest(url, timestamp);
}
export const fetchPingTable = async (timestamp, identifier) => {
let url = `/v1/pingTable?server=${identifier}`;
if (staticSite) url = `/data/pingTable-${identifier}.json`;

View File

@ -9,6 +9,7 @@ import {
faCodeCompare,
faCogs,
faCubes,
faFilterCircleXmark,
faGlobe,
faInfoCircle,
faLocationArrow,
@ -72,6 +73,12 @@ const ServerSidebar = () => {
icon: faCampground,
href: "pvppve",
permission: 'page.server.player.versus'
},
{
name: 'html.label.allowlist',
icon: faFilterCircleXmark,
href: "allowlist",
permission: 'page.server.allowlist.bounce'
}
],
},

View File

@ -0,0 +1,35 @@
import React from 'react';
import {useDataRequest} from "../../hooks/dataFetchHook";
import {useParams} from "react-router-dom";
import {fetchAllowlistBounces} from "../../service/serverService";
import ErrorView from "../ErrorView";
import {Col} from "react-bootstrap";
import LoadIn from "../../components/animation/LoadIn";
import ExtendableRow from "../../components/layout/extension/ExtendableRow";
import {useAuth} from "../../hooks/authenticationHook";
import AllowlistBounceTableCard from "../../components/cards/server/tables/AllowlistBounceTableCard.jsx";
const ServerAllowList = () => {
const {hasPermission} = useAuth();
const {identifier} = useParams();
const seeBounce = hasPermission('page.server.allowlist.bounce');
const {data, loadingError} = useDataRequest(fetchAllowlistBounces, [identifier], seeBounce);
if (loadingError) return <ErrorView error={loadingError}/>
return (
<LoadIn>
<section className="server-allowlist">
{seeBounce && <ExtendableRow id={'row-server-allowlist-0'}>
<Col md={12}>
<AllowlistBounceTableCard bounces={data?.allowlist_bounces || []}
lastSeen={data?.last_seen_by_uuid || {}}/>
</Col>
</ExtendableRow>}
</section>
</LoadIn>
)
};
export default ServerAllowList

View File

@ -27,8 +27,10 @@ import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plan.storage.database.transactions.events.BanStatusTransaction;
import com.djrapitops.plan.storage.database.transactions.events.KickStoreTransaction;
import com.djrapitops.plan.storage.database.transactions.events.StoreAllowlistBounceTransaction;
import com.djrapitops.plan.utilities.logging.ErrorContext;
import com.djrapitops.plan.utilities.logging.ErrorLogger;
import org.spongepowered.api.Game;
import org.spongepowered.api.Sponge;
import org.spongepowered.api.entity.living.player.Player;
import org.spongepowered.api.entity.living.player.server.ServerPlayer;
@ -54,6 +56,7 @@ public class PlayerOnlineListener {
private final PlayerJoinEventConsumer joinEventConsumer;
private final PlayerLeaveEventConsumer leaveEventConsumer;
private final Game game;
private final ServerInfo serverInfo;
private final DBSystem dbSystem;
private final Status status;
@ -63,13 +66,14 @@ public class PlayerOnlineListener {
public PlayerOnlineListener(
PlayerJoinEventConsumer joinEventConsumer,
PlayerLeaveEventConsumer leaveEventConsumer,
ServerInfo serverInfo,
Game game, ServerInfo serverInfo,
DBSystem dbSystem,
Status status,
ErrorLogger errorLogger
) {
this.joinEventConsumer = joinEventConsumer;
this.leaveEventConsumer = leaveEventConsumer;
this.game = game;
this.serverInfo = serverInfo;
this.dbSystem = dbSystem;
this.status = status;
@ -89,6 +93,18 @@ public class PlayerOnlineListener {
GameProfile profile = event.profile();
UUID playerUUID = profile.uniqueId();
ServerUUID serverUUID = serverInfo.getServerUUID();
if (game.server().isWhitelistEnabled()) {
game.server().serviceProvider().whitelistService().isWhitelisted(profile)
.thenAccept(whitelisted -> {
if (Boolean.FALSE.equals(whitelisted)) {
dbSystem.getDatabase().executeTransaction(new StoreAllowlistBounceTransaction(
playerUUID,
event.profile().name().orElse(event.user().uniqueId().toString()),
serverUUID,
System.currentTimeMillis()));
}
});
}
dbSystem.getDatabase().executeTransaction(new BanStatusTransaction(playerUUID, serverUUID, () -> isBanned(profile)));
}