From a39e26caf7bb7cd436df7ba7745ee5fb79d0db7b Mon Sep 17 00:00:00 2001 From: youpaishidifu <1@ypshidifu.cn> Date: Wed, 7 Oct 2026 23:05:29 +0800 Subject: [PATCH] =?UTF-8?q?opwl=E6=8E=A5=E7=AE=A1=E5=8E=9F=E7=94=9FOP?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=9A=E5=8A=A0=E8=BD=BD=E6=97=B6=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=8E=9F=E7=94=9FOP=EF=BC=8C=E9=9D=9E=E6=B3=95?= =?UTF-8?q?=E6=8C=81=E6=9C=89=E4=B8=80=E5=BE=8B=E6=92=A4=E9=94=80+?= =?UTF-8?q?=E6=8A=A5=E8=AD=A6(=E6=96=B0=E5=A2=9E=E9=9D=9E=E6=B3=95?= =?UTF-8?q?=E6=8C=81=E6=9C=89=E6=8A=A5=E8=AD=A6=E4=BF=A1=E6=81=AF=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=EF=BC=8C=E6=94=AF=E6=8C=81user/time=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E4=B8=AD=E8=8B=B1=E6=96=87)=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=8D=95=E6=96=87=E4=BB=B6opwl.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/OP白名单/opwl.json => opwl.json | 22 +- .../java/Sdf1_login/OpWhiteListManager.java | 411 ++++++++++-------- src/main/resources/opwl.json | 79 ++++ 3 files changed, 314 insertions(+), 198 deletions(-) rename src/main/resources/OP白名单/opwl.json => opwl.json (61%) create mode 100644 src/main/resources/opwl.json diff --git a/src/main/resources/OP白名单/opwl.json b/opwl.json similarity index 61% rename from src/main/resources/OP白名单/opwl.json rename to opwl.json index 7505bc3..a518b41 100644 --- a/src/main/resources/OP白名单/opwl.json +++ b/opwl.json @@ -9,7 +9,7 @@ }, "我们做了什么": { "第一层": "临时授权,允许你给OP设置到期时间,到期自动撤销", - "第二层": "防止你的OP乱给别人OP引起人人都是OP" + "第二层": "接管原生OP名单,不在白名单的一律撤销+报警" }, "你需要做什么": [ "共3步", @@ -21,6 +21,7 @@ "启用状态": true, "无固定期限授权列表": [], "有固定期限授权列表": {}, + "非法持有报警信息": "§8[§cOP白名单§8] §f{user} §7不持有授权却持有 OP,已于 §f{time} §7自动撤销 §8| §8[OPWL] §f{user} §7held OP illegally, revoked at §f{time}", "默认时长": "24h", "时长支持格式": [ "可以是中文口语:一个小时", @@ -28,6 +29,17 @@ "可以是标准写法:24H", "格式很宽松,只要是标准json字符串格式就行。至于你怎么写,插件会解析" ], + "报警模板变量": { + "{user}": "非法持有的玩家名(也认 player / 用户 / 玩家 / username)", + "{time}": "发现时间(也认 时间 / date / 日期)", + "写法": "大小写不敏感,中英文变量名都支持;& 颜色码可用,渲染后自动转成§" + }, + "接管说明": [ + "本插件【接管原生OP名单】:插件首次加载时,会把当前所有原生OP自动导入「无固定期限授权列表」并写回本文件。", + "之后:任何持有OP但不在白名单的玩家(不在永久表/ 临时已过期 / 时长无法解析),一律自动撤销OP并按「非法持有报警信息」报警。", + "所以以后新增OP请用 /opwl add <玩家>,别用原版 /op;否则会被15秒内的巡检撤掉。", + "白名单全部写在本文件里(单文件),没有子目录也没有附属名单文件。" + ], "以下是模板文件": { "# 启用状态": true, "# 无固定期限授权列表": [ @@ -39,6 +51,7 @@ "user2": "1小时", "user3": "two hour" }, + "# 非法持有报警信息": "支持 {user} 与 {time} 变量,中英文写法都认;留空则用内置默认文案", "# 默认时长": "24小时" }, "为什么这次选择json": { @@ -55,13 +68,12 @@ "opwl removetime user 1h": "给user用户扣1小时授权", "opwl remove user": "移除user的授权", "opwl list user": "获取user的授权状态", - "opwl list":"列出OP白名单", - "opwl reload":"重载OP白名单", + "opwl list": "列出OP白名单", + "opwl reload": "重载OP白名单", "温馨提示": "opwl命令仅限控台执行,普通用户无权", "加授权完全无感": "因为用户本身没有op时,被授权后,插件立即给予OP并记录授权状态。删除也是。不需要先添加用户在添加授权。" }, "安全升级": [ - "不追求一定是config.json,你可以把文件改成任意.json,这样你可以在目录放一些其它文件,提升伪装。", - "此文件即是配置文件也是说明书。这就意味着,你可以把其它混淆内容一起打包进本json文件,只要保留配置项。其它的你随便删改" + "本文件即是配置文件也是说明书。这就意味着,你可以把其它混淆内容一起打包进本json文件,只要保留配置项。其它的你随便删改" ] } \ No newline at end of file diff --git a/src/main/java/Sdf1_login/OpWhiteListManager.java b/src/main/java/Sdf1_login/OpWhiteListManager.java index 40e226e..8d9cbe9 100644 --- a/src/main/java/Sdf1_login/OpWhiteListManager.java +++ b/src/main/java/Sdf1_login/OpWhiteListManager.java @@ -6,7 +6,9 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -21,41 +23,45 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; -import java.util.HashSet; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; +import java.util.regex.Matcher; /** * OP 白名单(2026-10-07) * - * 配置目录:plugins/Sdf1_login/OP白名单/ —— 里面【任意一个 .json 文件】就是白名单 - * 配置(文件名随意,按文件名排序只读首个;模板 opwl.json 随 jar 释放)。 - * 该 json 同时是配置文件与说明书,除四个真实配置项外的其它字段一律忽略、写回时原样保留。 + * ★ 本插件【接管原生 OP 名单】:白名单文件是 OP 的唯一真相,原版 ops.json 只剩"存档"。 + * - 加载时:把当前所有原生 OP 自动导入「无固定期限授权列表」并写回文件(一次性接管); + * - 运行时:持 OP 但不在白名单(不在永久表 / 临时已过期 / 时长解析失败)=「非法持有」 + * → 一律撤销 OP + 按「非法持有报警信息」模板报警(控制台 + 全体在线 OP); + * 报警带 {user}/{time} 变量(支持 user/player/用户/玩家、time/时间,中英文写法都认)。 + * - 白名单内的在线玩家缺 OP 时自动补授(原版 /op 给的人若不在白名单,15 秒内会被撤)。 * - * 行为(与 opwl.json 说明书一致): - * - 读得出规则 → 白名单内玩家授 OP;【只授权不主动撤】:白名单外的玩家一律不碰 - * (原生 OP / 服主不受影响);本插件授过 OP 的玩家记入 granted(持久化到 - * .granted.txt),到期时自动撤销(说明书「到期自动撤销」)。 - * - 读不出规则(无 json / JSON 语法错 / 必需字段类型错)→ 【一次性】卸载全部 - * 已知玩家(含离线)的 OP,只执行一次,修复配置后自动恢复; - * - 启用状态=false → 功能休眠,不授权也不卸载; - * - 15 秒一次增量热重载:文件指纹(路径+lastModified+长度)有变化才重新解析并打 - * 日志,没变化只做静默的授权应用(新上线玩家 / 到期撤销),保持安静; - * - opwl 命令仅限控台(Main 侧对玩家直接静默 return,连帮助都不给)。 + * 配置:单文件 plugins/Sdf1_login/opwl.json(模板随 jar 释放,不再有子目录/附属文件)。 + * 该 json 同时是配置文件与说明书,除配置项外的其它字段一律忽略、写回时原样保留。 * - * 授权应用只在主线程跑(scheduler 主线程 + 命令 + join 事件),无并发问题。 + * 其他行为: + * - 读不出规则(无 json / JSON 语法错 / 必需字段类型错)→ 一次性卸载全部已知 OP, + * 持续失败不重复执行,修复后自动恢复; + * - 启用状态=false → 功能休眠,不授权也不撤销; + * - 15 秒一次增量热重载:文件指纹(路径+lastModified+长度)变化才重解析并打日志; + * - opwl 命令仅限控台(Main 侧对玩家静默 return,连帮助都不给)。 + * + * 所有授权/撤销都在主线程执行(scheduler + 命令 + join 事件),无并发问题。 */ public class OpWhiteListManager implements Listener { + /** 报警节流:同一人 60 秒内不重复报警(防刷屏,但仍会撤销) */ + private static final long WARN_COOLDOWN_MS = 60_000L; + private final Main plugin; - private final File dir; - private final File grantedFile; + private final File file; private final Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); - /** 当前被读的 json(pickJson 的结果,命令写回也写它) */ + /** 当前白名单文件(= dataFolder/opwl.json) */ private File watched; /** 解析成功后的完整配置(null = 解析失败态) */ private JsonObject cfg; @@ -64,57 +70,52 @@ public class OpWhiteListManager implements Listener { /** 文件指纹(路径 + lastModified + 长度),变化才重载打日志 */ private String lastPath = ""; private long lastStamp = Long.MIN_VALUE; - /** 本插件授过 OP 的玩家(小写名)——兑现「到期自动撤销」的凭据,持久化 */ - private final Set granted = new HashSet<>(); + /** 报警节流表:小写用户名 → 上次报警毫秒 */ + private final Map lastWarn = new HashMap<>(); public OpWhiteListManager(Main plugin) { this.plugin = plugin; - this.dir = new File(plugin.getDataFolder(), "OP白名单"); - this.grantedFile = new File(dir, ".granted.txt"); - if (!dir.exists()) { - dir.mkdirs(); + this.file = new File(plugin.getDataFolder(), "opwl.json"); + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); } // 模板随 jar 释放(只在不存在时复制,绝不覆盖用户改过的文件) - File tpl = new File(dir, "opwl.json"); - if (!tpl.exists()) { - try (InputStream in = plugin.getResource("OP白名单/opwl.json")) { + if (!file.exists()) { + try (InputStream in = plugin.getResource("opwl.json")) { if (in != null) { - Files.copy(in, tpl.toPath()); - plugin.getLogger().info("[OP白名单] 已释放模板配置: OP白名单/" + tpl.getName()); + Files.copy(in, file.toPath()); + plugin.getLogger().info("[OP白名单] 已释放配置模板: plugins/Sdf1_login/opwl.json"); } else { - plugin.getLogger().warning("[OPWl白名单] jar 内未找到模板 OP白名单/opwl.json,请手工在目录里放一个 .json"); + plugin.getLogger().warning("[OP白名单] jar 内未找到模板 opwl.json,请手工放置该文件"); } } catch (Exception e) { plugin.getLogger().warning("[OP白名单] 模板释放失败: " + e.getMessage()); } } - loadGranted(); - reloadInternal(true); - // 15 秒增量热重载 + 静默授权应用 + // 接管原生 OP 名单:首次加载先把现有 OP 全导入白名单(一次性) + reloadInternal(true, true); + if (cfg != null) { + importNativeOps(); + } + // 15 秒增量热重载 + 静默应用 Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 300L, 300L); } // ================= 配置读取 ================= - /** 目录里任意一个 .json = 白名单文件;文件名按字典序取首个(确定性) */ - private File pickJson() { - File[] fs = dir.listFiles((d, n) -> n.toLowerCase(Locale.ROOT).endsWith(".json")); - if (fs == null || fs.length == 0) { - return null; - } - Arrays.sort(fs, (a, b) -> a.getName().compareToIgnoreCase(b.getName())); - return fs[0]; - } - - private void reloadInternal(boolean log) { - watched = pickJson(); - loadFrom(watched, log); + private void reloadInternal(boolean log, boolean importNative) { + watched = file; + loadFrom(file, log); updateStamp(); + if (importNative && cfg != null) { + importNativeOps(); + } } private void loadFrom(File f, boolean log) { - if (f == null) { - fail("OP白名单目录里没有任何 .json 文件"); + if (f == null || !f.exists()) { + fail("OP白名单配置文件不存在(plugins/Sdf1_login/opwl.json)"); return; } try { @@ -134,19 +135,46 @@ public class OpWhiteListManager implements Listener { } cfg = o; if (parseFailed) { - plugin.getLogger().info("[OP白名单] 配置已恢复正常,恢复授权管理"); + plugin.getLogger().info("[OP白名单] 配置已恢复正常,恢复接管"); } parseFailed = false; if (log) { - plugin.getLogger().info("[OP白名单] 已重载 " + f.getName() + ":" + plugin.getLogger().info("[OP白名单] 已重载 opwl.json:" + (isEnabled() ? "启用" : "停用") + ",无固定期限 " + permanentList().size() + " 人,有固定期限 " + timedObject().size() + " 人"); } } catch (Exception e) { - fail(f.getName() + " 解析失败:" + e.getMessage()); + fail("opwl.json 解析失败:" + e.getMessage()); } } + /** + * ★ 接管原生 OP:把当前所有 OP 且不在白名单的玩家,自动导入「无固定期限授权列表」。 + * 幂等——已在白名单的不动;已撤销的非法持有者不再是 OP,下次接管不会把他加回来。 + */ + private void importNativeOps() { + List add = new ArrayList<>(); + for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) { + String n = op.getName(); + if (n == null || n.isEmpty() || !op.isOp()) { + continue; + } + if (hasPermanent(n) || findTimed(n) != null) { + continue; + } + add.add(n); + } + if (add.isEmpty()) { + return; + } + for (String n : add) { + permanentList().add(new JsonPrimitive(n)); + } + saveConfig(); + plugin.getLogger().info("[OP白名单] 已接管原生 OP 名单:导入 " + add.size() + + " 人为无固定期限授权 → " + String.join(", ", add)); + } + /** 解析失败 → 一次性卸载全部已知 OP(含离线),持续失败不重复执行 */ private void fail(String why) { cfg = null; @@ -155,95 +183,122 @@ public class OpWhiteListManager implements Listener { } parseFailed = true; int n = 0; - for (OfflinePlayer op : Bukkit.getOperators()) { + for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) { if (op.isOp()) { op.setOp(false); n++; } } - granted.clear(); - saveGranted(); + lastWarn.clear(); plugin.getLogger().warning("[OP白名单] " + why - + " —— 已一次性卸载全部 " + n + " 名玩家的 OP;修复 json 后自动恢复授权。"); + + " —— 已一次性卸载全部 " + n + " 名玩家的 OP;修复 json 后自动恢复接管。"); } /** 文件指纹:路径 + lastModified + 长度(任一变化都视为变更) */ private void updateStamp() { - File f = (watched != null && watched.exists()) ? watched : pickJson(); - if (f == null) { - lastPath = ""; - lastStamp = 0L; - return; - } - lastPath = f.getAbsolutePath(); - lastStamp = f.lastModified() + f.length(); + lastPath = file.getAbsolutePath(); + lastStamp = file.exists() ? file.lastModified() + file.length() : 0L; } // ================= 15 秒 tick ================= private void tick() { - File f = pickJson(); - String p = f == null ? "" : f.getAbsolutePath(); - long st = f == null ? 0L : f.lastModified() + f.length(); - if (!p.equals(lastPath) || st != lastStamp) { - // 有变化:重新解析 + 打日志(含解析失败的告警) - watched = f; - loadFrom(f, true); + long st = file.exists() ? file.lastModified() + file.length() : 0L; + if (!lastPath.equals(file.getAbsolutePath()) || st != lastStamp) { + loadFrom(file, true); // 有变化:重新解析 + 打日志(含解析失败告警) updateStamp(); } - // 无论有无变化都做一次静默的授权应用(不打无变化日志,保持安静) + // 无论有无变化都做一次静默应用(撤销非法持有 + 补授权),保持安静 apply(); } - /** 玩家上线:立即应用一次(白名单内马上拿到 OP,不用等下个 15 秒) */ + /** 玩家上线:立即应用一次(白名单内马上拿到 OP,不等下个 15 秒) */ @EventHandler public void onJoin(PlayerJoinEvent e) { apply(); } - // ================= 授权应用(只授不撤,例外仅两处:到期、解析失败) ================= + // ================= 接管:撤销非法持有 + 补授权 ================= private void apply() { if (cfg == null || !isEnabled()) { return; } long now = System.currentTimeMillis(); - // 1) 到期自动撤销:只动本插件授过 OP 且当前在「有固定期限」表里、已过期的玩家。 - // 白名单外的其他人(原生 OP 等)一律不碰。 - boolean dirty = false; - Iterator it = granted.iterator(); - while (it.hasNext()) { - String key = it.next(); - String timed = findTimed(key); - if (timed == null) { + // 1) 非法持有扫描:持 OP 但不在白名单 → 撤销 + 报警 + for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) { + String n = op.getName(); + if (n == null || n.isEmpty() || !op.isOp()) { continue; } - long exp = expireOf(timed); - if (exp > 0 && exp <= now) { - setOpByName(key, false); - it.remove(); - dirty = true; - plugin.getLogger().info("[OP白名单] " + key + " 授权到期,已自动撤销 OP"); + if (isWhitelisted(n)) { + continue; } + setOpByName(n, false); + warnIllegal(n, now, describeIllegal(n)); } - if (dirty) { - saveGranted(); - } - // 2) 白名单内在线玩家授权(已是 OP 的跳过;新授的记入 granted) + // 2) 白名单内在线玩家补授(已是 OP 的跳过) for (Player p : Bukkit.getOnlinePlayers()) { - if (p.isOp()) { - continue; - } - if (!isWhitelisted(p.getName())) { + if (p.isOp() || !isWhitelisted(p.getName())) { continue; } p.setOp(true); - granted.add(p.getName().toLowerCase(Locale.ROOT)); - saveGranted(); plugin.getLogger().info("[OP白名单] 已授权 " + p.getName() + " 获得 OP"); } } + /** 撤销非法持有 + 报警(控制台 + 全体在线 OP),带 60 秒节流 */ + private void warnIllegal(String name, long now, String reason) { + String key = name.toLowerCase(Locale.ROOT); + long last = lastWarn.getOrDefault(key, 0L); + if (last > 0 && now - last < WARN_COOLDOWN_MS) { + return; + } + lastWarn.put(key, now); + String alarm = renderAlarm(name, now); + plugin.getLogger().warning("[OP白名单] 非法持有已撤销 | 原因: " + reason + " | " + stripColor(alarm)); + for (Player p : Bukkit.getOnlinePlayers()) { + if (p.isOp()) { + p.sendMessage(alarm); + } + } + } + + /** 报警模板渲染:{user}/{player}/{用户}/{玩家} 与 {time}/{时间} 变量(大小写不敏感) */ + private String renderAlarm(String user, long now) { + String tpl = alarmTemplate(); + String time = fmtFull(now); + String s = tpl; + for (String k : new String[]{"user", "player", "用户", "玩家", "username"}) { + s = s.replaceAll("(?i)\\{" + k + "\\}", Matcher.quoteReplacement(user)); + } + for (String k : new String[]{"time", "时间", "date", "日期"}) { + s = s.replaceAll("(?i)\\{" + k + "\\}", Matcher.quoteReplacement(time)); + } + return ChatColor.translateAlternateColorCodes('&', s); + } + + /** 报警模板:配置项「非法持有报警信息」,空则用内置默认(中英双语) */ + private String alarmTemplate() { + if (cfg != null && cfg.has("非法持有报警信息") + && cfg.get("非法持有报警信息").isJsonPrimitive()) { + String s = cfg.get("非法持有报警信息").getAsString().trim(); + if (!s.isEmpty()) { + return s; + } + } + return "§8[§cOP白名单§8] §f{user} §7不持有授权却持有 OP,已于 §f{time} §7自动撤销" + + " §8| §8[OPWL] §f{user} §7held OP illegally, revoked at §f{time}"; + } + + /** 撤销原因(仅日志用,便于排查是哪一类"不在白名单") */ + private String describeIllegal(String name) { + if (findTimed(name) != null) { + return "临时授权已过期或时长无法解析(" + findTimed(name) + ")"; + } + return "不在白名单(" + (Bukkit.getPlayerExact(name) != null ? "在线" : "离线") + ")"; + } + private void setOpByName(String name, boolean op) { Player p = Bukkit.getPlayerExact(name); if (p != null) { @@ -310,10 +365,6 @@ public class OpWhiteListManager implements Listener { } private boolean removeTimed(String name) { - String k = timedKeyOf(name); - if (timedObject().has(k) && !k.equalsIgnoreCase(name) && timedObject().get(k) != null) { - // key 原样存在 - } for (String key : new ArrayList<>(timedObject().keySet())) { if (name.equalsIgnoreCase(key)) { timedObject().remove(key); @@ -323,7 +374,7 @@ public class OpWhiteListManager implements Listener { return false; } - /** 是否白名单有效成员:永久 或 有期限且未过期(坏时长视为无效,不授) */ + /** 是否白名单有效成员:永久 或 有期限且未过期(坏时长视为无效 → 算非法持有) */ private boolean isWhitelisted(String name) { if (cfg == null) { return false; @@ -339,7 +390,7 @@ public class OpWhiteListManager implements Listener { return e > 0 && e > System.currentTimeMillis(); } - /** 解析为绝对到期毫秒;解析失败返回 -1(调用方按「无效/不过期」保守处理) */ + /** 解析为绝对到期毫秒;解析失败返回 -1(调用方按「无效」保守处理) */ private long expireOf(String v) { try { return DurationParser.parseToExpireMs(v); @@ -360,50 +411,20 @@ public class OpWhiteListManager implements Listener { // ================= 持久化 ================= - /** 写回 json:保留用户在配置项之外塞的所有字段(说明书/混淆内容),只动配置项 */ + /** 写回 json:保留配置项之外的所有说明书/混淆字段,只动配置项 */ private void saveConfig() { try { - File target = watched; - if (target == null || !target.exists()) { - target = new File(dir, "opwl.json"); - watched = target; + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); } - Files.write(target.toPath(), gson.toJson(cfg).getBytes(StandardCharsets.UTF_8)); + Files.write(file.toPath(), gson.toJson(cfg).getBytes(StandardCharsets.UTF_8)); updateStamp(); // 命令自己有回馈,抑制下个 tick 再打一次「已重载」 } catch (Exception e) { plugin.getLogger().warning("[OP白名单] 配置写回失败: " + e.getMessage()); } } - private void loadGranted() { - granted.clear(); - if (!grantedFile.exists()) { - return; - } - try { - for (String line : Files.readAllLines(grantedFile.toPath(), StandardCharsets.UTF_8)) { - String s = line.trim().toLowerCase(Locale.ROOT); - if (!s.isEmpty()) { - granted.add(s); - } - } - } catch (Exception e) { - plugin.getLogger().warning("[OP白名单] 授权痕迹读取失败: " + e.getMessage()); - } - } - - private void saveGranted() { - try { - StringBuilder sb = new StringBuilder(); - for (String s : granted) { - sb.append(s).append('\n'); - } - Files.write(grantedFile.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8)); - } catch (Exception e) { - plugin.getLogger().warning("[OP白名单] 授权痕迹写入失败: " + e.getMessage()); - } - } - // ================= opwl 命令(仅控台;Main 已拦玩家) ================= public void onCommand(org.bukkit.command.CommandSender sender, String[] args) { @@ -414,14 +435,14 @@ public class OpWhiteListManager implements Listener { sendHelp(sender); return; case "reload": { - reloadInternal(true); + reloadInternal(true, false); apply(); sender.sendMessage(cfg == null ? "§c重载完成,但配置解析失败(详见控制台告警)" : "§a重载完成:§f" + (isEnabled() ? "启用" : "停用") + ",无固定期限 " + permanentList().size() - + " 人,有固定期限 " + timedObject().size() + " 人" + - "§d欢迎来到草原探险服务器,ip: mc2.ypshidifu.cn 端口(基岩版需要):30679"); + + " 人,有固定期限 " + timedObject().size() + " 人"); + sender.sendMessage("§7提示:原生 OP 只在插件【首次加载】时导入;之后请用 /opwl add 或改 json 加人"); return; } default: @@ -429,7 +450,7 @@ public class OpWhiteListManager implements Listener { } // 以下子命令需要配置可读 if (cfg == null) { - sender.sendMessage("§c配置解析失败,先修复 OP白名单 目录里的 json 再操作(或 /opwl reload)"); + sender.sendMessage("§c配置解析失败,先修复 plugins/Sdf1_login/opwl.json 再操作(或 /opwl reload)"); return; } boolean readOnly = "list".equals(sub); @@ -444,12 +465,13 @@ public class OpWhiteListManager implements Listener { return; } String name = args[1].trim(); - boolean moved = removeTimed(name); // 已有临时授权 → 转永久 + boolean moved = removeTimed(name); if (!hasPermanent(name)) { - permanentList().add(new com.google.gson.JsonPrimitive(name)); + permanentList().add(new JsonPrimitive(name)); } saveConfig(); - boolean ok = grantNow(name, true); + lastWarn.remove(name.toLowerCase(Locale.ROOT)); + boolean ok = setOpNow(name); sender.sendMessage("§a已添加 §f" + name + " §a为无固定期限授权" + (moved ? "(已把原临时授权转为永久)" : "") + (ok ? ",OP 已授予" : "(玩家不在本机档案中,上线时自动授予)")); @@ -474,10 +496,11 @@ public class OpWhiteListManager implements Listener { sender.sendMessage("§c该时长的到期时间已在过去: " + dur); return; } - removePermanent(name); // 临时授权优先:从永久表移出 + removePermanent(name); timedObject().addProperty(timedKeyOf(name), dur); saveConfig(); - boolean ok = grantNow(name, true); + lastWarn.remove(name.toLowerCase(Locale.ROOT)); + boolean ok = setOpNow(name); sender.sendMessage("§a已给 §f" + name + " §a添加临时授权 §7(时长 " + dur + ",到期 " + fmt(exp) + ")" + (ok ? ",OP 已授予" : ",上线时自动授予")); return; @@ -519,9 +542,8 @@ public class OpWhiteListManager implements Listener { if (newExp <= System.currentTimeMillis()) { removeTimed(name); saveConfig(); - boolean revoked = revokeNow(name); - sender.sendMessage("§a扣减后 " + name + " 的授权已到期,条目已移除" - + (revoked ? ",OP 已撤销" : "")); + setOpByName(name, false); + sender.sendMessage("§a扣减后 " + name + " 的授权已到期,条目已移除,OP 已撤销"); } else { String s = fmt(newExp); timedObject().addProperty(timedKeyOf(name), s); @@ -540,13 +562,15 @@ public class OpWhiteListManager implements Listener { boolean inPerm = removePermanent(name); boolean inTimed = removeTimed(name); saveConfig(); - boolean revoked = revokeNow(name); - if (!inPerm && !inTimed && !revoked) { - sender.sendMessage("§e" + name + " 本就不在白名单中"); + boolean wasOp = isOpNow(name); + setOpByName(name, false); + lastWarn.remove(name.toLowerCase(Locale.ROOT)); + if (!inPerm && !inTimed && !wasOp) { + sender.sendMessage("§e" + name + " 本就不在白名单中,也没有 OP"); } else { sender.sendMessage("§a已移除 §f" + name + " §a的授权" - + (revoked ? ",OP 已撤销" - : ((inPerm || inTimed) ? "(该玩家 OP 非本插件授予,OP 状态未动)" : ""))); + + (wasOp ? ",OP 已撤销" : "")); + sender.sendMessage("§7提示:若要长用请 /opwl add " + name + ",否则他的 OP 会被自动接管撤销"); } return; } @@ -555,8 +579,9 @@ public class OpWhiteListManager implements Listener { listOne(sender, args[1].trim()); return; } - sender.sendMessage("§e===== OP白名单 §7(" + (watched != null ? watched.getName() : "?") + ") ====="); - sender.sendMessage("§7状态: " + (isEnabled() ? "§a启用" : "§c停用") + sender.sendMessage("§e===== OP白名单(接管原生 OP) ====="); + sender.sendMessage("§7配置: §fplugins/Sdf1_login/opwl.json §7| 状态: " + + (isEnabled() ? "§a启用" : "§c停用") + " §7| 解析: " + (parseFailed ? "§c失败" : "§a正常")); JsonArray pu = permanentList(); StringBuilder sb = new StringBuilder("§e无固定期限(" + pu.size() + "): "); @@ -580,52 +605,46 @@ public class OpWhiteListManager implements Listener { long e = expireOf(v); String state = e <= 0 ? "§c到期时间无法解析: " + v : (e > now ? "§7到期 §f" + fmt(e) + " §7(剩 " + human(e - now) + ")" - : "§c已过期 " + fmt(e)); + : "§c已过期 " + fmt(e) + "(OP 将被撤销)"); sender.sendMessage("§7 " + en.getKey() + " → " + state); } + // 额外:谁在偷偷持有 OP(白名单外的) + List illegal = new ArrayList<>(); + for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) { + String n = op.getName(); + if (n != null && !n.isEmpty() && op.isOp() && !isWhitelisted(n)) { + illegal.add(n); + } + } + sender.sendMessage("§e当前持 OP 但不在白名单(" + illegal.size() + "): " + + (illegal.isEmpty() ? "§a无" : "§c" + String.join("§7, §c", illegal))); return; } default: sendHelp(sender); - return; } } // ================= 命令辅助 ================= - /** 立即授予/撤销并记录;返回 false = 档案未知(上线时由 join 自动补授) */ - private boolean grantNow(String name, boolean op) { - if (!op) { - return revokeNow(name); - } + /** 立即授 OP;返回 false = 档案未知(上线时由 join 自动补授) */ + private boolean setOpNow(String name) { Player p = Bukkit.getPlayerExact(name); if (p != null) { p.setOp(true); - granted.add(name.toLowerCase(Locale.ROOT)); - saveGranted(); return true; } OfflinePlayer o = Bukkit.getOfflinePlayer(name); if (!o.hasPlayedBefore()) { - // 档案未知:留到 join 兜底(isWhitelisted && !isOp → 授) return false; } o.setOp(true); - granted.add(name.toLowerCase(Locale.ROOT)); - saveGranted(); return true; } - /** 仅撤销本插件授过 OP 的玩家;返回是否真的撤了 */ - private boolean revokeNow(String name) { - String key = name.toLowerCase(Locale.ROOT); - if (!granted.contains(key)) { - return false; - } - setOpByName(name, false); - granted.remove(key); - saveGranted(); - return true; + private boolean isOpNow(String name) { + Player online = Bukkit.getPlayerExact(name); + return online != null ? online.isOp() : Bukkit.getOfflinePlayer(name).isOp(); } private void listOne(org.bukkit.command.CommandSender s, String name) { @@ -638,30 +657,27 @@ public class OpWhiteListManager implements Listener { long now = System.currentTimeMillis(); if (e > 0) { s.sendMessage("§7 类型: §f临时 §7到期 §f" + fmt(e) - + (e > now ? " §7(剩 " + human(e - now) + ")" : " §c(已过期)")); + + (e > now ? " §7(剩 " + human(e - now) + ")" : " §c(已过期,OP 会被撤销)")); } else { - s.sendMessage("§7 类型: §f临时 §c(到期时间无法解析: " + t + ")"); + s.sendMessage("§7 类型: §f临时 §c(到期时间无法解析: " + t + ",OP 会被撤销)"); } } else { - s.sendMessage("§7 类型: §c不在白名单"); + s.sendMessage("§7 类型: §c不在白名单(持有 OP 会被自动撤销并报警)"); } - Player online = Bukkit.getPlayerExact(name); - boolean isOp = online != null ? online.isOp() : Bukkit.getOfflinePlayer(name).isOp(); - s.sendMessage("§7 当前OP: " + (isOp ? "§f是" : "§f否") - + " §7| 本插件授OP记录: " + (granted.contains(name.toLowerCase(Locale.ROOT)) ? "§f是" : "§f否")); + s.sendMessage("§7 当前OP: " + (isOpNow(name) ? "§f是" : "§f否")); } private void sendHelp(org.bukkit.command.CommandSender s) { - s.sendMessage("§e===== OP白名单(仅控制台可用) ====="); + s.sendMessage("§e===== OP白名单(仅控制台可用,接管原生 OP) ====="); s.sendMessage("§f/opwl §7- 本帮助"); s.sendMessage("§f/opwl add <玩家> §7- 添加无固定期限授权"); - s.sendMessage("§f/opwl addtime <玩家> [时长] §7- 添加临时授权(缺省用默认时长 " - + defaultDuration() + ")"); + s.sendMessage("§f/opwl addtime <玩家> [时长] §7- 添加临时授权(缺省 " + defaultDuration() + ")"); s.sendMessage("§f/opwl removetime <玩家> <扣减时长> §7- 扣减授权时长(如 1h)"); - s.sendMessage("§f/opwl remove <玩家> §7- 移除授权"); - s.sendMessage("§f/opwl list [玩家] §7- 查看白名单 / 单个玩家状态"); + s.sendMessage("§f/opwl remove <玩家> §7- 移除授权并撤销其 OP"); + s.sendMessage("§f/opwl list [玩家] §7- 查看白名单 / 非法持有者 / 单人状态"); s.sendMessage("§f/opwl reload §7- 重载配置"); - s.sendMessage("§7配置目录: plugins/Sdf1_login/OP白名单/(任意 *.json),15 秒自动热重载"); + s.sendMessage("§7配置: plugins/Sdf1_login/opwl.json(单文件,白名单全在里面),15 秒自动热重载"); + s.sendMessage("§7报警模板变量: §f{user}§7=玩家名 §f{time}§7=发现时间(中英文写法均可)"); s.sendMessage("§d欢迎来到草原探险服务器,ip: mc2.ypshidifu.cn 端口(基岩版需要):30679"); } @@ -681,6 +697,10 @@ public class OpWhiteListManager implements Listener { return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date(ms)); } + private static String fmtFull(long ms) { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(ms)); + } + /** 剩余时长人性化:X天X小时X分 */ private static String human(long ms) { long min = ms / 60000L; @@ -702,4 +722,9 @@ public class OpWhiteListManager implements Listener { } return sb.length() == 0 ? "不到1分钟" : sb.toString(); } + + /** 日志里去掉颜色符号,避免控制台刷屏乱码 */ + private static String stripColor(String s) { + return s == null ? "" : s.replaceAll("(?i)&[0-9a-k]", "").replaceAll("\u00a7[0-9a-k]", ""); + } } \ No newline at end of file diff --git a/src/main/resources/opwl.json b/src/main/resources/opwl.json new file mode 100644 index 0000000..a518b41 --- /dev/null +++ b/src/main/resources/opwl.json @@ -0,0 +1,79 @@ +{ + "OP白名单": "服务器的好管家", + "为什么要OP白名单": { + "根因有3点": { + "第一点": "已有OP乱给权限防不住", + "第二点": "有人利用服务器漏洞拿到OP权限", + "第三点": "不信任OP" + } + }, + "我们做了什么": { + "第一层": "临时授权,允许你给OP设置到期时间,到期自动撤销", + "第二层": "接管原生OP名单,不在白名单的一律撤销+报警" + }, + "你需要做什么": [ + "共3步", + "第一步:安装sdf1_login插件", + "第二步改这个文件", + "第三步按参考格式或后台指令添加授权" + ], + "----": "----", + "启用状态": true, + "无固定期限授权列表": [], + "有固定期限授权列表": {}, + "非法持有报警信息": "§8[§cOP白名单§8] §f{user} §7不持有授权却持有 OP,已于 §f{time} §7自动撤销 §8| §8[OPWL] §f{user} §7held OP illegally, revoked at §f{time}", + "默认时长": "24h", + "时长支持格式": [ + "可以是中文口语:一个小时", + "可以是英文one hour", + "可以是标准写法:24H", + "格式很宽松,只要是标准json字符串格式就行。至于你怎么写,插件会解析" + ], + "报警模板变量": { + "{user}": "非法持有的玩家名(也认 player / 用户 / 玩家 / username)", + "{time}": "发现时间(也认 时间 / date / 日期)", + "写法": "大小写不敏感,中英文变量名都支持;& 颜色码可用,渲染后自动转成§" + }, + "接管说明": [ + "本插件【接管原生OP名单】:插件首次加载时,会把当前所有原生OP自动导入「无固定期限授权列表」并写回本文件。", + "之后:任何持有OP但不在白名单的玩家(不在永久表/ 临时已过期 / 时长无法解析),一律自动撤销OP并按「非法持有报警信息」报警。", + "所以以后新增OP请用 /opwl add <玩家>,别用原版 /op;否则会被15秒内的巡检撤掉。", + "白名单全部写在本文件里(单文件),没有子目录也没有附属名单文件。" + ], + "以下是模板文件": { + "# 启用状态": true, + "# 无固定期限授权列表": [ + "user1", + "user2" + ], + "# 有固定期限授权列表": { + "user1": "2026-10-06 23:00", + "user2": "1小时", + "user3": "two hour" + }, + "# 非法持有报警信息": "支持 {user} 与 {time} 变量,中英文写法都认;留空则用内置默认文案", + "# 默认时长": "24小时" + }, + "为什么这次选择json": { + "根因依旧2点": { + "1": "防止非专业人士修改本文件", + "2": "使用专业格式,让小白试探着劝退(一旦解析失败,所有玩家OP全部卸载)" + } + }, + "指令参数": { + "opwl": "帮助菜单", + "opwl add user": "添加user用户为无固定期限用户", + "opwl addtime user": "按默认时长添加临时授权用户", + "opwl addtime user 1h": "给user用户价1小时授权", + "opwl removetime user 1h": "给user用户扣1小时授权", + "opwl remove user": "移除user的授权", + "opwl list user": "获取user的授权状态", + "opwl list": "列出OP白名单", + "opwl reload": "重载OP白名单", + "温馨提示": "opwl命令仅限控台执行,普通用户无权", + "加授权完全无感": "因为用户本身没有op时,被授权后,插件立即给予OP并记录授权状态。删除也是。不需要先添加用户在添加授权。" + }, + "安全升级": [ + "本文件即是配置文件也是说明书。这就意味着,你可以把其它混淆内容一起打包进本json文件,只要保留配置项。其它的你随便删改" + ] +} \ No newline at end of file