diff --git a/src/main/java/emu/grasscutter/Configuration.java b/src/main/java/emu/grasscutter/Configuration.java index 7adc334c1..52bfa65aa 100644 --- a/src/main/java/emu/grasscutter/Configuration.java +++ b/src/main/java/emu/grasscutter/Configuration.java @@ -34,11 +34,12 @@ public final class Configuration extends ConfigContainer { public static final Database DATABASE = config.databaseInfo; public static final Account ACCOUNT = config.account; - public static final Dispatch DISPATCH_INFO = config.server.dispatch; + public static final HTTP HTTP_INFO = config.server.http; public static final Game GAME_INFO = config.server.game; + public static final Dispatch DISPATCH_INFO = config.server.dispatch; - public static final Encryption DISPATCH_ENCRYPTION = config.server.dispatch.encryption; - public static final Policies DISPATCH_POLICIES = config.server.dispatch.policies; + public static final Encryption HTTP_ENCRYPTION = config.server.http.encryption; + public static final Policies HTTP_POLICIES = config.server.http.policies; public static final GameOptions GAME_OPTIONS = config.server.game.gameOptions; public static final GameOptions.InventoryLimits INVENTORY_LIMITS = config.server.game.gameOptions.inventoryLimits; diff --git a/src/main/java/emu/grasscutter/Grasscutter.java b/src/main/java/emu/grasscutter/Grasscutter.java index 73e761e6e..bddfa9964 100644 --- a/src/main/java/emu/grasscutter/Grasscutter.java +++ b/src/main/java/emu/grasscutter/Grasscutter.java @@ -3,10 +3,18 @@ package emu.grasscutter; import java.io.*; import java.util.Calendar; +import emu.grasscutter.auth.AuthenticationSystem; +import emu.grasscutter.auth.DefaultAuthentication; import emu.grasscutter.command.CommandMap; import emu.grasscutter.plugin.PluginManager; import emu.grasscutter.plugin.api.ServerHook; import emu.grasscutter.scripts.ScriptLoader; +import emu.grasscutter.server.http.HttpServer; +import emu.grasscutter.server.http.dispatch.DispatchHandler; +import emu.grasscutter.server.http.handlers.AnnouncementsHandler; +import emu.grasscutter.server.http.handlers.GenericHandler; +import emu.grasscutter.server.http.handlers.LogHandler; +import emu.grasscutter.server.http.dispatch.RegionHandler; import emu.grasscutter.utils.ConfigContainer; import emu.grasscutter.utils.Utils; import org.jline.reader.EndOfFileException; @@ -47,8 +55,10 @@ public final class Grasscutter { private static int day; // Current day of week. private static DispatchServer dispatchServer; + private static HttpServer httpServer; private static GameServer gameServer; private static PluginManager pluginManager; + private static AuthenticationSystem authenticationSystem; public static final Reflections reflector = new Reflections("emu.grasscutter"); public static ConfigContainer config; @@ -98,14 +108,27 @@ public final class Grasscutter { // Initialize database. DatabaseManager.initialize(); + + // Initialize the default authentication system. + authenticationSystem = new DefaultAuthentication(); // Create server instances. dispatchServer = new DispatchServer(); + httpServer = new HttpServer(); gameServer = new GameServer(); // Create a server hook instance with both servers. new ServerHook(gameServer, dispatchServer); + // 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); // Start servers. var runMode = SERVER.runMode; @@ -114,6 +137,7 @@ public final class Grasscutter { gameServer.start(); } else if (runMode == ServerRunMode.DISPATCH_ONLY) { dispatchServer.start(); + httpServer.start(); } else if (runMode == ServerRunMode.GAME_ONLY) { gameServer.start(); } else { @@ -141,6 +165,19 @@ public final class Grasscutter { pluginManager.disablePlugins(); } + /* + * Methods for the language system component. + */ + + public static void loadLanguage() { + var locale = config.language.language; + language = Language.getLanguage(Utils.getLanguageCode(locale)); + } + + /* + * Methods for the configuration system component. + */ + /** * Attempts to load the configuration from a file. */ @@ -157,11 +194,6 @@ public final class Grasscutter { } } - public static void loadLanguage() { - var locale = config.language.language; - language = Language.getLanguage(Utils.getLanguageCode(locale)); - } - /** * Saves the provided server configuration. * @param config The configuration to save, or null for a new one. @@ -178,44 +210,10 @@ public final class Grasscutter { } } - public static void startConsole() { - // Console should not start in dispatch only mode. - if (SERVER.runMode == ServerRunMode.DISPATCH_ONLY) { - getLogger().info(translate("messages.dispatch.no_commands_error")); - return; - } - - getLogger().info(translate("messages.status.done")); - String input = null; - boolean isLastInterrupted = false; - while (true) { - try { - input = consoleLineReader.readLine("> "); - } catch (UserInterruptException e) { - if (!isLastInterrupted) { - isLastInterrupted = true; - Grasscutter.getLogger().info("Press Ctrl-C again to shutdown."); - continue; - } else { - Runtime.getRuntime().exit(0); - } - } catch (EndOfFileException e) { - Grasscutter.getLogger().info("EOF detected."); - continue; - } catch (IOError e) { - Grasscutter.getLogger().error("An IO error occurred.", e); - continue; - } - - isLastInterrupted = false; - try { - CommandMap.getInstance().invoke(null, null, input); - } catch (Exception e) { - Grasscutter.getLogger().error(translate("messages.game.command_error"), e); - } - } - } - + /* + * Getters for the various server components. + */ + public static ConfigContainer getConfig() { return config; } @@ -271,16 +269,74 @@ public final class Grasscutter { public static PluginManager getPluginManager() { return pluginManager; } - - public static void updateDayOfWeek() { - Calendar calendar = Calendar.getInstance(); - day = calendar.get(Calendar.DAY_OF_WEEK); + + public static AuthenticationSystem getAuthenticationSystem() { + return authenticationSystem; } public static int getCurrentDayOfWeek() { return day; } + + /* + * Utility methods. + */ + + public static void updateDayOfWeek() { + Calendar calendar = Calendar.getInstance(); + day = calendar.get(Calendar.DAY_OF_WEEK); + } + public static void startConsole() { + // Console should not start in dispatch only mode. + if (SERVER.runMode == ServerRunMode.DISPATCH_ONLY) { + getLogger().info(translate("messages.dispatch.no_commands_error")); + return; + } + + getLogger().info(translate("messages.status.done")); + String input = null; + boolean isLastInterrupted = false; + while (true) { + try { + input = consoleLineReader.readLine("> "); + } catch (UserInterruptException e) { + if (!isLastInterrupted) { + isLastInterrupted = true; + Grasscutter.getLogger().info("Press Ctrl-C again to shutdown."); + continue; + } else { + Runtime.getRuntime().exit(0); + } + } catch (EndOfFileException e) { + Grasscutter.getLogger().info("EOF detected."); + continue; + } catch (IOError e) { + Grasscutter.getLogger().error("An IO error occurred.", e); + continue; + } + + isLastInterrupted = false; + try { + CommandMap.getInstance().invoke(null, null, input); + } catch (Exception e) { + Grasscutter.getLogger().error(translate("messages.game.command_error"), e); + } + } + } + + /** + * Sets the authentication system for the server. + * @param authenticationSystem The authentication system to use. + */ + public static void setAuthenticationSystem(AuthenticationSystem authenticationSystem) { + Grasscutter.authenticationSystem = authenticationSystem; + } + + /* + * Enums for the configuration. + */ + public enum ServerRunMode { HYBRID, DISPATCH_ONLY, GAME_ONLY } diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java index 4e09f8881..8b8a9a185 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java @@ -117,7 +117,7 @@ public final class DispatchServer { .setTitle(DISPATCH_INFO.defaultName) .setType("DEV_PUBLIC") .setDispatchUrl( - "http" + (DISPATCH_ENCRYPTION.useInRouting ? "s" : "") + "://" + "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://" + lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":" + lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort) + "/query_cur_region/" + defaultServerName) @@ -150,7 +150,7 @@ public final class DispatchServer { .setTitle(regionInfo.Title) .setType("DEV_PUBLIC") .setDispatchUrl( - "http" + (DISPATCH_ENCRYPTION.useInRouting ? "s" : "") + "://" + "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://" + lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":" + lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort) + "/query_cur_region/" + regionInfo.Name) @@ -189,14 +189,14 @@ public final class DispatchServer { Server server = new Server(); ServerConnector serverConnector; - if(DISPATCH_ENCRYPTION.useEncryption) { + if(HTTP_ENCRYPTION.useEncryption) { SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); - File keystoreFile = new File(DISPATCH_ENCRYPTION.keystore); + File keystoreFile = new File(HTTP_ENCRYPTION.keystore); if(keystoreFile.exists()) { try { sslContextFactory.setKeyStorePath(keystoreFile.getPath()); - sslContextFactory.setKeyStorePassword(DISPATCH_ENCRYPTION.keystorePassword); + sslContextFactory.setKeyStorePassword(HTTP_ENCRYPTION.keystorePassword); } catch (Exception e) { e.printStackTrace(); Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.password_error")); @@ -214,7 +214,7 @@ public final class DispatchServer { serverConnector = new ServerConnector(server, sslContextFactory); } else { Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.no_keystore_error")); - DISPATCH_ENCRYPTION.useEncryption = false; + HTTP_ENCRYPTION.useEncryption = false; serverConnector = new ServerConnector(server); } @@ -227,18 +227,19 @@ public final class DispatchServer { return server; }); - config.enforceSsl = DISPATCH_ENCRYPTION.useEncryption; + config.enforceSsl = HTTP_ENCRYPTION.useEncryption; if(SERVER.debugLevel == ServerDebugMode.ALL) { config.enableDevLogging(); } - if (DISPATCH_POLICIES.cors.enabled) { - var corsPolicy = DISPATCH_POLICIES.cors; + if (HTTP_POLICIES.cors.enabled) { + var corsPolicy = HTTP_POLICIES.cors; if (corsPolicy.allowedOrigins.length > 0) config.enableCorsForOrigin(corsPolicy.allowedOrigins); else config.enableCorsForAllOrigins(); } }); + httpServer.get("/", (req, res) -> res.send("
" + translate("messages.status.welcome") + "")); httpServer.raw().error(404, ctx -> { @@ -279,6 +280,7 @@ public final class DispatchServer { res.send(event.getRegionList()); }); + // /server/:id -> 2.6.5x httpServer.get("/query_cur_region/:id", (req, res) -> { String regionName = req.params("id"); // Log diff --git a/src/main/java/emu/grasscutter/server/http/HttpServer.java b/src/main/java/emu/grasscutter/server/http/HttpServer.java new file mode 100644 index 000000000..dc0d396a6 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/HttpServer.java @@ -0,0 +1,176 @@ +package emu.grasscutter.server.http; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.Grasscutter.ServerDebugMode; +import express.Express; +import io.javalin.Javalin; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +import java.io.File; + +import static emu.grasscutter.Configuration.*; +import static emu.grasscutter.utils.Language.translate; + +/** + * Manages all HTTP-related classes. + * (including dispatch, announcements, gacha, etc.) + */ +public final class HttpServer { + private final Express express; + + /** + * Configures the Express application. + */ + public HttpServer() { + this.express = new Express(config -> { + // Set the Express HTTP server. + config.server(HttpServer::createServer); + + // Configure encryption/HTTPS/SSL. + config.enforceSsl = HTTP_ENCRYPTION.useEncryption; + + // Configure HTTP policies. + if(HTTP_POLICIES.cors.enabled) { + var allowedOrigins = HTTP_POLICIES.cors.allowedOrigins; + if (allowedOrigins.length > 0) + config.enableCorsForOrigin(allowedOrigins); + else config.enableCorsForAllOrigins(); + } + + // Configure debug logging. + if(SERVER.debugLevel == ServerDebugMode.ALL) + config.enableDevLogging(); + + // Disable compression on static files. + config.precompressStaticFiles = false; + }); + } + + /** + * Creates an HTTP(S) server. + * @return A server instance. + */ + @SuppressWarnings("resource") + private static Server createServer() { + Server server = new Server(); + ServerConnector serverConnector + = new ServerConnector(server); + + if(HTTP_ENCRYPTION.useEncryption) { + var sslContextFactory = new SslContextFactory.Server(); + var keystoreFile = new File(HTTP_ENCRYPTION.keystore); + + if(!keystoreFile.exists()) {; + HTTP_ENCRYPTION.useEncryption = false; + HTTP_ENCRYPTION.useInRouting = false; + + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.no_keystore_error")); + } else try { + sslContextFactory.setKeyStorePath(keystoreFile.getPath()); + sslContextFactory.setKeyStorePassword(HTTP_ENCRYPTION.keystorePassword); + } catch (Exception ignored) { + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.password_error")); + + try { + sslContextFactory.setKeyStorePath(keystoreFile.getPath()); + sslContextFactory.setKeyStorePassword("123456"); + + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.default_password")); + } catch (Exception exception) { + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.general_error"), exception); + } + } finally { + serverConnector = new ServerConnector(server, sslContextFactory); + } + } + + serverConnector.setPort(HTTP_INFO.bindPort); + server.setConnectors(new ServerConnector[]{serverConnector}); + + return server; + } + + /** + * Returns the handle for the Express application. + * @return A Javalin instance. + */ + public Javalin getHandle() { + return this.express.raw(); + } + + /** + * Initializes the provided class. + * @param router The router class. + * @return Method chaining. + */ + @SuppressWarnings("UnusedReturnValue") + public HttpServer addRouter(Class extends Router> router, Object... args) { + // Get all constructor parameters. + Class>[] types = new Class>[args.length]; + for(var argument : args) + types[args.length - 1] = argument.getClass(); + + try { // Create a router instance & apply routes. + var constructor = router.getDeclaredConstructor(types); // Get the constructor. + var routerInstance = constructor.newInstance(args); // Create instance. + routerInstance.applyRoutes(this.express, this.getHandle()); // Apply routes. + } catch (Exception exception) { + Grasscutter.getLogger().warn(translate("messages.dispatch.router_error"), exception); + } return this; + } + + /** + * Starts listening on the HTTP server. + */ + public void start() { + // Attempt to start the HTTP server. + this.express.listen(HTTP_INFO.bindAddress, HTTP_INFO.bindPort); + + // Log bind information. + Grasscutter.getLogger().info(translate("messages.dispatch.port_bind", Integer.toString(this.express.raw().port()))); + } + + /** + * Handles the '/' (index) endpoint on the Express application. + */ + public static class DefaultRequestRouter implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + express.get("/", (req, res) -> res.send(""" + + + + + + %s + + """.formatted(translate("messages.status.welcome")))); + } + } + + /** + * Handles unhandled endpoints on the Express application. + */ + public static class UnhandledRequestRouter implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + handle.error(404, context -> { + if(SERVER.debugLevel == ServerDebugMode.MISSING) + Grasscutter.getLogger().info(translate("messages.dispatch.unhandled_request_error", context.method(), context.url())); + context.contentType("text/html"); + context.result(""" + + + + + + + + + + + """); + }); + } + } +} diff --git a/src/main/java/emu/grasscutter/server/http/Router.java b/src/main/java/emu/grasscutter/server/http/Router.java new file mode 100644 index 000000000..1720d7ca0 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/Router.java @@ -0,0 +1,16 @@ +package emu.grasscutter.server.http; + +import express.Express; +import io.javalin.Javalin; + +/** + * Defines routes for an {@link Express} instance. + */ +public interface Router { + + /** + * Called when the router is initialized by Express. + * @param express An Express instance. + */ + void applyRoutes(Express express, Javalin handle); +} diff --git a/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java b/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java new file mode 100644 index 000000000..22a31fe6a --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java @@ -0,0 +1,100 @@ +package emu.grasscutter.server.http.dispatch; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.auth.AuthenticationSystem; +import emu.grasscutter.server.http.Router; +import emu.grasscutter.server.http.objects.*; +import emu.grasscutter.server.http.objects.ComboTokenReqJson.LoginTokenData; +import emu.grasscutter.utils.Utils; +import express.Express; +import express.http.Request; +import express.http.Response; +import io.javalin.Javalin; + +import static emu.grasscutter.utils.Language.translate; + +/** + * Handles requests related to authentication. (aka dispatch) + */ +public final class DispatchHandler implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + // Username & Password login (from client). + express.post("/hk4e_global/mdk/shield/api/login", DispatchHandler::clientLogin); + // Cached token login (from registry). + express.post("/hk4e_global/mdk/shield/api/verify", DispatchHandler::tokenLogin); + // Combo token login (from session key). + express.post("/hk4e_global/combo/granter/login/v2/login", DispatchHandler::sessionKeyLogin); + } + + /** + * @route /hk4e_global/mdk/shield/api/login + */ + private static void clientLogin(Request request, Response response) { + // Parse body data. + String rawBodyData = request.ctx().body(); + var bodyData = Utils.jsonDecode(rawBodyData, LoginAccountRequestJson.class); + + // Validate body data. + if(bodyData == null) + return; + + // Pass data to authentication handler. + var responseData = Grasscutter.getAuthenticationSystem() + .getPasswordAuthenticator() + .authenticate(AuthenticationSystem.fromPasswordRequest(request, bodyData)); + // Send response. + response.send(responseData); + + // Log to console. + Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", request.ip())); + } + + /** + * @route /hk4e_global/mdk/shield/api/verify + */ + private static void tokenLogin(Request request, Response response) { + // Parse body data. + String rawBodyData = request.ctx().body(); + var bodyData = Utils.jsonDecode(rawBodyData, LoginTokenRequestJson.class); + + // Validate body data. + if(bodyData == null) + return; + + // Pass data to authentication handler. + var responseData = Grasscutter.getAuthenticationSystem() + .getTokenAuthenticator() + .authenticate(AuthenticationSystem.fromTokenRequest(request, bodyData)); + // Send response. + response.send(responseData); + + // Log to console. + Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", request.ip())); + } + + /** + * @route /hk4e_global/combo/granter/login/v2/login + */ + private static void sessionKeyLogin(Request request, Response response) { + // Parse body data. + String rawBodyData = request.ctx().body(); + var bodyData = Utils.jsonDecode(rawBodyData, ComboTokenReqJson.class); + + // Validate body data. + if(bodyData == null || bodyData.data == null) + return; + + // Decode additional body data. + var tokenData = Utils.jsonDecode(bodyData.data, LoginTokenData.class); + + // Pass data to authentication handler. + var responseData = Grasscutter.getAuthenticationSystem() + .getSessionKeyAuthenticator() + .authenticate(AuthenticationSystem.fromComboTokenRequest(request, bodyData, tokenData)); + // Send response. + response.send(responseData); + + // Log to console. + Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", request.ip())); + } +} diff --git a/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java b/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java new file mode 100644 index 000000000..e720a4b15 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java @@ -0,0 +1,186 @@ +package emu.grasscutter.server.http.dispatch; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import emu.grasscutter.Grasscutter; +import emu.grasscutter.Grasscutter.ServerRunMode; +import emu.grasscutter.net.proto.QueryCurrRegionHttpRspOuterClass.*; +import emu.grasscutter.net.proto.RegionSimpleInfoOuterClass.RegionSimpleInfo; +import emu.grasscutter.server.event.dispatch.QueryAllRegionsEvent; +import emu.grasscutter.server.event.dispatch.QueryCurrentRegionEvent; +import emu.grasscutter.server.http.Router; +import emu.grasscutter.utils.FileUtils; +import emu.grasscutter.utils.Utils; +import express.Express; +import express.http.Request; +import express.http.Response; +import io.javalin.Javalin; + +import java.io.File; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static emu.grasscutter.Configuration.*; +import static emu.grasscutter.net.proto.QueryRegionListHttpRspOuterClass.*; + +/** + * Handles requests related to region queries. + */ +public final class RegionHandler implements Router { + private String regionQuery = ""; + private String regionList = ""; + + private static final Map