diff --git a/build.gradle b/build.gradle index b17826b7e..e6187f63a 100644 --- a/build.gradle +++ b/build.gradle @@ -113,6 +113,8 @@ dependencies { // Java HTTP server library. implementation group: 'io.javalin', name: 'javalin', version: '5.5.0' + // Java WebSocket server & client library. + implementation group: 'org.java-websocket', name: 'Java-WebSocket', version: '1.5.2' // Google Protocol Buffer definitions. // These are the raw '.proto' files. diff --git a/src/main/java/emu/grasscutter/Grasscutter.java b/src/main/java/emu/grasscutter/Grasscutter.java index 1ab52e101..78092c02c 100644 --- a/src/main/java/emu/grasscutter/Grasscutter.java +++ b/src/main/java/emu/grasscutter/Grasscutter.java @@ -1,8 +1,5 @@ package emu.grasscutter; -import static emu.grasscutter.config.Configuration.SERVER; -import static emu.grasscutter.utils.Language.translate; - import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import emu.grasscutter.auth.AuthenticationSystem; @@ -16,9 +13,10 @@ import emu.grasscutter.database.DatabaseManager; import emu.grasscutter.plugin.PluginManager; import emu.grasscutter.plugin.api.ServerHelper; import emu.grasscutter.plugin.api.ServerHook; +import emu.grasscutter.server.dispatch.DispatchServer; import emu.grasscutter.server.game.GameServer; import emu.grasscutter.server.http.HttpServer; -import emu.grasscutter.server.http.dispatch.DispatchHandler; +import emu.grasscutter.server.http.dispatch.AuthenticationHandler; import emu.grasscutter.server.http.dispatch.RegionHandler; import emu.grasscutter.server.http.documentation.DocumentationServerHandler; import emu.grasscutter.server.http.documentation.HandbookHandler; @@ -28,12 +26,6 @@ import emu.grasscutter.server.http.handlers.GenericHandler; import emu.grasscutter.server.http.handlers.LogHandler; import emu.grasscutter.tools.Tools; import emu.grasscutter.utils.*; -import java.io.File; -import java.io.FileWriter; -import java.io.IOError; -import java.io.IOException; -import java.util.Calendar; -import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import org.jline.reader.EndOfFileException; @@ -45,6 +37,16 @@ import org.jline.terminal.TerminalBuilder; import org.reflections.Reflections; import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; +import java.io.File; +import java.io.FileWriter; +import java.io.IOError; +import java.io.IOException; +import java.util.Calendar; + +import static emu.grasscutter.config.Configuration.SERVER; +import static emu.grasscutter.utils.Language.translate; + public final class Grasscutter { public static final File configFile = new File("./config.json"); public static final Reflections reflector = new Reflections("emu.grasscutter"); @@ -57,9 +59,11 @@ public final class Grasscutter { @Getter private static int currentDayOfWeek; @Setter private static ServerRunMode runModeOverride = null; // Config override for run mode + @Setter private static boolean noConsole = false; @Getter private static HttpServer httpServer; @Getter private static GameServer gameServer; + @Getter private static DispatchServer dispatchServer; @Getter private static PluginManager pluginManager; @Getter private static CommandMap commandMap; @@ -96,6 +100,9 @@ public final class Grasscutter { System.exit(0); // Exit early. } + // Get the server run mode. + var runMode = Grasscutter.getRunMode(); + // Create command map. commandMap = new CommandMap(true); @@ -104,12 +111,14 @@ public final class Grasscutter { logger.info(translate("messages.status.game_version", GameConstants.VERSION)); logger.info(translate("messages.status.version", BuildConfig.VERSION, BuildConfig.GIT_HASH)); - // Load all resources. - Grasscutter.updateDayOfWeek(); - ResourceLoader.loadAll(); + if (runMode != ServerRunMode.DISPATCH_ONLY) { + // Load all resources. + Grasscutter.updateDayOfWeek(); + ResourceLoader.loadAll(); - // Generate handbooks. - Tools.createGmHandbooks(false); + // Generate handbooks. + Tools.createGmHandbooks(false); + } // Initialize database. DatabaseManager.initialize(); @@ -119,8 +128,11 @@ public final class Grasscutter { permissionHandler = new DefaultPermissionHandler(); // Create server instances. - httpServer = new HttpServer(); - gameServer = new GameServer(); + if (runMode == ServerRunMode.HYBRID || runMode == ServerRunMode.GAME_ONLY) + Grasscutter.gameServer = new GameServer(); + if (runMode == ServerRunMode.HYBRID || runMode == ServerRunMode.DISPATCH_ONLY) + Grasscutter.httpServer = new HttpServer(); + // Create a server hook instance with both servers. new ServerHelper(gameServer, httpServer); // noinspection removal @@ -128,25 +140,31 @@ public final class Grasscutter { // Create plugin manager instance. pluginManager = new PluginManager(); - // Add HTTP routes after loading plugins. - httpServer.addRouter(HttpServer.UnhandledRequestRouter.class); - httpServer.addRouter(HttpServer.DefaultRequestRouter.class); - httpServer.addRouter(RegionHandler.class); - httpServer.addRouter(LogHandler.class); - httpServer.addRouter(GenericHandler.class); - httpServer.addRouter(AnnouncementsHandler.class); - httpServer.addRouter(DispatchHandler.class); - httpServer.addRouter(GachaHandler.class); - httpServer.addRouter(DocumentationServerHandler.class); - httpServer.addRouter(HandbookHandler.class); + + if (runMode != ServerRunMode.GAME_ONLY) { + // Add HTTP routes after loading plugins. + httpServer.addRouter(HttpServer.UnhandledRequestRouter.class); + httpServer.addRouter(HttpServer.DefaultRequestRouter.class); + httpServer.addRouter(RegionHandler.class); + httpServer.addRouter(LogHandler.class); + httpServer.addRouter(GenericHandler.class); + httpServer.addRouter(AnnouncementsHandler.class); + httpServer.addRouter(AuthenticationHandler.class); + httpServer.addRouter(GachaHandler.class); + httpServer.addRouter(DocumentationServerHandler.class); + httpServer.addRouter(HandbookHandler.class); + } // Start servers. - var runMode = Grasscutter.getRunMode(); if (runMode == ServerRunMode.HYBRID) { httpServer.start(); gameServer.start(); } else if (runMode == ServerRunMode.DISPATCH_ONLY) { httpServer.start(); + + // Start dispatch server. + dispatchServer = new DispatchServer("0.0.0.0", 1111); + dispatchServer.start(); } else if (runMode == ServerRunMode.GAME_ONLY) { gameServer.start(); } else { @@ -267,7 +285,7 @@ public final class Grasscutter { public static void startConsole() { // Console should not start in dispatch only mode. - if (SERVER.runMode == ServerRunMode.DISPATCH_ONLY) { + if (Grasscutter.getRunMode() == ServerRunMode.DISPATCH_ONLY && Grasscutter.noConsole) { logger.info(translate("messages.dispatch.no_commands_error")); return; } else { diff --git a/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java b/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java index e50ad288f..33fd4d4f1 100644 --- a/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java +++ b/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java @@ -2,12 +2,14 @@ package emu.grasscutter.auth; import emu.grasscutter.game.Account; import emu.grasscutter.server.http.objects.*; +import emu.grasscutter.utils.DispatchUtils; import io.javalin.http.Context; -import javax.annotation.Nullable; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import javax.annotation.Nullable; + /** Defines an authenticator for the server. Can be changed by plugins. */ public interface AuthenticationSystem { @@ -104,6 +106,17 @@ public interface AuthenticationSystem { */ Authenticator getSessionKeyAuthenticator(); + /** + * This is the authenticator used for validating session tokens. + * This is a part of the logic in {@link DispatchUtils#authenticate(String, String)}. + *

+ * Plugins can override this authenticator to add + * support for alternate session authentication methods. + * + * @return {@code true} if the session token is valid, {@code false} otherwise. + */ + Authenticator getSessionTokenValidator(); + /** * This is the authenticator used for handling external authentication requests. * @@ -123,7 +136,7 @@ public interface AuthenticationSystem { @AllArgsConstructor @Getter class AuthenticationRequest { - private final Context context; + @Nullable private final Context context; @Nullable private final LoginAccountRequestJson passwordRequest; @Nullable private final LoginTokenRequestJson tokenRequest; diff --git a/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java b/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java index 12f56b38e..90ee1d02e 100644 --- a/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java +++ b/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java @@ -1,14 +1,14 @@ package emu.grasscutter.auth; -import static emu.grasscutter.config.Configuration.ACCOUNT; -import static emu.grasscutter.utils.Language.translate; - import emu.grasscutter.Grasscutter; import emu.grasscutter.auth.DefaultAuthenticators.*; import emu.grasscutter.game.Account; import emu.grasscutter.server.http.objects.ComboTokenResJson; import emu.grasscutter.server.http.objects.LoginResultJson; +import static emu.grasscutter.config.Configuration.ACCOUNT; +import static emu.grasscutter.utils.Language.translate; + /** * The default Grasscutter authentication implementation. Allows all users to access any account. */ @@ -17,6 +17,7 @@ public final class DefaultAuthentication implements AuthenticationSystem { private final Authenticator tokenAuthenticator = new TokenAuthenticator(); private final Authenticator sessionKeyAuthenticator = new SessionKeyAuthenticator(); + private final Authenticator sessionTokenValidator = new SessionTokenValidator(); private final ExternalAuthenticator externalAuthenticator = new ExternalAuthentication(); private final OAuthAuthenticator oAuthAuthenticator = new OAuthAuthentication(); @@ -60,6 +61,11 @@ public final class DefaultAuthentication implements AuthenticationSystem { return this.sessionKeyAuthenticator; } + @Override + public Authenticator getSessionTokenValidator() { + return this.sessionTokenValidator; + } + @Override public ExternalAuthenticator getExternalAuthenticator() { return this.externalAuthenticator; diff --git a/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java b/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java index f2ce46a47..eb012e5f8 100644 --- a/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java +++ b/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java @@ -1,22 +1,27 @@ package emu.grasscutter.auth; -import static emu.grasscutter.config.Configuration.ACCOUNT; -import static emu.grasscutter.utils.Language.translate; - import at.favre.lib.crypto.bcrypt.BCrypt; import emu.grasscutter.Grasscutter; import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest; import emu.grasscutter.database.DatabaseHelper; import emu.grasscutter.game.Account; +import emu.grasscutter.server.dispatch.IDispatcher; +import emu.grasscutter.server.dispatch.PacketIds; import emu.grasscutter.server.http.objects.ComboTokenResJson; import emu.grasscutter.server.http.objects.LoginResultJson; import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.Utils; + +import javax.crypto.Cipher; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.interfaces.RSAPrivateKey; import java.security.spec.PKCS8EncodedKeySpec; -import javax.crypto.Cipher; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static emu.grasscutter.config.Configuration.ACCOUNT; +import static emu.grasscutter.utils.Language.translate; /** A class containing default authenticators. */ public final class DefaultAuthenticators { @@ -29,7 +34,6 @@ public final class DefaultAuthenticators { var requestData = request.getPasswordRequest(); assert requestData != null; // This should never be null. - int playerCount = Grasscutter.getGameServer().getPlayers().size(); boolean successfulLogin = false; String address = request.getContext().ip(); @@ -38,37 +42,31 @@ public final class DefaultAuthenticators { // Get account from database. Account account = DatabaseHelper.getAccountByName(requestData.account); - if (ACCOUNT.maxPlayer <= -1 || playerCount < ACCOUNT.maxPlayer) { - // Check if account exists. - if (account == null && ACCOUNT.autoCreate) { - // This account has been created AUTOMATICALLY. There will be no permissions added. - account = DatabaseHelper.createAccountWithUid(requestData.account, 0); + // Check if account exists. + if (account == null && ACCOUNT.autoCreate) { + // This account has been created AUTOMATICALLY. There will be no permissions added. + account = DatabaseHelper.createAccountWithUid(requestData.account, 0); - // Check if the account was created successfully. - if (account == null) { - responseMessage = translate("messages.dispatch.account.username_create_error"); - Grasscutter.getLogger() - .info(translate("messages.dispatch.account.account_login_create_error", address)); - } else { - // Continue with login. - successfulLogin = true; + // Check if the account was created successfully. + if (account == null) { + responseMessage = translate("messages.dispatch.account.username_create_error"); + Grasscutter.getLogger() + .info(translate("messages.dispatch.account.account_login_create_error", address)); + } else { + // Continue with login. + successfulLogin = true; - // Log the creation. - Grasscutter.getLogger() - .info( - translate( - "messages.dispatch.account.account_login_create_success", - address, - response.data.account.uid)); - } - } else if (account != null) successfulLogin = true; - else - loggerMessage = translate("messages.dispatch.account.account_login_exist_error", address); - - } else { - responseMessage = translate("messages.dispatch.account.server_max_player_limit"); - loggerMessage = translate("messages.dispatch.account.login_max_player_limit", address); - } + // Log the creation. + Grasscutter.getLogger() + .info( + translate( + "messages.dispatch.account.account_login_create_success", + address, + response.data.account.uid)); + } + } else if (account != null) successfulLogin = true; + else + loggerMessage = translate("messages.dispatch.account.account_login_exist_error", address); // Set response data. if (successfulLogin) { @@ -96,7 +94,6 @@ public final class DefaultAuthenticators { var requestData = request.getPasswordRequest(); assert requestData != null; // This should never be null. - int playerCount = Grasscutter.getGameServer().getPlayers().size(); boolean successfulLogin = false; String address = request.getContext().ip(); @@ -129,61 +126,56 @@ public final class DefaultAuthenticators { // Get account from database. Account account = DatabaseHelper.getAccountByName(requestData.account); - if (ACCOUNT.maxPlayer <= -1 || playerCount < ACCOUNT.maxPlayer) { - // Check if account exists. - if (account == null && ACCOUNT.autoCreate) { - // This account has been created AUTOMATICALLY. There will be no permissions added. - if (decryptedPassword.length() >= 8) { - account = DatabaseHelper.createAccountWithUid(requestData.account, 0); - account.setPassword( - BCrypt.withDefaults().hashToString(12, decryptedPassword.toCharArray())); - account.save(); + // Check if account exists. + if (account == null && ACCOUNT.autoCreate) { + // This account has been created AUTOMATICALLY. There will be no permissions added. + if (decryptedPassword.length() >= 8) { + account = DatabaseHelper.createAccountWithUid(requestData.account, 0); + account.setPassword( + BCrypt.withDefaults().hashToString(12, decryptedPassword.toCharArray())); + account.save(); - // Check if the account was created successfully. - if (account == null) { - responseMessage = translate("messages.dispatch.account.username_create_error"); - loggerMessage = - translate("messages.dispatch.account.account_login_create_error", address); - } else { - // Continue with login. - successfulLogin = true; + // Check if the account was created successfully. + if (account == null) { + responseMessage = translate("messages.dispatch.account.username_create_error"); + loggerMessage = + translate("messages.dispatch.account.account_login_create_error", address); + } else { + // Continue with login. + successfulLogin = true; - // Log the creation. - Grasscutter.getLogger() - .info( - translate( - "messages.dispatch.account.account_login_create_success", - address, - response.data.account.uid)); - } + // Log the creation. + Grasscutter.getLogger() + .info( + translate( + "messages.dispatch.account.account_login_create_success", + address, + response.data.account.uid)); + } + } else { + successfulLogin = false; + loggerMessage = translate("messages.dispatch.account.login_password_error", address); + responseMessage = translate("messages.dispatch.account.password_length_error"); + } + } else if (account != null) { + if (account.getPassword() != null && !account.getPassword().isEmpty()) { + if (BCrypt.verifyer() + .verify(decryptedPassword.toCharArray(), account.getPassword()) + .verified) { + successfulLogin = true; } else { successfulLogin = false; loggerMessage = translate("messages.dispatch.account.login_password_error", address); - responseMessage = translate("messages.dispatch.account.password_length_error"); - } - } else if (account != null) { - if (account.getPassword() != null && !account.getPassword().isEmpty()) { - if (BCrypt.verifyer() - .verify(decryptedPassword.toCharArray(), account.getPassword()) - .verified) { - successfulLogin = true; - } else { - successfulLogin = false; - loggerMessage = translate("messages.dispatch.account.login_password_error", address); - responseMessage = translate("messages.dispatch.account.password_error"); - } - } else { - successfulLogin = false; - loggerMessage = - translate("messages.dispatch.account.login_password_storage_error", address); - responseMessage = translate("messages.dispatch.account.password_storage_error"); + responseMessage = translate("messages.dispatch.account.password_error"); } } else { - loggerMessage = translate("messages.dispatch.account.account_login_exist_error", address); + successfulLogin = false; + loggerMessage = + translate("messages.dispatch.account.login_password_storage_error", address); + responseMessage = translate("messages.dispatch.account.password_storage_error"); } } else { - responseMessage = translate("messages.dispatch.account.server_max_player_limit"); - loggerMessage = translate("messages.dispatch.account.login_max_player_limit", address); + loggerMessage = translate("messages.dispatch.account.account_login_exist_error", address); } // Set response data. @@ -217,43 +209,33 @@ public final class DefaultAuthenticators { boolean successfulLogin; String address = request.getContext().ip(); String loggerMessage; - int playerCount = Grasscutter.getGameServer().getPlayers().size(); // Log the attempt. Grasscutter.getLogger() .info(translate("messages.dispatch.account.login_token_attempt", address)); - if (ACCOUNT.maxPlayer <= -1 || playerCount < ACCOUNT.maxPlayer) { + // Get account from database. + Account account = DatabaseHelper.getAccountById(requestData.uid); - // Get account from database. - Account account = DatabaseHelper.getAccountById(requestData.uid); + // Check if account exists/token is valid. + successfulLogin = account != null && account.getSessionKey().equals(requestData.token); - // Check if account exists/token is valid. - successfulLogin = account != null && account.getSessionKey().equals(requestData.token); - - // Set response data. - if (successfulLogin) { - response.message = "OK"; - response.data.account.uid = account.getId(); - response.data.account.token = account.getSessionKey(); - response.data.account.email = account.getEmail(); - - // Log the login. - loggerMessage = - translate("messages.dispatch.account.login_token_success", address, requestData.uid); - } else { - response.retcode = -201; - response.message = translate("messages.dispatch.account.account_cache_error"); - - // Log the failure. - loggerMessage = translate("messages.dispatch.account.login_token_error", address); - } + // Set response data. + if (successfulLogin) { + response.message = "OK"; + response.data.account.uid = account.getId(); + response.data.account.token = account.getSessionKey(); + response.data.account.email = account.getEmail(); + // Log the login. + loggerMessage = + translate("messages.dispatch.account.login_token_success", address, requestData.uid); } else { response.retcode = -201; - response.message = translate("messages.dispatch.account.server_max_player_limit"); + response.message = translate("messages.dispatch.account.account_cache_error"); - loggerMessage = translate("messages.dispatch.account.login_max_player_limit", address); + // Log the failure. + loggerMessage = translate("messages.dispatch.account.login_token_error", address); } Grasscutter.getLogger().info(loggerMessage); @@ -275,37 +257,29 @@ public final class DefaultAuthenticators { boolean successfulLogin; String address = request.getContext().ip(); String loggerMessage; - int playerCount = Grasscutter.getGameServer().getPlayers().size(); - if (ACCOUNT.maxPlayer <= -1 || playerCount < ACCOUNT.maxPlayer) { - // Get account from database. - Account account = DatabaseHelper.getAccountById(loginData.uid); + // Get account from database. + Account account = DatabaseHelper.getAccountById(loginData.uid); - // Check if account exists/token is valid. - successfulLogin = account != null && account.getSessionKey().equals(loginData.token); + // Check if account exists/token is valid. + successfulLogin = account != null && account.getSessionKey().equals(loginData.token); - // Set response data. - if (successfulLogin) { - response.message = "OK"; - response.data.open_id = account.getId(); - response.data.combo_id = "157795300"; - response.data.combo_token = account.generateLoginToken(); + // Set response data. + if (successfulLogin) { + response.message = "OK"; + response.data.open_id = account.getId(); + response.data.combo_id = "157795300"; + response.data.combo_token = account.generateLoginToken(); - // Log the login. - loggerMessage = translate("messages.dispatch.account.combo_token_success", address); + // Log the login. + loggerMessage = translate("messages.dispatch.account.combo_token_success", address); - } else { - response.retcode = -201; - response.message = translate("messages.dispatch.account.session_key_error"); - - // Log the failure. - loggerMessage = translate("messages.dispatch.account.combo_token_error", address); - } } else { response.retcode = -201; - response.message = translate("messages.dispatch.account.server_max_player_limit"); + response.message = translate("messages.dispatch.account.session_key_error"); - loggerMessage = translate("messages.dispatch.account.login_max_player_limit", address); + // Log the failure. + loggerMessage = translate("messages.dispatch.account.combo_token_error", address); } Grasscutter.getLogger().info(loggerMessage); @@ -360,4 +334,42 @@ public final class DefaultAuthenticators { .result("Authentication is not available with the default authentication method."); } } + + /** Validates a session token during game login. */ + public static class SessionTokenValidator implements Authenticator { + @Override + public Account authenticate(AuthenticationRequest request) { + var tokenRequest = request.getTokenRequest(); + if (tokenRequest == null) { + Grasscutter.getLogger().warn("Invalid session token validator request."); + return null; + } + + // Prepare the request. + var client = Grasscutter.getGameServer().getDispatchClient(); + var future = new CompletableFuture(); + + client.registerCallback(PacketIds.TokenValidateRsp, packet -> { + var data = IDispatcher.decode(packet); + + // Check if the token is valid. + var valid = data.get("valid").getAsBoolean(); + if (!valid) { + future.complete(null); + return; + } + + // Return the account data. + future.complete(IDispatcher.decode( + data.get("account"), Account.class)); + }); + client.sendMessage(PacketIds.TokenValidateReq, tokenRequest); + + try { + return future.get(5, TimeUnit.SECONDS); + } catch (Exception ignored) { + return null; + } + } + } } diff --git a/src/main/java/emu/grasscutter/command/commands/AccountCommand.java b/src/main/java/emu/grasscutter/command/commands/AccountCommand.java index fa9f5be23..1b7314c4b 100644 --- a/src/main/java/emu/grasscutter/command/commands/AccountCommand.java +++ b/src/main/java/emu/grasscutter/command/commands/AccountCommand.java @@ -1,7 +1,5 @@ package emu.grasscutter.command.commands; -import static emu.grasscutter.utils.Language.translate; - import at.favre.lib.crypto.bcrypt.BCrypt; import emu.grasscutter.Grasscutter; import emu.grasscutter.command.Command; @@ -11,9 +9,12 @@ import emu.grasscutter.database.DatabaseHelper; import emu.grasscutter.database.DatabaseManager; import emu.grasscutter.game.Account; import emu.grasscutter.game.player.Player; + import java.util.List; import java.util.stream.Collectors; +import static emu.grasscutter.utils.Language.translate; + @Command( label = "account", usage = { diff --git a/src/main/java/emu/grasscutter/config/ConfigContainer.java b/src/main/java/emu/grasscutter/config/ConfigContainer.java index 3e9a879c9..1438d2b37 100644 --- a/src/main/java/emu/grasscutter/config/ConfigContainer.java +++ b/src/main/java/emu/grasscutter/config/ConfigContainer.java @@ -6,11 +6,14 @@ import com.google.gson.annotations.SerializedName; import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter.ServerDebugMode; import emu.grasscutter.Grasscutter.ServerRunMode; +import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.JsonUtils; +import emu.grasscutter.utils.Utils; import lombok.NoArgsConstructor; import java.lang.reflect.Field; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.Set; @@ -27,9 +30,11 @@ public class ConfigContainer { * This field will be removed in future versions. * Version 6 - 'questing' has been fully replaced with 'questOptions'. * The field for 'legacyResources' has been removed. + * Version 7 - 'regionKey' is being added for authentication + * with the new dispatch server. */ private static int version() { - return 6; + return 7; } /** @@ -188,7 +193,16 @@ public class ConfigContainer { /* Data containers. */ public static class Dispatch { - public Region[] regions = {}; + /* An array of servers. */ + public List regions = List.of(); + + /* The URL used to make HTTP requests to the dispatch server. */ + public String dispatchUrl = "ws://127.0.0.1:1111"; + /* A unique key used for encryption. */ + public byte[] encryptionKey = Crypto.createSessionKey(32); + /* A unique key used for authentication. */ + public String dispatchKey = Utils.base64Encode( + Crypto.createSessionKey(32)); public String defaultName = "Grasscutter"; diff --git a/src/main/java/emu/grasscutter/database/DatabaseManager.java b/src/main/java/emu/grasscutter/database/DatabaseManager.java index 6a8e99832..809566559 100644 --- a/src/main/java/emu/grasscutter/database/DatabaseManager.java +++ b/src/main/java/emu/grasscutter/database/DatabaseManager.java @@ -1,8 +1,5 @@ package emu.grasscutter.database; -import static emu.grasscutter.config.Configuration.DATABASE; -import static emu.grasscutter.config.Configuration.SERVER; - import com.mongodb.MongoCommandException; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; @@ -19,6 +16,8 @@ import emu.grasscutter.Grasscutter.ServerRunMode; import emu.grasscutter.game.Account; import org.reflections.Reflections; +import static emu.grasscutter.config.Configuration.DATABASE; + public final class DatabaseManager { private static Datastore gameDatastore; private static Datastore dispatchDatastore; @@ -27,18 +26,14 @@ public final class DatabaseManager { return gameDatastore; } - public static MongoDatabase getGameDatabase() { - return getGameDatastore().getDatabase(); + public static Datastore getAccountDatastore() { + if (Grasscutter.getRunMode() == ServerRunMode.HYBRID) + return gameDatastore; + else return dispatchDatastore; } - // Yes. I very dislike this method. However, this will be good for now. - // TODO: Add dispatch routes for player account management - public static Datastore getAccountDatastore() { - if (SERVER.runMode == ServerRunMode.GAME_ONLY) { - return dispatchDatastore; - } else { - return gameDatastore; - } + public static MongoDatabase getGameDatabase() { + return getGameDatastore().getDatabase(); } public static void initialize() { @@ -69,7 +64,7 @@ public final class DatabaseManager { // Ensure indexes for the game datastore ensureIndexes(gameDatastore); - if (SERVER.runMode == ServerRunMode.GAME_ONLY) { + if (Grasscutter.getRunMode() != ServerRunMode.HYBRID) { MongoClient dispatchMongoClient = MongoClients.create(DATABASE.server.connectionUri); dispatchDatastore = diff --git a/src/main/java/emu/grasscutter/game/gacha/GachaSystem.java b/src/main/java/emu/grasscutter/game/gacha/GachaSystem.java index 581a525d1..94989aeca 100644 --- a/src/main/java/emu/grasscutter/game/gacha/GachaSystem.java +++ b/src/main/java/emu/grasscutter/game/gacha/GachaSystem.java @@ -1,7 +1,5 @@ package emu.grasscutter.game.gacha; -import static emu.grasscutter.config.Configuration.GAME_OPTIONS; - import com.sun.nio.file.SensitivityWatchEventModifier; import emu.grasscutter.Grasscutter; import emu.grasscutter.data.DataLoader; @@ -31,11 +29,14 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntList; +import org.greenrobot.eventbus.Subscribe; + import java.nio.file.*; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ThreadLocalRandom; -import org.greenrobot.eventbus.Subscribe; + +import static emu.grasscutter.config.Configuration.GAME_OPTIONS; public class GachaSystem extends BaseGameSystem { private static final int starglitterId = 221; @@ -76,7 +77,7 @@ public class GachaSystem extends BaseGameSystem { .error( "A Banner has not been loaded because it contains one or more deprecated fields. Remove the fields mentioned above and reload."); } else if (banner.isDisabled()) { - Grasscutter.getLogger().debug("A Banner has not been loaded because it is disabled."); + Grasscutter.getLogger().trace("A Banner has not been loaded because it is disabled."); } else { if (banner.scheduleId < 0) banner.scheduleId = autoScheduleId++; if (banner.sortId < 0) banner.sortId = autoSortId--; diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java new file mode 100644 index 000000000..44ff59efc --- /dev/null +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java @@ -0,0 +1,138 @@ +package emu.grasscutter.server.dispatch; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import emu.grasscutter.Grasscutter; +import emu.grasscutter.server.game.GameServer; +import emu.grasscutter.server.http.handlers.GachaHandler; +import emu.grasscutter.utils.Crypto; +import lombok.Getter; +import org.java_websocket.WebSocket; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; +import org.slf4j.Logger; + +import java.net.ConnectException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; + +public final class DispatchClient extends WebSocketClient implements IDispatcher { + @Getter private final Logger logger + = Grasscutter.getLogger(); + @Getter private final Map> handlers + = new HashMap<>(); + + @Getter private final Map>> callbacks + = new HashMap<>(); + + public DispatchClient(URI serverUri) { + super(serverUri); + + // Mark this client as authenticated. + this.setAttachment(true); + + this.registerHandler(PacketIds.GachaHistoryReq, this::fetchGachaHistory); + } + + /** + * Handles the gacha history request packet sent by the client. + * + * @param socket The socket the packet was received from. + * @param object The packet data. + */ + private void fetchGachaHistory(WebSocket socket, JsonElement object) { + var message = IDispatcher.decode(object); + var accountId = message.get("accountId").getAsString(); + var page = message.get("page").getAsInt(); + var type = message.get("gachaType").getAsInt(); + + // Create a response object. + var response = new JsonObject(); + + // Find a player with the specified account ID. + var player = Grasscutter.getGameServer() + .getPlayerByAccountId(accountId); + if (player == null) { + response.addProperty("retcode", 1); + this.sendMessage(PacketIds.GachaHistoryRsp, response); + return; + } + + // Fetch the gacha records. + GachaHandler.fetchGachaRecords( + player, response, page, type); + + // Send the response. + this.sendMessage(PacketIds.GachaHistoryRsp, response); + } + + /** + * Sends a serialized encrypted message to the server. + * + * @param message The message to send. + */ + public void sendMessage(int packetId, Object message) { + var serverMessage = this.encodeMessage(packetId, message); + // Serialize the message into JSON. + var serialized = JSON.toJson(serverMessage) + .getBytes(StandardCharsets.UTF_8); + // Encrypt the message. + Crypto.xor(serialized, DISPATCH_INFO.encryptionKey); + // Send the message. + this.send(serialized); + } + + @Override + public void onOpen(ServerHandshake handshake) { + // Attempt to handshake with the server. + this.sendMessage(PacketIds.LoginNotify, DISPATCH_INFO.dispatchKey); + + this.getLogger().info("Dispatch connection opened."); + } + + @Override + public void onMessage(String message) { + this.getLogger().debug("Received dispatch message from server:\n{}", + message); + } + + @Override + public void onMessage(ByteBuffer bytes) { + this.handleMessage(this, bytes.array()); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + this.getLogger().info("Dispatch connection closed."); + + // Attempt to reconnect. + new Thread(() -> { + try { + // Wait 5 seconds before reconnecting. + Thread.sleep(5000L); + } catch (Exception ignored) { } + + // Attempt to reconnect. + Grasscutter.getGameServer().setDispatchClient( + new DispatchClient(GameServer.getDispatchUrl())); + Grasscutter.getGameServer().getDispatchClient().connect(); + }).start(); + } + + @Override + public void onError(Exception ex) { + if (ex instanceof ConnectException) { + this.getLogger().info("Failed to reconnect, trying again in 5s..."); + } else { + this.getLogger().error("Dispatch connection error.", ex); + } + } +} diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java new file mode 100644 index 000000000..552eb9545 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java @@ -0,0 +1,165 @@ +package emu.grasscutter.server.dispatch; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import emu.grasscutter.Grasscutter; +import emu.grasscutter.database.DatabaseHelper; +import emu.grasscutter.utils.Crypto; +import lombok.Getter; +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; +import org.slf4j.Logger; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; + +/* Internal communications server. */ +public final class DispatchServer extends WebSocketServer implements IDispatcher { + @Getter private final Logger logger + = Grasscutter.getLogger(); + @Getter private final Map> handlers + = new HashMap<>(); + + @Getter private final Map>> callbacks + = new HashMap<>(); + + /** + * Constructs a new {@code DispatchServer} instance. + * + * @param address The address to bind to. + * @param port The port to bind to. + */ + public DispatchServer(String address, int port) { + super(new InetSocketAddress(address, port)); + + this.registerHandler(PacketIds.LoginNotify, this::handleLogin); + this.registerHandler(PacketIds.TokenValidateReq, this::validateToken); + } + + /** + * Handles the login packet sent by the client. + * + * @param socket The socket the packet was received from. + * @param object The packet data. + */ + private void handleLogin(WebSocket socket, JsonElement object) { + var dispatchKey = object.getAsString() + .replaceAll("\"", ""); + + // Check if the dispatch key is valid. + if (!dispatchKey.equals(DISPATCH_INFO.dispatchKey)) { + this.getLogger().warn("Invalid dispatch key received from {}.", + socket.getRemoteSocketAddress()); + this.getLogger().debug("Expected: {}, Received: {}", + DISPATCH_INFO.dispatchKey, dispatchKey); + socket.close(); + } else { + socket.setAttachment(true); + } + } + + /** + * Handles the token validation packet sent by the client. + * + * @param socket The socket the packet was received from. + * @param object The packet data. + */ + private void validateToken(WebSocket socket, JsonElement object) { + var message = IDispatcher.decode(object); + var accountId = message.get("uid").getAsString(); + var token = message.get("token").getAsString(); + + // Get the account from the database. + var account = DatabaseHelper.getAccountById(accountId); + var valid = account != null && account.getToken().equals(token); + // Create the response message. + var response = new JsonObject(); + response.addProperty("valid", valid); + if (valid) response.add("account", + JSON.toJsonTree(account)); + + // Send the response. + this.sendMessage(socket, PacketIds.TokenValidateRsp, response); + } + + /** + * Broadcasts an encrypted message to all connected clients. + * + * @param message The message to broadcast. + */ + public void sendMessage(int packetId, Object message) { + var serverMessage = this.encodeMessage(packetId, message); + this.getConnections().forEach( + socket -> this.sendMessage(socket, serverMessage)); + } + + /** + * Sends a serialized encrypted message to the client. + * + * @param socket The socket to send the message to. + * @param message The message to send. + */ + public void sendMessage(WebSocket socket, Object message) { + // Serialize the message into JSON. + var serialized = JSON.toJson(message) + .getBytes(StandardCharsets.UTF_8); + // Encrypt the message. + Crypto.xor(serialized, DISPATCH_INFO.encryptionKey); + // Send the message. + socket.send(serialized); + } + + /** + * Sends a serialized encrypted message to the client. + * + * @param socket The socket to send the message to. + * @param packetId The packet ID to send. + * @param message The message to send. + */ + public void sendMessage(WebSocket socket, int packetId, Object message) { + this.sendMessage(socket, this.encodeMessage(packetId, message)); + } + + @Override + public void onStart() { + this.getLogger().info("Dispatch server started on port {}.", + this.getPort()); + } + + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + this.getLogger().debug("Dispatch client connected from {}.", + conn.getRemoteSocketAddress()); + } + + @Override + public void onMessage(WebSocket conn, String message) { + this.getLogger().debug("Received dispatch message from {}:\n{}", + conn.getRemoteSocketAddress(), message); + } + + @Override + public void onMessage(WebSocket conn, ByteBuffer message) { + this.handleMessage(conn, message.array()); + } + + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) { + this.getLogger().debug("Dispatch client disconnected from {}.", + conn.getRemoteSocketAddress()); + } + + @Override + public void onError(WebSocket conn, Exception ex) { + this.getLogger().warn("Dispatch server error.", ex); + } +} diff --git a/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java b/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java new file mode 100644 index 000000000..afd987bf6 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java @@ -0,0 +1,196 @@ +package emu.grasscutter.server.dispatch; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import emu.grasscutter.utils.Crypto; +import emu.grasscutter.utils.JsonAdapters.ByteArrayAdapter; +import org.java_websocket.WebSocket; +import org.slf4j.Logger; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; + +public interface IDispatcher { + Gson JSON = new GsonBuilder() + .disableHtmlEscaping() + .registerTypeAdapter(byte[].class, new ByteArrayAdapter()) + .create(); + + /** + * Decodes an escaped JSON message. + * + * @param element The element to decode. + * @return The decoded JSON object. + */ + static JsonObject decode(JsonElement element) { + return IDispatcher.decode(element, JsonObject.class); + } + + /** + * Decodes an escaped JSON message. + * + * @param element The element to decode. + * @return The decoded JSON object. + */ + static T decode(JsonElement element, Class type) { + if (element.isJsonObject()) { + return JSON.fromJson(element, type); + } else { + var data = element.getAsString(); + + // Check if the element starts and ends with quotes. + if (data.startsWith("\"") + && data.endsWith("\"")) { + // Remove the quotes. + data = data.substring(1, data.length() - 1); + } + + // Un-escape the data. + data = data.replaceAll("\"", ""); + data = data.replaceAll("\\\\", ""); + + // De-serialize the data. + return JSON.fromJson(data, type); + } + } + + /** + * Decodes a message from the client. + * + * @param message The message to decode. + * @return The decoded message. + */ + default JsonObject decodeMessage(byte[] message) { + // Decrypt the message. + Crypto.xor(message, DISPATCH_INFO.encryptionKey); + // Deserialize the message. + return JSON.fromJson(new String( + message, StandardCharsets.UTF_8), JsonObject.class); + } + + /** + * Creates an encoded message. + * + * @param packetId The packet ID. + * @param message The message data. + * @return The encoded message. + */ + default JsonObject encodeMessage(int packetId, Object message) { + // Create a message from the message data. + var serverMessage = new JsonObject(); + serverMessage.addProperty("packetId", packetId); + serverMessage.addProperty("message", JSON.toJson(message)); + + return serverMessage; + } + + /** + * Handles a message from the client. + * + * @param socket The socket the message was received from. + * @param messageData The message data. + */ + default void handleMessage(WebSocket socket, byte[] messageData) { + // Decode the message. + var decoded = this.decodeMessage(messageData); + if (decoded == null) { + this.getLogger().warn("Received invalid message."); + socket.close(); + return; + } + + // Get the packet ID. + var packetId = decoded.get("packetId").getAsInt(); + // Get the packet data. + var packetData = decoded.get("message"); + + // Check to see if the client has authenticated. + if (packetId != PacketIds.LoginNotify) { + if (socket.getAttachment() instanceof Boolean authenticated) { + if (!authenticated) { + this.getLogger().warn("Received packet ID {} from unauthenticated client.", + packetId); + socket.close(); + return; + } + } else return; + } + + try { + // Check if the packet ID is registered. + if (this.getHandlers().containsKey(packetId)) { + // Get the handler. + var handler = this.getHandlers().get(packetId); + // Handle the packet. + handler.accept(socket, packetData); + } + + // Check if the packet ID has callbacks. + if (this.getCallbacks().containsKey(packetId)) { + // Get the callbacks. + var callbacks = this.getCallbacks().get(packetId); + // Call the callbacks. + callbacks.forEach(callback -> + callback.accept(packetData)); + callbacks.clear(); + } + } catch (Exception exception) { + this.getLogger().warn("Exception occurred while handling packet {}.", + packetId); + exception.printStackTrace(); + } + } + + /** + * Registers a message handler. + * + * @param packetId The packet ID to register. + * @param handler The handler to register. + */ + default void registerHandler(int packetId, BiConsumer handler) { + // Check if the packet ID is already registered. + if (this.getHandlers().containsKey(packetId)) + throw new IllegalArgumentException("Packet ID already registered."); + + // Register the handler. + this.getHandlers().put(packetId, handler); + } + + /** + * Registers a callback. + * + * @param packetId The packet ID to register. + * @param callback The callback to register. + */ + default void registerCallback(int packetId, Consumer callback) { + // Check if the packet ID has a list for callbacks. + if (!this.getCallbacks().containsKey(packetId)) + this.getCallbacks().put(packetId, new LinkedList<>()); + + // Register the callback. + this.getCallbacks().get(packetId).add(callback); + } + + /** + * @return The logger for the dispatcher. + */ + Logger getLogger(); + + /** + * @return The message handlers. + */ + Map> getHandlers(); + + /** + * @return The callbacks. + */ + Map>> getCallbacks(); +} diff --git a/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java b/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java new file mode 100644 index 000000000..d64c56182 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java @@ -0,0 +1,10 @@ +package emu.grasscutter.server.dispatch; + +/* Packet IDs for the dispatch server. */ +public interface PacketIds { + int LoginNotify = 1; + int TokenValidateReq = 2; + int TokenValidateRsp = 3; + int GachaHistoryReq = 4; + int GachaHistoryRsp = 5; +} diff --git a/src/main/java/emu/grasscutter/server/game/GameServer.java b/src/main/java/emu/grasscutter/server/game/GameServer.java index 266a3d515..f88999fb4 100644 --- a/src/main/java/emu/grasscutter/server/game/GameServer.java +++ b/src/main/java/emu/grasscutter/server/game/GameServer.java @@ -1,10 +1,8 @@ package emu.grasscutter.server.game; -import static emu.grasscutter.config.Configuration.GAME_INFO; -import static emu.grasscutter.utils.Language.translate; - import emu.grasscutter.GameConstants; import emu.grasscutter.Grasscutter; +import emu.grasscutter.Grasscutter.ServerRunMode; import emu.grasscutter.database.DatabaseHelper; import emu.grasscutter.game.Account; import emu.grasscutter.game.battlepass.BattlePassSystem; @@ -32,20 +30,29 @@ import emu.grasscutter.game.world.World; import emu.grasscutter.game.world.WorldDataSystem; import emu.grasscutter.net.packet.PacketHandler; import emu.grasscutter.net.proto.SocialDetailOuterClass.SocialDetail; +import emu.grasscutter.server.dispatch.DispatchClient; import emu.grasscutter.server.event.game.ServerTickEvent; import emu.grasscutter.server.event.internal.ServerStartEvent; import emu.grasscutter.server.event.internal.ServerStopEvent; import emu.grasscutter.server.event.types.ServerEvent; import emu.grasscutter.server.scheduler.ServerTaskScheduler; import emu.grasscutter.task.TaskMap; +import kcp.highway.ChannelConfig; +import kcp.highway.KcpServer; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; + import java.net.InetSocketAddress; +import java.net.URI; import java.time.Instant; import java.time.OffsetDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; -import kcp.highway.ChannelConfig; -import kcp.highway.KcpServer; -import lombok.Getter; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; +import static emu.grasscutter.config.Configuration.GAME_INFO; +import static emu.grasscutter.utils.Language.translate; @Getter public final class GameServer extends KcpServer { @@ -55,6 +62,8 @@ public final class GameServer extends KcpServer { private final Map players; private final Set worlds; + @Setter private DispatchClient dispatchClient; + // Server systems private final InventorySystem inventorySystem; private final GachaSystem gachaSystem; @@ -78,12 +87,50 @@ public final class GameServer extends KcpServer { private ChatSystemHandler chatManager; + /** + * @return The URI for the dispatch server. + */ + @SneakyThrows + public static URI getDispatchUrl() { + return new URI(DISPATCH_INFO.dispatchUrl); + } + public GameServer() { this(getAdapterInetSocketAddress()); } public GameServer(InetSocketAddress address) { - ChannelConfig channelConfig = new ChannelConfig(); + // Check if we are in dispatch only mode. + if (Grasscutter.getRunMode() == ServerRunMode.DISPATCH_ONLY) { + // Set all the systems to null. + this.scheduler = null; + this.taskMap = null; + + this.address = null; + this.packetHandler = null; + this.dispatchClient = null; + this.players = null; + this.worlds = null; + + this.inventorySystem = null; + this.gachaSystem = null; + this.shopSystem = null; + this.multiplayerSystem = null; + this.dungeonSystem = null; + this.expeditionSystem = null; + this.dropSystem = null; + this.dropSystemLegacy = null; + this.worldDataSystem = null; + this.battlePassSystem = null; + this.combineSystem = null; + this.towerSystem = null; + this.announcementSystem = null; + this.questSystem = null; + this.talkSystem = null; + return; + } + + var channelConfig = new ChannelConfig(); channelConfig.nodelay(true, GAME_INFO.kcpInterval, 2, true); channelConfig.setMtu(1400); channelConfig.setSndwnd(256); @@ -103,6 +150,7 @@ public final class GameServer extends KcpServer { // Game Server base this.address = address; this.packetHandler = new GameServerPacketHandler(PacketHandler.class); + this.dispatchClient = new DispatchClient(GameServer.getDispatchUrl()); this.players = new ConcurrentHashMap<>(); this.worlds = Collections.synchronizedSet(new HashSet<>()); @@ -248,6 +296,11 @@ public final class GameServer extends KcpServer { } public void start() { + if (Grasscutter.getRunMode() == ServerRunMode.GAME_ONLY) { + // Connect to dispatch server. + this.dispatchClient.connect(); + } + // Schedule game loop. Timer gameLoop = new Timer(); gameLoop.scheduleAtFixedRate( diff --git a/src/main/java/emu/grasscutter/server/http/HttpServer.java b/src/main/java/emu/grasscutter/server/http/HttpServer.java index 23d24b01d..51480ea09 100644 --- a/src/main/java/emu/grasscutter/server/http/HttpServer.java +++ b/src/main/java/emu/grasscutter/server/http/HttpServer.java @@ -27,6 +27,12 @@ public final class HttpServer { * Configures the Javalin application. */ public HttpServer() { + // Check if we are in game only mode. + if (Grasscutter.getRunMode() == Grasscutter.ServerRunMode.GAME_ONLY) { + this.javalin = null; + return; + } + this.javalin = Javalin.create(config -> { // Set the Javalin HTTP server. config.jetty.server(HttpServer::createServer); @@ -51,6 +57,13 @@ public final class HttpServer { // Static files should be added like this https://javalin.io/documentation#static-files }); + + this.javalin.exception(Exception.class, (exception, ctx) -> { + ctx.status(500).result("Internal server error. %s" + .formatted(exception.getMessage())); + Grasscutter.getLogger().debug("Exception thrown: " + + exception.getMessage(), exception); + }); } /** diff --git a/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java b/src/main/java/emu/grasscutter/server/http/dispatch/AuthenticationHandler.java similarity index 91% rename from src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java rename to src/main/java/emu/grasscutter/server/http/dispatch/AuthenticationHandler.java index 39b9ba45c..a6420a572 100644 --- a/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java +++ b/src/main/java/emu/grasscutter/server/http/dispatch/AuthenticationHandler.java @@ -1,7 +1,5 @@ package emu.grasscutter.server.http.dispatch; -import static emu.grasscutter.utils.Language.translate; - import emu.grasscutter.Grasscutter; import emu.grasscutter.auth.AuthenticationSystem; import emu.grasscutter.auth.OAuthAuthenticator.ClientType; @@ -14,8 +12,10 @@ import emu.grasscutter.utils.JsonUtils; import io.javalin.Javalin; import io.javalin.http.Context; -/** Handles requests related to authentication. (aka dispatch) */ -public final class DispatchHandler implements Router { +import static emu.grasscutter.utils.Language.translate; + +/** Handles requests related to authentication. */ +public final class AuthenticationHandler implements Router { /** * @route /hk4e_global/mdk/shield/api/login */ @@ -92,19 +92,19 @@ public final class DispatchHandler implements Router { public void applyRoutes(Javalin javalin) { // OS // Username & Password login (from client). - javalin.post("/hk4e_global/mdk/shield/api/login", DispatchHandler::clientLogin); + javalin.post("/hk4e_global/mdk/shield/api/login", AuthenticationHandler::clientLogin); // Cached token login (from registry). - javalin.post("/hk4e_global/mdk/shield/api/verify", DispatchHandler::tokenLogin); + javalin.post("/hk4e_global/mdk/shield/api/verify", AuthenticationHandler::tokenLogin); // Combo token login (from session key). - javalin.post("/hk4e_global/combo/granter/login/v2/login", DispatchHandler::sessionKeyLogin); + javalin.post("/hk4e_global/combo/granter/login/v2/login", AuthenticationHandler::sessionKeyLogin); // CN // Username & Password login (from client). - javalin.post("/hk4e_cn/mdk/shield/api/login", DispatchHandler::clientLogin); + javalin.post("/hk4e_cn/mdk/shield/api/login", AuthenticationHandler::clientLogin); // Cached token login (from registry). - javalin.post("/hk4e_cn/mdk/shield/api/verify", DispatchHandler::tokenLogin); + javalin.post("/hk4e_cn/mdk/shield/api/verify", AuthenticationHandler::tokenLogin); // Combo token login (from session key). - javalin.post("/hk4e_cn/combo/granter/login/v2/login", DispatchHandler::sessionKeyLogin); + javalin.post("/hk4e_cn/combo/granter/login/v2/login", AuthenticationHandler::sessionKeyLogin); // External login (from other clients). javalin.get( diff --git a/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java b/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java index 9a137f2f7..feb7d176a 100644 --- a/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java +++ b/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java @@ -1,7 +1,5 @@ package emu.grasscutter.server.http.dispatch; -import static emu.grasscutter.config.Configuration.*; - import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.protobuf.ByteString; @@ -23,11 +21,15 @@ import emu.grasscutter.utils.JsonUtils; import emu.grasscutter.utils.Utils; import io.javalin.Javalin; import io.javalin.http.Context; +import org.slf4j.Logger; + import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; -import org.slf4j.Logger; + +import static emu.grasscutter.config.Configuration.*; /** Handles requests related to region queries. */ public final class RegionHandler implements Router { @@ -57,8 +59,8 @@ public final class RegionHandler implements Router { var servers = new ArrayList(); var usedNames = new ArrayList(); // List to check for potential naming conflicts. - var configuredRegions = new ArrayList<>(List.of(DISPATCH_INFO.regions)); - if (SERVER.runMode != ServerRunMode.HYBRID && configuredRegions.size() == 0) { + var configuredRegions = new ArrayList<>(DISPATCH_INFO.regions); + if (Grasscutter.getRunMode() != ServerRunMode.HYBRID && configuredRegions.size() == 0) { Grasscutter.getLogger() .error( "[Dispatch] There are no game servers available. Exiting due to unplayable state."); @@ -340,6 +342,7 @@ public final class RegionHandler implements Router { * @return A {@link QueryCurrRegionHttpRsp} object. */ public static QueryCurrRegionHttpRsp getCurrentRegion() { - return SERVER.runMode == ServerRunMode.HYBRID ? regions.get("os_usa").getRegionQuery() : null; + return Grasscutter.getRunMode() == ServerRunMode.HYBRID ? + regions.get("os_usa").getRegionQuery() : null; } } diff --git a/src/main/java/emu/grasscutter/server/http/documentation/DocumentationServerHandler.java b/src/main/java/emu/grasscutter/server/http/documentation/DocumentationServerHandler.java index 1d38fbfc6..2dea0a3c9 100644 --- a/src/main/java/emu/grasscutter/server/http/documentation/DocumentationServerHandler.java +++ b/src/main/java/emu/grasscutter/server/http/documentation/DocumentationServerHandler.java @@ -1,5 +1,6 @@ package emu.grasscutter.server.http.documentation; +import emu.grasscutter.Grasscutter; import emu.grasscutter.server.http.Router; import io.javalin.Javalin; @@ -7,13 +8,16 @@ public final class DocumentationServerHandler implements Router { @Override public void applyRoutes(Javalin javalin) { - final RootRequestHandler root = new RootRequestHandler(); - final HandbookRequestHandler handbook = new HandbookRequestHandler(); - final GachaMappingRequestHandler gachaMapping = new GachaMappingRequestHandler(); + final var root = new RootRequestHandler(); + final var gachaMapping = new GachaMappingRequestHandler(); // TODO: Removal // TODO: Forward /documentation requests to https://grasscutter.io/wiki - javalin.get("/documentation/handbook", handbook::handle); + if (Grasscutter.getRunMode() != Grasscutter.ServerRunMode.DISPATCH_ONLY) { + final var handbook = new HandbookRequestHandler(); + javalin.get("/documentation/handbook", handbook::handle); + } + javalin.get("/documentation/gachamapping", gachaMapping::handle); javalin.get("/documentation", root::handle); } diff --git a/src/main/java/emu/grasscutter/server/http/handlers/GachaHandler.java b/src/main/java/emu/grasscutter/server/http/handlers/GachaHandler.java index 7ed622e8f..3d3430df3 100644 --- a/src/main/java/emu/grasscutter/server/http/handlers/GachaHandler.java +++ b/src/main/java/emu/grasscutter/server/http/handlers/GachaHandler.java @@ -1,28 +1,27 @@ package emu.grasscutter.server.http.handlers; -import static emu.grasscutter.utils.Language.translate; - +import com.google.gson.JsonObject; import emu.grasscutter.Grasscutter; import emu.grasscutter.database.DatabaseHelper; -import emu.grasscutter.game.Account; -import emu.grasscutter.game.gacha.GachaBanner; -import emu.grasscutter.game.gacha.GachaSystem; import emu.grasscutter.game.player.Player; import emu.grasscutter.server.http.Router; +import emu.grasscutter.utils.DispatchUtils; import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.Utils; import io.javalin.Javalin; import io.javalin.http.ContentType; import io.javalin.http.Context; import io.javalin.http.staticfiles.Location; +import lombok.Getter; + import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.LinkedHashSet; -import java.util.Set; -import lombok.Getter; + +import static emu.grasscutter.utils.Language.translate; /** Handles all gacha-related HTTP requests. */ public final class GachaHandler implements Router { @@ -33,55 +32,52 @@ public final class GachaHandler implements Router { public static final String gachaMappings = gachaMappingsPath.toString(); private static void gachaRecords(Context ctx) { - String sessionKey = ctx.queryParam("s"); - Account account = DatabaseHelper.getAccountBySessionKey(sessionKey); + var sessionKey = ctx.queryParam("s"); + var account = DatabaseHelper.getAccountBySessionKey(sessionKey); if (account == null) { - ctx.status(403).result("Requested account was not found"); - return; - } - Player player = Grasscutter.getGameServer().getPlayerByAccountId(account.getId()); - if (player == null) { - ctx.status(403).result("No player associated with requested account"); + ctx.status(403).result("Requested account was not found."); return; } + // Get page and gacha type. int page = 0, gachaType = 0; - if (ctx.queryParam("p") != null) page = Integer.parseInt(ctx.queryParam("p")); - if (ctx.queryParam("gachaType") != null) - gachaType = Integer.parseInt(ctx.queryParam("gachaType")); - String records = DatabaseHelper.getGachaRecords(player.getUid(), page, gachaType).toString(); - long maxPage = DatabaseHelper.getGachaRecordsMaxPage(player.getUid(), page, gachaType); + var pageStr = ctx.queryParam("p"); + if (pageStr != null) page = Integer.parseInt(pageStr); - String template = + var gachaTypeStr = ctx.queryParam("gachaType"); + if (gachaTypeStr != null) gachaType = Integer.parseInt(gachaTypeStr); + + // Make request to dispatch server. + var data = DispatchUtils.fetchGachaRecords( + account.getId(), page, gachaType); + var records = data.get("records").getAsString(); + var maxPage = data.get("maxPage").getAsLong(); + + var locale = account.getLocale(); + var template = new String( FileUtils.read(FileUtils.getDataPath("gacha/records.html")), StandardCharsets.UTF_8) - .replace("{{REPLACE_RECORDS}}", records) - .replace("{{REPLACE_MAXPAGE}}", String.valueOf(maxPage)) - .replace("{{TITLE}}", translate(player, "gacha.records.title")) - .replace("{{DATE}}", translate(player, "gacha.records.date")) - .replace("{{ITEM}}", translate(player, "gacha.records.item")) + .replace("'{{REPLACE_RECORDS}}'", records) + .replace("'{{REPLACE_MAXPAGE}}'", String.valueOf(maxPage)) + .replace("{{TITLE}}", translate(locale, "gacha.records.title")) + .replace("{{DATE}}", translate(locale, "gacha.records.date")) + .replace("{{ITEM}}", translate(locale, "gacha.records.item")) .replace("{{LANGUAGE}}", Utils.getLanguageCode(account.getLocale())); ctx.contentType(ContentType.TEXT_HTML); ctx.result(template); } private static void gachaDetails(Context ctx) { - Path detailsTemplate = FileUtils.getDataPath("gacha/details.html"); - String sessionKey = ctx.queryParam("s"); - Account account = DatabaseHelper.getAccountBySessionKey(sessionKey); + var detailsTemplate = FileUtils.getDataPath("gacha/details.html"); + var sessionKey = ctx.queryParam("s"); + var account = DatabaseHelper.getAccountBySessionKey(sessionKey); if (account == null) { ctx.status(403).result("Requested account was not found"); return; } - Player player = Grasscutter.getGameServer().getPlayerByAccountId(account.getId()); - if (player == null) { - ctx.status(403).result("No player associated with requested account"); - return; - } - String template; - try { + String template;try { template = Files.readString(detailsTemplate); } catch (IOException e) { Grasscutter.getLogger().warn("Failed to read data/gacha/details.html"); @@ -90,27 +86,35 @@ public final class GachaHandler implements Router { } // Add translated title etc. to the page. + var locale = account.getLocale(); template = template - .replace("{{TITLE}}", translate(player, "gacha.details.title")) + .replace("{{TITLE}}", translate(locale, "gacha.details.title")) .replace( - "{{AVAILABLE_FIVE_STARS}}", translate(player, "gacha.details.available_five_stars")) + "{{AVAILABLE_FIVE_STARS}}", translate(locale, "gacha.details.available_five_stars")) .replace( - "{{AVAILABLE_FOUR_STARS}}", translate(player, "gacha.details.available_four_stars")) + "{{AVAILABLE_FOUR_STARS}}", translate(locale, "gacha.details.available_four_stars")) .replace( "{{AVAILABLE_THREE_STARS}}", - translate(player, "gacha.details.available_three_stars")) + translate(locale, "gacha.details.available_three_stars")) .replace("{{LANGUAGE}}", Utils.getLanguageCode(account.getLocale())); // Get the banner info for the banner we want. - int scheduleId = Integer.parseInt(ctx.queryParam("scheduleId")); - GachaSystem manager = Grasscutter.getGameServer().getGachaSystem(); - GachaBanner banner = manager.getGachaBanners().get(scheduleId); + var scheduleIdStr = ctx.queryParam("scheduleId"); + if (scheduleIdStr == null) { + ctx.status(400).result("Missing scheduleId parameter"); + return; + } + + var scheduleId = Integer.parseInt(scheduleIdStr); + var manager = Grasscutter.getGameServer().getGachaSystem(); + var banner = manager.getGachaBanners().get(scheduleId); // Add 5-star items. - Set fiveStarItems = new LinkedHashSet<>(); + var fiveStarItems = new LinkedHashSet(); - Arrays.stream(banner.getRateUpItems5()).forEach(i -> fiveStarItems.add(Integer.toString(i))); + Arrays.stream(banner.getRateUpItems5()) + .forEach(i -> fiveStarItems.add(Integer.toString(i))); Arrays.stream(banner.getFallbackItems5Pool1()) .forEach(i -> fiveStarItems.add(Integer.toString(i))); Arrays.stream(banner.getFallbackItems5Pool2()) @@ -119,9 +123,10 @@ public final class GachaHandler implements Router { template = template.replace("{{FIVE_STARS}}", "[" + String.join(",", fiveStarItems) + "]"); // Add 4-star items. - Set fourStarItems = new LinkedHashSet<>(); + var fourStarItems = new LinkedHashSet(); - Arrays.stream(banner.getRateUpItems4()).forEach(i -> fourStarItems.add(Integer.toString(i))); + Arrays.stream(banner.getRateUpItems4()) + .forEach(i -> fourStarItems.add(Integer.toString(i))); Arrays.stream(banner.getFallbackItems4Pool1()) .forEach(i -> fourStarItems.add(Integer.toString(i))); Arrays.stream(banner.getFallbackItems4Pool2()) @@ -130,8 +135,9 @@ public final class GachaHandler implements Router { template = template.replace("{{FOUR_STARS}}", "[" + String.join(",", fourStarItems) + "]"); // Add 3-star items. - Set threeStarItems = new LinkedHashSet<>(); - Arrays.stream(banner.getFallbackItems3()).forEach(i -> threeStarItems.add(Integer.toString(i))); + var threeStarItems = new LinkedHashSet(); + Arrays.stream(banner.getFallbackItems3()) + .forEach(i -> threeStarItems.add(Integer.toString(i))); template = template.replace("{{THREE_STARS}}", "[" + String.join(",", threeStarItems) + "]"); // Done. @@ -139,6 +145,30 @@ public final class GachaHandler implements Router { ctx.result(template); } + /** + * Fetches the gacha records for the specified player. + * + * @param player The player to fetch the records for. + * @param response The response to write to. + * @param page The page to fetch. + * @param type The gacha type to fetch. + */ + public static void fetchGachaRecords( + Player player, JsonObject response, + int page, int type + ) { + var playerId = player.getUid(); + var records = DatabaseHelper.getGachaRecords( + playerId, page, type).toString(); + var maxPage = DatabaseHelper.getGachaRecordsMaxPage( + playerId, page, type); + + // Finish the response. + response.addProperty("retcode", 0); + response.addProperty("records", records); + response.addProperty("maxPage", maxPage); + } + @Override public void applyRoutes(Javalin javalin) { javalin.get("/gacha", GachaHandler::gachaRecords); diff --git a/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java b/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java index 2ab624d9e..14f0a3580 100644 --- a/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java +++ b/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java @@ -1,5 +1,11 @@ package emu.grasscutter.server.http.objects; +import lombok.Builder; + +/** + * This request object is used in both token-related authenticators. + */ +@Builder public class LoginTokenRequestJson { public String uid; public String token; diff --git a/src/main/java/emu/grasscutter/server/packet/recv/HandlerGetPlayerTokenReq.java b/src/main/java/emu/grasscutter/server/packet/recv/HandlerGetPlayerTokenReq.java index 730c38e6f..35a863cc1 100644 --- a/src/main/java/emu/grasscutter/server/packet/recv/HandlerGetPlayerTokenReq.java +++ b/src/main/java/emu/grasscutter/server/packet/recv/HandlerGetPlayerTokenReq.java @@ -1,10 +1,7 @@ package emu.grasscutter.server.packet.recv; -import static emu.grasscutter.config.Configuration.ACCOUNT; - import emu.grasscutter.Grasscutter; import emu.grasscutter.database.DatabaseHelper; -import emu.grasscutter.game.Account; import emu.grasscutter.game.player.Player; import emu.grasscutter.net.packet.Opcodes; import emu.grasscutter.net.packet.PacketHandler; @@ -16,21 +13,29 @@ import emu.grasscutter.server.game.GameSession.SessionState; import emu.grasscutter.server.packet.send.PacketGetPlayerTokenRsp; import emu.grasscutter.utils.ByteHelper; import emu.grasscutter.utils.Crypto; +import emu.grasscutter.utils.DispatchUtils; import emu.grasscutter.utils.Utils; + +import javax.crypto.Cipher; import java.nio.ByteBuffer; import java.security.Signature; -import javax.crypto.Cipher; + +import static emu.grasscutter.config.Configuration.ACCOUNT; @Opcodes(PacketOpcodes.GetPlayerTokenReq) public class HandlerGetPlayerTokenReq extends PacketHandler { - @Override public void handle(GameSession session, byte[] header, byte[] payload) throws Exception { - GetPlayerTokenReq req = GetPlayerTokenReq.parseFrom(payload); + var req = GetPlayerTokenReq.parseFrom(payload); - // Authenticate - Account account = DatabaseHelper.getAccountById(req.getAccountUid()); - if (account == null || !account.getToken().equals(req.getAccountToken())) { + // Fetch the account from the ID and token. + var accountId = req.getAccountUid(); + var account = DispatchUtils.authenticate( + accountId, req.getAccountToken()); + + // Check the account. + if (account == null) { + session.close(); return; } @@ -42,9 +47,10 @@ public class HandlerGetPlayerTokenReq extends PacketHandler { // secondly !!! // TODO - optimize boolean kicked = false; - Player exists = Grasscutter.getGameServer().getPlayerByAccountId(account.getId()); + var exists = Grasscutter.getGameServer() + .getPlayerByAccountId(accountId); if (exists != null) { - GameSession existsSession = exists.getSession(); + var existsSession = exists.getSession(); if (existsSession != session) { // No self-kicking exists.onLogout(); // must save immediately , or the below will load old data existsSession.close(); @@ -67,14 +73,14 @@ public class HandlerGetPlayerTokenReq extends PacketHandler { } // Call creation event. - PlayerCreationEvent event = new PlayerCreationEvent(session, Player.class); + var event = new PlayerCreationEvent(session, Player.class); event.call(); // Get player. - Player player = DatabaseHelper.getPlayerByAccount(account, event.getPlayerClass()); + var player = DatabaseHelper.getPlayerByAccount(account, event.getPlayerClass()); if (player == null) { - int nextPlayerUid = + var nextPlayerUid = DatabaseHelper.getNextPlayerId(session.getAccount().getReservedPlayerUid()); // Create player instance from event. @@ -107,19 +113,19 @@ public class HandlerGetPlayerTokenReq extends PacketHandler { // Only >= 2.7.50 has this if (req.getKeyId() > 0) { try { - Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + var cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.DECRYPT_MODE, Crypto.CUR_SIGNING_KEY); var client_seed_encrypted = Utils.base64Decode(req.getClientRandKey()); var client_seed = ByteBuffer.wrap(cipher.doFinal(client_seed_encrypted)).getLong(); - byte[] seed_bytes = + var seed_bytes = ByteBuffer.wrap(new byte[8]).putLong(Crypto.ENCRYPT_SEED ^ client_seed).array(); cipher.init(Cipher.ENCRYPT_MODE, Crypto.EncryptionKeys.get(req.getKeyId())); var seed_encrypted = cipher.doFinal(seed_bytes); - Signature privateSignature = Signature.getInstance("SHA256withRSA"); + var privateSignature = Signature.getInstance("SHA256withRSA"); privateSignature.initSign(Crypto.CUR_SIGNING_KEY); privateSignature.update(seed_bytes); @@ -128,14 +134,13 @@ public class HandlerGetPlayerTokenReq extends PacketHandler { session, Utils.base64Encode(seed_encrypted), Utils.base64Encode(privateSignature.sign()))); - } catch (Exception ignore) { + } catch (Exception ignored) { // Only UA Patch users will have exception - byte[] clientBytes = Utils.base64Decode(req.getClientRandKey()); - byte[] seed = ByteHelper.longToBytes(Crypto.ENCRYPT_SEED); + var clientBytes = Utils.base64Decode(req.getClientRandKey()); + var seed = ByteHelper.longToBytes(Crypto.ENCRYPT_SEED); Crypto.xor(clientBytes, seed); - String base64str = Utils.base64Encode(clientBytes); - + var base64str = Utils.base64Encode(clientBytes); session.send(new PacketGetPlayerTokenRsp(session, base64str, "bm90aGluZyBoZXJl")); } } else { diff --git a/src/main/java/emu/grasscutter/server/packet/send/PacketPlayerLoginRsp.java b/src/main/java/emu/grasscutter/server/packet/send/PacketPlayerLoginRsp.java index e134ad064..8597f41b0 100644 --- a/src/main/java/emu/grasscutter/server/packet/send/PacketPlayerLoginRsp.java +++ b/src/main/java/emu/grasscutter/server/packet/send/PacketPlayerLoginRsp.java @@ -1,7 +1,5 @@ package emu.grasscutter.server.packet.send; -import static emu.grasscutter.config.Configuration.*; - import com.google.protobuf.ByteString; import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter.ServerRunMode; @@ -13,8 +11,12 @@ import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo; import emu.grasscutter.server.game.GameSession; import emu.grasscutter.server.http.dispatch.RegionHandler; import emu.grasscutter.utils.Crypto; + import java.util.Objects; +import static emu.grasscutter.config.Configuration.GAME_INFO; +import static emu.grasscutter.config.Configuration.lr; + public class PacketPlayerLoginRsp extends BasePacket { private static QueryCurrRegionHttpRspOuterClass.QueryCurrRegionHttpRsp regionCache; @@ -26,7 +28,7 @@ public class PacketPlayerLoginRsp extends BasePacket { RegionInfo info; - if (SERVER.runMode == ServerRunMode.GAME_ONLY) { + if (Grasscutter.getRunMode() == ServerRunMode.GAME_ONLY) { if (regionCache == null) { try { // todo: we might want to push custom config to client diff --git a/src/main/java/emu/grasscutter/utils/DispatchUtils.java b/src/main/java/emu/grasscutter/utils/DispatchUtils.java new file mode 100644 index 000000000..827372b82 --- /dev/null +++ b/src/main/java/emu/grasscutter/utils/DispatchUtils.java @@ -0,0 +1,122 @@ +package emu.grasscutter.utils; + +import com.google.gson.JsonObject; +import emu.grasscutter.Grasscutter; +import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest; +import emu.grasscutter.database.DatabaseHelper; +import emu.grasscutter.game.Account; +import emu.grasscutter.server.dispatch.IDispatcher; +import emu.grasscutter.server.dispatch.PacketIds; +import emu.grasscutter.server.http.handlers.GachaHandler; +import emu.grasscutter.server.http.objects.LoginTokenRequestJson; + +import javax.annotation.Nullable; +import java.net.http.HttpClient; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; + +public interface DispatchUtils { + /** HTTP client used for dispatch queries. */ + HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + + /** + * @return The dispatch URL. + */ + static String getDispatchUrl() { + return DISPATCH_INFO.dispatchUrl; + } + + /** + * Validates an authentication request. + * + * @param accountId The account ID. + * @param token The token. + * @return {@code true} if the authentication request is valid, otherwise {@code false}. + */ + @Nullable + static Account authenticate(String accountId, String token) { + return switch (Grasscutter.getRunMode()) { + case GAME_ONLY -> + // Use the authentication system to validate the token. + Grasscutter.getAuthenticationSystem() + .getSessionTokenValidator() + .authenticate( + AuthenticationRequest.builder() + .tokenRequest( + LoginTokenRequestJson.builder() + .uid(accountId) + .token(token) + .build() + ).build() + ); + case HYBRID, DISPATCH_ONLY -> { + // Fetch the account from the database. + var account = DatabaseHelper.getAccountById(accountId); + if (account == null) yield null; + + // Check if the token is valid. + yield account.getToken().equals(token) ? account : null; + } + }; + } + + /** + * Fetches the gacha history for the specified account. + * + * @param accountId The account ID. + * @param page The page. + * @param gachaType The gacha type. + * @return The gacha history. + */ + static JsonObject fetchGachaRecords(String accountId, int page, int gachaType) { + return switch (Grasscutter.getRunMode()) { + case DISPATCH_ONLY -> { + // Create a request for gacha records. + var request = new JsonObject(); + request.addProperty("accountId", accountId); + request.addProperty("page", page); + request.addProperty("gachaType", gachaType); + + // Create a future for the response. + var future = new CompletableFuture(); + // Listen for the response. + var server = Grasscutter.getDispatchServer(); + server.registerCallback(PacketIds.GachaHistoryRsp, packet -> + future.complete(IDispatcher.decode(packet, JsonObject.class))); + + // Broadcast the request. + server.sendMessage(PacketIds.GachaHistoryReq, request); + + try { + // Wait for the response. + yield future.get(5L, TimeUnit.SECONDS); + } catch (Exception ignored) { + yield null; + } + } + case HYBRID, GAME_ONLY -> { + // Create a response object. + var response = new JsonObject(); + + // Get the player's ID from the account. + var player = Grasscutter.getGameServer() + .getPlayerByAccountId(accountId); + if (player == null) { + response.addProperty("retcode", 1); + yield response; + } + + // Fetch the gacha records. + GachaHandler.fetchGachaRecords( + player, response, page, gachaType); + + yield response; + } + }; + } +} diff --git a/src/main/java/emu/grasscutter/utils/JsonAdapters.java b/src/main/java/emu/grasscutter/utils/JsonAdapters.java index d6805bf91..f6e64b05e 100644 --- a/src/main/java/emu/grasscutter/utils/JsonAdapters.java +++ b/src/main/java/emu/grasscutter/utils/JsonAdapters.java @@ -11,12 +11,13 @@ import emu.grasscutter.data.common.DynamicFloat; import it.unimi.dsi.fastutil.floats.FloatArrayList; import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntList; +import lombok.val; + import java.io.IOException; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; import java.util.Objects; -import lombok.val; public class JsonAdapters { static class DynamicFloatAdapter extends TypeAdapter { @@ -77,6 +78,18 @@ public class JsonAdapters { } } + public static class ByteArrayAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, byte[] value) throws IOException { + out.value(Utils.base64Encode(value)); + } + + @Override + public byte[] read(JsonReader in) throws IOException { + return Utils.base64Decode(in.nextString()); + } + } + static class GridPositionAdapter extends TypeAdapter { @Override public void write(JsonWriter out, GridPosition value) throws IOException { diff --git a/src/main/java/emu/grasscutter/utils/JsonUtils.java b/src/main/java/emu/grasscutter/utils/JsonUtils.java index 2128eecdb..f6c50fad1 100644 --- a/src/main/java/emu/grasscutter/utils/JsonUtils.java +++ b/src/main/java/emu/grasscutter/utils/JsonUtils.java @@ -1,9 +1,6 @@ package emu.grasscutter.utils; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonSyntaxException; +import com.google.gson.*; import com.google.gson.reflect.TypeToken; import emu.grasscutter.data.common.DynamicFloat; import emu.grasscutter.utils.JsonAdapters.*; @@ -27,9 +24,21 @@ public final class JsonUtils { .registerTypeAdapter(IntList.class, new IntListAdapter()) .registerTypeAdapter(Position.class, new PositionAdapter()) .registerTypeAdapter(GridPosition.class, new GridPositionAdapter()) + .registerTypeAdapter(byte[].class, new ByteArrayAdapter()) .registerTypeAdapterFactory(new EnumTypeAdapterFactory()) + .disableHtmlEscaping() .create(); + /** + * Converts the given object to a JsonElement. + * + * @param object The object to convert. + * @return The JsonElement. + */ + public static JsonElement toJson(Object object) { + return gson.toJsonTree(object); + } + /* * Encode an object to a JSON string */ diff --git a/src/main/java/emu/grasscutter/utils/Language.java b/src/main/java/emu/grasscutter/utils/Language.java index 9a62542e6..c92e7a2a0 100644 --- a/src/main/java/emu/grasscutter/utils/Language.java +++ b/src/main/java/emu/grasscutter/utils/Language.java @@ -1,9 +1,5 @@ package emu.grasscutter.utils; -import static emu.grasscutter.config.Configuration.FALLBACK_LANGUAGE; -import static emu.grasscutter.utils.FileUtils.getCachePath; -import static emu.grasscutter.utils.FileUtils.getResourcePath; - import com.google.gson.JsonElement; import com.google.gson.JsonObject; import emu.grasscutter.Grasscutter; @@ -17,21 +13,23 @@ import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.ints.IntSet; import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import lombok.EqualsAndHashCode; + import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; -import lombok.EqualsAndHashCode; + +import static emu.grasscutter.config.Configuration.FALLBACK_LANGUAGE; +import static emu.grasscutter.utils.FileUtils.getCachePath; +import static emu.grasscutter.utils.FileUtils.getResourcePath; public final class Language { private static final Map cachedLanguages = new ConcurrentHashMap<>(); @@ -117,6 +115,41 @@ public final class Language { } } + /** + * Returns the translated value from the key while substituting arguments. + * + * @param locale The locale to use. + * @param key The key of the translated value to return. + * @param args The arguments to substitute. + * @return A translated value with arguments substituted. + */ + public static String translate(Locale locale, String key, Object... args) { + if (locale == null) { + return translate(key, args); + } + + var langCode = Utils.getLanguageCode(locale); + var translated = getLanguage(langCode).get(key); + + for (var i = 0; i < args.length; i++) { + args[i] = + switch (args[i].getClass().getSimpleName()) { + case "String" -> args[i]; + case "TextStrings" -> ((TextStrings) args[i]) + .getGC(langCode) + .replace("\\\\n", "\n"); // Note that we don't unescape \n for server console + default -> args[i].toString(); + }; + } + + try { + return translated.formatted(args); + } catch (Exception exception) { + Grasscutter.getLogger().error("Failed to format string: " + key, exception); + return translated; + } + } + /** * Returns the translated value from the key while substituting arguments. * @@ -130,26 +163,7 @@ public final class Language { return translate(key, args); } - var langCode = Utils.getLanguageCode(player.getAccount().getLocale()); - String translated = getLanguage(langCode).get(key); - - for (int i = 0; i < args.length; i++) { - args[i] = - switch (args[i].getClass().getSimpleName()) { - case "String" -> args[i]; - case "TextStrings" -> ((TextStrings) args[i]) - .getGC(langCode) - .replace("\\\\n", "\n"); // Note that we don't unescape \n for server console - default -> args[i].toString(); - }; - } - - try { - return translated.formatted(args); - } catch (Exception exception) { - Grasscutter.getLogger().error("Failed to format string: " + key, exception); - return translated; - } + return translate(player.getAccount().getLocale(), key, args); } /** diff --git a/src/main/java/emu/grasscutter/utils/StartupArguments.java b/src/main/java/emu/grasscutter/utils/StartupArguments.java index d7532e836..913955c0e 100644 --- a/src/main/java/emu/grasscutter/utils/StartupArguments.java +++ b/src/main/java/emu/grasscutter/utils/StartupArguments.java @@ -1,62 +1,73 @@ package emu.grasscutter.utils; -import static emu.grasscutter.config.Configuration.*; - import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import emu.grasscutter.BuildConfig; import emu.grasscutter.Grasscutter; -import emu.grasscutter.Grasscutter.ServerRunMode; import emu.grasscutter.net.packet.PacketOpcodesUtils; import emu.grasscutter.tools.Dumpers; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; import java.util.Map; import java.util.function.Function; -import org.slf4j.LoggerFactory; + +import static emu.grasscutter.config.Configuration.*; /** A parser for start-up arguments. */ public final class StartupArguments { /* A map of parameter -> argument handler. */ - private static final Map> argumentHandlers = - Map.of( - "-dumppacketids", - parameter -> { - PacketOpcodesUtils.dumpPacketIds(); - return true; - }, - "-version", StartupArguments::printVersion, - "-debug", StartupArguments::enableDebug, - "-lang", - parameter -> { - Grasscutter.setPreferredLanguage(parameter); - return false; - }, - "-game", - parameter -> { - Grasscutter.setRunModeOverride(ServerRunMode.GAME_ONLY); - return false; - }, - "-dispatch", - parameter -> { - Grasscutter.setRunModeOverride(ServerRunMode.DISPATCH_ONLY); - return false; - }, - "-test", - parameter -> { - // Disable the console. - SERVER.game.enableConsole = false; - // Disable HTTP encryption. - SERVER.http.encryption.useEncryption = false; - return false; - }, - "-dump", StartupArguments::dump, + private static final Map> argumentHandlers = new HashMap<>() { + { + putAll(Map.of("-dumppacketids", + parameter -> { + PacketOpcodesUtils.dumpPacketIds(); + return true; + }, + "-version", StartupArguments::printVersion, + "-debug", StartupArguments::enableDebug, + "-lang", + parameter -> { + Grasscutter.setPreferredLanguage(parameter); + return false; + }, + "-game", + parameter -> { + Grasscutter.setRunModeOverride(Grasscutter.ServerRunMode.GAME_ONLY); + return false; + }, + "-dispatch", + parameter -> { + Grasscutter.setRunModeOverride(Grasscutter.ServerRunMode.DISPATCH_ONLY); + return false; + }, + "-noconsole", + parameter -> { + Grasscutter.setNoConsole(true); + return false; + }, + "-test", + parameter -> { + // Disable the console. + SERVER.game.enableConsole = false; + // Disable HTTP encryption. + SERVER.http.encryption.useEncryption = false; + return false; + }, + "-dump", StartupArguments::dump, - // Aliases. - "-v", StartupArguments::printVersion, - "-debugall", - parameter -> { - StartupArguments.enableDebug("all"); - return false; - }); + // Aliases. + "-v", StartupArguments::printVersion + )); + putAll(Map.of( + "-debugall", + parameter -> { + StartupArguments.enableDebug("all"); + return false; + } + )); + } + }; private StartupArguments() { // This class is not meant to be instantiated. diff --git a/src/main/resources/defaults/data/gacha/records.html b/src/main/resources/defaults/data/gacha/records.html index 55b4e7fd1..f1ef4598a 100644 --- a/src/main/resources/defaults/data/gacha/records.html +++ b/src/main/resources/defaults/data/gacha/records.html @@ -68,18 +68,8 @@