diff --git a/README.md b/README.md index 3185e11..f20be0c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Listenern, Befehlen und eigenem Config-Abschnitt. Braucht LuckPerms auf Velocity | `mute` | `/mute`, `/unmute`, LuckPerms-Permission `chatbridge.muted` | – | | `chat` | öffentlicher Chat, `/msg`, `/r`, `/adminchat`, Join/Leave, Chat-Einstellungen | `mute` | | `tablist` | Spieler anderer Server in der Tabliste | `chat` | +| `signup` | erster Server beim Join: Login-Server oder CraftAttack, je nach Anmeldung auf der Website | – | ## Aufbau diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/Signup.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/Signup.java new file mode 100644 index 0000000..bd51fa3 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/Signup.java @@ -0,0 +1,178 @@ +package eu.mhsl.craftattack.proxy.appliances.signup; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import com.velocitypowered.api.scheduler.ScheduledTask; +import eu.mhsl.craftattack.proxy.appliances.signup.SignupApi.Status; +import eu.mhsl.craftattack.proxy.core.Config; +import eu.mhsl.craftattack.proxy.core.appliance.Appliance; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public final class Signup extends Appliance { + enum Phase { BEFORE, LOGIN, OPEN, LATE } + + enum Target { LOGIN, CRAFTATTACK } + + private RegisteredServer login; + private RegisteredServer craftattack; + private RegisteredServer fallback; + private Duration pingTimeout; + private ZoneId zone; + private Instant registrationRequiredFrom; + private Instant openForAllFrom; + private Instant lateSignupFrom; + private SignupApi api; + private @Nullable ScheduledTask recheckTask; + private final Set checking = ConcurrentHashMap.newKeySet(); + + private static final DateTimeFormatter START_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm"); + + @Override + protected void onEnable() { + Config config = this.config(); + this.login = this.server(config.string("login-server", "login")); + this.craftattack = this.server(config.string("craftattack-server", "craftattack")); + this.fallback = this.server(config.string("fallback-server", "limbo")); + this.pingTimeout = Duration.ofSeconds(config.integer("ping-timeout-seconds", 3)); + + ZoneId zone = this.zone = ZoneId.of(config.string("timezone", "Europe/Berlin")); + this.registrationRequiredFrom = LocalDateTime.parse(config.string("registration-required-from", "2026-12-01T00:00")).atZone(zone).toInstant(); + this.openForAllFrom = LocalDateTime.parse(config.string("open-for-all-from", "2026-12-26T14:00")).atZone(zone).toInstant(); + this.lateSignupFrom = LocalDateTime.parse(config.string("late-signup-from", "2026-12-29T14:00")).atZone(zone).toInstant(); + if (!this.registrationRequiredFrom.isBefore(this.openForAllFrom) || !this.openForAllFrom.isBefore(this.lateSignupFrom)) + throw new IllegalArgumentException("signup: registration-required-from < open-for-all-from < late-signup-from erwartet"); + + this.api = new SignupApi( + config.string("api-url", ""), + config.string("late-signup-path", "signup"), + config.string("api-secret", ""), + Duration.ofSeconds(config.integer("timeout-seconds", 5)), + this.logger()); + + int recheck = config.integer("recheck-seconds", 10); + if (recheck > 0) this.recheckTask = this.task(this::recheck).repeat(Duration.ofSeconds(recheck)).schedule(); + } + + private RegisteredServer server(String name) { + return this.proxy().getServer(name).orElseThrow(() -> new IllegalArgumentException("Server " + name + " ist in Velocity nicht eingetragen")); + } + + Phase phase(Instant now) { + return phase(now, this.registrationRequiredFrom, this.openForAllFrom, this.lateSignupFrom); + } + + static Phase phase(Instant now, Instant registrationRequiredFrom, Instant openForAllFrom, Instant lateSignupFrom) { + if (now.isBefore(registrationRequiredFrom)) return Phase.BEFORE; + if (now.isBefore(openForAllFrom)) return Phase.LOGIN; + if (now.isBefore(lateSignupFrom)) return Phase.OPEN; + return Phase.LATE; + } + + static Target target(Phase phase, Status registered, Status signedUp) { + if (registered == Status.FOUND || registered == Status.BLOCKED) return Target.CRAFTATTACK; + return switch (phase) { + case BEFORE, OPEN -> Target.CRAFTATTACK; + case LOGIN -> Target.LOGIN; + case LATE -> signedUp == Status.NOT_FOUND ? Target.CRAFTATTACK : Target.LOGIN; + }; + } + + CompletableFuture decide(Player player) { + Phase phase = this.phase(Instant.now()); + // Vor dem 1.12. und im offenen Zeitraum braucht es die Website nicht + if (phase == Phase.BEFORE || phase == Phase.OPEN) + return CompletableFuture.completedFuture(new Decision(phase, Status.UNKNOWN, Status.UNKNOWN)); + return this.api.registered(player).thenCompose(registered -> { + // Angemeldete und Gesperrte kommen immer auf CraftAttack, die nachträgliche Anmeldung ist dann egal + if (phase != Phase.LATE || registered == Status.FOUND || registered == Status.BLOCKED) + return CompletableFuture.completedFuture(new Decision(phase, registered, Status.UNKNOWN)); + return this.api.signedUp(player).thenApply(signedUp -> new Decision(phase, registered, signedUp)); + }); + } + + record Decision(Phase phase, Status registered, Status signedUp) { + Target target() { + return Signup.target(this.phase, this.registered, this.signedUp); + } + + @Override + public String toString() { + return this.phase + ", angemeldet " + this.registered + ", nachträglich " + this.signedUp + " -> " + this.target(); + } + } + + CompletableFuture server(Target target) { + RegisteredServer server = target == Target.LOGIN ? this.login : this.craftattack; + return server.ping() + .orTimeout(this.pingTimeout.toMillis(), TimeUnit.MILLISECONDS) + .thenApply(ping -> server) + .exceptionally(error -> { + this.logger().warn("[signup] {} nicht erreichbar, stattdessen {}", server.getServerInfo().getName(), this.fallback.getServerInfo().getName()); + return this.fallback; + }); + } + + private void recheck() { + for (Player player : this.login.getPlayersConnected()) { + if (!this.checking.add(player.getUniqueId())) continue; + this.decide(player).thenCompose(decision -> { + if (decision.target() != Target.CRAFTATTACK || !player.isActive()) return CompletableFuture.completedFuture(null); + if (decision.phase() != Phase.LATE && decision.registered() == Status.FOUND) { + this.logger().info("[signup] {} angemeldet, gekickt: {}", player.getUsername(), decision); + player.disconnect(this.thanks()); + return CompletableFuture.completedFuture(null); + } + return this.server(Target.CRAFTATTACK).thenAccept(server -> { + this.logger().info("[signup] {} vom Login-Server auf {}: {}", player.getUsername(), server.getServerInfo().getName(), decision); + if (server == this.craftattack) + player.sendMessage(Component.text("Du bist angemeldet und wirst auf CraftAttack verbunden.", NamedTextColor.GREEN)); + player.createConnectionRequest(server).fireAndForget(); + }); + }).whenComplete((ignored, error) -> { + this.checking.remove(player.getUniqueId()); + if (error != null) this.logger().error("[signup] Prüfung für {} fehlgeschlagen", player.getUsername(), error); + }); + } + } + + private Component thanks() { + if (Instant.now().isBefore(this.openForAllFrom)) + return Component.text("Danke für deine Anmeldung! CraftAttack startet am " + + START_FORMAT.format(this.openForAllFrom.atZone(this.zone)) + " Uhr.", NamedTextColor.GREEN); + return Component.text("Danke für deine Anmeldung! Verbinde dich neu, um auf CraftAttack zu spielen.", NamedTextColor.GREEN); + } + + boolean isLogin(RegisteredServer server) { + return server.equals(this.login); + } + + @Override + protected void onDisable() { + if (this.recheckTask != null) this.recheckTask.cancel(); + } + + @Override + public Logger logger() { + return super.logger(); + } + + @Override + protected List listeners() { + return List.of(new SignupListener(this)); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/SignupApi.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/SignupApi.java new file mode 100644 index 0000000..9efe2b6 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/SignupApi.java @@ -0,0 +1,97 @@ +package eu.mhsl.craftattack.proxy.appliances.signup; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.velocitypowered.api.proxy.Player; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +final class SignupApi { + enum Status { FOUND, NOT_FOUND, BLOCKED, UNKNOWN } + + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + private final URI api; + private final String signupPath; + private final @Nullable String authorization; + private final Duration timeout; + private final Logger logger; + + SignupApi(String url, String signupPath, String secret, Duration timeout, Logger logger) { + if (url.isEmpty()) throw new IllegalArgumentException("signup.api-url fehlt"); + this.api = root(url); + this.signupPath = signupPath.startsWith("/") ? signupPath.substring(1) : signupPath; + // Website: Basic-Auth mit Benutzer „api“ und API_SECRET; ohne Secret prüft sie nichts + this.authorization = secret.isEmpty() ? null + : "Basic " + Base64.getEncoder().encodeToString(("api:" + secret).getBytes(StandardCharsets.UTF_8)); + this.timeout = timeout; + this.logger = logger; + } + + static URI root(String url) { + String base = url.replaceAll("/+$", ""); + if (base.endsWith("/api")) base = base.substring(0, base.length() - "/api".length()); + return URI.create(base + "/api/"); + } + + CompletableFuture registered(Player player) { + String path = "users/" + player.getUniqueId(); + return this.get(path) + .thenApply(response -> switch (response.statusCode()) { + case 200 -> Status.FOUND; + case 403 -> Status.BLOCKED; + case 404 -> Status.NOT_FOUND; + default -> this.unexpected(path, player, response); + }) + .exceptionally(error -> this.unreachable(path, player, error)); + } + + CompletableFuture signedUp(Player player) { + return this.get(this.signupPath) + .thenApply(response -> { + if (response.statusCode() != 200) return this.unexpected(this.signupPath, player, response); + return uuids(response.body()).contains(player.getUniqueId()) ? Status.FOUND : Status.NOT_FOUND; + }) + .exceptionally(error -> this.unreachable(this.signupPath, player, error)); + } + + static Set uuids(String json) { + Set uuids = new HashSet<>(); + for (JsonElement entry : JsonParser.parseString(json).getAsJsonArray()) { + String uuid = entry.getAsString().trim().toLowerCase(Locale.ROOT); + if (uuid.length() == 32) uuid = uuid.replaceFirst("(.{8})(.{4})(.{4})(.{4})(.{12})", "$1-$2-$3-$4-$5"); + uuids.add(UUID.fromString(uuid)); + } + return uuids; + } + + private CompletableFuture> get(String path) { + HttpRequest.Builder request = HttpRequest.newBuilder(this.api.resolve(path)) + .timeout(this.timeout) + .GET(); + if (this.authorization != null) request.header("Authorization", this.authorization); + return this.http.sendAsync(request.build(), HttpResponse.BodyHandlers.ofString()); + } + + private Status unexpected(String path, Player player, HttpResponse response) { + this.logger.warn("[signup] Website antwortet {} auf {} für {}", response.statusCode(), path, player.getUsername()); + return Status.UNKNOWN; + } + + private Status unreachable(String path, Player player, Throwable error) { + this.logger.warn("[signup] Website nicht erreichbar oder Antwort ungültig ({}) für {}: {}", path, player.getUsername(), error.toString()); + return Status.UNKNOWN; + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/SignupListener.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/SignupListener.java new file mode 100644 index 0000000..87f8849 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/signup/SignupListener.java @@ -0,0 +1,32 @@ +package eu.mhsl.craftattack.proxy.appliances.signup; + +import com.velocitypowered.api.event.EventTask; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.player.KickedFromServerEvent; +import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent; +import com.velocitypowered.api.proxy.Player; +import net.kyori.adventure.text.Component; + +final class SignupListener { + private final Signup signup; + + SignupListener(Signup signup) { + this.signup = signup; + } + + @Subscribe + public EventTask onChooseInitialServer(PlayerChooseInitialServerEvent event) { + Player player = event.getPlayer(); + return EventTask.resumeWhenComplete(this.signup.decide(player).thenCompose(decision -> { + this.signup.logger().info("[signup] {} ({}): {}", player.getUsername(), player.getUniqueId(), decision); + return this.signup.server(decision.target()); + }).thenAccept(event::setInitialServer)); + } + + @Subscribe + public void onKicked(KickedFromServerEvent event) { + if (!this.signup.isLogin(event.getServer())) return; + Component reason = event.getServerKickReason().orElse(Component.text("Verbindung zum Login-Server getrennt.")); + event.setResult(KickedFromServerEvent.DisconnectPlayer.create(reason)); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java b/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java index 741f117..c4b7995 100644 --- a/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java @@ -10,6 +10,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import eu.mhsl.craftattack.proxy.appliances.chat.Chat; import eu.mhsl.craftattack.proxy.appliances.mute.Mute; +import eu.mhsl.craftattack.proxy.appliances.signup.Signup; import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList; import eu.mhsl.craftattack.proxy.core.appliance.Appliance; import net.luckperms.api.LuckPerms; @@ -42,7 +43,8 @@ public final class Main { private static final List> APPLIANCES = List.of( Mute::new, Chat::new, - NetworkTabList::new + NetworkTabList::new, + Signup::new ); private final ProxyServer proxy; diff --git a/src/main/resources/config.properties b/src/main/resources/config.properties index 912075d..4a59cd5 100644 --- a/src/main/resources/config.properties +++ b/src/main/resources/config.properties @@ -2,7 +2,7 @@ # Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity. # Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …). -# Appliances, die nicht geladen werden (mute, chat, tablist). chat braucht mute, tablist braucht chat. +# Appliances, die nicht geladen werden (mute, chat, tablist, signup). chat braucht mute, tablist braucht chat. disabled-appliances= # Adminchat, /mute und /unmute (LuckPerms auf Velocity) @@ -34,3 +34,41 @@ chat.marker-permission=adminmarker # Abgleich der Tablisten in Sekunden; nach Join, Wechsel und Quit zusätzlich kurz danach tablist.refresh-seconds=2 + + +# --- signup ----------------------------------------------------------------------------------- + +# Erster Server beim Join. Wer angemeldet oder gesperrt ist, kommt immer auf craftattack-server. +signup.login-server=login +signup.craftattack-server=craftattack + +# Antwortet login-server bzw. craftattack-server nicht innerhalb von ping-timeout-seconds auf einen +# Ping, kommt der Spieler stattdessen hierhin. +signup.fallback-server=limbo +signup.ping-timeout-seconds=3 + +# Was mit nicht Angemeldeten passiert, je Zeitpunkt (JJJJ-MM-TTTHH:MM in timezone): +# davor -> craftattack-server +# ab registration-required-from -> login-server +# ab open-for-all-from -> craftattack-server +# ab late-signup-from -> login-server, wenn sie in der nachträglichen Anmeldung +# stehen (late-signup-path), sonst craftattack-server +signup.registration-required-from=2026-12-01T00:00 +signup.open-for-all-from=2026-12-26T14:00 +signup.late-signup-from=2026-12-29T14:00 +signup.timezone=Europe/Berlin + +# Website wie api.url und api.secret im Login-Server (mit oder ohne /api am Ende), Secret wie +# API_SECRET der Website. Angemeldet: GET /api/users/. +# Nachträgliche Anmeldung: GET /api/ gibt eine JSON-Liste aller UUIDs zurück, +# die sich nachträglich anmelden dürfen, z. B. ["069a79f4-44e9-4726-a5be-fca90e38aaf5"]. +# Antwortet die Website nicht, kommt der Spieler auf login-server, wenn er dort hin könnte. +signup.api-url= +signup.api-secret= +signup.late-signup-path=signup +signup.timeout-seconds=5 + +# Spieler auf login-server werden so oft (Sekunden) neu geprüft. Wer sich angemeldet hat, wird vor +# late-signup-from gekickt, danach auf craftattack-server geschickt. 0 = aus. +# Wer vom login-server gekickt wird, fliegt auch vom Proxy. +signup.recheck-seconds=10