Upload via GUI (60 Dateien)
This commit is contained in:
@@ -110,6 +110,27 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
|
||||
mergeVerifyConfig();
|
||||
|
||||
// Bestehende network-guard.properties um neue Optionen ergaenzen (vorhandene Werte bleiben unveraendert)
|
||||
net.viper.status.util.ConfigMigrator.migrateProperties(
|
||||
new File(getDataFolder(), "network-guard.properties"),
|
||||
new String[][] {
|
||||
{"api.write_key", "",
|
||||
"API-Key fuer schreibende Endpunkte (/economy/update, /stats/update, /scoreboard/*, /player/* ...).\nWird als Header X-API-Key erwartet. Leer = KEINE Pruefung. Muss identisch zu api-key in den Bridges sein."},
|
||||
{"api.securitylog.key", "",
|
||||
"Optionaler Key fuer GET /antibot/security-log (Namen, UUIDs, IPs). Leer = offen."},
|
||||
{"api.ratelimit.enabled", "true", "Rate-Limit pro Client-IP fuer den HTTP-Server"},
|
||||
{"api.ratelimit.max_per_minute", "300", null},
|
||||
{"api.ratelimit.block_seconds", "30", null},
|
||||
{"api.ratelimit.exempt_private", "true",
|
||||
"Anfragen mit gueltigem api.write_key und Clients aus Loopback-/Privatnetzen sind nie rate-limitiert.\nHinter einem Reverse-Proxy (z. B. NPM) auf false setzen und api.write_key nutzen."},
|
||||
{"antibot.whitelist.ips", "",
|
||||
"AntiBot-Whitelist (kommagetrennt, auch per /antibot whitelist add|remove|list). IPs/CIDR, z. B. 203.0.113.5 oder 10.0.0.0/24."},
|
||||
{"antibot.whitelist.players", "",
|
||||
"Spielernamen: nach erfolgreichem Login wird die IP des Spielers fuer trust_hours vertraut."},
|
||||
{"antibot.whitelist.trust_hours", "24", null},
|
||||
},
|
||||
getLogger());
|
||||
|
||||
// Port aus verify.properties lesen
|
||||
String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191";
|
||||
try {
|
||||
@@ -197,11 +218,20 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
|
||||
// WebServer starten
|
||||
shuttingDown = false;
|
||||
requestExecutor = Executors.newFixedThreadPool(4, r -> {
|
||||
Thread t = new Thread(r, "StatusAPI-HTTP-Worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
// Begrenzte Warteschlange: bei Ueberlast werden Verbindungen sofort geschlossen
|
||||
// (RejectedExecutionException in submitConnection), statt unbegrenzt zu stauen.
|
||||
requestExecutor = new java.util.concurrent.ThreadPoolExecutor(
|
||||
8, 8, 0L, TimeUnit.MILLISECONDS,
|
||||
new java.util.concurrent.LinkedBlockingQueue<Runnable>(64),
|
||||
r -> {
|
||||
Thread t = new Thread(r, "StatusAPI-HTTP-Worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
},
|
||||
new java.util.concurrent.ThreadPoolExecutor.AbortPolicy());
|
||||
if (getApiWriteKey().isEmpty()) {
|
||||
getLogger().warning("[StatusAPI] api.write_key ist leer: schreibende Endpunkte (/economy/update, /stats/update, ...) sind OHNE Authentifizierung erreichbar! Bitte in network-guard.properties setzen.");
|
||||
}
|
||||
startHttpServerThread();
|
||||
httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule(
|
||||
this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS);
|
||||
@@ -488,6 +518,11 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
if (inputLine == null) return;
|
||||
lastHttpRequestAt.set(System.currentTimeMillis());
|
||||
|
||||
if (inputLine.length() > 2048) {
|
||||
sendHttpResponse(out, "{\"success\":false,\"error\":\"uri_too_long\"}", 400);
|
||||
return;
|
||||
}
|
||||
|
||||
String[] reqParts = inputLine.split(" ");
|
||||
if (reqParts.length < 2) return;
|
||||
String method = reqParts[0].trim();
|
||||
@@ -504,19 +539,15 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /antibot/security-log
|
||||
if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("success", true);
|
||||
payload.put("events", loadAntiBotSecurityEvents(250));
|
||||
sendHttpResponse(out, buildJsonString(payload), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// Headers lesen
|
||||
// Headers lesen (max. 64 Header, je max. 8 KB)
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
String line;
|
||||
int headerCount = 0;
|
||||
while ((line = in.readLine()) != null && !line.isEmpty()) {
|
||||
if (++headerCount > 64 || line.length() > 8192) {
|
||||
sendHttpResponse(out, "{\"success\":false,\"error\":\"headers_too_large\"}", 400);
|
||||
return;
|
||||
}
|
||||
int idx = line.indexOf(':');
|
||||
if (idx > 0) {
|
||||
headers.put(line.substring(0, idx).trim().toLowerCase(Locale.ROOT),
|
||||
@@ -524,12 +555,61 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-Limit pro Client-IP (schuetzt vor Flooding des HTTP-Servers).
|
||||
// Ausgenommen: Anfragen mit gueltigem api.write_key sowie Loopback-/Privatnetz-Clients
|
||||
// (Bridges der Unterserver senden sehr viele Updates pro Sekunde und duerfen nicht gedrosselt werden,
|
||||
// sonst zeigen Scoreboard/Tablist veraltete Werte).
|
||||
String clientIp = "unknown";
|
||||
boolean trustedClient = false;
|
||||
if (clientSocket.getInetAddress() != null) {
|
||||
java.net.InetAddress ca = clientSocket.getInetAddress();
|
||||
clientIp = ca.getHostAddress();
|
||||
boolean exemptPrivate = Boolean.parseBoolean(
|
||||
getApiSettings().getProperty("api.ratelimit.exempt_private", "true").trim());
|
||||
trustedClient = exemptPrivate
|
||||
&& (ca.isLoopbackAddress() || ca.isSiteLocalAddress() || ca.isLinkLocalAddress());
|
||||
}
|
||||
String rlWriteKey = getApiWriteKey();
|
||||
if (!rlWriteKey.isEmpty() && safeEquals(rlWriteKey, headerApiKey(headers))) trustedClient = true;
|
||||
if (!trustedClient) {
|
||||
net.viper.status.ratelimit.GlobalRateLimitFramework.Result rl =
|
||||
net.viper.status.ratelimit.GlobalRateLimitFramework.getInstance()
|
||||
.check("http-api", clientIp, getApiRateRule());
|
||||
if (rl.isBlocked()) {
|
||||
sendHttpResponse(out, "{\"success\":false,\"error\":\"rate_limited\"}", 429);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Schreibende Endpunkte ohne eigenen Key: zentrale Pruefung (api.write_key)
|
||||
if ("POST".equalsIgnoreCase(method) && requiresWriteKey(pathOnly)) {
|
||||
String writeKey = getApiWriteKey();
|
||||
if (!writeKey.isEmpty() && !safeEquals(writeKey, headerApiKey(headers))) {
|
||||
sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /antibot/security-log (optional per api.securitylog.key geschuetzt)
|
||||
if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) {
|
||||
String logKey = getApiSettings().getProperty("api.securitylog.key", "").trim();
|
||||
if (!logKey.isEmpty() && !safeEquals(logKey, headerApiKey(headers))) {
|
||||
sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("success", true);
|
||||
payload.put("events", loadAntiBotSecurityEvents(250));
|
||||
sendHttpResponse(out, buildJsonString(payload), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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())) {
|
||||
if (!requiredApiKey.isEmpty() && !safeEquals(requiredApiKey, providedApiKey == null ? "" : providedApiKey.trim())) {
|
||||
sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403);
|
||||
return;
|
||||
}
|
||||
@@ -1244,7 +1324,30 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
return new String(bodyChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest einen Top-Level-Wert aus einem JSON-Objekt (Gson). Schluessel in verschachtelten
|
||||
* Objekten oder innerhalb von Strings werden nicht mehr faelschlich gefunden.
|
||||
* Faellt bei ungueltigem JSON auf den alten Text-Parser zurueck (Kompatibilitaet).
|
||||
*/
|
||||
private String extractJsonString(String json, String key) {
|
||||
if (json == null || key == null) return null;
|
||||
try {
|
||||
com.google.gson.JsonElement root = com.google.gson.JsonParser.parseString(json);
|
||||
if (root != null && root.isJsonObject()) {
|
||||
com.google.gson.JsonObject obj = root.getAsJsonObject();
|
||||
if (!obj.has(key)) return null;
|
||||
com.google.gson.JsonElement el = obj.get(key);
|
||||
if (el == null || el.isJsonNull()) return null;
|
||||
if (el.isJsonPrimitive()) return el.getAsString().trim();
|
||||
return el.toString();
|
||||
}
|
||||
} catch (Exception | LinkageError ignored) {
|
||||
// kein gueltiges JSON (oder aeltere Gson-Version) -> Legacy-Parser
|
||||
}
|
||||
return extractJsonStringLegacy(json, key);
|
||||
}
|
||||
|
||||
private String extractJsonStringLegacy(String json, String key) {
|
||||
if (json == null || key == null) return null;
|
||||
String search = "\"" + key + "\"";
|
||||
int idx = json.indexOf(search);
|
||||
@@ -1304,6 +1407,59 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-Auth / Rate-Limit Helfer ──────────────────────────────────────────
|
||||
|
||||
private volatile Properties apiSettingsCache = null;
|
||||
private volatile long apiSettingsLoadedAt = 0L;
|
||||
|
||||
/** network-guard.properties mit 10s Cache (nicht bei jedem Request von Platte lesen). */
|
||||
private Properties getApiSettings() {
|
||||
long now = System.currentTimeMillis();
|
||||
Properties cached = apiSettingsCache;
|
||||
if (cached != null && now - apiSettingsLoadedAt < 10_000L) return cached;
|
||||
Properties fresh = loadNetworkGuardProperties();
|
||||
apiSettingsCache = fresh;
|
||||
apiSettingsLoadedAt = now;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private String getApiWriteKey() {
|
||||
return getApiSettings().getProperty("api.write_key", "").trim();
|
||||
}
|
||||
|
||||
private net.viper.status.ratelimit.GlobalRateLimitFramework.Rule getApiRateRule() {
|
||||
Properties p = getApiSettings();
|
||||
boolean enabled = Boolean.parseBoolean(p.getProperty("api.ratelimit.enabled", "true").trim());
|
||||
int max = 300;
|
||||
long blockSec = 30;
|
||||
try { max = Integer.parseInt(p.getProperty("api.ratelimit.max_per_minute", "300").trim()); } catch (NumberFormatException ignored) {}
|
||||
try { blockSec = Long.parseLong(p.getProperty("api.ratelimit.block_seconds", "30").trim()); } catch (NumberFormatException ignored) {}
|
||||
return new net.viper.status.ratelimit.GlobalRateLimitFramework.Rule(enabled, 60_000L, max, blockSec * 1000L);
|
||||
}
|
||||
|
||||
/** Endpunkte, die Daten veraendern und keinen eigenen API-Key haben. */
|
||||
private boolean requiresWriteKey(String pathOnly) {
|
||||
if (pathOnly == null) return false;
|
||||
String p = pathOnly.toLowerCase(Locale.ROOT);
|
||||
return p.equals("/economy/update") || p.equals("/punishment/update") || p.equals("/stats/update")
|
||||
|| p.equals("/ticket/update") || p.equals("/broadcast/cancel") || p.equals("/cancel")
|
||||
|| p.startsWith("/scoreboard/") || p.startsWith("/player/");
|
||||
}
|
||||
|
||||
private static String headerApiKey(Map<String, String> headers) {
|
||||
String v = headers.get("x-api-key");
|
||||
if (v == null) v = headers.get("x-apikey");
|
||||
return v == null ? "" : v.trim();
|
||||
}
|
||||
|
||||
/** Konstantzeit-Vergleich (verhindert Timing-Angriffe auf API-Keys). */
|
||||
public static boolean safeEquals(String expected, String provided) {
|
||||
if (expected == null || provided == null) return false;
|
||||
return java.security.MessageDigest.isEqual(
|
||||
expected.getBytes(StandardCharsets.UTF_8),
|
||||
provided.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private Properties loadNetworkGuardProperties() {
|
||||
Properties props = new Properties();
|
||||
File file = new File(getDataFolder(), "network-guard.properties");
|
||||
@@ -1358,7 +1514,7 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
|
||||
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");
|
||||
String status = code == 200 ? "OK" : (code == 403 ? "Forbidden" : code == 404 ? "Not Found" : code == 400 ? "Bad Request" : code == 429 ? "Too Many Requests" : "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");
|
||||
@@ -1409,12 +1565,29 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
|
||||
}
|
||||
|
||||
/** Liest nur die letzten maxBytes einer Datei (statt die ganze, ggf. riesige Datei zu laden). */
|
||||
private List<String> readTailLines(File file, int maxBytes) throws IOException {
|
||||
try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(file, "r")) {
|
||||
long len = raf.length();
|
||||
long start = Math.max(0L, len - maxBytes);
|
||||
raf.seek(start);
|
||||
byte[] buf = new byte[(int) (len - start)];
|
||||
raf.readFully(buf);
|
||||
String text = new String(buf, StandardCharsets.UTF_8);
|
||||
if (start > 0) {
|
||||
int nl = text.indexOf('\n');
|
||||
text = nl >= 0 ? text.substring(nl + 1) : "";
|
||||
}
|
||||
return new ArrayList<>(Arrays.asList(text.split("\\r?\\n")));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> loadAntiBotSecurityEvents(int maxEntries) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
File logFile = new File(getDataFolder(), "antibot-security.log");
|
||||
if (!logFile.exists() || maxEntries <= 0) return out;
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(logFile.toPath(), StandardCharsets.UTF_8);
|
||||
List<String> lines = readTailLines(logFile, 1024 * 1024);
|
||||
for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) {
|
||||
String line = lines.get(i);
|
||||
if (line == null || line.trim().isEmpty()) continue;
|
||||
@@ -1430,6 +1603,7 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
row.put("player", player);
|
||||
row.put("uuid", uuid);
|
||||
row.put("ip", parsed.getOrDefault("ip", "-"));
|
||||
row.put("event", event.trim());
|
||||
out.add(row);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -1455,7 +1629,9 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
||||
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");
|
||||
return e.equals("ip_rate_limit_block")
|
||||
|| e.equals("vpn_block")
|
||||
|| e.equals("learning_threshold_block");
|
||||
}
|
||||
|
||||
// ── PAPI-Token-Erkennung ──────────────────────────────────────────────────
|
||||
|
||||
@@ -102,6 +102,16 @@ public class AntiBotModule implements Module, Listener {
|
||||
private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L);
|
||||
private final Set<String> blockedIpsCurrentAttack = ConcurrentHashMap.newKeySet();
|
||||
|
||||
// Whitelist: Spielernamen (lowercase) und IP-Regeln (exakte IP oder CIDR).
|
||||
// Namen werden NICHT beim PreLogin vertraut (dort ungeprueft/faelschbar), sondern erst nach
|
||||
// erfolgreichem Login: dann wird die IP des Spielers temporaer als vertrauenswuerdig gemerkt.
|
||||
private final Set<String> whitelistNames = ConcurrentHashMap.newKeySet();
|
||||
private volatile List<String> whitelistIpRules = new ArrayList<>();
|
||||
private final Map<String, Long> trustedIpsUntil = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> lastBlockLogAt = new ConcurrentHashMap<>();
|
||||
private volatile int whitelistTrustHours = 24;
|
||||
private static final String BYPASS_PERMISSION = "statusapi.antibot.bypass";
|
||||
|
||||
private final Map<String, IpWindow> perIpWindows = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> blockedIpsUntil = new ConcurrentHashMap<>();
|
||||
private final Map<String, VpnCacheEntry> vpnCache = new ConcurrentHashMap<>();
|
||||
@@ -212,9 +222,20 @@ public class AntiBotModule implements Module, Listener {
|
||||
// FIX #3: cleanupExpired verwendet removeIf statt Iteration+remove
|
||||
cleanupExpired(now);
|
||||
|
||||
// Whitelist: vertrauenswuerdige IPs werden nie gezaehlt, gescored oder geblockt
|
||||
if (isWhitelistedIp(ip, now)) {
|
||||
blockedIpsUntil.remove(ip);
|
||||
return;
|
||||
}
|
||||
|
||||
Long blockedUntil = blockedIpsUntil.get(ip);
|
||||
if (blockedUntil != null && blockedUntil > now) {
|
||||
logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil);
|
||||
// Nur 1x pro Minute und IP loggen (verhindert Log-Flut bei Reconnect-Schleifen)
|
||||
Long lastLog = lastBlockLogAt.get(ip);
|
||||
if (lastLog == null || now - lastLog > 60_000L) {
|
||||
lastBlockLogAt.put(ip, now);
|
||||
logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil);
|
||||
}
|
||||
blockEvent(event);
|
||||
return;
|
||||
}
|
||||
@@ -323,7 +344,71 @@ public class AntiBotModule implements Module, Listener {
|
||||
ProxiedPlayer player = event.getPlayer();
|
||||
String ip = extractIpFromPlayer(player);
|
||||
if (ip == null || ip.isEmpty()) return;
|
||||
cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), System.currentTimeMillis());
|
||||
long now = System.currentTimeMillis();
|
||||
cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), now);
|
||||
|
||||
// Verifizierter Login von Whitelist-Spieler bzw. Bypass-Permission: IP temporaer vertrauen
|
||||
boolean trusted = (player.getName() != null && whitelistNames.contains(player.getName().toLowerCase(Locale.ROOT)))
|
||||
|| player.hasPermission(BYPASS_PERMISSION);
|
||||
if (trusted) {
|
||||
trustedIpsUntil.put(ip, now + Math.max(1, whitelistTrustHours) * 3_600_000L);
|
||||
blockedIpsUntil.remove(ip);
|
||||
learningProfiles.remove(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Whitelist ---
|
||||
|
||||
private boolean isWhitelistedIp(String ip, long now) {
|
||||
if (ip == null || ip.isEmpty()) return false;
|
||||
Long trustedUntil = trustedIpsUntil.get(ip);
|
||||
if (trustedUntil != null) {
|
||||
if (trustedUntil > now) return true;
|
||||
trustedIpsUntil.remove(ip);
|
||||
}
|
||||
for (String rule : whitelistIpRules) {
|
||||
if (ipMatchesRule(ip, rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isIpLiteral(String s) {
|
||||
return s != null && !s.isEmpty() && s.length() <= 45 && s.matches("[0-9a-fA-F:.]+");
|
||||
}
|
||||
|
||||
/** Exakte IP oder CIDR (IPv4/IPv6). Nutzt nur IP-Literale, nie DNS. */
|
||||
private boolean ipMatchesRule(String ip, String rule) {
|
||||
if (rule == null || rule.isEmpty()) return false;
|
||||
if (rule.equalsIgnoreCase(ip)) return true;
|
||||
int slash = rule.indexOf('/');
|
||||
if (slash < 0) return false;
|
||||
try {
|
||||
String net = rule.substring(0, slash);
|
||||
int bits = Integer.parseInt(rule.substring(slash + 1).trim());
|
||||
if (!isIpLiteral(net) || !isIpLiteral(ip)) return false;
|
||||
byte[] a = java.net.InetAddress.getByName(ip).getAddress();
|
||||
byte[] b = java.net.InetAddress.getByName(net).getAddress();
|
||||
if (a.length != b.length || bits < 0 || bits > a.length * 8) return false;
|
||||
int full = bits / 8, rest = bits % 8;
|
||||
for (int i = 0; i < full; i++) if (a[i] != b[i]) return false;
|
||||
if (rest == 0) return true;
|
||||
int mask = (0xFF << (8 - rest)) & 0xFF;
|
||||
return (a[full] & mask) == (b[full] & mask);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Eintrag ist IP/CIDR, wenn er nur aus IP-Zeichen (plus optional /Bits) besteht und einen Punkt oder Doppelpunkt enthaelt. */
|
||||
private static boolean looksLikeIpRule(String s) {
|
||||
return s.matches("[0-9a-fA-F:.]+(/\\d{1,3})?") && (s.indexOf('.') >= 0 || s.indexOf(':') >= 0);
|
||||
}
|
||||
|
||||
private void persistWhitelist() throws Exception {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
values.put("antibot.whitelist.players", String.join(",", new java.util.TreeSet<>(whitelistNames)));
|
||||
values.put("antibot.whitelist.ips", String.join(",", whitelistIpRules));
|
||||
updateConfigValues(values);
|
||||
}
|
||||
|
||||
private void blockEvent(PreLoginEvent event) {
|
||||
@@ -401,6 +486,8 @@ public class AntiBotModule implements Module, Listener {
|
||||
*/
|
||||
private void cleanupExpired(long now) {
|
||||
blockedIpsUntil.entrySet().removeIf(e -> e.getValue() <= now);
|
||||
trustedIpsUntil.entrySet().removeIf(e -> e.getValue() <= now);
|
||||
lastBlockLogAt.entrySet().removeIf(e -> now - e.getValue() > 120_000L);
|
||||
vpnCache.entrySet().removeIf(e -> e.getValue().expiresAt <= now);
|
||||
recentIdentityByIp.entrySet().removeIf(e -> {
|
||||
RecentPlayerIdentity id = e.getValue();
|
||||
@@ -503,6 +590,17 @@ public class AntiBotModule implements Module, Listener {
|
||||
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);
|
||||
|
||||
whitelistTrustHours = parseInt(props.getProperty("antibot.whitelist.trust_hours"), 24);
|
||||
whitelistNames.clear();
|
||||
for (String n : props.getProperty("antibot.whitelist.players", "").split(",")) {
|
||||
if (!n.trim().isEmpty()) whitelistNames.add(n.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
List<String> ipRules = new ArrayList<>();
|
||||
for (String r : props.getProperty("antibot.whitelist.ips", "").split(",")) {
|
||||
if (!r.trim().isEmpty()) ipRules.add(r.trim());
|
||||
}
|
||||
whitelistIpRules = ipRules;
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage());
|
||||
}
|
||||
@@ -692,6 +790,15 @@ public class AntiBotModule implements Module, Listener {
|
||||
+ " | details=" + safeLog(details);
|
||||
|
||||
synchronized (securityLogLock) {
|
||||
// Rotation: ab 5 MB wird die Datei zu <name>.1 (alte .1 wird ueberschrieben)
|
||||
try {
|
||||
if (securityLogFile.length() > 5L * 1024 * 1024) {
|
||||
File rotated = new File(securityLogFile.getParentFile(), securityLogFile.getName() + ".1");
|
||||
if (rotated.exists()) rotated.delete();
|
||||
securityLogFile.renameTo(rotated);
|
||||
securityLogFile.createNewFile();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) {
|
||||
bw.write(line); bw.newLine();
|
||||
} catch (Exception e) {
|
||||
@@ -868,6 +975,38 @@ public class AntiBotModule implements Module, Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("whitelist".equalsIgnoreCase(args[0])) {
|
||||
String sub = args.length >= 2 ? args[1].toLowerCase(Locale.ROOT) : "list";
|
||||
if ("list".equals(sub)) {
|
||||
sender.sendMessage(ChatColor.YELLOW + "Spieler: " + ChatColor.WHITE + (whitelistNames.isEmpty() ? "-" : String.join(", ", new java.util.TreeSet<>(whitelistNames))));
|
||||
sender.sendMessage(ChatColor.YELLOW + "IPs/CIDR: " + ChatColor.WHITE + (whitelistIpRules.isEmpty() ? "-" : String.join(", ", whitelistIpRules)));
|
||||
sender.sendMessage(ChatColor.GRAY + "Permission " + BYPASS_PERMISSION + " wirkt ebenfalls (nach Login).");
|
||||
return;
|
||||
}
|
||||
if (("add".equals(sub) || "remove".equals(sub)) && args.length >= 3) {
|
||||
String entry = args[2].trim();
|
||||
boolean isIp = looksLikeIpRule(entry);
|
||||
boolean add = "add".equals(sub);
|
||||
boolean changed;
|
||||
if (isIp) {
|
||||
List<String> copy = new ArrayList<>(whitelistIpRules);
|
||||
changed = add ? (!copy.contains(entry) && copy.add(entry)) : copy.remove(entry);
|
||||
whitelistIpRules = copy;
|
||||
if (add && !entry.contains("/")) blockedIpsUntil.remove(entry);
|
||||
} else {
|
||||
String lower = entry.toLowerCase(Locale.ROOT);
|
||||
changed = add ? whitelistNames.add(lower) : whitelistNames.remove(lower);
|
||||
}
|
||||
if (!changed) { sender.sendMessage(ChatColor.RED + (add ? "Bereits vorhanden: " : "Nicht gefunden: ") + entry); return; }
|
||||
try { persistWhitelist(); }
|
||||
catch (Exception e) { sender.sendMessage(ChatColor.RED + "Gespeichert nur bis Neustart: " + e.getMessage()); return; }
|
||||
sender.sendMessage(ChatColor.GREEN + (add ? "Whitelist + " : "Whitelist - ") + entry);
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(ChatColor.YELLOW + "/antibot whitelist <list|add|remove> [spieler|ip|cidr]");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("reload".equalsIgnoreCase(args[0])) {
|
||||
reloadRuntimeState();
|
||||
sender.sendMessage(ChatColor.GREEN + "AntiBot-Konfiguration neu geladen.");
|
||||
@@ -879,6 +1018,7 @@ public class AntiBotModule implements Module, Listener {
|
||||
sender.sendMessage(ChatColor.YELLOW + "/antibot clearblocks");
|
||||
sender.sendMessage(ChatColor.YELLOW + "/antibot unblock <ip>");
|
||||
sender.sendMessage(ChatColor.YELLOW + "/antibot profile <strict|high-traffic>");
|
||||
sender.sendMessage(ChatColor.YELLOW + "/antibot whitelist <list|add|remove> [spieler|ip|cidr]");
|
||||
sender.sendMessage(ChatColor.YELLOW + "/antibot reload");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public class BroadcastModule implements Module, Listener {
|
||||
loadConfig();
|
||||
if (!enabled) return false;
|
||||
if (requiredApiKey != null && !requiredApiKey.isEmpty()) {
|
||||
if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) {
|
||||
if (apiKeyHeader == null || !net.viper.status.StatusAPI.safeEquals(requiredApiKey, apiKeyHeader)) {
|
||||
plugin.getLogger().warning("[BroadcastModule] Broadcast abgelehnt: API-Key fehlt oder inkorrekt.");
|
||||
return false;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ public class BroadcastModule implements Module, Listener {
|
||||
props.setProperty(id + ".messageColor", sb.messageColor);
|
||||
props.setProperty(id + ".recur", sb.recur);
|
||||
}
|
||||
try (OutputStream out = new FileOutputStream(schedulesFile)) {
|
||||
try (OutputStream out = new net.viper.status.util.SafeFileOutputStream(schedulesFile)) {
|
||||
props.store(out, "PulseCast Scheduled Broadcasts");
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage());
|
||||
@@ -296,7 +296,7 @@ public class BroadcastModule implements Module, Listener {
|
||||
loadConfig();
|
||||
if (!enabled) return false;
|
||||
if (requiredApiKey != null && !requiredApiKey.isEmpty()) {
|
||||
if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) {
|
||||
if (apiKeyHeader == null || !net.viper.status.StatusAPI.safeEquals(requiredApiKey, apiKeyHeader)) {
|
||||
plugin.getLogger().warning("[BroadcastModule] schedule abgelehnt: API-Key fehlt oder inkorrekt.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -260,9 +260,9 @@ public class AccountLinkManager {
|
||||
|
||||
// ===== Persistenz =====
|
||||
|
||||
public void save() {
|
||||
public synchronized void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(
|
||||
new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
||||
new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||
for (LinkedAccount a : links.values()) {
|
||||
bw.write(a.minecraftUUID
|
||||
+ "|" + esc(a.minecraftName)
|
||||
|
||||
@@ -75,8 +75,8 @@ public class BlockManager {
|
||||
|
||||
// ===== Persistenz =====
|
||||
|
||||
public void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
||||
public synchronized void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||
for (Map.Entry<UUID, Set<UUID>> e : blocked.entrySet()) {
|
||||
if (e.getValue().isEmpty()) continue;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
@@ -82,8 +82,8 @@ public class MuteManager {
|
||||
|
||||
// ===== Persistenz =====
|
||||
|
||||
public void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
||||
public synchronized void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
for (Map.Entry<UUID, Long> e : mutes.entrySet()) {
|
||||
// Nur aktive Mutes speichern
|
||||
|
||||
@@ -144,9 +144,9 @@ public class ReportManager {
|
||||
|
||||
// ===== Persistenz =====
|
||||
|
||||
public void save() {
|
||||
public synchronized void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(
|
||||
new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
||||
new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||
for (ChatReport r : reports.values()) {
|
||||
bw.write(
|
||||
esc(r.id) + "|" +
|
||||
|
||||
+20
-1
@@ -204,7 +204,10 @@ public class CustomCommandModule implements Module, Listener {
|
||||
target = targetPlayer;
|
||||
}
|
||||
|
||||
String argsString = args.size() >= 1 ? String.join(" ", args) : "";
|
||||
// %args% bereinigen: Bei erhoehten Rechten (console/admin/anderer Spieler) strenger,
|
||||
// damit Spieler keine beliebigen Argumente in privilegierte Befehle einschleusen koennen.
|
||||
boolean privileged = !sendertype.equals("default");
|
||||
String argsString = sanitizeArgs(args.size() >= 1 ? String.join(" ", args) : "", privileged);
|
||||
final String finalArgs = argsString;
|
||||
final String senderName = player.getName();
|
||||
|
||||
@@ -231,6 +234,22 @@ public class CustomCommandModule implements Module, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private static String sanitizeArgs(String raw, boolean privileged) {
|
||||
if (raw == null) return "";
|
||||
// Steuerzeichen und Farbcode-Zeichen immer entfernen
|
||||
String s = raw.replaceAll("[\\p{Cntrl}§]", "");
|
||||
if (privileged) {
|
||||
// Nur harmlose Zeichen (Namen, Zahlen, einfache Woerter) zulassen
|
||||
s = s.replaceAll("[^A-Za-z0-9_.:\\- ]", "");
|
||||
if (s.length() > 64) s = s.substring(0, 64);
|
||||
} else if (s.length() > 256) {
|
||||
s = s.substring(0, 256);
|
||||
}
|
||||
// Fuehrende Slashes verhindern, dass %args% selbst wie ein Befehl wirkt
|
||||
s = s.replaceAll("^/+", "").trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
private void executeCommand(CommandSender sender, String rawCommand, String args, String playerName) {
|
||||
String parsed = rawCommand
|
||||
.replace("%args%", args)
|
||||
|
||||
@@ -102,7 +102,7 @@ public class ForumBridgeModule implements Module, Listener {
|
||||
// ===== HTTP HANDLER =====
|
||||
|
||||
public String handleNotify(String body, String apiKeyHeader) {
|
||||
if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) {
|
||||
if (!apiSecret.isEmpty() && !net.viper.status.StatusAPI.safeEquals(apiSecret, apiKeyHeader)) {
|
||||
return "{\"success\":false,\"error\":\"unauthorized\"}";
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public class ForumBridgeModule implements Module, Listener {
|
||||
}
|
||||
|
||||
public String handleUnlink(String body, String apiKeyHeader) {
|
||||
if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) return "{\"success\":false,\"error\":\"unauthorized\"}";
|
||||
if (!apiSecret.isEmpty() && !net.viper.status.StatusAPI.safeEquals(apiSecret, apiKeyHeader)) return "{\"success\":false,\"error\":\"unauthorized\"}";
|
||||
return "{\"success\":true}";
|
||||
}
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ public class ForumNotifStorage {
|
||||
|
||||
// ===== Datei-Operationen =====
|
||||
|
||||
public void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
||||
public synchronized void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||
for (CopyOnWriteArrayList<ForumNotification> list : pending.values()) {
|
||||
for (ForumNotification n : list) {
|
||||
if (!n.isDelivered()) {
|
||||
|
||||
@@ -198,7 +198,7 @@ public class NetworkInfoModule implements Module {
|
||||
if (attackApiKey == null || attackApiKey.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return providedKey != null && attackApiKey.equals(providedKey.trim());
|
||||
return providedKey != null && net.viper.status.StatusAPI.safeEquals(attackApiKey, providedKey.trim());
|
||||
}
|
||||
|
||||
public boolean sendAttackNotification(String eventType,
|
||||
|
||||
@@ -232,9 +232,9 @@ public class VanishModule implements Module, Listener {
|
||||
// PERSISTENZ
|
||||
// =========================================================
|
||||
|
||||
private void save() {
|
||||
private synchronized void save() {
|
||||
try (BufferedWriter bw = new BufferedWriter(
|
||||
new OutputStreamWriter(new FileOutputStream(dataFile), "UTF-8"))) {
|
||||
new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(dataFile), "UTF-8"))) {
|
||||
for (UUID uuid : persistentVanished) {
|
||||
bw.write(uuid.toString());
|
||||
bw.newLine();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.viper.status.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.Reader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Properties;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Ergaenzt fehlende Schluessel in bestehenden Konfigurationsdateien, ohne vorhandene Werte,
|
||||
* Kommentare oder die Reihenfolge zu veraendern. Neue Optionen erscheinen so nach einem
|
||||
* Plugin-Update automatisch (mit Kommentar) in der Datei.
|
||||
*
|
||||
* Existiert die Datei nicht, passiert nichts (sie wird vom Plugin mit allen Standardwerten erzeugt).
|
||||
*/
|
||||
public final class ConfigMigrator {
|
||||
|
||||
private ConfigMigrator() {}
|
||||
|
||||
/**
|
||||
* .properties-Datei (UTF-8). entries: je {schluessel, standardwert, kommentar}; der Kommentar darf
|
||||
* mehrere Zeilen ("\n") enthalten. Gibt die Anzahl ergaenzter Schluessel zurueck.
|
||||
*/
|
||||
public static int migrateProperties(File file, String[][] entries, Logger log) {
|
||||
if (file == null || !file.isFile()) return 0;
|
||||
try {
|
||||
Properties existing = new Properties();
|
||||
try (Reader r = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
|
||||
existing.load(r);
|
||||
}
|
||||
StringBuilder add = new StringBuilder();
|
||||
int count = 0;
|
||||
for (String[] e : entries) {
|
||||
if (existing.containsKey(e[0])) continue;
|
||||
if (count == 0) add.append("\n# -- Automatisch ergaenzt (neue Optionen nach Update) --\n");
|
||||
if (e.length > 2 && e[2] != null && !e[2].isEmpty()) {
|
||||
for (String line : e[2].split("\n")) add.append("# ").append(line).append('\n');
|
||||
}
|
||||
add.append(e[0]).append('=').append(e[1]).append("\n\n");
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
appendWithNewline(file, add.toString());
|
||||
if (log != null) log.info("[Config] " + file.getName() + ": " + count + " neue Option(en) ergaenzt.");
|
||||
}
|
||||
return count;
|
||||
} catch (IOException ex) {
|
||||
if (log != null) log.warning("[Config] Konnte " + file.getName() + " nicht aktualisieren: " + ex.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML-Datei: haengt block an, wenn der Top-Level-Schluessel {@code key} fehlt.
|
||||
* @return true wenn ergaenzt wurde
|
||||
*/
|
||||
public static boolean migrateYamlKey(File file, String key, String block, Logger log) {
|
||||
if (file == null || !file.isFile()) return false;
|
||||
try {
|
||||
String text = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
if (java.util.regex.Pattern.compile("(?m)^" + java.util.regex.Pattern.quote(key) + "\\s*:").matcher(text).find()) {
|
||||
return false;
|
||||
}
|
||||
appendWithNewline(file, "\n# -- Automatisch ergaenzt (neue Option nach Update) --\n" + block + "\n");
|
||||
if (log != null) log.info("[Config] " + file.getName() + ": Option '" + key + "' ergaenzt.");
|
||||
return true;
|
||||
} catch (IOException ex) {
|
||||
if (log != null) log.warning("[Config] Konnte " + file.getName() + " nicht aktualisieren: " + ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendWithNewline(File file, String text) throws IOException {
|
||||
byte[] existing = Files.readAllBytes(file.toPath());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (existing.length > 0 && existing[existing.length - 1] != '\n') sb.append('\n');
|
||||
sb.append(text);
|
||||
Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package net.viper.status.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
/**
|
||||
* Drop-in-Ersatz fuer {@code new FileOutputStream(file)} bei Datendateien.
|
||||
*
|
||||
* Schreibt zuerst in "<datei>.tmp" und ersetzt die Zieldatei erst beim erfolgreichen close()
|
||||
* (atomar, wenn moeglich). Ein Absturz oder Schreibfehler mitten im Speichern kann so die
|
||||
* bestehende Datei nicht mehr abschneiden oder halb ueberschreiben.
|
||||
*/
|
||||
public class SafeFileOutputStream extends FileOutputStream {
|
||||
|
||||
private final File target;
|
||||
private final File tmp;
|
||||
private boolean failed = false;
|
||||
private boolean closed = false;
|
||||
|
||||
public SafeFileOutputStream(File target) throws FileNotFoundException {
|
||||
this(target, new File(target.getPath() + ".tmp"));
|
||||
}
|
||||
|
||||
private SafeFileOutputStream(File target, File tmp) throws FileNotFoundException {
|
||||
super(tmp);
|
||||
this.target = target;
|
||||
this.tmp = tmp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
try { super.write(b); } catch (IOException e) { failed = true; throw e; }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b) throws IOException {
|
||||
try { super.write(b); } catch (IOException e) { failed = true; throw e; }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
try { super.write(b, off, len); } catch (IOException e) { failed = true; throw e; }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
super.close();
|
||||
if (failed) {
|
||||
tmp.delete();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.move(tmp.toPath(), target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user