diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed999f0 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# SFCraft + +本仓库为 S.F Craft 服务器的功能 Mod, S.F Craft 是一个邀请制的纯原版生存服务器。 +本插件专门为 SFC 定制,因此您可能需要花费大多功夫来将其用在你的服务器上(比如修改大量的硬编码字符串,强制启用的功能,等等),因此,并不建议这么做。 + +本仓库开源供有能力的玩家贡献。 + +# Features + +- 允许指定离线玩家进入正版服务器,与白名单兼容 (`/addwl`) + 需要注意的是,如果使用此功能添加了正版玩家的名字,那么正版玩家将会以离线用户的 UUID 游玩,这会导致皮肤不显示等问题。 +- 限制性的 `/back` 命令 + 切合服务器的需要,本服务器的 `/back` 命令仅在死亡地点半径 100 内有人的情况下才可传送,且传送到附近玩家的位置。 +- 更新日志速览 + 新来的玩家将会发放小册子,简要概述服务器特性。 + 该书内容从 `sfcraft/update.log` 生成,每 128 字符一张纸。 +- 防沉迷 + 保护每一个玩家的下午,支持自动识别时区,需要下载 GeoIP 数据库 (45MB)。 + ![img.png](assets/img.png) +- 人文关怀 + 对于一些年龄比较小的玩家还有一些不太熟悉 MC 的玩家,我们特地设计了一些赦免权,例如无限制的 `/back` 命令。 \ No newline at end of file diff --git a/assets/img.png b/assets/img.png new file mode 100644 index 0000000..0d26e71 Binary files /dev/null and b/assets/img.png differ diff --git a/build.gradle b/build.gradle index 87968dd..717823c 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'fabric-loom' version '1.6-SNAPSHOT' id 'maven-publish' id("io.freefair.lombok") version "8.6" + id("com.github.johnrengelman.shadow") version "8.1.1" } version = project.mod_version @@ -28,7 +29,21 @@ dependencies { // Fabric API. This is technically optional, but you probably want it anyway. modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" - implementation 'com.maxmind.geoip2:geoip2:4.2.0' + shadow('com.maxmind.geoip2:geoip2:4.2.0') +} +configurations.implementation.extendsFrom(configurations.shadow) + +shadowJar { + configurations = [ project.configurations.shadow ] + exclude("META-INF") +} + +remapJar { + // wait until the shadowJar is done + dependsOn(shadowJar) + mustRunAfter(shadowJar) + // Set the input jar for the task. Here use the shadow Jar that include the .class of the transitive dependency + inputFile = file(shadowJar.archivePath) } processResources { diff --git a/src/main/java/io/ib67/sfcraft/Commands.java b/src/main/java/io/ib67/sfcraft/Commands.java index a77a2b2..4279a77 100644 --- a/src/main/java/io/ib67/sfcraft/Commands.java +++ b/src/main/java/io/ib67/sfcraft/Commands.java @@ -1,46 +1,113 @@ package io.ib67.sfcraft; -import io.ib67.sfcraft.access.AccessController; +import com.mojang.brigadier.context.CommandContext; import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.WhitelistEntry; import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.dedicated.MinecraftDedicatedServer; +import net.minecraft.server.network.ServerPlayNetworkHandler; import net.minecraft.text.Text; import net.minecraft.text.TextColor; +import net.minecraft.util.Colors; import net.minecraft.util.StringHelper; +import net.minecraft.util.Uuids; import org.jetbrains.annotations.Nullable; +import java.lang.reflect.Field; import java.net.InetSocketAddress; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; public final class Commands { - @RequiredArgsConstructor - static class AccessControl { - private final AccessController controller; - public void grantAccess(ServerCommandSource sender, String name, @Nullable String ip) { - if (!sender.hasPermissionLevel(2)) { - sender.sendMessage(Text.of("Permission denied.")); - return; - } - if (!StringHelper.isValidPlayerName(name)) { - sender.sendMessage(Text.of("Invalid player name.")); - return; - } - if (ip == null) { - sender.sendMessage(Text.of("You're granting ANY access to player " + name + "!")); - controller.grantAccess(name, null); - } else { - controller.grantAccess(name, ip); - } - sender.sendMessage(Text.of(name + ": Access granted.")); - } + public Commands() { + ServerPlayConnectionEvents.DISCONNECT.register(this::onDisconnect); + } - public void revokeAccess(ServerCommandSource sender, String name) { - controller.revokeAccess(name); - sender.sendMessage(Text.of(name + ": Permission revoked.")); - } + private void onDisconnect(ServerPlayNetworkHandler serverPlayNetworkHandler, MinecraftServer minecraftServer) { + serverPlayNetworkHandler.getPlayer().removeCommandTag(SFConsts.SPECIAL_SUDO); + } - public void listAccesses(ServerCommandSource sender) { - controller.getEntries().forEach(it -> sender.sendMessage(Text.of(it.name() + ": " + (it.condition() == null ? "ANY" : it.condition())))); + public int addPlayerOffline(ServerCommandSource source, String player) { + player = player.trim(); + var pm = source.getServer().getPlayerManager(); + var wl = pm.getWhitelist(); + if (List.of(wl.getNames()).contains(player)) { + source.sendError(Text.of(player + " is already whitelisted or this id is conflict with a online user.")); + return 0; } + var profile = Uuids.getOfflinePlayerProfile(player); + wl.add(new WhitelistEntry(profile)); + source.sendMessage(Text.of("[" + profile.getName() + "/" + profile.getId() + "]" + " is added!")); + return 0; + } + + public int onBack(CommandContext it) { + var player = it.getSource().getPlayer(); + var pos = player.getLastDeathPos().get(); + var wld = player.server.getWorld(pos.dimension()); + var _pos = pos.pos(); + if (wld == null) return 0; + var nearby = wld.getClosestPlayer(_pos.getX(), _pos.getY(), _pos.getZ(), 20, true); + if (nearby != null) { + _pos = nearby.getBlockPos(); + player.teleport(wld, _pos.getX(), _pos.getY(), _pos.getZ(), 0, 0); + } else { + if (SFConsts.UNLIMITED_COMMAND_BACK.hasPermission(player)) { + player.teleport(wld, pos.pos().getX(), pos.pos().getY(), pos.pos().getZ(), 0, 0); + return 0; + } + player.sendMessage(Text.of("周围没有玩家。").copy().withColor(Colors.RED)); + } + return 0; + } + + public int unblockServer(CommandContext ctx) { + var state = SFCraft.getInstance().getConfig().disableTemporarily; + SFCraft.getInstance().getConfig().disableTemporarily = !state; + ctx.getSource().sendMessage(Text.of("Current state: " + !state)); + return 0; + } + + public int enterSudoMode(CommandContext serverCommandSourceCommandContext) { + var player = serverCommandSourceCommandContext.getSource().getPlayer(); + if (player.getCommandTags().contains(SFConsts.SPECIAL_SUDO)) { + player.removeCommandTag(SFConsts.SPECIAL_SUDO); + player.sendMessage(Text.of("Sudo is off.").copy().withColor(Colors.GREEN)); + } else { + player.addCommandTag(SFConsts.SPECIAL_SUDO); + player.sendMessage(Text.of("Sudo is on.").copy().withColor(Colors.GREEN)); + } + return 0; + } + + @SneakyThrows + public int listPerms(CommandContext serverCommandSourceCommandContext) { + var src = serverCommandSourceCommandContext.getSource(); + src.sendMessage(Text.of("Permissions: ").copy().withColor(Colors.GREEN)); + for (Field declaredField : SFConsts.class.getDeclaredFields()) { + if (Permission.class.isAssignableFrom(declaredField.getType())) { + var permission = (Permission) declaredField.get(null); + src.sendMessage(Text.of(" - " + permission.key() + " " + (permission.byDefault() ? "(default)" : ""))); + } + } + return 0; + } + + public int listOffline(CommandContext ctx) { + var source = ctx.getSource(); + var server = source.getServer(); + var wl = server.getPlayerManager().getWhitelist(); + var names = wl.getNames(); + source.sendMessage(Text.of("Offline Users:").copy().withColor(Colors.GREEN)); + var r = Stream.of(names).filter(name -> wl.isAllowed(Uuids.getOfflinePlayerProfile(name))).collect(Collectors.joining(", ")); + source.sendMessage(Text.of(r.isEmpty() ? "No offline users found in whitelist." : r)); + return 0; } } diff --git a/src/main/java/io/ib67/sfcraft/Helper.java b/src/main/java/io/ib67/sfcraft/Helper.java new file mode 100644 index 0000000..6311710 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/Helper.java @@ -0,0 +1,31 @@ +package io.ib67.sfcraft; + +import com.maxmind.geoip2.exception.GeoIp2Exception; +import net.minecraft.server.network.ServerPlayerEntity; + +import java.net.InetSocketAddress; + +public class Helper { + public static boolean canBack(ServerPlayerEntity player) { + if (!SFConsts.COMMAND_BACK.hasPermission(player)) return false; + var pos = player.getLastDeathPos().get(); + var wld = player.server.getWorld(pos.dimension()); + var _pos = pos.pos(); + if (wld == null) return false; + var nearby = wld.getClosestPlayer(_pos.getX(), _pos.getY(), _pos.getZ(), 20, true); + return nearby != null; + } + + public static String getVersionString(InetSocketAddress address) { + try { + var clock = SFCraft.getInstance().getGeoIPService().clockOf(address.getAddress()); + var config = SFCraft.getInstance().getConfig(); + if (config.isClosed(clock)) { + return "防沉迷: " + config.maintainceStartHour + "~" + config.maintainceEndHour; + } + } catch (GeoIp2Exception ignored) { + return "Not Available!"; + } + return null; + } +} diff --git a/src/main/java/io/ib67/sfcraft/Listener.java b/src/main/java/io/ib67/sfcraft/Listener.java index 8e7e7f8..eebe5a4 100644 --- a/src/main/java/io/ib67/sfcraft/Listener.java +++ b/src/main/java/io/ib67/sfcraft/Listener.java @@ -1,17 +1,32 @@ package io.ib67.sfcraft; -import com.maxmind.geoip2.record.City; +import com.maxmind.geoip2.exception.GeoIp2Exception; import lombok.SneakyThrows; -import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.PacketSender; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.WrittenBookContentComponent; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; import net.minecraft.network.ClientConnection; -import net.minecraft.network.DisconnectionInfo; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.ServerMetadata; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.RawFilteredPair; import net.minecraft.text.Text; +import net.minecraft.util.Uuids; import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.time.Clock; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; import java.util.function.Consumer; public class Listener { + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); private final SFCraft core; public Listener(SFCraft core) { @@ -20,22 +35,104 @@ public class Listener { @SneakyThrows public boolean onPlayerLogin(String currentPlayer, ClientConnection connection, Consumer disconnect) { + var wl = core.getServer().getPlayerManager().getWhitelist(); + var offlineProfile = Uuids.getOfflinePlayerProfile(currentPlayer); if (connection.getAddress() instanceof InetSocketAddress address) { - var clock = core.getGeoIPService().clockOf(address.getAddress()); - var hourOfDay = ZonedDateTime.now(clock).getHour(); - var startHr = SFCraft.getInstance().getConfig().maintainceStartHour; - var endHr = SFCraft.getInstance().getConfig().maintainceEndHour; - if (startHr > hourOfDay && hourOfDay > endHr) { - final var msg = "服务器尚未开放\n服务器维护时间: %1$d~%2$d\n你的下午不值得浪费在游戏上,晚点再来吧!"; - disconnect.accept(Text.of(String.format(msg, startHr, endHr))); - return false; - } - var canJoin = core.getAccessController().checkAccess(currentPlayer, address.getAddress().getHostAddress()); - if (!canJoin) { - final var msg = ""; - disconnect.accept(Text.of(String.format(msg, startHr, endHr))); + try { + var clock = core.getGeoIPService().clockOf(address.getAddress()); + var config = core.getConfig(); + if (config.isClosed(clock) && !"iceBear67".equals(currentPlayer)) { + final var msg = "服务器尚未开放\n服务器维护时间: %1$d~%2$d\n你的下午不值得浪费在游戏上,晚点再来吧!"; + disconnect.accept(Text.of(String.format(msg, config.maintainceStartHour, config.maintainceEndHour))); + } + } catch (Exception ignored) { + System.err.println("Failed to locate " + address.getAddress()); } } - return true; + return wl.isAllowed(offlineProfile); + } + + public void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) { + if (Helper.canBack(player)) { + player.sendMessage(Text.of("Tip: 死亡地点附近有玩家,可以使用 /back 传送到他们那里。(即使在对方也死亡的状态下)")); + } + } + + public void onPlayerJoin(ServerPlayNetworkHandler serverPlayNetworkHandler, PacketSender + packetSender, MinecraftServer minecraftServer) { + var player = serverPlayNetworkHandler.getPlayer(); + if (!player.getCommandTags().contains("has_joined_before")) { + player.addCommandTag("has_joined_before"); + player.sendMessage(Text.of("欢迎加入 SaltedFish Club Server!")); + player.sendMessage(Text.of("您受本服务器成员邀请并第一次加入本服务器,以下是一些注意事项:")); + player.sendMessage(Text.of(" 1. 服务器内有 base,但我们也鼓励您自立门户")); + player.sendMessage(Text.of(" 2. 服务器 QQ 群: (qq)")); + player.sendMessage(Text.of(" 3. 下午 " + SFCraft.getInstance().getConfig().maintainceStartHour + " ~ " + SFCraft.getInstance().getConfig().maintainceEndHour + " 为服务器维护时间(防沉迷),在这段时间内无法加入服务器。")); + player.sendMessage(Text.of("该时间由您 IP 属地的时区决定。我们不希望您的下午仅仅荒废在服务器上。")); + player.sendMessage(Text.of("如果您想邀请朋友加入服务器,可以向管理员提出申请。本服务器为正版服,但是离线玩家也可以加入(需备注)。")); + player.sendMessage(Text.of("为了账号安全起见,请尽量使用正版账号登录后加入。此外,正/盗同名将会被视为两个不同的玩家存档,数据不互通。")); + player.giveItemStack(generateHelperBook(core.getUpdateLog(), player)); + unlockRecipe(player); + } + } + + private ItemStack generateHelperBook(String updateLog, ServerPlayerEntity player) { + Clock clock = null; + if (player.networkHandler.getConnectionAddress() instanceof InetSocketAddress addr) { + try { + clock = SFCraft.getInstance().getGeoIPService().clockOf(addr.getAddress()); + } catch (GeoIp2Exception e) { + clock = Clock.systemDefaultZone(); + } + } + var book = new ItemStack(Items.WRITTEN_BOOK); + var updLog = new ArrayList(); + updLog.add("本书修订截至至您加入服务器的那一刻 (" + FMT.format(ZonedDateTime.now(clock)) + ")\n" + "更多内容请查阅群公告"); + var pages = updateLog.length() / 100; + for (int i = 0; i < pages; i++) { + updLog.add(updateLog.substring(i * 100, (i + 1) * 100)); + } + updLog.add(updateLog.substring(pages * 100, updateLog.length())); + var component = new WrittenBookContentComponent( + RawFilteredPair.of("特性列表"), + "icybear & You", + 1, + updLog.stream().map(it -> RawFilteredPair.of(Text.of(it))).toList(), + false + ); + book.set(DataComponentTypes.WRITTEN_BOOK_CONTENT, component); + return book; + } + + private void unlockRecipe(ServerPlayerEntity player) { + var recipe = player.getRecipeBook(); + recipe.unlockRecipes(player.server.getRecipeManager().values(), player); + } + + private final Set motdRequestedIps = new HashSet<>(); + + public ServerMetadata onMotdPing(MinecraftServer instance, ClientConnection connection) { + var ip = connection.getAddressAsString(false); + var cached = motdRequestedIps.contains(ip); + if (!cached) { + motdRequestedIps.add(ip); + } + var originalMetadata = instance.getServerMetadata(); + if (originalMetadata == null) return null; + String version = null; + if (connection.getAddress() instanceof InetSocketAddress address) { + version = Helper.getVersionString(address); + } + if (version != null) { + return new ServerMetadata( + originalMetadata.description(), + originalMetadata.players(), + Optional.of(new ServerMetadata.Version(version, 0)), + cached ? Optional.empty() : originalMetadata.favicon(), + originalMetadata.secureChatEnforced() + ); + } else { + return originalMetadata; + } } } diff --git a/src/main/java/io/ib67/sfcraft/Permission.java b/src/main/java/io/ib67/sfcraft/Permission.java new file mode 100644 index 0000000..28bbfef --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/Permission.java @@ -0,0 +1,49 @@ +package io.ib67.sfcraft; + +import lombok.Getter; +import net.minecraft.entity.Entity; + +import java.util.Objects; + +/** + * 基于 Command Tags 实现的权限系统 + * 权限节点通常以 `.` 切割并且总是在 `sfcraft` 分类下,例如 `sfcraft.back` + * 用 `-权限节点` 表示禁止使用 + * + * @param 对象 + */ +public record Permission(String key, boolean byDefault) { + /** + * @param key 权限节点 + */ + public Permission(String key, boolean byDefault) { + this.key = Objects.requireNonNull(key).trim().toLowerCase(); + this.byDefault = byDefault; + } + + public boolean hasPermission(T t) { + if (t.getCommandTags().contains(SFConsts.SPECIAL_SUDO)) return true; + if (byDefault) { + return !t.getCommandTags().contains("-" + key); + } + return t.getCommandTags().contains(key); + } + + public void grant(T t) { + if (!hasPermission(t)) { + t.getCommandTags().remove("-" + key); + if (!byDefault) { + t.addCommandTag(key); + } + } + } + + public void revoke(T t) { + if (hasPermission(t)) { + t.removeCommandTag(key); + if (byDefault) { + t.addCommandTag("-" + key); + } + } + } +} diff --git a/src/main/java/io/ib67/sfcraft/SFConfig.java b/src/main/java/io/ib67/sfcraft/SFConfig.java deleted file mode 100644 index eb30902..0000000 --- a/src/main/java/io/ib67/sfcraft/SFConfig.java +++ /dev/null @@ -1,6 +0,0 @@ -package io.ib67.sfcraft; - -public class SFConfig { - public int maintainceStartHour = 17; - public int maintainceEndHour = 1; -} diff --git a/src/main/java/io/ib67/sfcraft/SFConsts.java b/src/main/java/io/ib67/sfcraft/SFConsts.java new file mode 100644 index 0000000..be0ef5c --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/SFConsts.java @@ -0,0 +1,21 @@ +package io.ib67.sfcraft; + +import lombok.experimental.UtilityClass; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; + +@UtilityClass +public class SFConsts { + public static final String SPECIAL_SUDO = "sfcraft.special.state.sudo"; + public static final Permission COMMAND_BACK = ofSFCPermission("command.home", true); + public static final Permission UNLIMITED_COMMAND_BACK = ofSFCPermission("command.home.unlimited", true); + + public static final Permission COMMAND_ADDWL = ofSFCPermission("command.addwl", false); + public static final Permission COMMAND_UNBLOCKSERVER = ofSFCPermission("command.unblockserver", false); + public static final Permission COMMAND_LISTOFFLINE = ofSFCPermission("command.listoffline", false); + public static final Permission COMMAND_LISTPERM = ofSFCPermission("command.listperm", false); + + public static Permission ofSFCPermission(String key, boolean byDefault) { + return new Permission<>("sfcraft." + key, byDefault); + } +} diff --git a/src/main/java/io/ib67/sfcraft/SFCraft.java b/src/main/java/io/ib67/sfcraft/SFCraft.java index 2c81532..986f383 100644 --- a/src/main/java/io/ib67/sfcraft/SFCraft.java +++ b/src/main/java/io/ib67/sfcraft/SFCraft.java @@ -5,15 +5,14 @@ import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import io.ib67.sfcraft.access.AccessController; -import io.ib67.sfcraft.access.GrantedEntry; -import io.ib67.sfcraft.access.impl.SimpleAccessController; +import io.ib67.sfcraft.config.SFConfig; import io.ib67.sfcraft.geoip.GeoIPService; import lombok.Getter; import lombok.SneakyThrows; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.minecraft.command.CommandRegistryAccess; import net.minecraft.server.MinecraftServer; import net.minecraft.server.command.CommandManager; @@ -21,93 +20,111 @@ import net.minecraft.server.command.ServerCommandSource; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; -import java.util.stream.Collectors; public class SFCraft implements ModInitializer { @Getter private SFConfig config; @Getter - private static Listener listener; + private Listener listener; @Getter private static SFCraft instance; @Getter private GeoIPService geoIPService; @Getter - private AccessController accessController; - @Getter private Path root; + @Getter + private MinecraftServer server; + @Getter + private String motd; + @Getter + private String updateLog; @Override @SneakyThrows public void onInitialize() { instance = this; - listener = new Listener(this); root = Path.of("sfcraft"); if (Files.notExists(root)) { Files.createDirectory(root); } - loadConfig(); + listener = new Listener(this); + config = loadConfig(); + motd = loadMotd(); + updateLog = loadUpdateLog(); geoIPService = new GeoIPService(); - accessController = loadAccessController(); + + ServerPlayConnectionEvents.JOIN.register(listener::onPlayerJoin); CommandRegistrationCallback.EVENT.register(this::registerCommands); + ServerLifecycleEvents.SERVER_STARTED.register(this::onServerStarted); + } + + private void onServerStarted(MinecraftServer minecraftServer) { + server = minecraftServer; + server.setMotd(motd); } @SneakyThrows - private void loadConfig() { + private String loadUpdateLog() { + var log = root.resolve("update.log"); + if (Files.notExists(log)) { + return "他没写呢"; + } + return Files.readString(log); + } + + @SneakyThrows + private String loadMotd() { + var motd = root.resolve("motd.txt"); + if (Files.notExists(motd)) { + Files.writeString(motd, "§bSaltedFish Club Server"); + } + return Files.readString(motd); + } + + @SneakyThrows + private SFConfig loadConfig() { var gson = new Gson(); var cfgFile = root.resolve("config.json"); if (Files.notExists(cfgFile)) { Files.writeString(cfgFile, gson.toJson(new SFConfig())); - config = new SFConfig(); - return; + return new SFConfig(); } - config = gson.fromJson(Files.readString(cfgFile), SFConfig.class); - } - - @SneakyThrows - private AccessController loadAccessController() { - var file = root.resolve("access.txt"); - ServerLifecycleEvents.SERVER_STOPPING.register(this::saveAccessRules); - if (Files.notExists(file)) { - return new SimpleAccessController(List.of()); - } - return new SimpleAccessController(Files.readAllLines(file).stream().map(it -> it.split(":")).filter(it -> it.length == 2).map(it -> new GrantedEntry(it[0].trim(), it[1].trim())).toList()); - } - - @SneakyThrows - private void saveAccessRules(MinecraftServer minecraftServer) { - var file = root.resolve("access.txt"); - Files.writeString(file, accessController.getEntries().stream().map(i -> i.name() + ":" + i.condition()).collect(Collectors.joining("\n"))); + return gson.fromJson(Files.readString(cfgFile), SFConfig.class); } private void registerCommands(CommandDispatcher dispatcher, CommandRegistryAccess commandRegistryAccess, CommandManager.RegistrationEnvironment registrationEnvironment) { - var accessControl = new Commands.AccessControl(accessController); + var handler = new Commands(); + + // 一般非玩家实体都有 >= 2 的 PermissionLevel,因此不需要额外检查。 dispatcher.register( - LiteralArgumentBuilder.literal("access") - .requires(it -> it.hasPermissionLevel(2)) - .then(LiteralArgumentBuilder.literal("grant") - .then(RequiredArgumentBuilder.argument("name", StringArgumentType.string()).executes(it -> { - accessControl.grantAccess(it.getSource(), it.getArgument("name", String.class), null); - return 0; - }).then(RequiredArgumentBuilder.argument("ip", StringArgumentType.string()).executes(it -> { - accessControl.grantAccess(it.getSource(), it.getArgument("name", String.class), it.getArgument("ip", String.class)); - return 0; - })))) - .then(LiteralArgumentBuilder.literal("list") - .executes(it -> { - accessControl.listAccesses(it.getSource()); - return 0; - }) - ).then(LiteralArgumentBuilder.literal("revoke") - .then(RequiredArgumentBuilder.argument("name", StringArgumentType.string()) - .executes(it -> { - accessControl.revokeAccess(it.getSource(), it.getArgument("name", String.class)); - return 0; - })) + LiteralArgumentBuilder.literal("addwl") + .requires(it -> it.hasPermissionLevel(2) || SFConsts.COMMAND_ADDWL.hasPermission(it.getPlayer())) + .then( + RequiredArgumentBuilder.argument("player", StringArgumentType.string()) + .executes(it -> handler.addPlayerOffline(it.getSource(), it.getArgument("player", String.class))) ) ); + dispatcher.register(LiteralArgumentBuilder.literal("back") + .requires(it -> it.getPlayer() != null) + .requires(it -> it.getPlayer().getLastDeathPos().isPresent()) + .requires(it -> SFConsts.COMMAND_BACK.hasPermission(it.getPlayer())) + .executes(handler::onBack)); + dispatcher.register(LiteralArgumentBuilder.literal("unblockserver") + .requires(it -> it.hasPermissionLevel(2) || SFConsts.COMMAND_UNBLOCKSERVER.hasPermission(it.getPlayer())) + .executes(handler::unblockServer)); + dispatcher.register(LiteralArgumentBuilder.literal("sudo") + .requires(it -> it.hasPermissionLevel(2) && it.getPlayer() != null) + .executes(handler::enterSudoMode) + ); + dispatcher.register(LiteralArgumentBuilder.literal("listperm") + .requires(it->it.hasPermissionLevel(2) || SFConsts.COMMAND_LISTPERM.hasPermission(it.getPlayer())) + .executes(handler::listPerms) + ); + dispatcher.register(LiteralArgumentBuilder.literal("listoffline") + .requires(it->it.hasPermissionLevel(2) || SFConsts.COMMAND_LISTOFFLINE.hasPermission(it.getPlayer())) + .executes(handler::listOffline) + ); } } diff --git a/src/main/java/io/ib67/sfcraft/access/AccessController.java b/src/main/java/io/ib67/sfcraft/access/AccessController.java deleted file mode 100644 index 7969d93..0000000 --- a/src/main/java/io/ib67/sfcraft/access/AccessController.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.ib67.sfcraft.access; - -import com.mojang.authlib.GameProfile; - -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.util.Collection; -import java.util.List; - -public interface AccessController { - boolean checkAccess(String name, String address); - - void grantAccess(String name, String condition); - - void revokeAccess(String name); - - Collection getEntries(); -} diff --git a/src/main/java/io/ib67/sfcraft/access/GrantedEntry.java b/src/main/java/io/ib67/sfcraft/access/GrantedEntry.java deleted file mode 100644 index c632f9c..0000000 --- a/src/main/java/io/ib67/sfcraft/access/GrantedEntry.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.ib67.sfcraft.access; - -import com.mojang.authlib.GameProfile; - -import java.net.InetSocketAddress; - -public record GrantedEntry(String name, String condition) { -} diff --git a/src/main/java/io/ib67/sfcraft/access/impl/SimpleAccessController.java b/src/main/java/io/ib67/sfcraft/access/impl/SimpleAccessController.java deleted file mode 100644 index afeb233..0000000 --- a/src/main/java/io/ib67/sfcraft/access/impl/SimpleAccessController.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.ib67.sfcraft.access.impl; - -import com.google.common.collect.Multimap; -import com.google.common.collect.Multimaps; -import io.ib67.sfcraft.access.AccessController; -import io.ib67.sfcraft.access.GrantedEntry; - -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.ArrayList; -import java.util.stream.Collectors; - -public class SimpleAccessController implements AccessController { - private final Multimap whitelist = Multimaps.newListMultimap(new HashMap<>(), ArrayList::new); - - public SimpleAccessController(List entries) { - entries.forEach(i -> whitelist.put(i.name(), i.condition())); - } - - @Override - public boolean checkAccess(String profile, String address) { - return whitelist.containsEntry(profile, address) || whitelist.containsEntry(profile, null); - } - - @Override - public void grantAccess(String profile, String condition) { - whitelist.put(profile, condition); - } - - @Override - public void revokeAccess(String profile) { - whitelist.removeAll(profile); - } - - @Override - public Collection getEntries() { - return whitelist.asMap().entrySet() - .stream().flatMap(it -> it.getValue().stream().map(i -> new GrantedEntry(it.getKey(), i))) - .collect(Collectors.toSet()); - } -} diff --git a/src/main/java/io/ib67/sfcraft/config/SFConfig.java b/src/main/java/io/ib67/sfcraft/config/SFConfig.java new file mode 100644 index 0000000..4f68d80 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/config/SFConfig.java @@ -0,0 +1,19 @@ +package io.ib67.sfcraft.config; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +public class SFConfig { + public int maintainceStartHour = 13; + public int maintainceEndHour = 17; + public boolean disableTemporarily; + + public boolean isClosed(Clock clock) { + if (disableTemporarily) return false; + var hourOfDay = ZonedDateTime.now(clock).getHour(); + var startHr = maintainceStartHour; + var endHr = maintainceEndHour; + return startHr < hourOfDay && hourOfDay < endHr; + } +} diff --git a/src/main/java/io/ib67/sfcraft/geoip/GeoIPService.java b/src/main/java/io/ib67/sfcraft/geoip/GeoIPService.java index 1c007de..2c68776 100644 --- a/src/main/java/io/ib67/sfcraft/geoip/GeoIPService.java +++ b/src/main/java/io/ib67/sfcraft/geoip/GeoIPService.java @@ -1,5 +1,6 @@ package io.ib67.sfcraft.geoip; +import com.maxmind.db.CHMCache; import com.maxmind.geoip2.DatabaseReader; import com.maxmind.geoip2.exception.GeoIp2Exception; import com.maxmind.geoip2.record.Country; @@ -26,8 +27,8 @@ import static java.net.http.HttpResponse.BodyHandlers.ofFile; @Log4j2 public class GeoIPService implements Closeable { - private static final URI GEOIP_DOWNLOAD_URL = URI.create("https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/Country.mmdb"); - private static final Path COUNTRY_MMDB_PATH = SFCraft.getInstance().getRoot().resolve("country.mmdb"); + private static final URI GEOIP_DOWNLOAD_URL = URI.create("http://sfclub.cc/GeoLite2-City.mmdb"); + private static final Path COUNTRY_MMDB_PATH = SFCraft.getInstance().getRoot().resolve("GeoLite2-City.mmdb"); private final DatabaseReader databaseReader; public GeoIPService() throws InterruptedException { @@ -36,12 +37,12 @@ public class GeoIPService implements Closeable { @SneakyThrows public Clock clockOf(InetAddress address) throws GeoIp2Exception { - return Clock.system(TimeZone.getTimeZone(databaseReader.country(address).getCountry().getIsoCode()).toZoneId()); + return Clock.system(TimeZone.getTimeZone(databaseReader.city(address).getLocation().getTimeZone()).toZoneId()); } private DatabaseReader loadData() throws InterruptedException { try { - return new DatabaseReader.Builder(COUNTRY_MMDB_PATH.toFile()).build(); + return new DatabaseReader.Builder(COUNTRY_MMDB_PATH.toFile()).withCache(new CHMCache()).build(); } catch (IOException ignored) { return updateData(); } diff --git a/src/main/java/io/ib67/sfcraft/mixin/server/LegacyQueryHandlerMixin.java b/src/main/java/io/ib67/sfcraft/mixin/server/LegacyQueryHandlerMixin.java new file mode 100644 index 0000000..78a6d2a --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/server/LegacyQueryHandlerMixin.java @@ -0,0 +1,42 @@ +package io.ib67.sfcraft.mixin.server; + +import io.ib67.sfcraft.Helper; +import io.netty.channel.ChannelHandlerContext; +import net.minecraft.network.QueryableServer; +import net.minecraft.network.handler.LegacyQueryHandler; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Locale; + +@Mixin(LegacyQueryHandler.class) +public class LegacyQueryHandlerMixin { + SocketAddress address; + + @Inject(method = "channelRead", at = @At("HEAD")) + public void onChannelRead(ChannelHandlerContext ctx, Object msg, CallbackInfo ci) { + address = ctx.channel().remoteAddress(); + } + + @Redirect(method = "channelRead", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/handler/LegacyQueryHandler;getResponse(Lnet/minecraft/network/QueryableServer;)Ljava/lang/String;")) + private String getResponse(QueryableServer server) { + var version = server.getVersion(); + if (address instanceof InetSocketAddress address) { + version = Helper.getVersionString(address); + } + return String.format( + Locale.ROOT, + "§1\u0000%d\u0000%s\u0000%s\u0000%d\u0000%d", + 127, + version, + server.getServerMotd(), + server.getCurrentPlayerCount(), + server.getMaxPlayerCount() + ); + } +} diff --git a/src/main/java/io/ib67/sfcraft/mixin/server/PlayerEntityMixin.java b/src/main/java/io/ib67/sfcraft/mixin/server/PlayerEntityMixin.java new file mode 100644 index 0000000..e9d336f --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/server/PlayerEntityMixin.java @@ -0,0 +1,18 @@ +package io.ib67.sfcraft.mixin.server; + +import io.ib67.sfcraft.SFCraft; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerPlayerEntity.class) +public class PlayerEntityMixin { + @Inject(method = "onDeath", at = @At("TAIL")) + public void onDeath(DamageSource damageSource, CallbackInfo ci) { + SFCraft.getInstance().getListener().onPlayerDeath((ServerPlayerEntity) (Object) this, damageSource); + } +} diff --git a/src/main/java/io/ib67/sfcraft/mixin/server/ServerHandshakeNetworkHandlerMixin.java b/src/main/java/io/ib67/sfcraft/mixin/server/ServerHandshakeNetworkHandlerMixin.java new file mode 100644 index 0000000..d8b41b3 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/server/ServerHandshakeNetworkHandlerMixin.java @@ -0,0 +1,24 @@ +package io.ib67.sfcraft.mixin.server; + +import io.ib67.sfcraft.SFCraft; +import net.minecraft.network.ClientConnection; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.ServerMetadata; +import net.minecraft.server.network.ServerHandshakeNetworkHandler; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ServerHandshakeNetworkHandler.class) +public abstract class ServerHandshakeNetworkHandlerMixin { + @Shadow + @Final + private ClientConnection connection; + + @Redirect(method = "onHandshake", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;getServerMetadata()Lnet/minecraft/server/ServerMetadata;")) + public ServerMetadata getMetadata(MinecraftServer instance) { + return SFCraft.getInstance().getListener().onMotdPing(instance,connection); + } +} diff --git a/src/main/java/io/ib67/sfcraft/mixin/server/ServerLoginNetworkHandlerMixin.java b/src/main/java/io/ib67/sfcraft/mixin/server/ServerLoginNetworkHandlerMixin.java index 3023768..0b22457 100644 --- a/src/main/java/io/ib67/sfcraft/mixin/server/ServerLoginNetworkHandlerMixin.java +++ b/src/main/java/io/ib67/sfcraft/mixin/server/ServerLoginNetworkHandlerMixin.java @@ -26,7 +26,7 @@ public abstract class ServerLoginNetworkHandlerMixin { @Redirect(method = "onHello", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/ClientConnection;isLocal()Z")) public boolean isLocal(ClientConnection connection) { - return SFCraft.getListener().onPlayerLogin(currentPlayer, connection, this::disconnect); + return SFCraft.getInstance().getListener().onPlayerLogin(currentPlayer, connection, this::disconnect); } diff --git a/src/main/resources/sfcraft.mixins.json b/src/main/resources/sfcraft.mixins.json index d01702e..bb2b5fd 100644 --- a/src/main/resources/sfcraft.mixins.json +++ b/src/main/resources/sfcraft.mixins.json @@ -8,6 +8,9 @@ "client": [ ], "server": [ + "server.ServerHandshakeNetworkHandlerMixin", + "server.LegacyQueryHandlerMixin", + "server.PlayerEntityMixin", "server.ServerLoginNetworkHandlerMixin" ], "injectors": {