Add the Tools > Ban List window
Reimplements the official client's ban list: a searchable table of the server's bans with Add / Remove / Edit, an own-ban filter and highlight, choosable columns, and the Add/Edit dialog whose IP and name patterns can be read as IPv4/IPv6 wildcards, fixed strings or regular expressions (translated to the regexes banadd takes exactly as the official client does). Editing is add-then-delete, since the protocol has no banedit. The ban list is read from the notifybanlist burst that answers banlist, like channelpermlist. The menu item is only shown where the server grants b_client_ban_list. Also fixes ServerModel numbering permissions from 0 instead of 1, which had every selfPermissionValue() lookup reading the neighbouring permission — including the group-assignment menus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* The reasons the ban dialogs offer as presets, stored alongside the settings file.
|
||||
* Frontend-agnostic: no UI dependencies.
|
||||
*/
|
||||
public final class BanReasonPresets {
|
||||
|
||||
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||
private static final File FILE = new File(DIR, "banreasons.properties");
|
||||
|
||||
private final List<String> entries = new ArrayList<>();
|
||||
|
||||
public List<String> all() {
|
||||
return Collections.unmodifiableList(entries);
|
||||
}
|
||||
|
||||
/** Remembers a reason, moving it to the front if it is already known. */
|
||||
public void add(String reason) {
|
||||
if (reason == null || reason.isBlank()) return;
|
||||
entries.remove(reason.trim());
|
||||
entries.add(0, reason.trim());
|
||||
}
|
||||
|
||||
public void remove(String reason) {
|
||||
entries.remove(reason);
|
||||
}
|
||||
|
||||
public static BanReasonPresets load() {
|
||||
BanReasonPresets presets = new BanReasonPresets();
|
||||
if (!FILE.isFile()) return presets;
|
||||
Properties p = new Properties();
|
||||
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||
p.load(in);
|
||||
} catch (Exception e) {
|
||||
return presets;
|
||||
}
|
||||
int count = parseInt(p.getProperty("count"), 0);
|
||||
for (int i = 0; i < count; i++) {
|
||||
String text = p.getProperty("reason." + i);
|
||||
if (text != null && !text.isBlank()) presets.entries.add(text);
|
||||
}
|
||||
return presets;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
Properties p = new Properties();
|
||||
p.setProperty("count", Integer.toString(entries.size()));
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
p.setProperty("reason." + i, entries.get(i));
|
||||
}
|
||||
try {
|
||||
if (!DIR.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
DIR.mkdirs();
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||
p.store(out, "TS3J client ban reason presets");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(String v, int def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Integer.parseInt(v.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,10 @@ public final class Settings {
|
||||
public boolean showMasterVolumeSlider = true;
|
||||
/** Share of the tree/info column the tree takes, i.e. where their divider sits. */
|
||||
public double infoSplitRatio = 0.68;
|
||||
/** Whether the ban list shows the bans we created ourselves in bold. */
|
||||
public boolean banHighlightOwn = true;
|
||||
/** The ban list columns the user shows, comma-separated column names; empty for the default set. */
|
||||
public String banListColumns = "";
|
||||
|
||||
/** Which actions make a sound, and which ones are important enough to survive muting. */
|
||||
public final NotificationSettings notifications = new NotificationSettings();
|
||||
@@ -233,6 +237,8 @@ public final class Settings {
|
||||
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
|
||||
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
|
||||
infoSplitRatio = parseD(props.getProperty("infoSplitRatio"), infoSplitRatio);
|
||||
banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn);
|
||||
banListColumns = props.getProperty("banListColumns", banListColumns);
|
||||
notifications.load(props);
|
||||
}
|
||||
|
||||
@@ -276,6 +282,8 @@ public final class Settings {
|
||||
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
|
||||
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
|
||||
props.setProperty("infoSplitRatio", Double.toString(infoSplitRatio));
|
||||
props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn));
|
||||
props.setProperty("banListColumns", banListColumns);
|
||||
notifications.store(props);
|
||||
}
|
||||
|
||||
|
||||
115
ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java
Normal file
115
ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.command.CommandException;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.ts3client.net.ban.BanEntry;
|
||||
import com.ts3client.net.ban.BanRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The server's ban list: reading it and adding, replacing and deleting entries.
|
||||
*
|
||||
* <p>Split out of {@link TeamspeakConnection} like {@link ChannelAdmin}, and for the same
|
||||
* reason: it is a self-contained request/response corner of the protocol that only the
|
||||
* ban list window uses. Every call here blocks and must run off the UI thread; the
|
||||
* connection's public wrappers do that.
|
||||
*/
|
||||
final class BanAdmin {
|
||||
|
||||
/** What {@code banlist} answers when there are no bans at all. */
|
||||
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
/**
|
||||
* The {@code banlist} reply in progress. The server answers the command with a burst
|
||||
* of {@code notifybanlist} events rather than a command response, so one request may
|
||||
* be in flight at a time and its entries are collected here until the command itself
|
||||
* completes.
|
||||
*/
|
||||
private volatile List<BanEntry> listing;
|
||||
|
||||
BanAdmin(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
/** Reads every ban on the server. */
|
||||
List<BanEntry> list() throws Exception {
|
||||
List<BanEntry> entries = Collections.synchronizedList(new ArrayList<>());
|
||||
listing = entries;
|
||||
try {
|
||||
conn.socket().executeCommand(new SingleCommand("banlist", ProtocolRole.CLIENT)).complete();
|
||||
// The entries were queued on the event thread before the command's own reply
|
||||
// was read, so draining that thread is what guarantees they have all landed.
|
||||
conn.awaitEventsProcessed();
|
||||
} catch (CommandException e) {
|
||||
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
|
||||
} finally {
|
||||
listing = null;
|
||||
}
|
||||
return new ArrayList<>(entries);
|
||||
}
|
||||
|
||||
/** Collects one {@code notifybanlist} entry for the request in flight. */
|
||||
void onListEntry(UnknownTeamspeakEvent e) {
|
||||
List<BanEntry> entries = listing;
|
||||
if (entries == null) return;
|
||||
BanEntry entry = BanEntry.from(e.getMap());
|
||||
if (entry.id >= 0) entries.add(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a ban.
|
||||
*
|
||||
* @return the new ban's id, or -1 where the server did not name it
|
||||
*/
|
||||
int add(BanRule rule) throws Exception {
|
||||
if (!rule.hasTarget()) throw new IllegalArgumentException("A ban needs something to match");
|
||||
SingleCommand cmd = new SingleCommand("banadd", ProtocolRole.CLIENT);
|
||||
addIfSet(cmd, "ip", rule.ipRegex);
|
||||
addIfSet(cmd, "name", rule.nameRegex);
|
||||
addIfSet(cmd, "uid", rule.uid);
|
||||
addIfSet(cmd, "mytsid", rule.mytsid);
|
||||
if (!rule.isPermanent()) cmd.add(new CommandSingleParameter("time", Long.toString(rule.durationSeconds)));
|
||||
addIfSet(cmd, "banreason", rule.reason);
|
||||
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
|
||||
Map<String, String> fields = answer.toMap();
|
||||
if (fields.containsKey("banid")) {
|
||||
return BanEntry.from(fields).id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a ban with a re-written one. The server has no way to edit a ban in place,
|
||||
* so the new one is added before the old is removed: a failure half-way leaves the
|
||||
* client banned twice rather than not at all.
|
||||
*/
|
||||
void replace(int banId, BanRule rule) throws Exception {
|
||||
add(rule);
|
||||
try {
|
||||
delete(banId);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("The edited ban was added, but the original could not be"
|
||||
+ " removed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
void delete(int banId) throws Exception {
|
||||
SingleCommand cmd = new SingleCommand("bandel", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("banid", Integer.toString(banId)));
|
||||
conn.socket().executeCommand(cmd).complete();
|
||||
}
|
||||
|
||||
private static void addIfSet(SingleCommand cmd, String key, String value) {
|
||||
if (value != null && !value.isEmpty()) cmd.add(new CommandSingleParameter(key, value));
|
||||
}
|
||||
}
|
||||
@@ -601,6 +601,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
|
||||
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
|
||||
else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
@@ -21,11 +21,13 @@ public final class ServerModel {
|
||||
/**
|
||||
* Permission id -> name, learned from the server's {@code permissionlist} response.
|
||||
* Entries carry no id of their own: TS3 numbers them by position in the response,
|
||||
* and the empty "group_id_end" separator records marking category boundaries don't
|
||||
* count toward that position, so only {@link #putPermissionName} advances it.
|
||||
* starting at 1, and the empty "group_id_end" separator records marking category
|
||||
* boundaries don't count toward that position, so only {@link #putPermissionName}
|
||||
* advances it.
|
||||
*/
|
||||
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
|
||||
private int nextPermissionId;
|
||||
private static final int FIRST_PERMISSION_ID = 1;
|
||||
private int nextPermissionId = FIRST_PERMISSION_ID;
|
||||
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
|
||||
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
|
||||
private String serverName = "TeamSpeak Server";
|
||||
@@ -38,7 +40,7 @@ public final class ServerModel {
|
||||
serverGroups.clear();
|
||||
channelGroups.clear();
|
||||
permissionNames.clear();
|
||||
nextPermissionId = 0;
|
||||
nextPermissionId = FIRST_PERMISSION_ID;
|
||||
selfPermissionValues.clear();
|
||||
defaultChannelGroupId = 0;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import com.ts3client.chatlog.ChatLogStore;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.filetransfer.FileTransfer;
|
||||
import com.ts3client.net.filetransfer.FileTransferManager;
|
||||
import com.ts3client.net.ban.BanEntry;
|
||||
import com.ts3client.net.ban.BanRule;
|
||||
import com.ts3client.net.filetransfer.RemoteFile;
|
||||
import com.ts3client.sound.SoundEvent;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
@@ -70,6 +72,8 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
|
||||
/** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
|
||||
final ChannelAdmin channels = new ChannelAdmin(this);
|
||||
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */
|
||||
final BanAdmin bans = new BanAdmin(this);
|
||||
|
||||
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
|
||||
LocalTeamspeakClientSocket client;
|
||||
@@ -951,6 +955,56 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- bans ----
|
||||
|
||||
/** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */
|
||||
public void requestBanList(BiConsumer<List<BanEntry>, String> callback) {
|
||||
run("ts3j-ban-list", callback, bans::list);
|
||||
}
|
||||
|
||||
/** @param callback given {@code null} on success, or the failure message */
|
||||
public void addBan(BanRule rule, Consumer<String> callback) {
|
||||
run("ts3j-ban-add", (ignored, error) -> callback.accept(error), () -> {
|
||||
bans.add(rule);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites an existing ban.
|
||||
*
|
||||
* @param callback given {@code null} on success, or the failure message
|
||||
*/
|
||||
public void replaceBan(int banId, BanRule rule, Consumer<String> callback) {
|
||||
run("ts3j-ban-edit", (ignored, error) -> callback.accept(error), () -> {
|
||||
bans.replace(banId, rule);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes bans one by one, stopping at the first the server refuses.
|
||||
*
|
||||
* @param callback given {@code null} on success, or the failure message
|
||||
*/
|
||||
public void deleteBans(Collection<Integer> banIds, Consumer<String> callback) {
|
||||
run("ts3j-ban-delete", (ignored, error) -> callback.accept(error), () -> {
|
||||
for (int id : banIds) bans.delete(id);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Our own unique identifier on this server, which is how the server names us as a
|
||||
* ban's creator; empty while not connected.
|
||||
*/
|
||||
public String getSelfUniqueId() {
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
if (self != null && !self.uniqueId.isEmpty()) return self.uniqueId;
|
||||
LocalIdentity id = identity;
|
||||
return id == null ? "" : id.getUid().toBase64();
|
||||
}
|
||||
|
||||
// ---- channel administration ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ts3client.net.ban;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** One entry of the server's ban list, as a {@code notifybanlist} event describes it. */
|
||||
public final class BanEntry {
|
||||
|
||||
public final int id;
|
||||
/** The regular expression matched against a joining client's IP, or empty. */
|
||||
public final String ip;
|
||||
/** The regular expression matched against a joining client's nickname, or empty. */
|
||||
public final String name;
|
||||
public final String uid;
|
||||
public final String mytsid;
|
||||
/** The nickname the banned client last used, where the server knew one. */
|
||||
public final String lastNickname;
|
||||
public final long createdEpochSeconds;
|
||||
/** How long the ban lasts from its creation, or 0 when it never expires. */
|
||||
public final long durationSeconds;
|
||||
public final String invokerName;
|
||||
public final int invokerDatabaseId;
|
||||
public final String invokerUid;
|
||||
public final String reason;
|
||||
/** How many connection attempts the ban has blocked so far. */
|
||||
public final int enforcements;
|
||||
|
||||
public BanEntry(int id, String ip, String name, String uid, String mytsid, String lastNickname,
|
||||
long createdEpochSeconds, long durationSeconds, String invokerName,
|
||||
int invokerDatabaseId, String invokerUid, String reason, int enforcements) {
|
||||
this.id = id;
|
||||
this.ip = orEmpty(ip);
|
||||
this.name = orEmpty(name);
|
||||
this.uid = orEmpty(uid);
|
||||
this.mytsid = orEmpty(mytsid);
|
||||
this.lastNickname = orEmpty(lastNickname);
|
||||
this.createdEpochSeconds = createdEpochSeconds;
|
||||
this.durationSeconds = Math.max(0, durationSeconds);
|
||||
this.invokerName = orEmpty(invokerName);
|
||||
this.invokerDatabaseId = invokerDatabaseId;
|
||||
this.invokerUid = orEmpty(invokerUid);
|
||||
this.reason = orEmpty(reason);
|
||||
this.enforcements = enforcements;
|
||||
}
|
||||
|
||||
/** Reads an entry off a {@code notifybanlist} event's fields. */
|
||||
public static BanEntry from(Map<String, String> fields) {
|
||||
return new BanEntry(
|
||||
parseInt(fields.get("banid"), -1),
|
||||
fields.get("ip"), fields.get("name"), fields.get("uid"), fields.get("mytsid"),
|
||||
fields.get("lastnickname"),
|
||||
parseLong(fields.get("created"), 0), parseLong(fields.get("duration"), 0),
|
||||
fields.get("invokername"), parseInt(fields.get("invokercldbid"), 0),
|
||||
fields.get("invokeruid"), fields.get("reason"),
|
||||
parseInt(fields.get("enforcements"), 0));
|
||||
}
|
||||
|
||||
public boolean isPermanent() {
|
||||
return durationSeconds == 0;
|
||||
}
|
||||
|
||||
/** When the ban lifts, in seconds since the epoch; meaningless for a permanent one. */
|
||||
public long expiresEpochSeconds() {
|
||||
return createdEpochSeconds + durationSeconds;
|
||||
}
|
||||
|
||||
/** Whether {@code uid} (a client's unique identifier) created this ban. */
|
||||
public boolean createdBy(String uid) {
|
||||
return uid != null && !uid.isEmpty() && uid.equals(invokerUid);
|
||||
}
|
||||
|
||||
/** The entry's targets and settings as a rule, which is what editing starts from. */
|
||||
public BanRule toRule() {
|
||||
return new BanRule(ip, name, uid, mytsid, durationSeconds, reason);
|
||||
}
|
||||
|
||||
private static String orEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseLong(String value, long fallback) {
|
||||
try {
|
||||
return value == null || value.isEmpty() ? fallback : Long.parseLong(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.ts3client.net.ban;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
/**
|
||||
* The notations a ban's IP and name may be typed in, and their translation to and from
|
||||
* the regular expressions the server actually matches — the official client's
|
||||
* "Interpret IP/Name as" choice.
|
||||
*
|
||||
* <p>A wildcard pattern is the text with {@code *} standing for a run of characters
|
||||
* and everything else taken literally; a fixed string is taken literally throughout.
|
||||
* In an IP a wildcard covers one address group (decimal for IPv4, hexadecimal for
|
||||
* IPv6), in a name it covers anything.
|
||||
*/
|
||||
public enum BanPattern {
|
||||
|
||||
WILDCARD_IPV4("Wildcard ipv4", "[0-9]+"),
|
||||
WILDCARD_IPV6("Wildcard ipv6", "[0-9a-fA-F]+"),
|
||||
FIXED_STRING("Fixed String", null),
|
||||
REGULAR_EXPRESSION("Regular Expression", null);
|
||||
|
||||
private static final String NAME_WILDCARD = ".*";
|
||||
private static final String META = "\\.[]{}()+?|^$*";
|
||||
|
||||
private final String label;
|
||||
/** What one {@code *} in an IP stands for; {@code null} where the notation has no wildcards. */
|
||||
private final String ipWildcard;
|
||||
|
||||
BanPattern(String label, String ipWildcard) {
|
||||
this.label = label;
|
||||
this.ipWildcard = ipWildcard;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** The regular expression an IP written in this notation stands for. */
|
||||
public String ipToRegex(String text) {
|
||||
return toRegex(text, ipWildcard);
|
||||
}
|
||||
|
||||
/** The regular expression a nickname written in this notation stands for. */
|
||||
public String nameToRegex(String text) {
|
||||
return toRegex(text, ipWildcard == null ? null : NAME_WILDCARD);
|
||||
}
|
||||
|
||||
private String toRegex(String text, String wildcard) {
|
||||
if (text == null) return "";
|
||||
if (this == REGULAR_EXPRESSION) return text;
|
||||
StringBuilder out = new StringBuilder(text.length() + 8);
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (c == '*' && wildcard != null) out.append(wildcard);
|
||||
else if (META.indexOf(c) >= 0) out.append('\\').append(c);
|
||||
else out.append(c);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code regex} is something the server will accept as a pattern; an
|
||||
* unparsable expression fails the whole {@code banadd}.
|
||||
*/
|
||||
public static boolean isValidRegex(String regex) {
|
||||
if (regex == null || regex.isEmpty()) return true;
|
||||
try {
|
||||
Pattern.compile(regex);
|
||||
return true;
|
||||
} catch (PatternSyntaxException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A pattern written back in one of the friendlier notations, where that is possible. */
|
||||
public static final class Decoded {
|
||||
public final BanPattern pattern;
|
||||
public final String ip;
|
||||
public final String name;
|
||||
|
||||
Decoded(BanPattern pattern, String ip, String name) {
|
||||
this.pattern = pattern;
|
||||
this.ip = ip;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the friendliest notation that reproduces an existing ban's IP and name
|
||||
* expressions exactly, so an editor can show them the way they were most likely
|
||||
* typed; anything it cannot round-trip is shown as the raw regular expression.
|
||||
*/
|
||||
public static Decoded decode(String ipRegex, String nameRegex) {
|
||||
String ip = ipRegex == null ? "" : ipRegex;
|
||||
String name = nameRegex == null ? "" : nameRegex;
|
||||
for (BanPattern p : new BanPattern[] {WILDCARD_IPV4, WILDCARD_IPV6, FIXED_STRING}) {
|
||||
String ipText = p.fromRegex(ip, p.ipWildcard);
|
||||
String nameText = p.fromRegex(name, p.ipWildcard == null ? null : NAME_WILDCARD);
|
||||
if (ipText != null && nameText != null
|
||||
&& p.ipToRegex(ipText).equals(ip) && p.nameToRegex(nameText).equals(name)) {
|
||||
return new Decoded(p, ipText, nameText);
|
||||
}
|
||||
}
|
||||
return new Decoded(REGULAR_EXPRESSION, ip, name);
|
||||
}
|
||||
|
||||
/** Inverts {@link #toRegex}; {@code null} where the expression is not in this notation. */
|
||||
private String fromRegex(String regex, String wildcard) {
|
||||
StringBuilder out = new StringBuilder(regex.length());
|
||||
for (int i = 0; i < regex.length(); i++) {
|
||||
if (wildcard != null && regex.startsWith(wildcard, i)) {
|
||||
out.append('*');
|
||||
i += wildcard.length() - 1;
|
||||
} else if (regex.charAt(i) == '\\') {
|
||||
if (i + 1 >= regex.length() || META.indexOf(regex.charAt(i + 1)) < 0) return null;
|
||||
out.append(regex.charAt(++i));
|
||||
} else if (META.indexOf(regex.charAt(i)) >= 0) {
|
||||
return null;
|
||||
} else {
|
||||
out.append(regex.charAt(i));
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ts3client.net.ban;
|
||||
|
||||
/**
|
||||
* What a ban is made of, as {@code banadd} takes it: any combination of an IP pattern,
|
||||
* a nickname pattern, a unique identifier and a myTeamSpeak id, with a duration and a
|
||||
* reason. The patterns are regular expressions — see {@link BanPattern} for turning
|
||||
* friendlier notations into them.
|
||||
*/
|
||||
public final class BanRule {
|
||||
|
||||
/** The longest reason the server stores with a ban. */
|
||||
public static final int REASON_LIMIT = 80;
|
||||
|
||||
public final String ipRegex;
|
||||
public final String nameRegex;
|
||||
public final String uid;
|
||||
public final String mytsid;
|
||||
/** The ban's length in seconds, or 0 for a permanent one. */
|
||||
public final long durationSeconds;
|
||||
public final String reason;
|
||||
|
||||
public BanRule(String ipRegex, String nameRegex, String uid, String mytsid,
|
||||
long durationSeconds, String reason) {
|
||||
this.ipRegex = trimmed(ipRegex);
|
||||
this.nameRegex = trimmed(nameRegex);
|
||||
this.uid = trimmed(uid);
|
||||
this.mytsid = trimmed(mytsid);
|
||||
this.durationSeconds = Math.max(0, durationSeconds);
|
||||
this.reason = trimmed(reason);
|
||||
}
|
||||
|
||||
/** A rule with nothing to match bans nobody; the server rejects it. */
|
||||
public boolean hasTarget() {
|
||||
return !ipRegex.isEmpty() || !nameRegex.isEmpty() || !uid.isEmpty() || !mytsid.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isPermanent() {
|
||||
return durationSeconds == 0;
|
||||
}
|
||||
|
||||
private static String trimmed(String s) {
|
||||
return s == null ? "" : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ts3client.net.ban;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class BanPatternTest {
|
||||
|
||||
@Test
|
||||
void wildcardIpv4TurnsStarsIntoAddressGroups() {
|
||||
assertEquals("192\\.168\\.[0-9]+\\.[0-9]+", BanPattern.WILDCARD_IPV4.ipToRegex("192.168.*.*"));
|
||||
assertEquals("2001:db8:[0-9a-fA-F]+:[0-9a-fA-F]+", BanPattern.WILDCARD_IPV6.ipToRegex("2001:db8:*:*"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardNamesMatchAnything() {
|
||||
assertEquals("Troll.*", BanPattern.WILDCARD_IPV4.nameToRegex("Troll*"));
|
||||
assertEquals("\\[Bot\\].*", BanPattern.WILDCARD_IPV6.nameToRegex("[Bot]*"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedStringsAreTakenLiterally() {
|
||||
assertEquals("10\\.0\\.0\\.1", BanPattern.FIXED_STRING.ipToRegex("10.0.0.1"));
|
||||
assertEquals("a\\*b\\?\\(c\\)\\$\\^\\|\\\\", BanPattern.FIXED_STRING.nameToRegex("a*b?(c)$^|\\"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void regularExpressionsPassThrough() {
|
||||
assertEquals("^Troll[0-9]{2}$", BanPattern.REGULAR_EXPRESSION.nameToRegex("^Troll[0-9]{2}$"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeRecoversTheNotationThatProducedTheExpression() {
|
||||
BanPattern.Decoded d = BanPattern.decode("192\\.168\\.[0-9]+\\.[0-9]+", "Troll.*");
|
||||
assertEquals(BanPattern.WILDCARD_IPV4, d.pattern);
|
||||
assertEquals("192.168.*.*", d.ip);
|
||||
assertEquals("Troll*", d.name);
|
||||
|
||||
d = BanPattern.decode("fe80:[0-9a-fA-F]+", "");
|
||||
assertEquals(BanPattern.WILDCARD_IPV6, d.pattern);
|
||||
assertEquals("fe80:*", d.ip);
|
||||
|
||||
d = BanPattern.decode("", "a\\*b");
|
||||
assertEquals(BanPattern.FIXED_STRING, d.pattern);
|
||||
assertEquals("a*b", d.name);
|
||||
|
||||
d = BanPattern.decode("", "^Troll[0-9]{2}$");
|
||||
assertEquals(BanPattern.REGULAR_EXPRESSION, d.pattern);
|
||||
assertEquals("^Troll[0-9]{2}$", d.name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainAddressesDecodeAsWildcardsLikeTheDialogDefault() {
|
||||
BanPattern.Decoded d = BanPattern.decode("10\\.0\\.0\\.1", "");
|
||||
assertEquals(BanPattern.WILDCARD_IPV4, d.pattern);
|
||||
assertEquals("10.0.0.1", d.ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validityFollowsJavaRegexSyntax() {
|
||||
assertTrue(BanPattern.isValidRegex(""));
|
||||
assertTrue(BanPattern.isValidRegex("^a.*$"));
|
||||
assertFalse(BanPattern.isValidRegex("(unclosed"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user