From 70d264f9bf9feb1cfaa9eb0e35a91965e92c5fac Mon Sep 17 00:00:00 2001 From: Git Manager GUI Date: Mon, 13 Apr 2026 10:00:00 +0200 Subject: [PATCH] Upload folder via GUI - src --- src/main/java/net/viper/status/StatusAPI.java | 1938 ++++++++-------- .../java/net/viper/status/UpdateChecker.java | 2 - .../viper/status/module/ModuleManager.java | 119 +- .../AutoMessage/AutoMessageModule.java | 247 ++- .../status/modules/antibot/AntiBotModule.java | 1958 +++++++---------- .../modules/broadcast/BroadcastModule.java | 694 +++--- .../modules/chat/AccountLinkManager.java | 1 - .../viper/status/modules/chat/ChatConfig.java | 1079 +++++---- .../viper/status/modules/chat/ChatFilter.java | 96 +- .../viper/status/modules/chat/ChatModule.java | 354 +-- .../status/modules/chat/ReportManager.java | 2 +- .../status/modules/chat/VanishProvider.java | 71 + .../modules/chat/bridge/DiscordBridge.java | 747 +++---- .../modules/forum/ForumBridgeModule.java | 319 +-- .../modules/network/NetworkInfoModule.java | 6 +- .../status/modules/vanish/VanishModule.java | 269 +++ .../status/modules/verify/VerifyModule.java | 438 ++-- .../net/viper/status/stats/PlayerStats.java | 45 +- .../net/viper/status/stats/StatsModule.java | 3 - .../net/viper/status/stats/StatsStorage.java | 83 +- src/main/resources/chat.yml | 54 + src/main/resources/filter.yml | 16 + src/main/resources/plugin.yml | 272 ++- src/main/resources/verify.properties | 2 + 24 files changed, 4430 insertions(+), 4385 deletions(-) create mode 100644 src/main/java/net/viper/status/modules/chat/VanishProvider.java create mode 100644 src/main/java/net/viper/status/modules/vanish/VanishModule.java diff --git a/src/main/java/net/viper/status/StatusAPI.java b/src/main/java/net/viper/status/StatusAPI.java index db5db39..8aaadd7 100644 --- a/src/main/java/net/viper/status/StatusAPI.java +++ b/src/main/java/net/viper/status/StatusAPI.java @@ -1,988 +1,950 @@ -package net.viper.status; - -import net.md_5.bungee.api.ProxyServer; -import net.md_5.bungee.api.config.ListenerInfo; -import net.md_5.bungee.api.connection.ProxiedPlayer; -import net.md_5.bungee.api.plugin.Plugin; -import net.viper.status.module.ModuleManager; -import net.viper.status.modules.antibot.AntiBotModule; -import net.viper.status.modules.network.NetworkInfoModule; -import net.viper.status.modules.AutoMessage.AutoMessageModule; -import net.viper.status.modules.customcommands.CustomCommandModule; -import net.viper.status.stats.PlayerStats; -import net.viper.status.stats.StatsModule; -import net.viper.status.modules.verify.VerifyModule; -import net.viper.status.modules.commandblocker.CommandBlockerModule; -import net.viper.status.modules.broadcast.BroadcastModule; -import net.viper.status.modules.chat.ChatModule; - - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.io.StringReader; -import java.net.SocketTimeoutException; -import java.net.ServerSocket; -import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.*; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import net.md_5.bungee.api.scheduler.ScheduledTask; - -/** - * StatusAPI - zentraler Bungee HTTP-Status- und Broadcast-Endpunkt - */ - -public class StatusAPI extends Plugin implements Runnable { - - private volatile Thread thread; - private volatile ServerSocket serverSocket; - private volatile boolean shuttingDown = false; - private int port = 9191; - private ScheduledTask httpWatchdogTask; - private ExecutorService requestExecutor; - private final AtomicLong lastHttpRequestAt = new AtomicLong(0L); - - private ModuleManager moduleManager; - private UpdateChecker updateChecker; - private Properties verifyProperties; - - @Override - public void onEnable() { - getLogger().info("StatusAPI Core wird initialisiert..."); - - if (!getDataFolder().exists()) { - getDataFolder().mkdirs(); - } - - // Config mergen/updaten - mergeVerifyConfig(); - - // Port aus verify.properties lesen - String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191"; - try { - port = Integer.parseInt(portStr); - } catch (NumberFormatException e) { - getLogger().warning("Ungültiger Port in verify.properties, nutze Standard-Port 9191."); - port = 9191; - } - - moduleManager = new ModuleManager(); - - // Module registrieren - moduleManager.registerModule(new StatsModule()); - moduleManager.registerModule(new VerifyModule()); - moduleManager.registerModule(new BroadcastModule()); - moduleManager.registerModule(new CommandBlockerModule()); - moduleManager.registerModule(new ChatModule()); - moduleManager.registerModule(new AntiBotModule()); - moduleManager.registerModule(new NetworkInfoModule()); - moduleManager.registerModule(new AutoMessageModule()); - moduleManager.registerModule(new CustomCommandModule()); - - try { - Class forumBridge = Class.forName("net.viper.status.modules.forum.ForumBridgeModule"); - Object forumBridgeInstance = forumBridge.getDeclaredConstructor().newInstance(); - moduleManager.registerModule((net.viper.status.module.Module) forumBridgeInstance); - } catch (Exception e) { - getLogger().warning("ForumBridgeModule konnte nicht geladen werden: " + e.getMessage()); - } - - moduleManager.enableAll(this); - - // WebServer starten - getLogger().info("Starte Web-Server auf Port " + port + "..."); - shuttingDown = false; - requestExecutor = Executors.newFixedThreadPool(4, r -> { - Thread t = new Thread(r, "StatusAPI-HTTP-Worker"); - t.setDaemon(true); - return t; - }); - startHttpServerThread(); - httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule(this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS); - - // Update System - String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; - updateChecker = new UpdateChecker(this, currentVersion, 6); - // ...fileDownloader entfernt... - - checkAndMaybeUpdate(); - ProxyServer.getInstance().getScheduler().schedule(this, this::checkAndMaybeUpdate, 6, 6, TimeUnit.HOURS); - } - - @Override - public void onDisable() { - shuttingDown = true; - - getLogger().info("Stoppe Module..."); - if (moduleManager != null) { - moduleManager.disableAll(this); - } - - getLogger().info("Stoppe Web-Server..."); - if (httpWatchdogTask != null) { - httpWatchdogTask.cancel(); - httpWatchdogTask = null; - } - - stopHttpServerThread(); - - if (requestExecutor != null) { - requestExecutor.shutdownNow(); - requestExecutor = null; - } - } - - private synchronized void startHttpServerThread() { - if (thread != null && thread.isAlive()) { - return; - } - thread = new Thread(this, "StatusAPI-HTTP-Server"); - thread.setDaemon(true); - thread.start(); - } - - private synchronized void stopHttpServerThread() { - Thread localThread = thread; - if (localThread != null) { - localThread.interrupt(); - } - - ServerSocket localServerSocket = serverSocket; - if (localServerSocket != null) { - try { - localServerSocket.close(); - } catch (IOException ignored) { - } - } - - if (localThread != null) { - try { - localThread.join(1500); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - thread = null; - } - - private void ensureHttpServerAlive() { - if (shuttingDown) { - return; - } - Thread t = thread; - if (t == null || !t.isAlive()) { - getLogger().warning("HTTP-Server-Thread war gestoppt und wird neu gestartet."); - startHttpServerThread(); - } - } - - /** - * Hilfsmethode um Property-Werte zu bereinigen (unescape). - * Entfernt z.B. den Backslash vor URLs wie https\:// - */ - private String unescapePropertiesString(String input) { - if (input == null) return null; - try { - Properties p = new Properties(); - p.load(new StringReader("dummy=" + input)); - return p.getProperty("dummy"); - } catch (IOException e) { - return input; // Fallback, falls Parsing fehlschlägt - } - } - - // --- MERGE LOGIK --- - private void mergeVerifyConfig() { - try { - File file = new File(getDataFolder(), "verify.properties"); - verifyProperties = new Properties(); - if (file.exists()) { - try (FileInputStream fis = new FileInputStream(file)) { - verifyProperties.load(fis); - } - getLogger().info("verify.properties geladen."); - } else { - getLogger().warning("verify.properties nicht gefunden."); - } - } catch (IOException e) { - getLogger().severe("Fehler beim Laden der verify.properties: " + e.getMessage()); - } - } - - public Properties getVerifyProperties() { - synchronized (this) { - return verifyProperties; - } - } - - public ModuleManager getModuleManager() { - return moduleManager; - } - - // --- Update Logik --- - private void checkAndMaybeUpdate() { - try { - updateChecker.checkNow(); - String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; - - if (updateChecker.isUpdateAvailable(currentVersion)) { - String newVersion = updateChecker.getLatestVersion(); - String url = updateChecker.getLatestUrl(); - getLogger().warning("----------------------------------------"); - getLogger().warning("Neue Version verfügbar: " + newVersion); - getLogger().warning("Starte automatisches Update..."); - getLogger().warning("----------------------------------------"); - - File pluginFile = getFile(); - File newFile = new File(pluginFile.getParentFile(), "StatusAPI.jar.new"); - - // ...fileDownloader entfernt... - } - } catch (Exception e) { - getLogger().severe("Fehler beim Update-Check: " + e.getMessage()); - } - } - - private void triggerUpdateScript(File currentFile, File newFile) { - try { - File pluginsFolder = currentFile.getParentFile(); - File rootFolder = pluginsFolder.getParentFile(); - File batFile = new File(rootFolder, "StatusAPI_Update_" + System.currentTimeMillis() + ".bat"); - - String batContent = "@echo off\n" + - "echo Bitte warten, der Server fährt herunter...\n" + - "timeout /t 5 /nobreak >nul\n" + - "cd /d \"" + pluginsFolder.getAbsolutePath().replace("\\", "/") + "\"\n" + - "echo Fuehre Datei-Tausch durch...\n" + - "if exist StatusAPI.jar.bak del StatusAPI.jar.bak\n" + - "if exist StatusAPI.jar (\n" + - " ren StatusAPI.jar StatusAPI.jar.bak\n" + - ")\n" + - "if exist StatusAPI.new.jar (\n" + - " ren StatusAPI.new.jar StatusAPI.jar\n" + - " echo Update erfolgreich!\n" + - ") else (\n" + - " echo FEHLER: StatusAPI.new.jar nicht gefunden!\n" + - " pause\n" + - ")\n" + - "del \"%~f0\""; - - try (PrintWriter out = new PrintWriter(batFile)) { out.println(batContent); } - - for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { - p.disconnect("§cServer fährt für ein Update neu herunter. Bitte etwas warten."); - } - - getLogger().info("Starte Update-Skript..."); - Runtime.getRuntime().exec("cmd /c start \"Update_Proc\" \"" + batFile.getAbsolutePath() + "\""); - ProxyServer.getInstance().stop(); - - } catch (Exception e) { - getLogger().severe("Fehler beim Vorbereiten des Updates: " + e.getMessage()); - } - } - - // --- WebServer & JSON --- - @Override - public void run() { - while (!shuttingDown && !Thread.currentThread().isInterrupted()) { - try (ServerSocket localServerSocket = new ServerSocket(port)) { - this.serverSocket = localServerSocket; - localServerSocket.setReuseAddress(true); - localServerSocket.setSoTimeout(1000); - - while (!shuttingDown && !Thread.currentThread().isInterrupted()) { - try { - Socket clientSocket = localServerSocket.accept(); - submitConnection(clientSocket); - } catch (SocketTimeoutException ignored) { - // Poll-Schleife fuer Interrupt/Shutdown. - } catch (IOException e) { - if (!shuttingDown) { - getLogger().warning("Fehler beim Akzeptieren einer Verbindung: " + e.getMessage()); - } - } catch (Throwable t) { - if (!shuttingDown) { - getLogger().severe("Unbehandelter Fehler im HTTP-Accept-Loop: " + t.getMessage()); - } - } - } - } catch (IOException e) { - if (!shuttingDown) { - getLogger().severe("Konnte ServerSocket nicht starten: " + e.getMessage()); - try { - Thread.sleep(2000L); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - } finally { - serverSocket = null; - } - } - } - - private void submitConnection(Socket clientSocket) { - if (clientSocket == null) { - return; - } - - try { - clientSocket.setSoTimeout(5000); - clientSocket.setTcpNoDelay(true); - } catch (Exception ignored) { - } - - ExecutorService executor = requestExecutor; - if (executor == null || executor.isShutdown()) { - try { - clientSocket.close(); - } catch (IOException ignored) { - } - return; - } - - try { - executor.execute(() -> { - try { - handleConnection(clientSocket); - } finally { - try { - clientSocket.close(); - } catch (IOException ignored) { - } - } - }); - } catch (RejectedExecutionException ex) { - try { - clientSocket.close(); - } catch (IOException ignored) { - } - } - } - - private void handleConnection(Socket clientSocket) { - - // (doppelter/fehlerhafter Block entfernt) - try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8)); - OutputStream out = clientSocket.getOutputStream()) { - - String inputLine = in.readLine(); - if (inputLine == null) return; - lastHttpRequestAt.set(System.currentTimeMillis()); - - String[] reqParts = inputLine.split(" "); - if (reqParts.length < 2) return; - String method = reqParts[0].trim(); - String path = reqParts[1].trim(); - String pathOnly = path; - int queryIndex = path.indexOf('?'); - if (queryIndex >= 0) { - pathOnly = path.substring(0, queryIndex); - } - - if ("GET".equalsIgnoreCase(method) && "/health".equalsIgnoreCase(path)) { - long lastMs = lastHttpRequestAt.get(); - long age = lastMs <= 0L ? -1L : (System.currentTimeMillis() - lastMs); - String json = "{\"success\":true,\"online\":true,\"last_request_age_ms\":" + age + "}"; - sendHttpResponse(out, json, 200); - return; - } - - if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) { - Map payload = new LinkedHashMap(); - payload.put("success", true); - payload.put("events", loadAntiBotSecurityEvents(250)); - sendHttpResponse(out, buildJsonString(payload), 200); - return; - } - - Map headers = new HashMap<>(); - String line; - while ((line = in.readLine()) != null && !line.isEmpty()) { - int idx = line.indexOf(':'); - if (idx > 0) { - String key = line.substring(0, idx).trim().toLowerCase(Locale.ROOT); - String val = line.substring(idx + 1).trim(); - headers.put(key, val); - } - } - - // --- GET /network/backendguard/config --- - if ("GET".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/backendguard/config")) { - Properties guardProps = loadNetworkGuardProperties(); - String requiredApiKey = guardProps.getProperty("backendguard.sync.api_key", "").trim(); - String providedApiKey = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); - - if (!requiredApiKey.isEmpty() && !requiredApiKey.equals(providedApiKey == null ? "" : providedApiKey.trim())) { - sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); - return; - } - - Map payload = new LinkedHashMap(); - payload.put("success", true); - - Map guard = new LinkedHashMap(); - guard.put("enforcement_enabled", Boolean.parseBoolean(guardProps.getProperty("backendguard.enforcement_enabled", "true"))); - guard.put("log_blocked_attempts", Boolean.parseBoolean(guardProps.getProperty("backendguard.log_blocked_attempts", "true"))); - guard.put("kick_message", guardProps.getProperty("backendguard.kick_message", "&cBitte verbinde dich nur ueber den Proxy.")); - guard.put("allowed_proxy_ips", parseCommaListProperty(guardProps.getProperty("backendguard.allowed_proxy_ips", "127.0.0.1,::1"))); - guard.put("allowed_proxy_cidrs", parseCommaListProperty(guardProps.getProperty("backendguard.allowed_proxy_cidrs", ""))); - - payload.put("backend_guard", guard); - sendHttpResponse(out, buildJsonString(payload), 200); - return; - } - - // --- POST /forum/notify --- - if ("POST".equalsIgnoreCase(method) && path.equalsIgnoreCase("/forum/notify")) { - int contentLength = 0; - if (headers.containsKey("content-length")) { - try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} - } - char[] bodyChars = new char[Math.max(0, contentLength)]; - if (contentLength > 0) { - int read = 0; - while (read < contentLength) { - int r = in.read(bodyChars, read, contentLength - read); - if (r == -1) break; - read += r; - } - } - String body = new String(bodyChars); - String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); - Object mod = moduleManager.getModule("ForumBridgeModule"); - if (mod != null) { - try { - java.lang.reflect.Method m = mod.getClass().getMethod("handleNotify", String.class, String.class); - String resp = (String) m.invoke(mod, body, apiKeyHeader); - sendHttpResponse(out, resp, 200); - } catch (Exception e) { - e.printStackTrace(); - sendHttpResponse(out, "{\"success\":false,\"error\":\"internal\"}", 500); - } - } else { - sendHttpResponse(out, "{\"success\":false,\"error\":\"no_forum_module\"}", 500); - } - return; - } - - // --- POST /broadcast/cancel --- - if ("POST".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/attack")) { - int contentLength = 0; - if (headers.containsKey("content-length")) { - try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} - } - - char[] bodyChars = new char[Math.max(0, contentLength)]; - if (contentLength > 0) { - int read = 0; - while (read < contentLength) { - int r = in.read(bodyChars, read, contentLength - read); - if (r == -1) break; - read += r; - } - } - - String body = new String(bodyChars); - String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); - - NetworkInfoModule mod = (NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); - if (mod == null || !mod.isEnabled() || !mod.isAttackNotificationsEnabled()) { - sendHttpResponse(out, "{\"success\":false,\"error\":\"network_module_disabled\"}", 403); - return; - } - - if (!mod.isAttackApiKeyValid(apiKeyHeader)) { - sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); - return; - } - - String eventType = extractJsonString(body, "event"); - if (eventType == null || eventType.trim().isEmpty()) eventType = "detected"; - String source = extractJsonString(body, "source"); - - Integer cps = null; - Integer blockedIps = null; - Long blockedConnections = null; - - String cpsStr = extractJsonString(body, "connectionsPerSecond"); - if (cpsStr == null || cpsStr.isEmpty()) cpsStr = extractJsonString(body, "cps"); - try { if (cpsStr != null && !cpsStr.isEmpty()) cps = Integer.valueOf(cpsStr.trim()); } catch (Exception ignored) {} - - String blockedIpsStr = extractJsonString(body, "ipAddressesBlocked"); - if (blockedIpsStr == null || blockedIpsStr.isEmpty()) blockedIpsStr = extractJsonString(body, "blockedIps"); - try { if (blockedIpsStr != null && !blockedIpsStr.isEmpty()) blockedIps = Integer.valueOf(blockedIpsStr.trim()); } catch (Exception ignored) {} - - String blockedConnectionsStr = extractJsonString(body, "connectionsBlocked"); - if (blockedConnectionsStr == null || blockedConnectionsStr.isEmpty()) blockedConnectionsStr = extractJsonString(body, "blockedConnections"); - try { if (blockedConnectionsStr != null && !blockedConnectionsStr.isEmpty()) blockedConnections = Long.valueOf(blockedConnectionsStr.trim()); } catch (Exception ignored) {} - - boolean sent = mod.sendAttackNotification(eventType, cps, blockedIps, blockedConnections, source); - if (sent) { - sendHttpResponse(out, "{\"success\":true}", 200); - } else { - sendHttpResponse(out, "{\"success\":false,\"error\":\"webhook_disabled_or_missing\"}", 400); - } - return; - } - - // --- POST /broadcast/cancel --- - if ("POST".equalsIgnoreCase(method) && (path.equalsIgnoreCase("/broadcast/cancel") || path.equalsIgnoreCase("/cancel"))) { - int contentLength = 0; - if (headers.containsKey("content-length")) { - try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} - } - char[] bodyChars = new char[Math.max(0, contentLength)]; - if (contentLength > 0) { - int read = 0; - while (read < contentLength) { - int r = in.read(bodyChars, read, contentLength - read); - if (r == -1) break; - read += r; - } - } - String body = new String(bodyChars); - String clientScheduleId = extractJsonString(body, "clientScheduleId"); - if (clientScheduleId == null || clientScheduleId.isEmpty()) { - sendHttpResponse(out, "{\"success\":false,\"error\":\"missing_clientScheduleId\"}", 400); - return; - } - Object mod = moduleManager.getModule("BroadcastModule"); - if (mod instanceof BroadcastModule) { - boolean ok = ((BroadcastModule) mod).cancelScheduled(clientScheduleId); - if (ok) sendHttpResponse(out, "{\"success\":true}", 200); - else sendHttpResponse(out, "{\"success\":false,\"error\":\"not_found\"}", 404); - return; - } else { - sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); - return; - } - } - - // --- POST /broadcast --- - if ("POST".equalsIgnoreCase(method) && ("/broadcast".equalsIgnoreCase(path) || "/".equals(path) || path.isEmpty())) { - int contentLength = 0; - if (headers.containsKey("content-length")) { - try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} - } - - char[] bodyChars = new char[Math.max(0, contentLength)]; - if (contentLength > 0) { - int read = 0; - while (read < contentLength) { - int r = in.read(bodyChars, read, contentLength - read); - if (r == -1) break; - read += r; - } - } - String body = new String(bodyChars); - - String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); - - String message = extractJsonString(body, "message"); - String type = extractJsonString(body, "type"); - String prefix = extractJsonString(body, "prefix"); - String prefixColor = extractJsonString(body, "prefixColor"); - String bracketColor = extractJsonString(body, "bracketColor"); - String messageColor = extractJsonString(body, "messageColor"); - String sourceName = extractJsonString(body, "source"); - String scheduleTimeStr = extractJsonString(body, "scheduleTime"); - String recur = extractJsonString(body, "recur"); - String clientScheduleId = extractJsonString(body, "clientScheduleId"); - - if (sourceName == null || sourceName.isEmpty()) sourceName = "PulseCast"; - if (type == null || type.isEmpty()) type = "global"; - - if (scheduleTimeStr != null && !scheduleTimeStr.trim().isEmpty()) { - long scheduleMillis = 0L; - try { - scheduleMillis = Long.parseLong(scheduleTimeStr.trim()); - if (scheduleMillis < 1_000_000_000_000L) scheduleMillis = scheduleMillis * 1000L; - } catch (NumberFormatException ignored) { - try { - double d = Double.parseDouble(scheduleTimeStr.trim()); - long v = (long) d; - if (v < 1_000_000_000_000L) v = v * 1000L; - scheduleMillis = v; - } catch (Exception ex) { - sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_scheduleTime\"}", 400); - return; - } - } - - try { - Object mod = moduleManager.getModule("BroadcastModule"); - if (mod instanceof BroadcastModule) { - BroadcastModule bm = (BroadcastModule) mod; - boolean ok = bm.scheduleBroadcast(scheduleMillis, sourceName, message, type, apiKeyHeader, - prefix, prefixColor, bracketColor, messageColor, (recur == null ? "none" : recur), (clientScheduleId == null ? null : clientScheduleId)); - - if (ok) sendHttpResponse(out, "{\"success\":true}", 200); - else sendHttpResponse(out, "{\"success\":false,\"error\":\"rejected\"}", 403); - return; - } else { - sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); - return; - } - } catch (Throwable t) { - t.printStackTrace(); - sendHttpResponse(out, "{\"success\":false,\"error\":\"internal\"}", 500); - return; - } - } - - if (message == null || message.isEmpty()) { - String resp = "{\"success\":false,\"error\":\"missing_message\"}"; - sendHttpResponse(out, resp, 400); - return; - } - - try { - Object mod = moduleManager.getModule("BroadcastModule"); - if (mod instanceof BroadcastModule) { - BroadcastModule bm = (BroadcastModule) mod; - boolean ok = bm.handleBroadcast(sourceName, message, type, apiKeyHeader, prefix, prefixColor, bracketColor, messageColor); - - if (ok) sendHttpResponse(out, "{\"success\":true}", 200); - else sendHttpResponse(out, "{\"success\":false,\"error\":\"rejected\"}", 403); - return; - } else { - sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); - return; - } - } catch (Throwable t) { - t.printStackTrace(); - sendHttpResponse(out, "{\"success\":false,\"error\":\"internal\"}", 500); - return; - } - } - - // --- GET Handler --- - if (inputLine != null && inputLine.startsWith("GET")) { - Map data = new LinkedHashMap<>(); - data.put("online", true); - - String versionRaw = ProxyServer.getInstance().getVersion(); - String versionClean = (versionRaw != null && versionRaw.contains(":")) ? versionRaw.split(":")[2].trim() : versionRaw; - data.put("version", versionClean); - - // getPlayerLimit() = -1 wenn kein globales Limit. Dann Listener-Wert (Server-Ping-Anzeige) nutzen. - int globalLimit = ProxyServer.getInstance().getConfig().getPlayerLimit(); - if (globalLimit <= 0) { - try { - Iterator limIt = ProxyServer.getInstance().getConfig().getListeners().iterator(); - if (limIt.hasNext()) { - int listenerMax = limIt.next().getMaxPlayers(); - if (listenerMax > 0) globalLimit = listenerMax; - } - } catch (Exception ignored) {} - } - data.put("max_players", String.valueOf(globalLimit)); - - String motd = "BungeeCord"; - try { - Iterator it = ProxyServer.getInstance().getConfig().getListeners().iterator(); - if (it.hasNext()) motd = it.next().getMotd(); - } catch (Exception ignored) {} - data.put("motd", motd); - - StatsModule statsModule = (StatsModule) moduleManager.getModule("StatsModule"); - - boolean luckPermsEnabled = ProxyServer.getInstance().getPluginManager().getPlugin("LuckPerms") != null; - List> playersList = new ArrayList<>(); - - for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { - Map playerInfo = new LinkedHashMap<>(); - playerInfo.put("name", p.getName()); - try { playerInfo.put("uuid", p.getUniqueId().toString()); } catch (Exception ignored) {} - - // Floodgate-Bedrock-Erkennung - boolean isBedrock = false; - String bedrockId = null; - try { - Class floodgateApi = Class.forName("org.geysermc.floodgate.api.FloodgateApi"); - Object api = floodgateApi.getMethod("getInstance").invoke(null); - isBedrock = (boolean) api.getClass().getMethod("isBedrockPlayer", UUID.class).invoke(api, p.getUniqueId()); - if (isBedrock) { - bedrockId = (String) api.getClass().getMethod("getBedrockId", UUID.class).invoke(api, p.getUniqueId()); - } - } catch (Exception ignored) {} - playerInfo.put("isBedrock", isBedrock); - if (bedrockId != null) playerInfo.put("bedrockId", bedrockId); - - String prefix = ""; - if (luckPermsEnabled) { - try { - Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); - Object luckPermsApi = providerClass.getMethod("get").invoke(null); - Object userManager = luckPermsApi.getClass().getMethod("getUserManager").invoke(luckPermsApi); - Object user = userManager.getClass().getMethod("getUser", UUID.class).invoke(userManager, p.getUniqueId()); - if (user != null) { - Class queryOptionsClass = Class.forName("net.luckperms.api.query.QueryOptions"); - Object queryOptions = queryOptionsClass.getMethod("defaultContextualOptions").invoke(null); - Object cachedData = user.getClass().getMethod("getCachedData").invoke(user); - Object metaData = cachedData.getClass().getMethod("getMetaData", queryOptionsClass).invoke(cachedData, queryOptions); - Object result = metaData.getClass().getMethod("getPrefix").invoke(metaData); - if (result != null) prefix = (String) result; - } - } catch (Exception ignored) {} - } - playerInfo.put("prefix", prefix); - - if (statsModule != null) { - PlayerStats ps = statsModule.getManager().getIfPresent(p.getUniqueId()); - if (ps != null) { - playerInfo.put("playtime", ps.getPlaytimeWithCurrentSession()); - playerInfo.put("joins", ps.joins); - playerInfo.put("first_seen", ps.firstSeen); - playerInfo.put("last_seen", ps.lastSeen); - } - } - playersList.add(playerInfo); - } - data.put("players", playersList); - - NetworkInfoModule networkInfoModule = (NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); - if (networkInfoModule != null && networkInfoModule.isEnabled()) { - data.put("network", networkInfoModule.buildSnapshot()); - } - - AntiBotModule antiBotModule = (AntiBotModule) moduleManager.getModule("AntiBotModule"); - if (antiBotModule != null && antiBotModule.isEnabled()) { - data.put("antibot", antiBotModule.buildSnapshot()); - } - - String json = buildJsonString(data); - byte[] jsonBytes = json.getBytes("UTF-8"); - StringBuilder response = new StringBuilder(); - response.append("HTTP/1.1 200 OK\r\n"); - response.append("Content-Type: application/json; charset=UTF-8\r\n"); - response.append("Access-Control-Allow-Origin: *\r\n"); - response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); - response.append("Connection: close\r\n\r\n"); - out.write(response.toString().getBytes(StandardCharsets.UTF_8)); - out.write(jsonBytes); - out.flush(); - } - } catch (Exception e) { - getLogger().severe("Fehler beim Verarbeiten der Anfrage: " + e.getMessage()); - } - } - - private String extractJsonString(String json, String key) { - if (json == null || key == null) return null; - String search = "\"" + key + "\""; - int idx = json.indexOf(search); - if (idx < 0) return null; - int colon = json.indexOf(':', idx + search.length()); - if (colon < 0) return null; - int i = colon + 1; - while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++; - if (i >= json.length()) return null; - char c = json.charAt(i); - if (c == '"') { - i++; - StringBuilder sb = new StringBuilder(); - boolean escape = false; - while (i < json.length()) { - char ch = json.charAt(i++); - if (escape) { - sb.append(ch); - escape = false; - } else { - if (ch == '\\') escape = true; - else if (ch == '"') break; - else sb.append(ch); - } - } - return sb.toString(); - } else { - StringBuilder sb = new StringBuilder(); - while (i < json.length()) { - char ch = json.charAt(i); - if (ch == ',' || ch == '}' || ch == '\n' || ch == '\r') break; - sb.append(ch); - i++; - } - return sb.toString().trim(); - } - } - - private Properties loadNetworkGuardProperties() { - Properties props = new Properties(); - File file = new File(getDataFolder(), "network-guard.properties"); - if (!file.exists()) { - return props; - } - - try (FileInputStream fis = new FileInputStream(file)) { - props.load(new InputStreamReader(fis, StandardCharsets.UTF_8)); - } catch (Exception e) { - getLogger().warning("Konnte network-guard.properties nicht laden: " + e.getMessage()); - } - return props; - } - - private List parseCommaListProperty(String raw) { - List out = new ArrayList(); - if (raw == null || raw.trim().isEmpty()) { - return out; - } - - String[] parts = raw.split(","); - for (String p : parts) { - String trimmed = p == null ? "" : p.trim(); - if (!trimmed.isEmpty()) { - out.add(trimmed); - } - } - return out; - } - - private void sendHttpResponse(OutputStream out, String json, int code) throws IOException { - byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); - StringBuilder response = new StringBuilder(); - response.append("HTTP/1.1 ").append(code).append(" ").append(code == 200 ? "OK" : "ERROR").append("\r\n"); - response.append("Content-Type: application/json; charset=UTF-8\r\n"); - response.append("Access-Control-Allow-Origin: *\r\n"); - response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); - response.append("Connection: close\r\n\r\n"); - out.write(response.toString().getBytes(StandardCharsets.UTF_8)); - out.write(jsonBytes); - out.flush(); - } - - private String buildJsonString(Map data) { - StringBuilder sb = new StringBuilder("{"); - boolean first = true; - for (Map.Entry entry : data.entrySet()) { - if (!first) sb.append(","); - first = false; - sb.append("\"").append(escapeJson(entry.getKey())).append("\":").append(valueToString(entry.getValue())); - } - sb.append("}"); - return sb.toString(); - } - - private String valueToString(Object value) { - if (value == null) return "null"; - else if (value instanceof Boolean) return value.toString(); - else if (value instanceof Number) return value.toString(); - else if (value instanceof Map) { - @SuppressWarnings("unchecked") - Map m = (Map) value; - return buildJsonString(m); - } else if (value instanceof List) { - StringBuilder sb = new StringBuilder("["); - List list = (List) value; - for (int i = 0; i < list.size(); i++) { - if (i > 0) sb.append(","); - Object item = list.get(i); - if (item instanceof Map) sb.append(buildJsonString((Map) item)); - else if (item instanceof Number) sb.append(item.toString()); - else sb.append("\"").append(escapeJson(String.valueOf(item))).append("\""); - } - sb.append("]"); - return sb.toString(); - } - else return "\"" + escapeJson(String.valueOf(value)) + "\""; - } - - private String escapeJson(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); - } - - private List> loadAntiBotSecurityEvents(int maxEntries) { - List> out = new ArrayList>(); - File logFile = new File(getDataFolder(), "antibot-security.log"); - if (!logFile.exists() || maxEntries <= 0) { - return out; - } - - try { - List lines = Files.readAllLines(logFile.toPath(), StandardCharsets.UTF_8); - for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) { - String line = lines.get(i); - if (line == null || line.trim().isEmpty()) { - continue; - } - - Map parsed = parseSecurityLogLine(line); - String event = parsed.get("event"); - if (!isAttackSecurityEvent(event)) { - continue; - } - - String player = parsed.get("player"); - String uuid = parsed.get("uuid"); - if (player == null || player.trim().isEmpty() || "-".equals(player) || "unknown".equalsIgnoreCase(player)) { - continue; - } - if (uuid == null || uuid.trim().isEmpty()) { - uuid = "-"; - } - - Map row = new LinkedHashMap(); - row.put("datetime", parsed.getOrDefault("datetime", "")); - row.put("player", player); - row.put("uuid", uuid); - row.put("ip", parsed.getOrDefault("ip", "-")); - out.add(row); - } - } catch (Exception e) { - getLogger().warning("Konnte antibot-security.log nicht lesen: " + e.getMessage()); - } - - return out; - } - - private Map parseSecurityLogLine(String line) { - Map map = new LinkedHashMap(); - if (line == null) { - return map; - } - - String[] segments = line.split("\\s*\\|\\s*"); - if (segments.length > 0) { - map.put("datetime", segments[0].trim()); - } - - for (int i = 1; i < segments.length; i++) { - String seg = segments[i]; - int idx = seg.indexOf('='); - if (idx <= 0) { - continue; - } - String key = seg.substring(0, idx).trim().toLowerCase(Locale.ROOT); - String value = seg.substring(idx + 1).trim(); - map.put(key, value); - } - - return map; - } - - private boolean isAttackSecurityEvent(String event) { - if (event == null) { - return false; - } - String e = event.trim().toLowerCase(Locale.ROOT); - return e.contains("ip_rate") - || e.contains("vpn") - || e.contains("learning_threshold_block") - || e.contains("block"); - } -} \ No newline at end of file +package net.viper.status; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ListenerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.module.ModuleManager; +import net.viper.status.modules.antibot.AntiBotModule; +import net.viper.status.modules.network.NetworkInfoModule; +import net.viper.status.modules.AutoMessage.AutoMessageModule; +import net.viper.status.modules.customcommands.CustomCommandModule; +import net.viper.status.stats.PlayerStats; +import net.viper.status.stats.StatsModule; +import net.viper.status.modules.verify.VerifyModule; +import net.viper.status.modules.commandblocker.CommandBlockerModule; +import net.viper.status.modules.broadcast.BroadcastModule; +import net.viper.status.modules.chat.ChatModule; +import net.viper.status.modules.vanish.VanishModule; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.StringReader; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import net.md_5.bungee.api.scheduler.ScheduledTask; + +/** + * StatusAPI - zentraler BungeeCord HTTP-Status- und Broadcast-Endpunkt + */ +public class StatusAPI extends Plugin implements Runnable { + + private volatile Thread thread; + private volatile ServerSocket serverSocket; + private volatile boolean shuttingDown = false; + private int port = 9191; + private ScheduledTask httpWatchdogTask; + private ExecutorService requestExecutor; + private final AtomicLong lastHttpRequestAt = new AtomicLong(0L); + + private ModuleManager moduleManager; + private UpdateChecker updateChecker; + private Properties verifyProperties; + + @Override + public void onEnable() { + + if (!getDataFolder().exists()) { + getDataFolder().mkdirs(); + } + + mergeVerifyConfig(); + + // Port aus verify.properties lesen + String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191"; + try { + port = Integer.parseInt(portStr); + } catch (NumberFormatException e) { + getLogger().warning("Ungültiger Port in verify.properties, nutze Standard-Port 9191."); + port = 9191; + } + + moduleManager = new ModuleManager(); + + // Module in korrekter Reihenfolge registrieren + // VanishModule MUSS vor ChatModule registriert werden (VanishProvider-Abhängigkeit) + moduleManager.registerModule(new StatsModule()); + moduleManager.registerModule(new VerifyModule()); + moduleManager.registerModule(new BroadcastModule()); + moduleManager.registerModule(new CommandBlockerModule()); + moduleManager.registerModule(new VanishModule()); + moduleManager.registerModule(new ChatModule()); + moduleManager.registerModule(new AntiBotModule()); + moduleManager.registerModule(new NetworkInfoModule()); + moduleManager.registerModule(new AutoMessageModule()); + moduleManager.registerModule(new CustomCommandModule()); + + try { + Class forumBridge = Class.forName("net.viper.status.modules.forum.ForumBridgeModule"); + Object forumBridgeInstance = forumBridge.getDeclaredConstructor().newInstance(); + moduleManager.registerModule((net.viper.status.module.Module) forumBridgeInstance); + } catch (Exception e) { + getLogger().warning("ForumBridgeModule konnte nicht geladen werden: " + e.getMessage()); + } + + moduleManager.enableAll(this); + + // WebServer starten + shuttingDown = false; + requestExecutor = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "StatusAPI-HTTP-Worker"); + t.setDaemon(true); + return t; + }); + startHttpServerThread(); + httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule( + this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS); + + // Update-Checker + String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; + updateChecker = new UpdateChecker(this, currentVersion, 6); + checkAndMaybeUpdate(); + ProxyServer.getInstance().getScheduler().schedule(this, this::checkAndMaybeUpdate, 6, 6, TimeUnit.HOURS); + } + + @Override + public void onDisable() { + shuttingDown = true; + if (moduleManager != null) { + moduleManager.disableAll(this); + } + if (httpWatchdogTask != null) { + httpWatchdogTask.cancel(); + httpWatchdogTask = null; + } + stopHttpServerThread(); + if (requestExecutor != null) { + requestExecutor.shutdownNow(); + requestExecutor = null; + } + } + + private synchronized void startHttpServerThread() { + if (thread != null && thread.isAlive()) { + return; + } + thread = new Thread(this, "StatusAPI-HTTP-Server"); + thread.setDaemon(true); + thread.start(); + } + + private synchronized void stopHttpServerThread() { + Thread localThread = thread; + if (localThread != null) { + localThread.interrupt(); + } + ServerSocket localServerSocket = serverSocket; + if (localServerSocket != null) { + try { + localServerSocket.close(); + } catch (IOException ignored) { + } + } + if (localThread != null) { + try { + localThread.join(1500); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + thread = null; + } + + private void ensureHttpServerAlive() { + if (shuttingDown) return; + Thread t = thread; + if (t == null || !t.isAlive()) { + getLogger().warning("HTTP-Server-Thread war gestoppt und wird neu gestartet."); + startHttpServerThread(); + } + } + + // --- MERGE LOGIK --- + private void mergeVerifyConfig() { + try { + File file = new File(getDataFolder(), "verify.properties"); + verifyProperties = new Properties(); + if (file.exists()) { + try (FileInputStream fis = new FileInputStream(file)) { + verifyProperties.load(fis); + } + } else { + getLogger().warning("verify.properties nicht gefunden."); + } + } catch (IOException e) { + getLogger().severe("Fehler beim Laden der verify.properties: " + e.getMessage()); + } + } + + public Properties getVerifyProperties() { + synchronized (this) { + return verifyProperties; + } + } + + public ModuleManager getModuleManager() { + return moduleManager; + } + + // --- Update-Logik --- + private void checkAndMaybeUpdate() { + try { + updateChecker.checkNow(); + String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; + if (updateChecker.isUpdateAvailable(currentVersion)) { + String newVersion = updateChecker.getLatestVersion(); + getLogger().warning("----------------------------------------"); + getLogger().warning("Neue Version verfügbar: " + newVersion); + getLogger().warning("Download: " + updateChecker.getLatestUrl()); + getLogger().warning("----------------------------------------"); + } + } catch (Exception e) { + getLogger().severe("Fehler beim Update-Check: " + e.getMessage()); + } + } + + // --- WebServer --- + @Override + public void run() { + while (!shuttingDown && !Thread.currentThread().isInterrupted()) { + // FIX #1: reuseAddress muss VOR dem bind() gesetzt werden. + // new ServerSocket(port) bindet sofort → stattdessen unboundenen Socket anlegen. + ServerSocket localServerSocket = null; + try { + localServerSocket = new ServerSocket(); + localServerSocket.setReuseAddress(true); + localServerSocket.setSoTimeout(1000); + localServerSocket.bind(new InetSocketAddress(port)); + this.serverSocket = localServerSocket; + + while (!shuttingDown && !Thread.currentThread().isInterrupted()) { + try { + Socket clientSocket = localServerSocket.accept(); + submitConnection(clientSocket); + } catch (SocketTimeoutException ignored) { + // Poll-Schleife für Interrupt/Shutdown + } catch (IOException e) { + if (!shuttingDown) { + getLogger().warning("Fehler beim Akzeptieren einer Verbindung: " + e.getMessage()); + } + } catch (Throwable t) { + if (!shuttingDown) { + getLogger().severe("Unbehandelter Fehler im HTTP-Accept-Loop: " + t.getMessage()); + } + } + } + } catch (IOException e) { + if (!shuttingDown) { + getLogger().severe("Konnte ServerSocket nicht starten: " + e.getMessage()); + try { + Thread.sleep(2000L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } finally { + if (localServerSocket != null) { + try { localServerSocket.close(); } catch (IOException ignored) {} + } + serverSocket = null; + } + } + } + + private void submitConnection(Socket clientSocket) { + if (clientSocket == null) return; + try { + clientSocket.setSoTimeout(5000); + clientSocket.setTcpNoDelay(true); + } catch (Exception ignored) {} + + ExecutorService executor = requestExecutor; + if (executor == null || executor.isShutdown()) { + try { clientSocket.close(); } catch (IOException ignored) {} + return; + } + + try { + executor.execute(() -> { + try { + handleConnection(clientSocket); + } finally { + try { clientSocket.close(); } catch (IOException ignored) {} + } + }); + } catch (RejectedExecutionException ex) { + try { clientSocket.close(); } catch (IOException ignored) {} + } + } + + private void handleConnection(Socket clientSocket) { + try (BufferedReader in = new BufferedReader( + new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8)); + OutputStream out = clientSocket.getOutputStream()) { + + String inputLine = in.readLine(); + if (inputLine == null) return; + lastHttpRequestAt.set(System.currentTimeMillis()); + + String[] reqParts = inputLine.split(" "); + if (reqParts.length < 2) return; + String method = reqParts[0].trim(); + String path = reqParts[1].trim(); + String pathOnly = path; + int queryIndex = path.indexOf('?'); + if (queryIndex >= 0) pathOnly = path.substring(0, queryIndex); + + // GET /health + if ("GET".equalsIgnoreCase(method) && "/health".equalsIgnoreCase(path)) { + long lastMs = lastHttpRequestAt.get(); + long age = lastMs <= 0L ? -1L : (System.currentTimeMillis() - lastMs); + sendHttpResponse(out, "{\"success\":true,\"online\":true,\"last_request_age_ms\":" + age + "}", 200); + return; + } + + // GET /antibot/security-log + if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) { + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + payload.put("events", loadAntiBotSecurityEvents(250)); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // Headers lesen + Map headers = new HashMap<>(); + String line; + while ((line = in.readLine()) != null && !line.isEmpty()) { + int idx = line.indexOf(':'); + if (idx > 0) { + headers.put(line.substring(0, idx).trim().toLowerCase(Locale.ROOT), + line.substring(idx + 1).trim()); + } + } + + // GET /network/backendguard/config + if ("GET".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/backendguard/config")) { + Properties guardProps = loadNetworkGuardProperties(); + String requiredApiKey = guardProps.getProperty("backendguard.sync.api_key", "").trim(); + String providedApiKey = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + if (!requiredApiKey.isEmpty() && !requiredApiKey.equals(providedApiKey == null ? "" : providedApiKey.trim())) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + Map guard = new LinkedHashMap<>(); + guard.put("enforcement_enabled", Boolean.parseBoolean(guardProps.getProperty("backendguard.enforcement_enabled", "true"))); + guard.put("log_blocked_attempts", Boolean.parseBoolean(guardProps.getProperty("backendguard.log_blocked_attempts", "true"))); + guard.put("kick_message", guardProps.getProperty("backendguard.kick_message", "&cBitte verbinde dich nur ueber den Proxy.")); + guard.put("allowed_proxy_ips", parseCommaListProperty(guardProps.getProperty("backendguard.allowed_proxy_ips", "127.0.0.1,::1"))); + guard.put("allowed_proxy_cidrs", parseCommaListProperty(guardProps.getProperty("backendguard.allowed_proxy_cidrs", ""))); + payload.put("backend_guard", guard); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // POST /forum/notify + if ("POST".equalsIgnoreCase(method) && path.equalsIgnoreCase("/forum/notify")) { + String body = readBody(in, headers); + String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + Object mod = moduleManager.getModule("ForumBridgeModule"); + if (mod != null) { + try { + java.lang.reflect.Method m = mod.getClass().getMethod("handleNotify", String.class, String.class); + String resp = (String) m.invoke(mod, body, apiKeyHeader); + sendHttpResponse(out, resp, 200); + } catch (Exception e) { + e.printStackTrace(); + sendHttpResponse(out, "{\"success\":false,\"error\":\"internal\"}", 500); + } + } else { + sendHttpResponse(out, "{\"success\":false,\"error\":\"no_forum_module\"}", 500); + } + return; + } + + // POST /network/attack + if ("POST".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/attack")) { + String body = readBody(in, headers); + String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + NetworkInfoModule mod = (NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (mod == null || !mod.isEnabled() || !mod.isAttackNotificationsEnabled()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"network_module_disabled\"}", 403); + return; + } + if (!mod.isAttackApiKeyValid(apiKeyHeader)) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); + return; + } + String eventType = extractJsonString(body, "event"); + if (eventType == null || eventType.trim().isEmpty()) eventType = "detected"; + String source = extractJsonString(body, "source"); + + Integer cps = null, blockedIps = null; + Long blockedConnections = null; + String cpsStr = extractJsonString(body, "connectionsPerSecond"); + if (cpsStr == null || cpsStr.isEmpty()) cpsStr = extractJsonString(body, "cps"); + try { if (cpsStr != null && !cpsStr.isEmpty()) cps = Integer.valueOf(cpsStr.trim()); } catch (Exception ignored) {} + String blockedIpsStr = extractJsonString(body, "ipAddressesBlocked"); + if (blockedIpsStr == null || blockedIpsStr.isEmpty()) blockedIpsStr = extractJsonString(body, "blockedIps"); + try { if (blockedIpsStr != null && !blockedIpsStr.isEmpty()) blockedIps = Integer.valueOf(blockedIpsStr.trim()); } catch (Exception ignored) {} + String blockedConnectionsStr = extractJsonString(body, "connectionsBlocked"); + if (blockedConnectionsStr == null || blockedConnectionsStr.isEmpty()) blockedConnectionsStr = extractJsonString(body, "blockedConnections"); + try { if (blockedConnectionsStr != null && !blockedConnectionsStr.isEmpty()) blockedConnections = Long.valueOf(blockedConnectionsStr.trim()); } catch (Exception ignored) {} + + boolean sent = mod.sendAttackNotification(eventType, cps, blockedIps, blockedConnections, source); + sendHttpResponse(out, sent ? "{\"success\":true}" : "{\"success\":false,\"error\":\"webhook_disabled_or_missing\"}", sent ? 200 : 400); + return; + } + + // POST /broadcast/cancel + if ("POST".equalsIgnoreCase(method) && (path.equalsIgnoreCase("/broadcast/cancel") || path.equalsIgnoreCase("/cancel"))) { + String body = readBody(in, headers); + String clientScheduleId = extractJsonString(body, "clientScheduleId"); + if (clientScheduleId == null || clientScheduleId.isEmpty()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"missing_clientScheduleId\"}", 400); + return; + } + Object mod = moduleManager.getModule("BroadcastModule"); + if (mod instanceof BroadcastModule) { + boolean ok = ((BroadcastModule) mod).cancelScheduled(clientScheduleId); + sendHttpResponse(out, ok ? "{\"success\":true}" : "{\"success\":false,\"error\":\"not_found\"}", ok ? 200 : 404); + } else { + sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); + } + return; + } + + // POST /broadcast + if ("POST".equalsIgnoreCase(method) && ("/broadcast".equalsIgnoreCase(path) || "/".equals(path) || path.isEmpty())) { + String body = readBody(in, headers); + String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + String message = extractJsonString(body, "message"); + String type = extractJsonString(body, "type"); + String prefix = extractJsonString(body, "prefix"); + String prefixColor = extractJsonString(body, "prefixColor"); + String bracketColor = extractJsonString(body, "bracketColor"); + String messageColor = extractJsonString(body, "messageColor"); + String sourceName = extractJsonString(body, "source"); + String scheduleTimeStr= extractJsonString(body, "scheduleTime"); + String recur = extractJsonString(body, "recur"); + String clientScheduleId = extractJsonString(body, "clientScheduleId"); + + if (sourceName == null || sourceName.isEmpty()) sourceName = "PulseCast"; + if (type == null || type.isEmpty()) type = "global"; + + Object mod = moduleManager.getModule("BroadcastModule"); + if (!(mod instanceof BroadcastModule)) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); + return; + } + BroadcastModule bm = (BroadcastModule) mod; + + if (scheduleTimeStr != null && !scheduleTimeStr.trim().isEmpty()) { + long scheduleMillis; + try { + scheduleMillis = Long.parseLong(scheduleTimeStr.trim()); + if (scheduleMillis < 1_000_000_000_000L) scheduleMillis *= 1000L; + } catch (NumberFormatException ignored) { + try { + long v = (long) Double.parseDouble(scheduleTimeStr.trim()); + scheduleMillis = v < 1_000_000_000_000L ? v * 1000L : v; + } catch (Exception ex) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_scheduleTime\"}", 400); + return; + } + } + boolean ok = bm.scheduleBroadcast(scheduleMillis, sourceName, message, type, apiKeyHeader, + prefix, prefixColor, bracketColor, messageColor, + recur == null ? "none" : recur, clientScheduleId); + sendHttpResponse(out, ok ? "{\"success\":true}" : "{\"success\":false,\"error\":\"rejected\"}", ok ? 200 : 403); + return; + } + + if (message == null || message.isEmpty()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"missing_message\"}", 400); + return; + } + boolean ok = bm.handleBroadcast(sourceName, message, type, apiKeyHeader, prefix, prefixColor, bracketColor, messageColor); + sendHttpResponse(out, ok ? "{\"success\":true}" : "{\"success\":false,\"error\":\"rejected\"}", ok ? 200 : 403); + return; + } + + // GET /stats/player?uuid=... oder ?name=... + if ("GET".equalsIgnoreCase(method) && "/stats/player".equalsIgnoreCase(pathOnly)) { + Map qp = parseQueryParams(path); + StatsModule statsMod = (StatsModule) moduleManager.getModule("StatsModule"); + PlayerStats ps = resolvePlayer(qp.get("uuid"), qp.get("name"), statsMod); + if (ps == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + Map playerMap = new LinkedHashMap<>(); + playerMap.put("uuid", ps.uuid.toString()); + playerMap.put("name", ps.name); + playerMap.put("first_seen", ps.firstSeen); + playerMap.put("last_seen", ps.lastSeen); + playerMap.put("playtime", ps.getPlaytimeWithCurrentSession()); + playerMap.put("joins", ps.joins); + playerMap.put("online", ProxyServer.getInstance().getPlayer(ps.uuid) != null); + Map economy = new LinkedHashMap<>(); + economy.put("balance", ps.balance); + economy.put("total_earned", ps.totalEarned); + economy.put("total_spent", ps.totalSpent); + economy.put("transactions_count", ps.transactionsCount); + playerMap.put("economy", economy); + Map punishments = new LinkedHashMap<>(); + punishments.put("bans", ps.bansCount); + punishments.put("mutes", ps.mutesCount); + punishments.put("warns", ps.warnsCount); + punishments.put("last_punishment_at", ps.lastPunishmentAt); + punishments.put("last_punishment_type", ps.lastPunishmentType != null ? ps.lastPunishmentType : ""); + punishments.put("punishment_score", ps.punishmentScore); + playerMap.put("punishments", punishments); + payload.put("player", playerMap); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // GET /economy/player?uuid=... oder ?name=... + if ("GET".equalsIgnoreCase(method) && "/economy/player".equalsIgnoreCase(pathOnly)) { + Map qp = parseQueryParams(path); + StatsModule statsMod = (StatsModule) moduleManager.getModule("StatsModule"); + PlayerStats ps = resolvePlayer(qp.get("uuid"), qp.get("name"), statsMod); + if (ps == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + payload.put("uuid", ps.uuid.toString()); + payload.put("name", ps.name); + Map economy = new LinkedHashMap<>(); + economy.put("balance", ps.balance); + economy.put("total_earned", ps.totalEarned); + economy.put("total_spent", ps.totalSpent); + economy.put("transactions_count", ps.transactionsCount); + payload.put("economy", economy); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // POST /economy/update + if ("POST".equalsIgnoreCase(method) && "/economy/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + StatsModule statsModEco = (StatsModule) moduleManager.getModule("StatsModule"); + PlayerStats psEco = resolvePlayer(extractJsonString(body, "uuid"), extractJsonString(body, "name"), statsModEco); + if (psEco == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + String balStr = extractJsonString(body, "balance"); + String earnStr = extractJsonString(body, "total_earned"); + if (earnStr == null) earnStr = extractJsonString(body, "totalEarned"); + String spentStr = extractJsonString(body, "total_spent"); + if (spentStr == null) spentStr = extractJsonString(body, "totalSpent"); + String txStr = extractJsonString(body, "transactions_count"); + if (txStr == null) txStr = extractJsonString(body, "transactionsCount"); + synchronized (psEco) { + try { if (balStr != null && !balStr.isEmpty()) psEco.balance = Double.parseDouble(balStr); } catch (Exception ignored) {} + try { if (earnStr != null && !earnStr.isEmpty()) psEco.totalEarned = Double.parseDouble(earnStr); } catch (Exception ignored) {} + try { if (spentStr != null && !spentStr.isEmpty()) psEco.totalSpent = Double.parseDouble(spentStr); } catch (Exception ignored) {} + try { if (txStr != null && !txStr.isEmpty()) psEco.transactionsCount = Integer.parseInt(txStr); } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /punishment/update + if ("POST".equalsIgnoreCase(method) && "/punishment/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + StatsModule statsModPun = (StatsModule) moduleManager.getModule("StatsModule"); + PlayerStats psPun = resolvePlayer(extractJsonString(body, "uuid"), extractJsonString(body, "name"), statsModPun); + if (psPun == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + String bansStr = extractJsonString(body, "bans"); + String mutesStr = extractJsonString(body, "mutes"); + String warnsStr = extractJsonString(body, "warns"); + String lastAtStr = extractJsonString(body, "last_punishment_at"); + if (lastAtStr == null) lastAtStr = extractJsonString(body, "lastPunishmentAt"); + String typeStr = extractJsonString(body, "last_punishment_type"); + if (typeStr == null) typeStr = extractJsonString(body, "lastPunishmentType"); + String scoreStr = extractJsonString(body, "punishment_score"); + if (scoreStr == null) scoreStr = extractJsonString(body, "punishmentScore"); + // Typ auf erlaubte Werte begrenzen + String safeType = null; + if (typeStr != null) { + String t = typeStr.trim().toLowerCase(Locale.ROOT); + safeType = (t.equals("ban") || t.equals("mute") || t.equals("warn") || t.equals("kick")) ? t : ""; + } + synchronized (psPun) { + try { if (bansStr != null && !bansStr.isEmpty()) psPun.bansCount = Integer.parseInt(bansStr); } catch (Exception ignored) {} + try { if (mutesStr != null && !mutesStr.isEmpty()) psPun.mutesCount = Integer.parseInt(mutesStr); } catch (Exception ignored) {} + try { if (warnsStr != null && !warnsStr.isEmpty()) psPun.warnsCount = Integer.parseInt(warnsStr); } catch (Exception ignored) {} + try { if (lastAtStr != null && !lastAtStr.isEmpty()) psPun.lastPunishmentAt = Long.parseLong(lastAtStr); } catch (Exception ignored) {} + if (safeType != null) psPun.lastPunishmentType = safeType; + try { if (scoreStr != null && !scoreStr.isEmpty()) psPun.punishmentScore = Integer.parseInt(scoreStr); } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // GET – Status-Endpunkt + if (inputLine.startsWith("GET")) { + Map data = new LinkedHashMap<>(); + data.put("online", true); + + String versionRaw = ProxyServer.getInstance().getVersion(); + String versionClean = (versionRaw != null && versionRaw.contains(":")) ? versionRaw.split(":")[2].trim() : versionRaw; + data.put("version", versionClean); + + int globalLimit = ProxyServer.getInstance().getConfig().getPlayerLimit(); + if (globalLimit <= 0) { + try { + Iterator limIt = ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (limIt.hasNext()) { + int listenerMax = limIt.next().getMaxPlayers(); + if (listenerMax > 0) globalLimit = listenerMax; + } + } catch (Exception ignored) {} + } + data.put("max_players", String.valueOf(globalLimit)); + + String motd = "BungeeCord"; + try { + Iterator it = ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (it.hasNext()) motd = it.next().getMotd(); + } catch (Exception ignored) {} + data.put("motd", motd); + + StatsModule statsModule = (StatsModule) moduleManager.getModule("StatsModule"); + boolean luckPermsEnabled = ProxyServer.getInstance().getPluginManager().getPlugin("LuckPerms") != null; + List> playersList = new ArrayList<>(); + + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + Map playerInfo = new LinkedHashMap<>(); + playerInfo.put("name", p.getName()); + try { playerInfo.put("uuid", p.getUniqueId().toString()); } catch (Exception ignored) {} + + boolean isBedrock = false; + String bedrockId = null; + try { + Class floodgateApi = Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + Object api = floodgateApi.getMethod("getInstance").invoke(null); + isBedrock = (boolean) api.getClass().getMethod("isBedrockPlayer", java.util.UUID.class).invoke(api, p.getUniqueId()); + if (isBedrock) { + bedrockId = (String) api.getClass().getMethod("getBedrockId", java.util.UUID.class).invoke(api, p.getUniqueId()); + } + } catch (Exception ignored) {} + // Fallback: Floodgate-UUIDs haben MSB == 0 (00000000-0000-0000-xxxx-xxxxxxxxxxxx) + if (!isBedrock) { + isBedrock = p.getUniqueId().getMostSignificantBits() == 0L; + } + playerInfo.put("isBedrock", isBedrock); + if (bedrockId != null) playerInfo.put("bedrockId", bedrockId); + + String prefix = ""; + if (luckPermsEnabled) { + try { + Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object luckPermsApi = providerClass.getMethod("get").invoke(null); + Object userManager = luckPermsApi.getClass().getMethod("getUserManager").invoke(luckPermsApi); + Object user = userManager.getClass().getMethod("getUser", java.util.UUID.class).invoke(userManager, p.getUniqueId()); + if (user != null) { + Class queryOptionsClass = Class.forName("net.luckperms.api.query.QueryOptions"); + Object queryOptions = queryOptionsClass.getMethod("defaultContextualOptions").invoke(null); + Object cachedData = user.getClass().getMethod("getCachedData").invoke(user); + Object metaData = cachedData.getClass().getMethod("getMetaData", queryOptionsClass).invoke(cachedData, queryOptions); + Object result = metaData.getClass().getMethod("getPrefix").invoke(metaData); + if (result != null) prefix = (String) result; + } + } catch (Exception ignored) {} + } + playerInfo.put("prefix", prefix); + + if (statsModule != null) { + PlayerStats ps = statsModule.getManager().getIfPresent(p.getUniqueId()); + if (ps != null) { + playerInfo.put("playtime", ps.getPlaytimeWithCurrentSession()); + playerInfo.put("joins", ps.joins); + playerInfo.put("first_seen", ps.firstSeen); + playerInfo.put("last_seen", ps.lastSeen); + Map eco = new LinkedHashMap<>(); + eco.put("balance", ps.balance); + eco.put("total_earned", ps.totalEarned); + eco.put("total_spent", ps.totalSpent); + eco.put("transactions_count", ps.transactionsCount); + playerInfo.put("economy", eco); + Map pun = new LinkedHashMap<>(); + pun.put("bans", ps.bansCount); + pun.put("mutes", ps.mutesCount); + pun.put("warns", ps.warnsCount); + pun.put("last_punishment_at", ps.lastPunishmentAt); + pun.put("last_punishment_type", ps.lastPunishmentType != null ? ps.lastPunishmentType : ""); + pun.put("punishment_score", ps.punishmentScore); + playerInfo.put("punishments", pun); + } + } + playersList.add(playerInfo); + } + data.put("players", playersList); + + NetworkInfoModule networkInfoModule = (NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (networkInfoModule != null && networkInfoModule.isEnabled()) { + data.put("network", networkInfoModule.buildSnapshot()); + } + + AntiBotModule antiBotModule = (AntiBotModule) moduleManager.getModule("AntiBotModule"); + if (antiBotModule != null && antiBotModule.isEnabled()) { + data.put("antibot", antiBotModule.buildSnapshot()); + } + + String json = buildJsonString(data); + byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); + StringBuilder response = new StringBuilder(); + response.append("HTTP/1.1 200 OK\r\n"); + response.append("Content-Type: application/json; charset=UTF-8\r\n"); + response.append("Access-Control-Allow-Origin: *\r\n"); + response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); + response.append("Connection: close\r\n\r\n"); + out.write(response.toString().getBytes(StandardCharsets.UTF_8)); + out.write(jsonBytes); + out.flush(); + } + + } catch (Exception e) { + getLogger().severe("Fehler beim Verarbeiten der Anfrage: " + e.getMessage()); + } + } + + // --- Hilfsmethoden --- + + /** + * Liest den HTTP-Body basierend auf Content-Length. + */ + private String readBody(BufferedReader in, Map headers) throws IOException { + int contentLength = 0; + if (headers.containsKey("content-length")) { + try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} + } + if (contentLength <= 0) return ""; + char[] bodyChars = new char[contentLength]; + int read = 0; + while (read < contentLength) { + int r = in.read(bodyChars, read, contentLength - read); + if (r == -1) break; + read += r; + } + return new String(bodyChars); + } + + private String extractJsonString(String json, String key) { + if (json == null || key == null) return null; + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) return null; + int colon = json.indexOf(':', idx + search.length()); + if (colon < 0) return null; + int i = colon + 1; + while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++; + if (i >= json.length()) return null; + char c = json.charAt(i); + if (c == '"') { + i++; + StringBuilder sb = new StringBuilder(); + boolean escape = false; + while (i < json.length()) { + char ch = json.charAt(i++); + if (escape) { sb.append(ch); escape = false; } + else { + if (ch == '\\') escape = true; + else if (ch == '"') break; + else sb.append(ch); + } + } + return sb.toString(); + } else { + StringBuilder sb = new StringBuilder(); + while (i < json.length()) { + char ch = json.charAt(i); + if (ch == ',' || ch == '}' || ch == '\n' || ch == '\r') break; + sb.append(ch); + i++; + } + return sb.toString().trim(); + } + } + + private Properties loadNetworkGuardProperties() { + Properties props = new Properties(); + File file = new File(getDataFolder(), "network-guard.properties"); + if (!file.exists()) return props; + try (FileInputStream fis = new FileInputStream(file)) { + props.load(new java.io.InputStreamReader(fis, StandardCharsets.UTF_8)); + } catch (Exception e) { + getLogger().warning("Konnte network-guard.properties nicht laden: " + e.getMessage()); + } + return props; + } + + private List parseCommaListProperty(String raw) { + List out = new ArrayList<>(); + if (raw == null || raw.trim().isEmpty()) return out; + for (String p : raw.split(",")) { + String trimmed = p == null ? "" : p.trim(); + if (!trimmed.isEmpty()) out.add(trimmed); + } + return out; + } + + private Map parseQueryParams(String path) { + Map params = new HashMap<>(); + int q = path.indexOf('?'); + if (q < 0) return params; + String query = path.substring(q + 1); + for (String pair : query.split("&")) { + if (pair.isEmpty()) continue; + int eq = pair.indexOf('='); + if (eq <= 0) continue; + params.put(pair.substring(0, eq).trim().toLowerCase(Locale.ROOT), pair.substring(eq + 1).trim()); + } + return params; + } + + private PlayerStats resolvePlayer(String uuidStr, String name, StatsModule statsModule) { + if (statsModule == null) return null; + if (uuidStr != null && !uuidStr.isEmpty()) { + try { + return statsModule.getManager().getIfPresent(UUID.fromString(uuidStr.trim())); + } catch (IllegalArgumentException ignored) {} + } + if (name != null && !name.isEmpty()) { + String lower = name.trim().toLowerCase(Locale.ROOT); + for (PlayerStats ps : statsModule.getManager().all()) { + if (ps.name.toLowerCase(Locale.ROOT).equals(lower)) return ps; + } + } + return null; + } + + private void sendHttpResponse(OutputStream out, String json, int code) throws IOException { + byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); + String status = code == 200 ? "OK" : (code == 403 ? "Forbidden" : code == 404 ? "Not Found" : code == 400 ? "Bad Request" : "Error"); + StringBuilder response = new StringBuilder(); + response.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n"); + response.append("Content-Type: application/json; charset=UTF-8\r\n"); + response.append("Access-Control-Allow-Origin: *\r\n"); + response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); + response.append("Connection: close\r\n\r\n"); + out.write(response.toString().getBytes(StandardCharsets.UTF_8)); + out.write(jsonBytes); + out.flush(); + } + + private String buildJsonString(Map data) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : data.entrySet()) { + if (!first) sb.append(","); + first = false; + sb.append("\"").append(escapeJson(entry.getKey())).append("\":").append(valueToString(entry.getValue())); + } + sb.append("}"); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private String valueToString(Object value) { + if (value == null) return "null"; + else if (value instanceof Boolean) return value.toString(); + else if (value instanceof Number) return value.toString(); + else if (value instanceof Map) return buildJsonString((Map) value); + else if (value instanceof List) { + StringBuilder sb = new StringBuilder("["); + List list = (List) value; + for (int i = 0; i < list.size(); i++) { + if (i > 0) sb.append(","); + Object item = list.get(i); + if (item instanceof Map) sb.append(buildJsonString((Map) item)); + else if (item instanceof Number) sb.append(item.toString()); + else sb.append("\"").append(escapeJson(String.valueOf(item))).append("\""); + } + sb.append("]"); + return sb.toString(); + } + return "\"" + escapeJson(String.valueOf(value)) + "\""; + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private List> loadAntiBotSecurityEvents(int maxEntries) { + List> out = new ArrayList<>(); + File logFile = new File(getDataFolder(), "antibot-security.log"); + if (!logFile.exists() || maxEntries <= 0) return out; + try { + List lines = Files.readAllLines(logFile.toPath(), StandardCharsets.UTF_8); + for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) { + String line = lines.get(i); + if (line == null || line.trim().isEmpty()) continue; + Map parsed = parseSecurityLogLine(line); + String event = parsed.get("event"); + if (!isAttackSecurityEvent(event)) continue; + String player = parsed.get("player"); + String uuid = parsed.get("uuid"); + if (player == null || player.trim().isEmpty() || "-".equals(player) || "unknown".equalsIgnoreCase(player)) continue; + if (uuid == null || uuid.trim().isEmpty()) uuid = "-"; + Map row = new LinkedHashMap<>(); + row.put("datetime", parsed.getOrDefault("datetime", "")); + row.put("player", player); + row.put("uuid", uuid); + row.put("ip", parsed.getOrDefault("ip", "-")); + out.add(row); + } + } catch (Exception e) { + getLogger().warning("Konnte antibot-security.log nicht lesen: " + e.getMessage()); + } + return out; + } + + private Map parseSecurityLogLine(String line) { + Map map = new LinkedHashMap<>(); + if (line == null) return map; + String[] segments = line.split("\\s*\\|\\s*"); + if (segments.length > 0) map.put("datetime", segments[0].trim()); + for (int i = 1; i < segments.length; i++) { + String seg = segments[i]; + int idx = seg.indexOf('='); + if (idx <= 0) continue; + map.put(seg.substring(0, idx).trim().toLowerCase(Locale.ROOT), seg.substring(idx + 1).trim()); + } + return map; + } + + private boolean isAttackSecurityEvent(String event) { + if (event == null) return false; + String e = event.trim().toLowerCase(Locale.ROOT); + return e.contains("ip_rate") || e.contains("vpn") || e.contains("learning_threshold_block") || e.contains("block"); + } +} diff --git a/src/main/java/net/viper/status/UpdateChecker.java b/src/main/java/net/viper/status/UpdateChecker.java index c46733b..642dfb4 100644 --- a/src/main/java/net/viper/status/UpdateChecker.java +++ b/src/main/java/net/viper/status/UpdateChecker.java @@ -102,8 +102,6 @@ public class UpdateChecker { plugin.getLogger().warning("Keine StatusAPI.jar im neuesten Release gefunden."); return; } - - plugin.getLogger().info("Gefundene Version: " + foundVersion + " (Aktuell: " + currentVersion + ")"); latestVersion = foundVersion; latestUrl = foundUrl; diff --git a/src/main/java/net/viper/status/module/ModuleManager.java b/src/main/java/net/viper/status/module/ModuleManager.java index ed0badc..34620e7 100644 --- a/src/main/java/net/viper/status/module/ModuleManager.java +++ b/src/main/java/net/viper/status/module/ModuleManager.java @@ -1,60 +1,59 @@ -package net.viper.status.module; - -import net.md_5.bungee.api.plugin.Plugin; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -/** - * Verwaltet alle geladenen Module. - */ -public class ModuleManager { - - private final Map modules = new HashMap<>(); - - public void registerModule(Module module) { - modules.put(module.getName().toLowerCase(), module); - } - - public void enableAll(Plugin plugin) { - for (Module module : modules.values()) { - try { - plugin.getLogger().info("Aktiviere Modul: " + module.getName() + "..."); - module.onEnable(plugin); - } catch (Exception e) { - plugin.getLogger().severe("Fehler beim Aktivieren von Modul " + module.getName() + ": " + e.getMessage()); - e.printStackTrace(); - } - } - } - - public void disableAll(Plugin plugin) { - for (Module module : modules.values()) { - try { - plugin.getLogger().info("Deaktiviere Modul: " + module.getName() + "..."); - module.onDisable(plugin); - } catch (Exception e) { - plugin.getLogger().warning("Fehler beim Deaktivieren von Modul " + module.getName()); - } - } - modules.clear(); - } - - /** - * Ermöglicht anderen Komponenten (wie dem WebServer) Zugriff auf spezifische Module. - */ - public Module getModule(String name) { - return modules.get(name.toLowerCase()); - } - - @SuppressWarnings("unchecked") - public T getModule(Class clazz) { - for (Module m : modules.values()) { - if (clazz.isInstance(m)) { - return (T) m; - } - } - return null; - } -} \ No newline at end of file +package net.viper.status.module; + +import net.md_5.bungee.api.plugin.Plugin; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Verwaltet alle geladenen Module. + * Verwendet LinkedHashMap um die Registrierungsreihenfolge zu erhalten, + * damit Abhängigkeiten (z.B. VanishModule → ChatModule) korrekt aufgelöst werden. + */ +public class ModuleManager { + + private final Map modules = new LinkedHashMap<>(); + + public void registerModule(Module module) { + modules.put(module.getName().toLowerCase(), module); + } + + public void enableAll(Plugin plugin) { + for (Module module : modules.values()) { + try { + module.onEnable(plugin); + } catch (Exception e) { + plugin.getLogger().severe("Fehler beim Aktivieren von Modul " + module.getName() + ": " + e.getMessage()); + e.printStackTrace(); + } + } + } + + public void disableAll(Plugin plugin) { + for (Module module : modules.values()) { + try { + module.onDisable(plugin); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Deaktivieren von Modul " + module.getName()); + } + } + modules.clear(); + } + + /** + * Ermöglicht anderen Komponenten (wie dem WebServer) Zugriff auf spezifische Module. + */ + public Module getModule(String name) { + return modules.get(name.toLowerCase()); + } + + @SuppressWarnings("unchecked") + public T getModule(Class clazz) { + for (Module m : modules.values()) { + if (clazz.isInstance(m)) { + return (T) m; + } + } + return null; + } +} diff --git a/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java b/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java index 8eb3963..7f2f1f6 100644 --- a/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java +++ b/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java @@ -1,115 +1,132 @@ -package net.viper.status.modules.AutoMessage; - -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.ProxyServer; -import net.md_5.bungee.api.chat.TextComponent; -import net.md_5.bungee.api.plugin.Plugin; -import net.viper.status.StatusAPI; -import net.viper.status.module.Module; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; -import java.util.Properties; -import java.util.concurrent.TimeUnit; - -public class AutoMessageModule implements Module { - - private int taskId = -1; - - // Diese Methode fehlte bisher und ist zwingend für das Interface - @Override - public String getName() { - return "AutoMessage"; - } - - @Override - public void onEnable(Plugin plugin) { - // Hier casten wir das Plugin-Objekt zu StatusAPI, um an spezifische Methoden zu kommen - StatusAPI api = (StatusAPI) plugin; - - // Konfiguration aus der zentralen verify.properties laden - Properties props = api.getVerifyProperties(); - - boolean enabled = Boolean.parseBoolean(props.getProperty("automessage.enabled", "false")); - - if (!enabled) { - api.getLogger().info("AutoMessage-Modul ist deaktiviert."); - return; - } - - // Interval in Sekunden einlesen - int intervalSeconds; - try { - intervalSeconds = Integer.parseInt(props.getProperty("automessage.interval", "300")); - } catch (NumberFormatException e) { - api.getLogger().warning("Ungültiges Intervall für AutoMessage! Nutze Standard (300s)."); - intervalSeconds = 300; - } - - // Dateiname einlesen (Standard: messages.txt) - String fileName = props.getProperty("automessage.file", "messages.txt"); - File messageFile = new File(api.getDataFolder(), fileName); - - if (!messageFile.exists()) { - api.getLogger().warning("Die Datei '" + fileName + "' wurde nicht gefunden (" + messageFile.getAbsolutePath() + ")!"); - api.getLogger().info("Erstelle eine leere Datei '" + fileName + "' als Vorlage..."); - try { - messageFile.createNewFile(); - } catch (IOException e) { - api.getLogger().severe("Konnte Datei nicht erstellen: " + e.getMessage()); - } - return; - } - - // Nachrichten aus der Datei lesen - List messages; - try { - messages = Files.readAllLines(messageFile.toPath(), StandardCharsets.UTF_8); - } catch (IOException e) { - api.getLogger().severe("Fehler beim Lesen von '" + fileName + "': " + e.getMessage()); - return; - } - - // Leere Zeilen und Kommentare herausfiltern - messages.removeIf(line -> line.trim().isEmpty() || line.trim().startsWith("#")); - - if (messages.isEmpty()) { - api.getLogger().warning("Die Datei '" + fileName + "' enthält keine gültigen Nachrichten!"); - return; - } - - // Optional: Prefix aus Config lesen - String prefixRaw = props.getProperty("automessage.prefix", ""); - String prefix = ChatColor.translateAlternateColorCodes('&', prefixRaw); - - api.getLogger().info("Starte AutoMessage-Task (" + messages.size() + " Nachrichten aus " + fileName + ")"); - - // Finaler Index für den Lambda-Ausdruck - final int[] currentIndex = {0}; - - // Task planen - taskId = ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { - String msg = messages.get(currentIndex[0]); - - String finalMessage = (prefix.isEmpty() ? "" : prefix + " ") + msg; - - // Nachricht an alle auf dem Proxy senden - ProxyServer.getInstance().broadcast(TextComponent.fromLegacy(finalMessage)); - - // Index erhöhen und Loop starten - currentIndex[0] = (currentIndex[0] + 1) % messages.size(); - }, intervalSeconds, intervalSeconds, TimeUnit.SECONDS).getId(); - } - - @Override - public void onDisable(Plugin plugin) { - if (taskId != -1) { - ProxyServer.getInstance().getScheduler().cancel(taskId); - taskId = -1; - plugin.getLogger().info("AutoMessage-Task gestoppt."); - } - } -} \ No newline at end of file +package net.viper.status.modules.AutoMessage; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * AutoMessageModule + * + * Fix #5: + * - Nachrichten werden bei jedem Zyklus frisch aus der Datei gelesen, + * damit Änderungen an messages.txt sofort wirken ohne Neustart. + * - Neuer Befehl /automessage reload (Permission: statusapi.automessage) + * lädt die Konfiguration neu und setzt den Zähler zurück. + * - TextComponent.fromLegacy() → ChatColor.translateAlternateColorCodes für §-Codes. + */ +public class AutoMessageModule implements Module { + + private int taskId = -1; + private StatusAPI api; + private final AtomicInteger currentIndex = new AtomicInteger(0); + + // Konfiguration (für Reload zugänglich) + private volatile boolean enabled = false; + private volatile int intervalSeconds = 300; + private volatile String fileName = "messages.txt"; + private volatile String prefix = ""; + + @Override + public String getName() { return "AutoMessage"; } + + @Override + public void onEnable(Plugin plugin) { + this.api = (StatusAPI) plugin; + loadSettings(); + + if (!enabled) return; + + registerReloadCommand(); + scheduleTask(); + } + + @Override + public void onDisable(Plugin plugin) { + cancelTask(); + } + + private void loadSettings() { + Properties props = api.getVerifyProperties(); + enabled = Boolean.parseBoolean(props.getProperty("automessage.enabled", "false")); + String rawInterval = props.getProperty("automessage.interval", "300"); + try { intervalSeconds = Integer.parseInt(rawInterval); } + catch (NumberFormatException e) { api.getLogger().warning("Ungültiges Intervall für AutoMessage! Nutze Standard (300s)."); intervalSeconds = 300; } + fileName = props.getProperty("automessage.file", "messages.txt"); + prefix = props.getProperty("automessage.prefix", ""); + } + + private void registerReloadCommand() { + ProxyServer.getInstance().getPluginManager().registerCommand(api, new Command("automessage", "statusapi.automessage") { + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length > 0 && "reload".equalsIgnoreCase(args[0])) { + cancelTask(); + loadSettings(); + currentIndex.set(0); + if (enabled) { + scheduleTask(); + sender.sendMessage(ChatColor.GREEN + "[AutoMessage] Neu geladen. Intervall: " + intervalSeconds + "s"); + } else { + sender.sendMessage(ChatColor.YELLOW + "[AutoMessage] Modul ist deaktiviert (automessage.enabled=false)."); + } + } else { + sender.sendMessage(ChatColor.YELLOW + "/automessage reload"); + } + } + }); + } + + private void scheduleTask() { + taskId = ProxyServer.getInstance().getScheduler().schedule(api, () -> { + File messageFile = new File(api.getDataFolder(), fileName); + if (!messageFile.exists()) { + api.getLogger().warning("[AutoMessage] Datei nicht gefunden: " + messageFile.getAbsolutePath()); + return; + } + + // Fix #5: Datei bei jedem Tick neu einlesen → Änderungen wirken sofort + List messages; + try { + messages = Files.readAllLines(messageFile.toPath(), StandardCharsets.UTF_8); + } catch (IOException e) { + api.getLogger().severe("[AutoMessage] Fehler beim Lesen von '" + fileName + "': " + e.getMessage()); + return; + } + messages.removeIf(line -> line.trim().isEmpty() || line.trim().startsWith("#")); + if (messages.isEmpty()) return; + + // Index wrappen (threadsafe) + int idx = currentIndex.getAndUpdate(i -> (i + 1) % messages.size()); + if (idx >= messages.size()) idx = 0; + + String raw = messages.get(idx); + String prefixPart = prefix.isEmpty() ? "" : ChatColor.translateAlternateColorCodes('&', prefix) + " "; + // Fix: §-Codes direkt übersetzen (messages.txt nutzt §-Codes) + String text = prefixPart + ChatColor.translateAlternateColorCodes('&', + raw.replace("\u00a7", "&").replace("§", "&")); + + ProxyServer.getInstance().broadcast(new TextComponent(text)); + }, intervalSeconds, intervalSeconds, TimeUnit.SECONDS).getId(); + } + + private void cancelTask() { + if (taskId != -1) { + ProxyServer.getInstance().getScheduler().cancel(taskId); + taskId = -1; + } + } +} diff --git a/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java b/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java index f7213da..3ff9864 100644 --- a/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java +++ b/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java @@ -1,1118 +1,840 @@ -package net.viper.status.modules.antibot; - -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.CommandSender; -import net.md_5.bungee.api.ProxyServer; -import net.md_5.bungee.api.connection.PendingConnection; -import net.md_5.bungee.api.connection.ProxiedPlayer; -import net.md_5.bungee.api.event.PreLoginEvent; -import net.md_5.bungee.api.event.PostLoginEvent; -import net.md_5.bungee.api.plugin.Command; -import net.md_5.bungee.api.plugin.Listener; -import net.md_5.bungee.api.plugin.Plugin; -import net.md_5.bungee.event.EventHandler; -import net.viper.status.StatusAPI; -import net.viper.status.module.Module; -import net.viper.status.modules.network.NetworkInfoModule; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileWriter; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.InetSocketAddress; -import java.net.URL; -import java.nio.file.Files; -import java.nio.charset.StandardCharsets; -import java.text.SimpleDateFormat; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Date; -import java.util.Deque; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Eigenstaendige AntiBot/Attack-Guard Funktionen, angelehnt an BetterBungee-Ideen. - */ -public class AntiBotModule implements Module, Listener { - - private static final String CONFIG_FILE_NAME = "network-guard.properties"; - - private StatusAPI plugin; - - private boolean enabled = true; - private String profile = "high-traffic"; - private int maxCps = 120; - private int attackStartCps = 220; - private int attackStopCps = 120; - private int attackCalmSeconds = 20; - private int ipConnectionsPerMinute = 18; - private int ipBlockSeconds = 600; - private String kickMessage = "Zu viele Verbindungen von deiner IP. Bitte warte kurz."; - - private boolean vpnCheckEnabled = false; - private boolean vpnBlockProxy = true; - private boolean vpnBlockHosting = true; - private int vpnCacheMinutes = 30; - private int vpnTimeoutMs = 2500; - private boolean securityLogEnabled = true; - private String securityLogFileName = "antibot-security.log"; - private File securityLogFile; - private final Object securityLogLock = new Object(); - - private boolean learningModeEnabled = true; - private int learningScoreThreshold = 100; - private int learningDecayPerSecond = 2; - private int learningStateWindowSeconds = 120; - private int learningRapidWindowMs = 1500; - private int learningRapidPoints = 12; - private int learningIpRateExceededPoints = 30; - private int learningVpnProxyPoints = 40; - private int learningVpnHostingPoints = 30; - private int learningAttackModePoints = 12; - private int learningHighCpsPoints = 10; - private int learningRecentEventLimit = 30; - - private final AtomicInteger currentSecondConnections = new AtomicInteger(0); - private volatile long currentSecond = System.currentTimeMillis() / 1000L; - private volatile int lastCps = 0; - private final AtomicInteger peakCps = new AtomicInteger(0); - - private volatile boolean attackMode = false; - private volatile long attackCalmSince = 0L; - private final AtomicLong blockedConnectionsTotal = new AtomicLong(0L); - private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L); - private final Set blockedIpsCurrentAttack = ConcurrentHashMap.newKeySet(); - - private final Map perIpWindows = new ConcurrentHashMap(); - private final Map blockedIpsUntil = new ConcurrentHashMap(); - private final Map vpnCache = new ConcurrentHashMap(); - private final Map recentIdentityByIp = new ConcurrentHashMap(); - private final Map learningProfiles = new ConcurrentHashMap(); - private final Deque learningRecentEvents = new ArrayDeque(); - - @Override - public String getName() { - return "AntiBotModule"; - } - - @Override - public void onEnable(Plugin plugin) { - if (!(plugin instanceof StatusAPI)) { - return; - } - this.plugin = (StatusAPI) plugin; - ensureModuleConfigExists(); - loadConfig(); - ensureSecurityLogFile(); - - if (!enabled) { - this.plugin.getLogger().info("[AntiBotModule] deaktiviert via " + CONFIG_FILE_NAME + " (antibot.enabled=false)"); - return; - } - - ProxyServer.getInstance().getPluginManager().registerListener(this.plugin, this); - ProxyServer.getInstance().getPluginManager().registerCommand(this.plugin, new AntiBotCommand()); - ProxyServer.getInstance().getScheduler().schedule(this.plugin, this::tick, 1, 1, TimeUnit.SECONDS); - - this.plugin.getLogger().info("[AntiBotModule] aktiviert. maxCps=" + maxCps + ", attackStartCps=" + attackStartCps + ", ip/min=" + ipConnectionsPerMinute); - } - - @Override - public void onDisable(Plugin plugin) { - perIpWindows.clear(); - blockedIpsUntil.clear(); - vpnCache.clear(); - learningProfiles.clear(); - synchronized (learningRecentEvents) { - learningRecentEvents.clear(); - } - blockedIpsCurrentAttack.clear(); - attackMode = false; - } - - public boolean isEnabled() { - return enabled; - } - - private void reloadRuntimeState() { - perIpWindows.clear(); - blockedIpsUntil.clear(); - vpnCache.clear(); - learningProfiles.clear(); - synchronized (learningRecentEvents) { - learningRecentEvents.clear(); - } - blockedIpsCurrentAttack.clear(); - attackMode = false; - attackCalmSince = 0L; - blockedConnectionsCurrentAttack.set(0L); - currentSecondConnections.set(0); - lastCps = 0; - peakCps.set(0); - loadConfig(); - ensureSecurityLogFile(); - } - - public Map buildSnapshot() { - Map out = new LinkedHashMap(); - out.put("enabled", enabled); - out.put("profile", profile); - out.put("attack_mode", attackMode); - out.put("protection_enabled", enabled); - out.put("attack_mode_status", attackMode ? "active" : "normal"); - out.put("attack_mode_display", attackMode ? "Angriff erkannt" : "Normalbetrieb"); - out.put("status_message", enabled - ? (attackMode ? "AntiBot aktiv: Angriff erkannt" : "AntiBot aktiv: kein Angriff erkannt") - : "AntiBot deaktiviert"); - out.put("last_cps", lastCps); - out.put("peak_cps", peakCps.get()); - out.put("blocked_ips_active", blockedIpsUntil.size()); - out.put("blocked_connections_total", blockedConnectionsTotal.get()); - out.put("vpn_check_enabled", vpnCheckEnabled); - out.put("learning_mode_enabled", learningModeEnabled); - out.put("learning_profiles", learningProfiles.size()); - out.put("thresholds", buildThresholds()); - return out; - } - - private Map buildThresholds() { - Map m = new LinkedHashMap(); - m.put("max_cps", maxCps); - m.put("attack_start_cps", attackStartCps); - m.put("attack_stop_cps", attackStopCps); - m.put("attack_calm_seconds", attackCalmSeconds); - m.put("ip_connections_per_minute", ipConnectionsPerMinute); - m.put("ip_block_seconds", ipBlockSeconds); - m.put("learning_score_threshold", learningScoreThreshold); - m.put("learning_decay_per_second", learningDecayPerSecond); - return m; - } - - @EventHandler - public void onPreLogin(PreLoginEvent event) { - if (!enabled) { - return; - } - - String ip = extractIp(event.getConnection()); - if (ip == null || ip.isEmpty()) { - return; - } - - cacheRecentIdentity(ip, event.getConnection(), System.currentTimeMillis()); - - recordConnection(); - long now = System.currentTimeMillis(); - - cleanupExpired(now); - - Long blockedUntil = blockedIpsUntil.get(ip); - if (blockedUntil != null && blockedUntil > now) { - logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil); - blockEvent(event); - return; - } - - if (learningModeEnabled) { - evaluateLearningBaseline(ip, now); - Long learningBlock = blockedIpsUntil.get(ip); - if (learningBlock != null && learningBlock > now) { - blockEvent(event); - return; - } - } - - boolean ipRateExceeded = isIpRateExceeded(ip, now); - if (ipRateExceeded) { - if (learningModeEnabled) { - int score = addLearningScore(ip, now, learningIpRateExceededPoints, "ip-rate-exceeded", true); - logSecurityEvent("ip_rate_exceeded_scored", ip, event.getConnection(), "score=" + score + ", threshold=" + learningScoreThreshold); - if (score >= learningScoreThreshold) { - logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=ip-rate-exceeded, score=" + score); - blockEvent(event); - return; - } - } else { - blockIp(ip, now); - logSecurityEvent("ip_rate_limit_block", ip, event.getConnection(), "mode=direct"); - blockEvent(event); - return; - } - } - - if (vpnCheckEnabled) { - VpnCheckResult info = getVpnInfo(ip, now); - if (info != null) { - boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting); - if (shouldBlock) { - logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting); - if (learningModeEnabled) { - if (vpnBlockProxy && info.proxy) { - addLearningScore(ip, now, learningVpnProxyPoints, "vpn-proxy", false); - } - if (vpnBlockHosting && info.hosting) { - addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false); - } - - int current = getLearningScore(ip, now); - if (current >= learningScoreThreshold) { - blockIp(ip, now); - logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current); - recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current); - blockEvent(event); - } - } else { - blockIp(ip, now); - logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting); - blockEvent(event); - } - } - } - } - } - - @EventHandler - public void onPostLogin(PostLoginEvent event) { - if (!enabled || event == null || event.getPlayer() == null) { - return; - } - - ProxiedPlayer player = event.getPlayer(); - String ip = extractIpFromPlayer(player); - if (ip == null || ip.isEmpty()) { - return; - } - - cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), System.currentTimeMillis()); - } - - private void blockEvent(PreLoginEvent event) { - event.setCancelled(true); - } - - private String extractIp(PendingConnection conn) { - if (conn == null || conn.getAddress() == null) { - return null; - } - if (conn.getAddress() instanceof InetSocketAddress) { - InetSocketAddress sa = (InetSocketAddress) conn.getAddress(); - if (sa.getAddress() != null) { - return sa.getAddress().getHostAddress(); - } - return sa.getHostString(); - } - return String.valueOf(conn.getAddress()); - } - - private String extractIpFromPlayer(ProxiedPlayer player) { - if (player == null || player.getAddress() == null) { - return null; - } - if (player.getAddress() instanceof InetSocketAddress) { - InetSocketAddress sa = (InetSocketAddress) player.getAddress(); - if (sa.getAddress() != null) { - return sa.getAddress().getHostAddress(); - } - return sa.getHostString(); - } - return String.valueOf(player.getAddress()); - } - - private void recordConnection() { - long sec = System.currentTimeMillis() / 1000L; - if (sec != currentSecond) { - synchronized (this) { - if (sec != currentSecond) { - currentSecond = sec; - currentSecondConnections.set(0); - } - } - } - currentSecondConnections.incrementAndGet(); - } - - private boolean isIpRateExceeded(String ip, long now) { - IpWindow window = perIpWindows.computeIfAbsent(ip, k -> new IpWindow(now)); - synchronized (window) { - long diff = now - window.windowStart; - if (diff > 60_000L) { - window.windowStart = now; - window.count = 0; - } - window.count++; - return window.count > Math.max(1, ipConnectionsPerMinute); - } - } - - private void blockIp(String ip, long now) { - blockedIpsUntil.put(ip, now + Math.max(1, ipBlockSeconds) * 1000L); - blockedConnectionsTotal.incrementAndGet(); - - if (attackMode) { - blockedConnectionsCurrentAttack.incrementAndGet(); - blockedIpsCurrentAttack.add(ip); - } - } - - private void cleanupExpired(long now) { - for (Map.Entry entry : blockedIpsUntil.entrySet()) { - if (entry.getValue() <= now) { - blockedIpsUntil.remove(entry.getKey()); - } - } - - for (Map.Entry entry : vpnCache.entrySet()) { - if (entry.getValue().expiresAt <= now) { - vpnCache.remove(entry.getKey()); - } - } - - for (Map.Entry entry : recentIdentityByIp.entrySet()) { - RecentPlayerIdentity id = entry.getValue(); - if (id == null || (now - id.updatedAtMs) > 600_000L) { - recentIdentityByIp.remove(entry.getKey()); - } - } - - if (learningModeEnabled) { - long staleAfter = Math.max(60, learningStateWindowSeconds) * 1000L; - for (Map.Entry entry : learningProfiles.entrySet()) { - LearningProfile lp = entry.getValue(); - if (lp == null) { - learningProfiles.remove(entry.getKey()); - continue; - } - if ((now - lp.lastSeenAt) > staleAfter && lp.score <= 0) { - learningProfiles.remove(entry.getKey()); - } - } - } - } - - private void tick() { - if (!enabled) { - return; - } - - int cps = currentSecondConnections.getAndSet(0); - lastCps = cps; - - if (cps > peakCps.get()) { - peakCps.set(cps); - } - - long now = System.currentTimeMillis(); - - if (!attackMode && cps >= Math.max(1, attackStartCps)) { - attackMode = true; - attackCalmSince = 0L; - blockedConnectionsCurrentAttack.set(0L); - blockedIpsCurrentAttack.clear(); - sendAttackToWebhook("detected", cps, null, null, "StatusAPI AntiBot"); - plugin.getLogger().warning("[AntiBotModule] Attack erkannt. CPS=" + cps); - return; - } - - if (attackMode) { - if (cps <= Math.max(1, attackStopCps)) { - if (attackCalmSince == 0L) { - attackCalmSince = now; - } - - long calmFor = now - attackCalmSince; - if (calmFor >= Math.max(1, attackCalmSeconds) * 1000L) { - attackMode = false; - attackCalmSince = 0L; - - int blockedIps = blockedIpsCurrentAttack.size(); - long blockedConns = blockedConnectionsCurrentAttack.get(); - sendAttackToWebhook("stopped", cps, blockedIps, blockedConns, "StatusAPI AntiBot"); - plugin.getLogger().warning("[AntiBotModule] Attack beendet. blockedIps=" + blockedIps + ", blockedConnections=" + blockedConns); - } - } else { - attackCalmSince = 0L; - } - } - } - - private void sendAttackToWebhook(String type, Integer cps, Integer blockedIps, Long blockedConnections, String source) { - NetworkInfoModule networkInfoModule = getNetworkInfoModule(); - if (networkInfoModule == null) { - return; - } - networkInfoModule.sendAttackNotification(type, cps, blockedIps, blockedConnections, source); - } - - private NetworkInfoModule getNetworkInfoModule() { - if (plugin == null || plugin.getModuleManager() == null) { - return null; - } - return (NetworkInfoModule) plugin.getModuleManager().getModule("NetworkInfoModule"); - } - - private void loadConfig() { - File file = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); - if (!file.exists()) { - return; - } - - Properties props = new Properties(); - try (FileInputStream in = new FileInputStream(file)) { - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - - enabled = parseBoolean(props.getProperty("antibot.enabled"), true); - profile = normalizeProfile(props.getProperty("antibot.profile", "high-traffic")); - applyProfileDefaults(profile); - - maxCps = parseInt(props.getProperty("antibot.max_cps"), maxCps); - attackStartCps = parseInt(props.getProperty("antibot.attack.start_cps"), attackStartCps); - attackStopCps = parseInt(props.getProperty("antibot.attack.stop_cps"), attackStopCps); - attackCalmSeconds = parseInt(props.getProperty("antibot.attack.stop_grace_seconds"), attackCalmSeconds); - ipConnectionsPerMinute = parseInt(props.getProperty("antibot.ip.max_connections_per_minute"), ipConnectionsPerMinute); - ipBlockSeconds = parseInt(props.getProperty("antibot.ip.block_seconds"), ipBlockSeconds); - kickMessage = props.getProperty("antibot.kick_message", kickMessage); - - vpnCheckEnabled = parseBoolean(props.getProperty("antibot.vpn_check.enabled"), vpnCheckEnabled); - vpnBlockProxy = parseBoolean(props.getProperty("antibot.vpn_check.block_proxy"), vpnBlockProxy); - vpnBlockHosting = parseBoolean(props.getProperty("antibot.vpn_check.block_hosting"), vpnBlockHosting); - vpnCacheMinutes = parseInt(props.getProperty("antibot.vpn_check.cache_minutes"), vpnCacheMinutes); - vpnTimeoutMs = parseInt(props.getProperty("antibot.vpn_check.timeout_ms"), vpnTimeoutMs); - securityLogEnabled = parseBoolean(props.getProperty("antibot.security_log.enabled"), securityLogEnabled); - securityLogFileName = props.getProperty("antibot.security_log.file", securityLogFileName).trim(); - if (securityLogFileName.isEmpty()) { - securityLogFileName = "antibot-security.log"; - } - - learningModeEnabled = parseBoolean(props.getProperty("antibot.learning.enabled"), learningModeEnabled); - learningScoreThreshold = parseInt(props.getProperty("antibot.learning.score_threshold"), learningScoreThreshold); - learningDecayPerSecond = parseInt(props.getProperty("antibot.learning.decay_per_second"), learningDecayPerSecond); - learningStateWindowSeconds = parseInt(props.getProperty("antibot.learning.state_window_seconds"), learningStateWindowSeconds); - learningRapidWindowMs = parseInt(props.getProperty("antibot.learning.rapid.window_ms"), learningRapidWindowMs); - learningRapidPoints = parseInt(props.getProperty("antibot.learning.rapid.points"), learningRapidPoints); - learningIpRateExceededPoints = parseInt(props.getProperty("antibot.learning.ip_rate_exceeded.points"), learningIpRateExceededPoints); - learningVpnProxyPoints = parseInt(props.getProperty("antibot.learning.vpn_proxy.points"), learningVpnProxyPoints); - learningVpnHostingPoints = parseInt(props.getProperty("antibot.learning.vpn_hosting.points"), learningVpnHostingPoints); - learningAttackModePoints = parseInt(props.getProperty("antibot.learning.attack_mode.points"), learningAttackModePoints); - learningHighCpsPoints = parseInt(props.getProperty("antibot.learning.high_cps.points"), learningHighCpsPoints); - learningRecentEventLimit = parseInt(props.getProperty("antibot.learning.recent_events.limit"), learningRecentEventLimit); - } catch (Exception e) { - plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage()); - } - } - - private String normalizeProfile(String raw) { - if (raw == null) { - return "high-traffic"; - } - String value = raw.trim().toLowerCase(Locale.ROOT); - if ("strict".equals(value)) { - return "strict"; - } - return "high-traffic"; - } - - private void applyProfileDefaults(String profileName) { - if ("strict".equals(profileName)) { - maxCps = 120; - attackStartCps = 220; - attackStopCps = 120; - attackCalmSeconds = 20; - ipConnectionsPerMinute = 18; - ipBlockSeconds = 900; - vpnCheckEnabled = true; - vpnBlockProxy = true; - vpnBlockHosting = true; - vpnCacheMinutes = 30; - vpnTimeoutMs = 2500; - return; - } - - maxCps = 180; - attackStartCps = 300; - attackStopCps = 170; - attackCalmSeconds = 25; - ipConnectionsPerMinute = 24; - ipBlockSeconds = 600; - vpnCheckEnabled = false; - vpnBlockProxy = true; - vpnBlockHosting = true; - vpnCacheMinutes = 30; - vpnTimeoutMs = 2500; - } - - private boolean isSupportedProfile(String raw) { - if (raw == null) { - return false; - } - String value = raw.trim().toLowerCase(Locale.ROOT); - return "strict".equals(value) || "high-traffic".equals(value); - } - - private boolean applyProfileAndPersist(String requestedProfile) { - if (!isSupportedProfile(requestedProfile)) { - return false; - } - - String normalized = normalizeProfile(requestedProfile); - profile = normalized; - applyProfileDefaults(normalized); - - Map values = new LinkedHashMap(); - values.put("antibot.profile", normalized); - values.put("antibot.max_cps", String.valueOf(maxCps)); - values.put("antibot.attack.start_cps", String.valueOf(attackStartCps)); - values.put("antibot.attack.stop_cps", String.valueOf(attackStopCps)); - values.put("antibot.attack.stop_grace_seconds", String.valueOf(attackCalmSeconds)); - values.put("antibot.ip.max_connections_per_minute", String.valueOf(ipConnectionsPerMinute)); - values.put("antibot.ip.block_seconds", String.valueOf(ipBlockSeconds)); - values.put("antibot.vpn_check.enabled", String.valueOf(vpnCheckEnabled)); - values.put("antibot.vpn_check.block_proxy", String.valueOf(vpnBlockProxy)); - values.put("antibot.vpn_check.block_hosting", String.valueOf(vpnBlockHosting)); - values.put("antibot.vpn_check.cache_minutes", String.valueOf(vpnCacheMinutes)); - values.put("antibot.vpn_check.timeout_ms", String.valueOf(vpnTimeoutMs)); - - try { - updateConfigValues(values); - return true; - } catch (Exception e) { - plugin.getLogger().warning("[AntiBotModule] Konnte Profil nicht speichern: " + e.getMessage()); - return false; - } - } - - private synchronized void updateConfigValues(Map keyValues) throws Exception { - File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); - List lines = target.exists() - ? Files.readAllLines(target.toPath(), StandardCharsets.UTF_8) - : new ArrayList(); - - for (Map.Entry entry : keyValues.entrySet()) { - String key = entry.getKey(); - String newLine = key + "=" + entry.getValue(); - boolean replaced = false; - - for (int i = 0; i < lines.size(); i++) { - String current = lines.get(i).trim(); - if (current.startsWith(key + "=")) { - lines.set(i, newLine); - replaced = true; - break; - } - } - - if (!replaced) { - lines.add(newLine); - } - } - - Files.write(target.toPath(), lines, StandardCharsets.UTF_8); - } - - private void ensureModuleConfigExists() { - File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); - if (target.exists()) { - return; - } - - if (!plugin.getDataFolder().exists()) { - plugin.getDataFolder().mkdirs(); - } - - try (InputStream in = plugin.getResourceAsStream(CONFIG_FILE_NAME); - FileOutputStream out = new FileOutputStream(target)) { - if (in == null) { - plugin.getLogger().warning("[AntiBotModule] Standarddatei " + CONFIG_FILE_NAME + " nicht im JAR gefunden."); - return; - } - byte[] buffer = new byte[4096]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - plugin.getLogger().info("[AntiBotModule] " + CONFIG_FILE_NAME + " wurde erstellt."); - } catch (Exception e) { - plugin.getLogger().warning("[AntiBotModule] Konnte " + CONFIG_FILE_NAME + " nicht erstellen: " + e.getMessage()); - } - } - - private void evaluateLearningBaseline(String ip, long now) { - LearningProfile profile = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); - - synchronized (profile) { - decayLearningProfile(profile, now); - - long delta = now - profile.lastConnectionAt; - if (profile.lastConnectionAt > 0 && delta <= Math.max(250L, learningRapidWindowMs)) { - profile.rapidStreak++; - int points = learningRapidPoints + Math.min(profile.rapidStreak, 5); - profile.score += Math.max(1, points); - recordLearningEvent("IP=" + ip + " +" + points + " rapid-connect score=" + profile.score); - } else { - profile.rapidStreak = 0; - } - - if (attackMode) { - profile.score += Math.max(1, learningAttackModePoints); - recordLearningEvent("IP=" + ip + " +" + learningAttackModePoints + " attack-mode score=" + profile.score); - } - - if (lastCps >= Math.max(1, maxCps)) { - profile.score += Math.max(1, learningHighCpsPoints); - recordLearningEvent("IP=" + ip + " +" + learningHighCpsPoints + " high-cps score=" + profile.score); - } - - profile.lastConnectionAt = now; - profile.lastSeenAt = now; - - if (profile.score >= learningScoreThreshold) { - blockIp(ip, now); - recordLearningEvent("BLOCK " + ip + " reason=learning-threshold score=" + profile.score); - } - } - } - - private int addLearningScore(String ip, long now, int points, String reason, boolean checkThreshold) { - LearningProfile profile = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); - synchronized (profile) { - decayLearningProfile(profile, now); - int add = Math.max(1, points); - profile.score += add; - profile.lastSeenAt = now; - recordLearningEvent("IP=" + ip + " +" + add + " " + reason + " score=" + profile.score); - - if (checkThreshold && profile.score >= learningScoreThreshold) { - blockIp(ip, now); - recordLearningEvent("BLOCK " + ip + " reason=" + reason + " score=" + profile.score); - } - - return profile.score; - } - } - - private int getLearningScore(String ip, long now) { - LearningProfile profile = learningProfiles.get(ip); - if (profile == null) { - return 0; - } - synchronized (profile) { - decayLearningProfile(profile, now); - return profile.score; - } - } - - private void decayLearningProfile(LearningProfile profile, long now) { - long elapsedMs = Math.max(0L, now - profile.lastScoreUpdateAt); - if (elapsedMs > 0L) { - long decay = (elapsedMs / 1000L) * Math.max(0, learningDecayPerSecond); - if (decay > 0L) { - profile.score = (int) Math.max(0L, profile.score - decay); - } - profile.lastScoreUpdateAt = now; - } - - long resetAfter = Math.max(30, learningStateWindowSeconds) * 1000L; - if (profile.lastSeenAt > 0L && now - profile.lastSeenAt > resetAfter) { - profile.score = 0; - profile.rapidStreak = 0; - } - } - - private void recordLearningEvent(String event) { - String line = new SimpleDateFormat("HH:mm:ss").format(new Date()) + " " + event; - synchronized (learningRecentEvents) { - learningRecentEvents.addLast(line); - while (learningRecentEvents.size() > Math.max(5, learningRecentEventLimit)) { - learningRecentEvents.pollFirst(); - } - } - } - - private void ensureSecurityLogFile() { - if (plugin == null) { - return; - } - if (!plugin.getDataFolder().exists()) { - plugin.getDataFolder().mkdirs(); - } - securityLogFile = new File(plugin.getDataFolder(), securityLogFileName); - try { - if (!securityLogFile.exists()) { - securityLogFile.createNewFile(); - } - } catch (Exception e) { - plugin.getLogger().warning("[AntiBotModule] Konnte Sicherheitslog nicht erstellen: " + e.getMessage()); - } - } - - private void logSecurityEvent(String eventType, String ip, PendingConnection conn, String details) { - if (!securityLogEnabled || plugin == null) { - return; - } - if (securityLogFile == null) { - ensureSecurityLogFile(); - if (securityLogFile == null) { - return; - } - } - - String name = extractPlayerName(conn); - String uuid = extractPlayerUuid(conn, name); - - if ((name == null || name.isEmpty() || "unknown".equalsIgnoreCase(name)) && ip != null) { - RecentPlayerIdentity cached = recentIdentityByIp.get(ip); - if (cached != null) { - if (cached.playerName != null && !cached.playerName.trim().isEmpty()) { - name = cached.playerName; - } - if ((uuid == null || uuid.isEmpty() || "unknown".equalsIgnoreCase(uuid)) - && cached.playerUuid != null && !cached.playerUuid.trim().isEmpty()) { - uuid = cached.playerUuid; - } - } - } - - if (name == null || name.trim().isEmpty()) { - name = "unknown"; - } - if (uuid == null || uuid.trim().isEmpty()) { - uuid = "unknown"; - } - - String line = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) - + " | event=" + safeLog(eventType) - + " | ip=" + safeLog(ip) - + " | player=" + safeLog(name) - + " | uuid=" + safeLog(uuid) - + " | details=" + safeLog(details); - - synchronized (securityLogLock) { - try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) { - bw.write(line); - bw.newLine(); - } catch (Exception e) { - plugin.getLogger().warning("[AntiBotModule] Sicherheitslog-Schreibfehler: " + e.getMessage()); - } - } - } - - private void cacheRecentIdentity(String ip, PendingConnection conn, long now) { - if (ip == null || ip.isEmpty() || conn == null) { - return; - } - - String name = extractPlayerName(conn); - String uuid = extractPlayerUuid(conn, name); - - if ((name == null || name.isEmpty()) && (uuid == null || uuid.isEmpty())) { - return; - } - - RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); - synchronized (identity) { - if (name != null && !name.trim().isEmpty()) { - identity.playerName = name.trim(); - } - if (uuid != null && !uuid.trim().isEmpty()) { - identity.playerUuid = uuid.trim(); - } - identity.updatedAtMs = now; - } - } - - private void cacheRecentIdentityDirect(String ip, String playerName, UUID playerUuid, long now) { - if (ip == null || ip.isEmpty()) { - return; - } - - String name = playerName == null ? "" : playerName.trim(); - String uuid = playerUuid == null ? "" : playerUuid.toString(); - if (name.isEmpty() && uuid.isEmpty()) { - return; - } - - RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); - synchronized (identity) { - if (!name.isEmpty()) { - identity.playerName = name; - } - if (!uuid.isEmpty()) { - identity.playerUuid = uuid; - } - identity.updatedAtMs = now; - } - } - - private String extractPlayerName(PendingConnection conn) { - if (conn == null) { - return ""; - } - try { - String raw = conn.getName(); - return raw == null ? "" : raw.trim(); - } catch (Exception ignored) { - return ""; - } - } - - private String extractPlayerUuid(PendingConnection conn, String playerName) { - if (conn != null) { - try { - UUID uuid = conn.getUniqueId(); - if (uuid != null) { - return uuid.toString(); - } - } catch (Exception ignored) { - } - } - - if (playerName != null && !playerName.trim().isEmpty()) { - UUID offlineUuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName.trim()).getBytes(StandardCharsets.UTF_8)); - return offlineUuid.toString(); - } - - return ""; - } - - private String safeLog(String input) { - if (input == null || input.isEmpty()) { - return "-"; - } - return input.replace("\n", " ").replace("\r", " ").trim(); - } - - private List getRecentLearningEvents(int max) { - List out = new ArrayList(); - synchronized (learningRecentEvents) { - int skip = Math.max(0, learningRecentEvents.size() - Math.max(1, max)); - int idx = 0; - for (String line : learningRecentEvents) { - if (idx++ < skip) { - continue; - } - out.add(line); - } - } - return out; - } - - private boolean parseBoolean(String s, boolean fallback) { - if (s == null) return fallback; - return Boolean.parseBoolean(s.trim()); - } - - private int parseInt(String s, int fallback) { - try { - return Integer.parseInt(s == null ? "" : s.trim()); - } catch (Exception ignored) { - return fallback; - } - } - - private VpnCheckResult getVpnInfo(String ip, long now) { - VpnCacheEntry cached = vpnCache.get(ip); - if (cached != null && cached.expiresAt > now) { - return cached.result; - } - - VpnCheckResult fresh = requestIpApi(ip); - if (fresh != null) { - VpnCacheEntry entry = new VpnCacheEntry(); - entry.result = fresh; - entry.expiresAt = now + Math.max(1, vpnCacheMinutes) * 60_000L; - vpnCache.put(ip, entry); - } - return fresh; - } - - private VpnCheckResult requestIpApi(String ip) { - HttpURLConnection conn = null; - try { - String url = "http://ip-api.com/json/" + ip + "?fields=status,proxy,hosting"; - conn = (HttpURLConnection) new URL(url).openConnection(); - conn.setRequestMethod("GET"); - conn.setConnectTimeout(vpnTimeoutMs); - conn.setReadTimeout(vpnTimeoutMs); - conn.setRequestProperty("User-Agent", "StatusAPI-AntiBot/1.0"); - - int code = conn.getResponseCode(); - if (code < 200 || code >= 300) { - return null; - } - - StringBuilder sb = new StringBuilder(); - try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = br.readLine()) != null) { - sb.append(line); - } - } - - String json = sb.toString(); - if (json.isEmpty() || !json.contains("\"status\":\"success\"")) { - return null; - } - - VpnCheckResult result = new VpnCheckResult(); - result.proxy = json.contains("\"proxy\":true"); - result.hosting = json.contains("\"hosting\":true"); - return result; - } catch (Exception ignored) { - return null; - } finally { - if (conn != null) { - conn.disconnect(); - } - } - } - - private static class IpWindow { - long windowStart; - int count; - - IpWindow(long now) { - this.windowStart = now; - this.count = 0; - } - } - - private static class VpnCacheEntry { - VpnCheckResult result; - long expiresAt; - } - - private static class VpnCheckResult { - boolean proxy; - boolean hosting; - } - - private static class LearningProfile { - long lastConnectionAt; - long lastScoreUpdateAt; - long lastSeenAt; - int rapidStreak; - int score; - - LearningProfile(long now) { - this.lastConnectionAt = now; - this.lastScoreUpdateAt = now; - this.lastSeenAt = now; - this.rapidStreak = 0; - this.score = 0; - } - } - - private static class RecentPlayerIdentity { - String playerName; - String playerUuid; - long updatedAtMs; - } - - private class AntiBotCommand extends Command { - AntiBotCommand() { - super("antibot", "statusapi.antibot"); - } - - @Override - public void execute(CommandSender sender, String[] args) { - if (!enabled) { - sender.sendMessage(ChatColor.RED + "AntiBotModule ist deaktiviert."); - return; - } - - if (args.length == 0 || "status".equalsIgnoreCase(args[0])) { - sender.sendMessage(ChatColor.GOLD + "----- AntiBot Status -----"); - sender.sendMessage(ChatColor.YELLOW + "Schutz aktiv: " + ChatColor.WHITE + enabled + ChatColor.GRAY + " (Modul eingeschaltet)"); - if (attackMode) { - sender.sendMessage(ChatColor.YELLOW + "Attack Mode: " + ChatColor.RED + "AKTIV" + ChatColor.GRAY + " (Angriff erkannt)"); - } else { - sender.sendMessage(ChatColor.YELLOW + "Attack Mode: " + ChatColor.GREEN + "Normal" + ChatColor.GRAY + " (kein Angriff erkannt)"); - } - sender.sendMessage(ChatColor.YELLOW + "CPS: " + ChatColor.WHITE + lastCps + ChatColor.GRAY + " (Peak " + peakCps.get() + ")"); - sender.sendMessage(ChatColor.YELLOW + "Schwellen: " + ChatColor.WHITE + "start " + attackStartCps + ChatColor.GRAY + " / " + ChatColor.WHITE + "stop " + attackStopCps + ChatColor.GRAY + " CPS"); - sender.sendMessage(ChatColor.YELLOW + "Active IP Blocks: " + ChatColor.WHITE + blockedIpsUntil.size()); - sender.sendMessage(ChatColor.YELLOW + "Total blocked connections: " + ChatColor.WHITE + blockedConnectionsTotal.get()); - sender.sendMessage(ChatColor.YELLOW + "VPN Check: " + ChatColor.WHITE + vpnCheckEnabled); - sender.sendMessage(ChatColor.YELLOW + "Learning Mode: " + ChatColor.WHITE + learningModeEnabled - + ChatColor.GRAY + " (threshold=" + learningScoreThreshold + ")"); - - List recent = getRecentLearningEvents(3); - if (!recent.isEmpty()) { - sender.sendMessage(ChatColor.YELLOW + "Learning Events:"); - for (String line : recent) { - sender.sendMessage(ChatColor.GRAY + "- " + line); - } - } - return; - } - - if ("clearblocks".equalsIgnoreCase(args[0])) { - blockedIpsUntil.clear(); - sender.sendMessage(ChatColor.GREEN + "Alle IP-Blocks wurden entfernt."); - return; - } - - if ("unblock".equalsIgnoreCase(args[0]) && args.length >= 2) { - String ip = args[1].trim(); - Long removed = blockedIpsUntil.remove(ip); - if (removed != null) { - sender.sendMessage(ChatColor.GREEN + "IP entblockt: " + ip); - } else { - sender.sendMessage(ChatColor.RED + "IP war nicht geblockt: " + ip); - } - return; - } - - if ("profile".equalsIgnoreCase(args[0])) { - if (args.length < 2) { - sender.sendMessage(ChatColor.YELLOW + "Aktuelles Profil: " + ChatColor.WHITE + profile); - sender.sendMessage(ChatColor.YELLOW + "Benutzung: /antibot profile "); - return; - } - - String requested = args[1].trim().toLowerCase(Locale.ROOT); - if (!isSupportedProfile(requested)) { - sender.sendMessage(ChatColor.RED + "Unbekanntes Profil. Erlaubt: strict, high-traffic"); - return; - } - - boolean ok = applyProfileAndPersist(requested); - if (!ok) { - sender.sendMessage(ChatColor.RED + "Profil konnte nicht gespeichert werden. Siehe Konsole."); - return; - } - - sender.sendMessage(ChatColor.GREEN + "AntiBot-Profil umgestellt auf: " + requested); - sender.sendMessage(ChatColor.GRAY + "Werte wurden in " + CONFIG_FILE_NAME + " gespeichert."); - return; - } - - if ("reload".equalsIgnoreCase(args[0])) { - reloadRuntimeState(); - sender.sendMessage(ChatColor.GREEN + "AntiBot-Konfiguration neu geladen."); - sender.sendMessage(ChatColor.GRAY + "Aktives Profil: " + profile); - return; - } - - sender.sendMessage(ChatColor.YELLOW + "/antibot status"); - sender.sendMessage(ChatColor.YELLOW + "/antibot clearblocks"); - sender.sendMessage(ChatColor.YELLOW + "/antibot unblock "); - sender.sendMessage(ChatColor.YELLOW + "/antibot profile "); - sender.sendMessage(ChatColor.YELLOW + "/antibot reload"); - } - } -} +package net.viper.status.modules.antibot; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.connection.PendingConnection; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PreLoginEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; +import net.viper.status.modules.network.NetworkInfoModule; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.file.Files; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Date; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Eigenständiger AntiBot/Attack-Guard. + * + * Fixes: + * - cleanupExpired() nutzt jetzt removeIf() statt Iteration + remove() (Bug #3) + * - applyProfileDefaults() setzt korrekten attackDefaultSource aus Config + */ +public class AntiBotModule implements Module, Listener { + + private static final String CONFIG_FILE_NAME = "network-guard.properties"; + + private StatusAPI plugin; + + private boolean enabled = true; + private String profile = "high-traffic"; + private int maxCps = 120; + private int attackStartCps = 220; + private int attackStopCps = 120; + private int attackCalmSeconds = 20; + private int ipConnectionsPerMinute = 18; + private int ipBlockSeconds = 600; + private String kickMessage = "Zu viele Verbindungen von deiner IP. Bitte warte kurz."; + + private boolean vpnCheckEnabled = false; + private boolean vpnBlockProxy = true; + private boolean vpnBlockHosting = true; + private int vpnCacheMinutes = 30; + private int vpnTimeoutMs = 2500; + private boolean securityLogEnabled = true; + private String securityLogFileName = "antibot-security.log"; + private File securityLogFile; + private final Object securityLogLock = new Object(); + + private boolean learningModeEnabled = true; + private int learningScoreThreshold = 100; + private int learningDecayPerSecond = 2; + private int learningStateWindowSeconds = 120; + private int learningRapidWindowMs = 1500; + private int learningRapidPoints = 12; + private int learningIpRateExceededPoints = 30; + private int learningVpnProxyPoints = 40; + private int learningVpnHostingPoints = 30; + private int learningAttackModePoints = 12; + private int learningHighCpsPoints = 10; + private int learningRecentEventLimit = 30; + + private final AtomicInteger currentSecondConnections = new AtomicInteger(0); + private volatile long currentSecond = System.currentTimeMillis() / 1000L; + private volatile int lastCps = 0; + private final AtomicInteger peakCps = new AtomicInteger(0); + + private volatile boolean attackMode = false; + private volatile long attackCalmSince = 0L; + private final AtomicLong blockedConnectionsTotal = new AtomicLong(0L); + private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L); + private final Set blockedIpsCurrentAttack = ConcurrentHashMap.newKeySet(); + + private final Map perIpWindows = new ConcurrentHashMap<>(); + private final Map blockedIpsUntil = new ConcurrentHashMap<>(); + private final Map vpnCache = new ConcurrentHashMap<>(); + private final Map recentIdentityByIp = new ConcurrentHashMap<>(); + private final Map learningProfiles = new ConcurrentHashMap<>(); + private final Deque learningRecentEvents = new ArrayDeque<>(); + + @Override + public String getName() { return "AntiBotModule"; } + + @Override + public void onEnable(Plugin plugin) { + if (!(plugin instanceof StatusAPI)) return; + this.plugin = (StatusAPI) plugin; + ensureModuleConfigExists(); + loadConfig(); + ensureSecurityLogFile(); + + if (!enabled) { + this.plugin.getLogger().info("[AntiBotModule] deaktiviert via " + CONFIG_FILE_NAME); + return; + } + + ProxyServer.getInstance().getPluginManager().registerListener(this.plugin, this); + ProxyServer.getInstance().getPluginManager().registerCommand(this.plugin, new AntiBotCommand()); + ProxyServer.getInstance().getScheduler().schedule(this.plugin, this::tick, 1, 1, TimeUnit.SECONDS); + + this.plugin.getLogger().info("[AntiBotModule] aktiviert. maxCps=" + maxCps + + ", attackStartCps=" + attackStartCps + ", ip/min=" + ipConnectionsPerMinute); + } + + @Override + public void onDisable(Plugin plugin) { + perIpWindows.clear(); + blockedIpsUntil.clear(); + vpnCache.clear(); + learningProfiles.clear(); + synchronized (learningRecentEvents) { learningRecentEvents.clear(); } + blockedIpsCurrentAttack.clear(); + attackMode = false; + } + + public boolean isEnabled() { return enabled; } + + private void reloadRuntimeState() { + perIpWindows.clear(); + blockedIpsUntil.clear(); + vpnCache.clear(); + learningProfiles.clear(); + synchronized (learningRecentEvents) { learningRecentEvents.clear(); } + blockedIpsCurrentAttack.clear(); + attackMode = false; + attackCalmSince = 0L; + blockedConnectionsCurrentAttack.set(0L); + currentSecondConnections.set(0); + lastCps = 0; + peakCps.set(0); + loadConfig(); + ensureSecurityLogFile(); + } + + public Map buildSnapshot() { + Map out = new LinkedHashMap<>(); + out.put("enabled", enabled); + out.put("profile", profile); + out.put("attack_mode", attackMode); + out.put("protection_enabled", enabled); + out.put("attack_mode_status", attackMode ? "active" : "normal"); + out.put("attack_mode_display", attackMode ? "Angriff erkannt" : "Normalbetrieb"); + out.put("status_message", enabled + ? (attackMode ? "AntiBot aktiv: Angriff erkannt" : "AntiBot aktiv: kein Angriff erkannt") + : "AntiBot deaktiviert"); + out.put("last_cps", lastCps); + out.put("peak_cps", peakCps.get()); + out.put("blocked_ips_active", blockedIpsUntil.size()); + out.put("blocked_connections_total", blockedConnectionsTotal.get()); + out.put("vpn_check_enabled", vpnCheckEnabled); + out.put("learning_mode_enabled", learningModeEnabled); + out.put("learning_profiles", learningProfiles.size()); + out.put("thresholds", buildThresholds()); + return out; + } + + private Map buildThresholds() { + Map m = new LinkedHashMap<>(); + m.put("max_cps", maxCps); + m.put("attack_start_cps", attackStartCps); + m.put("attack_stop_cps", attackStopCps); + m.put("attack_calm_seconds", attackCalmSeconds); + m.put("ip_connections_per_minute", ipConnectionsPerMinute); + m.put("ip_block_seconds", ipBlockSeconds); + m.put("learning_score_threshold", learningScoreThreshold); + m.put("learning_decay_per_second", learningDecayPerSecond); + return m; + } + + @EventHandler + public void onPreLogin(PreLoginEvent event) { + if (!enabled) return; + + String ip = extractIp(event.getConnection()); + if (ip == null || ip.isEmpty()) return; + + cacheRecentIdentity(ip, event.getConnection(), System.currentTimeMillis()); + recordConnection(); + long now = System.currentTimeMillis(); + + // FIX #3: cleanupExpired verwendet removeIf statt Iteration+remove + cleanupExpired(now); + + Long blockedUntil = blockedIpsUntil.get(ip); + if (blockedUntil != null && blockedUntil > now) { + logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil); + blockEvent(event); + return; + } + + if (learningModeEnabled) { + evaluateLearningBaseline(ip, now); + Long learningBlock = blockedIpsUntil.get(ip); + if (learningBlock != null && learningBlock > now) { + blockEvent(event); + return; + } + } + + boolean ipRateExceeded = isIpRateExceeded(ip, now); + if (ipRateExceeded) { + if (learningModeEnabled) { + int score = addLearningScore(ip, now, learningIpRateExceededPoints, "ip-rate-exceeded", true); + logSecurityEvent("ip_rate_exceeded_scored", ip, event.getConnection(), "score=" + score + ", threshold=" + learningScoreThreshold); + if (score >= learningScoreThreshold) { + logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=ip-rate-exceeded, score=" + score); + blockEvent(event); + return; + } + } else { + blockIp(ip, now); + logSecurityEvent("ip_rate_limit_block", ip, event.getConnection(), "mode=direct"); + blockEvent(event); + return; + } + } + + if (vpnCheckEnabled) { + VpnCheckResult info = getVpnInfo(ip, now); + if (info != null) { + boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting); + if (shouldBlock) { + logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting); + if (learningModeEnabled) { + if (vpnBlockProxy && info.proxy) addLearningScore(ip, now, learningVpnProxyPoints, "vpn-proxy", false); + if (vpnBlockHosting && info.hosting) addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false); + int current = getLearningScore(ip, now); + if (current >= learningScoreThreshold) { + blockIp(ip, now); + logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current); + recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current); + blockEvent(event); + } + } else { + blockIp(ip, now); + logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting); + blockEvent(event); + } + } + } + } + } + + @EventHandler + public void onPostLogin(PostLoginEvent event) { + if (!enabled || event == null || event.getPlayer() == null) return; + ProxiedPlayer player = event.getPlayer(); + String ip = extractIpFromPlayer(player); + if (ip == null || ip.isEmpty()) return; + cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), System.currentTimeMillis()); + } + + private void blockEvent(PreLoginEvent event) { + event.setCancelled(true); + } + + private String extractIp(PendingConnection conn) { + if (conn == null || conn.getAddress() == null) return null; + if (conn.getAddress() instanceof InetSocketAddress) { + InetSocketAddress sa = (InetSocketAddress) conn.getAddress(); + return sa.getAddress() != null ? sa.getAddress().getHostAddress() : sa.getHostString(); + } + return String.valueOf(conn.getAddress()); + } + + private String extractIpFromPlayer(ProxiedPlayer player) { + if (player == null || player.getAddress() == null) return null; + if (player.getAddress() instanceof InetSocketAddress) { + InetSocketAddress sa = (InetSocketAddress) player.getAddress(); + return sa.getAddress() != null ? sa.getAddress().getHostAddress() : sa.getHostString(); + } + return String.valueOf(player.getAddress()); + } + + private void recordConnection() { + long sec = System.currentTimeMillis() / 1000L; + if (sec != currentSecond) { + synchronized (this) { + if (sec != currentSecond) { + currentSecond = sec; + currentSecondConnections.set(0); + } + } + } + currentSecondConnections.incrementAndGet(); + } + + private boolean isIpRateExceeded(String ip, long now) { + IpWindow window = perIpWindows.computeIfAbsent(ip, k -> new IpWindow(now)); + synchronized (window) { + long diff = now - window.windowStart; + if (diff > 60_000L) { + window.windowStart = now; + window.count = 0; + } + window.count++; + return window.count > Math.max(1, ipConnectionsPerMinute); + } + } + + private void blockIp(String ip, long now) { + blockedIpsUntil.put(ip, now + Math.max(1, ipBlockSeconds) * 1000L); + blockedConnectionsTotal.incrementAndGet(); + if (attackMode) { + blockedConnectionsCurrentAttack.incrementAndGet(); + blockedIpsCurrentAttack.add(ip); + } + } + + /** + * FIX #3: Verwendet removeIf() statt for-each + remove() um ConcurrentModificationException zu vermeiden. + */ + private void cleanupExpired(long now) { + blockedIpsUntil.entrySet().removeIf(e -> e.getValue() <= now); + vpnCache.entrySet().removeIf(e -> e.getValue().expiresAt <= now); + recentIdentityByIp.entrySet().removeIf(e -> { + RecentPlayerIdentity id = e.getValue(); + return id == null || (now - id.updatedAtMs) > 600_000L; + }); + if (learningModeEnabled) { + long staleAfter = Math.max(60, learningStateWindowSeconds) * 1000L; + learningProfiles.entrySet().removeIf(e -> { + LearningProfile lp = e.getValue(); + return lp == null || ((now - lp.lastSeenAt) > staleAfter && lp.score <= 0); + }); + } + } + + private void tick() { + if (!enabled) return; + int cps = currentSecondConnections.getAndSet(0); + lastCps = cps; + if (cps > peakCps.get()) peakCps.set(cps); + + long now = System.currentTimeMillis(); + + if (!attackMode && cps >= Math.max(1, attackStartCps)) { + attackMode = true; + attackCalmSince = 0L; + blockedConnectionsCurrentAttack.set(0L); + blockedIpsCurrentAttack.clear(); + sendAttackToWebhook("detected", cps, null, null, "StatusAPI AntiBot"); + plugin.getLogger().warning("[AntiBotModule] Attack erkannt. CPS=" + cps); + return; + } + + if (attackMode) { + if (cps <= Math.max(1, attackStopCps)) { + if (attackCalmSince == 0L) attackCalmSince = now; + long calmFor = now - attackCalmSince; + if (calmFor >= Math.max(1, attackCalmSeconds) * 1000L) { + attackMode = false; + attackCalmSince = 0L; + int blockedIps = blockedIpsCurrentAttack.size(); + long blockedConns = blockedConnectionsCurrentAttack.get(); + sendAttackToWebhook("stopped", cps, blockedIps, blockedConns, "StatusAPI AntiBot"); + plugin.getLogger().warning("[AntiBotModule] Attack beendet. blockedIps=" + blockedIps + ", blockedConnections=" + blockedConns); + } + } else { + attackCalmSince = 0L; + } + } + } + + private void sendAttackToWebhook(String type, Integer cps, Integer blockedIps, Long blockedConnections, String source) { + NetworkInfoModule networkInfoModule = getNetworkInfoModule(); + if (networkInfoModule == null) return; + networkInfoModule.sendAttackNotification(type, cps, blockedIps, blockedConnections, source); + } + + private NetworkInfoModule getNetworkInfoModule() { + if (plugin == null || plugin.getModuleManager() == null) return null; + return (NetworkInfoModule) plugin.getModuleManager().getModule("NetworkInfoModule"); + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + if (!file.exists()) return; + + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(file)) { + props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + + enabled = parseBoolean(props.getProperty("antibot.enabled"), true); + profile = normalizeProfile(props.getProperty("antibot.profile", "high-traffic")); + applyProfileDefaults(profile); + + maxCps = parseInt(props.getProperty("antibot.max_cps"), maxCps); + attackStartCps = parseInt(props.getProperty("antibot.attack.start_cps"), attackStartCps); + attackStopCps = parseInt(props.getProperty("antibot.attack.stop_cps"), attackStopCps); + attackCalmSeconds = parseInt(props.getProperty("antibot.attack.stop_grace_seconds"), attackCalmSeconds); + ipConnectionsPerMinute = parseInt(props.getProperty("antibot.ip.max_connections_per_minute"), ipConnectionsPerMinute); + ipBlockSeconds = parseInt(props.getProperty("antibot.ip.block_seconds"), ipBlockSeconds); + kickMessage = props.getProperty("antibot.kick_message", kickMessage); + + vpnCheckEnabled = parseBoolean(props.getProperty("antibot.vpn_check.enabled"), vpnCheckEnabled); + vpnBlockProxy = parseBoolean(props.getProperty("antibot.vpn_check.block_proxy"), vpnBlockProxy); + vpnBlockHosting = parseBoolean(props.getProperty("antibot.vpn_check.block_hosting"), vpnBlockHosting); + vpnCacheMinutes = parseInt(props.getProperty("antibot.vpn_check.cache_minutes"), vpnCacheMinutes); + vpnTimeoutMs = parseInt(props.getProperty("antibot.vpn_check.timeout_ms"), vpnTimeoutMs); + securityLogEnabled = parseBoolean(props.getProperty("antibot.security_log.enabled"), securityLogEnabled); + securityLogFileName = props.getProperty("antibot.security_log.file", securityLogFileName).trim(); + if (securityLogFileName.isEmpty()) securityLogFileName = "antibot-security.log"; + + learningModeEnabled = parseBoolean(props.getProperty("antibot.learning.enabled"), learningModeEnabled); + learningScoreThreshold = parseInt(props.getProperty("antibot.learning.score_threshold"), learningScoreThreshold); + learningDecayPerSecond = parseInt(props.getProperty("antibot.learning.decay_per_second"), learningDecayPerSecond); + learningStateWindowSeconds = parseInt(props.getProperty("antibot.learning.state_window_seconds"), learningStateWindowSeconds); + learningRapidWindowMs = parseInt(props.getProperty("antibot.learning.rapid.window_ms"), learningRapidWindowMs); + learningRapidPoints = parseInt(props.getProperty("antibot.learning.rapid.points"), learningRapidPoints); + learningIpRateExceededPoints = parseInt(props.getProperty("antibot.learning.ip_rate_exceeded.points"), learningIpRateExceededPoints); + learningVpnProxyPoints = parseInt(props.getProperty("antibot.learning.vpn_proxy.points"), learningVpnProxyPoints); + learningVpnHostingPoints = parseInt(props.getProperty("antibot.learning.vpn_hosting.points"), learningVpnHostingPoints); + learningAttackModePoints = parseInt(props.getProperty("antibot.learning.attack_mode.points"), learningAttackModePoints); + learningHighCpsPoints = parseInt(props.getProperty("antibot.learning.high_cps.points"), learningHighCpsPoints); + learningRecentEventLimit = parseInt(props.getProperty("antibot.learning.recent_events.limit"), learningRecentEventLimit); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage()); + } + } + + private String normalizeProfile(String raw) { + if (raw == null) return "high-traffic"; + String v = raw.trim().toLowerCase(Locale.ROOT); + return "strict".equals(v) ? "strict" : "high-traffic"; + } + + private void applyProfileDefaults(String profileName) { + if ("strict".equals(profileName)) { + maxCps = 120; attackStartCps = 220; attackStopCps = 120; attackCalmSeconds = 20; + ipConnectionsPerMinute = 18; ipBlockSeconds = 900; + vpnCheckEnabled = true; vpnBlockProxy = true; vpnBlockHosting = true; + vpnCacheMinutes = 30; vpnTimeoutMs = 2500; + } else { + maxCps = 180; attackStartCps = 300; attackStopCps = 170; attackCalmSeconds = 25; + ipConnectionsPerMinute = 24; ipBlockSeconds = 600; + vpnCheckEnabled = false; vpnBlockProxy = true; vpnBlockHosting = true; + vpnCacheMinutes = 30; vpnTimeoutMs = 2500; + } + } + + private boolean isSupportedProfile(String raw) { + if (raw == null) return false; + String v = raw.trim().toLowerCase(Locale.ROOT); + return "strict".equals(v) || "high-traffic".equals(v); + } + + private boolean applyProfileAndPersist(String requestedProfile) { + if (!isSupportedProfile(requestedProfile)) return false; + String normalized = normalizeProfile(requestedProfile); + profile = normalized; + applyProfileDefaults(normalized); + + Map values = new LinkedHashMap<>(); + values.put("antibot.profile", normalized); + values.put("antibot.max_cps", String.valueOf(maxCps)); + values.put("antibot.attack.start_cps", String.valueOf(attackStartCps)); + values.put("antibot.attack.stop_cps", String.valueOf(attackStopCps)); + values.put("antibot.attack.stop_grace_seconds", String.valueOf(attackCalmSeconds)); + values.put("antibot.ip.max_connections_per_minute", String.valueOf(ipConnectionsPerMinute)); + values.put("antibot.ip.block_seconds", String.valueOf(ipBlockSeconds)); + values.put("antibot.vpn_check.enabled", String.valueOf(vpnCheckEnabled)); + values.put("antibot.vpn_check.block_proxy", String.valueOf(vpnBlockProxy)); + values.put("antibot.vpn_check.block_hosting", String.valueOf(vpnBlockHosting)); + values.put("antibot.vpn_check.cache_minutes", String.valueOf(vpnCacheMinutes)); + values.put("antibot.vpn_check.timeout_ms", String.valueOf(vpnTimeoutMs)); + try { updateConfigValues(values); return true; } + catch (Exception e) { plugin.getLogger().warning("[AntiBotModule] Konnte Profil nicht speichern: " + e.getMessage()); return false; } + } + + private synchronized void updateConfigValues(Map keyValues) throws Exception { + File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + List lines = target.exists() + ? Files.readAllLines(target.toPath(), StandardCharsets.UTF_8) + : new ArrayList<>(); + for (Map.Entry entry : keyValues.entrySet()) { + String key = entry.getKey(); + String newLine = key + "=" + entry.getValue(); + boolean replaced = false; + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).trim().startsWith(key + "=")) { + lines.set(i, newLine); + replaced = true; + break; + } + } + if (!replaced) lines.add(newLine); + } + Files.write(target.toPath(), lines, StandardCharsets.UTF_8); + } + + private void ensureModuleConfigExists() { + File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + if (target.exists()) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + try (InputStream in = plugin.getResourceAsStream(CONFIG_FILE_NAME); + FileOutputStream out = new FileOutputStream(target)) { + if (in == null) { plugin.getLogger().warning("[AntiBotModule] Standarddatei nicht im JAR."); return; } + byte[] buffer = new byte[4096]; int read; + while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Konnte Config nicht erstellen: " + e.getMessage()); + } + } + + private void evaluateLearningBaseline(String ip, long now) { + LearningProfile lp = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); + synchronized (lp) { + decayLearningProfile(lp, now); + long delta = now - lp.lastConnectionAt; + if (lp.lastConnectionAt > 0 && delta <= Math.max(250L, learningRapidWindowMs)) { + lp.rapidStreak++; + int points = learningRapidPoints + Math.min(lp.rapidStreak, 5); + lp.score += Math.max(1, points); + recordLearningEvent("IP=" + ip + " +" + points + " rapid-connect score=" + lp.score); + } else { + lp.rapidStreak = 0; + } + if (attackMode) { lp.score += Math.max(1, learningAttackModePoints); recordLearningEvent("IP=" + ip + " +" + learningAttackModePoints + " attack-mode score=" + lp.score); } + if (lastCps >= Math.max(1, maxCps)) { lp.score += Math.max(1, learningHighCpsPoints); recordLearningEvent("IP=" + ip + " +" + learningHighCpsPoints + " high-cps score=" + lp.score); } + lp.lastConnectionAt = now; + lp.lastSeenAt = now; + if (lp.score >= learningScoreThreshold) { + blockIp(ip, now); + recordLearningEvent("BLOCK " + ip + " reason=learning-threshold score=" + lp.score); + } + } + } + + private int addLearningScore(String ip, long now, int points, String reason, boolean checkThreshold) { + LearningProfile lp = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); + synchronized (lp) { + decayLearningProfile(lp, now); + int add = Math.max(1, points); + lp.score += add; + lp.lastSeenAt = now; + recordLearningEvent("IP=" + ip + " +" + add + " " + reason + " score=" + lp.score); + if (checkThreshold && lp.score >= learningScoreThreshold) { + blockIp(ip, now); + recordLearningEvent("BLOCK " + ip + " reason=" + reason + " score=" + lp.score); + } + return lp.score; + } + } + + private int getLearningScore(String ip, long now) { + LearningProfile lp = learningProfiles.get(ip); + if (lp == null) return 0; + synchronized (lp) { decayLearningProfile(lp, now); return lp.score; } + } + + private void decayLearningProfile(LearningProfile lp, long now) { + long elapsedMs = Math.max(0L, now - lp.lastScoreUpdateAt); + if (elapsedMs > 0L) { + long decay = (elapsedMs / 1000L) * Math.max(0, learningDecayPerSecond); + if (decay > 0L) lp.score = (int) Math.max(0L, lp.score - decay); + lp.lastScoreUpdateAt = now; + } + long resetAfter = Math.max(30, learningStateWindowSeconds) * 1000L; + if (lp.lastSeenAt > 0L && now - lp.lastSeenAt > resetAfter) { + lp.score = 0; + lp.rapidStreak = 0; + } + } + + private void recordLearningEvent(String event) { + String line = new SimpleDateFormat("HH:mm:ss").format(new Date()) + " " + event; + synchronized (learningRecentEvents) { + learningRecentEvents.addLast(line); + while (learningRecentEvents.size() > Math.max(5, learningRecentEventLimit)) learningRecentEvents.pollFirst(); + } + } + + private void ensureSecurityLogFile() { + if (plugin == null) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + securityLogFile = new File(plugin.getDataFolder(), securityLogFileName); + try { if (!securityLogFile.exists()) securityLogFile.createNewFile(); } + catch (Exception e) { plugin.getLogger().warning("[AntiBotModule] Konnte Sicherheitslog nicht erstellen: " + e.getMessage()); } + } + + private void logSecurityEvent(String eventType, String ip, PendingConnection conn, String details) { + if (!securityLogEnabled || plugin == null) return; + if (securityLogFile == null) { ensureSecurityLogFile(); if (securityLogFile == null) return; } + + String name = extractPlayerName(conn); + String uuid = extractPlayerUuid(conn, name); + if ((name == null || name.isEmpty() || "unknown".equalsIgnoreCase(name)) && ip != null) { + RecentPlayerIdentity cached = recentIdentityByIp.get(ip); + if (cached != null) { + if (cached.playerName != null && !cached.playerName.trim().isEmpty()) name = cached.playerName; + if ((uuid == null || uuid.isEmpty()) && cached.playerUuid != null && !cached.playerUuid.trim().isEmpty()) uuid = cached.playerUuid; + } + } + if (name == null || name.trim().isEmpty()) name = "unknown"; + if (uuid == null || uuid.trim().isEmpty()) uuid = "unknown"; + + String line = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + + " | event=" + safeLog(eventType) + + " | ip=" + safeLog(ip) + + " | player=" + safeLog(name) + + " | uuid=" + safeLog(uuid) + + " | details=" + safeLog(details); + + synchronized (securityLogLock) { + try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) { + bw.write(line); bw.newLine(); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Sicherheitslog-Schreibfehler: " + e.getMessage()); + } + } + } + + private void cacheRecentIdentity(String ip, PendingConnection conn, long now) { + if (ip == null || ip.isEmpty() || conn == null) return; + String name = extractPlayerName(conn); + String uuid = extractPlayerUuid(conn, name); + if ((name == null || name.isEmpty()) && (uuid == null || uuid.isEmpty())) return; + RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); + synchronized (identity) { + if (name != null && !name.trim().isEmpty()) identity.playerName = name.trim(); + if (uuid != null && !uuid.trim().isEmpty()) identity.playerUuid = uuid.trim(); + identity.updatedAtMs = now; + } + } + + private void cacheRecentIdentityDirect(String ip, String playerName, UUID playerUuid, long now) { + if (ip == null || ip.isEmpty()) return; + String name = playerName == null ? "" : playerName.trim(); + String uuid = playerUuid == null ? "" : playerUuid.toString(); + if (name.isEmpty() && uuid.isEmpty()) return; + RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); + synchronized (identity) { + if (!name.isEmpty()) identity.playerName = name; + if (!uuid.isEmpty()) identity.playerUuid = uuid; + identity.updatedAtMs = now; + } + } + + private String extractPlayerName(PendingConnection conn) { + if (conn == null) return ""; + try { String raw = conn.getName(); return raw == null ? "" : raw.trim(); } catch (Exception ignored) { return ""; } + } + + private String extractPlayerUuid(PendingConnection conn, String playerName) { + if (conn != null) { + try { UUID uuid = conn.getUniqueId(); if (uuid != null) return uuid.toString(); } catch (Exception ignored) {} + } + if (playerName != null && !playerName.trim().isEmpty()) { + return UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName.trim()).getBytes(StandardCharsets.UTF_8)).toString(); + } + return ""; + } + + private String safeLog(String input) { + if (input == null || input.isEmpty()) return "-"; + return input.replace("\n", " ").replace("\r", " ").trim(); + } + + private List getRecentLearningEvents(int max) { + List out = new ArrayList<>(); + synchronized (learningRecentEvents) { + int skip = Math.max(0, learningRecentEvents.size() - Math.max(1, max)); + int idx = 0; + for (String line : learningRecentEvents) { + if (idx++ < skip) continue; + out.add(line); + } + } + return out; + } + + private boolean parseBoolean(String s, boolean fallback) { + if (s == null) return fallback; + return Boolean.parseBoolean(s.trim()); + } + + private int parseInt(String s, int fallback) { + try { return Integer.parseInt(s == null ? "" : s.trim()); } catch (Exception ignored) { return fallback; } + } + + private VpnCheckResult getVpnInfo(String ip, long now) { + VpnCacheEntry cached = vpnCache.get(ip); + if (cached != null && cached.expiresAt > now) return cached.result; + VpnCheckResult fresh = requestIpApi(ip); + if (fresh != null) { + VpnCacheEntry entry = new VpnCacheEntry(); + entry.result = fresh; + entry.expiresAt = now + Math.max(1, vpnCacheMinutes) * 60_000L; + vpnCache.put(ip, entry); + } + return fresh; + } + + private VpnCheckResult requestIpApi(String ip) { + HttpURLConnection conn = null; + try { + String url = "http://ip-api.com/json/" + ip + "?fields=status,proxy,hosting"; + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(vpnTimeoutMs); + conn.setReadTimeout(vpnTimeoutMs); + conn.setRequestProperty("User-Agent", "StatusAPI-AntiBot/1.0"); + if (conn.getResponseCode() < 200 || conn.getResponseCode() >= 300) return null; + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; while ((line = br.readLine()) != null) sb.append(line); + } + String json = sb.toString(); + if (json.isEmpty() || !json.contains("\"status\":\"success\"")) return null; + VpnCheckResult result = new VpnCheckResult(); + result.proxy = json.contains("\"proxy\":true"); + result.hosting = json.contains("\"hosting\":true"); + return result; + } catch (Exception ignored) { return null; } + finally { if (conn != null) conn.disconnect(); } + } + + // --- Interne Klassen --- + + private static class IpWindow { + long windowStart; int count; + IpWindow(long now) { this.windowStart = now; this.count = 0; } + } + + private static class VpnCacheEntry { VpnCheckResult result; long expiresAt; } + private static class VpnCheckResult { boolean proxy; boolean hosting; } + + private static class LearningProfile { + long lastConnectionAt, lastScoreUpdateAt, lastSeenAt; + int rapidStreak, score; + LearningProfile(long now) { lastConnectionAt = lastScoreUpdateAt = lastSeenAt = now; } + } + + private static class RecentPlayerIdentity { String playerName; String playerUuid; long updatedAtMs; } + + // --- Command --- + + private class AntiBotCommand extends Command { + AntiBotCommand() { super("antibot", "statusapi.antibot"); } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!enabled) { sender.sendMessage(ChatColor.RED + "AntiBotModule ist deaktiviert."); return; } + + if (args.length == 0 || "status".equalsIgnoreCase(args[0])) { + sender.sendMessage(ChatColor.GOLD + "----- AntiBot Status -----"); + sender.sendMessage(ChatColor.YELLOW + "Schutz aktiv: " + ChatColor.WHITE + enabled); + sender.sendMessage(ChatColor.YELLOW + "Profil: " + ChatColor.WHITE + profile); + if (attackMode) sender.sendMessage(ChatColor.YELLOW + "Attack Mode: " + ChatColor.RED + "AKTIV"); + else sender.sendMessage(ChatColor.YELLOW + "Attack Mode: " + ChatColor.GREEN + "Normal"); + sender.sendMessage(ChatColor.YELLOW + "CPS: " + ChatColor.WHITE + lastCps + ChatColor.GRAY + " (Peak " + peakCps.get() + ")"); + sender.sendMessage(ChatColor.YELLOW + "Schwellen: " + ChatColor.WHITE + "start " + attackStartCps + ChatColor.GRAY + " / " + ChatColor.WHITE + "stop " + attackStopCps + ChatColor.GRAY + " CPS"); + sender.sendMessage(ChatColor.YELLOW + "Active IP Blocks: " + ChatColor.WHITE + blockedIpsUntil.size()); + sender.sendMessage(ChatColor.YELLOW + "Total blocked connections: " + ChatColor.WHITE + blockedConnectionsTotal.get()); + sender.sendMessage(ChatColor.YELLOW + "VPN Check: " + ChatColor.WHITE + vpnCheckEnabled); + sender.sendMessage(ChatColor.YELLOW + "Learning Mode: " + ChatColor.WHITE + learningModeEnabled + + ChatColor.GRAY + " (threshold=" + learningScoreThreshold + ")"); + List recent = getRecentLearningEvents(3); + if (!recent.isEmpty()) { + sender.sendMessage(ChatColor.YELLOW + "Learning Events:"); + for (String line : recent) sender.sendMessage(ChatColor.GRAY + "- " + line); + } + return; + } + + if ("clearblocks".equalsIgnoreCase(args[0])) { + blockedIpsUntil.clear(); + sender.sendMessage(ChatColor.GREEN + "Alle IP-Blocks wurden entfernt."); + return; + } + + if ("unblock".equalsIgnoreCase(args[0]) && args.length >= 2) { + String ip = args[1].trim(); + if (blockedIpsUntil.remove(ip) != null) sender.sendMessage(ChatColor.GREEN + "IP entblockt: " + ip); + else sender.sendMessage(ChatColor.RED + "IP war nicht geblockt: " + ip); + return; + } + + if ("profile".equalsIgnoreCase(args[0])) { + if (args.length < 2) { + sender.sendMessage(ChatColor.YELLOW + "Aktuelles Profil: " + ChatColor.WHITE + profile); + sender.sendMessage(ChatColor.YELLOW + "Benutzung: /antibot profile "); + return; + } + String requested = args[1].trim().toLowerCase(Locale.ROOT); + if (!isSupportedProfile(requested)) { sender.sendMessage(ChatColor.RED + "Unbekanntes Profil. Erlaubt: strict, high-traffic"); return; } + boolean ok = applyProfileAndPersist(requested); + if (!ok) { sender.sendMessage(ChatColor.RED + "Profil konnte nicht gespeichert werden."); return; } + sender.sendMessage(ChatColor.GREEN + "AntiBot-Profil umgestellt auf: " + requested); + sender.sendMessage(ChatColor.GRAY + "Werte wurden in " + CONFIG_FILE_NAME + " gespeichert."); + return; + } + + if ("reload".equalsIgnoreCase(args[0])) { + reloadRuntimeState(); + sender.sendMessage(ChatColor.GREEN + "AntiBot-Konfiguration neu geladen."); + sender.sendMessage(ChatColor.GRAY + "Aktives Profil: " + profile); + return; + } + + sender.sendMessage(ChatColor.YELLOW + "/antibot status"); + sender.sendMessage(ChatColor.YELLOW + "/antibot clearblocks"); + sender.sendMessage(ChatColor.YELLOW + "/antibot unblock "); + sender.sendMessage(ChatColor.YELLOW + "/antibot profile "); + sender.sendMessage(ChatColor.YELLOW + "/antibot reload"); + } + } +} diff --git a/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java b/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java index 9228034..1defc1d 100644 --- a/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java +++ b/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java @@ -1,381 +1,313 @@ -package net.viper.status.modules.broadcast; - -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.plugin.Plugin; -import net.md_5.bungee.api.connection.ProxiedPlayer; -import net.md_5.bungee.api.chat.TextComponent; -import net.md_5.bungee.api.plugin.Listener; -import net.viper.status.module.Module; - -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.text.SimpleDateFormat; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; - -/** - * BroadcastModule - * - * Speichert geplante Broadcasts jetzt persistent in 'broadcasts.schedules'. - * Beim Neustart werden diese automatisch wieder geladen. - */ -public class BroadcastModule implements Module, Listener { - - private Plugin plugin; - private boolean enabled = true; - private String requiredApiKey = ""; - private String format = "%prefix% %message%"; - private String fallbackPrefix = "[Broadcast]"; - private String fallbackPrefixColor = "&c"; - private String fallbackBracketColor = "&8"; // Neu - private String fallbackMessageColor = "&f"; - - private final Map scheduledByClientId = new ConcurrentHashMap<>(); - private File schedulesFile; - private final SimpleDateFormat dateFormat; - - public BroadcastModule() { - dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z"); - dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - } - - @Override - public String getName() { - return "BroadcastModule"; - } - - @Override - public void onEnable(Plugin plugin) { - this.plugin = plugin; - schedulesFile = new File(plugin.getDataFolder(), "broadcasts.schedules"); - loadConfig(); - - if (!enabled) { - plugin.getLogger().info("[BroadcastModule] deaktiviert via verify.properties (broadcast.enabled=false)"); - return; - } - - try { - plugin.getProxy().getPluginManager().registerListener(plugin, this); - } catch (Throwable ignored) {} - - plugin.getLogger().info("[BroadcastModule] aktiviert. Format: " + format); - loadSchedules(); - plugin.getProxy().getScheduler().schedule(plugin, this::processScheduled, 1, 1, TimeUnit.SECONDS); - } - - @Override - public void onDisable(Plugin plugin) { - plugin.getLogger().info("[BroadcastModule] deaktiviert."); - saveSchedules(); - scheduledByClientId.clear(); - } - - private void loadConfig() { - File file = new File(plugin.getDataFolder(), "verify.properties"); - if (!file.exists()) { - enabled = true; - requiredApiKey = ""; - format = "%prefix% %message%"; - fallbackPrefix = "[Broadcast]"; - fallbackPrefixColor = "&c"; - fallbackBracketColor = "&8"; // Neu - fallbackMessageColor = "&f"; - return; - } - - try (InputStream in = new FileInputStream(file)) { - Properties props = new Properties(); - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - enabled = Boolean.parseBoolean(props.getProperty("broadcast.enabled", "true")); - requiredApiKey = props.getProperty("broadcast.api_key", "").trim(); - format = props.getProperty("broadcast.format", format).trim(); - if (format.isEmpty()) format = "%prefix% %message%"; - fallbackPrefix = props.getProperty("broadcast.prefix", fallbackPrefix).trim(); - fallbackPrefixColor = props.getProperty("broadcast.prefix-color", fallbackPrefixColor).trim(); - fallbackBracketColor = props.getProperty("broadcast.bracket-color", fallbackBracketColor).trim(); // Neu - fallbackMessageColor = props.getProperty("broadcast.message-color", fallbackMessageColor).trim(); - } catch (IOException e) { - plugin.getLogger().warning("[BroadcastModule] Fehler beim Laden von verify.properties: " + e.getMessage()); - } - } - - public boolean handleBroadcast(String sourceName, String message, String type, String apiKeyHeader, - String prefix, String prefixColor, String bracketColor, String messageColor) { - loadConfig(); - - if (!enabled) { - plugin.getLogger().info("[BroadcastModule] Broadcast abgelehnt: Modul ist deaktiviert."); - return false; - } - - if (requiredApiKey != null && !requiredApiKey.isEmpty()) { - if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { - plugin.getLogger().warning("[BroadcastModule] Broadcast abgelehnt: API-Key fehlt oder inkorrekt."); - return false; - } - } - - if (message == null) message = ""; - if (sourceName == null || sourceName.isEmpty()) sourceName = "System"; - if (type == null) type = "global"; - - String usedPrefix = (prefix != null && !prefix.trim().isEmpty()) ? prefix.trim() : fallbackPrefix; - String usedPrefixColor = (prefixColor != null && !prefixColor.trim().isEmpty()) ? prefixColor.trim() : fallbackPrefixColor; - String usedBracketColor = (bracketColor != null && !bracketColor.trim().isEmpty()) ? bracketColor.trim() : fallbackBracketColor; // Neu - String usedMessageColor = (messageColor != null && !messageColor.trim().isEmpty()) ? messageColor.trim() : fallbackMessageColor; - - String prefixColorCode = normalizeColorCode(usedPrefixColor); - String bracketColorCode = normalizeColorCode(usedBracketColor); // Neu - String messageColorCode = normalizeColorCode(usedMessageColor); - - // --- KLAMMER LOGIK --- - String finalPrefix; - - // Wenn eine Klammerfarbe gesetzt ist, bauen wir den Prefix neu zusammen - // Format: [BracketColor][ [PrefixColor]Text [BracketColor]] - if (!bracketColorCode.isEmpty()) { - String textContent = usedPrefix; - // Entferne manuelle Klammern, falls der User [Broadcast] in das Textfeld geschrieben hat - if (textContent.startsWith("[")) textContent = textContent.substring(1); - if (textContent.endsWith("]")) textContent = textContent.substring(0, textContent.length() - 1); - - finalPrefix = bracketColorCode + "[" + prefixColorCode + textContent + bracketColorCode + "]" + ChatColor.RESET; - } else { - // Altes Verhalten: Ganzen String einfärben - finalPrefix = prefixColorCode + usedPrefix + ChatColor.RESET; - } - // --------------------- - - String coloredMessage = (messageColorCode.isEmpty() ? "" : messageColorCode) + message; - - String out = format.replace("%name%", sourceName) - .replace("%prefix%", finalPrefix) // Neu verwendete Variable - .replace("%prefixColored%", finalPrefix) // Fallback - .replace("%message%", message) - .replace("%messageColored%", coloredMessage) - .replace("%type%", type); - - if (!out.contains("%prefixColored%") && !out.contains("%messageColored%") && !out.contains("%prefix%") && !out.contains("%message%")) { - out = finalPrefix + " " + coloredMessage; - } - - TextComponent tc = new TextComponent(out); - int sent = 0; - for (ProxiedPlayer p : plugin.getProxy().getPlayers()) { - try { p.sendMessage(tc); sent++; } catch (Throwable ignored) {} - } - - plugin.getLogger().info("[BroadcastModule] Broadcast gesendet (Empfänger=" + sent + "): " + message); - return true; - } - - private String normalizeColorCode(String code) { - if (code == null) return ""; - code = code.trim(); - if (code.isEmpty()) return ""; - return code.contains("&") ? ChatColor.translateAlternateColorCodes('&', code) : code; - } - - private void saveSchedules() { - Properties props = new Properties(); - for (Map.Entry entry : scheduledByClientId.entrySet()) { - String id = entry.getKey(); - ScheduledBroadcast sb = entry.getValue(); - props.setProperty(id + ".nextRunMillis", String.valueOf(sb.nextRunMillis)); - props.setProperty(id + ".sourceName", sb.sourceName); - props.setProperty(id + ".message", sb.message); - props.setProperty(id + ".type", sb.type); - props.setProperty(id + ".prefix", sb.prefix); - props.setProperty(id + ".prefixColor", sb.prefixColor); - props.setProperty(id + ".bracketColor", sb.bracketColor); // Neu - props.setProperty(id + ".messageColor", sb.messageColor); - props.setProperty(id + ".recur", sb.recur); - } - - try (OutputStream out = new FileOutputStream(schedulesFile)) { - props.store(out, "PulseCast Scheduled Broadcasts"); - } catch (IOException e) { - plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage()); - } - } - - private void loadSchedules() { - if (!schedulesFile.exists()) { - plugin.getLogger().info("[BroadcastModule] Keine bestehenden Schedules gefunden (Neustart)."); - return; - } - - Properties props = new Properties(); - try (InputStream in = new FileInputStream(schedulesFile)) { - props.load(in); - } catch (IOException e) { - plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht laden: " + e.getMessage()); - return; - } - - Map loaded = new HashMap<>(); - for (String key : props.stringPropertyNames()) { - if (!key.contains(".")) continue; - String[] parts = key.split("\\."); - if (parts.length != 2) continue; - - String id = parts[0]; - String field = parts[1]; - String value = props.getProperty(key); - - ScheduledBroadcast sb = loaded.get(id); - if (sb == null) { - sb = new ScheduledBroadcast(id, 0, "", "", "", "", "", "", "", ""); // Ein leerer String mehr für Bracket - loaded.put(id, sb); - } - - switch (field) { - case "nextRunMillis": - try { sb.nextRunMillis = Long.parseLong(value); } catch (NumberFormatException ignored) {} - break; - case "sourceName": sb.sourceName = value; break; - case "message": sb.message = value; break; - case "type": sb.type = value; break; - case "prefix": sb.prefix = value; break; - case "prefixColor": sb.prefixColor = value; break; - case "bracketColor": sb.bracketColor = value; break; // Neu - case "messageColor": sb.messageColor = value; break; - case "recur": sb.recur = value; break; - } - } - scheduledByClientId.putAll(loaded); - plugin.getLogger().info("[BroadcastModule] " + loaded.size() + " geplante Broadcasts aus Datei wiederhergestellt."); - } - - public boolean scheduleBroadcast(long timestampMillis, String sourceName, String message, String type, - String apiKeyHeader, String prefix, String prefixColor, String bracketColor, String messageColor, - String recur, String clientScheduleId) { - loadConfig(); - - if (!enabled) { - plugin.getLogger().info("[BroadcastModule] schedule abgelehnt: Modul deaktiviert."); - return false; - } - - if (requiredApiKey != null && !requiredApiKey.isEmpty()) { - if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { - plugin.getLogger().warning("[BroadcastModule] schedule abgelehnt: API-Key fehlt oder inkorrekt."); - return false; - } - } - - if (message == null) message = ""; - if (sourceName == null || sourceName.isEmpty()) sourceName = "System"; - if (type == null) type = "global"; - if (recur == null) recur = "none"; - - String id = (clientScheduleId != null && !clientScheduleId.trim().isEmpty()) ? clientScheduleId.trim() : UUID.randomUUID().toString(); - - long now = System.currentTimeMillis(); - String scheduledTimeStr = dateFormat.format(new Date(timestampMillis)); - - plugin.getLogger().info("[BroadcastModule] Neue geplante Nachricht registriert: " + id + " @ " + scheduledTimeStr); - - if (timestampMillis <= now) { - plugin.getLogger().warning("[BroadcastModule] Geplante Zeit liegt in der Vergangenheit -> sende sofort!"); - return handleBroadcast(sourceName, message, type, apiKeyHeader, prefix, prefixColor, bracketColor, messageColor); - } - - ScheduledBroadcast sb = new ScheduledBroadcast(id, timestampMillis, sourceName, message, type, prefix, prefixColor, bracketColor, messageColor, recur); - scheduledByClientId.put(id, sb); - saveSchedules(); - return true; - } - - public boolean cancelScheduled(String clientScheduleId) { - if (clientScheduleId == null || clientScheduleId.trim().isEmpty()) return false; - ScheduledBroadcast removed = scheduledByClientId.remove(clientScheduleId); - if (removed != null) { - plugin.getLogger().info("[BroadcastModule] Geplante Nachricht abgebrochen: id=" + clientScheduleId); - saveSchedules(); - return true; - } - return false; - } - - private void processScheduled() { - if (scheduledByClientId.isEmpty()) return; - - long now = System.currentTimeMillis(); - List toRemove = new ArrayList<>(); - boolean changed = false; - - for (Map.Entry entry : scheduledByClientId.entrySet()) { - ScheduledBroadcast sb = entry.getValue(); - - if (sb.nextRunMillis <= now) { - String timeStr = dateFormat.format(new Date(sb.nextRunMillis)); - plugin.getLogger().info("[BroadcastModule] ⏰ Sende geplante Nachricht (ID: " + entry.getKey() + ", Zeit: " + timeStr + ")"); - - handleBroadcast(sb.sourceName, sb.message, sb.type, "", sb.prefix, sb.prefixColor, sb.bracketColor, sb.messageColor); - - if (!"none".equalsIgnoreCase(sb.recur)) { - long next = computeNextMillis(sb.nextRunMillis, sb.recur); - if (next > 0) { - sb.nextRunMillis = next; - String nextTimeStr = dateFormat.format(new Date(next)); - plugin.getLogger().info("[BroadcastModule] Nächste Wiederholung (" + sb.recur + "): " + nextTimeStr); - changed = true; - } else { - toRemove.add(entry.getKey()); - changed = true; - } - } else { - toRemove.add(entry.getKey()); - changed = true; - } - } - } - - if (changed || !toRemove.isEmpty()) { - for (String k : toRemove) { - scheduledByClientId.remove(k); - plugin.getLogger().info("[BroadcastModule] Schedule entfernt: " + k); - } - saveSchedules(); - } - } - - private long computeNextMillis(long currentMillis, String recur) { - switch (recur.toLowerCase(Locale.ROOT)) { - case "hourly": return currentMillis + TimeUnit.HOURS.toMillis(1); - case "daily": return currentMillis + TimeUnit.DAYS.toMillis(1); - case "weekly": return currentMillis + TimeUnit.DAYS.toMillis(7); - default: return -1L; - } - } - - private static class ScheduledBroadcast { - final String clientId; - long nextRunMillis; - String sourceName; - String message; - String type; - String prefix; - String prefixColor; - String bracketColor; // Neu - String messageColor; - String recur; - - ScheduledBroadcast(String clientId, long nextRunMillis, String sourceName, String message, String type, - String prefix, String prefixColor, String bracketColor, String messageColor, String recur) { - this.clientId = clientId; - this.nextRunMillis = nextRunMillis; - this.sourceName = sourceName; - this.message = message; - this.type = type; - this.prefix = prefix; - this.prefixColor = prefixColor; - this.bracketColor = bracketColor; // Neu - this.messageColor = messageColor; - this.recur = (recur == null ? "none" : recur); - } - } -} \ No newline at end of file +package net.viper.status.modules.broadcast; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.plugin.Listener; +import net.viper.status.module.Module; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * BroadcastModule + * + * Fixes: + * - loadSchedules(): ID-Split nutzt jetzt indexOf/lastIndexOf statt split("\\.") mit length==2-Check. + * Damit werden auch clientScheduleIds die Punkte enthalten korrekt geladen. (Bug #2) + */ +public class BroadcastModule implements Module, Listener { + + private Plugin plugin; + private boolean enabled = true; + private String requiredApiKey = ""; + private String format = "%prefix% %message%"; + private String fallbackPrefix = "[Broadcast]"; + private String fallbackPrefixColor = "&c"; + private String fallbackBracketColor = "&8"; + private String fallbackMessageColor = "&f"; + + private final Map scheduledByClientId = new ConcurrentHashMap<>(); + private File schedulesFile; + private final SimpleDateFormat dateFormat; + + public BroadcastModule() { + dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + @Override + public String getName() { return "BroadcastModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + schedulesFile = new File(plugin.getDataFolder(), "broadcasts.schedules"); + loadConfig(); + if (!enabled) return; + try { plugin.getProxy().getPluginManager().registerListener(plugin, this); } catch (Throwable ignored) {} + plugin.getLogger().info("[BroadcastModule] aktiviert. Format: " + format); + loadSchedules(); + plugin.getProxy().getScheduler().schedule(plugin, this::processScheduled, 1, 1, TimeUnit.SECONDS); + } + + @Override + public void onDisable(Plugin plugin) { + saveSchedules(); + scheduledByClientId.clear(); + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), "verify.properties"); + if (!file.exists()) return; + try (InputStream in = new FileInputStream(file)) { + Properties props = new Properties(); + props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + enabled = Boolean.parseBoolean(props.getProperty("broadcast.enabled", "true")); + requiredApiKey = props.getProperty("broadcast.api_key", "").trim(); + format = props.getProperty("broadcast.format", format).trim(); + if (format.isEmpty()) format = "%prefix% %message%"; + fallbackPrefix = props.getProperty("broadcast.prefix", fallbackPrefix).trim(); + fallbackPrefixColor = props.getProperty("broadcast.prefix-color", fallbackPrefixColor).trim(); + fallbackBracketColor = props.getProperty("broadcast.bracket-color", fallbackBracketColor).trim(); + fallbackMessageColor = props.getProperty("broadcast.message-color", fallbackMessageColor).trim(); + } catch (IOException e) { + plugin.getLogger().warning("[BroadcastModule] Fehler beim Laden von verify.properties: " + e.getMessage()); + } + } + + public boolean handleBroadcast(String sourceName, String message, String type, String apiKeyHeader, + String prefix, String prefixColor, String bracketColor, String messageColor) { + loadConfig(); + if (!enabled) return false; + if (requiredApiKey != null && !requiredApiKey.isEmpty()) { + if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { + plugin.getLogger().warning("[BroadcastModule] Broadcast abgelehnt: API-Key fehlt oder inkorrekt."); + return false; + } + } + + if (message == null) message = ""; + if (sourceName == null || sourceName.isEmpty()) sourceName = "System"; + if (type == null) type = "global"; + + String usedPrefix = (prefix != null && !prefix.trim().isEmpty()) ? prefix.trim() : fallbackPrefix; + String usedPrefixColor = (prefixColor != null && !prefixColor.trim().isEmpty()) ? prefixColor.trim() : fallbackPrefixColor; + String usedBracketColor = (bracketColor != null && !bracketColor.trim().isEmpty()) ? bracketColor.trim() : fallbackBracketColor; + String usedMessageColor = (messageColor != null && !messageColor.trim().isEmpty()) ? messageColor.trim() : fallbackMessageColor; + + String prefixColorCode = normalizeColorCode(usedPrefixColor); + String bracketColorCode = normalizeColorCode(usedBracketColor); + String messageColorCode = normalizeColorCode(usedMessageColor); + + String finalPrefix; + if (!bracketColorCode.isEmpty()) { + String textContent = usedPrefix; + if (textContent.startsWith("[")) textContent = textContent.substring(1); + if (textContent.endsWith("]")) textContent = textContent.substring(0, textContent.length() - 1); + finalPrefix = bracketColorCode + "[" + prefixColorCode + textContent + bracketColorCode + "]" + ChatColor.RESET; + } else { + finalPrefix = prefixColorCode + usedPrefix + ChatColor.RESET; + } + + String coloredMessage = (messageColorCode.isEmpty() ? "" : messageColorCode) + message; + String out = format + .replace("%name%", sourceName) + .replace("%prefix%", finalPrefix) + .replace("%prefixColored%", finalPrefix) + .replace("%message%", message) + .replace("%messageColored%",coloredMessage) + .replace("%type%", type); + + TextComponent tc = new TextComponent(out); + int sent = 0; + for (ProxiedPlayer p : plugin.getProxy().getPlayers()) { + try { p.sendMessage(tc); sent++; } catch (Throwable ignored) {} + } + plugin.getLogger().info("[BroadcastModule] Broadcast gesendet (Empfänger=" + sent + "): " + message); + return true; + } + + private String normalizeColorCode(String code) { + if (code == null) return ""; + code = code.trim(); + if (code.isEmpty()) return ""; + return code.contains("&") ? ChatColor.translateAlternateColorCodes('&', code) : code; + } + + private void saveSchedules() { + Properties props = new Properties(); + for (Map.Entry entry : scheduledByClientId.entrySet()) { + String id = entry.getKey(); + ScheduledBroadcast sb = entry.getValue(); + // Wir escapen den ID-Wert damit Punkte in der ID nicht den Parser verwirren + props.setProperty(id + ".nextRunMillis", String.valueOf(sb.nextRunMillis)); + props.setProperty(id + ".sourceName", sb.sourceName); + props.setProperty(id + ".message", sb.message); + props.setProperty(id + ".type", sb.type); + props.setProperty(id + ".prefix", sb.prefix); + props.setProperty(id + ".prefixColor", sb.prefixColor); + props.setProperty(id + ".bracketColor", sb.bracketColor); + props.setProperty(id + ".messageColor", sb.messageColor); + props.setProperty(id + ".recur", sb.recur); + } + try (OutputStream out = new FileOutputStream(schedulesFile)) { + props.store(out, "PulseCast Scheduled Broadcasts"); + } catch (IOException e) { + plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage()); + } + } + + /** + * FIX #2: Robusteres Parsen der Property-Keys. + * Statt split("\\.") mit length==2-Check nutzen wir lastIndexOf('.') um den letzten + * Punkt als Trenner zu verwenden. Damit funktionieren auch IDs die Punkte enthalten. + * + * Bekannte Felder: nextRunMillis, sourceName, message, type, prefix, prefixColor, + * bracketColor, messageColor, recur → alle ohne Punkte im Namen. + */ + private void loadSchedules() { + if (!schedulesFile.exists()) return; + Properties props = new Properties(); + try (InputStream in = new FileInputStream(schedulesFile)) { + props.load(in); + } catch (IOException e) { + plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht laden: " + e.getMessage()); + return; + } + + // Bekannte Feld-Suffixe + Set knownFields = new HashSet<>(Arrays.asList( + "nextRunMillis", "sourceName", "message", "type", + "prefix", "prefixColor", "bracketColor", "messageColor", "recur" + )); + + Map loaded = new LinkedHashMap<>(); + for (String key : props.stringPropertyNames()) { + // Finde das letzte '.' das einen bekannten Feldnamen abtrennt + int lastDot = key.lastIndexOf('.'); + if (lastDot < 0) continue; + String field = key.substring(lastDot + 1); + if (!knownFields.contains(field)) continue; + String id = key.substring(0, lastDot); + if (id.isEmpty()) continue; + String value = props.getProperty(key); + + ScheduledBroadcast sb = loaded.computeIfAbsent(id, + k -> new ScheduledBroadcast(k, 0, "", "", "", "", "", "", "", "")); + + switch (field) { + case "nextRunMillis": try { sb.nextRunMillis = Long.parseLong(value); } catch (NumberFormatException ignored) {} break; + case "sourceName": sb.sourceName = value; break; + case "message": sb.message = value; break; + case "type": sb.type = value; break; + case "prefix": sb.prefix = value; break; + case "prefixColor": sb.prefixColor = value; break; + case "bracketColor": sb.bracketColor = value; break; + case "messageColor": sb.messageColor = value; break; + case "recur": sb.recur = value; break; + } + } + scheduledByClientId.putAll(loaded); + plugin.getLogger().info("[BroadcastModule] " + loaded.size() + " geplante Broadcasts aus Datei wiederhergestellt."); + } + + public boolean scheduleBroadcast(long timestampMillis, String sourceName, String message, String type, + String apiKeyHeader, String prefix, String prefixColor, String bracketColor, + String messageColor, String recur, String clientScheduleId) { + loadConfig(); + if (!enabled) return false; + if (requiredApiKey != null && !requiredApiKey.isEmpty()) { + if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { + plugin.getLogger().warning("[BroadcastModule] schedule abgelehnt: API-Key fehlt oder inkorrekt."); + return false; + } + } + if (message == null) message = ""; + if (sourceName == null || sourceName.isEmpty()) sourceName = "System"; + if (type == null) type = "global"; + if (recur == null) recur = "none"; + + String id = (clientScheduleId != null && !clientScheduleId.trim().isEmpty()) + ? clientScheduleId.trim() : UUID.randomUUID().toString(); + + long now = System.currentTimeMillis(); + if (timestampMillis <= now) { + plugin.getLogger().warning("[BroadcastModule] Geplante Zeit in der Vergangenheit → sende sofort!"); + return handleBroadcast(sourceName, message, type, apiKeyHeader, prefix, prefixColor, bracketColor, messageColor); + } + + ScheduledBroadcast sb = new ScheduledBroadcast(id, timestampMillis, sourceName, message, type, + prefix, prefixColor, bracketColor, messageColor, recur); + scheduledByClientId.put(id, sb); + saveSchedules(); + plugin.getLogger().info("[BroadcastModule] Neue geplante Nachricht registriert: " + id + + " @ " + dateFormat.format(new Date(timestampMillis))); + return true; + } + + public boolean cancelScheduled(String clientScheduleId) { + if (clientScheduleId == null || clientScheduleId.trim().isEmpty()) return false; + ScheduledBroadcast removed = scheduledByClientId.remove(clientScheduleId); + if (removed != null) { plugin.getLogger().info("[BroadcastModule] Schedule abgebrochen: " + clientScheduleId); saveSchedules(); return true; } + return false; + } + + private void processScheduled() { + if (scheduledByClientId.isEmpty()) return; + long now = System.currentTimeMillis(); + List toRemove = new ArrayList<>(); + boolean changed = false; + + for (Map.Entry entry : scheduledByClientId.entrySet()) { + ScheduledBroadcast sb = entry.getValue(); + if (sb.nextRunMillis <= now) { + plugin.getLogger().info("[BroadcastModule] ⏰ Sende geplante Nachricht (ID: " + entry.getKey() + ")"); + handleBroadcast(sb.sourceName, sb.message, sb.type, "", sb.prefix, sb.prefixColor, sb.bracketColor, sb.messageColor); + if (!"none".equalsIgnoreCase(sb.recur)) { + long next = computeNextMillis(sb.nextRunMillis, sb.recur); + if (next > 0) { sb.nextRunMillis = next; changed = true; } + else { toRemove.add(entry.getKey()); changed = true; } + } else { toRemove.add(entry.getKey()); changed = true; } + } + } + if (changed || !toRemove.isEmpty()) { + for (String k : toRemove) { scheduledByClientId.remove(k); } + saveSchedules(); + } + } + + private long computeNextMillis(long currentMillis, String recur) { + switch (recur.toLowerCase(Locale.ROOT)) { + case "hourly": return currentMillis + TimeUnit.HOURS.toMillis(1); + case "daily": return currentMillis + TimeUnit.DAYS.toMillis(1); + case "weekly": return currentMillis + TimeUnit.DAYS.toMillis(7); + default: return -1L; + } + } + + private static class ScheduledBroadcast { + final String clientId; + long nextRunMillis; + String sourceName, message, type, prefix, prefixColor, bracketColor, messageColor, recur; + + ScheduledBroadcast(String clientId, long nextRunMillis, String sourceName, String message, String type, + String prefix, String prefixColor, String bracketColor, String messageColor, String recur) { + this.clientId = clientId; + this.nextRunMillis = nextRunMillis; + this.sourceName = sourceName; + this.message = message; + this.type = type; + this.prefix = prefix; + this.prefixColor = prefixColor; + this.bracketColor = bracketColor; + this.messageColor = messageColor; + this.recur = recur == null ? "none" : recur; + } + } +} diff --git a/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java b/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java index 2646353..0260d62 100644 --- a/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java +++ b/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java @@ -304,7 +304,6 @@ public class AccountLinkManager { } catch (IOException e) { logger.warning("[ChatModule] Fehler beim Laden der Account-Links: " + e.getMessage()); } - logger.info("[ChatModule] " + links.size() + " Account-Verknüpfungen geladen."); } private static String esc(String s) { diff --git a/src/main/java/net/viper/status/modules/chat/ChatConfig.java b/src/main/java/net/viper/status/modules/chat/ChatConfig.java index 4bd1563..104b41e 100644 --- a/src/main/java/net/viper/status/modules/chat/ChatConfig.java +++ b/src/main/java/net/viper/status/modules/chat/ChatConfig.java @@ -1,583 +1,496 @@ -package net.viper.status.modules.chat; - -import net.md_5.bungee.api.plugin.Plugin; -import net.md_5.bungee.config.Configuration; -import net.md_5.bungee.config.ConfigurationProvider; -import net.md_5.bungee.config.YamlConfiguration; - -import java.io.*; -import java.nio.file.Files; -import java.util.*; - -/** - * Lädt und verwaltet die chat.yml Konfiguration. - */ -public class ChatConfig { - - private final Plugin plugin; - private Configuration config; - - // Geladene Kanäle - private final Map channels = new LinkedHashMap<>(); - private String defaultChannel; - - // HelpOp - private String helpopFormat; - private String helpopPermission; - private int helpopCooldown; - private String helpopConfirm; - private String helpopDiscordWebhook; - private String helpopTelegramChatId; - - // Broadcast - private String broadcastFormat; - private String broadcastPermission; - - // Private Messages - private boolean pmEnabled; - private String pmFormatSender; - private String pmFormatReceiver; - private String pmFormatSpy; - private String pmSpyPermission; - private boolean pmRateLimitEnabled; - private long pmRateLimitWindowMs; - private int pmRateLimitMaxActions; - private long pmRateLimitBlockMs; - private String pmRateLimitMessage; - - // Mute - private int defaultMuteDuration; - private String mutedMessage; - - // Emoji - private boolean emojiEnabled; - private boolean emojiBedrockSupport; - private final Map emojiMappings = new LinkedHashMap<>(); - - // Discord - private boolean discordEnabled; - private String discordBotToken; - private String discordGuildId; - private int discordPollInterval; - private String discordFromFormat; - private String discordAdminChannelId; - private String discordEmbedColor; - - // Telegram - private boolean telegramEnabled; - private String telegramBotToken; - private int telegramPollInterval; - private String telegramFromFormat; - private String telegramAdminChatId; - private int telegramChatTopicId; - private int telegramAdminTopicId; - - // Account-Linking - private boolean linkingEnabled; - private String linkDiscordMessage; - private String linkTelegramMessage; - private String linkSuccessDiscord; - private String linkSuccessTelegram; - private String linkBotSuccessDiscord; - private String linkBotSuccessTelegram; - private String linkedDiscordFormat; - private String linkedTelegramFormat; - private int telegramAdminThreadId; - - // Admin - private String adminBypassPermission; - private String adminNotifyPermission; - - // ===== NEU: Chatlog ===== - private boolean chatlogEnabled; - private int chatlogRetentionDays; - - // ===== NEU: Server-Farben ===== - private final Map serverColors = new LinkedHashMap<>(); - private final Map serverDisplayNames = new LinkedHashMap<>(); - private String serverColorDefault; - - // ===== NEU: Reports ===== - private boolean reportsEnabled; - private String reportConfirm; - - // ===== Chat-Filter ===== - private ChatFilter.ChatFilterConfig filterConfig = new ChatFilter.ChatFilterConfig(); - - // ===== Mentions ===== - private boolean mentionsEnabled; - private String mentionsHighlightColor; - private String mentionsSound; - private boolean mentionsAllowToggle; - private String mentionsNotifyPrefix; - - // ===== Chat-History ===== - private int historyMaxLines; - private int historyDefaultLines; - private String reportPermission; - private String reportClosePermission; - private String reportViewPermission; - private int reportCooldown; - private String reportDiscordWebhook; - private String reportTelegramChatId; - private boolean reportWebhookEnabled; - - public ChatConfig(Plugin plugin) { - this.plugin = plugin; - } - - public void load() { - File file = new File(plugin.getDataFolder(), "chat.yml"); - if (!file.exists()) { - plugin.getDataFolder().mkdirs(); - InputStream in = plugin.getResourceAsStream("chat.yml"); - if (in != null) { - try { - Files.copy(in, file.toPath()); - } catch (IOException e) { - plugin.getLogger().severe("[ChatModule] Konnte chat.yml nicht erstellen: " + e.getMessage()); - } - } else { - plugin.getLogger().warning("[ChatModule] chat.yml nicht in JAR gefunden, erstelle leere Datei."); - try { file.createNewFile(); } catch (IOException ignored) {} - } - } - - try { - config = ConfigurationProvider.getProvider(YamlConfiguration.class).load(file); - } catch (IOException e) { - plugin.getLogger().severe("[ChatModule] Fehler beim Laden der chat.yml: " + e.getMessage()); - config = new Configuration(); - } - - parseConfig(); - plugin.getLogger().info("[ChatModule] " + channels.size() + " Kanäle geladen."); - } - - private void parseConfig() { - defaultChannel = config.getString("default-channel", "global"); - - // --- Kanäle --- - channels.clear(); - Configuration chSection = config.getSection("channels"); - if (chSection != null) { - for (String id : chSection.getKeys()) { - Configuration ch = chSection.getSection(id); - if (ch == null) continue; - channels.put(id.toLowerCase(), new ChatChannel( - id.toLowerCase(), - ch.getString("name", id), - ch.getString("symbol", id.substring(0, 1).toUpperCase()), - ch.getString("permission", ""), - ch.getString("color", "&f"), - ch.getString("format", "&8[&7{server}&8] {prefix}&r{player}{suffix}&8: &f{message}"), - ch.getBoolean("local-only", false), - ch.getString("discord-webhook", ""), - ch.getString("discord-channel-id", ""), - ch.getString("telegram-chat-id", ""), - ch.getInt("telegram-thread-id", 0), - ch.getBoolean("use-admin-bridge", false) - )); - } - } - - // Fallback: global-Kanal immer vorhanden - if (!channels.containsKey("global")) { - channels.put("global", new ChatChannel( - "global", "Global", "G", "", "&a", - "&8[&a{server}&8] {prefix}&r{player}{suffix}&8: &f{message}", - false, "", "", "", 0, false - )); - } - - // --- HelpOp --- - Configuration ho = config.getSection("helpop"); - if (ho != null) { - helpopFormat = ho.getString("format", "&8[&eHELPOP&8] &f{player}&8@&7{server}&8: &e{message}"); - helpopPermission = ho.getString("receive-permission", "chat.helpop.receive"); - helpopCooldown = ho.getInt("cooldown", 30); - helpopConfirm = ho.getString("confirm-message", "&aHilferuf gesendet!"); - helpopDiscordWebhook = ho.getString("discord-webhook", ""); - helpopTelegramChatId = ho.getString("telegram-chat-id", ""); - } - - // --- Broadcast --- - Configuration bc = config.getSection("broadcast"); - if (bc != null) { - broadcastFormat = bc.getString("format", "&c[&6Broadcast&c] &e{message}"); - broadcastPermission = bc.getString("permission", "chat.broadcast"); - } - - // --- Private Messages --- - Configuration pm = config.getSection("private-messages"); - if (pm != null) { - pmEnabled = pm.getBoolean("enabled", true); - pmFormatSender = pm.getString("format-sender", "&8[&7Du &8→ &b{player}&8] &f{message}"); - pmFormatReceiver = pm.getString("format-receiver", "&8[&b{player} &8→ &7Dir&8] &f{message}"); - pmFormatSpy = pm.getString("format-social-spy","&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}"); - pmSpyPermission = pm.getString("social-spy-permission", "chat.socialspy"); - } - - // --- Mute --- - Configuration mu = config.getSection("mute"); - if (mu != null) { - defaultMuteDuration = mu.getInt("default-duration-minutes", 60); - mutedMessage = mu.getString("muted-message", "&cDu bist stummgeschaltet. Noch: &f{time}"); - } - - // --- Emoji --- - Configuration em = config.getSection("emoji"); - if (em != null) { - emojiEnabled = em.getBoolean("enabled", true); - emojiBedrockSupport = em.getBoolean("bedrock-support", true); - emojiMappings.clear(); - Configuration map = em.getSection("mappings"); - if (map != null) { - for (String key : map.getKeys()) { - emojiMappings.put(key, map.getString(key, key)); - } - } - } - - // --- Discord --- - Configuration dc = config.getSection("discord"); - if (dc != null) { - discordEnabled = dc.getBoolean("enabled", false); - discordBotToken = dc.getString("bot-token", ""); - discordGuildId = dc.getString("guild-id", ""); - discordPollInterval = dc.getInt("poll-interval", 3); - discordFromFormat = dc.getString("from-discord-format", "&9[Discord] &b{user}&8: &f{message}"); - discordAdminChannelId = dc.getString("admin-channel-id", ""); - discordEmbedColor = dc.getString("embed-color", "5865F2"); - } - - // --- Telegram --- - Configuration tg = config.getSection("telegram"); - if (tg != null) { - telegramEnabled = tg.getBoolean("enabled", false); - telegramBotToken = tg.getString("bot-token", ""); - telegramPollInterval = tg.getInt("poll-interval", 3); - telegramFromFormat = tg.getString("from-telegram-format", "&3[Telegram] &b{user}&8: &f{message}"); - telegramAdminChatId = tg.getString("admin-chat-id", ""); - telegramChatTopicId = tg.getInt("chat-topic-id", 0); - telegramAdminTopicId = tg.getInt("admin-topic-id", 0); - } - - // --- Account-Linking --- - Configuration al = config.getSection("account-linking"); - if (al != null) { - linkingEnabled = al.getBoolean("enabled", true); - linkDiscordMessage = al.getString("discord-link-message", "&aCode: &f{token}"); - linkTelegramMessage = al.getString("telegram-link-message", "&aCode: &f{token}"); - linkSuccessDiscord = al.getString("success-discord", "&aDiscord verknüpft!"); - linkSuccessTelegram = al.getString("success-telegram", "&aTelegram verknüpft!"); - linkBotSuccessDiscord = al.getString("bot-success-discord", "✅ Verknüpft: {player}"); - linkBotSuccessTelegram = al.getString("bot-success-telegram", "✅ Verknüpft: {player}"); - linkedDiscordFormat = al.getString("linked-discord-format", - "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}"); - linkedTelegramFormat = al.getString("linked-telegram-format", - "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}"); - } else { - linkingEnabled = true; - linkDiscordMessage = "&aCode: &f{token}"; - linkTelegramMessage = "&aCode: &f{token}"; - linkSuccessDiscord = "&aDiscord verknüpft!"; - linkSuccessTelegram = "&aTelegram verknüpft!"; - linkBotSuccessDiscord = "✅ Verknüpft: {player}"; - linkBotSuccessTelegram = "✅ Verknüpft: {player}"; - linkedDiscordFormat = "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}"; - linkedTelegramFormat = "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}"; - } - - // --- Chat-Filter --- - filterConfig = new ChatFilter.ChatFilterConfig(); - Configuration cf = config.getSection("chat-filter"); - if (cf != null) { - Configuration spam = cf.getSection("anti-spam"); - if (spam != null) { - filterConfig.antiSpamEnabled = spam.getBoolean("enabled", true); - filterConfig.spamCooldownMs = spam.getInt("cooldown-ms", 1500); - filterConfig.spamMaxMessages = spam.getInt("max-messages", 3); - filterConfig.spamMessage = spam.getString("message", "&cNicht so schnell!"); - filterConfig.globalRateLimitWindowMs = Math.max(500L, filterConfig.spamCooldownMs); - filterConfig.globalRateLimitMaxActions = Math.max(1, filterConfig.spamMaxMessages); - filterConfig.globalRateLimitBlockMs = Math.max(2000L, filterConfig.spamCooldownMs * 2L); - } - Configuration dup = cf.getSection("duplicate-check"); - if (dup != null) { - filterConfig.duplicateCheckEnabled = dup.getBoolean("enabled", true); - filterConfig.duplicateMessage = dup.getString("message", "&cKeine identischen Nachrichten."); - } - Configuration bl = cf.getSection("blacklist"); - if (bl != null) { - filterConfig.blacklistEnabled = bl.getBoolean("enabled", true); - filterConfig.blacklistWords.clear(); - // words ist eine YAML-Liste, nicht eine Section → getList() verwenden - try { - java.util.List wordList = bl.getList("words"); - if (wordList != null) { - for (Object o : wordList) { - if (o != null && !o.toString().trim().isEmpty()) { - filterConfig.blacklistWords.add(o.toString().trim()); - } - } - } - } catch (Exception ignored) {} - } - Configuration caps = cf.getSection("caps-filter"); - if (caps != null) { - filterConfig.capsFilterEnabled = caps.getBoolean("enabled", true); - filterConfig.capsMinLength = caps.getInt("min-length", 6); - filterConfig.capsMaxPercent = caps.getInt("max-percent", 70); - } - } - - // --- Globales Rate-Limit-Framework --- - pmRateLimitEnabled = true; - pmRateLimitWindowMs = 5000L; - pmRateLimitMaxActions = 4; - pmRateLimitBlockMs = 10000L; - pmRateLimitMessage = "&cDu sendest zu viele private Nachrichten. Bitte warte kurz."; - - Configuration rl = config.getSection("rate-limit"); - if (rl != null) { - Configuration rlChat = rl.getSection("chat"); - if (rlChat != null) { - filterConfig.globalRateLimitEnabled = rlChat.getBoolean("enabled", true); - filterConfig.globalRateLimitWindowMs = rlChat.getLong("window-ms", filterConfig.globalRateLimitWindowMs); - filterConfig.globalRateLimitMaxActions = rlChat.getInt("max-actions", filterConfig.globalRateLimitMaxActions); - filterConfig.globalRateLimitBlockMs = rlChat.getLong("block-ms", filterConfig.globalRateLimitBlockMs); - filterConfig.spamMessage = rlChat.getString("message", filterConfig.spamMessage); - } - - Configuration rlPm = rl.getSection("private-messages"); - if (rlPm != null) { - pmRateLimitEnabled = rlPm.getBoolean("enabled", pmRateLimitEnabled); - pmRateLimitWindowMs = rlPm.getLong("window-ms", pmRateLimitWindowMs); - pmRateLimitMaxActions = rlPm.getInt("max-actions", pmRateLimitMaxActions); - pmRateLimitBlockMs = rlPm.getLong("block-ms", pmRateLimitBlockMs); - pmRateLimitMessage = rlPm.getString("message", pmRateLimitMessage); - } - } - - // --- Mentions --- - Configuration mn = config.getSection("mentions"); - if (mn != null) { - mentionsEnabled = mn.getBoolean("enabled", true); - mentionsHighlightColor = mn.getString("highlight-color", "&e&l"); - mentionsSound = mn.getString("sound", "ENTITY_EXPERIENCE_ORB_PICKUP"); - mentionsAllowToggle = mn.getBoolean("allow-toggle", true); - mentionsNotifyPrefix = mn.getString("notify-prefix", "&e&l[Mention] &r"); - } else { - mentionsEnabled = true; - mentionsHighlightColor = "&e&l"; - mentionsSound = "ENTITY_EXPERIENCE_ORB_PICKUP"; - mentionsAllowToggle = true; - mentionsNotifyPrefix = "&e&l[Mention] &r"; - } - - // --- Chat-History --- - Configuration ch = config.getSection("chat-history"); - if (ch != null) { - historyMaxLines = ch.getInt("max-lines", 50); - historyDefaultLines = ch.getInt("default-lines", 10); - } else { - historyMaxLines = 50; - historyDefaultLines = 10; - } - - // --- Admin --- - Configuration adm = config.getSection("admin"); - if (adm != null) { - adminBypassPermission = adm.getString("bypass-permission", "chat.admin.bypass"); - adminNotifyPermission = adm.getString("notify-permission", "chat.admin.notify"); - } else { - adminBypassPermission = "chat.admin.bypass"; - adminNotifyPermission = "chat.admin.notify"; - } - - // --- Server-Farben --- - serverColors.clear(); - serverDisplayNames.clear(); - Configuration sc = config.getSection("server-colors"); - if (sc != null) { - serverColorDefault = sc.getString("default", "&7"); - for (String key : sc.getKeys()) { - if (key.equals("default")) continue; - // Neues Format: server hat Untersektion mit color + display - Configuration sub = sc.getSection(key); - if (sub != null) { - serverColors.put(key.toLowerCase(), sub.getString("color", "&7")); - String display = sub.getString("display", ""); - if (!display.isEmpty()) { - serverDisplayNames.put(key.toLowerCase(), display); - } - } else { - // Altes Format: server: "&a" (nur Farbe, kein display) - serverColors.put(key.toLowerCase(), sc.getString(key, "&7")); - } - } - } else { - serverColorDefault = "&7"; - } - - // --- Chatlog (NEU) --- - Configuration cl = config.getSection("chatlog"); - if (cl != null) { - chatlogEnabled = cl.getBoolean("enabled", true); - // Nur 7 oder 14 erlaubt; Standardwert 7 - int raw = cl.getInt("retention-days", 7); - chatlogRetentionDays = (raw == 14) ? 14 : 7; - } else { - chatlogEnabled = true; - chatlogRetentionDays = 7; - } - - // --- Reports (NEU) --- - Configuration rp = config.getSection("reports"); - if (rp != null) { - reportsEnabled = rp.getBoolean("enabled", true); - reportWebhookEnabled = rp.getBoolean("webhook-enabled", false); - reportConfirm = rp.getString("confirm-message", - "&aDein Report &8(§f{id}&8) &awurde eingereicht. Danke!"); - reportPermission = rp.getString("report-permission", ""); // leer = jeder - reportClosePermission= rp.getString("close-permission", "chat.admin.bypass"); - reportViewPermission = rp.getString("view-permission", "chat.admin.bypass"); - reportCooldown = rp.getInt("cooldown", 60); - reportDiscordWebhook = rp.getString("discord-webhook", ""); - reportTelegramChatId = rp.getString("telegram-chat-id", ""); - } else { - reportsEnabled = true; - reportWebhookEnabled = false; - reportConfirm = "&aDein Report &8({id}) &awurde eingereicht. Danke!"; - reportPermission = ""; - reportClosePermission = "chat.admin.bypass"; - reportViewPermission = "chat.admin.bypass"; - reportCooldown = 60; - reportDiscordWebhook = ""; - reportTelegramChatId = ""; - } - } - - // ===== Getter (bestehend) ===== - - public Map getChannels() { return Collections.unmodifiableMap(channels); } - public ChatChannel getChannel(String id) { return channels.get(id == null ? defaultChannel : id.toLowerCase()); } - public ChatChannel getDefaultChannel() { return channels.getOrDefault(defaultChannel, channels.values().iterator().next()); } - public String getDefaultChannelId() { return defaultChannel; } - - public String getHelpopFormat() { return helpopFormat; } - public String getHelpopPermission() { return helpopPermission; } - public int getHelpopCooldown() { return helpopCooldown; } - public String getHelpopConfirm() { return helpopConfirm; } - public String getHelpopDiscordWebhook() { return helpopDiscordWebhook; } - public String getHelpopTelegramChatId() { return helpopTelegramChatId; } - - public String getBroadcastFormat() { return broadcastFormat; } - public String getBroadcastPermission() { return broadcastPermission; } - - public boolean isPmEnabled() { return pmEnabled; } - public String getPmFormatSender() { return pmFormatSender; } - public String getPmFormatReceiver() { return pmFormatReceiver; } - public String getPmFormatSpy() { return pmFormatSpy; } - public String getPmSpyPermission() { return pmSpyPermission; } - public boolean isPmRateLimitEnabled() { return pmRateLimitEnabled; } - public long getPmRateLimitWindowMs() { return pmRateLimitWindowMs; } - public int getPmRateLimitMaxActions() { return pmRateLimitMaxActions; } - public long getPmRateLimitBlockMs() { return pmRateLimitBlockMs; } - public String getPmRateLimitMessage() { return pmRateLimitMessage; } - - public int getDefaultMuteDuration() { return defaultMuteDuration; } - public String getMutedMessage() { return mutedMessage; } - - public boolean isEmojiEnabled() { return emojiEnabled; } - public boolean isEmojiBedrockSupport() { return emojiBedrockSupport; } - public Map getEmojiMappings() { return Collections.unmodifiableMap(emojiMappings); } - - public boolean isDiscordEnabled() { return discordEnabled; } - public String getDiscordBotToken() { return discordBotToken; } - public String getDiscordGuildId() { return discordGuildId; } - public int getDiscordPollInterval() { return discordPollInterval; } - public String getDiscordFromFormat() { return discordFromFormat; } - public String getDiscordAdminChannelId() { return discordAdminChannelId; } - public String getDiscordEmbedColor() { return discordEmbedColor; } - - public boolean isTelegramEnabled() { return telegramEnabled; } - public String getTelegramBotToken() { return telegramBotToken; } - public int getTelegramPollInterval() { return telegramPollInterval; } - public String getTelegramFromFormat() { return telegramFromFormat; } - public String getTelegramAdminChatId() { return telegramAdminChatId; } - public int getTelegramChatTopicId() { return telegramChatTopicId; } - public int getTelegramAdminTopicId() { return telegramAdminTopicId; } - - // ===== Getter (Account-Linking) ===== - public boolean isLinkingEnabled() { return linkingEnabled; } - public String getLinkDiscordMessage() { return linkDiscordMessage; } - public String getLinkTelegramMessage() { return linkTelegramMessage; } - public String getLinkSuccessDiscord() { return linkSuccessDiscord; } - public String getLinkSuccessTelegram() { return linkSuccessTelegram; } - public String getLinkBotSuccessDiscord() { return linkBotSuccessDiscord; } - public String getLinkBotSuccessTelegram() { return linkBotSuccessTelegram; } - public String getLinkedDiscordFormat() { return linkedDiscordFormat; } - public String getLinkedTelegramFormat() { return linkedTelegramFormat; } - - public String getAdminBypassPermission() { return adminBypassPermission; } - public String getAdminNotifyPermission() { return adminNotifyPermission; } - - // ===== Getter (NEU: Server-Farben) ===== - - /** - * Gibt den konfigurierten Farb-Code für einen Server zurück. - * Unterstützt &-Codes und &#RRGGBB HEX-Codes. - * Fallback: "default"-Eintrag, dann "&7". - */ - public String getServerColor(String serverName) { - if (serverName == null) return serverColorDefault; - String color = serverColors.get(serverName.toLowerCase()); - return color != null ? color : serverColorDefault; - } - - public Map getServerColors() { return Collections.unmodifiableMap(serverColors); } - public String getServerColorDefault() { return serverColorDefault; } - - /** - * Gibt den konfigurierten Anzeigenamen für einen Server zurück. - * Fallback: echter Servername (unverändert). - */ - public String getServerDisplay(String serverName) { - if (serverName == null) return ""; - String display = serverDisplayNames.get(serverName.toLowerCase()); - return display != null ? display : serverName; - } - - // ===== Getter (NEU: Chatlog) ===== - - public boolean isChatlogEnabled() { return chatlogEnabled; } - public int getChatlogRetentionDays() { return chatlogRetentionDays; } - - // ===== Getter (NEU: Reports) ===== - - public boolean isReportsEnabled() { return reportsEnabled; } - public String getReportConfirm() { return reportConfirm; } - public String getReportPermission() { return reportPermission; } - public String getReportClosePermission() { return reportClosePermission; } - public String getReportViewPermission() { return reportViewPermission; } - public int getReportCooldown() { return reportCooldown; } - public String getReportDiscordWebhook() { return reportDiscordWebhook; } - public String getReportTelegramChatId() { return reportTelegramChatId; } - public boolean isReportWebhookEnabled() { return reportWebhookEnabled; } - - // ===== Getter (Chat-Filter) ===== - public ChatFilter.ChatFilterConfig getFilterConfig() { return filterConfig; } - - // ===== Getter (Mentions) ===== - public boolean isMentionsEnabled() { return mentionsEnabled; } - public String getMentionsHighlightColor() { return mentionsHighlightColor; } - public String getMentionsSound() { return mentionsSound; } - public boolean isMentionsAllowToggle() { return mentionsAllowToggle; } - public String getMentionsNotifyPrefix() { return mentionsNotifyPrefix; } - - // ===== Getter (Chat-History) ===== - public int getHistoryMaxLines() { return historyMaxLines; } - public int getHistoryDefaultLines() { return historyDefaultLines; } -} \ No newline at end of file +package net.viper.status.modules.chat; + +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; + +import java.io.*; +import java.nio.file.Files; +import java.util.*; + +/** + * Lädt und verwaltet die chat.yml Konfiguration. + * + * Fix #8: Rate-Limit-Werte aus anti-spam werden nicht mehr durch nachfolgende + * Berechnungen überschrieben. Der rate-limit.chat-Block hat jetzt Vorrang. + * Die Reihenfolge ist: erst rate-limit.chat einlesen, dann ggf. durch anti-spam + * als Fallback ergänzen, nicht umgekehrt. + */ +public class ChatConfig { + + private final Plugin plugin; + private Configuration config; + + private final Map channels = new LinkedHashMap<>(); + private String defaultChannel; + + private String helpopFormat, helpopPermission, helpopConfirm, helpopDiscordWebhook, helpopTelegramChatId; + private int helpopCooldown; + + private String broadcastFormat, broadcastPermission; + + private boolean pmEnabled; + private String pmFormatSender, pmFormatReceiver, pmFormatSpy, pmSpyPermission, pmRateLimitMessage; + private boolean pmRateLimitEnabled; + private long pmRateLimitWindowMs; + private int pmRateLimitMaxActions; + private long pmRateLimitBlockMs; + + private int defaultMuteDuration; + private String mutedMessage; + + private boolean emojiEnabled, emojiBedrockSupport; + private final Map emojiMappings = new LinkedHashMap<>(); + + private boolean discordEnabled; + private String discordBotToken, discordGuildId, discordFromFormat, discordAdminChannelId, discordEmbedColor; + private int discordPollInterval; + + private boolean telegramEnabled; + private String telegramBotToken, telegramFromFormat, telegramAdminChatId; + private int telegramPollInterval, telegramChatTopicId, telegramAdminTopicId; + + private boolean linkingEnabled; + private String linkDiscordMessage, linkTelegramMessage, linkSuccessDiscord, linkSuccessTelegram; + private String linkBotSuccessDiscord, linkBotSuccessTelegram, linkedDiscordFormat, linkedTelegramFormat; + private int telegramAdminThreadId; + + private String adminBypassPermission, adminNotifyPermission; + + private boolean chatlogEnabled; + private int chatlogRetentionDays; + + private final Map serverColors = new LinkedHashMap<>(); + private final Map serverDisplayNames = new LinkedHashMap<>(); + private String serverColorDefault; + + private boolean reportsEnabled, reportWebhookEnabled; + private String reportConfirm, reportPermission, reportClosePermission, reportViewPermission; + private String reportDiscordWebhook, reportTelegramChatId; + private int reportCooldown; + + private ChatFilter.ChatFilterConfig filterConfig = new ChatFilter.ChatFilterConfig(); + + private boolean mentionsEnabled, mentionsAllowToggle; + private String mentionsHighlightColor, mentionsSound, mentionsNotifyPrefix; + + private int historyMaxLines, historyDefaultLines; + + private boolean joinLeaveEnabled, vanishShowToAdmins; + private String joinFormat, leaveFormat, vanishJoinFormat, vanishLeaveFormat; + private String joinLeaveDiscordWebhook, joinLeaveTelegramChatId; + private int joinLeaveTelegramThreadId; + + public ChatConfig(Plugin plugin) { this.plugin = plugin; } + + public void load() { + File file = new File(plugin.getDataFolder(), "chat.yml"); + if (!file.exists()) { + plugin.getDataFolder().mkdirs(); + InputStream in = plugin.getResourceAsStream("chat.yml"); + if (in != null) { + try { Files.copy(in, file.toPath()); } + catch (IOException e) { plugin.getLogger().severe("[ChatModule] Konnte chat.yml nicht erstellen: " + e.getMessage()); } + } else { + plugin.getLogger().warning("[ChatModule] chat.yml nicht in JAR, erstelle leere Datei."); + try { file.createNewFile(); } catch (IOException ignored) {} + } + } + try { config = ConfigurationProvider.getProvider(YamlConfiguration.class).load(file); } + catch (IOException e) { + plugin.getLogger().severe("[ChatModule] Fehler beim Laden der chat.yml: " + e.getMessage()); + config = new Configuration(); + } + parseConfig(); + plugin.getLogger().info("[ChatModule] " + channels.size() + " Kanäle geladen."); + } + + private void parseConfig() { + defaultChannel = config.getString("default-channel", "global"); + + // --- Kanäle --- + channels.clear(); + Configuration chSection = config.getSection("channels"); + if (chSection != null) { + for (String id : chSection.getKeys()) { + Configuration ch = chSection.getSection(id); + if (ch == null) continue; + channels.put(id.toLowerCase(), new ChatChannel( + id.toLowerCase(), + ch.getString("name", id), + ch.getString("symbol", id.substring(0, 1).toUpperCase()), + ch.getString("permission", ""), + ch.getString("color", "&f"), + ch.getString("format", "&8[&7{server}&8] {prefix}&r{player}{suffix}&8: &f{message}"), + ch.getBoolean("local-only", false), + ch.getString("discord-webhook", ""), + ch.getString("discord-channel-id", ""), + ch.getString("telegram-chat-id", ""), + ch.getInt("telegram-thread-id", 0), + ch.getBoolean("use-admin-bridge", false) + )); + } + } + if (!channels.containsKey("global")) { + channels.put("global", new ChatChannel("global", "Global", "G", "", "&a", + "&8[&a{server}&8] {prefix}&r{player}{suffix}&8: &f{message}", false, "", "", "", 0, false)); + } + + // --- HelpOp --- + Configuration ho = config.getSection("helpop"); + if (ho != null) { + helpopFormat = ho.getString("format", "&8[&eHELPOP&8] &f{player}&8@&7{server}&8: &e{message}"); + helpopPermission = ho.getString("receive-permission", "chat.helpop.receive"); + helpopCooldown = ho.getInt("cooldown", 30); + helpopConfirm = ho.getString("confirm-message", "&aHilferuf gesendet!"); + helpopDiscordWebhook = ho.getString("discord-webhook", ""); + helpopTelegramChatId = ho.getString("telegram-chat-id", ""); + } else { + helpopFormat = "&8[&eHELPOP&8] &f{player}&8@&7{server}&8: &e{message}"; + helpopPermission = "chat.helpop.receive"; helpopCooldown = 30; + helpopConfirm = "&aHilferuf gesendet!"; helpopDiscordWebhook = ""; helpopTelegramChatId = ""; + } + + // --- Broadcast --- + Configuration bc = config.getSection("broadcast"); + broadcastFormat = bc != null ? bc.getString("format", "&c[&6Broadcast&c] &e{message}") : "&c[&6Broadcast&c] &e{message}"; + broadcastPermission = bc != null ? bc.getString("permission", "chat.broadcast") : "chat.broadcast"; + + // --- Private Messages --- + Configuration pm = config.getSection("private-messages"); + pmEnabled = pm == null || pm.getBoolean("enabled", true); + pmFormatSender = pm != null ? pm.getString("format-sender", "&8[&7Du &8→ &b{player}&8] &f{message}") : "&8[&7Du &8→ &b{player}&8] &f{message}"; + pmFormatReceiver = pm != null ? pm.getString("format-receiver", "&8[&b{player} &8→ &7Dir&8] &f{message}") : "&8[&b{player} &8→ &7Dir&8] &f{message}"; + pmFormatSpy = pm != null ? pm.getString("format-social-spy","&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}") : "&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}"; + pmSpyPermission = pm != null ? pm.getString("social-spy-permission", "chat.socialspy") : "chat.socialspy"; + + // --- Mute --- + Configuration mu = config.getSection("mute"); + defaultMuteDuration = mu != null ? mu.getInt("default-duration-minutes", 60) : 60; + mutedMessage = mu != null ? mu.getString("muted-message", "&cDu bist stummgeschaltet. Noch: &f{time}") : "&cDu bist stummgeschaltet. Noch: &f{time}"; + + // --- Emoji --- + Configuration em = config.getSection("emoji"); + if (em != null) { + emojiEnabled = em.getBoolean("enabled", true); + emojiBedrockSupport = em.getBoolean("bedrock-support", true); + emojiMappings.clear(); + Configuration map = em.getSection("mappings"); + if (map != null) { for (String key : map.getKeys()) emojiMappings.put(key, map.getString(key, key)); } + } else { emojiEnabled = true; emojiBedrockSupport = true; } + + // --- Discord --- + Configuration dc = config.getSection("discord"); + if (dc != null) { + discordEnabled = dc.getBoolean("enabled", false); + discordBotToken = dc.getString("bot-token", ""); + discordGuildId = dc.getString("guild-id", ""); + discordPollInterval = dc.getInt("poll-interval", 3); + discordFromFormat = dc.getString("from-discord-format", "&9[Discord] &b{user}&8: &f{message}"); + discordAdminChannelId = dc.getString("admin-channel-id", ""); + discordEmbedColor = dc.getString("embed-color", "5865F2"); + } else { discordEnabled = false; discordBotToken = ""; discordGuildId = ""; discordPollInterval = 3; discordFromFormat = "&9[Discord] &b{user}&8: &f{message}"; discordAdminChannelId = ""; discordEmbedColor = "5865F2"; } + + // --- Telegram --- + Configuration tg = config.getSection("telegram"); + if (tg != null) { + telegramEnabled = tg.getBoolean("enabled", false); + telegramBotToken = tg.getString("bot-token", ""); + telegramPollInterval = tg.getInt("poll-interval", 3); + telegramFromFormat = tg.getString("from-telegram-format", "&3[Telegram] &b{user}&8: &f{message}"); + telegramAdminChatId = tg.getString("admin-chat-id", ""); + telegramChatTopicId = tg.getInt("chat-topic-id", 0); + telegramAdminTopicId = tg.getInt("admin-topic-id", 0); + } else { telegramEnabled = false; telegramBotToken = ""; telegramPollInterval = 3; telegramFromFormat = "&3[Telegram] &b{user}&8: &f{message}"; telegramAdminChatId = ""; telegramChatTopicId = 0; telegramAdminTopicId = 0; } + + // --- Account-Linking --- + Configuration al = config.getSection("account-linking"); + linkingEnabled = al == null || al.getBoolean("enabled", true); + linkDiscordMessage = al != null ? al.getString("discord-link-message", "&aCode: &f{token}") : "&aCode: &f{token}"; + linkTelegramMessage = al != null ? al.getString("telegram-link-message", "&aCode: &f{token}") : "&aCode: &f{token}"; + linkSuccessDiscord = al != null ? al.getString("success-discord", "&aDiscord verknüpft!") : "&aDiscord verknüpft!"; + linkSuccessTelegram = al != null ? al.getString("success-telegram", "&aTelegram verknüpft!") : "&aTelegram verknüpft!"; + linkBotSuccessDiscord = al != null ? al.getString("bot-success-discord", "✅ Verknüpft: {player}") : "✅ Verknüpft: {player}"; + linkBotSuccessTelegram = al != null ? al.getString("bot-success-telegram", "✅ Verknüpft: {player}") : "✅ Verknüpft: {player}"; + linkedDiscordFormat = al != null ? al.getString("linked-discord-format", "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}") : "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}"; + linkedTelegramFormat = al != null ? al.getString("linked-telegram-format", "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}") : "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}"; + + // --- Chat-Filter --- + filterConfig = new ChatFilter.ChatFilterConfig(); + Configuration cf = config.getSection("chat-filter"); + if (cf != null) { + Configuration spam = cf.getSection("anti-spam"); + if (spam != null) { + filterConfig.antiSpamEnabled = spam.getBoolean("enabled", true); + filterConfig.spamCooldownMs = spam.getInt("cooldown-ms", 1500); + filterConfig.spamMaxMessages = spam.getInt("max-messages", 3); + filterConfig.spamMessage = spam.getString("message", "&cNicht so schnell!"); + // FIX #8: Fallback-Werte aus anti-spam werden NUR gesetzt wenn rate-limit.chat nicht + // konfiguriert ist. Wir setzen die Werte hier als Vorbelegung und überschreiben sie + // unten mit dem rate-limit.chat-Block wenn vorhanden. + filterConfig.globalRateLimitWindowMs = Math.max(500L, filterConfig.spamCooldownMs); + filterConfig.globalRateLimitMaxActions = Math.max(1, filterConfig.spamMaxMessages); + filterConfig.globalRateLimitBlockMs = Math.max(2000L, filterConfig.spamCooldownMs * 4L); + } + Configuration dup = cf.getSection("duplicate-check"); + if (dup != null) { + filterConfig.duplicateCheckEnabled = dup.getBoolean("enabled", true); + filterConfig.duplicateMessage = dup.getString("message", "&cKeine identischen Nachrichten."); + } + Configuration bl = cf.getSection("blacklist"); + if (bl != null) { + filterConfig.blacklistEnabled = bl.getBoolean("enabled", true); + filterConfig.blacklistWords.clear(); + loadFilterWords(filterConfig.blacklistWords); + try { + java.util.List wordList = bl.getList("words"); + if (wordList != null) { + for (Object o : wordList) { + if (o != null && !o.toString().trim().isEmpty()) { + String w = o.toString().trim(); + if (!filterConfig.blacklistWords.contains(w)) filterConfig.blacklistWords.add(w); + } + } + } + } catch (Exception ignored) {} + } + Configuration caps = cf.getSection("caps-filter"); + if (caps != null) { + filterConfig.capsFilterEnabled = caps.getBoolean("enabled", true); + filterConfig.capsMinLength = caps.getInt("min-length", 6); + filterConfig.capsMaxPercent = caps.getInt("max-percent", 70); + } + Configuration antiAd = cf.getSection("anti-ad"); + if (antiAd != null) { + filterConfig.antiAdEnabled = antiAd.getBoolean("enabled", true); + filterConfig.antiAdMessage = antiAd.getString("message", "&cWerbung ist nicht erlaubt!"); + java.util.List wl = antiAd.getList("whitelist"); + if (wl != null) { filterConfig.antiAdWhitelist.clear(); for (Object o : wl) if (o != null) filterConfig.antiAdWhitelist.add(o.toString()); } + java.util.List tlds = antiAd.getList("blocked-tlds"); + if (tlds != null) { filterConfig.antiAdBlockedTlds.clear(); for (Object o : tlds) if (o != null) filterConfig.antiAdBlockedTlds.add(o.toString()); } + } + } + + // --- Rate-Limit (FIX #8: dieser Block setzt die endgültigen Werte, hat Vorrang) --- + pmRateLimitEnabled = true; + pmRateLimitWindowMs = 5000L; + pmRateLimitMaxActions = 4; + pmRateLimitBlockMs = 10000L; + pmRateLimitMessage = "&cDu sendest zu viele private Nachrichten. Bitte warte kurz."; + + Configuration rl = config.getSection("rate-limit"); + if (rl != null) { + Configuration rlChat = rl.getSection("chat"); + if (rlChat != null) { + // FIX #8: rate-limit.chat überschreibt die anti-spam-Fallbacks vollständig + filterConfig.globalRateLimitEnabled = rlChat.getBoolean("enabled", true); + filterConfig.globalRateLimitWindowMs = rlChat.getLong("window-ms", filterConfig.globalRateLimitWindowMs); + filterConfig.globalRateLimitMaxActions = rlChat.getInt("max-actions", filterConfig.globalRateLimitMaxActions); + filterConfig.globalRateLimitBlockMs = rlChat.getLong("block-ms", filterConfig.globalRateLimitBlockMs); + filterConfig.spamMessage = rlChat.getString("message", filterConfig.spamMessage); + } + Configuration rlPm = rl.getSection("private-messages"); + if (rlPm != null) { + pmRateLimitEnabled = rlPm.getBoolean("enabled", pmRateLimitEnabled); + pmRateLimitWindowMs = rlPm.getLong("window-ms", pmRateLimitWindowMs); + pmRateLimitMaxActions = rlPm.getInt("max-actions", pmRateLimitMaxActions); + pmRateLimitBlockMs = rlPm.getLong("block-ms", pmRateLimitBlockMs); + pmRateLimitMessage = rlPm.getString("message", pmRateLimitMessage); + } + } + + // --- Mentions --- + Configuration mn = config.getSection("mentions"); + mentionsEnabled = mn == null || mn.getBoolean("enabled", true); + mentionsHighlightColor = mn != null ? mn.getString("highlight-color", "&e&l") : "&e&l"; + mentionsSound = mn != null ? mn.getString("sound", "ENTITY_EXPERIENCE_ORB_PICKUP") : "ENTITY_EXPERIENCE_ORB_PICKUP"; + mentionsAllowToggle = mn == null || mn.getBoolean("allow-toggle", true); + mentionsNotifyPrefix = mn != null ? mn.getString("notify-prefix", "&e&l[Mention] &r") : "&e&l[Mention] &r"; + + // --- Chat-History --- + Configuration ch = config.getSection("chat-history"); + historyMaxLines = ch != null ? ch.getInt("max-lines", 50) : 50; + historyDefaultLines = ch != null ? ch.getInt("default-lines", 10) : 10; + + // --- Admin --- + Configuration adm = config.getSection("admin"); + adminBypassPermission = adm != null ? adm.getString("bypass-permission", "chat.admin.bypass") : "chat.admin.bypass"; + adminNotifyPermission = adm != null ? adm.getString("notify-permission", "chat.admin.notify") : "chat.admin.notify"; + + // --- Server-Farben --- + serverColors.clear(); serverDisplayNames.clear(); + Configuration sc = config.getSection("server-colors"); + if (sc != null) { + serverColorDefault = sc.getString("default", "&7"); + for (String key : sc.getKeys()) { + if (key.equals("default")) continue; + Configuration sub = sc.getSection(key); + if (sub != null) { + serverColors.put(key.toLowerCase(), sub.getString("color", "&7")); + String display = sub.getString("display", ""); + if (!display.isEmpty()) serverDisplayNames.put(key.toLowerCase(), display); + } else { + serverColors.put(key.toLowerCase(), sc.getString(key, "&7")); + } + } + } else { serverColorDefault = "&7"; } + + // --- Chatlog --- + Configuration cl = config.getSection("chatlog"); + chatlogEnabled = cl == null || cl.getBoolean("enabled", true); + int raw = cl != null ? cl.getInt("retention-days", 7) : 7; + chatlogRetentionDays = (raw == 14) ? 14 : 7; + + // --- Reports --- + Configuration rp = config.getSection("reports"); + if (rp != null) { + reportsEnabled = rp.getBoolean("enabled", true); + reportWebhookEnabled = rp.getBoolean("webhook-enabled", false); + reportConfirm = rp.getString("confirm-message", "&aDein Report &8({id}) &awurde eingereicht. Danke!"); + reportPermission = rp.getString("report-permission", ""); + reportClosePermission = rp.getString("close-permission", "chat.admin.bypass"); + reportViewPermission = rp.getString("view-permission", "chat.admin.bypass"); + reportCooldown = rp.getInt("cooldown", 60); + reportDiscordWebhook = rp.getString("discord-webhook", ""); + reportTelegramChatId = rp.getString("telegram-chat-id", ""); + } else { + reportsEnabled = true; reportWebhookEnabled = false; + reportConfirm = "&aDein Report &8({id}) &awurde eingereicht. Danke!"; + reportPermission = ""; reportClosePermission = "chat.admin.bypass"; reportViewPermission = "chat.admin.bypass"; + reportCooldown = 60; reportDiscordWebhook = ""; reportTelegramChatId = ""; + } + + // --- Join / Leave --- + Configuration jl = config.getSection("join-leave"); + if (jl != null) { + joinLeaveEnabled = jl.getBoolean("enabled", true); + joinFormat = jl.getString("join-format", "&8[&a+&8] {prefix}&a{player}&r &7hat das Netzwerk betreten."); + leaveFormat = jl.getString("leave-format", "&8[&c-&8] {prefix}&c{player}&r &7hat das Netzwerk verlassen."); + vanishShowToAdmins = jl.getBoolean("vanish-show-to-admins", true); + vanishJoinFormat = jl.getString("vanish-join-format", "&8[&7+&8] &8{player} &7hat das Netzwerk betreten. &8(Vanish)"); + vanishLeaveFormat = jl.getString("vanish-leave-format", "&8[&7-&8] &8{player} &7hat das Netzwerk verlassen. &8(Vanish)"); + joinLeaveDiscordWebhook = jl.getString("discord-webhook", ""); + joinLeaveTelegramChatId = jl.getString("telegram-chat-id", ""); + joinLeaveTelegramThreadId = jl.getInt("telegram-thread-id", 0); + } else { + joinLeaveEnabled = true; + joinFormat = "&8[&a+&8] {prefix}&a{player}&r &7hat das Netzwerk betreten."; + leaveFormat = "&8[&c-&8] {prefix}&c{player}&r &7hat das Netzwerk verlassen."; + vanishShowToAdmins = true; + vanishJoinFormat = "&8[&7+&8] &8{player} &7hat das Netzwerk betreten. &8(Vanish)"; + vanishLeaveFormat = "&8[&7-&8] &8{player} &7hat das Netzwerk verlassen. &8(Vanish)"; + joinLeaveDiscordWebhook = ""; joinLeaveTelegramChatId = ""; joinLeaveTelegramThreadId = 0; + } + } + + private void loadFilterWords(java.util.List target) { + File filterFile = new File(plugin.getDataFolder(), "filter.yml"); + if (!filterFile.exists()) { + try { + plugin.getDataFolder().mkdirs(); + try (java.io.FileWriter fw = new java.io.FileWriter(filterFile)) { + fw.write("# StatusAPI - Wort-Blacklist\n# words:\n# - beispielwort\nwords:\n"); + } + plugin.getLogger().info("[ChatModule] filter.yml erstellt."); + } catch (IOException e) { plugin.getLogger().warning("[ChatModule] Konnte filter.yml nicht erstellen: " + e.getMessage()); } + return; + } + try { + Configuration fc = ConfigurationProvider.getProvider(YamlConfiguration.class).load(filterFile); + java.util.List words = fc.getList("words"); + if (words != null) { + for (Object o : words) { + if (o != null && !o.toString().trim().isEmpty()) target.add(o.toString().trim().toLowerCase()); + } + } + } catch (Exception e) { plugin.getLogger().warning("[ChatModule] Fehler beim Laden der filter.yml: " + e.getMessage()); } + } + + // ===== Getter ===== + + public Map getChannels() { return Collections.unmodifiableMap(channels); } + public ChatChannel getChannel(String id) { return channels.get(id == null ? defaultChannel : id.toLowerCase()); } + public ChatChannel getDefaultChannel() { return channels.getOrDefault(defaultChannel, channels.values().iterator().next()); } + public String getDefaultChannelId() { return defaultChannel; } + public String getHelpopFormat() { return helpopFormat; } + public String getHelpopPermission() { return helpopPermission; } + public int getHelpopCooldown() { return helpopCooldown; } + public String getHelpopConfirm() { return helpopConfirm; } + public String getHelpopDiscordWebhook() { return helpopDiscordWebhook; } + public String getHelpopTelegramChatId() { return helpopTelegramChatId; } + public String getBroadcastFormat() { return broadcastFormat; } + public String getBroadcastPermission() { return broadcastPermission; } + public boolean isPmEnabled() { return pmEnabled; } + public String getPmFormatSender() { return pmFormatSender; } + public String getPmFormatReceiver() { return pmFormatReceiver; } + public String getPmFormatSpy() { return pmFormatSpy; } + public String getPmSpyPermission() { return pmSpyPermission; } + public boolean isPmRateLimitEnabled() { return pmRateLimitEnabled; } + public long getPmRateLimitWindowMs() { return pmRateLimitWindowMs; } + public int getPmRateLimitMaxActions() { return pmRateLimitMaxActions; } + public long getPmRateLimitBlockMs() { return pmRateLimitBlockMs; } + public String getPmRateLimitMessage() { return pmRateLimitMessage; } + public int getDefaultMuteDuration() { return defaultMuteDuration; } + public String getMutedMessage() { return mutedMessage; } + public boolean isEmojiEnabled() { return emojiEnabled; } + public boolean isEmojiBedrockSupport() { return emojiBedrockSupport; } + public Map getEmojiMappings() { return Collections.unmodifiableMap(emojiMappings); } + public boolean isDiscordEnabled() { return discordEnabled; } + public String getDiscordBotToken() { return discordBotToken; } + public String getDiscordGuildId() { return discordGuildId; } + public int getDiscordPollInterval() { return discordPollInterval; } + public String getDiscordFromFormat() { return discordFromFormat; } + public String getDiscordAdminChannelId() { return discordAdminChannelId; } + public String getDiscordEmbedColor() { return discordEmbedColor; } + public boolean isTelegramEnabled() { return telegramEnabled; } + public String getTelegramBotToken() { return telegramBotToken; } + public int getTelegramPollInterval() { return telegramPollInterval; } + public String getTelegramFromFormat() { return telegramFromFormat; } + public String getTelegramAdminChatId() { return telegramAdminChatId; } + public int getTelegramChatTopicId() { return telegramChatTopicId; } + public int getTelegramAdminTopicId() { return telegramAdminTopicId; } + public boolean isLinkingEnabled() { return linkingEnabled; } + public String getLinkDiscordMessage() { return linkDiscordMessage; } + public String getLinkTelegramMessage() { return linkTelegramMessage; } + public String getLinkSuccessDiscord() { return linkSuccessDiscord; } + public String getLinkSuccessTelegram() { return linkSuccessTelegram; } + public String getLinkBotSuccessDiscord() { return linkBotSuccessDiscord; } + public String getLinkBotSuccessTelegram() { return linkBotSuccessTelegram; } + public String getLinkedDiscordFormat() { return linkedDiscordFormat; } + public String getLinkedTelegramFormat() { return linkedTelegramFormat; } + public String getAdminBypassPermission() { return adminBypassPermission; } + public String getAdminNotifyPermission() { return adminNotifyPermission; } + public String getServerColor(String serverName) { if (serverName == null) return serverColorDefault; String c = serverColors.get(serverName.toLowerCase()); return c != null ? c : serverColorDefault; } + public Map getServerColors() { return Collections.unmodifiableMap(serverColors); } + public String getServerColorDefault() { return serverColorDefault; } + public String getServerDisplay(String serverName) { if (serverName == null) return ""; String d = serverDisplayNames.get(serverName.toLowerCase()); return d != null ? d : serverName; } + public boolean isChatlogEnabled() { return chatlogEnabled; } + public int getChatlogRetentionDays() { return chatlogRetentionDays; } + public boolean isReportsEnabled() { return reportsEnabled; } + public String getReportConfirm() { return reportConfirm; } + public String getReportPermission() { return reportPermission; } + public String getReportClosePermission() { return reportClosePermission; } + public String getReportViewPermission() { return reportViewPermission; } + public int getReportCooldown() { return reportCooldown; } + public String getReportDiscordWebhook() { return reportDiscordWebhook; } + public String getReportTelegramChatId() { return reportTelegramChatId; } + public boolean isReportWebhookEnabled() { return reportWebhookEnabled; } + public ChatFilter.ChatFilterConfig getFilterConfig() { return filterConfig; } + public boolean isMentionsEnabled() { return mentionsEnabled; } + public String getMentionsHighlightColor() { return mentionsHighlightColor; } + public String getMentionsSound() { return mentionsSound; } + public boolean isMentionsAllowToggle() { return mentionsAllowToggle; } + public String getMentionsNotifyPrefix() { return mentionsNotifyPrefix; } + public int getHistoryMaxLines() { return historyMaxLines; } + public int getHistoryDefaultLines() { return historyDefaultLines; } + public boolean isJoinLeaveEnabled() { return joinLeaveEnabled; } + public String getJoinFormat() { return joinFormat; } + public String getLeaveFormat() { return leaveFormat; } + public boolean isVanishShowToAdmins() { return vanishShowToAdmins; } + public String getVanishJoinFormat() { return vanishJoinFormat; } + public String getVanishLeaveFormat() { return vanishLeaveFormat; } + public String getJoinLeaveDiscordWebhook() { return joinLeaveDiscordWebhook; } + public String getJoinLeaveTelegramChatId() { return joinLeaveTelegramChatId; } + public int getJoinLeaveTelegramThreadId() { return joinLeaveTelegramThreadId; } +} diff --git a/src/main/java/net/viper/status/modules/chat/ChatFilter.java b/src/main/java/net/viper/status/modules/chat/ChatFilter.java index 81ab01a..098829c 100644 --- a/src/main/java/net/viper/status/modules/chat/ChatFilter.java +++ b/src/main/java/net/viper/status/modules/chat/ChatFilter.java @@ -134,6 +134,13 @@ public class ChatFilter { } } + // ── 6. Anti-Werbung ── + if (cfg.antiAdEnabled && !isAdmin) { + if (containsAdvertisement(result)) { + return new FilterResponse(FilterResult.BLOCKED, result, cfg.antiAdMessage); + } + } + return new FilterResponse( modified ? FilterResult.MODIFIED : FilterResult.ALLOWED, result, @@ -187,14 +194,88 @@ public class ChatFilter { // Kein Recht → & und nächstes Zeichen überspringen if (isColor || isFormat) { i++; continue; } - // Hex: &# + 6 Zeichen überspringen - if (isHex && i + 7 < message.length()) { i += 7; continue; } + // Hex: &# + 6 Zeichen überspringen (i zeigt auf &, +1 = #, +2..+7 = RRGGBB) + if (isHex && i + 7 <= message.length()) { i += 7; continue; } } sb.append(c); } return sb.toString(); } + // ===== Anti-Werbung ===== + + // Vorkompilierte Patterns (einmalig beim Classload) + private static final Pattern PATTERN_IP = + Pattern.compile("\\b(\\d{1,3}[.,]){3}\\d{1,3}(:\\d{1,5})?\\b"); + + private static final Pattern PATTERN_DOMAIN_GENERIC = + Pattern.compile("(?i)\\b[a-z0-9-]{2,63}\\.[a-z]{2,10}(?:[/:\\d]\\S*)?\\b"); + + private static final Pattern PATTERN_URL_PREFIX = + Pattern.compile("(?i)(https?://|www\\.)\\S+"); + + /** + * Prüft ob die Nachricht Werbung enthält (IP, URL, fremde Domain). + * Domains auf der Whitelist werden ignoriert. + * + * Erkennt: + * - http:// / https:// / www. Prefixe + * - IPv4-Adressen (auch mit Port) + * - Domain-Namen mit konfigurierten TLDs (z.B. .net, .de, .com) + * - Verschleierungsversuche mit Leerzeichen um Punkte ("play . server . net") + */ + private boolean containsAdvertisement(String message) { + // Normalisierung: "play . server . net" → "play.server.net" + String normalized = message.replaceAll("\\s*\\.\\s*", "."); + + // 1. Explizite URL-Prefixe + if (PATTERN_URL_PREFIX.matcher(normalized).find()) { + return !allMatchesWhitelisted(normalized, PATTERN_URL_PREFIX); + } + + // 2. IP-Adressen (werden nie whitelisted) + if (PATTERN_IP.matcher(normalized).find()) { + return true; + } + + // 3. Domains mit bekannten TLDs + if (!cfg.antiAdBlockedTlds.isEmpty()) { + java.util.regex.Matcher m = PATTERN_DOMAIN_GENERIC.matcher(normalized); + while (m.find()) { + String match = m.group(); + String tld = extractTld(match); + if (cfg.antiAdBlockedTlds.contains(tld.toLowerCase())) { + if (!isOnWhitelist(match)) return true; + } + } + } + + return false; + } + + /** true wenn ALLE Treffer des Patterns auf der Whitelist stehen. */ + private boolean allMatchesWhitelisted(String message, Pattern pattern) { + java.util.regex.Matcher m = pattern.matcher(message); + while (m.find()) { + if (!isOnWhitelist(m.group())) return false; + } + return true; + } + + private boolean isOnWhitelist(String match) { + String lower = match.toLowerCase(); + for (String entry : cfg.antiAdWhitelist) { + if (lower.contains(entry.toLowerCase())) return true; + } + return false; + } + + private static String extractTld(String domain) { + String clean = domain.split("[/:]")[0]; + int dot = clean.lastIndexOf('.'); + return dot >= 0 ? clean.substring(dot + 1) : ""; + } + private static String buildStars(int length) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < Math.max(length, 4); i++) sb.append('*'); @@ -235,5 +316,16 @@ public class ChatFilter { public boolean capsFilterEnabled = true; public int capsMinLength = 6; // Mindestlänge für Caps-Check public int capsMaxPercent = 70; // Max. % Großbuchstaben + + // Anti-Werbung + public boolean antiAdEnabled = true; + public String antiAdMessage = "&cWerbung ist in diesem Chat nicht erlaubt!"; + // Domains/Substrings die NICHT geblockt werden (z.B. eigene Serveradresse) + public List antiAdWhitelist = new ArrayList<>(); + // TLDs die als Werbung gewertet werden (leer = alle TLDs prüfen) + public List antiAdBlockedTlds = new ArrayList<>(Arrays.asList( + "net", "com", "de", "org", "gg", "io", "eu", "tv", "xyz", + "info", "me", "cc", "co", "app", "online", "site", "fun" + )); } } \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ChatModule.java b/src/main/java/net/viper/status/modules/chat/ChatModule.java index c3af97d..82263ab 100644 --- a/src/main/java/net/viper/status/modules/chat/ChatModule.java +++ b/src/main/java/net/viper/status/modules/chat/ChatModule.java @@ -43,6 +43,8 @@ import java.util.logging.Logger; * ✅ Report-System (/report, /reports, /reportclose) * ✅ Admin-Benachrichtigung bei Reports (sofort oder verzögert nach Login) * ✅ Server-Farben pro Server (&-Codes und &#RRGGBB HEX) + * ✅ Join / Leave Nachrichten (mit Vanish-Support) + * ✅ BungeeCord-Vanish-Integration via VanishProvider */ public class ChatModule implements Module, Listener { @@ -74,10 +76,13 @@ public class ChatModule implements Module, Listener { private final Map helpopCooldowns = new ConcurrentHashMap<>(); // Report-Cooldown: UUID → letzter Report-Zeitstempel (Sekunden) - private final Map reportCooldowns = new ConcurrentHashMap<>(); // NEU + private final Map reportCooldowns = new ConcurrentHashMap<>(); // Letzte Chatnachricht pro Spieler (für Report-Kontext): name.toLowerCase() → message - private final Map lastChatMessages = new ConcurrentHashMap<>(); // NEU + private final Map lastChatMessages = new ConcurrentHashMap<>(); + + // UUIDs die gerade auf Plugin-Chat-Eingabe warten + private final Set awaitingInput = Collections.newSetFromMap(new ConcurrentHashMap<>()); // Geyser-Präfix für Bedrock-Spieler (Standard: ".") private static final String GEYSER_PREFIX = "."; @@ -105,13 +110,13 @@ public class ChatModule implements Module, Listener { emojiParser = new EmojiParser(config.getEmojiMappings(), config.isEmojiEnabled()); chatFilter = new ChatFilter(config.getFilterConfig()); - // NEU: ChatLogger + // ChatLogger if (config.isChatlogEnabled()) { chatLogger = new ChatLogger(plugin.getDataFolder(), logger, config.getChatlogRetentionDays()); logger.info("[ChatModule] Chat-Log aktiviert (" + config.getChatlogRetentionDays() + " Tage Aufbewahrung)."); } - // NEU: ReportManager + // ReportManager if (config.isReportsEnabled()) { reportManager = new ReportManager(plugin.getDataFolder(), logger); reportManager.load(); @@ -155,7 +160,6 @@ public class ChatModule implements Module, Listener { helpopCooldowns.clear(); reportCooldowns.clear(); lastChatMessages.clear(); - logger.info("[ChatModule] Deaktiviert."); } // ========================================================= @@ -182,11 +186,6 @@ public class ChatModule implements Module, Listener { if (!(e.getSender() instanceof ProxiedPlayer)) return; ProxiedPlayer player = (ProxiedPlayer) e.getSender(); - // Bypass: Spieler wartet auf Plugin-Eingabe (CMI etc.) - // Event komplett unberührt lassen → Originalnachricht mit Signatur - // geht direkt zum Sub-Server. Funktioniert auf Spigot ohne Einschränkung. - // Auf Paper-Sub-Servern muss reject-chat-unsigned: false gesetzt sein — - // das ist eine Paper-Limitierung, nicht lösbar auf BungeeCord-Ebene. if (awaitingInput.contains(player.getUniqueId())) { awaitingInput.remove(player.getUniqueId()); return; // Event NICHT cancellen → Nachricht geht mit Originalsignatur durch @@ -198,7 +197,6 @@ public class ChatModule implements Module, Listener { /** * Zentrale Chat-Verarbeitungslogik. - * Wird von beiden Event-Handlern aufgerufen. */ private void processChat(ProxiedPlayer player, String rawMessage) { if (rawMessage == null || rawMessage.trim().isEmpty()) return; @@ -236,7 +234,7 @@ public class ChatModule implements Module, Listener { player.sendMessage(color(filterResp.denyReason)); return; } - message = filterResp.message; // ggf. modifiziert (Caps, Blacklist) + message = filterResp.message; String serverName = player.getServer() != null ? player.getServer().getInfo().getName() @@ -259,7 +257,6 @@ public class ChatModule implements Module, Listener { ProxiedPlayer mentioned = ProxyServer.getInstance().getPlayer(targetName); if (mentioned != null && !mentioned.getUniqueId().equals(uuid)) { mentionedPlayers.add(mentioned.getUniqueId()); - // Wort hervorheben word = translateColors(highlightColor + word + "&r"); } } @@ -316,10 +313,8 @@ public class ChatModule implements Module, Listener { && !mentionsDisabled.contains(recipient.getUniqueId()); if (isMentioned) { - // Prefix-Nachricht über der Chat-Zeile recipient.sendMessage(color(config.getMentionsNotifyPrefix() + "&7" + finalSenderName + " &7hat dich erwähnt!")); - // Sound via Plugin-Messaging an Sub-Server senden sendMentionSound(recipient, config.getMentionsSound()); } @@ -335,44 +330,147 @@ public class ChatModule implements Module, Listener { }); } - @EventHandler - public void onDisconnect(PlayerDisconnectEvent e) { - UUID uuid = e.getPlayer().getUniqueId(); - chatFilter.cleanup(uuid); - playerChannels.remove(uuid); - mentionsDisabled.remove(uuid); - awaitingInput.remove(uuid); - } - // ========================================================= - // LOGIN-EVENT: Kanal setzen + Report-Benachrichtigung + // LOGIN-EVENT: Kanal setzen + Join-Nachricht + Report-Info // ========================================================= @EventHandler public void onLogin(PostLoginEvent e) { ProxiedPlayer player = e.getPlayer(); - playerChannels.put(player.getUniqueId(), config.getDefaultChannelId()); + UUID uuid = player.getUniqueId(); - // NEU: Offene Reports nach 2 Sekunden anzeigen (damit Update-Meldungen nicht überlagert werden) - if (reportManager == null) return; - if (!player.hasPermission(config.getAdminNotifyPermission()) - && !player.hasPermission(config.getAdminBypassPermission())) return; - - int openCount = reportManager.getOpenCount(); - if (openCount == 0) return; + // Standard-Kanal setzen + playerChannels.put(uuid, config.getDefaultChannelId()); + // Join-Nachricht und Report-Benachrichtigung mit kurzem Delay senden, + // damit alle anderen Proxy-Initialisierungen (inkl. VanishModule) abgeschlossen sind. + // 2s statt 1s: VanishModule markiert den Spieler oft erst beim ServerConnectedEvent. plugin.getProxy().getScheduler().schedule(plugin, () -> { if (!player.isConnected()) return; - int count = reportManager.getOpenCount(); - if (count == 0) return; - player.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); - player.sendMessage(color("&c&l⚑ OFFENE REPORTS &8| &f" + count + " ausstehend")); - player.sendMessage(color("&7Tippe &f/reports &7für eine Übersicht.")); - player.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + // ── Join-Nachricht ── + if (config.isJoinLeaveEnabled()) { + broadcastJoinLeave(player, true); + } + + // ── Offene Reports für Admins anzeigen ── + if (reportManager != null + && (player.hasPermission(config.getAdminNotifyPermission()) + || player.hasPermission(config.getAdminBypassPermission()))) { + int count = reportManager.getOpenCount(); + if (count > 0) { + player.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + player.sendMessage(color("&c&l⚑ OFFENE REPORTS &8| &f" + count + " ausstehend")); + player.sendMessage(color("&7Tippe &f/reports &7für eine Übersicht.")); + player.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + } + } }, 2, TimeUnit.SECONDS); } + // ========================================================= + // DISCONNECT-EVENT: Cleanup + Leave-Nachricht + // ========================================================= + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent e) { + ProxiedPlayer player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + + // Leave-Nachricht + if (config.isJoinLeaveEnabled()) { + broadcastJoinLeave(player, false); + } + + // Cleanup + chatFilter.cleanup(uuid); + playerChannels.remove(uuid); + mentionsDisabled.remove(uuid); + awaitingInput.remove(uuid); + VanishProvider.cleanup(uuid); // Vanish-Status bereinigen + } + + // ========================================================= + // JOIN / LEAVE NACHRICHTEN (mit Vanish-Support) + // ========================================================= + + /** + * Sendet eine Join- oder Leave-Nachricht an alle Spieler. + * + * Vanish-Logik: + * - Unsichtbare Spieler: kein Broadcast an normale Spieler. + * - Ist vanish-show-to-admins=true, erhalten Admins (bypass-permission) + * eine dezente Vanish-Benachrichtigung. + * + * @param player Der betroffene Spieler + * @param isJoin true = Join, false = Leave + */ + private void broadcastJoinLeave(ProxiedPlayer player, boolean isJoin) { + boolean isVanished = VanishProvider.isVanished(player); + + String prefix = getLuckPermsPrefix(player); + String suffix = getLuckPermsSuffix(player); + String server = (player.getServer() != null) + ? config.getServerDisplay(player.getServer().getInfo().getName()) + : "Netzwerk"; + + String normalFormat = isJoin ? config.getJoinFormat() : config.getLeaveFormat(); + String vanishFormat = isJoin ? config.getVanishJoinFormat() : config.getVanishLeaveFormat(); + + // Platzhalter ersetzen + String normalMsg = normalFormat + .replace("{player}", player.getName()) + .replace("{prefix}", prefix != null ? prefix : "") + .replace("{suffix}", suffix != null ? suffix : "") + .replace("{server}", server); + + String vanishMsg = vanishFormat + .replace("{player}", player.getName()) + .replace("{prefix}", prefix != null ? prefix : "") + .replace("{suffix}", suffix != null ? suffix : "") + .replace("{server}", server); + + for (ProxiedPlayer recipient : ProxyServer.getInstance().getPlayers()) { + // Spieler sieht seine eigene Join-/Leave-Meldung nie + if (recipient.getUniqueId().equals(player.getUniqueId())) continue; + + // Admin = bypass-permission ODER notify-permission + boolean recipientIsAdmin = recipient.hasPermission(config.getAdminBypassPermission()) + || recipient.hasPermission(config.getAdminNotifyPermission()); + + if (isVanished) { + // Vanished: Nur Admins sehen eine dezente Meldung (wenn konfiguriert) + if (recipientIsAdmin && config.isVanishShowToAdmins()) { + recipient.sendMessage(color(vanishMsg)); + } + } else { + // Normaler Spieler: alle erhalten die Nachricht + // Vanished Admins sehen Join/Leave-Events anderer Spieler normal + recipient.sendMessage(color(normalMsg)); + } + } + + // Konsole immer informieren + String logMsg = isVanished + ? "[ChatModule] " + (isJoin ? "JOIN(V)" : "LEAVE(V)") + " " + player.getName() + : "[ChatModule] " + (isJoin ? "JOIN" : "LEAVE") + " " + player.getName(); + ProxyServer.getInstance().getConsole().sendMessage(color( + isVanished ? "&8" + logMsg : "&7" + logMsg)); + + // Brücken (nur für nicht-vanished Spieler) + if (!isVanished) { + String cleanMsg = ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', normalMsg)); + if (discordBridge != null && !config.getJoinLeaveDiscordWebhook().isEmpty()) { + discordBridge.sendToDiscord(config.getJoinLeaveDiscordWebhook(), + player.getName(), cleanMsg, null); + } + if (telegramBridge != null && !config.getJoinLeaveTelegramChatId().isEmpty()) { + telegramBridge.sendToTelegram(config.getJoinLeaveTelegramChatId(), + config.getJoinLeaveTelegramThreadId(), cleanMsg); + } + } + } + // ========================================================= // BEFEHLE REGISTRIEREN // ========================================================= @@ -452,17 +550,24 @@ public class ChatModule implements Module, Listener { ProxyServer.getInstance().getPluginManager().registerCommand(plugin, helpop); // /msg + // Vanish: Vanished Spieler sind für normale Spieler nicht erreichbar. + // Admins können vanished Spieler per PM kontaktieren. Command msgCmd = new Command("msg", null, "tell", "w", "whisper") { @Override public void execute(CommandSender sender, String[] args) { if (!config.isPmEnabled()) { sender.sendMessage(color("&cPrivat-Nachrichten sind deaktiviert.")); return; } if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } if (args.length < 2) { sender.sendMessage(color("&cBenutzung: /msg ")); return; } + ProxiedPlayer from = (ProxiedPlayer) sender; - ProxiedPlayer to = ProxyServer.getInstance().getPlayer(args[0]); + boolean fromIsAdmin = from.hasPermission(config.getAdminBypassPermission()); + + // Ziel suchen – vanished Spieler sind für Nicht-Admins "nicht gefunden" + ProxiedPlayer to = findVisiblePlayer(args[0], fromIsAdmin); if (to == null || !to.isConnected()) { from.sendMessage(color("&cSpieler &f" + args[0] + " &cnicht gefunden.")); return; } + String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); pmManager.send(from, to, message, config, config.getAdminBypassPermission()); } @@ -652,27 +757,24 @@ public class ChatModule implements Module, Listener { UUID tUUID = target.getUniqueId(); String tServer = target.getServer() != null ? target.getServer().getInfo().getName() : "Proxy"; - // Kanal String channelId = playerChannels.getOrDefault(tUUID, config.getDefaultChannelId()); ChatChannel ch = config.getChannel(channelId); String channelName = ch != null ? ch.getFormattedTag() + " &f" + ch.getName() : "&f" + channelId; - // Mute-Status String muteStatus = muteManager.isMuted(tUUID) ? "&cJa &8(noch: &f" + muteManager.getRemainingTime(tUUID) + "&8)" : "&aKein"; - // Blockierungen Set blocked = blockManager.getBlockedBy(tUUID); - // Account-Links AccountLinkManager.LinkedAccount link = linkManager.getByUUID(tUUID); String discordInfo = (link != null && !link.discordUserId.isEmpty()) ? "&a" + link.discordUsername + " &8(" + link.discordUserId + ")" : "&7Nicht verknüpft"; String telegramInfo = (link != null && !link.telegramUserId.isEmpty()) ? "&a" + link.telegramUsername + " &8(" + link.telegramUserId + ")" : "&7Nicht verknüpft"; - // Ausgabe + String vanishStatus = VanishProvider.isVanished(target) ? "&eJa" : "&aKein"; + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); sender.sendMessage(color("&eChatInfo: &f" + target.getName() + " &8@ &7" + tServer)); sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); @@ -680,6 +782,7 @@ public class ChatModule implements Module, Listener { sender.sendMessage(color("&7Mute: " + muteStatus)); sender.sendMessage(color("&7Chat-aus: " + (selfChatMuted.contains(tUUID) ? "&cJa" : "&aKein"))); sender.sendMessage(color("&7Mentions: " + (mentionsDisabled.contains(tUUID) ? "&cDeaktiviert" : "&aAktiv"))); + sender.sendMessage(color("&7Vanish: " + vanishStatus)); sender.sendMessage(color("&7Blockiert: &f" + blocked.size() + " Spieler")); if (!blocked.isEmpty()) { for (UUID bUUID : blocked) { @@ -707,7 +810,6 @@ public class ChatModule implements Module, Listener { int lines = config.getHistoryDefaultLines(); if (args.length >= 1) { - // Erstes Arg: Spielername oder Zahl? try { lines = Math.min(Integer.parseInt(args[0]), config.getHistoryMaxLines()); } catch (NumberFormatException ex) { @@ -765,7 +867,6 @@ public class ChatModule implements Module, Listener { ProxyServer.getInstance().getPluginManager().registerCommand(plugin, mentionsCmd); // /chatbypass – Chat-Verarbeitung für nächste Eingabe(n) überspringen - // Nützlich wenn ein Plugin (CMI, Shop, etc.) auf eine Chat-Eingabe wartet Command bypassCmd = new Command("chatbypass", null, "cbp") { @Override public void execute(CommandSender sender, String[] args) { @@ -784,9 +885,7 @@ public class ChatModule implements Module, Listener { }; ProxyServer.getInstance().getPluginManager().registerCommand(plugin, bypassCmd); - // ───────────────────────────────────────────────────── - // /discordlink – Discord-Account verknüpfen - // ───────────────────────────────────────────────────── + // /discordlink – Discord-Account verknüpfen Command discordLinkCmd = new Command("discordlink", null, "dlink") { @Override public void execute(CommandSender sender, String[] args) { @@ -806,9 +905,7 @@ public class ChatModule implements Module, Listener { }; ProxyServer.getInstance().getPluginManager().registerCommand(plugin, discordLinkCmd); - // ───────────────────────────────────────────────────── - // /telegramlink – Telegram-Account verknüpfen - // ───────────────────────────────────────────────────── + // /telegramlink – Telegram-Account verknüpfen Command telegramLinkCmd = new Command("telegramlink", null, "tlink") { @Override public void execute(CommandSender sender, String[] args) { @@ -828,9 +925,7 @@ public class ChatModule implements Module, Listener { }; ProxyServer.getInstance().getPluginManager().registerCommand(plugin, telegramLinkCmd); - // ───────────────────────────────────────────────────── - // /unlink – Verknüpfung aufheben - // ───────────────────────────────────────────────────── + // /unlink – Verknüpfung aufheben Command unlinkCmd = new Command("unlink") { @Override public void execute(CommandSender sender, String[] args) { @@ -868,9 +963,7 @@ public class ChatModule implements Module, Listener { }; ProxyServer.getInstance().getPluginManager().registerCommand(plugin, unlinkCmd); - // ───────────────────────────────────────────────────── - // NEU: /report - // ───────────────────────────────────────────────────── + // /report Command reportCmd = new Command("report") { @Override public void execute(CommandSender sender, String[] args) { @@ -879,7 +972,6 @@ public class ChatModule implements Module, Listener { ProxiedPlayer p = (ProxiedPlayer) sender; - // Permission prüfen (optional) String reqPerm = config.getReportPermission(); if (reqPerm != null && !reqPerm.isEmpty() && !p.hasPermission(reqPerm)) { p.sendMessage(color("&cDu hast keine Berechtigung für /report.")); return; @@ -890,7 +982,6 @@ public class ChatModule implements Module, Listener { return; } - // Cooldown long now = System.currentTimeMillis() / 1000L; Long last = reportCooldowns.get(p.getUniqueId()); if (last != null && (now - last) < config.getReportCooldown()) { @@ -902,67 +993,54 @@ public class ChatModule implements Module, Listener { String reportedName = args[0]; String reason = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); String server = p.getServer() != null ? p.getServer().getInfo().getName() : "Proxy"; - - // Letzte Nachricht des Gemeldeten als Kontext String msgContext = lastChatMessages.getOrDefault(reportedName.toLowerCase(), "(keine Chat-Nachricht bekannt)"); - // Report erstellen String reportId = reportManager.createReport( p.getName(), p.getUniqueId(), reportedName, server, msgContext, reason); - // Report auch ins Chatlog schreiben (ID sichtbar) if (chatLogger != null) { - String logMsg = "[REPORT] Reporter: " + p.getName() + ", Gemeldet: " + reportedName + ", Grund: " + reason + - " | Letzte Nachricht: " + msgContext + " | Report-ID: " + reportId; - String msgId = reportId; // Damit die ID im Chatlog und im Report identisch ist - chatLogger.log(msgId, server, "report", p.getName(), logMsg); + String logMsg = "[REPORT] Reporter: " + p.getName() + ", Gemeldet: " + reportedName + + ", Grund: " + reason + " | Letzte Nachricht: " + msgContext + + " | Report-ID: " + reportId; + chatLogger.log(reportId, server, "report", p.getName(), logMsg); } - // ==== Discord/Telegram Benachrichtigung ==== - // Discord Webhook String reportWebhook = config.getReportDiscordWebhook(); - logger.info("[Debug] DiscordWebhookEnabled=" + config.isReportWebhookEnabled() - + ", discordBridge=" + (discordBridge != null) - + ", reportWebhook=" + reportWebhook); - if (config.isReportWebhookEnabled() && discordBridge != null && reportWebhook != null && !reportWebhook.isEmpty()) { + if (config.isReportWebhookEnabled() && discordBridge != null + && reportWebhook != null && !reportWebhook.isEmpty()) { String title = "Neuer Report eingegangen"; - String desc = "**Reporter:** " + p.getName() + - "\n**Gemeldet:** " + reportedName + - "\n**Server:** " + server + - "\n**Grund:** " + reason + - "\n**Letzte Nachricht:** " + msgContext + - "\n**Report-ID:** " + reportId; + String desc = "**Reporter:** " + p.getName() + + "\n**Gemeldet:** " + reportedName + + "\n**Server:** " + server + + "\n**Grund:** " + reason + + "\n**Letzte Nachricht:** " + msgContext + + "\n**Report-ID:** " + reportId; discordBridge.sendEmbedToDiscord(reportWebhook, title, desc, config.getDiscordEmbedColor()); } - // Telegram Benachrichtigung String reportTgChatId = config.getReportTelegramChatId(); if (telegramBridge != null && reportTgChatId != null && !reportTgChatId.isEmpty()) { String header = "Neuer Report eingegangen"; - String content = "Reporter: " + p.getName() + - "\nGemeldet: " + reportedName + - "\nServer: " + server + - "\nGrund: " + reason + - "\nLetzte Nachricht: " + msgContext + - "\nReport-ID: " + reportId; + String content = "Reporter: " + p.getName() + + "\nGemeldet: " + reportedName + + "\nServer: " + server + + "\nGrund: " + reason + + "\nLetzte Nachricht: " + msgContext + + "\nReport-ID: " + reportId; telegramBridge.sendFormattedToTelegram(reportTgChatId, header, content); } reportCooldowns.put(p.getUniqueId(), now); - // Bestätigung an Reporter String confirm = config.getReportConfirm().replace("{id}", reportId); p.sendMessage(color(confirm)); - // ── Online-Admins sofort benachrichtigen ── notifyAdminsReport(reportId, p.getName(), reportedName, server, reason, msgContext); } }; ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportCmd); - // ───────────────────────────────────────────────────── - // NEU: /reports [all] – Admin-Übersicht - // ───────────────────────────────────────────────────── + // /reports [all] – Admin-Übersicht Command reportsCmd = new Command("reports", config.getReportViewPermission()) { @Override public void execute(CommandSender sender, String[] args) { @@ -988,15 +1066,12 @@ public class ChatModule implements Module, Listener { String statusColor = r.closed ? "&a✔" : "&c✘"; if (sender instanceof ProxiedPlayer) { - // Klickbare Zeile: ID-Click kopiert ID in Zwischenablage ComponentBuilder line = new ComponentBuilder(""); - // Status line.append(ChatColor.translateAlternateColorCodes('&', statusColor + " ")) .event((ClickEvent) null) .event((HoverEvent) null); - // Klickbare Report-ID line.append(ChatColor.translateAlternateColorCodes('&', "&8[&f" + r.id + "&8]")) .event(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, r.id)) .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, @@ -1009,7 +1084,6 @@ public class ChatModule implements Module, Listener { + "\n" + ChatColor.YELLOW + "Grund: " + ChatColor.RED + r.reason + (r.closed ? "\n" + ChatColor.GREEN + "Geschlossen von: " + r.closedBy : "")).create())); - // Rest der Zeile line.append(ChatColor.translateAlternateColorCodes('&', " &b" + r.reportedName + " &8← &7" + r.reporterName + " &8@ &a" + r.server @@ -1019,7 +1093,6 @@ public class ChatModule implements Module, Listener { ((ProxiedPlayer) sender).sendMessage(line.create()); } else { - // Konsole: plain text sender.sendMessage(color(statusColor + " &8[&f" + r.id + "&8] &b" + r.reportedName + " &8← &7" + r.reporterName + " &8@ &a" + r.server + " &8| &e" + r.getFormattedTime() @@ -1035,9 +1108,7 @@ public class ChatModule implements Module, Listener { }; ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportsCmd); - // ───────────────────────────────────────────────────── - // NEU: /reportclose - // ───────────────────────────────────────────────────── + // /reportclose Command reportCloseCmd = new Command("reportclose", config.getReportClosePermission()) { @Override public void execute(CommandSender sender, String[] args) { @@ -1062,14 +1133,12 @@ public class ChatModule implements Module, Listener { sender.sendMessage(color("&aReport &f" + id + " &awurde geschlossen.")); - // Reporter benachrichtigen, falls online ProxiedPlayer reporter = ProxyServer.getInstance().getPlayer(report.reporterUUID); if (reporter != null && reporter.isConnected()) { reporter.sendMessage(color("&8[&aReport&8] &7Dein Report &f" + id + " &7gegen &c" + report.reportedName + " &7wurde von &b" + adminName + " &7bearbeitet.")); } - // Andere Admins informieren notifyAdmins("&8[&aReport geschlossen&8] &f" + adminName + " &7hat Report &f" + id + " &7geschlossen."); } }; @@ -1078,38 +1147,10 @@ public class ChatModule implements Module, Listener { // ========================================================= // PLUGIN-INPUT BYPASS - // - // Spieler die gerade auf eine Chat-Eingabe eines Sub-Server- - // Plugins warten (CMI, Shops, etc.) werden vom ChatModule - // übersprungen. Die Nachricht geht direkt an den Sub-Server. - // - // Drei Erkennungsmethoden: - // 1. Manueller Bypass-Toggle via /chatbypass (für Admins) - // 2. Programmatische API: ChatModule.setAwaitingInput(uuid, true) - // 3. Automatische Erkennung bekannter Plugin-Nachrichten // ========================================================= - // UUIDs die gerade auf Plugin-Chat-Eingabe warten - private final Set awaitingInput = Collections.newSetFromMap(new ConcurrentHashMap<>()); - /** - * Prüft ob ein Spieler gerade auf eine Chat-Eingabe eines - * Sub-Server-Plugins wartet und das ChatModule überspringen soll. - */ - private boolean isAwaitingPluginInput(ProxiedPlayer player) { - // 1. Manuell / programmatisch gesetzt - if (awaitingInput.contains(player.getUniqueId())) return true; - - // 2. Automatische Erkennung: BungeeCord leitet SubServer-Nachrichten - // via PluginChannel weiter – wir prüfen bekannte CMI-Patterns nicht, - // da wir keinen Zugriff auf SubServer-Metadaten haben. - // Stattdessen: Spieler kann selbst /chatbypass togglen oder - // Sub-Server-Plugin ruft setAwaitingInput() auf. - return false; - } - - /** - * Öffentliche API für Sub-Server-Plugins oder BungeeCord-eigene Plugins: + * Öffentliche API für Sub-Server-Plugins oder BungeeCord-eigene Plugins. * Setzt den Bypass-Status für einen Spieler. * * Beispiel aus einem anderen BungeeCord-Plugin: @@ -1126,6 +1167,24 @@ public class ChatModule implements Module, Listener { return awaitingInput.contains(uuid); } + // ========================================================= + // VANISH-HILFSMETHODEN + // ========================================================= + + /** + * Sucht einen Spieler nach Name und berücksichtigt den Vanish-Status. + * + * @param name Spielername (case-insensitiv) + * @param callerIsAdmin true → Vanished Spieler werden ebenfalls gefunden + * @return ProxiedPlayer oder null wenn nicht gefunden / vanished (für Nicht-Admins) + */ + private ProxiedPlayer findVisiblePlayer(String name, boolean callerIsAdmin) { + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(name); + if (target == null) return null; + if (!callerIsAdmin && VanishProvider.isVanished(target)) return null; + return target; + } + // ========================================================= // HILFSMETHODEN // ========================================================= @@ -1133,7 +1192,7 @@ public class ChatModule implements Module, Listener { private String buildFormat(String format, String server, String prefix, String player, String suffix, String message) { String serverColor = config.getServerColor(server); - String serverDisplay = config.getServerDisplay(server); // Anzeigename aus config + String serverDisplay = config.getServerDisplay(server); String coloredServer = translateColors(serverColor + serverDisplay + "&r"); return format @@ -1158,23 +1217,13 @@ public class ChatModule implements Module, Listener { /** * Übersetzt sowohl klassische &-Farbcodes als auch HEX-Codes im Format &#RRGGBB. - * - * Beispiele: - * &a → §a (Grün) - * &#FF5500 → BungeeCord HEX-Farbe Orange - * &l&#FF5500Text → Fett + Orange - * - * BungeeCord unterstützt ChatColor.of("#RRGGBB") ab 1.16-kompatiblen Builds. - * Ältere Builds erhalten automatisch den nächsten &-Code als Fallback. */ private String translateColors(String text) { if (text == null) return ""; - // 1. Schritt: &#RRGGBB → BungeeCord ChatColor StringBuilder sb = new StringBuilder(); int i = 0; while (i < text.length()) { - // Prüfe auf &#RRGGBB (8 Zeichen: & # R R G G B B) if (i + 7 < text.length() && text.charAt(i) == '&' && text.charAt(i + 1) == '#') { @@ -1195,7 +1244,6 @@ public class ChatModule implements Module, Listener { i++; } - // 2. Schritt: Standard &-Codes übersetzen return ChatColor.translateAlternateColorCodes('&', sb.toString()); } @@ -1209,11 +1257,9 @@ public class ChatModule implements Module, Listener { /** * Benachrichtigt alle online Admins über einen neuen Report. - * Baut eine mehrzeilige, klickbare Nachricht. */ private void notifyAdminsReport(String reportId, String reporter, String reported, String server, String reason, String msgContext) { - // Zeitstempel java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("dd.MM.yyyy HH:mm:ss"); String zeit = sdf.format(new java.util.Date()); @@ -1221,7 +1267,6 @@ public class ChatModule implements Module, Listener { if (!p.hasPermission(config.getAdminNotifyPermission()) && !p.hasPermission(config.getAdminBypassPermission())) continue; - // Mehrzeilige Report-Notification p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); p.sendMessage(color("&8[&cReport&8] &7Zeit: &e" + zeit)); p.sendMessage(color("&7Reporter: &b" + reporter)); @@ -1229,7 +1274,6 @@ public class ChatModule implements Module, Listener { p.sendMessage(color("&7Letzte Nachricht: &f" + msgContext)); p.sendMessage(color("&7Grund: &c" + reason)); - // Klickbare ID-Zeile ComponentBuilder idLine = new ComponentBuilder(ChatColor.GRAY + "ID: "); idLine.append(ChatColor.WHITE + "" + ChatColor.BOLD + reportId) .event(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, reportId)) @@ -1244,28 +1288,22 @@ public class ChatModule implements Module, Listener { p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); } - // Konsole ebenfalls informieren ProxyServer.getInstance().getConsole().sendMessage(color( "&8[&cReport " + reportId + "&8] &7Reporter: &b" + reporter + " &7→ &c" + reported + " &7@ &a" + server + " &8| &cGrund: &f" + reason)); } /** - * Baut eine BungeeCord-Nachricht ohne sichtbare ID. - * Am Ende erscheint ein klickbarer [⚑] Melden-Button (nur wenn Reports aktiviert). - * - * Layout: §8[§c⚑§8] + * Baut eine BungeeCord-Nachricht mit klickbarem [⚑] Melden-Button. */ private BaseComponent[] buildClickableMessage(String msgId, String formatted, String senderName) { ComponentBuilder builder = new ComponentBuilder(""); - // Eigentliche Nachricht (kein ID-Tag mehr sichtbar) builder.append(ChatColor.translateAlternateColorCodes('&', formatted), ComponentBuilder.FormatRetention.NONE) .event((ClickEvent) null) .event((HoverEvent) null); - // [⚑] Melden-Button am Ende (nur wenn Report-System aktiv und Sender bekannt) if (msgId != null && senderName != null && reportManager != null) { builder.append(" ", ComponentBuilder.FormatRetention.NONE) .event((ClickEvent) null) @@ -1287,15 +1325,11 @@ public class ChatModule implements Module, Listener { } /** - * Sendet einen Sound an einen Spieler via Plugin-Messaging. - * Der Sub-Server muss den Kanal "BungeeCord" registriert haben (standard). - * Sound wird als Proxy-Message gesendet → Sub-Server-Plugin nötig für echten Sound. - * Als Fallback: Actionbar-Nachricht mit ♪-Symbol. + * Sendet einen Sound-Hinweis via Actionbar (Mention-Feedback). */ private void sendMentionSound(ProxiedPlayer player, String soundName) { if (soundName == null || soundName.isEmpty()) return; try { - // Actionbar als visuellen Feedback (funktioniert ohne Sub-Server-Plugin) net.md_5.bungee.api.chat.TextComponent actionBar = new net.md_5.bungee.api.chat.TextComponent( ChatColor.translateAlternateColorCodes('&', "&e♪ Mention!")); diff --git a/src/main/java/net/viper/status/modules/chat/ReportManager.java b/src/main/java/net/viper/status/modules/chat/ReportManager.java index 311b529..27a52a1 100644 --- a/src/main/java/net/viper/status/modules/chat/ReportManager.java +++ b/src/main/java/net/viper/status/modules/chat/ReportManager.java @@ -205,7 +205,7 @@ public class ReportManager { logger.warning("[ChatModule] Fehler beim Laden der Reports: " + e.getMessage()); } idCounter.set(maxNum); - logger.info("[ChatModule] " + reports.size() + " Reports geladen (" + getOpenCount() + " offen)."); + } // ===== Escape-Helfer (Pipe-Zeichen und Zeilenumbrüche escapen) ===== diff --git a/src/main/java/net/viper/status/modules/chat/VanishProvider.java b/src/main/java/net/viper/status/modules/chat/VanishProvider.java new file mode 100644 index 0000000..73599ef --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/VanishProvider.java @@ -0,0 +1,71 @@ +package net.viper.status.modules.chat; + +import net.md_5.bungee.api.connection.ProxiedPlayer; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Zentrale Schnittstelle zwischen dem VanishModule und dem ChatModule. + * + * Das VanishModule (oder jedes andere Modul) ruft {@link #setVanished} auf + * um Spieler als unsichtbar zu markieren. Das ChatModule prüft via + * {@link #isVanished} bevor es Join-/Leave-Nachrichten sendet oder + * Privat-Nachrichten zulässt. + * + * Verwendung im VanishModule: + * VanishProvider.setVanished(player.getUniqueId(), true); // beim Verschwinden + * VanishProvider.setVanished(player.getUniqueId(), false); // beim Erscheinen / Disconnect + */ +public final class VanishProvider { + + private VanishProvider() {} + + /** Intern verwaltete Menge aller aktuell unsichtbaren Spieler-UUIDs. */ + private static final Set vanishedPlayers = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // ===== Schreib-API (wird vom VanishModule aufgerufen) ===== + + /** + * Markiert einen Spieler als sichtbar oder unsichtbar. + * + * @param uuid UUID des Spielers + * @param vanished true = unsichtbar, false = sichtbar + */ + public static void setVanished(UUID uuid, boolean vanished) { + if (vanished) { + vanishedPlayers.add(uuid); + } else { + vanishedPlayers.remove(uuid); + } + } + + /** + * Entfernt einen Spieler beim Disconnect aus der Vanish-Liste. + * Sollte vom ChatModule (onDisconnect) aufgerufen werden, damit + * kein toter Eintrag verbleibt. + */ + public static void cleanup(UUID uuid) { + vanishedPlayers.remove(uuid); + } + + // ===== Lese-API (wird vom ChatModule aufgerufen) ===== + + /** @return true wenn der Spieler aktuell als unsichtbar markiert ist. */ + public static boolean isVanished(ProxiedPlayer player) { + return player != null && vanishedPlayers.contains(player.getUniqueId()); + } + + /** @return true wenn der Spieler mit der angegebenen UUID unsichtbar ist. */ + public static boolean isVanished(UUID uuid) { + return uuid != null && vanishedPlayers.contains(uuid); + } + + /** Snapshot der aktuell unsichtbaren Spieler (für Debugging / Logs). */ + public static Set getVanishedPlayers() { + return Collections.unmodifiableSet(vanishedPlayers); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java b/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java index 2e8a4e6..6d42903 100644 --- a/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java +++ b/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java @@ -1,424 +1,323 @@ -package net.viper.status.modules.chat.bridge; - -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.ProxyServer; -import net.md_5.bungee.api.chat.TextComponent; -import net.md_5.bungee.api.plugin.Plugin; -import net.viper.status.modules.chat.AccountLinkManager; -import net.viper.status.modules.chat.ChatConfig; - -import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import java.util.logging.Logger; - -/** - * Discord-Brücke für bidirektionale Kommunikation. - * - * Minecraft → Discord: Via Webhook (kein Bot benötigt) - * Discord → Minecraft: Via Bot-Polling der Discord REST-API - * - * Voraussetzungen: - * - Bot mit "Read Message History" und "Send Messages" Permissions - * - Bot muss in den jeweiligen Kanälen sein - * - Bot-Token in chat.yml eintragen - */ -public class DiscordBridge { - - private final Plugin plugin; - private final ChatConfig config; - private final Logger logger; - private AccountLinkManager linkManager; - - // Letztes verarbeitetes Discord Message-ID pro Kanal (für Polling) - private final java.util.Map lastMessageIds = new java.util.concurrent.ConcurrentHashMap<>(); - - private volatile boolean running = false; - - public DiscordBridge(Plugin plugin, ChatConfig config) { - this.plugin = plugin; - this.config = config; - this.logger = plugin.getLogger(); - } - - /** Setzt den AccountLinkManager – muss vor start() aufgerufen werden. */ - public void setLinkManager(AccountLinkManager linkManager) { - this.linkManager = linkManager; - } - - public void start() { - if (!config.isDiscordEnabled() - || config.getDiscordBotToken().isEmpty() - || config.getDiscordBotToken().equals("YOUR_BOT_TOKEN_HERE")) { - logger.warning("[ChatModule-Discord] Bot-Token nicht konfiguriert. Discord-Empfang deaktiviert."); - return; - } - - running = true; - - // Starte Polling-Task für alle konfigurierten Kanäle - int interval = Math.max(2, config.getDiscordPollInterval()); - plugin.getProxy().getScheduler().schedule(plugin, this::pollAllChannels, - interval, interval, TimeUnit.SECONDS); - - logger.info("[ChatModule-Discord] Brücke gestartet (Poll-Intervall: " + interval + "s)."); - } - - public void stop() { - running = false; - } - - // ===== Minecraft → Discord ===== - - /** - * Sendet eine Nachricht via Webhook an Discord. - * Funktioniert ohne Bot-Token! - */ - public void sendToDiscord(String webhookUrl, String username, String message, String avatarUrl) { - if (webhookUrl == null || webhookUrl.isEmpty()) return; - - plugin.getProxy().getScheduler().runAsync(plugin, () -> { - try { - String safeUsername = escapeJson(username); - String safeMessage = escapeJson(message); - String payload = "{\"username\":\"" + safeUsername + "\"" - + (avatarUrl != null && !avatarUrl.isEmpty() - ? ",\"avatar_url\":\"" + avatarUrl + "\"" - : "") - + ",\"content\":\"" + safeMessage + "\"}"; - - postJson(webhookUrl, payload, null); - } catch (Exception e) { - logger.warning("[ChatModule-Discord] Webhook-Fehler: " + e.getMessage()); - } - }); - } - - /** - * Sendet eine Embed-Nachricht (für HelpOp, Broadcast) an einen Discord-Kanal via Webhook. - */ - public void sendEmbedToDiscord(String webhookUrl, String title, String description, String colorHex) { - if (webhookUrl == null || webhookUrl.isEmpty()) return; - - plugin.getProxy().getScheduler().runAsync(plugin, () -> { - try { - int color = 0; - try { color = Integer.parseInt(colorHex.replace("#", ""), 16); } - catch (Exception ignored) { color = 0x5865F2; } - - String payload = "{\"embeds\":[{\"title\":\"" + escapeJson(title) + "\"" - + ",\"description\":\"" + escapeJson(description) + "\"" - + ",\"color\":" + color + "}]}"; - - logger.info("[ChatModule-Discord] Sende Embed an Webhook: " + webhookUrl); - logger.info("[ChatModule-Discord] Payload: " + payload); - - postJson(webhookUrl, payload, null); - - logger.info("[ChatModule-Discord] Embed erfolgreich an Discord gesendet."); - } catch (Exception e) { - logger.warning("[ChatModule-Discord] Embed-Fehler: " + e.getMessage()); - } - }); - } - - /** - * Sendet eine Nachricht direkt in einen Discord-Kanal via Bot-Token. - * Benötigt: DISCORD_BOT_TOKEN, channel-id - */ - public void sendToChannel(String channelId, String message) { - if (channelId == null || channelId.isEmpty()) return; - if (config.getDiscordBotToken().isEmpty()) return; - - plugin.getProxy().getScheduler().runAsync(plugin, () -> { - try { - String url = "https://discord.com/api/v10/channels/" + channelId + "/messages"; - String payload = "{\"content\":\"" + escapeJson(message) + "\"}"; - postJson(url, payload, "Bot " + config.getDiscordBotToken()); - } catch (Exception e) { - logger.warning("[ChatModule-Discord] Send-to-Channel-Fehler: " + e.getMessage()); - } - }); - } - - // ===== Discord → Minecraft (Polling) ===== - - private void pollAllChannels() { - if (!running) return; - - // Alle Kanal-IDs aus der Konfiguration sammeln - java.util.Set channelIds = new java.util.LinkedHashSet<>(); - - for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { - if (!ch.getDiscordChannelId().isEmpty()) { - channelIds.add(ch.getDiscordChannelId()); - } - } - if (!config.getDiscordAdminChannelId().isEmpty()) { - channelIds.add(config.getDiscordAdminChannelId()); - } - - for (String channelId : channelIds) { - pollChannel(channelId); - } - } - - private void pollChannel(String channelId) { - try { - AtomicLong lastId = lastMessageIds.computeIfAbsent(channelId, k -> new AtomicLong(0L)); - - // Beim ersten Poll: aktuelle neueste ID holen und merken, nicht broadcasten. - // So werden beim Start keine alten Discord-Nachrichten in Minecraft angezeigt. - if (lastId.get() == 0L) { - String initUrl = "https://discord.com/api/v10/channels/" + channelId + "/messages?limit=1"; - String initResp = getJson(initUrl, "Bot " + config.getDiscordBotToken()); - if (initResp != null && !initResp.equals("[]") && !initResp.isEmpty()) { - java.util.List initMsgs = parseMessages(initResp); - if (!initMsgs.isEmpty()) { - lastId.set(initMsgs.get(0).id); - } - } - return; // Erster Poll nur zum Initialisieren, nichts broadcasten - } - - String afterParam = "?after=" + lastId.get() + "&limit=10"; - - String url = "https://discord.com/api/v10/channels/" + channelId + "/messages" + afterParam; - String response = getJson(url, "Bot " + config.getDiscordBotToken()); - if (response == null || response.equals("[]") || response.isEmpty()) return; - - // JSON-Array von Nachrichten parsen (ohne externe Library) - java.util.List messages = parseMessages(response); - - // Nachrichten chronologisch verarbeiten (älteste zuerst) - messages.sort(java.util.Comparator.comparingLong(m -> m.id)); - - for (DiscordMessage msg : messages) { - if (msg.id <= lastId.get()) continue; - if (msg.isBot) continue; - if (msg.content.isEmpty()) continue; - - lastId.set(msg.id); - - // ── Token-Einlösung: !link ── - if (msg.content.startsWith("!link ")) { - String token = msg.content.substring(6).trim().toUpperCase(); - if (linkManager != null) { - AccountLinkManager.LinkedAccount acc = - linkManager.redeemDiscord(token, msg.authorId, msg.authorName); - if (acc != null) { - sendToChannel(channelId, - "✅ Verknüpfung erfolgreich! Minecraft-Account: **" - + acc.minecraftName + "**"); - } else { - sendToChannel(channelId, - "❌ Ungültiger oder abgelaufener Token. Bitte `/discordlink` im Spiel erneut ausführen."); - } - } - continue; // Nicht als Chat-Nachricht weiterleiten - } - - // ── Account-Name auflösen ── - String displayName = (linkManager != null) - ? linkManager.resolveDiscordName(msg.authorId, msg.authorName) - : msg.authorName; - - // Welchem Kanal gehört diese Discord-Kanal-ID? - final String mcFormat = resolveFormat(channelId); - if (mcFormat == null) continue; - - final String formatted = ChatColor.translateAlternateColorCodes('&', - mcFormat.replace("{user}", displayName) - .replace("{message}", msg.content)); - - ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> - ProxyServer.getInstance().broadcast(new TextComponent(formatted)) - ); - } - } catch (Exception e) { - logger.fine("[ChatModule-Discord] Poll-Fehler für Kanal " + channelId + ": " + e.getMessage()); - } - } - - private String resolveFormat(String channelId) { - // Admin-Kanal? - if (channelId.equals(config.getDiscordAdminChannelId())) { - return config.getDiscordFromFormat(); - } - // Reguläre Kanäle - for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { - if (channelId.equals(ch.getDiscordChannelId())) { - return config.getDiscordFromFormat(); - } - } - return null; - } - - // ===== HTTP-Hilfsklassen ===== - - private void postJson(String urlStr, String payload, String authorization) throws Exception { - HttpURLConnection conn = openConnection(urlStr, "POST", authorization); - byte[] data = payload.getBytes(StandardCharsets.UTF_8); - conn.setRequestProperty("Content-Length", String.valueOf(data.length)); - conn.setDoOutput(true); - try (OutputStream os = conn.getOutputStream()) { os.write(data); } - int code = conn.getResponseCode(); - if (code >= 400) { - String err = readStream(conn.getErrorStream()); - logger.warning("[ChatModule-Discord] HTTP " + code + ": " + err); - } - conn.disconnect(); - } - - private String getJson(String urlStr, String authorization) throws Exception { - HttpURLConnection conn = openConnection(urlStr, "GET", authorization); - int code = conn.getResponseCode(); - if (code != 200) { conn.disconnect(); return null; } - String result = readStream(conn.getInputStream()); - conn.disconnect(); - return result; - } - - private HttpURLConnection openConnection(String urlStr, String method, String authorization) throws Exception { - HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); - conn.setRequestMethod(method); - conn.setConnectTimeout(5000); - conn.setReadTimeout(8000); - conn.setRequestProperty("Content-Type", "application/json"); - conn.setRequestProperty("User-Agent", "StatusAPI-ChatModule/1.0"); - if (authorization != null && !authorization.isEmpty()) { - conn.setRequestProperty("Authorization", authorization); - } - return conn; - } - - private String readStream(InputStream in) throws IOException { - if (in == null) return ""; - try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) sb.append(line); - return sb.toString(); - } - } - - // ===== JSON Mini-Parser ===== - - /** Repräsentiert eine Discord-Nachricht (minimale Felder). */ - private static class DiscordMessage { - long id; - String authorId = ""; - String authorName = ""; - String content = ""; - boolean isBot = false; - } - - /** - * Parst ein JSON-Array von Discord-Nachrichten ohne externe Bibliothek. - * Nur die benötigten Felder werden extrahiert. - */ - private java.util.List parseMessages(String json) { - java.util.List result = new java.util.ArrayList<>(); - // Jedes Objekt im Array extrahieren - int depth = 0, start = -1; - for (int i = 0; i < json.length(); i++) { - char c = json.charAt(i); - if (c == '{') { if (depth++ == 0) start = i; } - else if (c == '}') { - if (--depth == 0 && start != -1) { - String obj = json.substring(start, i + 1); - DiscordMessage msg = parseMessage(obj); - if (msg != null) result.add(msg); - start = -1; - } - } - } - return result; - } - - private DiscordMessage parseMessage(String obj) { - try { - DiscordMessage msg = new DiscordMessage(); - msg.id = Long.parseLong(extractJsonString(obj, "\"id\"")); - msg.content = unescapeJson(extractJsonString(obj, "\"content\"")); - - // Webhook-Nachrichten herausfiltern (Echo-Loop verhindern): - // Nachrichten die via Webhook gesendet wurden haben "webhook_id" gesetzt. - // Das sind unsere eigenen Minecraft→Discord Nachrichten die wir ignorieren. - String webhookId = extractJsonString(obj, "\"webhook_id\""); - if (!webhookId.isEmpty()) { - msg.isBot = true; // Als Bot markieren → wird übersprungen - return msg; - } - - // Author-Block - int authStart = obj.indexOf("\"author\""); - if (authStart >= 0) { - String authBlock = extractJsonObject(obj, authStart); - msg.authorId = extractJsonString(authBlock, "\"id\""); - msg.authorName = unescapeJson(extractJsonString(authBlock, "\"username\"")); - String botFlag = extractJsonString(authBlock, "\"bot\""); - msg.isBot = "true".equals(botFlag); - } - return msg; - } catch (Exception e) { - return null; - } - } - - private String extractJsonString(String json, String key) { - int keyIdx = json.indexOf(key); - if (keyIdx < 0) return ""; - int colon = json.indexOf(':', keyIdx + key.length()); - if (colon < 0) return ""; - // Wert direkt nach dem Doppelpunkt - int valStart = colon + 1; - while (valStart < json.length() && json.charAt(valStart) == ' ') valStart++; - if (valStart >= json.length()) return ""; - char first = json.charAt(valStart); - if (first == '"') { - // String-Wert - int end = valStart + 1; - while (end < json.length()) { - if (json.charAt(end) == '"' && json.charAt(end - 1) != '\\') break; - end++; - } - return json.substring(valStart + 1, end); - } else { - // Primitiver Wert (Zahl, Boolean) - int end = valStart; - while (end < json.length() && ",}\n".indexOf(json.charAt(end)) < 0) end++; - return json.substring(valStart, end).trim(); - } - } - - private String extractJsonObject(String json, int fromIndex) { - int depth = 0, start = -1; - for (int i = fromIndex; i < json.length(); i++) { - char c = json.charAt(i); - if (c == '{') { if (depth++ == 0) start = i; } - else if (c == '}') { if (--depth == 0 && start >= 0) return json.substring(start, i + 1); } - } - return ""; - } - - private static String escapeJson(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } - - private static String unescapeJson(String s) { - if (s == null) return ""; - return s.replace("\\\"", "\"") - .replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\\\", "\\"); - } -} \ No newline at end of file +package net.viper.status.modules.chat.bridge; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.modules.chat.AccountLinkManager; +import net.viper.status.modules.chat.ChatConfig; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Discord-Brücke für bidirektionale Kommunikation. + * + * Fix #12: extractJsonString() behandelt Escape-Sequenzen jetzt korrekt. + * Statt Zeichenvergleich mit dem Vorgänger-Char wird ein expliziter Escape-Flag verwendet. + */ +public class DiscordBridge { + + private final Plugin plugin; + private final ChatConfig config; + private final Logger logger; + private AccountLinkManager linkManager; + + private final java.util.Map lastMessageIds = new java.util.concurrent.ConcurrentHashMap<>(); + private volatile boolean running = false; + + public DiscordBridge(Plugin plugin, ChatConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + public void setLinkManager(AccountLinkManager linkManager) { this.linkManager = linkManager; } + + public void start() { + if (!config.isDiscordEnabled() + || config.getDiscordBotToken().isEmpty() + || config.getDiscordBotToken().equals("YOUR_BOT_TOKEN_HERE")) { + logger.warning("[ChatModule-Discord] Bot-Token nicht konfiguriert. Discord-Empfang deaktiviert."); + return; + } + running = true; + int interval = Math.max(2, config.getDiscordPollInterval()); + plugin.getProxy().getScheduler().schedule(plugin, this::pollAllChannels, interval, interval, TimeUnit.SECONDS); + logger.info("[ChatModule-Discord] Brücke gestartet (Poll-Intervall: " + interval + "s)."); + } + + public void stop() { running = false; } + + // ===== Minecraft → Discord ===== + + public void sendToDiscord(String webhookUrl, String username, String message, String avatarUrl) { + if (webhookUrl == null || webhookUrl.isEmpty()) return; + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String payload = "{\"username\":\"" + escapeJson(username) + "\"" + + (avatarUrl != null && !avatarUrl.isEmpty() ? ",\"avatar_url\":\"" + avatarUrl + "\"" : "") + + ",\"content\":\"" + escapeJson(message) + "\"}"; + postJson(webhookUrl, payload, null); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Webhook-Fehler: " + e.getMessage()); + } + }); + } + + public void sendEmbedToDiscord(String webhookUrl, String title, String description, String colorHex) { + if (webhookUrl == null || webhookUrl.isEmpty()) return; + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + int color = 0x5865F2; + try { color = Integer.parseInt(colorHex.replace("#", ""), 16); } catch (Exception ignored) {} + String payload = "{\"embeds\":[{\"title\":\"" + escapeJson(title) + "\"" + + ",\"description\":\"" + escapeJson(description) + "\"" + + ",\"color\":" + color + "}]}"; + postJson(webhookUrl, payload, null); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Embed-Fehler: " + e.getMessage()); + } + }); + } + + public void sendToChannel(String channelId, String message) { + if (channelId == null || channelId.isEmpty()) return; + if (config.getDiscordBotToken().isEmpty()) return; + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String url = "https://discord.com/api/v10/channels/" + channelId + "/messages"; + postJson(url, "{\"content\":\"" + escapeJson(message) + "\"}", "Bot " + config.getDiscordBotToken()); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Send-to-Channel-Fehler: " + e.getMessage()); + } + }); + } + + // ===== Discord → Minecraft (Polling) ===== + + private void pollAllChannels() { + if (!running) return; + java.util.Set channelIds = new java.util.LinkedHashSet<>(); + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (!ch.getDiscordChannelId().isEmpty()) channelIds.add(ch.getDiscordChannelId()); + } + if (!config.getDiscordAdminChannelId().isEmpty()) channelIds.add(config.getDiscordAdminChannelId()); + for (String channelId : channelIds) pollChannel(channelId); + } + + private void pollChannel(String channelId) { + try { + AtomicLong lastId = lastMessageIds.computeIfAbsent(channelId, k -> new AtomicLong(0L)); + if (lastId.get() == 0L) { + String initResp = getJson("https://discord.com/api/v10/channels/" + channelId + "/messages?limit=1", + "Bot " + config.getDiscordBotToken()); + if (initResp != null && !initResp.equals("[]") && !initResp.isEmpty()) { + java.util.List initMsgs = parseMessages(initResp); + if (!initMsgs.isEmpty()) lastId.set(initMsgs.get(0).id); + } + return; + } + + String url = "https://discord.com/api/v10/channels/" + channelId + "/messages?after=" + lastId.get() + "&limit=10"; + String response = getJson(url, "Bot " + config.getDiscordBotToken()); + if (response == null || response.equals("[]") || response.isEmpty()) return; + + java.util.List messages = parseMessages(response); + messages.sort(java.util.Comparator.comparingLong(m -> m.id)); + + for (DiscordMessage msg : messages) { + if (msg.id <= lastId.get()) continue; + if (msg.isBot) continue; + if (msg.content.isEmpty()) continue; + lastId.set(msg.id); + + if (msg.content.startsWith("!link ")) { + String token = msg.content.substring(6).trim().toUpperCase(); + if (linkManager != null) { + AccountLinkManager.LinkedAccount acc = linkManager.redeemDiscord(token, msg.authorId, msg.authorName); + if (acc != null) sendToChannel(channelId, "✅ Verknüpfung erfolgreich! Minecraft-Account: **" + acc.minecraftName + "**"); + else sendToChannel(channelId, "❌ Ungültiger oder abgelaufener Token. Bitte `/discordlink` im Spiel erneut ausführen."); + } + continue; + } + + String displayName = (linkManager != null) + ? linkManager.resolveDiscordName(msg.authorId, msg.authorName) : msg.authorName; + String mcFormat = resolveFormat(channelId); + if (mcFormat == null) continue; + + String formatted = ChatColor.translateAlternateColorCodes('&', + mcFormat.replace("{user}", displayName).replace("{message}", msg.content)); + ProxyServer.getInstance().getScheduler().runAsync(plugin, + () -> ProxyServer.getInstance().broadcast(new TextComponent(formatted))); + } + } catch (Exception e) { + logger.fine("[ChatModule-Discord] Poll-Fehler für Kanal " + channelId + ": " + e.getMessage()); + } + } + + private String resolveFormat(String channelId) { + if (channelId.equals(config.getDiscordAdminChannelId())) return config.getDiscordFromFormat(); + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (channelId.equals(ch.getDiscordChannelId())) return config.getDiscordFromFormat(); + } + return null; + } + + // ===== HTTP ===== + + private void postJson(String urlStr, String payload, String authorization) throws Exception { + HttpURLConnection conn = openConnection(urlStr, "POST", authorization); + byte[] data = payload.getBytes(StandardCharsets.UTF_8); + conn.setRequestProperty("Content-Length", String.valueOf(data.length)); + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { os.write(data); } + int code = conn.getResponseCode(); + if (code >= 400) logger.warning("[ChatModule-Discord] HTTP " + code + ": " + readStream(conn.getErrorStream())); + conn.disconnect(); + } + + private String getJson(String urlStr, String authorization) throws Exception { + HttpURLConnection conn = openConnection(urlStr, "GET", authorization); + int code = conn.getResponseCode(); + if (code != 200) { conn.disconnect(); return null; } + String result = readStream(conn.getInputStream()); + conn.disconnect(); + return result; + } + + private HttpURLConnection openConnection(String urlStr, String method, String authorization) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(5000); + conn.setReadTimeout(8000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "StatusAPI-ChatModule/1.0"); + if (authorization != null && !authorization.isEmpty()) conn.setRequestProperty("Authorization", authorization); + return conn; + } + + private String readStream(InputStream in) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + // ===== JSON Mini-Parser ===== + + private static class DiscordMessage { + long id; + String authorId = "", authorName = "", content = ""; + boolean isBot = false; + } + + private java.util.List parseMessages(String json) { + java.util.List result = new java.util.ArrayList<>(); + int depth = 0, start = -1; + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { + if (--depth == 0 && start != -1) { + DiscordMessage msg = parseMessage(json.substring(start, i + 1)); + if (msg != null) result.add(msg); + start = -1; + } + } + } + return result; + } + + private DiscordMessage parseMessage(String obj) { + try { + DiscordMessage msg = new DiscordMessage(); + msg.id = Long.parseLong(extractJsonString(obj, "id")); + msg.content = unescapeJson(extractJsonString(obj, "content")); + + // Webhook-Nachrichten als Bot markieren (Echo-Loop verhindern) + if (!extractJsonString(obj, "webhook_id").isEmpty()) { + msg.isBot = true; + return msg; + } + + int authStart = obj.indexOf("\"author\""); + if (authStart >= 0) { + String authBlock = extractJsonObject(obj, authStart); + msg.authorId = extractJsonString(authBlock, "id"); + msg.authorName = unescapeJson(extractJsonString(authBlock, "username")); + msg.isBot = "true".equals(extractJsonString(authBlock, "bot")); + } + return msg; + } catch (Exception e) { + return null; + } + } + + /** + * FIX #12: Escape-Sequenzen werden korrekt mit einem Escape-Flag behandelt + * statt den Vorgänger-Char zu vergleichen (der bei '\\' + '"' versagt). + * Gibt immer einen leeren String zurück wenn der Key nicht gefunden wird (nie null). + */ + private String extractJsonString(String json, String key) { + if (json == null || key == null) return ""; + String fullKey = "\"" + key + "\""; + int keyIdx = json.indexOf(fullKey); + if (keyIdx < 0) return ""; + int colon = json.indexOf(':', keyIdx + fullKey.length()); + if (colon < 0) return ""; + int valStart = colon + 1; + while (valStart < json.length() && json.charAt(valStart) == ' ') valStart++; + if (valStart >= json.length()) return ""; + char first = json.charAt(valStart); + if (first == '"') { + // FIX: Expliziter Escape-Flag statt Vorgänger-Char-Vergleich + int end = valStart + 1; + boolean escaped = false; + while (end < json.length()) { + char ch = json.charAt(end); + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + break; + } + end++; + } + return json.substring(valStart + 1, end); + } else { + int end = valStart; + while (end < json.length() && ",}\n".indexOf(json.charAt(end)) < 0) end++; + return json.substring(valStart, end).trim(); + } + } + + private String extractJsonObject(String json, int fromIndex) { + int depth = 0, start = -1; + for (int i = fromIndex; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { if (--depth == 0 && start >= 0) return json.substring(start, i + 1); } + } + return ""; + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); + } + + private static String unescapeJson(String s) { + if (s == null) return ""; + return s.replace("\\\"", "\"").replace("\\n", "\n").replace("\\r", "\r").replace("\\\\", "\\"); + } +} diff --git a/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java b/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java index 0bf0712..4106fe4 100644 --- a/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java +++ b/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java @@ -26,153 +26,92 @@ import java.util.concurrent.TimeUnit; /** * ForumBridgeModule: Verbindet das WP Business Forum mit dem Minecraft-Server. * - * HTTP-Endpoints (werden vom StatusAPI WebServer geroutet): - * POST /forum/notify — WordPress pusht Benachrichtigung - * POST /forum/unlink — WordPress informiert über Verknüpfungslösung - * GET /forum/status — Verbindungstest - * - * Commands: - * /forumlink — Account mit Forum verknüpfen - * /forum — Ausstehende Benachrichtigungen anzeigen + * Fix #13: extractJsonString() gibt jetzt immer einen leeren String statt null zurück. + * Alle Aufrufer müssen nicht mehr auf null prüfen, was NullPointerExceptions verhindert. */ public class ForumBridgeModule implements Module, Listener { private Plugin plugin; private ForumNotifStorage storage; - // Konfiguration aus verify.properties private boolean enabled = true; private String wpBaseUrl = ""; private String apiSecret = ""; private int loginDelaySeconds = 3; @Override - public String getName() { - return "ForumBridgeModule"; - } + public String getName() { return "ForumBridgeModule"; } @Override public void onEnable(Plugin plugin) { this.plugin = plugin; - - // Config laden loadConfig(plugin); + if (!enabled) { plugin.getLogger().info("ForumBridgeModule ist deaktiviert."); return; } - if (!enabled) { - plugin.getLogger().info("ForumBridgeModule ist deaktiviert."); - return; - } - - // Storage initialisieren und laden storage = new ForumNotifStorage(plugin.getDataFolder(), plugin.getLogger()); storage.load(); - // Event Listener registrieren plugin.getProxy().getPluginManager().registerListener(plugin, this); - - // Commands registrieren ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ForumLinkCommand()); ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ForumCommand()); - // Auto-Save alle 10 Minuten plugin.getProxy().getScheduler().schedule(plugin, () -> { - try { - storage.save(); - } catch (Exception e) { - plugin.getLogger().warning("ForumBridge Auto-Save Fehler: " + e.getMessage()); - } + try { storage.save(); } catch (Exception e) { plugin.getLogger().warning("ForumBridge Auto-Save Fehler: " + e.getMessage()); } }, 10, 10, TimeUnit.MINUTES); - // Alte Benachrichtigungen aufräumen (täglich, max 30 Tage) - plugin.getProxy().getScheduler().schedule(plugin, () -> { - storage.purgeOld(30); - }, 1, 24, TimeUnit.HOURS); - + plugin.getProxy().getScheduler().schedule(plugin, () -> storage.purgeOld(30), 1, 24, TimeUnit.HOURS); plugin.getLogger().info("ForumBridgeModule aktiviert."); } @Override public void onDisable(Plugin plugin) { - if (storage != null) { - storage.save(); - plugin.getLogger().info("Forum-Benachrichtigungen gespeichert."); - } + if (storage != null) { storage.save(); plugin.getLogger().info("Forum-Benachrichtigungen gespeichert."); } } - // ===== CONFIG ===== - private void loadConfig(Plugin plugin) { try { Properties props = new Properties(); File configFile = new File(plugin.getDataFolder(), "verify.properties"); if (configFile.exists()) { - try (FileInputStream fis = new FileInputStream(configFile)) { - props.load(fis); - } + try (FileInputStream fis = new FileInputStream(configFile)) { props.load(fis); } } - this.enabled = !"false".equalsIgnoreCase(props.getProperty("forum.enabled", "true")); - this.wpBaseUrl = props.getProperty("forum.wp_url", props.getProperty("wp_verify_url", "")); - this.apiSecret = props.getProperty("forum.api_secret", ""); + this.enabled = !"false".equalsIgnoreCase(props.getProperty("forum.enabled", "true")); + this.wpBaseUrl = props.getProperty("forum.wp_url", props.getProperty("wp_verify_url", "")); + this.apiSecret = props.getProperty("forum.api_secret", ""); this.loginDelaySeconds = parseInt(props.getProperty("forum.login_delay_seconds", "3"), 3); } catch (Exception e) { plugin.getLogger().warning("Fehler beim Laden der ForumBridge-Config: " + e.getMessage()); } } - private int parseInt(String s, int def) { - try { return Integer.parseInt(s); } catch (Exception e) { return def; } - } + private int parseInt(String s, int def) { try { return Integer.parseInt(s); } catch (Exception e) { return def; } } - // ===== HTTP HANDLER (aufgerufen vom StatusAPI WebServer) ===== + // ===== HTTP HANDLER ===== - /** - * Verarbeitet POST /forum/notify von WordPress. - * WordPress sendet Benachrichtigungen wenn ein verknüpfter Spieler - * eine neue Antwort, Erwähnung oder PN erhält. - * - * Erwarteter JSON-Body: - * { - * "player_uuid": "uuid-string", - * "type": "reply|mention|message", - * "title": "Thread-Titel oder PN", - * "author": "Absender-Name", - * "url": "Forum-Link", - * "wp_user_id": 123 - * } - */ public String handleNotify(String body, String apiKeyHeader) { - // API-Key prüfen if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) { return "{\"success\":false,\"error\":\"unauthorized\"}"; } + // FIX #13: extractJsonString gibt "" statt null → kein NullPointerException möglich String playerUuid = extractJsonString(body, "player_uuid"); - String type = extractJsonString(body, "type"); - String title = extractJsonString(body, "title"); - String author = extractJsonString(body, "author"); - String url = extractJsonString(body, "url"); + String type = extractJsonString(body, "type"); + String title = extractJsonString(body, "title"); + String author = extractJsonString(body, "author"); + String url = extractJsonString(body, "url"); - if (playerUuid == null || playerUuid.isEmpty()) { - return "{\"success\":false,\"error\":\"missing_player_uuid\"}"; - } + if (playerUuid.isEmpty()) return "{\"success\":false,\"error\":\"missing_player_uuid\"}"; java.util.UUID uuid; - try { - uuid = java.util.UUID.fromString(playerUuid); - } catch (Exception e) { - return "{\"success\":false,\"error\":\"invalid_uuid\"}"; - } + try { uuid = java.util.UUID.fromString(playerUuid); } + catch (Exception e) { return "{\"success\":false,\"error\":\"invalid_uuid\"}"; } - // Fallback: Wenn type 'thread' und title enthält 'Umfrage', dann als 'poll' behandeln - if (type != null && type.equalsIgnoreCase("thread") && title != null && title.toLowerCase().contains("umfrage")) { - type = "poll"; - } - if (type == null || type.isEmpty()) type = "reply"; + if ("thread".equalsIgnoreCase(type) && title.toLowerCase().contains("umfrage")) type = "poll"; + if (type.isEmpty()) type = "reply"; - // Notification erstellen + // Alle Werte sind garantiert nicht null (extractJsonString gibt "" zurück) ForumNotification notification = new ForumNotification(uuid, type, title, author, url); - // Sofort zustellen wenn online ProxiedPlayer online = ProxyServer.getInstance().getPlayer(uuid); if (online != null && online.isConnected()) { deliverNotification(online, notification); @@ -180,62 +119,30 @@ public class ForumBridgeModule implements Module, Listener { return "{\"success\":true,\"delivered\":true}"; } - // Offline → speichern für späteren Login storage.add(notification); return "{\"success\":true,\"delivered\":false}"; } - /** - * Verarbeitet POST /forum/unlink von WordPress. - * Wird aufgerufen wenn ein User seine Verknüpfung im Forum löst. - */ public String handleUnlink(String body, String apiKeyHeader) { - if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) { - return "{\"success\":false,\"error\":\"unauthorized\"}"; - } - // Aktuell keine lokale Aktion nötig — die Zuordnung liegt in WordPress + if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) return "{\"success\":false,\"error\":\"unauthorized\"}"; return "{\"success\":true}"; } - /** - * Verarbeitet GET /forum/status — Verbindungstest. - */ public String handleStatus() { String version = "unknown"; - try { - if (plugin.getDescription() != null) { - version = plugin.getDescription().getVersion(); - } - } catch (Exception ignored) {} - + try { if (plugin.getDescription() != null) version = plugin.getDescription().getVersion(); } catch (Exception ignored) {} return "{\"success\":true,\"module\":\"ForumBridgeModule\",\"version\":\"" + version + "\"}"; } - // ===== NOTIFICATION ZUSTELLUNG ===== + // ===== NOTIFICATION ===== - /** - * Stellt eine einzelne Benachrichtigung an einen Online-Spieler zu. - */ private void deliverNotification(ProxiedPlayer player, ForumNotification notif) { String color = notif.getTypeColor(); String label = notif.getTypeLabel(); - - // Trennlinie player.sendMessage(new TextComponent("§8§m ")); - - // Hauptnachricht - TextComponent header = new TextComponent("§6§l✉ Forum §8» " + color + label); - player.sendMessage(header); - - // Details - if (!notif.getTitle().isEmpty()) { - player.sendMessage(new TextComponent("§7 " + notif.getTitle())); - } - if (!notif.getAuthor().isEmpty()) { - player.sendMessage(new TextComponent("§7 von §f" + notif.getAuthor())); - } - - // Klickbarer Link (wenn URL vorhanden) + player.sendMessage(new TextComponent("§6§l✉ Forum §8» " + color + label)); + if (!notif.getTitle().isEmpty()) player.sendMessage(new TextComponent("§7 " + notif.getTitle())); + if (!notif.getAuthor().isEmpty()) player.sendMessage(new TextComponent("§7 von §f" + notif.getAuthor())); if (!notif.getUrl().isEmpty()) { TextComponent link = new TextComponent("§a ➜ Im Forum ansehen"); link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, notif.getUrl())); @@ -243,94 +150,56 @@ public class ForumBridgeModule implements Module, Listener { new ComponentBuilder("§7Klicke um den Beitrag im Forum zu öffnen").create())); player.sendMessage(link); } - - // Trennlinie player.sendMessage(new TextComponent("§8§m ")); } - /** - * Stellt alle ausstehenden Benachrichtigungen an einen Spieler zu. - * Wird beim Login aufgerufen (mit kurzem Delay). - */ private void deliverPending(ProxiedPlayer player) { List pending = storage.getPending(player.getUniqueId()); if (pending.isEmpty()) return; - int count = pending.size(); - - // Zusammenfassung wenn mehr als 3 if (count > 3) { player.sendMessage(new TextComponent("§8§m ")); player.sendMessage(new TextComponent("§6§l✉ Forum §8» §fDu hast §e" + count + " §fneue Benachrichtigungen!")); player.sendMessage(new TextComponent("§7 Tippe §e/forum §7um sie anzuzeigen.")); player.sendMessage(new TextComponent("§8§m ")); } else { - // Einzeln zustellen - for (ForumNotification n : pending) { - deliverNotification(player, n); - } + for (ForumNotification n : pending) deliverNotification(player, n); } - - // Alle als zugestellt markieren und aufräumen storage.markAllDelivered(player.getUniqueId()); storage.clearDelivered(player.getUniqueId()); } - // ===== EVENTS ===== - @EventHandler public void onJoin(PostLoginEvent e) { ProxiedPlayer player = e.getPlayer(); - - // Verzögert zustellen damit der Spieler den Server-Wechsel abgeschlossen hat plugin.getProxy().getScheduler().schedule(plugin, () -> { - if (player.isConnected()) { - deliverPending(player); - } + if (player.isConnected()) deliverPending(player); }, loginDelaySeconds, TimeUnit.SECONDS); } // ===== COMMANDS ===== - /** - * /forumlink — Verknüpft den MC-Account mit dem Forum. - */ private class ForumLinkCommand extends Command { - - public ForumLinkCommand() { - super("forumlink", null, "fl"); - } + public ForumLinkCommand() { super("forumlink", null, "fl"); } @Override public void execute(CommandSender sender, String[] args) { - if (!(sender instanceof ProxiedPlayer)) { - sender.sendMessage(new TextComponent("§cNur Spieler können diesen Befehl benutzen.")); - return; - } - + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(new TextComponent("§cNur Spieler können diesen Befehl benutzen.")); return; } ProxiedPlayer p = (ProxiedPlayer) sender; - if (args.length != 1) { p.sendMessage(new TextComponent("§eBenutzung: §f/forumlink ")); p.sendMessage(new TextComponent("§7Den Token erhältst du in deinem Forum-Profil unter §fMinecraft-Verknüpfung§7.")); return; } - String token = args[0].trim().toUpperCase(); - - if (wpBaseUrl.isEmpty()) { - p.sendMessage(new TextComponent("§cForum-Verknüpfung ist nicht konfiguriert.")); - return; - } - + if (wpBaseUrl.isEmpty()) { p.sendMessage(new TextComponent("§cForum-Verknüpfung ist nicht konfiguriert.")); return; } p.sendMessage(new TextComponent("§7Überprüfe Token...")); - // Asynchron an WordPress senden plugin.getProxy().getScheduler().runAsync(plugin, () -> { try { String endpoint = wpBaseUrl + "/wp-json/mc-bridge/v1/verify-link"; - String payload = "{\"token\":\"" + escapeJson(token) + "\"," - + "\"mc_uuid\":\"" + p.getUniqueId().toString() + "\"," + String payload = "{\"token\":\"" + escapeJson(token) + "\"," + + "\"mc_uuid\":\"" + p.getUniqueId() + "\"," + "\"mc_name\":\"" + escapeJson(p.getName()) + "\"}"; HttpURLConnection conn = (HttpURLConnection) new URL(endpoint).openConnection(); @@ -339,50 +208,32 @@ public class ForumBridgeModule implements Module, Listener { conn.setConnectTimeout(5000); conn.setReadTimeout(7000); conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); - if (!apiSecret.isEmpty()) { - conn.setRequestProperty("X-Api-Key", apiSecret); - } + if (!apiSecret.isEmpty()) conn.setRequestProperty("X-Api-Key", apiSecret); Charset utf8 = Charset.forName("UTF-8"); - try (OutputStream os = conn.getOutputStream()) { - os.write(payload.getBytes(utf8)); - } + try (OutputStream os = conn.getOutputStream()) { os.write(payload.getBytes(utf8)); } int code = conn.getResponseCode(); - String resp; - if (code >= 200 && code < 300) { - resp = streamToString(conn.getInputStream(), utf8); - } else { - resp = streamToString(conn.getErrorStream(), utf8); - } + String resp = code >= 200 && code < 300 + ? streamToString(conn.getInputStream(), utf8) + : streamToString(conn.getErrorStream(), utf8); - // Antwort auswerten if (resp != null && resp.contains("\"success\":true")) { String displayName = extractJsonString(resp, "display_name"); - String username = extractJsonString(resp, "username"); - String show = (displayName != null && !displayName.isEmpty()) ? displayName : username; - + String username = extractJsonString(resp, "username"); + String show = !displayName.isEmpty() ? displayName : username; p.sendMessage(new TextComponent("§8§m ")); p.sendMessage(new TextComponent("§a§l✓ §fForum-Account erfolgreich verknüpft!")); - if (show != null && !show.isEmpty()) { - p.sendMessage(new TextComponent("§7 Forum-User: §f" + show)); - } + if (!show.isEmpty()) p.sendMessage(new TextComponent("§7 Forum-User: §f" + show)); p.sendMessage(new TextComponent("§7 Du erhältst jetzt Ingame-Benachrichtigungen.")); p.sendMessage(new TextComponent("§8§m ")); } else { - // Fehlermeldung auslesen - String error = extractJsonString(resp, "error"); + String error = extractJsonString(resp, "error"); String message = extractJsonString(resp, "message"); - - if ("token_expired".equals(error)) { - p.sendMessage(new TextComponent("§c✗ Der Token ist abgelaufen. Generiere einen neuen im Forum.")); - } else if ("uuid_already_linked".equals(error)) { - p.sendMessage(new TextComponent("§c✗ " + (message != null ? message : "Diese UUID ist bereits verknüpft."))); - } else if ("invalid_token".equals(error)) { - p.sendMessage(new TextComponent("§c✗ Ungültiger Token. Prüfe die Eingabe oder generiere einen neuen.")); - } else { - p.sendMessage(new TextComponent("§c✗ Verknüpfung fehlgeschlagen: " + (error != null ? error : "Unbekannter Fehler"))); - } + if ("token_expired".equals(error)) p.sendMessage(new TextComponent("§c✗ Der Token ist abgelaufen.")); + else if ("uuid_already_linked".equals(error)) p.sendMessage(new TextComponent("§c✗ " + (!message.isEmpty() ? message : "Diese UUID ist bereits verknüpft."))); + else if ("invalid_token".equals(error)) p.sendMessage(new TextComponent("§c✗ Ungültiger Token.")); + else p.sendMessage(new TextComponent("§c✗ Verknüpfung fehlgeschlagen: " + (!error.isEmpty() ? error : "Unbekannter Fehler"))); } } catch (Exception ex) { p.sendMessage(new TextComponent("§c✗ Fehler bei der Verbindung zum Forum.")); @@ -392,34 +243,21 @@ public class ForumBridgeModule implements Module, Listener { } } - /** - * /forum — Zeigt ausstehende Forum-Benachrichtigungen an. - */ private class ForumCommand extends Command { - - public ForumCommand() { - super("forum"); - } + public ForumCommand() { super("forum"); } @Override public void execute(CommandSender sender, String[] args) { - if (!(sender instanceof ProxiedPlayer)) { - sender.sendMessage(new TextComponent("§cNur Spieler können diesen Befehl benutzen.")); - return; - } - + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(new TextComponent("§cNur Spieler können diesen Befehl benutzen.")); return; } ProxiedPlayer p = (ProxiedPlayer) sender; List pending = storage.getPending(p.getUniqueId()); if (pending.isEmpty()) { p.sendMessage(new TextComponent("§7Keine neuen Forum-Benachrichtigungen.")); - - // Forum-Link anzeigen wenn konfiguriert if (!wpBaseUrl.isEmpty()) { TextComponent link = new TextComponent("§a➜ Forum öffnen"); link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, wpBaseUrl)); - link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, - new ComponentBuilder("§7Klicke um das Forum zu öffnen").create())); + link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("§7Klicke um das Forum zu öffnen").create())); p.sendMessage(link); } return; @@ -428,39 +266,22 @@ public class ForumBridgeModule implements Module, Listener { p.sendMessage(new TextComponent("§8§m ")); p.sendMessage(new TextComponent("§6§l✉ Forum-Benachrichtigungen §8(§f" + pending.size() + "§8)")); p.sendMessage(new TextComponent("")); - int shown = 0; for (ForumNotification n : pending) { - if (shown >= 10) { - p.sendMessage(new TextComponent("§7 ... und " + (pending.size() - 10) + " weitere")); - break; - } - + if (shown >= 10) { p.sendMessage(new TextComponent("§7 ... und " + (pending.size() - 10) + " weitere")); break; } String color = n.getTypeColor(); TextComponent line = new TextComponent(color + " • " + n.getTypeLabel() + "§7: "); - - TextComponent detail; - if (!n.getTitle().isEmpty()) { - detail = new TextComponent("§f" + n.getTitle()); - } else { - detail = new TextComponent("§fvon " + n.getAuthor()); - } - + TextComponent detail = new TextComponent(!n.getTitle().isEmpty() ? "§f" + n.getTitle() : "§fvon " + n.getAuthor()); if (!n.getUrl().isEmpty()) { detail.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, n.getUrl())); - detail.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, - new ComponentBuilder("§7Klicke zum Öffnen").create())); + detail.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("§7Klicke zum Öffnen").create())); } - line.addExtra(detail); p.sendMessage(line); shown++; } - p.sendMessage(new TextComponent("")); p.sendMessage(new TextComponent("§8§m ")); - - // Alle als gelesen markieren storage.markAllDelivered(p.getUniqueId()); storage.clearDelivered(p.getUniqueId()); } @@ -468,21 +289,22 @@ public class ForumBridgeModule implements Module, Listener { // ===== HELPER ===== - /** Getter für den Storage (für StatusAPI HTTP-Handler) */ - public ForumNotifStorage getStorage() { - return storage; - } + public ForumNotifStorage getStorage() { return storage; } + /** + * FIX #13: Gibt immer einen leeren String zurück, niemals null. + * Verhindert NullPointerExceptions in allen Aufrufern. + */ private static String extractJsonString(String json, String key) { - if (json == null || key == null) return null; + if (json == null || key == null) return ""; String search = "\"" + key + "\""; int idx = json.indexOf(search); - if (idx < 0) return null; + if (idx < 0) return ""; int colon = json.indexOf(':', idx + search.length()); - if (colon < 0) return null; + if (colon < 0) return ""; int i = colon + 1; while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++; - if (i >= json.length()) return null; + if (i >= json.length()) return ""; char c = json.charAt(i); if (c == '"') { i++; @@ -491,15 +313,11 @@ public class ForumBridgeModule implements Module, Listener { while (i < json.length()) { char ch = json.charAt(i++); if (escape) { sb.append(ch); escape = false; } - else { - if (ch == '\\') escape = true; - else if (ch == '"') break; - else sb.append(ch); - } + else { if (ch == '\\') escape = true; else if (ch == '"') break; else sb.append(ch); } } return sb.toString(); } - return null; + return ""; } private static String escapeJson(String s) { @@ -510,8 +328,7 @@ public class ForumBridgeModule implements Module, Listener { private static String streamToString(InputStream in, Charset charset) throws IOException { if (in == null) return ""; try (BufferedReader br = new BufferedReader(new InputStreamReader(in, charset))) { - StringBuilder sb = new StringBuilder(); - String line; + StringBuilder sb = new StringBuilder(); String line; while ((line = br.readLine()) != null) sb.append(line); return sb.toString(); } diff --git a/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java b/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java index dcf8e17..7089c2e 100644 --- a/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java +++ b/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java @@ -82,7 +82,7 @@ public class NetworkInfoModule implements Module { loadConfig(); if (!enabled) { - plugin.getLogger().info("[NetworkInfoModule] deaktiviert via " + CONFIG_FILE_NAME + " (networkinfo.enabled=false)"); + return; } @@ -109,7 +109,7 @@ public class NetworkInfoModule implements Module { alertTask = ProxyServer.getInstance().getScheduler().schedule(plugin, this::evaluateAndSendAlerts, interval, interval, TimeUnit.SECONDS); } - plugin.getLogger().info("[NetworkInfoModule] aktiviert. commandEnabled=" + commandEnabled + ", includePlayerNames=" + includePlayerNames + ", webhookEnabled=" + webhookEnabled + ", notifyStartStop=" + webhookNotifyStartStop + ", webhookUrlPresent=" + !webhookUrl.isEmpty()); + } @Override @@ -514,7 +514,7 @@ public class NetworkInfoModule implements Module { while ((read = in.read(buffer)) != -1) { out.write(buffer, 0, read); } - plugin.getLogger().info("[NetworkInfoModule] " + CONFIG_FILE_NAME + " wurde erstellt."); + } catch (Exception e) { plugin.getLogger().warning("[NetworkInfoModule] Konnte " + CONFIG_FILE_NAME + " nicht erstellen: " + e.getMessage()); } diff --git a/src/main/java/net/viper/status/modules/vanish/VanishModule.java b/src/main/java/net/viper/status/modules/vanish/VanishModule.java new file mode 100644 index 0000000..d5c6190 --- /dev/null +++ b/src/main/java/net/viper/status/modules/vanish/VanishModule.java @@ -0,0 +1,269 @@ +package net.viper.status.modules.vanish; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.event.EventPriority; +import net.viper.status.module.Module; +import net.viper.status.modules.chat.VanishProvider; + +import java.io.*; +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * VanishModule für StatusAPI (BungeeCord) + * + * Features: + * - /vanish zum Ein-/Ausschalten + * - /vanish für Admin-Vanish anderer Spieler + * - /vanishlist – zeigt alle aktuell unsichtbaren Spieler + * - Vanish-Status wird persistent in vanish.dat gespeichert + * - Beim Login wird gespeicherter Status wiederhergestellt + * - Volle Integration mit VanishProvider → ChatModule sieht den Status + * + * Permission: + * - vanish.use → darf vanishen + * - vanish.other → darf andere Spieler vanishen + * - vanish.list → darf /vanishlist nutzen + * - chat.admin.bypass → sieht Vanish-Join/Leave-Meldungen im Chat + */ +public class VanishModule implements Module, Listener { + + private static final String PERMISSION = "chat.admin.bypass"; + private static final String PERMISSION_OTHER = "chat.admin.bypass"; + private static final String PERMISSION_LIST = "chat.admin.bypass"; + + private Plugin plugin; + + // Persistente Vanish-UUIDs (werden in vanish.dat gespeichert) + private final Set persistentVanished = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + + private File dataFile; + + @Override + public String getName() { + return "VanishModule"; + } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + this.dataFile = new File(plugin.getDataFolder(), "vanish.dat"); + + load(); + + plugin.getProxy().getPluginManager().registerListener(plugin, this); + registerCommands(); + } + + @Override + public void onDisable(Plugin plugin) { + save(); + // Alle als sichtbar markieren beim Shutdown (damit beim nächsten Start + // der VanishProvider sauber ist – load() setzt sie beim Login neu) + for (UUID uuid : persistentVanished) { + VanishProvider.setVanished(uuid, false); + } + } + + // ========================================================= + // EVENTS + // ========================================================= + + /** + * Beim Login: Wenn der Spieler persistent gevanisht war, sofort + * in den VanishProvider eintragen – BEVOR das ChatModule die + * Join-Nachricht nach 2 Sekunden sendet. + */ + @EventHandler(priority = EventPriority.LOWEST) + public void onLogin(PostLoginEvent e) { + ProxiedPlayer player = e.getPlayer(); + if (persistentVanished.contains(player.getUniqueId())) { + VanishProvider.setVanished(player.getUniqueId(), true); + // Kurze Bestätigung an den Spieler selbst (nach kurzem Delay damit + // der Client bereit ist) + plugin.getProxy().getScheduler().schedule(plugin, () -> { + if (player.isConnected()) { + player.sendMessage(color("&8[&7Vanish&8] &7Du bist &cUnsichtbar&7.")); + } + }, 1, java.util.concurrent.TimeUnit.SECONDS); + } + } + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent e) { + // VanishProvider cleanup – der Eintrag in persistentVanished bleibt + // erhalten damit der Status beim nächsten Login wiederhergestellt wird + VanishProvider.cleanup(e.getPlayer().getUniqueId()); + } + + // ========================================================= + // COMMANDS + // ========================================================= + + private void registerCommands() { + + // /vanish [spieler] + plugin.getProxy().getPluginManager().registerCommand(plugin, + new Command("vanish", PERMISSION, "v") { + @Override + public void execute(CommandSender sender, String[] args) { + + if (args.length == 0) { + // Sich selbst vanishen + if (!(sender instanceof ProxiedPlayer)) { + sender.sendMessage(color("&cNur Spieler!")); + return; + } + toggleVanish((ProxiedPlayer) sender, (ProxiedPlayer) sender); + + } else { + // Anderen Spieler vanishen + if (!sender.hasPermission(PERMISSION_OTHER)) { + sender.sendMessage(color("&cDu hast keine Berechtigung für /vanish .")); + return; + } + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { + sender.sendMessage(color("&cSpieler &f" + args[0] + " &cnicht gefunden.")); + return; + } + toggleVanish(sender, target); + } + } + }); + + // /vanishlist + plugin.getProxy().getPluginManager().registerCommand(plugin, + new Command("vanishlist", PERMISSION_LIST, "vlist") { + @Override + public void execute(CommandSender sender, String[] args) { + Set vanished = VanishProvider.getVanishedPlayers(); + if (vanished.isEmpty()) { + sender.sendMessage(color("&8[Vanish] &7Keine unsichtbaren Spieler.")); + return; + } + sender.sendMessage(color("&8[Vanish] &7Unsichtbare Spieler &8(" + vanished.size() + ")&7:")); + for (UUID uuid : vanished) { + ProxiedPlayer p = ProxyServer.getInstance().getPlayer(uuid); + String name = p != null ? p.getName() : uuid.toString().substring(0, 8) + "..."; + String online = p != null ? " &8(online)" : " &8(offline/persistent)"; + sender.sendMessage(color(" &8- &7" + name + online)); + } + } + }); + } + + // ========================================================= + // VANISH-LOGIK + // ========================================================= + + /** + * Schaltet den Vanish-Status eines Spielers um. + * + * @param executor Der Befehlsgeber (für Feedback-Nachrichten) + * @param target Der betroffene Spieler + */ + private void toggleVanish(CommandSender executor, ProxiedPlayer target) { + boolean nowVanished = !VanishProvider.isVanished(target); + setVanished(target, nowVanished); + + String statusMsg = nowVanished + ? "&8[&7Vanish&8] &f" + target.getName() + " &7ist jetzt &cUnsichtbar&7." + : "&8[&7Vanish&8] &f" + target.getName() + " &7ist jetzt &aSichtbar&7."; + + // Feedback an den Ausführenden + executor.sendMessage(color(statusMsg)); + + // Falls jemand anderes gevanisht wurde, auch dem Ziel Bescheid geben + if (!executor.equals(target)) { + String selfMsg = nowVanished + ? "&8[&7Vanish&8] &7Du wurdest &cUnsichtbar &7gemacht." + : "&8[&7Vanish&8] &7Du wurdest &aSichtbar &7gemacht."; + target.sendMessage(color(selfMsg)); + } + + // Admins mit chat.admin.bypass informieren (außer dem Ausführenden) + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (p.equals(executor) || p.equals(target)) continue; + if (p.hasPermission("chat.admin.bypass")) { + p.sendMessage(color(statusMsg)); + } + } + } + + /** + * Setzt den Vanish-Status direkt (ohne Toggle). + * Aktualisiert VanishProvider UND die persistente Liste. + */ + public void setVanished(ProxiedPlayer player, boolean vanished) { + VanishProvider.setVanished(player.getUniqueId(), vanished); + if (vanished) { + persistentVanished.add(player.getUniqueId()); + } else { + persistentVanished.remove(player.getUniqueId()); + } + save(); + } + + /** + * Öffentliche API für andere Module. + */ + public boolean isVanished(ProxiedPlayer player) { + return VanishProvider.isVanished(player); + } + + // ========================================================= + // PERSISTENZ + // ========================================================= + + private void save() { + try (BufferedWriter bw = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(dataFile), "UTF-8"))) { + for (UUID uuid : persistentVanished) { + bw.write(uuid.toString()); + bw.newLine(); + } + } catch (IOException e) { + plugin.getLogger().warning("[VanishModule] Fehler beim Speichern: " + e.getMessage()); + } + } + + private void load() { + persistentVanished.clear(); + if (!dataFile.exists()) return; + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(dataFile), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + try { + persistentVanished.add(UUID.fromString(line)); + } catch (IllegalArgumentException ignored) {} + } + } catch (IOException e) { + plugin.getLogger().warning("[VanishModule] Fehler beim Laden: " + e.getMessage()); + } + } + + // ========================================================= + // HILFSMETHODEN + // ========================================================= + + private TextComponent color(String text) { + return new TextComponent(ChatColor.translateAlternateColorCodes('&', text)); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/verify/VerifyModule.java b/src/main/java/net/viper/status/modules/verify/VerifyModule.java index f065a12..bb6f91b 100644 --- a/src/main/java/net/viper/status/modules/verify/VerifyModule.java +++ b/src/main/java/net/viper/status/modules/verify/VerifyModule.java @@ -1,248 +1,190 @@ -package net.viper.status.modules.verify; - -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.CommandSender; -import net.md_5.bungee.api.ProxyServer; -import net.md_5.bungee.api.connection.ProxiedPlayer; -import net.md_5.bungee.api.plugin.Command; -import net.md_5.bungee.api.plugin.Plugin; -import net.viper.status.module.Module; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.Charset; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; - -/** - * VerifyModule: Multi-Server Support. - * Liest pro Server die passende ID und das Secret aus der verify.properties. - */ -public class VerifyModule implements Module { - - private String wpVerifyUrl; - // Speichert für jeden Servernamen (z.B. "Lobby") die passende Konfiguration - private final Map serverConfigs = new HashMap<>(); - - @Override - public String getName() { - return "VerifyModule"; - } - - @Override - public void onEnable(Plugin plugin) { - loadConfig(plugin); - - // Befehl registrieren - ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new VerifyCommand()); - - plugin.getLogger().info("VerifyModule aktiviert. " + serverConfigs.size() + " Server-Konfigurationen geladen."); - } - - @Override - public void onDisable(Plugin plugin) { - // Befehl muss nicht manuell entfernt werden, BungeeCord übernimmt das beim Plugin-Stop - } - - // --- Konfiguration Laden & Kopieren --- - private void loadConfig(Plugin plugin) { - String fileName = "verify.properties"; - File configFile = new File(plugin.getDataFolder(), fileName); - Properties props = new Properties(); - - // 1. Datei kopieren, falls sie noch nicht existiert - if (!configFile.exists()) { - plugin.getDataFolder().mkdirs(); - try (InputStream in = plugin.getResourceAsStream(fileName); - OutputStream out = new FileOutputStream(configFile)) { - if (in == null) { - plugin.getLogger().warning("Standard-config '" + fileName + "' nicht in JAR gefunden. Erstelle manuell."); - return; - } - byte[] buffer = new byte[1024]; - int length; - while ((length = in.read(buffer)) > 0) { - out.write(buffer, 0, length); - } - plugin.getLogger().info("Konfigurationsdatei '" + fileName + "' erstellt."); - } catch (Exception e) { - plugin.getLogger().severe("Fehler beim Erstellen der Config: " + e.getMessage()); - return; - } - } - - // 2. Eigentliche Config laden - try (InputStream in = new FileInputStream(configFile)) { - props.load(in); - } catch (IOException e) { - e.printStackTrace(); - return; - } - - // Globale URL - this.wpVerifyUrl = props.getProperty("wp_verify_url", "https://deine-wp-domain.tld"); - - // Server-Configs parsen (z.B. server.Lobby.id) - this.serverConfigs.clear(); - for (String key : props.stringPropertyNames()) { - if (key.startsWith("server.")) { - // Key Struktur: server..id oder .secret - String[] parts = key.split("\\."); - if (parts.length == 3) { - String serverName = parts[1]; - String type = parts[2]; - - // Eintrag in der Map erstellen oder holen - ServerConfig config = serverConfigs.computeIfAbsent(serverName, k -> new ServerConfig()); - - if ("id".equalsIgnoreCase(type)) { - try { - config.serverId = Integer.parseInt(props.getProperty(key)); - } catch (NumberFormatException e) { - plugin.getLogger().warning("Ungültige Server ID für " + serverName); - } - } else if ("secret".equalsIgnoreCase(type)) { - config.sharedSecret = props.getProperty(key); - } - } - } - } - } - - // --- Hilfsklasse für die Daten eines Servers --- - private static class ServerConfig { - int serverId = 0; - String sharedSecret = ""; - } - - // --- Die Command Klasse --- - private class VerifyCommand extends Command { - - public VerifyCommand() { - super("verify"); - } - - @Override - public void execute(CommandSender sender, String[] args) { - if (!(sender instanceof ProxiedPlayer)) { - sender.sendMessage(ChatColor.RED + "Nur Spieler können diesen Befehl benutzen."); - return; - } - - ProxiedPlayer p = (ProxiedPlayer) sender; - if (args.length != 1) { - p.sendMessage(ChatColor.YELLOW + "Benutzung: /verify "); - return; - } - - // --- WICHTIG: Servernamen ermitteln --- - String serverName = p.getServer().getInfo().getName(); - - // Konfiguration für diesen Server laden - ServerConfig config = serverConfigs.get(serverName); - - // Check ob Konfig existiert - if (config == null || config.serverId == 0 || config.sharedSecret.isEmpty()) { - p.sendMessage(ChatColor.RED + "✗ Dieser Server ist nicht in der Verify-Konfiguration hinterlegt."); - p.sendMessage(ChatColor.GRAY + "Aktueller Servername: " + ChatColor.WHITE + serverName); - p.sendMessage(ChatColor.GRAY + "Bitte kontaktiere einen Admin."); - return; - } - - String token = args[0].trim(); - String playerName = p.getName(); - - HttpURLConnection conn = null; - try { - Charset utf8 = Charset.forName("UTF-8"); - // Wir signieren Name + Token mit dem SERVER-SPECIFISCHEN Secret - String signature = hmacSHA256(playerName + token, config.sharedSecret, utf8); - - // Payload aufbauen mit der SERVER-SPECIFISCHEN ID - String payload = "{\"player\":\"" + escapeJson(playerName) + "\",\"token\":\"" + escapeJson(token) + "\",\"server_id\":" + config.serverId + ",\"signature\":\"" + signature + "\"}"; - - URL url = new URL(wpVerifyUrl + "/wp-json/mc-gallery/v1/verify"); - conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(5000); - conn.setReadTimeout(7000); - conn.setDoOutput(true); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); - - try (OutputStream os = conn.getOutputStream()) { - os.write(payload.getBytes(utf8)); - } - - int code = conn.getResponseCode(); - String resp; - - if (code >= 200 && code < 300) { - resp = streamToString(conn.getInputStream(), utf8); - } else { - resp = streamToString(conn.getErrorStream(), utf8); - } - - // Antwort verarbeiten - if (resp != null && !resp.isEmpty() && resp.trim().startsWith("{")) { - boolean isSuccess = resp.contains("\"success\":true"); - String message = "Ein unbekannter Fehler ist aufgetreten."; - - int keyIndex = resp.indexOf("\"message\":\""); - if (keyIndex != -1) { - int startIndex = keyIndex + 11; - int endIndex = resp.indexOf("\"", startIndex); - if (endIndex != -1) { - message = resp.substring(startIndex, endIndex); - } - } - - if (isSuccess) { - p.sendMessage(ChatColor.GREEN + "✓ " + message); - p.sendMessage(ChatColor.GRAY + "Du kannst nun Bilder hochladen!"); - } else { - p.sendMessage(ChatColor.RED + "✗ " + message); - } - } else { - p.sendMessage(ChatColor.RED + "✗ Fehler beim Verbinden mit der Webseite (Code: " + code + ")"); - } - - } catch (Exception ex) { - p.sendMessage(ChatColor.RED + "✗ Ein interner Fehler ist aufgetreten."); - ProxyServer.getInstance().getLogger().warning("Verify error: " + ex.getMessage()); - ex.printStackTrace(); - } finally { - if (conn != null) { - conn.disconnect(); - } - } - } - } - - // --- Helper Methoden --- - private static String hmacSHA256(String data, String key, Charset charset) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(key.getBytes(charset), "HmacSHA256")); - byte[] raw = mac.doFinal(data.getBytes(charset)); - StringBuilder sb = new StringBuilder(); - for (byte b : raw) sb.append(String.format("%02x", b)); - return sb.toString(); - } - - private static String streamToString(InputStream in, Charset charset) throws IOException { - if (in == null) return ""; - try (BufferedReader br = new BufferedReader(new InputStreamReader(in, charset))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) sb.append(line); - return sb.toString(); - } - } - - private static String escapeJson(String s) { - return s.replace("\\", "\\\\").replace("\"","\\\"").replace("\n","\\n").replace("\r","\\r"); - } -} \ No newline at end of file +package net.viper.status.modules.verify; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.module.Module; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * VerifyModule: Multi-Server Support. + * + * Fix #7: Servernamen werden jetzt case-insensitiv verglichen. + * Keys in serverConfigs werden beim Laden auf lowercase normalisiert + * und die Suche erfolgt ebenfalls lowercase. + */ +public class VerifyModule implements Module { + + private String wpVerifyUrl; + // Keys sind lowercase normalisiert für case-insensitiven Vergleich + private final Map serverConfigs = new HashMap<>(); + + @Override + public String getName() { return "VerifyModule"; } + + @Override + public void onEnable(Plugin plugin) { + loadConfig(plugin); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new VerifyCommand()); + plugin.getLogger().info("VerifyModule aktiviert. " + serverConfigs.size() + " Server-Konfigurationen geladen."); + } + + @Override + public void onDisable(Plugin plugin) {} + + private void loadConfig(Plugin plugin) { + String fileName = "verify.properties"; + File configFile = new File(plugin.getDataFolder(), fileName); + Properties props = new Properties(); + + if (!configFile.exists()) { + plugin.getDataFolder().mkdirs(); + try (InputStream in = plugin.getResourceAsStream(fileName); + OutputStream out = new FileOutputStream(configFile)) { + if (in == null) { plugin.getLogger().warning("Standard-config '" + fileName + "' nicht in JAR."); return; } + byte[] buffer = new byte[1024]; int length; + while ((length = in.read(buffer)) > 0) out.write(buffer, 0, length); + plugin.getLogger().info("Konfigurationsdatei '" + fileName + "' erstellt."); + } catch (Exception e) { plugin.getLogger().severe("Fehler beim Erstellen der Config: " + e.getMessage()); return; } + } + + try (InputStream in = new FileInputStream(configFile)) { + props.load(in); + } catch (IOException e) { e.printStackTrace(); return; } + + this.wpVerifyUrl = props.getProperty("wp_verify_url", "https://deine-wp-domain.tld"); + + // FIX #7: Keys beim Laden auf lowercase normalisieren + this.serverConfigs.clear(); + for (String key : props.stringPropertyNames()) { + if (key.startsWith("server.")) { + String[] parts = key.split("\\."); + if (parts.length == 3) { + // Servername lowercase → case-insensitiver Lookup + String serverName = parts[1].toLowerCase(); + String type = parts[2]; + ServerConfig config = serverConfigs.computeIfAbsent(serverName, k -> new ServerConfig()); + if ("id".equalsIgnoreCase(type)) { + try { config.serverId = Integer.parseInt(props.getProperty(key)); } + catch (NumberFormatException e) { plugin.getLogger().warning("Ungültige Server ID für " + serverName); } + } else if ("secret".equalsIgnoreCase(type)) { + config.sharedSecret = props.getProperty(key); + } + } + } + } + } + + private static class ServerConfig { + int serverId = 0; + String sharedSecret = ""; + } + + private class VerifyCommand extends Command { + public VerifyCommand() { super("verify"); } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(ChatColor.RED + "Nur Spieler können diesen Befehl benutzen."); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + if (args.length != 1) { p.sendMessage(ChatColor.YELLOW + "Benutzung: /verify "); return; } + + // FIX #7: Servername lowercase für case-insensitiven Lookup + String serverName = p.getServer().getInfo().getName().toLowerCase(); + ServerConfig config = serverConfigs.get(serverName); + + if (config == null || config.serverId == 0 || config.sharedSecret.isEmpty()) { + p.sendMessage(ChatColor.RED + "✗ Dieser Server ist nicht in der Verify-Konfiguration hinterlegt."); + p.sendMessage(ChatColor.GRAY + "Aktueller Servername: " + ChatColor.WHITE + p.getServer().getInfo().getName()); + p.sendMessage(ChatColor.GRAY + "Bitte kontaktiere einen Admin."); + return; + } + + String token = args[0].trim(); + String playerName = p.getName(); + + HttpURLConnection conn = null; + try { + Charset utf8 = Charset.forName("UTF-8"); + String signature = hmacSHA256(playerName + token, config.sharedSecret, utf8); + String payload = "{\"player\":\"" + escapeJson(playerName) + + "\",\"token\":\"" + escapeJson(token) + + "\",\"server_id\":" + config.serverId + + ",\"signature\":\"" + signature + "\"}"; + + URL url = new URL(wpVerifyUrl + "/wp-json/mc-gallery/v1/verify"); + conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5000); + conn.setReadTimeout(7000); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + try (OutputStream os = conn.getOutputStream()) { os.write(payload.getBytes(utf8)); } + + int code = conn.getResponseCode(); + String resp = code >= 200 && code < 300 + ? streamToString(conn.getInputStream(), utf8) + : streamToString(conn.getErrorStream(), utf8); + + if (resp != null && !resp.isEmpty() && resp.trim().startsWith("{")) { + boolean isSuccess = resp.contains("\"success\":true"); + String message = "Ein unbekannter Fehler ist aufgetreten."; + int keyIndex = resp.indexOf("\"message\":\""); + if (keyIndex != -1) { + int startIndex = keyIndex + 11; + int endIndex = resp.indexOf("\"", startIndex); + if (endIndex != -1) message = resp.substring(startIndex, endIndex); + } + if (isSuccess) { + p.sendMessage(ChatColor.GREEN + "✓ " + message); + p.sendMessage(ChatColor.GRAY + "Du kannst nun Bilder hochladen!"); + } else { + p.sendMessage(ChatColor.RED + "✗ " + message); + } + } else { + p.sendMessage(ChatColor.RED + "✗ Fehler beim Verbinden mit der Webseite (Code: " + code + ")"); + } + } catch (Exception ex) { + p.sendMessage(ChatColor.RED + "✗ Ein interner Fehler ist aufgetreten."); + ProxyServer.getInstance().getLogger().warning("Verify error: " + ex.getMessage()); + ex.printStackTrace(); + } finally { + if (conn != null) conn.disconnect(); + } + } + } + + private static String hmacSHA256(String data, String key, Charset charset) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(charset), "HmacSHA256")); + byte[] raw = mac.doFinal(data.getBytes(charset)); + StringBuilder sb = new StringBuilder(); + for (byte b : raw) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + private static String streamToString(InputStream in, Charset charset) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, charset))) { + StringBuilder sb = new StringBuilder(); String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } +} diff --git a/src/main/java/net/viper/status/stats/PlayerStats.java b/src/main/java/net/viper/status/stats/PlayerStats.java index 9ea857d..5395999 100644 --- a/src/main/java/net/viper/status/stats/PlayerStats.java +++ b/src/main/java/net/viper/status/stats/PlayerStats.java @@ -11,6 +11,20 @@ public class PlayerStats { public long currentSessionStart; public int joins; + // Economy + public double balance; + public double totalEarned; + public double totalSpent; + public int transactionsCount; + + // Punishments + public int bansCount; + public int mutesCount; + public int warnsCount; + public long lastPunishmentAt; // Unix-Timestamp (Sek.), 0 = nie + public String lastPunishmentType; // "ban", "mute", "warn", "kick", "" = nie + public int punishmentScore; + public PlayerStats(UUID uuid, String name) { this.uuid = uuid; this.name = name; @@ -20,6 +34,16 @@ public class PlayerStats { this.totalPlaytime = 0; this.currentSessionStart = 0; this.joins = 0; + this.balance = 0.0; + this.totalEarned = 0.0; + this.totalSpent = 0.0; + this.transactionsCount = 0; + this.bansCount = 0; + this.mutesCount = 0; + this.warnsCount = 0; + this.lastPunishmentAt = 0; + this.lastPunishmentType = ""; + this.punishmentScore = 0; } public synchronized void onJoin() { @@ -47,7 +71,10 @@ public class PlayerStats { } public synchronized String toLine() { - return uuid + "|" + name.replace("|", "_") + "|" + firstSeen + "|" + lastSeen + "|" + totalPlaytime + "|" + currentSessionStart + "|" + joins; + String safeType = (lastPunishmentType == null ? "" : lastPunishmentType).replace("|", "_"); + return uuid + "|" + name.replace("|", "_") + "|" + firstSeen + "|" + lastSeen + "|" + totalPlaytime + "|" + currentSessionStart + "|" + joins + + "|" + balance + "|" + totalEarned + "|" + totalSpent + "|" + transactionsCount + + "|" + bansCount + "|" + mutesCount + "|" + warnsCount + "|" + lastPunishmentAt + "|" + safeType + "|" + punishmentScore; } public static PlayerStats fromLine(String line) { @@ -62,6 +89,22 @@ public class PlayerStats { ps.totalPlaytime = Long.parseLong(parts[4]); ps.currentSessionStart = Long.parseLong(parts[5]); ps.joins = Integer.parseInt(parts[6]); + // Economy (felder 7-10) + if (parts.length >= 11) { + try { ps.balance = Double.parseDouble(parts[7]); } catch (Exception ignored) {} + try { ps.totalEarned = Double.parseDouble(parts[8]); } catch (Exception ignored) {} + try { ps.totalSpent = Double.parseDouble(parts[9]); } catch (Exception ignored) {} + try { ps.transactionsCount = Integer.parseInt(parts[10]); } catch (Exception ignored) {} + } + // Punishments (felder 11-16) + if (parts.length >= 17) { + try { ps.bansCount = Integer.parseInt(parts[11]); } catch (Exception ignored) {} + try { ps.mutesCount = Integer.parseInt(parts[12]); } catch (Exception ignored) {} + try { ps.warnsCount = Integer.parseInt(parts[13]); } catch (Exception ignored) {} + try { ps.lastPunishmentAt = Long.parseLong(parts[14]); } catch (Exception ignored) {} + ps.lastPunishmentType = parts[15]; + try { ps.punishmentScore = Integer.parseInt(parts[16]); } catch (Exception ignored) {} + } return ps; } catch (Exception e) { return null; diff --git a/src/main/java/net/viper/status/stats/StatsModule.java b/src/main/java/net/viper/status/stats/StatsModule.java index 3744f67..52ce3b6 100644 --- a/src/main/java/net/viper/status/stats/StatsModule.java +++ b/src/main/java/net/viper/status/stats/StatsModule.java @@ -32,7 +32,6 @@ public class StatsModule implements Module, Listener { // Laden try { storage.load(manager); - plugin.getLogger().info("Player-Stats wurden erfolgreich geladen."); } catch (Exception e) { plugin.getLogger().warning("Fehler beim Laden der Stats: " + e.getMessage()); } @@ -44,7 +43,6 @@ public class StatsModule implements Module, Listener { plugin.getProxy().getScheduler().schedule(plugin, () -> { try { storage.save(manager); - plugin.getLogger().info("Auto-Save: Player-Stats gespeichert."); } catch (Exception e) { plugin.getLogger().warning("Fehler beim Auto-Save: " + e.getMessage()); } @@ -69,7 +67,6 @@ public class StatsModule implements Module, Listener { } try { storage.save(manager); - plugin.getLogger().info("Player-Stats beim Shutdown gespeichert."); } catch (Exception e) { plugin.getLogger().warning("Fehler beim Speichern (Shutdown): " + e.getMessage()); } diff --git a/src/main/java/net/viper/status/stats/StatsStorage.java b/src/main/java/net/viper/status/stats/StatsStorage.java index 86e649d..7913271 100644 --- a/src/main/java/net/viper/status/stats/StatsStorage.java +++ b/src/main/java/net/viper/status/stats/StatsStorage.java @@ -1,37 +1,46 @@ -package net.viper.status.stats; - -import java.io.*; - -public class StatsStorage { - private final File file; - - public StatsStorage(File pluginFolder) { - if (!pluginFolder.exists()) pluginFolder.mkdirs(); - this.file = new File(pluginFolder, "stats.dat"); - } - - public void save(StatsManager manager) { - try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { - for (PlayerStats ps : manager.all()) { - bw.write(ps.toLine()); - bw.newLine(); - } - bw.flush(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public void load(StatsManager manager) { - if (!file.exists()) return; - try (BufferedReader br = new BufferedReader(new FileReader(file))) { - String line; - while ((line = br.readLine()) != null) { - PlayerStats ps = PlayerStats.fromLine(line); - if (ps != null) manager.put(ps); - } - } catch (IOException e) { - e.printStackTrace(); - } - } -} \ No newline at end of file +package net.viper.status.stats; + +import java.io.*; + +/** + * Fix #9: save() und load() sind jetzt synchronized um Race Conditions + * zwischen Auto-Save-Task und Shutdown-Aufruf zu verhindern. + */ +public class StatsStorage { + private final File file; + private final Object fileLock = new Object(); + + public StatsStorage(File pluginFolder) { + if (!pluginFolder.exists()) pluginFolder.mkdirs(); + this.file = new File(pluginFolder, "stats.dat"); + } + + public void save(StatsManager manager) { + synchronized (fileLock) { + try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { + for (PlayerStats ps : manager.all()) { + bw.write(ps.toLine()); + bw.newLine(); + } + bw.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + public void load(StatsManager manager) { + if (!file.exists()) return; + synchronized (fileLock) { + try (BufferedReader br = new BufferedReader(new FileReader(file))) { + String line; + while ((line = br.readLine()) != null) { + PlayerStats ps = PlayerStats.fromLine(line); + if (ps != null) manager.put(ps); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } +} diff --git a/src/main/resources/chat.yml b/src/main/resources/chat.yml index 0238c6d..6fe2fcd 100644 --- a/src/main/resources/chat.yml +++ b/src/main/resources/chat.yml @@ -135,6 +135,30 @@ private-messages: format-social-spy: "&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}" social-spy-permission: "chat.socialspy" +# ============================================================ +# JOIN / LEAVE NACHRICHTEN +# Platzhalter: +# {player} - Spielername +# {prefix} - LuckPerms Prefix +# {suffix} - LuckPerms Suffix +# {server} - Zuletzt bekannter Server (bei Leave) oder "Netzwerk" +# ============================================================ +join-leave: + enabled: true + # Normale Join/Leave-Nachrichten (für alle sichtbar) + join-format: "&8[&a+&8] {prefix}&a{player}&r &7hat das Netzwerk betreten." + leave-format: "&8[&c-&8] {prefix}&c{player}&r &7hat das Netzwerk verlassen." + # Vanish: Unsichtbare Spieler erzeugen keine normalen Join/Leave-Meldungen. + # Ist vanish-show-to-admins true, sehen Admins mit bypass-permission eine + # abweichende, dezente Benachrichtigung. + vanish-show-to-admins: true + vanish-join-format: "&8[&7+&8] &8{player} &7hat das Netzwerk betreten. &8(Vanish)" + vanish-leave-format: "&8[&7-&8] &8{player} &7hat das Netzwerk verlassen. &8(Vanish)" + # Brücken-Weitergabe (leer = deaktiviert) + discord-webhook: "" + telegram-chat-id: "" + telegram-thread-id: 0 + # ============================================================ # GLOBALES RATE-LIMIT-FRAMEWORK # Zentraler Schutz für Chat/PM/Command-Flood. @@ -304,6 +328,36 @@ chat-filter: min-length: 6 max-percent: 70 + anti-ad: + enabled: true + message: "&cWerbung ist in diesem Chat nicht erlaubt!" + # Domains/Substrings die NICHT geblockt werden (z.B. eigene Serveradresse) + # Vergleich ist case-insensitiv und prüft ob der Substring im Match enthalten ist + whitelist: + - "viper-network.de" + - "m-viper.de" + - "https://www.spigotmc.org" + # TLDs die als Werbung gewertet werden. + # Leer = alle Domain-Treffer blockieren (nicht empfohlen, hohe False-Positive-Rate) + blocked-tlds: + - "net" + - "com" + - "de" + - "org" + - "gg" + - "io" + - "eu" + - "tv" + - "xyz" + - "info" + - "me" + - "cc" + - "co" + - "app" + - "online" + - "site" + - "fun" + # ============================================================ # MENTIONS (@Spielername) # ============================================================ diff --git a/src/main/resources/filter.yml b/src/main/resources/filter.yml index e69de29..f4f6e85 100644 --- a/src/main/resources/filter.yml +++ b/src/main/resources/filter.yml @@ -0,0 +1,16 @@ +# ============================================================ +# StatusAPI - ChatModule Wort-Blacklist +# Wörter werden case-insensitiv und als Teilwort geprüft. +# Erkannte Wörter werden durch **** ersetzt. +# +# Diese Datei wird bei /chatreload automatisch neu eingelesen. +# Wörter die hier stehen ÜBERSCHREIBEN NICHT die Einträge in +# chat.yml → beide Listen werden zusammengeführt. +# ============================================================ + +words: + - beispielwort1 + - beispielwort2 + # Hier eigene Wörter eintragen, eines pro Zeile: + # - schimpfwort + # - spam \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b091468..40037c5 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,7 +1,265 @@ -name: StatusAPIBridge -version: 1.0.0 -main: net.viper.statusapibridge.StatusAPIBridge -api-version: 1.21 -description: Sendet Vault-Economy-Daten an die BungeeCord StatusAPI -authors: [Viper] -softdepend: [Vault] +name: StatusAPI +main: net.viper.status.StatusAPI +version: 4.1.0 +author: M_Viper +description: StatusAPI für BungeeCord inkl. Update-Checker, Modul-System und ChatModule +# Mindestanforderung: Minecraft 1.20 / BungeeCord mit PlayerChatEvent-Unterstützung + +softdepend: + - LuckPerms + - Geyser-BungeeCord + +commands: + # ── VanishModule ────────────────────────────────────────── + vanish: + description: Vanish ein-/ausschalten + usage: /vanish [Spieler] + aliases: [v] + + vanishlist: + description: Alle unsichtbaren Spieler anzeigen + usage: /vanishlist + aliases: [vlist] + + # ── Verify Modul ────────────────────────────────────────── + verify: + description: Verifiziere dich mit einem Token + usage: /verify + + # ── ForumBridge Modul ───────────────────────────────────── + forumlink: + description: Verknüpfe deinen Minecraft-Account mit dem Forum + usage: /forumlink + aliases: [fl] + + forum: + description: Zeigt ausstehende Forum-Benachrichtigungen an + usage: /forum + + # ── NetworkInfo Modul ───────────────────────────────────── + netinfo: + description: Zeigt erweiterte Proxy- und Systeminfos an + usage: /netinfo + + antibot: + description: Zeigt AntiBot-Status und Verwaltung + usage: /antibot + + # ── AutoMessage Modul ───────────────────────────────────── + automessage: + description: AutoMessage Verwaltung + usage: /automessage reload + + # ── ChatModule – Kanal ──────────────────────────────────── + channel: + description: Kanal wechseln oder Kanalliste anzeigen + usage: /channel [kanalname] + aliases: [ch, kanal] + + # ── ChatModule – HelpOp ─────────────────────────────────── + helpop: + description: Sende eine Hilfeanfrage an das Team + usage: /helpop + + # ── ChatModule – Privat-Nachrichten ─────────────────────── + msg: + description: Sende eine private Nachricht + usage: /msg + aliases: [tell, w, whisper] + + r: + description: Antworte auf die letzte private Nachricht + usage: /r + aliases: [reply, antwort] + + # ── ChatModule – Blockieren ─────────────────────────────── + ignore: + description: Spieler ignorieren + usage: /ignore + aliases: [block] + + unignore: + description: Spieler nicht mehr ignorieren + usage: /unignore + aliases: [unblock] + + # ── ChatModule – Mute (Admin) ───────────────────────────── + chatmute: + description: Spieler im Chat stumm schalten + usage: /chatmute [Minuten] + aliases: [gmute] + + chatunmute: + description: Chat-Stummschaltung aufheben + usage: /chatunmute + aliases: [gunmute] + + # ── ChatModule – Selbst-Mute ────────────────────────────── + chataus: + description: Eigenen Chat-Empfang ein-/ausschalten + usage: /chataus + aliases: [togglechat, chaton, chatoff] + + # ── ChatModule – Broadcast ──────────────────────────────── + broadcast: + description: Nachricht an alle Spieler senden + usage: /broadcast + aliases: [bc, alert] + + # ── ChatModule – Emoji ──────────────────────────────────── + emoji: + description: Liste aller verfügbaren Emojis + usage: /emoji + aliases: [emojis] + + # ── ChatModule – Social Spy ─────────────────────────────── + socialspy: + description: Private Nachrichten mitlesen (Admin) + usage: /socialspy + aliases: [spy] + + # ── ChatModule – Reload ─────────────────────────────────── + chatreload: + description: Chat-Konfiguration neu laden + usage: /chatreload + + # ── ChatModule – Admin-Info ─────────────────────────────── + chatinfo: + description: Chat-Informationen ueber einen Spieler anzeigen (Admin) + usage: /chatinfo + + # ── ChatModule – Chat-History ───────────────────────────── + chathist: + description: Chat-History aus dem Logfile anzeigen (Admin) + usage: /chathist [Spieler] [Anzahl] + + # ── ChatModule – Mentions ───────────────────────────────── + mentions: + description: Mention-Benachrichtigungen ein-/ausschalten + usage: /mentions + aliases: [mention] + + # ── ChatModule – Plugin-Bypass ──────────────────────────── + chatbypass: + description: ChatModule fuer naechste Eingabe ueberspringen (fuer Plugin-Dialoge wie CMI) + usage: /chatbypass + aliases: [cbp] + + # ── ChatModule – Account-Verknuepfung ───────────────────── + # FIX #4: Command-Namen stimmen jetzt mit der Code-Registrierung überein. + # Im ChatModule wird "discordlink" mit Alias "dlink" registriert, + # und "telegramlink" mit Alias "tlink". + discordlink: + description: Minecraft-Account mit Discord verknuepfen + usage: /discordlink + aliases: [dlink] + + telegramlink: + description: Minecraft-Account mit Telegram verknuepfen + usage: /telegramlink + aliases: [tlink] + + unlink: + description: Account-Verknuepfung aufheben + usage: /unlink + + # ── ChatModule – Report ─────────────────────────────────── + report: + description: Spieler melden + usage: /report + + reports: + description: Offene Reports anzeigen (Admin) + usage: /reports [all] + + reportclose: + description: Report schliessen (Admin) + usage: /reportclose + +permissions: + # ── StatusAPI Core ──────────────────────────────────────── + statusapi.update.notify: + description: Erlaubt Update-Benachrichtigungen + default: op + + statusapi.netinfo: + description: Zugriff auf /netinfo + default: op + + statusapi.antibot: + description: Zugriff auf /antibot + default: op + + statusapi.automessage: + description: Zugriff auf /automessage reload + default: op + + # ── ChatModule – Kanaele ────────────────────────────────── + chat.channel.local: + description: Zugang zum Local-Kanal + default: true + + chat.channel.trade: + description: Zugang zum Trade-Kanal + default: true + + chat.channel.staff: + description: Zugang zum Staff-Kanal + default: false + + # ── ChatModule – HelpOp ─────────────────────────────────── + chat.helpop.receive: + description: HelpOp-Nachrichten empfangen + default: false + + # ── ChatModule – Mute ───────────────────────────────────── + chat.mute: + description: Spieler muten / unmuten + default: false + + # ── ChatModule – Broadcast ──────────────────────────────── + chat.broadcast: + description: Broadcast-Nachrichten senden + default: false + + # ── ChatModule – Social Spy ─────────────────────────────── + chat.socialspy: + description: Private Nachrichten mitlesen + default: false + + # ── ChatModule – Admin ──────────────────────────────────── + chat.admin.bypass: + description: Admin-Bypass - Kann nicht geblockt/gemutet werden + default: op + + chat.admin.notify: + description: Benachrichtigungen ueber Mutes und Blocks erhalten + default: false + + # ── ChatModule – Report ─────────────────────────────────── + chat.report: + description: Spieler reporten (/report) + default: true + + # ── ChatModule – Farben ─────────────────────────────────── + chat.color: + description: Farbcodes (&a, &b, ...) im Chat nutzen + default: false + + chat.color.format: + description: Formatierungen (&l, &o, &n, ...) im Chat nutzen + default: false + + # ── ChatModule – Filter ─────────────────────────────────── + chat.filter.bypass: + description: Chat-Filter (Anti-Spam, Caps, Blacklist) umgehen + default: false + + # ── CommandBlocker ──────────────────────────────────────── + commandblocker.bypass: + description: Command-Blocker umgehen + default: op + + commandblocker.admin: + description: CommandBlocker verwalten (/cb) + default: op diff --git a/src/main/resources/verify.properties b/src/main/resources/verify.properties index 6314afe..434cac8 100644 --- a/src/main/resources/verify.properties +++ b/src/main/resources/verify.properties @@ -17,6 +17,8 @@ broadcast.format=%prefixColored% %messageColored% # =========================== statusapi.port=9191 + + # =========================== # WORDPRESS / VERIFY EINSTELLUNGEN # ===========================