simplified BanEvasion

This commit is contained in:
MineTec committed 2026-10-14 21:07:27 +02:00
1 parent 0800793e64
commit 6590513d56
10 files changed
+455 -531

No files matched your search

@@ -6,6 +6,7 @@ import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.database.Database; import eu.mhsl.craftattack.spawn.core.database.Database;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent; import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import eu.mhsl.craftattack.spawn.core.event.SpawnEvent;
import eu.mhsl.craftattack.spawn.core.util.network.Networks; import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate; import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
@@ -126,8 +127,8 @@ public class LoginLog extends Appliance {
id = keys.getLong(1); id = keys.getLong(1);
} }
} }
Bukkit.getPluginManager().callEvent(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt( SpawnEvent.call(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt(
id, attempt.at(), attempt.uuid(), attempt.name(), attempt.ip(), network, attempt.stage(), attempt.result() id, attempt.at(), attempt.uuid(), attempt.name(), network, attempt.stage(), attempt.result()
))); )));
}); });
} }
@@ -29,7 +29,6 @@ public class LoginAttemptRecordedEvent extends Event {
Instant at, Instant at,
UUID uuid, UUID uuid,
String name, String name,
String ip,
@Nullable String network, @Nullable String network,
String stage, String stage,
String result String result
@@ -25,7 +25,6 @@ class AccountLinksCommand extends ApplianceCommand.PlayerChecked<BanEvasion> {
@Override @Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
Player player = this.getPlayer(); Player player = this.getPlayer();
if(!player.hasPermission(BanEvasion.ADMIN_PERMISSION)) return;
if(Floodgate.isBedrock(player)) throw new Error("Nur für Java-Clients"); if(Floodgate.isBedrock(player)) throw new Error("Nur für Java-Clients");
if(!this.getAppliance().isActive()) throw new Error("Keine Datenbank konfiguriert"); if(!this.getAppliance().isActive()) throw new Error("Keine Datenbank konfiguriert");
@@ -53,6 +53,7 @@ final class AdminNotifier {
} }
private Component format(Notice notice) { private Component format(Notice notice) {
long caseId = notice.caseId();
NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY; NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY;
String reasons = notice.reasons().stream() String reasons = notice.reasons().stream()
.filter(reason -> !reason.informational) .filter(reason -> !reason.informational)
@@ -66,7 +67,7 @@ final class AdminNotifier {
.append(account(notice.secondName(), notice.secondBanned())) .append(account(notice.secondName(), notice.secondBanned()))
.append(Component.text(" – " + reasons + " ", color)) .append(Component.text(" – " + reasons + " ", color))
.append(Component.text("[Öffnen]", NamedTextColor.GOLD) .append(Component.text("[Öffnen]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openCase(player, notice.caseId(), false)))) .clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openCase(player, caseId, false))))
.build(); .build();
} }
@@ -1,5 +1,7 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion; package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
@@ -7,7 +9,14 @@ import java.util.UUID;
* One row of login_attempts with a known connection key. * One row of login_attempts with a known connection key.
*/ */
record Attempt(long id, Instant at, UUID uuid, String name, String network, String stage, String result) { record Attempt(long id, Instant at, UUID uuid, String name, String network, String stage, String result) {
static final String STAGE_WEBSITE = "WEBSITE"; private static final String STAGE_WEBSITE = "WEBSITE";
/**
* Only for attempts with a known connection key.
*/
static Attempt of(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
return new Attempt(recorded.id(), recorded.at(), recorded.uuid(), recorded.name(), recorded.network(), recorded.stage(), recorded.result());
}
boolean isWebsiteSignup() { boolean isWebsiteSignup() {
return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result); return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result);
@@ -25,20 +25,18 @@ import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
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.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.logging.Level; import java.util.logging.Level;
@@ -53,7 +51,6 @@ public class BanEvasion extends Appliance {
static final String ADMIN_PERMISSION = "admin"; static final String ADMIN_PERMISSION = "admin";
private static final long TICKS_PER_MINUTE = 20L * 60; private static final long TICKS_PER_MINUTE = 20L * 60;
private static final long TICKS_PER_DAY = TICKS_PER_MINUTE * 60 * 24; private static final long TICKS_PER_DAY = TICKS_PER_MINUTE * 60 * 24;
private static final Duration QUERY_TIMEOUT = Duration.ofSeconds(30);
private static final Duration BAN_LOOKUP_TIMEOUT = Duration.ofSeconds(5); private static final Duration BAN_LOOKUP_TIMEOUT = Duration.ofSeconds(5);
private static final Duration BAN_LOOKUP_BACKOFF = Duration.ofSeconds(30); private static final Duration BAN_LOOKUP_BACKOFF = Duration.ofSeconds(30);
@@ -62,14 +59,16 @@ public class BanEvasion extends Appliance {
T load() throws Exception; T load() throws Exception;
} }
private record Households(Map<UUID, Long> ofAccount, Map<Long, Set<UUID>> members) { /**
* Everything an evaluation needs from the database, loaded in one go.
*/
private record Context(List<Attempt> partners, Map<UUID, Long> households, Map<Long, Set<UUID>> householdMembers) {
} }
private final BanEvasionRepository repository = new BanEvasionRepository(); private final BanEvasionRepository repository = new BanEvasionRepository();
private final OnlineNetworks onlineNetworks = new OnlineNetworks(); private final OnlineNetworks onlineNetworks = new OnlineNetworks();
private Database database; private Database database;
private ExecutorService worker; private ExecutorService worker;
private ExecutorService httpExecutor;
private BukkitTask retentionTask; private BukkitTask retentionTask;
private BanStatusCache banStatus; private BanStatusCache banStatus;
private AdminNotifier notifier; private AdminNotifier notifier;
@@ -95,11 +94,6 @@ public class BanEvasion extends Appliance {
this.rejectionWindow = Duration.ofHours(config.getInt("rejectionWindowHours", 24)); this.rejectionWindow = Duration.ofHours(config.getInt("rejectionWindowHours", 24));
this.retentionMonths = config.getInt("retentionMonths", 16); this.retentionMonths = config.getInt("retentionMonths", 16);
this.banStatus = new BanStatusCache(this::lookupBanState, Duration.ofMinutes(config.getInt("banCacheMinutes", 5)), BAN_LOOKUP_BACKOFF, Clock.systemUTC()); this.banStatus = new BanStatusCache(this::lookupBanState, Duration.ofMinutes(config.getInt("banCacheMinutes", 5)), BAN_LOOKUP_BACKOFF, Clock.systemUTC());
this.httpExecutor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "Spawn-BanEvasion-Http");
thread.setDaemon(true);
return thread;
});
this.worker = Executors.newSingleThreadExecutor(runnable -> { this.worker = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Spawn-BanEvasion"); Thread thread = new Thread(runnable, "Spawn-BanEvasion");
thread.setDaemon(true); thread.setDaemon(true);
@@ -116,7 +110,6 @@ public class BanEvasion extends Appliance {
this.active = false; this.active = false;
if(this.retentionTask != null) this.retentionTask.cancel(); if(this.retentionTask != null) this.retentionTask.cancel();
if(this.worker != null) this.worker.shutdownNow(); if(this.worker != null) this.worker.shutdownNow();
if(this.httpExecutor != null) this.httpExecutor.shutdownNow();
} }
boolean isActive() { boolean isActive() {
@@ -135,8 +128,7 @@ public class BanEvasion extends Appliance {
* Runs the query on the database thread and waits for it. Only for the worker thread. * Runs the query on the database thread and waits for it. Only for the worker thread.
*/ */
<T> T query(Database.SqlQuery<T> query) { <T> T query(Database.SqlQuery<T> query) {
// a dropped statement (full database queue) never completes, the worker must not wait for it forever return this.database.query(query).join();
return this.database.query(query).orTimeout(QUERY_TIMEOUT.toSeconds(), TimeUnit.SECONDS).join();
} }
/** /**
@@ -166,13 +158,30 @@ public class BanEvasion extends Appliance {
}); });
} }
/**
* Shortcut of {@link #loadFor} for a single database query.
*/
<T> void queryFor(Player admin, Database.SqlQuery<T> query, Consumer<T> onMainThread) {
this.loadFor(admin, () -> this.query(query), onMainThread);
}
/**
* Runs a database change for the admin, then continues on the main thread.
*/
void runFor(Player admin, Database.SqlAction action, Runnable onMainThread) {
this.queryFor(admin, connection -> {
action.run(connection);
return true;
}, done -> onMainThread.run());
}
/** /**
* Called on the database thread: hands the attempt to the worker without waiting. * Called on the database thread: hands the attempt to the worker without waiting.
*/ */
void onAttemptRecorded(LoginAttemptRecordedEvent.RecordedAttempt recorded) { void onAttemptRecorded(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
if(!this.active || recorded.network() == null) return; if(!this.active || recorded.network() == null) return;
Attempt attempt = new Attempt(recorded.id(), recorded.at(), recorded.uuid(), recorded.name(), recorded.network(), recorded.stage(), recorded.result()); Attempt attempt = Attempt.of(recorded);
Set<UUID> online = this.onlineNetworks.onlineOn(recorded.network()); Set<UUID> online = this.onlineNetworks.onlineOn(attempt.network());
this.submit(() -> this.evaluate(attempt, online)); this.submit(() -> this.evaluate(attempt, online));
} }
@@ -181,7 +190,7 @@ public class BanEvasion extends Appliance {
String network = address == null || address.getAddress() == null ? null : Networks.networkOf(address.getAddress().getHostAddress()); String network = address == null || address.getAddress() == null ? null : Networks.networkOf(address.getAddress().getHostAddress());
this.onlineNetworks.joined(player.getUniqueId(), network); this.onlineNetworks.joined(player.getUniqueId(), network);
if(!this.active || !player.hasPermission(ADMIN_PERMISSION)) return; if(!this.active || !player.hasPermission(ADMIN_PERMISSION)) return;
this.loadFor(player, () -> this.query(this.repository::countOpen), count -> this.notifier.announceOpenCases(player, count)); this.queryFor(player, this.repository::countOpen, count -> this.notifier.announceOpenCases(player, count));
} }
void playerLeft(Player player) { void playerLeft(Player player) {
@@ -198,35 +207,61 @@ public class BanEvasion extends Appliance {
private void evaluate(Attempt attempt, Set<UUID> online) { private void evaluate(Attempt attempt, Set<UUID> online) {
try { try {
if(this.query(connection -> this.repository.isExempt(connection, attempt.network(), attempt.at()))) return; Context context = this.query(connection -> this.loadContext(connection, attempt));
List<Attempt> partners = this.query(connection -> this.repository.partnerAttempts(connection, attempt, this.window)); if(context == null) return;
if(partners.isEmpty()) return;
Set<UUID> involved = new HashSet<>(); Set<UUID> banStateNeeded = new HashSet<>();
involved.add(attempt.uuid()); banStateNeeded.add(attempt.uuid());
partners.forEach(partner -> involved.add(partner.uuid())); context.partners().forEach(partner -> banStateNeeded.add(partner.uuid()));
Households households = this.query(connection -> { context.householdMembers().values().forEach(banStateNeeded::addAll);
Map<UUID, Long> ofAccount = this.repository.householdsOf(connection, involved);
return new Households(ofAccount, this.repository.householdMembers(connection, new HashSet<>(ofAccount.values())));
});
Set<UUID> banStateNeeded = new HashSet<>(involved);
households.members().values().forEach(banStateNeeded::addAll);
Map<UUID, BanState> banStates = this.banStatus.getAll(banStateNeeded); Map<UUID, BanState> banStates = this.banStatus.getAll(banStateNeeded);
List<PairFinding> findings = CaseEvaluator.evaluate(new CaseEvaluator.Input( List<PairFinding> findings = CaseEvaluator.evaluate(new CaseEvaluator.Input(
attempt, partners, banStates, households.ofAccount(), households.members(), online, this.rejectionWindow attempt, context.partners(), banStates, context.households(), context.householdMembers(), online, this.rejectionWindow
)); ));
for(PairFinding finding : findings) { if(findings.isEmpty()) return;
AdminNotifier.Notice notice = this.query(connection -> this.applyFinding(connection, finding, attempt, banStates));
if(notice != null) this.notifier.announce(notice); // partner attempts are ordered by time, so the last one carries the latest name
} Map<UUID, String> names = new HashMap<>();
context.partners().forEach(partner -> names.put(partner.uuid(), partner.name()));
names.put(attempt.uuid(), attempt.name());
List<AdminNotifier.Notice> notices = this.query(connection -> {
List<AdminNotifier.Notice> announced = new ArrayList<>();
for(PairFinding finding : findings) {
AdminNotifier.Notice notice = this.applyFinding(connection, finding, attempt, banStates, names);
if(notice != null) announced.add(notice);
}
return announced;
});
notices.forEach(this.notifier::announce);
} catch(Exception e) { } catch(Exception e) {
Main.logger().log(Level.WARNING, String.format("BanEvasion: evaluating login attempt #%d failed", attempt.id()), e); Main.logger().log(Level.WARNING, String.format("BanEvasion: evaluating login attempt #%d failed", attempt.id()), e);
} }
} }
private @Nullable AdminNotifier.Notice applyFinding(Connection connection, PairFinding finding, Attempt attempt, Map<UUID, BanState> banStates) throws SQLException { /**
* @return null if the attempt is exempted or nobody else used the connection within the window
*/
private @Nullable Context loadContext(Connection connection, Attempt attempt) throws SQLException {
if(this.repository.isExempt(connection, attempt.network(), attempt.at())) return null;
List<Attempt> partners = this.repository.partnerAttempts(connection, attempt, this.window);
if(partners.isEmpty()) return null;
Set<UUID> involved = new HashSet<>();
involved.add(attempt.uuid());
partners.forEach(partner -> involved.add(partner.uuid()));
Map<UUID, Long> households = this.repository.householdsOf(connection, involved);
return new Context(partners, households, this.repository.householdMembers(connection, new HashSet<>(households.values())));
}
private @Nullable AdminNotifier.Notice applyFinding(
Connection connection,
PairFinding finding,
Attempt attempt,
Map<UUID, BanState> banStates,
Map<UUID, String> names
) throws SQLException {
CaseDecision.ExistingCase existing = this.repository.findCase(connection, finding.first(), finding.second()); CaseDecision.ExistingCase existing = this.repository.findCase(connection, finding.first(), finding.second());
CaseDecision.Result decision = CaseDecision.decide(existing, finding); CaseDecision.Result decision = CaseDecision.decide(existing, finding);
long caseId; long caseId;
@@ -245,9 +280,9 @@ public class BanEvasion extends Appliance {
if(!decision.announce()) return null; if(!decision.announce()) return null;
return new AdminNotifier.Notice( return new AdminNotifier.Notice(
caseId, caseId,
this.repository.nameOf(connection, finding.first()), names.get(finding.first()),
banStates.get(finding.first()) == BanState.BANNED, banStates.get(finding.first()) == BanState.BANNED,
this.repository.nameOf(connection, finding.second()), names.get(finding.second()),
banStates.get(finding.second()) == BanState.BANNED, banStates.get(finding.second()) == BanState.BANNED,
finding.severity(), finding.severity(),
finding.reasons().keySet(), finding.reasons().keySet(),
@@ -255,34 +290,19 @@ public class BanEvasion extends Appliance {
); );
} }
/**
* Asks the website with a time limit, so a hanging website cannot block the worker. A timed out request
* keeps running on its own daemon thread until the HTTP client gives up.
*/
private BanState lookupBanState(UUID uuid) { private BanState lookupBanState(UUID uuid) {
try { try {
return CompletableFuture.supplyAsync(() -> this.fetchBanState(uuid), this.httpExecutor) ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid, BAN_LOOKUP_TIMEOUT);
.get(BAN_LOOKUP_TIMEOUT.toSeconds(), TimeUnit.SECONDS); if(response.status() == HttpStatus.NOT_FOUND) return BanState.NOT_BANNED;
} catch(TimeoutException e) { if(response.status() != HttpStatus.OK || response.data() == null) return BanState.UNKNOWN;
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: website did not answer in time"); List<WhitelistRepository.UserData.Strike> strikes = Objects.requireNonNullElse(response.data().strikes(), List.of());
return BanState.UNKNOWN; return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED;
} catch(ExecutionException e) { } catch(RuntimeException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getCause().getMessage()); Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getMessage());
return BanState.UNKNOWN;
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
return BanState.UNKNOWN; return BanState.UNKNOWN;
} }
} }
private BanState fetchBanState(UUID uuid) {
ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid);
if(response.status() == HttpStatus.NOT_FOUND) return BanState.NOT_BANNED;
if(response.status() != HttpStatus.OK || response.data() == null) return BanState.UNKNOWN;
List<WhitelistRepository.UserData.Strike> strikes = Objects.requireNonNullElse(response.data().strikes(), List.of());
return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED;
}
private void deleteExpired() { private void deleteExpired() {
this.database.execute("deleting expired ban evasion data", connection -> { this.database.execute("deleting expired ban evasion data", connection -> {
int deleted = this.repository.deleteExpired(connection, this.retentionMonths); int deleted = this.repository.deleteExpired(connection, this.retentionMonths);
@@ -20,6 +20,8 @@ import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
@@ -33,7 +35,8 @@ import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* Admin dialogs of the ban evasion detection. Every button checks the admin permission again on the server. * Admin dialogs of the ban evasion detection. Every button checks the admin permission again on the server
* and captures only the ids it needs, so the registered callbacks stay small.
*/ */
final class BanEvasionDialogs { final class BanEvasionDialogs {
private static final int LIST_LIMIT = 30; private static final int LIST_LIMIT = 30;
@@ -42,7 +45,10 @@ final class BanEvasionDialogs {
private static final int RECENT_NETWORKS = 5; private static final int RECENT_NETWORKS = 5;
private static final int BUTTON_WIDTH = 310; private static final int BUTTON_WIDTH = 310;
private static final int BODY_WIDTH = 400; private static final int BODY_WIDTH = 400;
private static final Duration CALLBACK_LIFETIME = Duration.ofHours(12); private static final ClickCallback.Options CHAT_CALLBACK = ClickCallback.Options.builder()
.uses(ClickCallback.UNLIMITED_USES).lifetime(Duration.ofHours(12)).build();
private static final ClickCallback.Options DIALOG_CALLBACK = ClickCallback.Options.builder()
.uses(ClickCallback.UNLIMITED_USES).lifetime(Duration.ofMinutes(30)).build();
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("dd.MM.yy HH:mm").withZone(ZoneId.of("Europe/Berlin")); private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("dd.MM.yy HH:mm").withZone(ZoneId.of("Europe/Berlin"));
private static final String NOTE_KEY = "note"; private static final String NOTE_KEY = "note";
private static final String NETWORK_KEY = "network"; private static final String NETWORK_KEY = "network";
@@ -54,49 +60,57 @@ final class BanEvasionDialogs {
private record LinksView(String name, List<BanEvasionRepository.Link> links, List<String> networks, @Nullable Long household) { private record LinksView(String name, List<BanEvasionRepository.Link> links, List<String> networks, @Nullable Long household) {
} }
private record HouseholdView(BanEvasionRepository.Household household, Map<UUID, BanState> banStates) {
}
private final BanEvasion appliance; private final BanEvasion appliance;
private final BanEvasionRepository repository;
BanEvasionDialogs(BanEvasion appliance) { BanEvasionDialogs(BanEvasion appliance) {
this.appliance = appliance; this.appliance = appliance;
this.repository = appliance.repository();
} }
/** /**
* Click event for chat messages that only works for admins. * Click event for chat messages that only works for admins.
*/ */
static ClickEvent<?> adminClick(Consumer<Player> action) { static ClickEvent<?> adminClick(Consumer<Player> action) {
return ClickEvent.callback( return ClickEvent.callback(audience -> {
audience -> { if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player);
if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player); }, CHAT_CALLBACK);
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
} }
void openList(Player admin, CaseStatus status) { void openList(Player admin, CaseStatus status) {
this.appliance.loadFor(admin, this.appliance.queryFor(admin, connection -> this.repository.listCases(connection, status, LIST_LIMIT), cases -> {
() -> this.appliance.query(connection -> this.appliance.repository().listCases(connection, status, LIST_LIMIT)), List<ActionButton> buttons = new ArrayList<>();
cases -> { cases.forEach(summary -> {
List<ActionButton> buttons = new ArrayList<>(); long caseId = summary.id();
cases.forEach(summary -> buttons.add(button(caseLabel(summary), (player, response) -> this.openCase(player, summary.id(), false)))); buttons.add(button(caseLabel(summary), player -> this.openCase(player, caseId, false)));
CaseStatus other = status == CaseStatus.OPEN ? CaseStatus.CLOSED : CaseStatus.OPEN;
buttons.add(button(other == CaseStatus.OPEN ? "Offene Fälle" : "Geschlossene Fälle", player -> this.openList(player, other)));
buttons.add(button("Haushalte", this::openHouseholds));
buttons.add(button("IP-Ausnahmen", this::openExemptions));
Component body = cases.isEmpty()
? Component.text("Keine Fälle.")
: Component.text(String.format("%d Fälle (höchstens %d), hohe zuerst, dann neueste.", cases.size(), LIST_LIMIT));
show(admin, status == CaseStatus.OPEN ? "Verdachtsfälle – offen" : "Verdachtsfälle – geschlossen", body, List.of(), buttons);
}); });
CaseStatus other = status == CaseStatus.OPEN ? CaseStatus.CLOSED : CaseStatus.OPEN;
buttons.add(button(other == CaseStatus.OPEN ? "Offene Fälle" : "Geschlossene Fälle", player -> this.openList(player, other)));
buttons.add(button("Haushalte", this::openHouseholds));
buttons.add(button("IP-Ausnahmen", this::openExemptions));
Component body = cases.isEmpty()
? Component.text("Keine Fälle.")
: Component.text(String.format("%d Fälle (höchstens %d), hohe zuerst, dann neueste.", cases.size(), LIST_LIMIT));
show(admin, status == CaseStatus.OPEN ? "Verdachtsfälle – offen" : "Verdachtsfälle – geschlossen", body, List.of(), buttons);
});
} }
void openCase(Player admin, long caseId, boolean fullNetworks) { void openCase(Player admin, long caseId, boolean fullNetworks) {
this.appliance.loadFor(admin, () -> { this.appliance.loadFor(admin, () -> {
BanEvasionRepository.CaseDetails details = this.appliance.query(connection -> this.appliance.repository().loadCase(connection, caseId, TIMELINE_LIMIT)); record Loaded(BanEvasionRepository.CaseDetails details, Map<UUID, Long> households) {
if(details == null) return null; }
List<UUID> accounts = List.of(details.summary().first(), details.summary().second()); Loaded loaded = this.appliance.query(connection -> {
Map<UUID, Long> households = this.appliance.query(connection -> this.appliance.repository().householdsOf(connection, accounts)); BanEvasionRepository.CaseDetails details = this.repository.loadCase(connection, caseId, TIMELINE_LIMIT);
return new CaseView(details, this.appliance.banStates(accounts), households); if(details == null) return null;
return new Loaded(details, this.repository.householdsOf(connection, List.of(details.summary().first(), details.summary().second())));
});
if(loaded == null) return null;
List<UUID> accounts = List.of(loaded.details().summary().first(), loaded.details().summary().second());
return new CaseView(loaded.details(), this.appliance.banStates(accounts), loaded.households());
}, view -> { }, view -> {
if(view == null) { if(view == null) {
admin.sendMessage(Component.text("Fall #" + caseId + " gibt es nicht (mehr).", NamedTextColor.RED)); admin.sendMessage(Component.text("Fall #" + caseId + " gibt es nicht (mehr).", NamedTextColor.RED));
@@ -107,40 +121,18 @@ final class BanEvasionDialogs {
} }
void openLinks(Player admin, String name) { void openLinks(Player admin, String name) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().uuidByName(connection, name)), uuid -> { this.appliance.queryFor(admin, connection -> {
if(uuid == null) { BanEvasionRepository.Member account = this.repository.accountByName(connection, name);
admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED)); return account == null ? null : this.loadLinks(connection, account.uuid(), account.name());
return; }, view -> {
} if(view == null) admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED));
this.openLinks(admin, uuid); else this.showLinks(admin, view);
}); });
} }
void openLinks(Player admin, UUID uuid) { void openLinks(Player admin, UUID uuid) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> new LinksView( this.appliance.queryFor(admin, connection -> this.loadLinks(connection, uuid, this.repository.nameOf(connection, uuid)),
this.appliance.repository().nameOf(connection, uuid), view -> this.showLinks(admin, view));
this.appliance.repository().linksOf(connection, uuid, LINK_LIMIT),
this.appliance.repository().recentNetworks(connection, uuid, RECENT_NETWORKS),
this.appliance.repository().householdsOf(connection, List.of(uuid)).get(uuid)
)), view -> {
List<Component> lines = new ArrayList<>();
if(view.household() != null) lines.add(Component.text(view.name() + " gehört zu Haushalt #" + view.household() + "."));
if(view.links().isEmpty()) lines.add(Component.text("Kein anderer Account hat je einen Anschluss mit " + view.name() + " geteilt."));
view.links().forEach(link -> lines.add(Component.text(String.format("%s – %d Einträge an gemeinsamen Anschlüssen, %s bis %s%s",
link.name(), link.sharedAttempts(), TIME.format(link.firstAt()), TIME.format(link.lastAt()),
link.caseId() == null ? "" : " · Fall #" + link.caseId()))));
List<ActionButton> buttons = new ArrayList<>();
if(view.household() != null)
buttons.add(button("Haushalt #" + view.household(), player -> this.openHousehold(player, view.household())));
view.links().stream()
.filter(link -> link.caseId() != null)
.forEach(link -> buttons.add(button("Fall #" + link.caseId() + ": " + link.name(), player -> this.openCase(player, link.caseId(), false))));
if(!view.networks().isEmpty())
buttons.add(button("IP-Ausnahme für Anschluss von " + view.name(), player -> this.openNewExemption(player, view.networks())));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN)));
show(admin, "Accounts zu " + view.name(), lines(lines), List.of(), buttons);
});
} }
void openNewExemption(Player admin, List<String> networks) { void openNewExemption(Player admin, List<String> networks) {
@@ -150,11 +142,11 @@ final class BanEvasionDialogs {
List<DialogInput> inputs = List.of( List<DialogInput> inputs = List.of(
DialogInput.singleOption(NETWORK_KEY, Component.text("Anschluss"), options).width(BODY_WIDTH).build(), DialogInput.singleOption(NETWORK_KEY, Component.text("Anschluss"), options).width(BODY_WIDTH).build(),
DialogInput.numberRange(HOURS_KEY, Component.text("Dauer in Stunden"), 1, 168).step(1f).initial(12f).width(BODY_WIDTH).build(), DialogInput.numberRange(HOURS_KEY, Component.text("Dauer in Stunden"), 1, 168).step(1f).initial(12f).width(BODY_WIDTH).build(),
DialogInput.text(NOTE_KEY, Component.text("Notiz, z. B. LAN-Party bei …")).width(BODY_WIDTH).maxLength(255).build() noteInput("Notiz, z. B. LAN-Party bei …")
); );
List<ActionButton> buttons = List.of( List<ActionButton> buttons = List.of(
button(Component.text("Ausnahme anlegen"), (player, response) -> this.createExemption(player, networks, response)), inputButton("Ausnahme anlegen", (player, response) -> this.createExemption(player, networks, response)),
button("Abbrechen", player -> this.openList(player, CaseStatus.OPEN)) this.backToList("Abbrechen")
); );
show(admin, "IP-Ausnahme anlegen", show(admin, "IP-Ausnahme anlegen",
Component.text("Einträge von diesem Anschluss lösen in der gewählten Zeit keine Meldung aus. Sie werden weiter protokolliert."), Component.text("Einträge von diesem Anschluss lösen in der gewählten Zeit keine Meldung aus. Sie werden weiter protokolliert."),
@@ -163,7 +155,7 @@ final class BanEvasionDialogs {
void openExemptions(Player admin) { void openExemptions(Player admin) {
Instant now = Instant.now(); Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().activeExemptions(connection, now)), exemptions -> { this.appliance.queryFor(admin, connection -> this.repository.activeExemptions(connection, now), exemptions -> {
Component body = Component.text(exemptions.isEmpty() Component body = Component.text(exemptions.isEmpty()
? "Keine aktiven IP-Ausnahmen." ? "Keine aktiven IP-Ausnahmen."
: exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben."); : exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben.");
@@ -171,45 +163,32 @@ final class BanEvasionDialogs {
exemptions.forEach(exemption -> buttons.add(button( exemptions.forEach(exemption -> buttons.add(button(
String.format("%s bis %s%s", Networks.mask(exemption.network()), TIME.format(exemption.validUntil()), String.format("%s bis %s%s", Networks.mask(exemption.network()), TIME.format(exemption.validUntil()),
exemption.note() == null ? "" : " · " + exemption.note()), exemption.note() == null ? "" : " · " + exemption.note()),
player -> this.openExemption(player, exemption)))); player -> this.showExemption(player, exemption))));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN))); buttons.add(this.backToList("Zur Fallliste"));
show(admin, "IP-Ausnahmen", body, List.of(), buttons); show(admin, "IP-Ausnahmen", body, List.of(), buttons);
}); });
} }
private void openExemption(Player admin, BanEvasionRepository.Exemption exemption) {
Component body = lines(List.of(
Component.text("Anschluss: " + exemption.network()),
Component.text("Gültig: " + TIME.format(exemption.validFrom()) + " bis " + TIME.format(exemption.validUntil())),
Component.text("Angelegt von: " + (exemption.createdByName() == null ? "?" : exemption.createdByName())),
Component.text("Notiz: " + (exemption.note() == null ? "–" : exemption.note()))
));
show(admin, "IP-Ausnahme", body, List.of(), List.of(
button("Jetzt beenden", player -> this.endExemption(player, exemption)),
button("Zurück", this::openExemptions)
));
}
void openHouseholds(Player admin) { void openHouseholds(Player admin) {
this.appliance.loadFor(admin, () -> this.appliance.query(this.appliance.repository()::listHouseholds), households -> { this.appliance.queryFor(admin, this.repository::listHouseholds, households -> {
String text = households.isEmpty() ? "Keine Haushalte." : households.size() + " Haushalte, neueste zuerst."; String text = households.isEmpty() ? "Keine Haushalte." : households.size() + " Haushalte, neueste zuerst.";
if(households.size() > LIST_LIMIT) if(households.size() > LIST_LIMIT)
text += " Gezeigt werden die neuesten " + LIST_LIMIT + " – ältere über /accountLinks <spieler> finden."; text += " Gezeigt werden die neuesten " + LIST_LIMIT + " – ältere über /accountLinks <spieler> finden.";
List<ActionButton> buttons = new ArrayList<>(); List<ActionButton> buttons = new ArrayList<>();
households.stream().limit(LIST_LIMIT).forEach(household -> buttons.add(button( households.stream().limit(LIST_LIMIT).forEach(household -> {
"#" + household.id() + ": " + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")), long householdId = household.id();
player -> this.openHousehold(player, household.id())))); buttons.add(button(
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN))); "#" + householdId + ": " + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")),
player -> this.openHousehold(player, householdId)));
});
buttons.add(this.backToList("Zur Fallliste"));
show(admin, "Haushalte", Component.text(text), List.of(), buttons); show(admin, "Haushalte", Component.text(text), List.of(), buttons);
}); });
} }
private record HouseholdView(BanEvasionRepository.Household household, Map<UUID, BanState> banStates) {
}
void openHousehold(Player admin, long householdId) { void openHousehold(Player admin, long householdId) {
this.appliance.loadFor(admin, () -> { this.appliance.loadFor(admin, () -> {
BanEvasionRepository.Household household = this.appliance.query(connection -> this.appliance.repository().household(connection, householdId)); BanEvasionRepository.Household household = this.appliance.query(connection -> this.repository.household(connection, householdId));
if(household == null) return null; if(household == null) return null;
return new HouseholdView(household, this.appliance.banStates(household.members().stream().map(BanEvasionRepository.Member::uuid).toList())); return new HouseholdView(household, this.appliance.banStates(household.members().stream().map(BanEvasionRepository.Member::uuid).toList()));
}, view -> { }, view -> {
@@ -220,8 +199,7 @@ final class BanEvasionDialogs {
} }
BanEvasionRepository.Household household = view.household(); BanEvasionRepository.Household household = view.household();
List<Component> lines = new ArrayList<>(); List<Component> lines = new ArrayList<>();
lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von " lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von " + orUnknown(household.createdByName())));
+ (household.createdByName() == null ? "?" : household.createdByName())));
lines.add(Component.text("Notiz: " + (household.note() == null ? "–" : household.note()))); lines.add(Component.text("Notiz: " + (household.note() == null ? "–" : household.note())));
lines.add(Component.empty()); lines.add(Component.empty());
household.members().forEach(member -> lines.add(accountLine(member.name(), view.banStates().get(member.uuid()), null))); household.members().forEach(member -> lines.add(accountLine(member.name(), view.banStates().get(member.uuid()), null)));
@@ -233,6 +211,51 @@ final class BanEvasionDialogs {
}); });
} }
private LinksView loadLinks(Connection connection, UUID uuid, String name) throws SQLException {
return new LinksView(
name,
this.repository.linksOf(connection, uuid, LINK_LIMIT),
this.repository.recentNetworks(connection, uuid, RECENT_NETWORKS),
this.repository.householdsOf(connection, List.of(uuid)).get(uuid)
);
}
private void showLinks(Player admin, LinksView view) {
String name = view.name();
Long household = view.household();
List<String> networks = view.networks();
List<Component> lines = new ArrayList<>();
if(household != null) lines.add(Component.text(name + " gehört zu Haushalt #" + household + "."));
if(view.links().isEmpty()) lines.add(Component.text("Kein anderer Account hat je einen Anschluss mit " + name + " geteilt."));
view.links().forEach(link -> lines.add(Component.text(String.format("%s – %d Einträge an gemeinsamen Anschlüssen, %s bis %s%s",
link.name(), link.sharedAttempts(), TIME.format(link.firstAt()), TIME.format(link.lastAt()),
link.caseId() == null ? "" : " · Fall #" + link.caseId()))));
List<ActionButton> buttons = new ArrayList<>();
if(household != null) buttons.add(button("Haushalt #" + household, player -> this.openHousehold(player, household)));
view.links().forEach(link -> {
Long caseId = link.caseId();
if(caseId != null) buttons.add(button("Fall #" + caseId + ": " + link.name(), player -> this.openCase(player, caseId, false)));
});
if(!networks.isEmpty()) buttons.add(button("IP-Ausnahme für Anschluss von " + name, player -> this.openNewExemption(player, networks)));
buttons.add(this.backToList("Zur Fallliste"));
show(admin, "Accounts zu " + name, lines(lines), List.of(), buttons);
}
private void showExemption(Player admin, BanEvasionRepository.Exemption exemption) {
long exemptionId = exemption.id();
Component body = lines(List.of(
Component.text("Anschluss: " + exemption.network()),
Component.text("Gültig: " + TIME.format(exemption.validFrom()) + " bis " + TIME.format(exemption.validUntil())),
Component.text("Angelegt von: " + orUnknown(exemption.createdByName())),
Component.text("Notiz: " + (exemption.note() == null ? "–" : exemption.note()))
));
show(admin, "IP-Ausnahme", body, List.of(), List.of(
button("Jetzt beenden", player -> this.endExemption(player, exemptionId)),
button("Zurück", this::openExemptions)
));
}
private void showCase(Player admin, CaseView view, boolean fullNetworks) { private void showCase(Player admin, CaseView view, boolean fullNetworks) {
BanEvasionRepository.CaseDetails details = view.details(); BanEvasionRepository.CaseDetails details = view.details();
BanEvasionRepository.CaseSummary summary = details.summary(); BanEvasionRepository.CaseSummary summary = details.summary();
@@ -262,40 +285,41 @@ final class BanEvasionDialogs {
if(details.closedAt() != null) { if(details.closedAt() != null) {
lines.add(Component.empty()); lines.add(Component.empty());
lines.add(Component.text(String.format("Zuletzt geschlossen %s von %s%s", lines.add(Component.text(String.format("Zuletzt geschlossen %s von %s%s",
TIME.format(details.closedAt()), TIME.format(details.closedAt()), orUnknown(details.closedByName()),
details.closedByName() == null ? "?" : details.closedByName(),
details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY)); details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY));
} }
long caseId = summary.id(); long caseId = summary.id();
UUID first = summary.first();
UUID second = summary.second();
CaseStatus status = summary.status();
List<String> shared = details.sharedNetworks();
List<DialogInput> inputs = new ArrayList<>(); List<DialogInput> inputs = new ArrayList<>();
List<ActionButton> buttons = new ArrayList<>(); List<ActionButton> buttons = new ArrayList<>();
if(summary.status() == CaseStatus.OPEN) { if(status == CaseStatus.OPEN) {
inputs.add(DialogInput.text(NOTE_KEY, Component.text("Notiz (optional)")).width(BODY_WIDTH).maxLength(255).build()); inputs.add(noteInput("Notiz (optional)"));
for(Outcome outcome : Outcome.values()) for(Outcome outcome : Outcome.values())
buttons.add(button(Component.text("Schließen: " + outcome.label), (player, response) -> this.close(player, summary, outcome, response.getText(NOTE_KEY)))); buttons.add(inputButton("Schließen: " + outcome.label,
(player, response) -> this.close(player, caseId, first, second, outcome, response.getText(NOTE_KEY))));
} }
buttons.add(button(fullNetworks ? "Anschlüsse gekürzt" : "Volle Anschlüsse zeigen", player -> this.openCase(player, caseId, !fullNetworks))); buttons.add(button(fullNetworks ? "Anschlüsse gekürzt" : "Volle Anschlüsse zeigen", player -> this.openCase(player, caseId, !fullNetworks)));
buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, summary.first()))); buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, first)));
buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, summary.second()))); buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, second)));
if(!details.sharedNetworks().isEmpty()) if(!shared.isEmpty()) buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, shared)));
buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, details.sharedNetworks()))); buttons.add(button("Zurück zur Liste", player -> this.openList(player, status)));
buttons.add(button("Zurück zur Liste", player -> this.openList(player, summary.status())));
show(admin, "Verdachtsfall #" + caseId, lines(lines), inputs, buttons); show(admin, "Verdachtsfall #" + caseId, lines(lines), inputs, buttons);
} }
private void close(Player admin, BanEvasionRepository.CaseSummary summary, Outcome outcome, @Nullable String rawNote) { private void close(Player admin, long caseId, UUID first, UUID second, Outcome outcome, @Nullable String rawNote) {
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip(); String note = note(rawNote);
UUID adminUuid = admin.getUniqueId(); UUID adminUuid = admin.getUniqueId();
Instant now = Instant.now(); Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> { this.appliance.runFor(admin, connection -> {
this.appliance.repository().closeCase(connection, summary.id(), outcome, adminUuid, note, now); this.repository.closeCase(connection, caseId, outcome, adminUuid, note, now);
if(outcome == Outcome.HOUSEHOLD) if(outcome == Outcome.HOUSEHOLD) this.repository.linkHousehold(connection, List.of(first, second), adminUuid, note, now);
this.appliance.repository().linkHousehold(connection, List.of(summary.first(), summary.second()), adminUuid, note, now); }, () -> {
return true; Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", caseId, outcome, admin.getName()));
}), done -> { admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", caseId, outcome.label), NamedTextColor.GRAY));
Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", summary.id(), outcome, admin.getName()));
admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", summary.id(), outcome.label), NamedTextColor.GRAY));
this.openList(admin, CaseStatus.OPEN); this.openList(admin, CaseStatus.OPEN);
}); });
} }
@@ -307,41 +331,35 @@ final class BanEvasionDialogs {
admin.sendMessage(Component.text("Ungültige Eingabe.", NamedTextColor.RED)); admin.sendMessage(Component.text("Ungültige Eingabe.", NamedTextColor.RED));
return; return;
} }
String rawNote = response.getText(NOTE_KEY); String note = note(response.getText(NOTE_KEY));
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
Instant from = Instant.now(); Instant from = Instant.now();
Instant until = from.plus(Duration.ofHours(Math.round(hours))); Instant until = from.plus(Duration.ofHours(Math.round(hours)));
UUID adminUuid = admin.getUniqueId(); UUID adminUuid = admin.getUniqueId();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> { this.appliance.runFor(admin, connection -> this.repository.addExemption(connection, network, from, until, adminUuid, note), () -> {
this.appliance.repository().addExemption(connection, network, from, until, adminUuid, note);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption until %s added by %s", until, admin.getName())); Main.logger().info(String.format("BanEvasion: exemption until %s added by %s", until, admin.getName()));
admin.sendMessage(Component.text(String.format("IP-Ausnahme für %s bis %s angelegt.", Networks.mask(network), TIME.format(until)), NamedTextColor.GRAY)); admin.sendMessage(Component.text(String.format("IP-Ausnahme für %s bis %s angelegt.", Networks.mask(network), TIME.format(until)), NamedTextColor.GRAY));
}); });
} }
private void endExemption(Player admin, BanEvasionRepository.Exemption exemption) { private void endExemption(Player admin, long exemptionId) {
Instant now = Instant.now(); Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> { this.appliance.runFor(admin, connection -> this.repository.endExemption(connection, exemptionId, now), () -> {
this.appliance.repository().endExemption(connection, exemption.id(), now); Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemptionId, admin.getName()));
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemption.id(), admin.getName()));
this.openExemptions(admin); this.openExemptions(admin);
}); });
} }
private void removeMember(Player admin, long householdId, BanEvasionRepository.Member member) { private void removeMember(Player admin, long householdId, BanEvasionRepository.Member member) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> { this.appliance.runFor(admin, connection -> this.repository.removeFromHousehold(connection, member.uuid()), () -> {
this.appliance.repository().removeFromHousehold(connection, member.uuid());
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName())); Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName()));
this.openHousehold(admin, householdId); this.openHousehold(admin, householdId);
}); });
} }
private ActionButton backToList(String label) {
return button(label, player -> this.openList(player, CaseStatus.OPEN));
}
private static Component caseLabel(BanEvasionRepository.CaseSummary summary) { private static Component caseLabel(BanEvasionRepository.CaseSummary summary) {
NamedTextColor color = summary.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.WHITE; NamedTextColor color = summary.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.WHITE;
String outcome = summary.outcome() == null ? "" : " · " + summary.outcome().label; String outcome = summary.outcome() == null ? "" : " · " + summary.outcome().label;
@@ -368,28 +386,44 @@ final class BanEvasionDialogs {
return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", ")); return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", "));
} }
private static String orUnknown(@Nullable String name) {
return name == null ? "?" : name;
}
private static @Nullable String note(@Nullable String raw) {
return raw == null || raw.isBlank() ? null : raw.strip();
}
private static DialogInput noteInput(String label) {
return DialogInput.text(NOTE_KEY, Component.text(label)).width(BODY_WIDTH).maxLength(255).build();
}
private static Component lines(List<Component> lines) { private static Component lines(List<Component> lines) {
return Component.join(JoinConfiguration.newlines(), lines); return Component.join(JoinConfiguration.newlines(), lines);
} }
private static DialogAction adminAction(BiConsumer<Player, DialogResponseView> action) { private static DialogAction adminAction(BiConsumer<Player, DialogResponseView> action) {
return DialogAction.customClick( return DialogAction.customClick((response, audience) -> {
(response, audience) -> { if(!(audience instanceof Player player)) return;
if(!(audience instanceof Player player)) return; Bukkit.getScheduler().runTask(Main.instance(), () -> {
Bukkit.getScheduler().runTask(Main.instance(), () -> { if(player.isOnline() && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player, response);
if(player.isOnline() && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player, response); });
}); }, DIALOG_CALLBACK);
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
} }
private static ActionButton button(Component label, BiConsumer<Player, DialogResponseView> action) { private static ActionButton button(Component label, Consumer<Player> action) {
return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction(action)).build(); return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction((player, response) -> action.accept(player))).build();
} }
private static ActionButton button(String label, Consumer<Player> action) { private static ActionButton button(String label, Consumer<Player> action) {
return button(Component.text(label), (player, response) -> action.accept(player)); return button(Component.text(label), action);
}
/**
* Button that reads the inputs of the dialog.
*/
private static ActionButton inputButton(String label, BiConsumer<Player, DialogResponseView> action) {
return ActionButton.builder(Component.text(label)).width(BUTTON_WIDTH).action(adminAction(action)).build();
} }
private static void show(Player admin, String title, Component body, List<DialogInput> inputs, List<ActionButton> buttons) { private static void show(Player admin, String title, Component body, List<DialogInput> inputs, List<ActionButton> buttons) {
@@ -20,6 +20,7 @@ import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -76,6 +77,11 @@ final class BanEvasionRepository {
record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) { record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) {
} }
@FunctionalInterface
private interface RowMapper<T> {
T map(ResultSet row) throws SQLException;
}
/** /**
* Latest known name of the account in the given column. * Latest known name of the account in the given column.
*/ */
@@ -83,13 +89,7 @@ final class BanEvasionRepository {
boolean isExempt(Connection connection, String network, Instant at) throws SQLException { boolean isExempt(Connection connection, String network, Instant at) throws SQLException {
String sql = "SELECT 1 FROM ban_evasion_ip_exemptions WHERE network = ? AND ? BETWEEN valid_from AND valid_until LIMIT 1"; String sql = "SELECT 1 FROM ban_evasion_ip_exemptions WHERE network = ? AND ? BETWEEN valid_from AND valid_until LIMIT 1";
try(PreparedStatement statement = connection.prepareStatement(sql)) { return first(connection, sql, row -> true, network, at) != null;
statement.setString(1, network);
statement.setObject(2, toDb(at));
try(ResultSet result = statement.executeQuery()) {
return result.next();
}
}
} }
List<Attempt> partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException { List<Attempt> partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException {
@@ -103,73 +103,43 @@ final class BanEvasionRepository {
) )
ORDER BY a.at ORDER BY a.at
"""; """;
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> new Attempt(
statement.setString(1, current.network()); row.getLong("id"),
statement.setString(2, current.uuid().toString()); fromDb(row, "at"),
statement.setObject(3, toDb(current.at().minus(window))); uuid(row, "uuid"),
statement.setObject(4, toDb(current.at())); row.getString("name"),
List<Attempt> attempts = new ArrayList<>(); row.getString("network"),
try(ResultSet row = statement.executeQuery()) { row.getString("stage"),
while(row.next()) attempts.add(new Attempt( row.getString("result")
row.getLong("id"), ), current.network(), current.uuid(), current.at().minus(window), current.at());
fromDb(row, "at"),
UUID.fromString(row.getString("uuid")),
row.getString("name"),
row.getString("network"),
row.getString("stage"),
row.getString("result")
));
}
return attempts;
}
} }
Map<UUID, Long> householdsOf(Connection connection, Collection<UUID> uuids) throws SQLException { Map<UUID, Long> householdsOf(Connection connection, Collection<UUID> uuids) throws SQLException {
if(uuids.isEmpty()) return Map.of(); if(uuids.isEmpty()) return Map.of();
String sql = "SELECT uuid, household_id FROM ban_evasion_household_members WHERE uuid IN (" + placeholders(uuids.size()) + ")"; String sql = "SELECT uuid, household_id FROM ban_evasion_household_members WHERE uuid IN (" + placeholders(uuids.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) { Map<UUID, Long> households = new HashMap<>();
int index = 1; list(connection, sql, row -> Map.entry(uuid(row, "uuid"), row.getLong("household_id")), uuids.toArray())
for(UUID uuid : uuids) statement.setString(index++, uuid.toString()); .forEach(entry -> households.put(entry.getKey(), entry.getValue()));
Map<UUID, Long> households = new HashMap<>(); return households;
try(ResultSet row = statement.executeQuery()) {
while(row.next()) households.put(UUID.fromString(row.getString("uuid")), row.getLong("household_id"));
}
return households;
}
} }
Map<Long, Set<UUID>> householdMembers(Connection connection, Collection<Long> householdIds) throws SQLException { Map<Long, Set<UUID>> householdMembers(Connection connection, Collection<Long> householdIds) throws SQLException {
if(householdIds.isEmpty()) return Map.of(); if(householdIds.isEmpty()) return Map.of();
String sql = "SELECT household_id, uuid FROM ban_evasion_household_members WHERE household_id IN (" + placeholders(householdIds.size()) + ")"; String sql = "SELECT household_id, uuid FROM ban_evasion_household_members WHERE household_id IN (" + placeholders(householdIds.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) { Map<Long, Set<UUID>> members = new HashMap<>();
int index = 1; list(connection, sql, row -> Map.entry(row.getLong("household_id"), uuid(row, "uuid")), householdIds.toArray())
for(long id : householdIds) statement.setLong(index++, id); .forEach(entry -> members.computeIfAbsent(entry.getKey(), id -> new HashSet<>()).add(entry.getValue()));
Map<Long, Set<UUID>> members = new HashMap<>(); return members;
try(ResultSet row = statement.executeQuery()) {
while(row.next())
members.computeIfAbsent(row.getLong("household_id"), id -> new HashSet<>()).add(UUID.fromString(row.getString("uuid")));
}
return members;
}
} }
@Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException { @Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException {
long id; String sql = "SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?";
CaseStatus status; CaseDecision.ExistingCase found = first(connection, sql, row -> new CaseDecision.ExistingCase(
String outcome; row.getLong("id"), CaseStatus.valueOf(row.getString("status")), outcomeOf(row), EnumSet.noneOf(Reason.class)
try(PreparedStatement statement = connection.prepareStatement("SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?")) { ), first, second);
statement.setString(1, first.toString()); if(found == null) return null;
statement.setString(2, second.toString()); this.reasonsOf(connection, found.id()).forEach(reason -> found.reasons().add(reason.reason()));
try(ResultSet row = statement.executeQuery()) { return found;
if(!row.next()) return null;
id = row.getLong("id");
status = CaseStatus.valueOf(row.getString("status"));
outcome = row.getString("outcome");
}
}
Set<Reason> reasons = EnumSet.noneOf(Reason.class);
this.reasonsOf(connection, id).forEach(reason -> reasons.add(reason.reason()));
return new CaseDecision.ExistingCase(id, status, outcome == null ? null : Outcome.valueOf(outcome), reasons);
} }
/** /**
@@ -181,19 +151,7 @@ final class BanEvasionRepository {
VALUES (?, ?, 'OPEN', ?, ?, ?, ?, 1) VALUES (?, ?, 'OPEN', ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
"""; """;
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { return insert(connection, sql, finding.first(), finding.second(), finding.severity(), finding.banStatusUnknown(), at, at);
statement.setString(1, finding.first().toString());
statement.setString(2, finding.second().toString());
statement.setString(3, finding.severity().name());
statement.setBoolean(4, finding.banStatusUnknown());
statement.setObject(5, toDb(at));
statement.setObject(6, toDb(at));
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) throw new SQLException("no id returned for ban evasion case");
return keys.getLong(1);
}
}
} }
void updateCase(Connection connection, long caseId, PairFinding finding, Instant at, boolean reopen) throws SQLException { void updateCase(Connection connection, long caseId, PairFinding finding, Instant at, boolean reopen) throws SQLException {
@@ -207,31 +165,12 @@ final class BanEvasionRepository {
outcome = IF(?, NULL, outcome) outcome = IF(?, NULL, outcome)
WHERE id = ? WHERE id = ?
"""; """;
try(PreparedStatement statement = connection.prepareStatement(sql)) { update(connection, sql, at, finding.severity(), finding.banStatusUnknown(), reopen, reopen, caseId);
statement.setObject(1, toDb(at));
statement.setString(2, finding.severity().name());
statement.setBoolean(3, finding.banStatusUnknown());
statement.setBoolean(4, reopen);
statement.setBoolean(5, reopen);
statement.setLong(6, caseId);
statement.executeUpdate();
}
} }
void addReasons(Connection connection, long caseId, Map<Reason, Severity> reasons, Set<Reason> which, long attemptId, Instant at) throws SQLException { void addReasons(Connection connection, long caseId, Map<Reason, Severity> reasons, Set<Reason> which, long attemptId, Instant at) throws SQLException {
if(which.isEmpty()) return;
String sql = "INSERT IGNORE INTO ban_evasion_case_reasons (case_id, reason, severity, first_seen_at, attempt_id) VALUES (?, ?, ?, ?, ?)"; String sql = "INSERT IGNORE INTO ban_evasion_case_reasons (case_id, reason, severity, first_seen_at, attempt_id) VALUES (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) { for(Reason reason : which) update(connection, sql, caseId, reason, reasons.get(reason), at, attemptId);
for(Reason reason : which) {
statement.setLong(1, caseId);
statement.setString(2, reason.name());
statement.setString(3, reasons.get(reason).name());
statement.setObject(4, toDb(at));
statement.setLong(5, attemptId);
statement.addBatch();
}
statement.executeBatch();
}
} }
List<CaseSummary> listCases(Connection connection, CaseStatus status, int limit) throws SQLException { List<CaseSummary> listCases(Connection connection, CaseStatus status, int limit) throws SQLException {
@@ -243,23 +182,12 @@ final class BanEvasionRepository {
ORDER BY c.severity = 'HIGH' DESC, c.last_seen_at DESC ORDER BY c.severity = 'HIGH' DESC, c.last_seen_at DESC
LIMIT ? LIMIT ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b")); """.formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"));
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, BanEvasionRepository::summaryOf, status, limit);
statement.setString(1, status.name());
statement.setInt(2, limit);
List<CaseSummary> cases = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) cases.add(summaryOf(row));
}
return cases;
}
} }
OpenCount countOpen(Connection connection) throws SQLException { OpenCount countOpen(Connection connection) throws SQLException {
String sql = "SELECT COUNT(*) AS total, COALESCE(SUM(severity = 'HIGH'), 0) AS high FROM ban_evasion_cases WHERE status = 'OPEN'"; String sql = "SELECT COUNT(*) AS total, COALESCE(SUM(severity = 'HIGH'), 0) AS high FROM ban_evasion_cases WHERE status = 'OPEN'";
try(PreparedStatement statement = connection.prepareStatement(sql); ResultSet row = statement.executeQuery()) { return Objects.requireNonNull(first(connection, sql, row -> new OpenCount(row.getInt("total"), row.getInt("high"))));
row.next();
return new OpenCount(row.getInt("total"), row.getInt("high"));
}
} }
@Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException { @Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException {
@@ -268,42 +196,29 @@ final class BanEvasionRepository {
FROM ban_evasion_cases c FROM ban_evasion_cases c
WHERE c.id = ? WHERE c.id = ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"), NAME_OF.formatted("c.closed_by")); """.formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"), NAME_OF.formatted("c.closed_by"));
CaseSummary summary; CaseDetails found = first(connection, sql, row -> new CaseDetails(
boolean banStatusUnknown; summaryOf(row),
Instant createdAt; row.getBoolean("ban_status_unknown"),
Instant closedAt; fromDb(row, "created_at"),
String closedByName; nullableFromDb(row, "closed_at"),
String note; row.getString("closed_by_name"),
try(PreparedStatement statement = connection.prepareStatement(sql)) { row.getString("note"),
statement.setLong(1, caseId); List.of(), List.of(), List.of()
try(ResultSet row = statement.executeQuery()) { ), caseId);
if(!row.next()) return null; if(found == null) return null;
summary = summaryOf(row); UUID first = found.summary().first();
banStatusUnknown = row.getBoolean("ban_status_unknown"); UUID second = found.summary().second();
createdAt = fromDb(row, "created_at");
closedAt = nullableFromDb(row, "closed_at");
closedByName = row.getString("closed_by_name");
note = row.getString("note");
}
}
return new CaseDetails( return new CaseDetails(
summary, banStatusUnknown, createdAt, closedAt, closedByName, note, found.summary(), found.banStatusUnknown(), found.createdAt(), found.closedAt(), found.closedByName(), found.note(),
this.reasonsOf(connection, caseId), this.reasonsOf(connection, caseId),
this.sharedNetworks(connection, summary.first(), summary.second()), this.sharedNetworks(connection, first, second),
this.timeline(connection, summary.first(), summary.second(), timelineLimit) this.timeline(connection, first, second, timelineLimit)
); );
} }
void closeCase(Connection connection, long caseId, Outcome outcome, UUID admin, @Nullable String note, Instant at) throws SQLException { void closeCase(Connection connection, long caseId, Outcome outcome, UUID admin, @Nullable String note, Instant at) throws SQLException {
String sql = "UPDATE ban_evasion_cases SET status = 'CLOSED', outcome = ?, closed_at = ?, closed_by = ?, note = ? WHERE id = ?"; String sql = "UPDATE ban_evasion_cases SET status = 'CLOSED', outcome = ?, closed_at = ?, closed_by = ?, note = ? WHERE id = ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) { update(connection, sql, outcome, at, admin, note, caseId);
statement.setString(1, outcome.name());
statement.setObject(2, toDb(at));
statement.setString(3, admin.toString());
statement.setString(4, note);
statement.setLong(5, caseId);
statement.executeUpdate();
}
} }
/** /**
@@ -322,43 +237,30 @@ final class BanEvasionRepository {
ORDER BY last_at DESC ORDER BY last_at DESC
LIMIT ? LIMIT ?
""".formatted(NAME_OF.formatted("o.uuid")); """.formatted(NAME_OF.formatted("o.uuid"));
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> {
for(int index = 1; index <= 4; index++) statement.setString(index, uuid.toString()); UUID other = uuid(row, "uuid");
statement.setInt(5, limit); return new Link(
List<Link> links = new ArrayList<>(); other,
try(ResultSet row = statement.executeQuery()) { nameOr(row.getString("name"), other),
while(row.next()) { row.getInt("shared"),
UUID other = UUID.fromString(row.getString("uuid")); fromDb(row, "first_at"),
String name = nameOr(row.getString("name"), other); fromDb(row, "last_at"),
int shared = row.getInt("shared"); row.getObject("case_id", Long.class)
Instant firstAt = fromDb(row, "first_at"); );
Instant lastAt = fromDb(row, "last_at"); }, uuid, uuid, uuid, uuid, limit);
long caseId = row.getLong("case_id");
boolean noCase = row.wasNull();
links.add(new Link(other, name, shared, firstAt, lastAt, noCase ? null : caseId));
}
}
return links;
}
} }
/**
@Nullable UUID uuidByName(Connection connection, String name) throws SQLException { * Latest account that used the name (case-insensitive), with its name as stored.
try(PreparedStatement statement = connection.prepareStatement("SELECT uuid FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1")) { */
statement.setString(1, name); @Nullable Member accountByName(Connection connection, String name) throws SQLException {
try(ResultSet row = statement.executeQuery()) { String sql = "SELECT uuid, name FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1";
return row.next() ? UUID.fromString(row.getString("uuid")) : null; return first(connection, sql, row -> new Member(uuid(row, "uuid"), row.getString("name")), name);
}
}
} }
String nameOf(Connection connection, UUID uuid) throws SQLException { String nameOf(Connection connection, UUID uuid) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1")) { String name = first(connection, "SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1", row -> row.getString("name"), uuid);
statement.setString(1, uuid.toString()); return nameOr(name, uuid);
try(ResultSet row = statement.executeQuery()) {
return row.next() ? row.getString("name") : uuid.toString();
}
}
} }
List<String> recentNetworks(Connection connection, UUID uuid, int limit) throws SQLException { List<String> recentNetworks(Connection connection, UUID uuid, int limit) throws SQLException {
@@ -367,27 +269,12 @@ final class BanEvasionRepository {
WHERE uuid = ? AND network IS NOT NULL WHERE uuid = ? AND network IS NOT NULL
GROUP BY network ORDER BY last_at DESC LIMIT ? GROUP BY network ORDER BY last_at DESC LIMIT ?
"""; """;
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> row.getString("network"), uuid, limit);
statement.setString(1, uuid.toString());
statement.setInt(2, limit);
List<String> networks = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) networks.add(row.getString("network"));
}
return networks;
}
} }
void addExemption(Connection connection, String network, Instant from, Instant until, UUID admin, @Nullable String note) throws SQLException { void addExemption(Connection connection, String network, Instant from, Instant until, UUID admin, @Nullable String note) throws SQLException {
String sql = "INSERT INTO ban_evasion_ip_exemptions (network, valid_from, valid_until, created_by, note) VALUES (?, ?, ?, ?, ?)"; String sql = "INSERT INTO ban_evasion_ip_exemptions (network, valid_from, valid_until, created_by, note) VALUES (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) { update(connection, sql, network, from, until, admin, note);
statement.setString(1, network);
statement.setObject(2, toDb(from));
statement.setObject(3, toDb(until));
statement.setString(4, admin.toString());
statement.setString(5, note);
statement.executeUpdate();
}
} }
List<Exemption> activeExemptions(Connection connection, Instant now) throws SQLException { List<Exemption> activeExemptions(Connection connection, Instant now) throws SQLException {
@@ -397,30 +284,18 @@ final class BanEvasionRepository {
WHERE e.valid_until >= ? WHERE e.valid_until >= ?
ORDER BY e.valid_until ORDER BY e.valid_until
""".formatted(NAME_OF.formatted("e.created_by")); """.formatted(NAME_OF.formatted("e.created_by"));
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> new Exemption(
statement.setObject(1, toDb(now)); row.getLong("id"),
List<Exemption> exemptions = new ArrayList<>(); row.getString("network"),
try(ResultSet row = statement.executeQuery()) { fromDb(row, "valid_from"),
while(row.next()) exemptions.add(new Exemption( fromDb(row, "valid_until"),
row.getLong("id"), row.getString("created_by_name"),
row.getString("network"), row.getString("note")
fromDb(row, "valid_from"), ), now);
fromDb(row, "valid_until"),
row.getString("created_by_name"),
row.getString("note")
));
}
return exemptions;
}
} }
void endExemption(Connection connection, long id, Instant now) throws SQLException { void endExemption(Connection connection, long id, Instant now) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?")) { update(connection, "UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?", now, id, now);
statement.setObject(1, toDb(now));
statement.setLong(2, id);
statement.setObject(3, toDb(now));
statement.executeUpdate();
}
} }
/** /**
@@ -431,28 +306,22 @@ final class BanEvasionRepository {
connection.setAutoCommit(false); connection.setAutoCommit(false);
try { try {
List<Long> existing = this.householdsOf(connection, uuids).values().stream().distinct().sorted().toList(); List<Long> existing = this.householdsOf(connection, uuids).values().stream().distinct().sorted().toList();
long target = existing.isEmpty() ? this.createHousehold(connection, admin, note, at) : existing.getFirst(); long target = existing.isEmpty()
for(long other : existing.subList(Math.min(1, existing.size()), existing.size())) { ? insert(connection, "INSERT INTO ban_evasion_households (created_at, created_by, note) VALUES (?, ?, ?)", at, admin, note)
try(PreparedStatement move = connection.prepareStatement("UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id = ?")) { : existing.getFirst();
move.setLong(1, target); if(existing.size() > 1) {
move.setLong(2, other); Object[] merged = existing.subList(1, existing.size()).toArray();
move.executeUpdate(); String in = placeholders(merged.length);
} Object[] moveParams = new Object[merged.length + 1];
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_households WHERE id = ?")) { moveParams[0] = target;
delete.setLong(1, other); System.arraycopy(merged, 0, moveParams, 1, merged.length);
delete.executeUpdate(); update(connection, "UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id IN (" + in + ")", moveParams);
} update(connection, "DELETE FROM ban_evasion_households WHERE id IN (" + in + ")", merged);
}
try(PreparedStatement insert = connection.prepareStatement("INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)")) {
for(UUID uuid : uuids) {
insert.setString(1, uuid.toString());
insert.setLong(2, target);
insert.addBatch();
}
insert.executeBatch();
} }
for(UUID uuid : uuids)
update(connection, "INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)", uuid, target);
connection.commit(); connection.commit();
} catch(SQLException e) { } catch(SQLException | RuntimeException e) {
connection.rollback(); connection.rollback();
throw e; throw e;
} finally { } finally {
@@ -464,7 +333,7 @@ final class BanEvasionRepository {
* All households, newest first. * All households, newest first.
*/ */
List<Household> listHouseholds(Connection connection) throws SQLException { List<Household> listHouseholds(Connection connection) throws SQLException {
return this.households(connection, "", null); return this.households(connection, "");
} }
@Nullable Household household(Connection connection, long id) throws SQLException { @Nullable Household household(Connection connection, long id) throws SQLException {
@@ -472,7 +341,7 @@ final class BanEvasionRepository {
return households.isEmpty() ? null : households.getFirst(); return households.isEmpty() ? null : households.getFirst();
} }
private List<Household> households(Connection connection, String where, @Nullable Long id) throws SQLException { private List<Household> households(Connection connection, String where, Object... params) throws SQLException {
String sql = """ String sql = """
SELECT h.id, h.created_at, h.note, %s AS created_by_name, m.uuid, %s AS name SELECT h.id, h.created_at, h.note, %s AS created_by_name, m.uuid, %s AS name
FROM ban_evasion_households h FROM ban_evasion_households h
@@ -481,19 +350,15 @@ final class BanEvasionRepository {
ORDER BY h.id DESC, name ORDER BY h.id DESC, name
""".formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where); """.formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where);
Map<Long, Household> households = new LinkedHashMap<>(); Map<Long, Household> households = new LinkedHashMap<>();
try(PreparedStatement statement = connection.prepareStatement(sql)) { list(connection, sql, row -> {
if(id != null) statement.setLong(1, id); UUID uuid = uuid(row, "uuid");
try(ResultSet row = statement.executeQuery()) { Household household = households.get(row.getLong("id"));
while(row.next()) { if(household == null) {
Instant createdAt = fromDb(row, "created_at"); household = new Household(row.getLong("id"), fromDb(row, "created_at"), row.getString("created_by_name"), row.getString("note"), new ArrayList<>());
String createdByName = row.getString("created_by_name"); households.put(household.id(), household);
String householdNote = row.getString("note");
UUID uuid = UUID.fromString(row.getString("uuid"));
households.computeIfAbsent(row.getLong("id"), key -> new Household(key, createdAt, createdByName, householdNote, new ArrayList<>()))
.members().add(new Member(uuid, nameOr(row.getString("name"), uuid)));
}
} }
} return household.members().add(new Member(uuid, nameOr(row.getString("name"), uuid)));
}, params);
return List.copyOf(households.values()); return List.copyOf(households.values());
} }
@@ -503,60 +368,24 @@ final class BanEvasionRepository {
void removeFromHousehold(Connection connection, UUID uuid) throws SQLException { void removeFromHousehold(Connection connection, UUID uuid) throws SQLException {
Long household = this.householdsOf(connection, List.of(uuid)).get(uuid); Long household = this.householdsOf(connection, List.of(uuid)).get(uuid);
if(household == null) return; if(household == null) return;
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_household_members WHERE uuid = ?")) { update(connection, "DELETE FROM ban_evasion_household_members WHERE uuid = ?", uuid);
delete.setString(1, uuid.toString()); update(connection,
delete.executeUpdate(); "DELETE FROM ban_evasion_households WHERE id = ? AND (SELECT COUNT(*) FROM ban_evasion_household_members WHERE household_id = ?) < 2",
} household, household);
String sql = "DELETE FROM ban_evasion_households WHERE id = ? AND (SELECT COUNT(*) FROM ban_evasion_household_members WHERE household_id = ?) < 2";
try(PreparedStatement dissolve = connection.prepareStatement(sql)) {
dissolve.setLong(1, household);
dissolve.setLong(2, household);
dissolve.executeUpdate();
}
} }
int deleteExpired(Connection connection, int retentionMonths) throws SQLException { int deleteExpired(Connection connection, int retentionMonths) throws SQLException {
int deleted = 0; return update(connection, "DELETE FROM ban_evasion_cases WHERE last_seen_at < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths)
for(String sql : List.of( + update(connection, "DELETE FROM ban_evasion_ip_exemptions WHERE valid_until < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths);
"DELETE FROM ban_evasion_cases WHERE last_seen_at < UTC_TIMESTAMP() - INTERVAL ? MONTH",
"DELETE FROM ban_evasion_ip_exemptions WHERE valid_until < UTC_TIMESTAMP() - INTERVAL ? MONTH"
)) {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, retentionMonths);
deleted += statement.executeUpdate();
}
}
return deleted;
}
private long createHousehold(Connection connection, UUID admin, @Nullable String note, Instant at) throws SQLException {
String sql = "INSERT INTO ban_evasion_households (created_at, created_by, note) VALUES (?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setObject(1, toDb(at));
statement.setString(2, admin.toString());
statement.setString(3, note);
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
keys.next();
return keys.getLong(1);
}
}
} }
private List<CaseReason> reasonsOf(Connection connection, long caseId) throws SQLException { private List<CaseReason> reasonsOf(Connection connection, long caseId) throws SQLException {
String sql = "SELECT reason, severity, first_seen_at FROM ban_evasion_case_reasons WHERE case_id = ? ORDER BY first_seen_at, reason"; String sql = "SELECT reason, severity, first_seen_at FROM ban_evasion_case_reasons WHERE case_id = ? ORDER BY first_seen_at, reason";
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> new CaseReason(
statement.setLong(1, caseId); Reason.valueOf(row.getString("reason")),
List<CaseReason> reasons = new ArrayList<>(); Severity.valueOf(row.getString("severity")),
try(ResultSet row = statement.executeQuery()) { fromDb(row, "first_seen_at")
while(row.next()) reasons.add(new CaseReason( ), caseId);
Reason.valueOf(row.getString("reason")),
Severity.valueOf(row.getString("severity")),
fromDb(row, "first_seen_at")
));
}
return reasons;
}
} }
private List<String> sharedNetworks(Connection connection, UUID first, UUID second) throws SQLException { private List<String> sharedNetworks(Connection connection, UUID first, UUID second) throws SQLException {
@@ -566,37 +395,19 @@ final class BanEvasionRepository {
AND EXISTS (SELECT 1 FROM login_attempts b WHERE b.uuid = ? AND b.network = a.network) AND EXISTS (SELECT 1 FROM login_attempts b WHERE b.uuid = ? AND b.network = a.network)
ORDER BY a.network ORDER BY a.network
"""; """;
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> row.getString("network"), first, second);
statement.setString(1, first.toString());
statement.setString(2, second.toString());
List<String> networks = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) networks.add(row.getString("network"));
}
return networks;
}
} }
private List<TimelineEntry> timeline(Connection connection, UUID first, UUID second, int limit) throws SQLException { private List<TimelineEntry> timeline(Connection connection, UUID first, UUID second, int limit) throws SQLException {
String sql = "SELECT at, name, network, stage, result FROM login_attempts WHERE uuid IN (?, ?) ORDER BY at DESC LIMIT ?"; String sql = "SELECT at, name, network, stage, result FROM login_attempts WHERE uuid IN (?, ?) ORDER BY at DESC LIMIT ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) { return list(connection, sql, row -> new TimelineEntry(
statement.setString(1, first.toString()); fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result")
statement.setString(2, second.toString()); ), first, second, limit);
statement.setInt(3, limit);
List<TimelineEntry> entries = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) entries.add(new TimelineEntry(
fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result")
));
}
return entries;
}
} }
private static CaseSummary summaryOf(ResultSet row) throws SQLException { private static CaseSummary summaryOf(ResultSet row) throws SQLException {
UUID first = UUID.fromString(row.getString("uuid_a")); UUID first = uuid(row, "uuid_a");
UUID second = UUID.fromString(row.getString("uuid_b")); UUID second = uuid(row, "uuid_b");
String outcome = row.getString("outcome");
return new CaseSummary( return new CaseSummary(
row.getLong("id"), row.getLong("id"),
first, first,
@@ -604,13 +415,72 @@ final class BanEvasionRepository {
second, second,
nameOr(row.getString("name_b"), second), nameOr(row.getString("name_b"), second),
CaseStatus.valueOf(row.getString("status")), CaseStatus.valueOf(row.getString("status")),
outcome == null ? null : Outcome.valueOf(outcome), outcomeOf(row),
Severity.valueOf(row.getString("severity")), Severity.valueOf(row.getString("severity")),
fromDb(row, "last_seen_at"), fromDb(row, "last_seen_at"),
row.getInt("hits") row.getInt("hits")
); );
} }
private static <T> List<T> list(Connection connection, String sql, RowMapper<T> mapper, Object... params) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
bind(statement, params);
List<T> rows = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) rows.add(mapper.map(row));
}
return rows;
}
}
private static <T> @Nullable T first(Connection connection, String sql, RowMapper<T> mapper, Object... params) throws SQLException {
List<T> rows = list(connection, sql, mapper, params);
return rows.isEmpty() ? null : rows.getFirst();
}
private static int update(Connection connection, String sql, Object... params) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
bind(statement, params);
return statement.executeUpdate();
}
}
/**
* Executes an insert and returns the generated (or, with LAST_INSERT_ID(id), the existing) id.
*/
private static long insert(Connection connection, String sql, Object... params) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
bind(statement, params);
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) throw new SQLException("no id returned by insert");
return keys.getLong(1);
}
}
}
private static void bind(PreparedStatement statement, Object... params) throws SQLException {
for(int i = 0; i < params.length; i++) {
Object value = switch(params[i]) {
case UUID uuid -> uuid.toString();
case Instant instant -> toDb(instant);
case Enum<?> constant -> constant.name();
case null -> null;
default -> params[i];
};
statement.setObject(i + 1, value);
}
}
private static UUID uuid(ResultSet row, String column) throws SQLException {
return UUID.fromString(row.getString(column));
}
private static @Nullable Outcome outcomeOf(ResultSet row) throws SQLException {
String outcome = row.getString("outcome");
return outcome == null ? null : Outcome.valueOf(outcome);
}
private static String nameOr(@Nullable String name, UUID uuid) { private static String nameOr(@Nullable String name, UUID uuid) {
return name != null ? name : uuid.toString(); return name != null ? name : uuid.toString();
} }
@@ -624,7 +494,7 @@ final class BanEvasionRepository {
} }
private static Instant fromDb(ResultSet row, String column) throws SQLException { private static Instant fromDb(ResultSet row, String column) throws SQLException {
return row.getObject(column, LocalDateTime.class).toInstant(ZoneOffset.UTC); return Objects.requireNonNull(nullableFromDb(row, column), column);
} }
private static @Nullable Instant nullableFromDb(ResultSet row, String column) throws SQLException { private static @Nullable Instant nullableFromDb(ResultSet row, String column) throws SQLException {
@@ -41,12 +41,8 @@ final class BanStatusCache {
if(now.isBefore(this.failingUntil)) return BanState.UNKNOWN; if(now.isBefore(this.failingUntil)) return BanState.UNKNOWN;
BanState state = this.lookup.apply(uuid); BanState state = this.lookup.apply(uuid);
if(state == BanState.UNKNOWN) { if(state == BanState.UNKNOWN) this.failingUntil = this.clock.instant().plus(this.backoff);
this.entries.remove(uuid); else this.entries.put(uuid, new Entry(state, now));
this.failingUntil = this.clock.instant().plus(this.backoff);
} else {
this.entries.put(uuid, new Entry(state, now));
}
return state; return state;
} }
@@ -11,7 +11,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
/** /**
* Rules of the ban evasion detection, free of Bukkit and database access. * Rules of the ban evasion detection, free of Bukkit and database access.
@@ -61,7 +60,7 @@ final class CaseEvaluator {
Long household = input.households().get(current.uuid()); Long household = input.households().get(current.uuid());
boolean sameHousehold = household != null && household.equals(input.households().get(partner)); boolean sameHousehold = household != null && household.equals(input.households().get(partner));
if(sameHousehold && householdState(input, household) != BanState.BANNED) return null; if(sameHousehold && !householdBanned(input, household)) return null;
Map<Reason, Severity> reasons = new EnumMap<>(Reason.class); Map<Reason, Severity> reasons = new EnumMap<>(Reason.class);
reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL); reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL);
@@ -91,12 +90,8 @@ final class CaseEvaluator {
return input.banStates().getOrDefault(uuid, BanState.UNKNOWN); return input.banStates().getOrDefault(uuid, BanState.UNKNOWN);
} }
private static BanState householdState(Input input, long household) { private static boolean householdBanned(Input input, long household) {
Set<BanState> states = input.householdMembers().getOrDefault(household, Set.of()).stream() return input.householdMembers().getOrDefault(household, Set.of()).stream()
.map(member -> banState(input, member)) .anyMatch(member -> banState(input, member) == BanState.BANNED);
.collect(Collectors.toSet());
if(states.contains(BanState.BANNED)) return BanState.BANNED;
if(states.contains(BanState.UNKNOWN)) return BanState.UNKNOWN;
return BanState.NOT_BANNED;
} }
} }