init.
This commit is contained in:
1 parent
16fd49be35
commit
b1a611a3e6
20 files changed
+519
-168
No files matched your search
@@ -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)。
|
||||

|
||||
- 人文关怀
|
||||
对于一些年龄比较小的玩家还有一些不太熟悉 MC 的玩家,我们特地设计了一些赦免权,例如无限制的 `/back` 命令。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+16
-1
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
public Commands() {
|
||||
ServerPlayConnectionEvents.DISCONNECT.register(this::onDisconnect);
|
||||
}
|
||||
if (!StringHelper.isValidPlayerName(name)) {
|
||||
sender.sendMessage(Text.of("Invalid player name."));
|
||||
return;
|
||||
|
||||
private void onDisconnect(ServerPlayNetworkHandler serverPlayNetworkHandler, MinecraftServer minecraftServer) {
|
||||
serverPlayNetworkHandler.getPlayer().removeCommandTag(SFConsts.SPECIAL_SUDO);
|
||||
}
|
||||
if (ip == null) {
|
||||
sender.sendMessage(Text.of("You're granting ANY access to player " + name + "!"));
|
||||
controller.grantAccess(name, null);
|
||||
|
||||
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<ServerCommandSource> 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 {
|
||||
controller.grantAccess(name, ip);
|
||||
if (SFConsts.UNLIMITED_COMMAND_BACK.hasPermission(player)) {
|
||||
player.teleport(wld, pos.pos().getX(), pos.pos().getY(), pos.pos().getZ(), 0, 0);
|
||||
return 0;
|
||||
}
|
||||
sender.sendMessage(Text.of(name + ": Access granted."));
|
||||
player.sendMessage(Text.of("周围没有玩家。").copy().withColor(Colors.RED));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void revokeAccess(ServerCommandSource sender, String name) {
|
||||
controller.revokeAccess(name);
|
||||
sender.sendMessage(Text.of(name + ": Permission revoked."));
|
||||
public int unblockServer(CommandContext<ServerCommandSource> ctx) {
|
||||
var state = SFCraft.getInstance().getConfig().disableTemporarily;
|
||||
SFCraft.getInstance().getConfig().disableTemporarily = !state;
|
||||
ctx.getSource().sendMessage(Text.of("Current state: " + !state));
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void listAccesses(ServerCommandSource sender) {
|
||||
controller.getEntries().forEach(it -> sender.sendMessage(Text.of(it.name() + ": " + (it.condition() == null ? "ANY" : it.condition()))));
|
||||
public int enterSudoMode(CommandContext<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Text> disconnect) {
|
||||
var wl = core.getServer().getPlayerManager().getWhitelist();
|
||||
var offlineProfile = Uuids.getOfflinePlayerProfile(currentPlayer);
|
||||
if (connection.getAddress() instanceof InetSocketAddress address) {
|
||||
try {
|
||||
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) {
|
||||
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, startHr, endHr)));
|
||||
return false;
|
||||
disconnect.accept(Text.of(String.format(msg, config.maintainceStartHour, config.maintainceEndHour)));
|
||||
}
|
||||
var canJoin = core.getAccessController().checkAccess(currentPlayer, address.getAddress().getHostAddress());
|
||||
if (!canJoin) {
|
||||
final var msg = "";
|
||||
disconnect.accept(Text.of(String.format(msg, startHr, endHr)));
|
||||
} 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<String>();
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <T> 对象
|
||||
*/
|
||||
public record Permission<T extends Entity>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package io.ib67.sfcraft;
|
||||
|
||||
public class SFConfig {
|
||||
public int maintainceStartHour = 17;
|
||||
public int maintainceEndHour = 1;
|
||||
}
|
||||
@@ -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<PlayerEntity> COMMAND_BACK = ofSFCPermission("command.home", true);
|
||||
public static final Permission<PlayerEntity> UNLIMITED_COMMAND_BACK = ofSFCPermission("command.home.unlimited", true);
|
||||
|
||||
public static final Permission<PlayerEntity> COMMAND_ADDWL = ofSFCPermission("command.addwl", false);
|
||||
public static final Permission<PlayerEntity> COMMAND_UNBLOCKSERVER = ofSFCPermission("command.unblockserver", false);
|
||||
public static final Permission<PlayerEntity> COMMAND_LISTOFFLINE = ofSFCPermission("command.listoffline", false);
|
||||
public static final Permission<PlayerEntity> COMMAND_LISTPERM = ofSFCPermission("command.listperm", false);
|
||||
|
||||
public static Permission<PlayerEntity> ofSFCPermission(String key, boolean byDefault) {
|
||||
return new Permission<>("sfcraft." + key, byDefault);
|
||||
}
|
||||
}
|
||||
@@ -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<ServerCommandSource> dispatcher, CommandRegistryAccess commandRegistryAccess, CommandManager.RegistrationEnvironment registrationEnvironment) {
|
||||
var accessControl = new Commands.AccessControl(accessController);
|
||||
var handler = new Commands();
|
||||
|
||||
// 一般非玩家实体都有 >= 2 的 PermissionLevel,因此不需要额外检查。
|
||||
dispatcher.register(
|
||||
LiteralArgumentBuilder.<ServerCommandSource>literal("access")
|
||||
.requires(it -> it.hasPermissionLevel(2))
|
||||
.then(LiteralArgumentBuilder.<ServerCommandSource>literal("grant")
|
||||
.then(RequiredArgumentBuilder.<ServerCommandSource, String>argument("name", StringArgumentType.string()).executes(it -> {
|
||||
accessControl.grantAccess(it.getSource(), it.getArgument("name", String.class), null);
|
||||
return 0;
|
||||
}).then(RequiredArgumentBuilder.<ServerCommandSource, String>argument("ip", StringArgumentType.string()).executes(it -> {
|
||||
accessControl.grantAccess(it.getSource(), it.getArgument("name", String.class), it.getArgument("ip", String.class));
|
||||
return 0;
|
||||
}))))
|
||||
.then(LiteralArgumentBuilder.<ServerCommandSource>literal("list")
|
||||
.executes(it -> {
|
||||
accessControl.listAccesses(it.getSource());
|
||||
return 0;
|
||||
})
|
||||
).then(LiteralArgumentBuilder.<ServerCommandSource>literal("revoke")
|
||||
.then(RequiredArgumentBuilder.<ServerCommandSource, String>argument("name", StringArgumentType.string())
|
||||
.executes(it -> {
|
||||
accessControl.revokeAccess(it.getSource(), it.getArgument("name", String.class));
|
||||
return 0;
|
||||
}))
|
||||
LiteralArgumentBuilder.<ServerCommandSource>literal("addwl")
|
||||
.requires(it -> it.hasPermissionLevel(2) || SFConsts.COMMAND_ADDWL.hasPermission(it.getPlayer()))
|
||||
.then(
|
||||
RequiredArgumentBuilder.<ServerCommandSource, String>argument("player", StringArgumentType.string())
|
||||
.executes(it -> handler.addPlayerOffline(it.getSource(), it.getArgument("player", String.class)))
|
||||
)
|
||||
|
||||
);
|
||||
dispatcher.register(LiteralArgumentBuilder.<ServerCommandSource>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.<ServerCommandSource>literal("unblockserver")
|
||||
.requires(it -> it.hasPermissionLevel(2) || SFConsts.COMMAND_UNBLOCKSERVER.hasPermission(it.getPlayer()))
|
||||
.executes(handler::unblockServer));
|
||||
dispatcher.register(LiteralArgumentBuilder.<ServerCommandSource>literal("sudo")
|
||||
.requires(it -> it.hasPermissionLevel(2) && it.getPlayer() != null)
|
||||
.executes(handler::enterSudoMode)
|
||||
);
|
||||
dispatcher.register(LiteralArgumentBuilder.<ServerCommandSource>literal("listperm")
|
||||
.requires(it->it.hasPermissionLevel(2) || SFConsts.COMMAND_LISTPERM.hasPermission(it.getPlayer()))
|
||||
.executes(handler::listPerms)
|
||||
);
|
||||
dispatcher.register(LiteralArgumentBuilder.<ServerCommandSource>literal("listoffline")
|
||||
.requires(it->it.hasPermissionLevel(2) || SFConsts.COMMAND_LISTOFFLINE.hasPermission(it.getPlayer()))
|
||||
.executes(handler::listOffline)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<? extends GrantedEntry> getEntries();
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<String, String> whitelist = Multimaps.newListMultimap(new HashMap<>(), ArrayList::new);
|
||||
|
||||
public SimpleAccessController(List<GrantedEntry> 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<? extends GrantedEntry> getEntries() {
|
||||
return whitelist.asMap().entrySet()
|
||||
.stream().flatMap(it -> it.getValue().stream().map(i -> new GrantedEntry(it.getKey(), i)))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"client": [
|
||||
],
|
||||
"server": [
|
||||
"server.ServerHandshakeNetworkHandlerMixin",
|
||||
"server.LegacyQueryHandlerMixin",
|
||||
"server.PlayerEntityMixin",
|
||||
"server.ServerLoginNetworkHandlerMixin"
|
||||
],
|
||||
"injectors": {
|
||||
|
||||
Reference in new issue
Block a user