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
+415 -491

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.database.Database;
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.server.Floodgate;
import net.kyori.adventure.text.Component;
@@ -126,8 +127,8 @@ public class LoginLog extends Appliance {
id = keys.getLong(1);
}
}
Bukkit.getPluginManager().callEvent(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt(
id, attempt.at(), attempt.uuid(), attempt.name(), attempt.ip(), network, attempt.stage(), attempt.result()
SpawnEvent.call(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt(
id, attempt.at(), attempt.uuid(), attempt.name(), network, attempt.stage(), attempt.result()
)));
});
}
@@ -29,7 +29,6 @@ public class LoginAttemptRecordedEvent extends Event {
Instant at,
UUID uuid,
String name,
String ip,
@Nullable String network,
String stage,
String result
@@ -25,7 +25,6 @@ class AccountLinksCommand extends ApplianceCommand.PlayerChecked<BanEvasion> {
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
Player player = this.getPlayer();
if(!player.hasPermission(BanEvasion.ADMIN_PERMISSION)) return;
if(Floodgate.isBedrock(player)) throw new Error("Nur für Java-Clients");
if(!this.getAppliance().isActive()) throw new Error("Keine Datenbank konfiguriert");
@@ -53,6 +53,7 @@ final class AdminNotifier {
}
private Component format(Notice notice) {
long caseId = notice.caseId();
NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY;
String reasons = notice.reasons().stream()
.filter(reason -> !reason.informational)
@@ -66,7 +67,7 @@ final class AdminNotifier {
.append(account(notice.secondName(), notice.secondBanned()))
.append(Component.text(" – " + reasons + " ", color))
.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();
}
@@ -1,5 +1,7 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import java.time.Instant;
import java.util.UUID;
@@ -7,7 +9,14 @@ import java.util.UUID;
* 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) {
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() {
return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result);
@@ -25,20 +25,18 @@ import java.sql.Connection;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import java.util.logging.Level;
@@ -53,7 +51,6 @@ public class BanEvasion extends Appliance {
static final String ADMIN_PERMISSION = "admin";
private static final long TICKS_PER_MINUTE = 20L * 60;
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_BACKOFF = Duration.ofSeconds(30);
@@ -62,14 +59,16 @@ public class BanEvasion extends Appliance {
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 OnlineNetworks onlineNetworks = new OnlineNetworks();
private Database database;
private ExecutorService worker;
private ExecutorService httpExecutor;
private BukkitTask retentionTask;
private BanStatusCache banStatus;
private AdminNotifier notifier;
@@ -95,11 +94,6 @@ public class BanEvasion extends Appliance {
this.rejectionWindow = Duration.ofHours(config.getInt("rejectionWindowHours", 24));
this.retentionMonths = config.getInt("retentionMonths", 16);
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 -> {
Thread thread = new Thread(runnable, "Spawn-BanEvasion");
thread.setDaemon(true);
@@ -116,7 +110,6 @@ public class BanEvasion extends Appliance {
this.active = false;
if(this.retentionTask != null) this.retentionTask.cancel();
if(this.worker != null) this.worker.shutdownNow();
if(this.httpExecutor != null) this.httpExecutor.shutdownNow();
}
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.
*/
<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).orTimeout(QUERY_TIMEOUT.toSeconds(), TimeUnit.SECONDS).join();
return this.database.query(query).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.
*/
void onAttemptRecorded(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
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());
Set<UUID> online = this.onlineNetworks.onlineOn(recorded.network());
Attempt attempt = Attempt.of(recorded);
Set<UUID> online = this.onlineNetworks.onlineOn(attempt.network());
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());
this.onlineNetworks.joined(player.getUniqueId(), network);
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) {
@@ -198,35 +207,61 @@ public class BanEvasion extends Appliance {
private void evaluate(Attempt attempt, Set<UUID> online) {
try {
if(this.query(connection -> this.repository.isExempt(connection, attempt.network(), attempt.at()))) return;
List<Attempt> partners = this.query(connection -> this.repository.partnerAttempts(connection, attempt, this.window));
if(partners.isEmpty()) return;
Context context = this.query(connection -> this.loadContext(connection, attempt));
if(context == null) return;
Set<UUID> involved = new HashSet<>();
involved.add(attempt.uuid());
partners.forEach(partner -> involved.add(partner.uuid()));
Households households = this.query(connection -> {
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);
Set<UUID> banStateNeeded = new HashSet<>();
banStateNeeded.add(attempt.uuid());
context.partners().forEach(partner -> banStateNeeded.add(partner.uuid()));
context.householdMembers().values().forEach(banStateNeeded::addAll);
Map<UUID, BanState> banStates = this.banStatus.getAll(banStateNeeded);
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
));
if(findings.isEmpty()) return;
// 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.query(connection -> this.applyFinding(connection, finding, attempt, banStates));
if(notice != null) this.notifier.announce(notice);
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) {
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.Result decision = CaseDecision.decide(existing, finding);
long caseId;
@@ -245,9 +280,9 @@ public class BanEvasion extends Appliance {
if(!decision.announce()) return null;
return new AdminNotifier.Notice(
caseId,
this.repository.nameOf(connection, finding.first()),
names.get(finding.first()),
banStates.get(finding.first()) == BanState.BANNED,
this.repository.nameOf(connection, finding.second()),
names.get(finding.second()),
banStates.get(finding.second()) == BanState.BANNED,
finding.severity(),
finding.reasons().keySet(),
@@ -255,32 +290,17 @@ 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) {
try {
return CompletableFuture.supplyAsync(() -> this.fetchBanState(uuid), this.httpExecutor)
.get(BAN_LOOKUP_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
} catch(TimeoutException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: website did not answer in time");
return BanState.UNKNOWN;
} catch(ExecutionException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getCause().getMessage());
return BanState.UNKNOWN;
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
return BanState.UNKNOWN;
}
}
private BanState fetchBanState(UUID uuid) {
ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid);
ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid, BAN_LOOKUP_TIMEOUT);
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;
} catch(RuntimeException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getMessage());
return BanState.UNKNOWN;
}
}
private void deleteExpired() {
@@ -20,6 +20,8 @@ import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
@@ -33,7 +35,8 @@ import java.util.function.Consumer;
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 {
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 BUTTON_WIDTH = 310;
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 String NOTE_KEY = "note";
private static final String NETWORK_KEY = "network";
@@ -54,30 +60,33 @@ final class BanEvasionDialogs {
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 BanEvasionRepository repository;
BanEvasionDialogs(BanEvasion appliance) {
this.appliance = appliance;
this.repository = appliance.repository();
}
/**
* Click event for chat messages that only works for admins.
*/
static ClickEvent<?> adminClick(Consumer<Player> action) {
return ClickEvent.callback(
audience -> {
return ClickEvent.callback(audience -> {
if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player);
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
}, CHAT_CALLBACK);
}
void openList(Player admin, CaseStatus status) {
this.appliance.loadFor(admin,
() -> this.appliance.query(connection -> this.appliance.repository().listCases(connection, status, LIST_LIMIT)),
cases -> {
this.appliance.queryFor(admin, connection -> this.repository.listCases(connection, status, LIST_LIMIT), cases -> {
List<ActionButton> buttons = new ArrayList<>();
cases.forEach(summary -> buttons.add(button(caseLabel(summary), (player, response) -> this.openCase(player, summary.id(), false))));
cases.forEach(summary -> {
long caseId = summary.id();
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));
@@ -92,11 +101,16 @@ final class BanEvasionDialogs {
void openCase(Player admin, long caseId, boolean fullNetworks) {
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) {
}
Loaded loaded = this.appliance.query(connection -> {
BanEvasionRepository.CaseDetails details = this.repository.loadCase(connection, caseId, TIMELINE_LIMIT);
if(details == null) return null;
List<UUID> accounts = List.of(details.summary().first(), details.summary().second());
Map<UUID, Long> households = this.appliance.query(connection -> this.appliance.repository().householdsOf(connection, accounts));
return new CaseView(details, this.appliance.banStates(accounts), households);
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 -> {
if(view == null) {
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) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().uuidByName(connection, name)), uuid -> {
if(uuid == null) {
admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED));
return;
}
this.openLinks(admin, uuid);
this.appliance.queryFor(admin, connection -> {
BanEvasionRepository.Member account = this.repository.accountByName(connection, name);
return account == null ? null : this.loadLinks(connection, account.uuid(), account.name());
}, view -> {
if(view == null) admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED));
else this.showLinks(admin, view);
});
}
void openLinks(Player admin, UUID uuid) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> new LinksView(
this.appliance.repository().nameOf(connection, uuid),
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);
});
this.appliance.queryFor(admin, connection -> this.loadLinks(connection, uuid, this.repository.nameOf(connection, uuid)),
view -> this.showLinks(admin, view));
}
void openNewExemption(Player admin, List<String> networks) {
@@ -150,11 +142,11 @@ final class BanEvasionDialogs {
List<DialogInput> inputs = List.of(
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.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(
button(Component.text("Ausnahme anlegen"), (player, response) -> this.createExemption(player, networks, response)),
button("Abbrechen", player -> this.openList(player, CaseStatus.OPEN))
inputButton("Ausnahme anlegen", (player, response) -> this.createExemption(player, networks, response)),
this.backToList("Abbrechen")
);
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."),
@@ -163,7 +155,7 @@ final class BanEvasionDialogs {
void openExemptions(Player admin) {
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()
? "Keine aktiven IP-Ausnahmen."
: exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben.");
@@ -171,45 +163,32 @@ final class BanEvasionDialogs {
exemptions.forEach(exemption -> buttons.add(button(
String.format("%s bis %s%s", Networks.mask(exemption.network()), TIME.format(exemption.validUntil()),
exemption.note() == null ? "" : " · " + exemption.note()),
player -> this.openExemption(player, exemption))));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN)));
player -> this.showExemption(player, exemption))));
buttons.add(this.backToList("Zur Fallliste"));
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) {
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.";
if(households.size() > LIST_LIMIT)
text += " Gezeigt werden die neuesten " + LIST_LIMIT + " – ältere über /accountLinks <spieler> finden.";
List<ActionButton> buttons = new ArrayList<>();
households.stream().limit(LIST_LIMIT).forEach(household -> buttons.add(button(
"#" + household.id() + ": " + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")),
player -> this.openHousehold(player, household.id()))));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN)));
households.stream().limit(LIST_LIMIT).forEach(household -> {
long householdId = household.id();
buttons.add(button(
"#" + 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);
});
}
private record HouseholdView(BanEvasionRepository.Household household, Map<UUID, BanState> banStates) {
}
void openHousehold(Player admin, long householdId) {
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;
return new HouseholdView(household, this.appliance.banStates(household.members().stream().map(BanEvasionRepository.Member::uuid).toList()));
}, view -> {
@@ -220,8 +199,7 @@ final class BanEvasionDialogs {
}
BanEvasionRepository.Household household = view.household();
List<Component> lines = new ArrayList<>();
lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von "
+ (household.createdByName() == null ? "?" : household.createdByName())));
lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von " + orUnknown(household.createdByName())));
lines.add(Component.text("Notiz: " + (household.note() == null ? "–" : household.note())));
lines.add(Component.empty());
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) {
BanEvasionRepository.CaseDetails details = view.details();
BanEvasionRepository.CaseSummary summary = details.summary();
@@ -262,40 +285,41 @@ final class BanEvasionDialogs {
if(details.closedAt() != null) {
lines.add(Component.empty());
lines.add(Component.text(String.format("Zuletzt geschlossen %s von %s%s",
TIME.format(details.closedAt()),
details.closedByName() == null ? "?" : details.closedByName(),
TIME.format(details.closedAt()), orUnknown(details.closedByName()),
details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY));
}
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<ActionButton> buttons = new ArrayList<>();
if(summary.status() == CaseStatus.OPEN) {
inputs.add(DialogInput.text(NOTE_KEY, Component.text("Notiz (optional)")).width(BODY_WIDTH).maxLength(255).build());
if(status == CaseStatus.OPEN) {
inputs.add(noteInput("Notiz (optional)"));
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("Accounts zu " + summary.firstName(), player -> this.openLinks(player, summary.first())));
buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, summary.second())));
if(!details.sharedNetworks().isEmpty())
buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, details.sharedNetworks())));
buttons.add(button("Zurück zur Liste", player -> this.openList(player, summary.status())));
buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, first)));
buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, second)));
if(!shared.isEmpty()) buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, shared)));
buttons.add(button("Zurück zur Liste", player -> this.openList(player, status)));
show(admin, "Verdachtsfall #" + caseId, lines(lines), inputs, buttons);
}
private void close(Player admin, BanEvasionRepository.CaseSummary summary, Outcome outcome, @Nullable String rawNote) {
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
private void close(Player admin, long caseId, UUID first, UUID second, Outcome outcome, @Nullable String rawNote) {
String note = note(rawNote);
UUID adminUuid = admin.getUniqueId();
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().closeCase(connection, summary.id(), outcome, adminUuid, note, now);
if(outcome == Outcome.HOUSEHOLD)
this.appliance.repository().linkHousehold(connection, List.of(summary.first(), summary.second()), adminUuid, note, now);
return true;
}), done -> {
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.appliance.runFor(admin, connection -> {
this.repository.closeCase(connection, caseId, outcome, adminUuid, note, now);
if(outcome == Outcome.HOUSEHOLD) this.repository.linkHousehold(connection, List.of(first, second), adminUuid, note, now);
}, () -> {
Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", caseId, outcome, admin.getName()));
admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", caseId, outcome.label), NamedTextColor.GRAY));
this.openList(admin, CaseStatus.OPEN);
});
}
@@ -307,41 +331,35 @@ final class BanEvasionDialogs {
admin.sendMessage(Component.text("Ungültige Eingabe.", NamedTextColor.RED));
return;
}
String rawNote = response.getText(NOTE_KEY);
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
String note = note(response.getText(NOTE_KEY));
Instant from = Instant.now();
Instant until = from.plus(Duration.ofHours(Math.round(hours)));
UUID adminUuid = admin.getUniqueId();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().addExemption(connection, network, from, until, adminUuid, note);
return true;
}), done -> {
this.appliance.runFor(admin, connection -> this.repository.addExemption(connection, network, from, until, adminUuid, note), () -> {
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));
});
}
private void endExemption(Player admin, BanEvasionRepository.Exemption exemption) {
private void endExemption(Player admin, long exemptionId) {
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().endExemption(connection, exemption.id(), now);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemption.id(), admin.getName()));
this.appliance.runFor(admin, connection -> this.repository.endExemption(connection, exemptionId, now), () -> {
Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemptionId, admin.getName()));
this.openExemptions(admin);
});
}
private void removeMember(Player admin, long householdId, BanEvasionRepository.Member member) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().removeFromHousehold(connection, member.uuid());
return true;
}), done -> {
this.appliance.runFor(admin, connection -> this.repository.removeFromHousehold(connection, member.uuid()), () -> {
Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName()));
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) {
NamedTextColor color = summary.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.WHITE;
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(", "));
}
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) {
return Component.join(JoinConfiguration.newlines(), lines);
}
private static DialogAction adminAction(BiConsumer<Player, DialogResponseView> action) {
return DialogAction.customClick(
(response, audience) -> {
return DialogAction.customClick((response, audience) -> {
if(!(audience instanceof Player player)) return;
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(player.isOnline() && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player, response);
});
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
}, DIALOG_CALLBACK);
}
private static ActionButton button(Component label, BiConsumer<Player, DialogResponseView> action) {
return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction(action)).build();
private static ActionButton button(Component label, Consumer<Player> action) {
return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction((player, response) -> action.accept(player))).build();
}
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) {
@@ -20,6 +20,7 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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) {
}
@FunctionalInterface
private interface RowMapper<T> {
T map(ResultSet row) throws SQLException;
}
/**
* 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 {
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)) {
statement.setString(1, network);
statement.setObject(2, toDb(at));
try(ResultSet result = statement.executeQuery()) {
return result.next();
}
}
return first(connection, sql, row -> true, network, at) != null;
}
List<Attempt> partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException {
@@ -103,73 +103,43 @@ final class BanEvasionRepository {
)
ORDER BY a.at
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, current.network());
statement.setString(2, current.uuid().toString());
statement.setObject(3, toDb(current.at().minus(window)));
statement.setObject(4, toDb(current.at()));
List<Attempt> attempts = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) attempts.add(new Attempt(
return list(connection, sql, row -> new Attempt(
row.getLong("id"),
fromDb(row, "at"),
UUID.fromString(row.getString("uuid")),
uuid(row, "uuid"),
row.getString("name"),
row.getString("network"),
row.getString("stage"),
row.getString("result")
));
}
return attempts;
}
), current.network(), current.uuid(), current.at().minus(window), current.at());
}
Map<UUID, Long> householdsOf(Connection connection, Collection<UUID> uuids) throws SQLException {
if(uuids.isEmpty()) return Map.of();
String sql = "SELECT uuid, household_id FROM ban_evasion_household_members WHERE uuid IN (" + placeholders(uuids.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for(UUID uuid : uuids) statement.setString(index++, uuid.toString());
Map<UUID, Long> households = new HashMap<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) households.put(UUID.fromString(row.getString("uuid")), row.getLong("household_id"));
}
list(connection, sql, row -> Map.entry(uuid(row, "uuid"), row.getLong("household_id")), uuids.toArray())
.forEach(entry -> households.put(entry.getKey(), entry.getValue()));
return households;
}
}
Map<Long, Set<UUID>> householdMembers(Connection connection, Collection<Long> householdIds) throws SQLException {
if(householdIds.isEmpty()) return Map.of();
String sql = "SELECT household_id, uuid FROM ban_evasion_household_members WHERE household_id IN (" + placeholders(householdIds.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for(long id : householdIds) statement.setLong(index++, id);
Map<Long, Set<UUID>> members = new HashMap<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next())
members.computeIfAbsent(row.getLong("household_id"), id -> new HashSet<>()).add(UUID.fromString(row.getString("uuid")));
}
list(connection, sql, row -> Map.entry(row.getLong("household_id"), uuid(row, "uuid")), householdIds.toArray())
.forEach(entry -> members.computeIfAbsent(entry.getKey(), id -> new HashSet<>()).add(entry.getValue()));
return members;
}
}
@Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException {
long id;
CaseStatus status;
String outcome;
try(PreparedStatement statement = connection.prepareStatement("SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?")) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
try(ResultSet row = statement.executeQuery()) {
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);
String sql = "SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?";
CaseDecision.ExistingCase found = first(connection, sql, row -> new CaseDecision.ExistingCase(
row.getLong("id"), CaseStatus.valueOf(row.getString("status")), outcomeOf(row), EnumSet.noneOf(Reason.class)
), first, second);
if(found == null) return null;
this.reasonsOf(connection, found.id()).forEach(reason -> found.reasons().add(reason.reason()));
return found;
}
/**
@@ -181,19 +151,7 @@ final class BanEvasionRepository {
VALUES (?, ?, 'OPEN', ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
""";
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
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);
}
}
return insert(connection, sql, finding.first(), finding.second(), finding.severity(), finding.banStatusUnknown(), at, at);
}
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)
WHERE id = ?
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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();
}
update(connection, sql, at, finding.severity(), finding.banStatusUnknown(), reopen, reopen, caseId);
}
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 (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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();
}
for(Reason reason : which) update(connection, sql, caseId, reason, reasons.get(reason), at, attemptId);
}
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
LIMIT ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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;
}
return list(connection, sql, BanEvasionRepository::summaryOf, status, limit);
}
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'";
try(PreparedStatement statement = connection.prepareStatement(sql); ResultSet row = statement.executeQuery()) {
row.next();
return new OpenCount(row.getInt("total"), row.getInt("high"));
}
return Objects.requireNonNull(first(connection, sql, row -> new OpenCount(row.getInt("total"), row.getInt("high"))));
}
@Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException {
@@ -268,42 +196,29 @@ final class BanEvasionRepository {
FROM ban_evasion_cases c
WHERE c.id = ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"), NAME_OF.formatted("c.closed_by"));
CaseSummary summary;
boolean banStatusUnknown;
Instant createdAt;
Instant closedAt;
String closedByName;
String note;
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, caseId);
try(ResultSet row = statement.executeQuery()) {
if(!row.next()) return null;
summary = summaryOf(row);
banStatusUnknown = row.getBoolean("ban_status_unknown");
createdAt = fromDb(row, "created_at");
closedAt = nullableFromDb(row, "closed_at");
closedByName = row.getString("closed_by_name");
note = row.getString("note");
}
}
CaseDetails found = first(connection, sql, row -> new CaseDetails(
summaryOf(row),
row.getBoolean("ban_status_unknown"),
fromDb(row, "created_at"),
nullableFromDb(row, "closed_at"),
row.getString("closed_by_name"),
row.getString("note"),
List.of(), List.of(), List.of()
), caseId);
if(found == null) return null;
UUID first = found.summary().first();
UUID second = found.summary().second();
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.sharedNetworks(connection, summary.first(), summary.second()),
this.timeline(connection, summary.first(), summary.second(), timelineLimit)
this.sharedNetworks(connection, first, second),
this.timeline(connection, first, second, timelineLimit)
);
}
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 = ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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();
}
update(connection, sql, outcome, at, admin, note, caseId);
}
/**
@@ -322,43 +237,30 @@ final class BanEvasionRepository {
ORDER BY last_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("o.uuid"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
for(int index = 1; index <= 4; index++) statement.setString(index, uuid.toString());
statement.setInt(5, limit);
List<Link> links = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) {
UUID other = UUID.fromString(row.getString("uuid"));
String name = nameOr(row.getString("name"), other);
int shared = row.getInt("shared");
Instant firstAt = fromDb(row, "first_at");
Instant lastAt = fromDb(row, "last_at");
long caseId = row.getLong("case_id");
boolean noCase = row.wasNull();
links.add(new Link(other, name, shared, firstAt, lastAt, noCase ? null : caseId));
}
}
return links;
}
return list(connection, sql, row -> {
UUID other = uuid(row, "uuid");
return new Link(
other,
nameOr(row.getString("name"), other),
row.getInt("shared"),
fromDb(row, "first_at"),
fromDb(row, "last_at"),
row.getObject("case_id", Long.class)
);
}, uuid, uuid, uuid, uuid, limit);
}
@Nullable UUID uuidByName(Connection connection, String name) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("SELECT uuid FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1")) {
statement.setString(1, name);
try(ResultSet row = statement.executeQuery()) {
return row.next() ? UUID.fromString(row.getString("uuid")) : null;
}
}
/**
* Latest account that used the name (case-insensitive), with its name as stored.
*/
@Nullable Member accountByName(Connection connection, String name) throws SQLException {
String sql = "SELECT uuid, name FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1";
return first(connection, sql, row -> new Member(uuid(row, "uuid"), row.getString("name")), name);
}
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")) {
statement.setString(1, uuid.toString());
try(ResultSet row = statement.executeQuery()) {
return row.next() ? row.getString("name") : uuid.toString();
}
}
String name = first(connection, "SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1", row -> row.getString("name"), uuid);
return nameOr(name, uuid);
}
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
GROUP BY network ORDER BY last_at DESC LIMIT ?
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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;
}
return list(connection, sql, row -> row.getString("network"), uuid, limit);
}
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 (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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();
}
update(connection, sql, network, from, until, admin, note);
}
List<Exemption> activeExemptions(Connection connection, Instant now) throws SQLException {
@@ -397,30 +284,18 @@ final class BanEvasionRepository {
WHERE e.valid_until >= ?
ORDER BY e.valid_until
""".formatted(NAME_OF.formatted("e.created_by"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setObject(1, toDb(now));
List<Exemption> exemptions = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) exemptions.add(new Exemption(
return list(connection, sql, row -> new Exemption(
row.getLong("id"),
row.getString("network"),
fromDb(row, "valid_from"),
fromDb(row, "valid_until"),
row.getString("created_by_name"),
row.getString("note")
));
}
return exemptions;
}
), now);
}
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 > ?")) {
statement.setObject(1, toDb(now));
statement.setLong(2, id);
statement.setObject(3, toDb(now));
statement.executeUpdate();
}
update(connection, "UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?", now, id, now);
}
/**
@@ -431,28 +306,22 @@ final class BanEvasionRepository {
connection.setAutoCommit(false);
try {
List<Long> existing = this.householdsOf(connection, uuids).values().stream().distinct().sorted().toList();
long target = existing.isEmpty() ? this.createHousehold(connection, admin, note, at) : existing.getFirst();
for(long other : existing.subList(Math.min(1, existing.size()), existing.size())) {
try(PreparedStatement move = connection.prepareStatement("UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id = ?")) {
move.setLong(1, target);
move.setLong(2, other);
move.executeUpdate();
}
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_households WHERE id = ?")) {
delete.setLong(1, other);
delete.executeUpdate();
}
}
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();
long target = existing.isEmpty()
? insert(connection, "INSERT INTO ban_evasion_households (created_at, created_by, note) VALUES (?, ?, ?)", at, admin, note)
: existing.getFirst();
if(existing.size() > 1) {
Object[] merged = existing.subList(1, existing.size()).toArray();
String in = placeholders(merged.length);
Object[] moveParams = new Object[merged.length + 1];
moveParams[0] = target;
System.arraycopy(merged, 0, moveParams, 1, merged.length);
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);
}
for(UUID uuid : uuids)
update(connection, "INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)", uuid, target);
connection.commit();
} catch(SQLException e) {
} catch(SQLException | RuntimeException e) {
connection.rollback();
throw e;
} finally {
@@ -464,7 +333,7 @@ final class BanEvasionRepository {
* All households, newest first.
*/
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 {
@@ -472,7 +341,7 @@ final class BanEvasionRepository {
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 = """
SELECT h.id, h.created_at, h.note, %s AS created_by_name, m.uuid, %s AS name
FROM ban_evasion_households h
@@ -481,19 +350,15 @@ final class BanEvasionRepository {
ORDER BY h.id DESC, name
""".formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where);
Map<Long, Household> households = new LinkedHashMap<>();
try(PreparedStatement statement = connection.prepareStatement(sql)) {
if(id != null) statement.setLong(1, id);
try(ResultSet row = statement.executeQuery()) {
while(row.next()) {
Instant createdAt = fromDb(row, "created_at");
String createdByName = row.getString("created_by_name");
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)));
}
}
list(connection, sql, row -> {
UUID uuid = uuid(row, "uuid");
Household household = households.get(row.getLong("id"));
if(household == null) {
household = new Household(row.getLong("id"), fromDb(row, "created_at"), row.getString("created_by_name"), row.getString("note"), new ArrayList<>());
households.put(household.id(), household);
}
return household.members().add(new Member(uuid, nameOr(row.getString("name"), uuid)));
}, params);
return List.copyOf(households.values());
}
@@ -503,60 +368,24 @@ final class BanEvasionRepository {
void removeFromHousehold(Connection connection, UUID uuid) throws SQLException {
Long household = this.householdsOf(connection, List.of(uuid)).get(uuid);
if(household == null) return;
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_household_members WHERE uuid = ?")) {
delete.setString(1, uuid.toString());
delete.executeUpdate();
}
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();
}
update(connection, "DELETE FROM ban_evasion_household_members WHERE uuid = ?", uuid);
update(connection,
"DELETE FROM ban_evasion_households WHERE id = ? AND (SELECT COUNT(*) FROM ban_evasion_household_members WHERE household_id = ?) < 2",
household, household);
}
int deleteExpired(Connection connection, int retentionMonths) throws SQLException {
int deleted = 0;
for(String sql : List.of(
"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);
}
}
return update(connection, "DELETE FROM ban_evasion_cases WHERE last_seen_at < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths)
+ update(connection, "DELETE FROM ban_evasion_ip_exemptions WHERE valid_until < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths);
}
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";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, caseId);
List<CaseReason> reasons = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) reasons.add(new CaseReason(
return list(connection, sql, row -> new CaseReason(
Reason.valueOf(row.getString("reason")),
Severity.valueOf(row.getString("severity")),
fromDb(row, "first_seen_at")
));
}
return reasons;
}
), caseId);
}
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)
ORDER BY a.network
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
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;
}
return list(connection, sql, row -> row.getString("network"), first, second);
}
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 ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
statement.setInt(3, limit);
List<TimelineEntry> entries = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) entries.add(new TimelineEntry(
return list(connection, sql, row -> new TimelineEntry(
fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result")
));
}
return entries;
}
), first, second, limit);
}
private static CaseSummary summaryOf(ResultSet row) throws SQLException {
UUID first = UUID.fromString(row.getString("uuid_a"));
UUID second = UUID.fromString(row.getString("uuid_b"));
String outcome = row.getString("outcome");
UUID first = uuid(row, "uuid_a");
UUID second = uuid(row, "uuid_b");
return new CaseSummary(
row.getLong("id"),
first,
@@ -604,13 +415,72 @@ final class BanEvasionRepository {
second,
nameOr(row.getString("name_b"), second),
CaseStatus.valueOf(row.getString("status")),
outcome == null ? null : Outcome.valueOf(outcome),
outcomeOf(row),
Severity.valueOf(row.getString("severity")),
fromDb(row, "last_seen_at"),
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) {
return name != null ? name : uuid.toString();
}
@@ -624,7 +494,7 @@ final class BanEvasionRepository {
}
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 {
@@ -41,12 +41,8 @@ final class BanStatusCache {
if(now.isBefore(this.failingUntil)) return BanState.UNKNOWN;
BanState state = this.lookup.apply(uuid);
if(state == BanState.UNKNOWN) {
this.entries.remove(uuid);
this.failingUntil = this.clock.instant().plus(this.backoff);
} else {
this.entries.put(uuid, new Entry(state, now));
}
if(state == BanState.UNKNOWN) this.failingUntil = this.clock.instant().plus(this.backoff);
else this.entries.put(uuid, new Entry(state, now));
return state;
}
@@ -11,7 +11,6 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 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());
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);
reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL);
@@ -91,12 +90,8 @@ final class CaseEvaluator {
return input.banStates().getOrDefault(uuid, BanState.UNKNOWN);
}
private static BanState householdState(Input input, long household) {
Set<BanState> states = input.householdMembers().getOrDefault(household, Set.of()).stream()
.map(member -> banState(input, member))
.collect(Collectors.toSet());
if(states.contains(BanState.BANNED)) return BanState.BANNED;
if(states.contains(BanState.UNKNOWN)) return BanState.UNKNOWN;
return BanState.NOT_BANNED;
private static boolean householdBanned(Input input, long household) {
return input.householdMembers().getOrDefault(household, Set.of()).stream()
.anyMatch(member -> banState(input, member) == BanState.BANNED);
}
}