Merge pull request #868 from Grasscutters/plugin-auth

Refactor 'dispatch' server/Update plugin authentication API
This commit is contained in:
Melledy 2022-05-14 16:06:13 -07:00 committed by GitHub
commit 5c3d99800e
38 changed files with 1487 additions and 1009 deletions

View File

@ -43,7 +43,7 @@ sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
group = 'xyz.grasscutters' group = 'xyz.grasscutters'
version = '1.1.1-dev' version = '1.1.2-dev'
sourceCompatibility = 17 sourceCompatibility = 17
@ -87,6 +87,9 @@ dependencies {
implementation group: 'org.luaj', name: 'luaj-jse', version: '3.0.1' implementation group: 'org.luaj', name: 'luaj-jse', version: '3.0.1'
protobuf files('proto/') protobuf files('proto/')
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
} }
configurations.all { configurations.all {

View File

@ -1,6 +1,7 @@
package emu.grasscutter; package emu.grasscutter;
import emu.grasscutter.utils.ConfigContainer; import emu.grasscutter.utils.ConfigContainer;
import emu.grasscutter.utils.ConfigContainer.*;
import java.util.Locale; import java.util.Locale;
@ -34,11 +35,13 @@ public final class Configuration extends ConfigContainer {
public static final Database DATABASE = config.databaseInfo; public static final Database DATABASE = config.databaseInfo;
public static final Account ACCOUNT = config.account; 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 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 Encryption HTTP_ENCRYPTION = config.server.http.encryption;
public static final Policies DISPATCH_POLICIES = config.server.dispatch.policies; public static final Policies HTTP_POLICIES = config.server.http.policies;
public static final Files HTTP_STATIC_FILES = config.server.http.files;
public static final GameOptions GAME_OPTIONS = config.server.game.gameOptions; public static final GameOptions GAME_OPTIONS = config.server.game.gameOptions;
public static final GameOptions.InventoryLimits INVENTORY_LIMITS = config.server.game.gameOptions.inventoryLimits; public static final GameOptions.InventoryLimits INVENTORY_LIMITS = config.server.game.gameOptions.inventoryLimits;

View File

@ -3,11 +3,17 @@ package emu.grasscutter;
import java.io.*; import java.io.*;
import java.util.Calendar; import java.util.Calendar;
import emu.grasscutter.auth.AuthenticationSystem;
import emu.grasscutter.auth.DefaultAuthentication;
import emu.grasscutter.command.CommandMap; import emu.grasscutter.command.CommandMap;
import emu.grasscutter.game.managers.StaminaManager.StaminaManager; import emu.grasscutter.game.managers.StaminaManager.StaminaManager;
import emu.grasscutter.plugin.PluginManager; import emu.grasscutter.plugin.PluginManager;
import emu.grasscutter.plugin.api.ServerHook; import emu.grasscutter.plugin.api.ServerHook;
import emu.grasscutter.scripts.ScriptLoader; import emu.grasscutter.scripts.ScriptLoader;
import emu.grasscutter.server.http.HttpServer;
import emu.grasscutter.server.http.dispatch.DispatchHandler;
import emu.grasscutter.server.http.handlers.*;
import emu.grasscutter.server.http.dispatch.RegionHandler;
import emu.grasscutter.utils.ConfigContainer; import emu.grasscutter.utils.ConfigContainer;
import emu.grasscutter.utils.Utils; import emu.grasscutter.utils.Utils;
import org.jline.reader.EndOfFileException; import org.jline.reader.EndOfFileException;
@ -26,11 +32,9 @@ import ch.qos.logback.classic.Logger;
import emu.grasscutter.data.ResourceLoader; import emu.grasscutter.data.ResourceLoader;
import emu.grasscutter.database.DatabaseManager; import emu.grasscutter.database.DatabaseManager;
import emu.grasscutter.utils.Language; import emu.grasscutter.utils.Language;
import emu.grasscutter.server.dispatch.DispatchServer;
import emu.grasscutter.server.game.GameServer; import emu.grasscutter.server.game.GameServer;
import emu.grasscutter.tools.Tools; import emu.grasscutter.tools.Tools;
import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.Crypto;
import emu.grasscutter.BuildConfig;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -48,9 +52,10 @@ public final class Grasscutter {
private static int day; // Current day of week. private static int day; // Current day of week.
private static DispatchServer dispatchServer; private static HttpServer httpServer;
private static GameServer gameServer; private static GameServer gameServer;
private static PluginManager pluginManager; private static PluginManager pluginManager;
private static AuthenticationSystem authenticationSystem;
public static final Reflections reflector = new Reflections("emu.grasscutter"); public static final Reflections reflector = new Reflections("emu.grasscutter");
public static ConfigContainer config; public static ConfigContainer config;
@ -104,13 +109,26 @@ public final class Grasscutter {
// Initialize database. // Initialize database.
DatabaseManager.initialize(); DatabaseManager.initialize();
// Initialize the default authentication system.
authenticationSystem = new DefaultAuthentication();
// Create server instances. // Create server instances.
dispatchServer = new DispatchServer(); httpServer = new HttpServer();
gameServer = new GameServer(); gameServer = new GameServer();
// Create a server hook instance with both servers. // Create a server hook instance with both servers.
new ServerHook(gameServer, dispatchServer); new ServerHook(gameServer, httpServer);
// Create plugin manager instance. // Create plugin manager instance.
pluginManager = new PluginManager(); 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);
// TODO: find a better place? // TODO: find a better place?
StaminaManager.initialize(); StaminaManager.initialize();
@ -118,10 +136,10 @@ public final class Grasscutter {
// Start servers. // Start servers.
var runMode = SERVER.runMode; var runMode = SERVER.runMode;
if (runMode == ServerRunMode.HYBRID) { if (runMode == ServerRunMode.HYBRID) {
dispatchServer.start(); httpServer.start();
gameServer.start(); gameServer.start();
} else if (runMode == ServerRunMode.DISPATCH_ONLY) { } else if (runMode == ServerRunMode.DISPATCH_ONLY) {
dispatchServer.start(); httpServer.start();
} else if (runMode == ServerRunMode.GAME_ONLY) { } else if (runMode == ServerRunMode.GAME_ONLY) {
gameServer.start(); gameServer.start();
} else { } else {
@ -149,6 +167,19 @@ public final class Grasscutter {
pluginManager.disablePlugins(); 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. * Attempts to load the configuration from a file.
*/ */
@ -164,18 +195,12 @@ public final class Grasscutter {
// If the file already exists, we attempt to load it. // If the file already exists, we attempt to load it.
try (FileReader file = new FileReader(configFile)) { try (FileReader file = new FileReader(configFile)) {
config = gson.fromJson(file, ConfigContainer.class); config = gson.fromJson(file, ConfigContainer.class);
} } catch (Exception exception) {
catch (Exception exception) {
getLogger().error("There was an error while trying to load the configuration from config.json. Please make sure that there are no syntax errors. If you want to start with a default configuration, delete your existing config.json."); getLogger().error("There was an error while trying to load the configuration from config.json. Please make sure that there are no syntax errors. If you want to start with a default configuration, delete your existing config.json.");
System.exit(1); System.exit(1);
} }
} }
public static void loadLanguage() {
var locale = config.language.language;
language = Language.getLanguage(Utils.getLanguageCode(locale));
}
/** /**
* Saves the provided server configuration. * Saves the provided server configuration.
* @param config The configuration to save, or null for a new one. * @param config The configuration to save, or null for a new one.
@ -192,44 +217,9 @@ public final class Grasscutter {
} }
} }
public static void startConsole() { /*
// Console should not start in dispatch only mode. * Getters for the various server components.
if (SERVER.runMode == ServerRunMode.DISPATCH_ONLY) { */
getLogger().info(translate("messages.dispatch.no_commands_error"));
return;
}
getLogger().info(translate("messages.status.done"));
getLogger().info(translate("messages.status.version", BuildConfig.VERSION, BuildConfig.GIT_HASH));
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);
}
}
}
public static ConfigContainer getConfig() { public static ConfigContainer getConfig() {
return config; return config;
@ -275,8 +265,8 @@ public final class Grasscutter {
return gson; return gson;
} }
public static DispatchServer getDispatchServer() { public static HttpServer getHttpServer() {
return dispatchServer; return httpServer;
} }
public static GameServer getGameServer() { public static GameServer getGameServer() {
@ -287,15 +277,73 @@ public final class Grasscutter {
return pluginManager; return pluginManager;
} }
public static void updateDayOfWeek() { public static AuthenticationSystem getAuthenticationSystem() {
Calendar calendar = Calendar.getInstance(); return authenticationSystem;
day = calendar.get(Calendar.DAY_OF_WEEK);
} }
public static int getCurrentDayOfWeek() { public static int getCurrentDayOfWeek() {
return day; 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 { public enum ServerRunMode {
HYBRID, DISPATCH_ONLY, GAME_ONLY HYBRID, DISPATCH_ONLY, GAME_ONLY
} }

View File

@ -0,0 +1,127 @@
package emu.grasscutter.auth;
import emu.grasscutter.server.http.objects.*;
import express.http.Request;
import express.http.Response;
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 {
/**
* Called when a user requests to make an account.
* @param username The provided username.
* @param password The provided password. (SHA-256'ed)
*/
void createAccount(String username, String password);
/**
* Called when a user requests to reset their password.
* @param username The username of the account to reset.
*/
void resetPassword(String username);
/**
* Called by plugins to internally verify a user's identity.
* @param details A unique, one-time token to verify the user.
* @return True if the user is verified, False otherwise.
*/
boolean verifyUser(String details);
/**
* This is the authenticator used for password authentication.
* @return An authenticator.
*/
Authenticator<LoginResultJson> getPasswordAuthenticator();
/**
* This is the authenticator used for token authentication.
* @return An authenticator.
*/
Authenticator<LoginResultJson> getTokenAuthenticator();
/**
* This is the authenticator used for session authentication.
* @return An authenticator.
*/
Authenticator<ComboTokenResJson> getSessionKeyAuthenticator();
/**
* This is the authenticator used for handling external authentication requests.
* @return An authenticator.
*/
ExternalAuthenticator getExternalAuthenticator();
/**
* A data container that holds relevant data for authenticating a client.
*/
@Builder @AllArgsConstructor @Getter
class AuthenticationRequest {
private final Request request;
@Nullable private final Response response;
@Nullable private final LoginAccountRequestJson passwordRequest;
@Nullable private final LoginTokenRequestJson tokenRequest;
@Nullable private final ComboTokenReqJson sessionKeyRequest;
@Nullable private final ComboTokenReqJson.LoginTokenData sessionKeyData;
}
/**
* Generates an authentication request from a {@link LoginAccountRequestJson} object.
* @param request The Express request.
* @param jsonData The JSON data.
* @return An authentication request.
*/
static AuthenticationRequest fromPasswordRequest(Request request, LoginAccountRequestJson jsonData) {
return AuthenticationRequest.builder()
.request(request)
.passwordRequest(jsonData)
.build();
}
/**
* Generates an authentication request from a {@link LoginTokenRequestJson} object.
* @param request The Express request.
* @param jsonData The JSON data.
* @return An authentication request.
*/
static AuthenticationRequest fromTokenRequest(Request request, LoginTokenRequestJson jsonData) {
return AuthenticationRequest.builder()
.request(request)
.tokenRequest(jsonData)
.build();
}
/**
* Generates an authentication request from a {@link ComboTokenReqJson} object.
* @param request The Express request.
* @param jsonData The JSON data.
* @return An authentication request.
*/
static AuthenticationRequest fromComboTokenRequest(Request request, ComboTokenReqJson jsonData,
ComboTokenReqJson.LoginTokenData tokenData) {
return AuthenticationRequest.builder()
.request(request)
.sessionKeyRequest(jsonData)
.sessionKeyData(tokenData)
.build();
}
/**
* Generates an authentication request from a {@link Response} object.
* @param request The Express request.
* @param response the Express response.
* @return An authentication request.
*/
static AuthenticationRequest fromExternalRequest(Request request, Response response) {
return AuthenticationRequest.builder().request(request)
.response(response).build();
}
}

View File

@ -0,0 +1,17 @@
package emu.grasscutter.auth;
import emu.grasscutter.server.http.objects.*;
/**
* Handles username/password authentication from the client.
* @param <T> The response object type. Should be {@link LoginResultJson} or {@link ComboTokenResJson}
*/
public interface Authenticator<T> {
/**
* Attempt to authenticate the client with the provided credentials.
* @param request The authentication request wrapped in a {@link AuthenticationSystem.AuthenticationRequest} object.
* @return The result of the login in an object.
*/
T authenticate(AuthenticationSystem.AuthenticationRequest request);
}

View File

@ -0,0 +1,55 @@
package emu.grasscutter.auth;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.auth.DefaultAuthenticators.*;
import emu.grasscutter.server.http.objects.ComboTokenResJson;
import emu.grasscutter.server.http.objects.LoginResultJson;
import static emu.grasscutter.utils.Language.translate;
/**
* The default Grasscutter authentication implementation.
* Allows all users to access any account.
*/
public final class DefaultAuthentication implements AuthenticationSystem {
private final Authenticator<LoginResultJson> passwordAuthenticator = new PasswordAuthenticator();
private final Authenticator<LoginResultJson> tokenAuthenticator = new TokenAuthenticator();
private final Authenticator<ComboTokenResJson> sessionKeyAuthenticator = new SessionKeyAuthenticator();
private final ExternalAuthenticator externalAuthenticator = new ExternalAuthentication();
@Override
public void createAccount(String username, String password) {
// Unhandled. The default authenticator doesn't store passwords.
}
@Override
public void resetPassword(String username) {
// Unhandled. The default authenticator doesn't store passwords.
}
@Override
public boolean verifyUser(String details) {
Grasscutter.getLogger().info(translate("dispatch.authentication.default_unable_to_verify"));
return false;
}
@Override
public Authenticator<LoginResultJson> getPasswordAuthenticator() {
return this.passwordAuthenticator;
}
@Override
public Authenticator<LoginResultJson> getTokenAuthenticator() {
return this.tokenAuthenticator;
}
@Override
public Authenticator<ComboTokenResJson> getSessionKeyAuthenticator() {
return this.sessionKeyAuthenticator;
}
@Override
public ExternalAuthenticator getExternalAuthenticator() {
return this.externalAuthenticator;
}
}

View File

@ -0,0 +1,181 @@
package emu.grasscutter.auth;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest;
import emu.grasscutter.database.DatabaseHelper;
import emu.grasscutter.game.Account;
import emu.grasscutter.server.http.objects.*;
import static emu.grasscutter.Configuration.*;
import static emu.grasscutter.utils.Language.translate;
/**
* A class containing default authenticators.
*/
public final class DefaultAuthenticators {
/**
* Handles the authentication request from the username and password form.
*/
public static class PasswordAuthenticator implements Authenticator<LoginResultJson> {
@Override public LoginResultJson authenticate(AuthenticationRequest request) {
var response = new LoginResultJson();
var requestData = request.getPasswordRequest();
assert requestData != null; // This should never be null.
boolean successfulLogin = false;
String address = request.getRequest().ip();
String responseMessage = translate("messages.dispatch.account.username_error");
// Get account from database.
Account account = DatabaseHelper.getAccountByName(requestData.account);
// Check if account exists.
if(account == null && ACCOUNT.autoCreate) {
// This account has been created AUTOMATICALLY. There will be no permissions added.
account = DatabaseHelper.createAccountWithId(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 {
// Add default permissions.
for (var permission : ACCOUNT.defaultPermissions)
account.addPermission(permission);
// 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;
// Set response data.
if(successfulLogin) {
response.message = "OK";
response.data.account.uid = account.getId();
response.data.account.token = account.generateSessionKey();
response.data.account.email = account.getEmail();
// Log the login.
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_success", address, account.getId()));
} else {
response.retcode = -201;
response.message = responseMessage;
// Log the failure.
Grasscutter.getLogger().info(translate("messages.dispatch.account.account_login_exist_error", address));
}
return response;
}
}
/**
* Handles the authentication request from the game when using a registry token.
*/
public static class TokenAuthenticator implements Authenticator<LoginResultJson> {
@Override public LoginResultJson authenticate(AuthenticationRequest request) {
var response = new LoginResultJson();
var requestData = request.getTokenRequest();
assert requestData != null;
boolean successfulLogin;
String address = request.getRequest().ip();
// Log the attempt.
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_token_attempt", address));
// 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);
// 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.
Grasscutter.getLogger().info(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.
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_token_error", address));
}
return response;
}
}
/**
* Handles the authentication request from the game when using a combo token/session key.
*/
public static class SessionKeyAuthenticator implements Authenticator<ComboTokenResJson> {
@Override public ComboTokenResJson authenticate(AuthenticationRequest request) {
var response = new ComboTokenResJson();
var requestData = request.getSessionKeyRequest();
var loginData = request.getSessionKeyData();
assert requestData != null; assert loginData != null;
boolean successfulLogin;
String address = request.getRequest().ip();
// 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);
// 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.
Grasscutter.getLogger().info(translate("messages.dispatch.account.combo_token_success", address));
} else {
response.retcode = -201;
response.message = translate("messages.dispatch.account.session_key_error");
// Log the failure.
Grasscutter.getLogger().info(translate("messages.dispatch.account.combo_token_error", address));
}
return response;
}
}
/**
* Handles authentication requests from external sources.
*/
public static class ExternalAuthentication implements ExternalAuthenticator {
@Override public void handleLogin(AuthenticationRequest request) {
assert request.getResponse() != null;
request.getResponse().send("Authentication is not available with the default authentication method.");
}
@Override public void handleAccountCreation(AuthenticationRequest request) {
assert request.getResponse() != null;
request.getResponse().send("Authentication is not available with the default authentication method.");
}
@Override public void handlePasswordReset(AuthenticationRequest request) {
assert request.getResponse() != null;
request.getResponse().send("Authentication is not available with the default authentication method.");
}
}
}

View File

@ -0,0 +1,33 @@
package emu.grasscutter.auth;
import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest;
/**
* Handles authentication via external routes.
*/
public interface ExternalAuthenticator {
/**
* Called when an external login request is made.
* @param request The authentication request.
*/
void handleLogin(AuthenticationRequest request);
/**
* Called when an external account creation request is made.
* @param request The authentication request.
*
* For developers: Use {@link AuthenticationRequest#getRequest()} to get the request body.
* Use {@link AuthenticationRequest#getResponse()} to get the response body.
*/
void handleAccountCreation(AuthenticationRequest request);
/**
* Called when an external password reset request is made.
* @param request The authentication request.
*
* For developers: Use {@link AuthenticationRequest#getRequest()} to get the request body.
* Use {@link AuthenticationRequest#getResponse()} to get the response body.
*/
void handlePasswordReset(AuthenticationRequest request);
}

View File

@ -136,13 +136,13 @@ public class GachaBanner {
} }
public GachaInfo toProto(String sessionKey) { public GachaInfo toProto(String sessionKey) {
String record = "http" + (DISPATCH_INFO.encryption.useInRouting ? "s" : "") + "://" String record = "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://"
+ lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":" + lr(HTTP_INFO.accessAddress, HTTP_INFO.bindAddress) + ":"
+ lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort) + lr(HTTP_INFO.accessPort, HTTP_INFO.bindPort)
+ "/gacha?s=" + sessionKey + "&gachaType=" + gachaType; + "/gacha?s=" + sessionKey + "&gachaType=" + gachaType;
String details = "http" + (DISPATCH_INFO.encryption.useInRouting ? "s" : "") + "://" String details = "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://"
+ lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":" + lr(HTTP_INFO.accessAddress, HTTP_INFO.bindAddress) + ":"
+ lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort) + lr(HTTP_INFO.accessPort, HTTP_INFO.bindPort)
+ "/gacha/details?s=" + sessionKey + "&gachaType=" + gachaType; + "/gacha/details?s=" + sessionKey + "&gachaType=" + gachaType;
// Grasscutter.getLogger().info("record = " + record); // Grasscutter.getLogger().info("record = " + record);

View File

@ -3,6 +3,8 @@ package emu.grasscutter.plugin;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import emu.grasscutter.plugin.api.ServerHook; import emu.grasscutter.plugin.api.ServerHook;
import emu.grasscutter.server.game.GameServer; import emu.grasscutter.server.game.GameServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
import java.io.InputStream; import java.io.InputStream;
@ -19,6 +21,7 @@ public abstract class Plugin {
private PluginIdentifier identifier; private PluginIdentifier identifier;
private URLClassLoader classLoader; private URLClassLoader classLoader;
private File dataFolder; private File dataFolder;
private Logger logger;
/** /**
* This method is reflected into. * This method is reflected into.
@ -35,6 +38,7 @@ public abstract class Plugin {
this.identifier = identifier; this.identifier = identifier;
this.classLoader = classLoader; this.classLoader = classLoader;
this.dataFolder = new File(PLUGINS_FOLDER, identifier.name); this.dataFolder = new File(PLUGINS_FOLDER, identifier.name);
this.logger = LoggerFactory.getLogger(identifier.name);
if(!this.dataFolder.exists() && !this.dataFolder.mkdirs()) { if(!this.dataFolder.exists() && !this.dataFolder.mkdirs()) {
Grasscutter.getLogger().warn("Failed to create plugin data folder for " + this.identifier.name); Grasscutter.getLogger().warn("Failed to create plugin data folder for " + this.identifier.name);
@ -104,6 +108,14 @@ public abstract class Plugin {
return this.server; return this.server;
} }
/**
* Returns the plugin's logger.
* @return A SLF4J logger.
*/
public final Logger getLogger() {
return this.logger;
}
/* Called when the plugin is first loaded. */ /* Called when the plugin is first loaded. */
public void onLoad() { } public void onLoad() { }
/* Called after (most of) the server enables. */ /* Called after (most of) the server enables. */

View File

@ -1,10 +1,13 @@
package emu.grasscutter.plugin.api; package emu.grasscutter.plugin.api;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.auth.AuthenticationSystem;
import emu.grasscutter.command.Command; import emu.grasscutter.command.Command;
import emu.grasscutter.command.CommandHandler; import emu.grasscutter.command.CommandHandler;
import emu.grasscutter.game.player.Player; import emu.grasscutter.game.player.Player;
import emu.grasscutter.server.dispatch.DispatchServer;
import emu.grasscutter.server.game.GameServer; import emu.grasscutter.server.game.GameServer;
import emu.grasscutter.server.http.HttpServer;
import emu.grasscutter.server.http.Router;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -15,7 +18,7 @@ import java.util.List;
public final class ServerHook { public final class ServerHook {
private static ServerHook instance; private static ServerHook instance;
private final GameServer gameServer; private final GameServer gameServer;
private final DispatchServer dispatchServer; private final HttpServer httpServer;
/** /**
* Gets the server hook instance. * Gets the server hook instance.
@ -28,11 +31,11 @@ public final class ServerHook {
/** /**
* Hooks into a server. * Hooks into a server.
* @param gameServer The game server to hook into. * @param gameServer The game server to hook into.
* @param dispatchServer The dispatch server to hook into. * @param httpServer The HTTP server to hook into.
*/ */
public ServerHook(GameServer gameServer, DispatchServer dispatchServer) { public ServerHook(GameServer gameServer, HttpServer httpServer) {
this.gameServer = gameServer; this.gameServer = gameServer;
this.dispatchServer = dispatchServer; this.httpServer = httpServer;
instance = this; instance = this;
} }
@ -45,10 +48,10 @@ public final class ServerHook {
} }
/** /**
* @return The dispatch server. * @return The HTTP server.
*/ */
public DispatchServer getDispatchServer() { public HttpServer getHttpServer() {
return this.dispatchServer; return this.httpServer;
} }
/** /**
@ -70,4 +73,28 @@ public final class ServerHook {
Command commandData = clazz.getAnnotation(Command.class); Command commandData = clazz.getAnnotation(Command.class);
this.gameServer.getCommandMap().registerCommand(commandData.label(), handler); this.gameServer.getCommandMap().registerCommand(commandData.label(), handler);
} }
/**
* Adds a router using an instance of a class.
* @param router A router instance.
*/
public void addRouter(Router router) {
this.addRouter(router.getClass());
}
/**
* Adds a router using a class.
* @param router The class of the router.
*/
public void addRouter(Class<? extends Router> router) {
this.httpServer.addRouter(router);
}
/**
* Sets the server's authentication system.
* @param authSystem An instance of the authentication system.
*/
public void setAuthSystem(AuthenticationSystem authSystem) {
Grasscutter.setAuthenticationSystem(authSystem);
}
} }

View File

@ -70,15 +70,13 @@ public class LuaSerializer implements Serializer {
} }
try { try {
//noinspection ConfusingArgumentToVarargsMethod
object = type.getDeclaredConstructor().newInstance(null); object = type.getDeclaredConstructor().newInstance(null);
LuaValue[] keys = table.keys(); LuaValue[] keys = table.keys();
for (LuaValue k : keys) { for (LuaValue k : keys) {
try { try {
Field field = object.getClass().getDeclaredField(k.checkjstring()); Field field = object.getClass().getDeclaredField(k.checkjstring());
if (field == null) {
continue;
}
field.setAccessible(true); field.setAccessible(true);
LuaValue keyValue = table.get(k); LuaValue keyValue = table.get(k);

View File

@ -1,43 +0,0 @@
package emu.grasscutter.server.dispatch;
import emu.grasscutter.Grasscutter;
import express.http.HttpContextHandler;
import express.http.Request;
import express.http.Response;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import static emu.grasscutter.Configuration.*;
public final class AnnouncementHandler implements HttpContextHandler {
@Override
public void handle(Request request, Response response) throws IOException {
if (Objects.equals(request.baseUrl(), "/common/hk4e_global/announcement/api/getAnnContent")) {
String data = readToString(Paths.get(DATA("GameAnnouncement.json")));
response.send("{\"retcode\":0,\"message\":\"OK\",\"data\":" + data + "}");
} else if (Objects.equals(request.baseUrl(), "/common/hk4e_global/announcement/api/getAnnList")) {
String data = readToString(Paths.get(DATA("GameAnnouncementList.json")))
.replace("System.currentTimeMillis()", String.valueOf(System.currentTimeMillis()));
response.send("{\"retcode\":0,\"message\":\"OK\",\"data\": " + data + "}");
}
}
private static String readToString(Path path) {
String content = "";
try {
content = Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException ignored) {
Grasscutter.getLogger().warn("Unable to open file " + path.toAbsolutePath());
}
return content;
}
}

View File

@ -1,19 +0,0 @@
package emu.grasscutter.server.dispatch;
import express.http.HttpContextHandler;
import express.http.Request;
import express.http.Response;
import java.io.IOException;
/**
* Used for processing crash dumps and logs generated by the game.
* Logs are in JSON, and are sent to the server for logging.
*/
public final class ClientLogHandler implements HttpContextHandler {
@Override
public void handle(Request request, Response response) throws IOException {
// TODO: Figure out how to dump request body and log to file.
response.send("{\"code\":0}");
}
}

View File

@ -1,524 +0,0 @@
package emu.grasscutter.server.dispatch;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.protobuf.ByteString;
import emu.grasscutter.GameConstants;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.Grasscutter.ServerDebugMode;
import emu.grasscutter.Grasscutter.ServerRunMode;
import emu.grasscutter.database.DatabaseHelper;
import emu.grasscutter.game.Account;
import emu.grasscutter.net.proto.QueryCurrRegionHttpRspOuterClass.QueryCurrRegionHttpRsp;
import emu.grasscutter.net.proto.QueryRegionListHttpRspOuterClass.QueryRegionListHttpRsp;
import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo;
import emu.grasscutter.net.proto.RegionSimpleInfoOuterClass.RegionSimpleInfo;
import emu.grasscutter.server.dispatch.authentication.AuthenticationHandler;
import emu.grasscutter.server.dispatch.authentication.DefaultAuthenticationHandler;
import emu.grasscutter.server.dispatch.http.AnnouncementIndexHandler;
import emu.grasscutter.server.dispatch.http.GachaDetailsHandler;
import emu.grasscutter.server.dispatch.http.GachaRecordHandler;
import emu.grasscutter.server.dispatch.json.*;
import emu.grasscutter.server.dispatch.json.ComboTokenReqJson.LoginTokenData;
import emu.grasscutter.server.event.dispatch.QueryAllRegionsEvent;
import emu.grasscutter.server.event.dispatch.QueryCurrentRegionEvent;
import emu.grasscutter.tools.Tools;
import emu.grasscutter.utils.Crypto;
import emu.grasscutter.utils.FileUtils;
import emu.grasscutter.utils.Utils;
import express.Express;
import io.javalin.http.staticfiles.Location;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static emu.grasscutter.utils.Language.translate;
import static emu.grasscutter.Configuration.*;
public final class DispatchServer {
private final Gson gson;
private final String defaultServerName = "os_usa";
public String regionListBase64;
public Map<String, RegionData> regions;
private AuthenticationHandler authHandler;
private Express httpServer;
public DispatchServer() {
this.regions = new HashMap<>();
this.gson = new GsonBuilder().create();
this.initRegion();
}
public Express getServer() {
return httpServer;
}
public void setHttpServer(Express httpServer) {
this.httpServer.stop();
this.httpServer = httpServer;
this.httpServer.listen(DISPATCH_INFO.bindPort);
}
public Gson getGsonFactory() {
return gson;
}
public QueryCurrRegionHttpRsp getCurrRegion() {
// Needs to be fixed by having the game servers connect to the dispatch server.
if (SERVER.runMode == ServerRunMode.HYBRID) {
return regions.get(defaultServerName).parsedRegionQuery;
}
Grasscutter.getLogger().warn("[Dispatch] Unsupported run mode for getCurrRegion()");
return null;
}
private void initRegion() {
try {
String dispatchDomain = "http" + (DISPATCH_ENCRYPTION.useInRouting ? "s" : "") + "://"
+ lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":"
+ lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort);
List<RegionSimpleInfo> servers = new ArrayList<>();
List<String> usedNames = new ArrayList<>(); // List to check for potential naming conflicts.
if (SERVER.runMode == ServerRunMode.HYBRID) { // Automatically add the game server if in hybrid mode.
RegionSimpleInfo server = RegionSimpleInfo.newBuilder()
.setName("os_usa")
.setTitle(DISPATCH_INFO.defaultName)
.setType("DEV_PUBLIC")
.setDispatchUrl(dispatchDomain + "/query_cur_region/" + defaultServerName)
.build();
usedNames.add(defaultServerName);
servers.add(server);
// todo: we might want to push custom config to client, see regionList below for clues.
RegionInfo serverRegion = RegionInfo.newBuilder()
.setGateserverIp(lr(GAME_INFO.accessAddress, GAME_INFO.bindAddress))
.setGateserverPort(lr(GAME_INFO.accessPort, GAME_INFO.bindPort))
.setSecretKey(ByteString.copyFrom(Crypto.DISPATCH_SEED))
.build();
QueryCurrRegionHttpRsp parsedRegionQuery = QueryCurrRegionHttpRsp.newBuilder().setRegionInfo(serverRegion).build();
regions.put(
defaultServerName,
new RegionData(
parsedRegionQuery,
Base64.getEncoder().encodeToString(parsedRegionQuery.toByteString().toByteArray())
)
);
} else if (DISPATCH_INFO.regions.length == 0) {
Grasscutter.getLogger().error("[Dispatch] There are no game servers available. Exiting due to unplayable state.");
System.exit(1);
}
for (var regionInfo : DISPATCH_INFO.regions) {
if (usedNames.contains(regionInfo.Name)) {
Grasscutter.getLogger().error("Region name already in use.");
continue;
}
// todo: we might want to push custom config to client, see regionList below for clues.
RegionSimpleInfo server = RegionSimpleInfo.newBuilder()
.setName(regionInfo.Name)
.setTitle(regionInfo.Title)
.setType("DEV_PUBLIC")
.setDispatchUrl(dispatchDomain + "/query_cur_region/" + regionInfo.Name)
.build();
usedNames.add(regionInfo.Name);
servers.add(server);
RegionInfo serverRegion = RegionInfo.newBuilder()
.setGateserverIp(regionInfo.Ip)
.setGateserverPort(regionInfo.Port)
.setSecretKey(ByteString.copyFrom(Crypto.DISPATCH_SEED))
.build();
QueryCurrRegionHttpRsp parsedRegionQuery = QueryCurrRegionHttpRsp.newBuilder().setRegionInfo(serverRegion).build();
regions.put(
regionInfo.Name,
new RegionData(
parsedRegionQuery,
Base64.getEncoder().encodeToString(parsedRegionQuery.toByteString().toByteArray())
)
);
}
byte[] customConfig = "{\"sdkenv\":\"2\",\"checkdevice\":\"false\",\"loadPatch\":\"false\",\"showexception\":\"false\",\"regionConfig\":\"pm|fk|add\",\"downloadMode\":\"0\"}".getBytes();
Crypto.xor(customConfig, Crypto.DISPATCH_KEY);
QueryRegionListHttpRsp regionList = QueryRegionListHttpRsp.newBuilder()
.addAllRegionList(servers)
.setClientSecretKey(ByteString.copyFrom(Crypto.DISPATCH_SEED))
.setClientCustomConfigEncrypted(ByteString.copyFrom(customConfig))
.setEnableLoginPc(true)
.build();
this.regionListBase64 = Base64.getEncoder().encodeToString(regionList.toByteString().toByteArray());
} catch (Exception exception) {
Grasscutter.getLogger().error("[Dispatch] Error while initializing region info!", exception);
}
}
public void start() throws Exception {
httpServer = new Express(config -> {
config.server(() -> {
Server server = new Server();
ServerConnector serverConnector;
if(DISPATCH_ENCRYPTION.useEncryption) {
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
File keystoreFile = new File(DISPATCH_ENCRYPTION.keystore);
if(keystoreFile.exists()) {
try {
sslContextFactory.setKeyStorePath(keystoreFile.getPath());
sslContextFactory.setKeyStorePassword(DISPATCH_ENCRYPTION.keystorePassword);
} catch (Exception e) {
e.printStackTrace();
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 e2) {
Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.general_error"));
e2.printStackTrace();
}
}
serverConnector = new ServerConnector(server, sslContextFactory);
} else {
Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.no_keystore_error"));
DISPATCH_ENCRYPTION.useEncryption = false;
serverConnector = new ServerConnector(server);
}
} else {
serverConnector = new ServerConnector(server);
}
serverConnector.setPort(DISPATCH_INFO.bindPort);
server.setConnectors(new Connector[]{serverConnector});
return server;
});
config.enforceSsl = DISPATCH_ENCRYPTION.useEncryption;
if(SERVER.debugLevel == ServerDebugMode.ALL) {
config.enableDevLogging();
}
if (DISPATCH_POLICIES.cors.enabled) {
var corsPolicy = DISPATCH_POLICIES.cors;
if (corsPolicy.allowedOrigins.length > 0)
config.enableCorsForOrigin(corsPolicy.allowedOrigins);
else config.enableCorsForAllOrigins();
}
});
httpServer.get("/", (req, res) -> res.send("<!doctype html><html><head><meta charset=\"utf8\"></head><body>" + translate("messages.status.welcome") + "</body></html>"));
httpServer.raw().error(404, ctx -> {
if(SERVER.debugLevel == ServerDebugMode.MISSING) {
Grasscutter.getLogger().info(translate("messages.dispatch.unhandled_request_error", ctx.method(), ctx.url()));
}
ctx.contentType("text/html");
ctx.result("<!doctype html><html><head><meta charset=\"utf8\"></head><body><img src=\"https://http.cat/404\" /></body></html>"); // I'm like 70% sure this won't break anything.
});
// Authentication Handler
// These routes are so that authentication routes are always the same no matter what auth system is used.
httpServer.get("/authentication/type", (req, res) -> {
res.send(this.getAuthHandler().getClass().getName());
});
httpServer.post("/authentication/login", (req, res) -> this.getAuthHandler().handleLogin(req, res));
httpServer.post("/authentication/register", (req, res) -> this.getAuthHandler().handleRegister(req, res));
httpServer.post("/authentication/change_password", (req, res) -> this.getAuthHandler().handleChangePassword(req, res));
// Server Status
httpServer.get("/status/server", (req, res) -> {
int playerCount = Grasscutter.getGameServer().getPlayers().size();
String version = GameConstants.VERSION;
res.send("{\"retcode\":0,\"status\":{\"playerCount\":" + playerCount + ",\"version\":\"" + version + "\"}}");
});
// Dispatch
httpServer.get("/query_region_list", (req, res) -> {
// Log
Grasscutter.getLogger().info(String.format("[Dispatch] Client %s request: query_region_list", req.ip()));
// Invoke event.
QueryAllRegionsEvent event = new QueryAllRegionsEvent(regionListBase64); event.call();
// Respond with event result.
res.send(event.getRegionList());
});
httpServer.get("/query_cur_region/:id", (req, res) -> {
String regionName = req.params("id");
// Log
Grasscutter.getLogger().info(
String.format("Client %s request: query_cur_region/%s", req.ip(), regionName));
// Create a response form the request query parameters
String response = "CAESGE5vdCBGb3VuZCB2ZXJzaW9uIGNvbmZpZw==";
if (req.query().values().size() > 0) {
response = regions.get(regionName).Base64;
}
// Invoke event.
QueryCurrentRegionEvent event = new QueryCurrentRegionEvent(response); event.call();
// Respond with event result.
res.send(event.getRegionInfo());
});
// Login
httpServer.post("/hk4e_global/mdk/shield/api/login", (req, res) -> {
// Get post data
LoginAccountRequestJson requestData = null;
try {
String body = req.ctx().body();
requestData = getGsonFactory().fromJson(body, LoginAccountRequestJson.class);
} catch (Exception ignored) { }
// Create response json
if (requestData == null) {
return;
}
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", req.ip()));
res.send(this.getAuthHandler().handleGameLogin(req, requestData));
});
// Login via token
httpServer.post("/hk4e_global/mdk/shield/api/verify", (req, res) -> {
// Get post data
LoginTokenRequestJson requestData = null;
try {
String body = req.ctx().body();
requestData = getGsonFactory().fromJson(body, LoginTokenRequestJson.class);
} catch (Exception ignored) {
}
// Create response json
if (requestData == null) {
return;
}
LoginResultJson responseData = new LoginResultJson();
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_token_attempt", req.ip()));
// Login
Account account = DatabaseHelper.getAccountById(requestData.uid);
// Test
if (account == null || !account.getSessionKey().equals(requestData.token)) {
responseData.retcode = -111;
responseData.message = translate("messages.dispatch.account.account_cache_error");
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_token_error", req.ip()));
} else {
responseData.message = "OK";
responseData.data.account.uid = requestData.uid;
responseData.data.account.token = requestData.token;
responseData.data.account.email = account.getEmail();
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_token_success", req.ip(), requestData.uid));
}
res.send(responseData);
});
// Exchange for combo token
httpServer.post("/hk4e_global/combo/granter/login/v2/login", (req, res) -> {
// Get post data
ComboTokenReqJson requestData = null;
try {
String body = req.ctx().body();
requestData = getGsonFactory().fromJson(body, ComboTokenReqJson.class);
} catch (Exception ignored) {
}
// Create response json
if (requestData == null || requestData.data == null) {
return;
}
LoginTokenData loginData = getGsonFactory().fromJson(requestData.data, LoginTokenData.class); // Get login
// data
ComboTokenResJson responseData = new ComboTokenResJson();
// Login
Account account = DatabaseHelper.getAccountById(loginData.uid);
// Test
if (account == null || !account.getSessionKey().equals(loginData.token)) {
responseData.retcode = -201;
responseData.message = translate("messages.dispatch.account.session_key_error");
Grasscutter.getLogger().info(translate("messages.dispatch.account.combo_token_error", req.ip()));
} else {
responseData.message = "OK";
responseData.data.open_id = loginData.uid;
responseData.data.combo_id = "157795300";
responseData.data.combo_token = account.generateLoginToken();
Grasscutter.getLogger().info(translate("messages.dispatch.account.combo_token_success", req.ip()));
}
res.send(responseData);
});
// TODO: There are some missing route request types here (You can tell if they are missing if they are .all and not anything else)
// When http requests for theses routes are found please remove it from the list in DispatchHttpJsonHandler and update the route request types here
// Agreement and Protocol
// hk4e-sdk-os.hoyoverse.com
httpServer.get("/hk4e_global/mdk/agreement/api/getAgreementInfos", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"marketing_agreements\":[]}}"));
// hk4e-sdk-os.hoyoverse.com
// this could be either GET or POST based on the observation of different clients
httpServer.all("/hk4e_global/combo/granter/api/compareProtocolVersion", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"modified\":true,\"protocol\":{\"id\":0,\"app_id\":4,\"language\":\"en\",\"user_proto\":\"\",\"priv_proto\":\"\",\"major\":7,\"minimum\":0,\"create_time\":\"0\",\"teenager_proto\":\"\",\"third_proto\":\"\"}}}"));
// Game data
// hk4e-api-os.hoyoverse.com
httpServer.all("/common/hk4e_global/announcement/api/getAlertPic", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"total\":0,\"list\":[]}}"));
// hk4e-api-os.hoyoverse.com
httpServer.all("/common/hk4e_global/announcement/api/getAlertAnn", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"alert\":false,\"alert_id\":0,\"remind\":true}}"));
// hk4e-api-os.hoyoverse.com
httpServer.all("/common/hk4e_global/announcement/api/getAnnList", new AnnouncementHandler());
// hk4e-api-os-static.hoyoverse.com
httpServer.all("/common/hk4e_global/announcement/api/getAnnContent", new AnnouncementHandler());
// hk4e-sdk-os.hoyoverse.com
httpServer.all("/hk4e_global/mdk/shopwindow/shopwindow/listPriceTier", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"suggest_currency\":\"USD\",\"tiers\":[]}}"));
// Captcha
// api-account-os.hoyoverse.com
httpServer.post("/account/risky/api/check", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"id\":\"none\",\"action\":\"ACTION_NONE\",\"geetest\":null}}"));
// Config
// sdk-os-static.hoyoverse.com
httpServer.get("/combo/box/api/config/sdk/combo", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"vals\":{\"disable_email_bind_skip\":\"false\",\"email_bind_remind_interval\":\"7\",\"email_bind_remind\":\"true\"}}}"));
// hk4e-sdk-os-static.hoyoverse.com
httpServer.get("/hk4e_global/combo/granter/api/getConfig", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"protocol\":true,\"qr_enabled\":false,\"log_level\":\"INFO\",\"announce_url\":\"https://webstatic-sea.hoyoverse.com/hk4e/announcement/index.html?sdk_presentation_style=fullscreen\\u0026sdk_screen_transparent=true\\u0026game_biz=hk4e_global\\u0026auth_appid=announcement\\u0026game=hk4e#/\",\"push_alias_type\":2,\"disable_ysdk_guard\":false,\"enable_announce_pic_popup\":true}}"));
// hk4e-sdk-os-static.hoyoverse.com
httpServer.get("/hk4e_global/mdk/shield/api/loadConfig", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"id\":6,\"game_key\":\"hk4e_global\",\"client\":\"PC\",\"identity\":\"I_IDENTITY\",\"guest\":false,\"ignore_versions\":\"\",\"scene\":\"S_NORMAL\",\"name\":\"原神海外\",\"disable_regist\":false,\"enable_email_captcha\":false,\"thirdparty\":[\"fb\",\"tw\"],\"disable_mmt\":false,\"server_guest\":false,\"thirdparty_ignore\":{\"tw\":\"\",\"fb\":\"\"},\"enable_ps_bind_account\":false,\"thirdparty_login_configs\":{\"tw\":{\"token_type\":\"TK_GAME_TOKEN\",\"game_token_expires_in\":604800},\"fb\":{\"token_type\":\"TK_GAME_TOKEN\",\"game_token_expires_in\":604800}}}}"));
// Test api?
// abtest-api-data-sg.hoyoverse.com
httpServer.post("/data_abtest_api/config/experiment/list", new DispatchHttpJsonHandler("{\"retcode\":0,\"success\":true,\"message\":\"\",\"data\":[{\"code\":1000,\"type\":2,\"config_id\":\"14\",\"period_id\":\"6036_99\",\"version\":\"1\",\"configs\":{\"cardType\":\"old\"}}]}"));
// log-upload-os.mihoyo.com
httpServer.all("/log/sdk/upload", new DispatchHttpJsonHandler("{\"code\":0}"));
httpServer.all("/sdk/upload", new DispatchHttpJsonHandler("{\"code\":0}"));
httpServer.post("/sdk/dataUpload", new DispatchHttpJsonHandler("{\"code\":0}"));
// /perf/config/verify?device_id=xxx&platform=x&name=xxx
httpServer.all("/perf/config/verify", new DispatchHttpJsonHandler("{\"code\":0}"));
// Logging servers
// overseauspider.yuanshen.com
httpServer.all("/log", new ClientLogHandler());
// log-upload-os.mihoyo.com
httpServer.all("/crash/dataUpload", new ClientLogHandler());
// webstatic-sea.hoyoverse.com
httpServer.get("/admin/mi18n/plat_oversea/m202003048/m202003048-version.json", new DispatchHttpJsonHandler("{\"version\":51}"));
// gacha record.
String gachaMappingsPath = Utils.toFilePath(DATA("/gacha_mappings.js"));
// TODO: Only serve the html page and have a subsequent request to fetch the gacha data.
httpServer.get("/gacha", new GachaRecordHandler());
if(!(new File(gachaMappingsPath).exists())) {
Tools.createGachaMapping(gachaMappingsPath);
}
httpServer.raw().config.addSinglePageRoot("/gacha/mappings", gachaMappingsPath, Location.EXTERNAL);
// gacha details
httpServer.get("/gacha/details", new GachaDetailsHandler());
// announcement index
httpServer.get("/hk4e/announcement/*", new AnnouncementIndexHandler());
httpServer.get("/sw.js", new AnnouncementIndexHandler());
httpServer.get("/dora/lib/vue/2.6.11/vue.min.js", new AnnouncementIndexHandler());
// static file support for plugins
httpServer.raw().config.precompressStaticFiles = false; // If this isn't set to false, files such as images may appear corrupted when serving static files
httpServer.listen(DISPATCH_INFO.bindPort);
Grasscutter.getLogger().info(translate("messages.dispatch.port_bind", Integer.toString(httpServer.raw().port())));
}
private Map<String, String> parseQueryString(String qs) {
Map<String, String> result = new HashMap<>();
if (qs == null) {
return result;
}
int last = 0, next, l = qs.length();
while (last < l) {
next = qs.indexOf('&', last);
if (next == -1) {
next = l;
}
if (next > last) {
int eqPos = qs.indexOf('=', last);
if (eqPos < 0 || eqPos > next) {
result.put(URLDecoder.decode(qs.substring(last, next), StandardCharsets.UTF_8), "");
} else {
result.put(URLDecoder.decode(qs.substring(last, eqPos), StandardCharsets.UTF_8),
URLDecoder.decode(qs.substring(eqPos + 1, next), StandardCharsets.UTF_8));
}
}
last = next + 1;
}
return result;
}
public AuthenticationHandler getAuthHandler() {
if(authHandler == null) {
return new DefaultAuthenticationHandler();
}
return authHandler;
}
public boolean registerAuthHandler(AuthenticationHandler authHandler) {
if(this.authHandler != null) {
Grasscutter.getLogger().error(String.format("[Dispatch] Unable to register '%s' authentication handler. \n" +
"The '%s' authentication handler has already been registered", authHandler.getClass().getName(), this.authHandler.getClass().getName()));
return false;
}
this.authHandler = authHandler;
return true;
}
public void resetAuthHandler() {
this.authHandler = null;
}
public static class RegionData {
QueryCurrRegionHttpRsp parsedRegionQuery;
String Base64;
public RegionData(QueryCurrRegionHttpRsp prq, String b64) {
this.parsedRegionQuery = prq;
this.Base64 = b64;
}
public QueryCurrRegionHttpRsp getParsedRegionQuery() {
return parsedRegionQuery;
}
public String getBase64() {
return Base64;
}
}
}

View File

@ -1,23 +0,0 @@
package emu.grasscutter.server.dispatch.authentication;
import emu.grasscutter.server.dispatch.json.LoginAccountRequestJson;
import emu.grasscutter.server.dispatch.json.LoginResultJson;
import express.http.Request;
import express.http.Response;
public interface AuthenticationHandler {
// This is in case plugins also want some sort of authentication
void handleLogin(Request req, Response res);
void handleRegister(Request req, Response res);
void handleChangePassword(Request req, Response res);
/**
* Other plugins may need to verify a user's identity using details from handleLogin()
* @param details The user's unique one-time token that needs to be verified
* @return If the verification was successful
*/
boolean verifyUser(String details);
LoginResultJson handleGameLogin(Request req, LoginAccountRequestJson requestData);
}

View File

@ -1,86 +0,0 @@
package emu.grasscutter.server.dispatch.authentication;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.database.DatabaseHelper;
import emu.grasscutter.game.Account;
import emu.grasscutter.server.dispatch.json.LoginAccountRequestJson;
import emu.grasscutter.server.dispatch.json.LoginResultJson;
import express.http.Request;
import express.http.Response;
import static emu.grasscutter.utils.Language.translate;
import static emu.grasscutter.Configuration.*;
public class DefaultAuthenticationHandler implements AuthenticationHandler {
@Override
public void handleLogin(Request req, Response res) {
res.send("Authentication is not available with the default authentication method");
}
@Override
public void handleRegister(Request req, Response res) {
res.send("Authentication is not available with the default authentication method");
}
@Override
public void handleChangePassword(Request req, Response res) {
res.send("Authentication is not available with the default authentication method");
}
@Override
public boolean verifyUser(String details) {
Grasscutter.getLogger().info(translate("dispatch.authentication.default_unable_to_verify"));
return false;
}
@Override
public LoginResultJson handleGameLogin(Request req, LoginAccountRequestJson requestData) {
LoginResultJson responseData = new LoginResultJson();
// Login
Account account = DatabaseHelper.getAccountByName(requestData.account);
// Check if account exists, else create a new one.
if (account == null) {
// Account doesn't exist, so we can either auto create it if the config value is set.
if (ACCOUNT.autoCreate) {
// This account has been created AUTOMATICALLY. There will be no permissions added.
account = DatabaseHelper.createAccountWithId(requestData.account, 0);
for (String permission : ACCOUNT.defaultPermissions) {
account.addPermission(permission);
}
if (account != null) {
responseData.message = "OK";
responseData.data.account.uid = account.getId();
responseData.data.account.token = account.generateSessionKey();
responseData.data.account.email = account.getEmail();
Grasscutter.getLogger().info(translate("messages.dispatch.account.account_login_create_success", req.ip(), responseData.data.account.uid));
} else {
responseData.retcode = -201;
responseData.message = translate("messages.dispatch.account.username_create_error");
Grasscutter.getLogger().info(translate("messages.dispatch.account.account_login_create_error", req.ip()));
}
} else {
responseData.retcode = -201;
responseData.message = translate("messages.dispatch.account.username_error");
Grasscutter.getLogger().info(translate("messages.dispatch.account.account_login_exist_error", req.ip()));
}
} else {
// Account was found, log the player in
responseData.message = "OK";
responseData.data.account.uid = account.getId();
responseData.data.account.token = account.generateSessionKey();
responseData.data.account.email = account.getEmail();
Grasscutter.getLogger().info(translate("messages.dispatch.account.login_success", req.ip(), responseData.data.account.uid));
}
return responseData;
}
}

View File

@ -1,61 +0,0 @@
package emu.grasscutter.server.dispatch.http;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.database.DatabaseHelper;
import emu.grasscutter.game.Account;
import emu.grasscutter.utils.FileUtils;
import emu.grasscutter.utils.Utils;
import express.http.HttpContextHandler;
import express.http.Request;
import express.http.Response;
import java.io.File;
import java.io.IOException;
import java.util.Objects;
import static emu.grasscutter.Configuration.DATA;
public class AnnouncementIndexHandler implements HttpContextHandler {
private final String render_template;
private final String render_swjs;
private final String render_vueminjs;
public AnnouncementIndexHandler() {
File template = new File(Utils.toFilePath(DATA("/hk4e/announcement/index.html")));
File swjs = new File(Utils.toFilePath(DATA("/hk4e/announcement/sw.js")));
File vueminjs = new File(Utils.toFilePath(DATA("/hk4e/announcement/vue.min.js")));
this.render_template = template.exists() ? new String(FileUtils.read(template)) : null;
this.render_swjs = swjs.exists() ? new String(FileUtils.read(swjs)) : null;
this.render_vueminjs = vueminjs.exists() ? new String(FileUtils.read(vueminjs)) : null;
}
@Override
public void handle(Request req, Response res) throws IOException {
if (Objects.equals(req.path(), "/sw.js")) {
res.send(render_swjs);
}else if(Objects.equals(req.path(), "/hk4e/announcement/index.html")) {
res.send(render_template);
}else if(Objects.equals(req.path(), "/dora/lib/vue/2.6.11/vue.min.js")){
res.send(render_vueminjs);
}else{
File renderFile = new File(Utils.toFilePath(DATA(req.path())));
if(renderFile.exists()){
String ext = req.path().substring(req.path().lastIndexOf(".") + 1);
switch(ext){
case "css":
res.type("text/css");
res.send(FileUtils.read(renderFile));
break;
case "js":
default:
res.send(FileUtils.read(renderFile));
break;
}
}else{
Grasscutter.getLogger().info( "File not exist: " + req.path());
}
}
}
}

View File

@ -1,88 +0,0 @@
package emu.grasscutter.server.dispatch.http;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
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.GachaManager;
import emu.grasscutter.game.gacha.GachaBanner.BannerType;
import emu.grasscutter.game.player.Player;
import emu.grasscutter.utils.FileUtils;
import emu.grasscutter.utils.Utils;
import express.http.HttpContextHandler;
import express.http.Request;
import express.http.Response;
import static emu.grasscutter.utils.Language.translate;
import static emu.grasscutter.Configuration.*;
public final class GachaDetailsHandler implements HttpContextHandler {
private final String render_template;
public GachaDetailsHandler() {
File template = new File(Utils.toFilePath(DATA("/gacha_details.html")));
this.render_template = template.exists() ? new String(FileUtils.read(template)) : null;
}
@Override
public void handle(Request req, Response res) throws IOException {
String response = this.render_template;
// Get player info (for langauge).
String sessionKey = req.query("s");
Account account = DatabaseHelper.getAccountBySessionKey(sessionKey);
Player player = Grasscutter.getGameServer().getPlayerByUid(account.getPlayerUid());
// If the template was not loaded, return an error.
if (this.render_template == null) {
res.send(translate(player, "gacha.details.template_missing"));
return;
}
// Add translated title etc. to the page.
response = response.replace("{{TITLE}}", translate(player, "gacha.details.title"));
response = response.replace("{{AVAILABLE_FIVE_STARS}}", translate(player, "gacha.details.available_five_stars"));
response = response.replace("{{AVAILABLE_FOUR_STARS}}", translate(player, "gacha.details.available_four_stars"));
response = response.replace("{{AVAILABLE_THREE_STARS}}", translate(player, "gacha.details.available_three_stars"));
response = response.replace("{{LANGUAGE}}", Utils.getLanguageCode(account.getLocale()));
// Get the banner info for the banner we want.
int gachaType = Integer.parseInt(req.query("gachaType"));
GachaManager manager = Grasscutter.getGameServer().getGachaManager();
GachaBanner banner = manager.getGachaBanners().get(gachaType);
// Add 5-star items.
Set<String> fiveStarItems = new LinkedHashSet<>();
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()).forEach(i -> fiveStarItems.add(Integer.toString(i)));
response = response.replace("{{FIVE_STARS}}", "[" + String.join(",", fiveStarItems) + "]");
// Add 4-star items.
Set<String> fourStarItems = new LinkedHashSet<>();
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()).forEach(i -> fourStarItems.add(Integer.toString(i)));
response = response.replace("{{FOUR_STARS}}", "[" + String.join(",", fourStarItems) + "]");
// Add 3-star items.
Set<String> threeStarItems = new LinkedHashSet<>();
Arrays.stream(banner.getFallbackItems3()).forEach(i -> threeStarItems.add(Integer.toString(i)));
response = response.replace("{{THREE_STARS}}", "[" + String.join(",", threeStarItems) + "]");
// Done.
res.send(response);
}
}

View File

@ -1,54 +0,0 @@
package emu.grasscutter.server.dispatch.http;
import java.io.File;
import java.io.IOException;
import emu.grasscutter.database.DatabaseHelper;
import emu.grasscutter.game.Account;
import emu.grasscutter.utils.FileUtils;
import emu.grasscutter.utils.Utils;
import express.http.HttpContextHandler;
import express.http.Request;
import express.http.Response;
import static emu.grasscutter.Configuration.*;
public final class GachaRecordHandler implements HttpContextHandler {
String render_template;
public GachaRecordHandler() {
File template = new File(Utils.toFilePath(DATA("/gacha_records.html")));
if (template.exists()) {
// Load from cache
render_template = new String(FileUtils.read(template));
} else {
render_template = "{{REPLACE_RECORD}}";
}
}
@Override
public void handle(Request req, Response res) throws IOException {
// Grasscutter.getLogger().info( req.query().toString() );
String sessionKey = req.query("s");
int page = 0;
int gachaType = 0;
if (req.query("p") != null) {
page = Integer.parseInt(req.query("p"));
}
if (req.query("gachaType") != null) {
gachaType = Integer.parseInt(req.query("gachaType"));
}
Account account = DatabaseHelper.getAccountBySessionKey(sessionKey);
if (account != null) {
String records = DatabaseHelper.getGachaRecords(account.getPlayerUid(), page, gachaType).toString();
// Grasscutter.getLogger().info(records);
String response = render_template.replace("{{REPLACE_RECORD}}", records)
.replace("{{REPLACE_MAXPAGE}}", String.valueOf(DatabaseHelper.getGachaRecordsMaxPage(account.getPlayerUid(), page, gachaType)));
res.send(response);
} else {
res.send("No account found.");
}
}
}

View File

@ -0,0 +1,198 @@
package emu.grasscutter.server.http;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.Grasscutter.ServerDebugMode;
import emu.grasscutter.utils.FileUtils;
import express.Express;
import express.http.MediaType;
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 java.io.IOException;
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("/", (request, response) -> {
File file = new File(HTTP_STATIC_FILES.errorFile);
if(!file.exists())
response.send("""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8">
</head>
<body>%s</body>
</html>
""".formatted(translate("messages.status.welcome")));
else {
final var filePath = file.getPath();
final MediaType fromExtension = MediaType.getByExtension(filePath.substring(filePath.lastIndexOf(".") + 1));
response.type((fromExtension != null) ? fromExtension.getMIME() : "text/plain")
.send(FileUtils.read(filePath));
}
});
}
}
/**
* 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");
File file = new File(HTTP_STATIC_FILES.errorFile);
if(!file.exists())
context.result("""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8">
</head>
<body>
<img src="https://http.cat/404" />
</body>
</html>
""");
else {
final var filePath = file.getPath();
final MediaType fromExtension = MediaType.getByExtension(filePath.substring(filePath.lastIndexOf(".") + 1));
context.contentType((fromExtension != null) ? fromExtension.getMIME() : "text/plain")
.result(FileUtils.read(filePath));
}
});
}
}
}

View File

@ -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);
}

View File

@ -0,0 +1,109 @@
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);
// External login (from other clients).
express.get("/authentication/type", (request, response) -> response.send(Grasscutter.getAuthenticationSystem().getClass().getSimpleName()));
express.post("/authentication/login", (request, response) -> Grasscutter.getAuthenticationSystem().getExternalAuthenticator()
.handleLogin(AuthenticationSystem.fromExternalRequest(request, response)));
express.post("/authentication/register", (request, response) -> Grasscutter.getAuthenticationSystem().getExternalAuthenticator()
.handleAccountCreation(AuthenticationSystem.fromExternalRequest(request, response)));
express.post("/authentication/change_password", (request, response) -> Grasscutter.getAuthenticationSystem().getExternalAuthenticator()
.handlePasswordReset(AuthenticationSystem.fromExternalRequest(request, response)));
}
/**
* @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()));
}
}

View File

@ -0,0 +1,174 @@
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.RegionInfoOuterClass;
import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo;
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.Crypto;
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 static final Map<String, RegionData> regions = new ConcurrentHashMap<>();
private static String regionListResponse;
public RegionHandler() {
try { // Read & initialize region data.
this.initialize();
} catch (Exception exception) {
Grasscutter.getLogger().error("Failed to initialize region data.", exception);
}
}
/**
* Configures region data according to configuration.
*/
private void initialize() {
String dispatchDomain = "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://"
+ lr(HTTP_INFO.accessAddress, HTTP_INFO.bindAddress) + ":"
+ lr(HTTP_INFO.accessPort, HTTP_INFO.bindPort);
// Create regions.
List<RegionSimpleInfo> servers = new ArrayList<>();
List<String> 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) {
Grasscutter.getLogger().error("[Dispatch] There are no game servers available. Exiting due to unplayable state.");
System.exit(1);
} else configuredRegions.add(new Region("os_usa", DISPATCH_INFO.defaultName,
lr(GAME_INFO.accessAddress, GAME_INFO.bindAddress),
lr(GAME_INFO.accessPort, GAME_INFO.bindPort)));
configuredRegions.forEach(region -> {
if (usedNames.contains(region.Name)) {
Grasscutter.getLogger().error("Region name already in use.");
return;
}
// Create a region identifier.
var identifier = RegionSimpleInfo.newBuilder()
.setName(region.Name).setTitle(region.Title).setType("DEV_PUBLIC")
.setDispatchUrl(dispatchDomain + "/query_cur_region/" + region.Name)
.build();
usedNames.add(region.Name); servers.add(identifier);
// Create a region info object.
var regionInfo = RegionInfo.newBuilder()
.setGateserverIp(region.Ip).setGateserverPort(region.Port)
.setSecretKey(ByteString.copyFrom(Crypto.DISPATCH_SEED))
.build();
// Create an updated region query.
var updatedQuery = QueryCurrRegionHttpRsp.newBuilder().setRegionInfo(regionInfo).build();
regions.put(region.Name, new RegionData(updatedQuery, Utils.base64Encode(updatedQuery.toByteString().toByteArray())));
});
// Create a config object.
byte[] customConfig = "{\"sdkenv\":\"2\",\"checkdevice\":\"false\",\"loadPatch\":\"false\",\"showexception\":\"false\",\"regionConfig\":\"pm|fk|add\",\"downloadMode\":\"0\"}".getBytes();
Crypto.xor(customConfig, Crypto.DISPATCH_KEY); // XOR the config with the key.
// Create an updated region list.
QueryRegionListHttpRsp updatedRegionList = QueryRegionListHttpRsp.newBuilder()
.addAllRegionList(servers)
.setClientSecretKey(ByteString.copyFrom(Crypto.DISPATCH_SEED))
.setClientCustomConfigEncrypted(ByteString.copyFrom(customConfig))
.setEnableLoginPc(true).build();
// Set the region list response.
regionListResponse = Utils.base64Encode(updatedRegionList.toByteString().toByteArray());
}
@Override public void applyRoutes(Express express, Javalin handle) {
express.get("/query_region_list", RegionHandler::queryRegionList);
express.get("/query_cur_region/:region", RegionHandler::queryCurrentRegion );
}
/**
* @route /query_region_list
*/
private static void queryRegionList(Request request, Response response) {
// Invoke event.
QueryAllRegionsEvent event = new QueryAllRegionsEvent(regionListResponse); event.call();
// Respond with event result.
response.send(event.getRegionList());
// Log to console.
Grasscutter.getLogger().info(String.format("[Dispatch] Client %s request: query_region_list", request.ip()));
}
/**
* @route /query_cur_region/:region
*/
private static void queryCurrentRegion(Request request, Response response) {
// Get region to query.
String regionName = request.params("region");
// Get region data.
String regionData = "CAESGE5vdCBGb3VuZCB2ZXJzaW9uIGNvbmZpZw==";
if (request.query().values().size() > 0) {
var region = regions.get(regionName);
if(region != null) regionData = region.getBase64();
}
// Invoke event.
QueryCurrentRegionEvent event = new QueryCurrentRegionEvent(regionData); event.call();
// Respond with event result.
response.send(event.getRegionInfo());
// Log to console.
Grasscutter.getLogger().info(String.format("Client %s request: query_cur_region/%s", request.ip(), regionName));
}
/**
* Region data container.
*/
public static class RegionData {
private final QueryCurrRegionHttpRsp regionQuery;
private final String base64;
public RegionData(QueryCurrRegionHttpRsp prq, String b64) {
this.regionQuery = prq;
this.base64 = b64;
}
public QueryCurrRegionHttpRsp getRegionQuery() {
return this.regionQuery;
}
public String getBase64() {
return this.base64;
}
}
/**
* Gets the current region query.
* @return A {@link QueryCurrRegionHttpRsp} object.
*/
public static QueryCurrRegionHttpRsp getCurrentRegion() {
return SERVER.runMode == ServerRunMode.HYBRID ? regions.get("os_usa").getRegionQuery() : null;
}
}

View File

@ -0,0 +1,104 @@
package emu.grasscutter.server.http.handlers;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.server.http.objects.HttpJsonResponse;
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.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Objects;
import static emu.grasscutter.Configuration.DATA;
/**
* Handles requests related to the announcements page.
*/
public final class AnnouncementsHandler implements Router {
private static String template, swjs, vue;
public AnnouncementsHandler() {
var templateFile = new File(Utils.toFilePath(DATA("/hk4e/announcement/index.html")));
var swjsFile = new File(Utils.toFilePath(DATA("/hk4e/announcement/sw.js")));
var vueFile = new File(Utils.toFilePath(DATA("/hk4e/announcement/vue.min.js")));
template = templateFile.exists() ? new String(FileUtils.read(template)) : null;
swjs = swjsFile.exists() ? new String(FileUtils.read(swjs)) : null;
vue = vueFile.exists() ? new String(FileUtils.read(vueFile)) : null;
}
@Override public void applyRoutes(Express express, Javalin handle) {
// hk4e-api-os.hoyoverse.com
express.all("/common/hk4e_global/announcement/api/getAlertPic", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"total\":0,\"list\":[]}}"));
// hk4e-api-os.hoyoverse.com
express.all("/common/hk4e_global/announcement/api/getAlertAnn", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"alert\":false,\"alert_id\":0,\"remind\":true}}"));
// hk4e-api-os.hoyoverse.com
express.all("/common/hk4e_global/announcement/api/getAnnList", AnnouncementsHandler::getAnnouncement);
// hk4e-api-os-static.hoyoverse.com
express.all("/common/hk4e_global/announcement/api/getAnnContent", AnnouncementsHandler::getAnnouncement);
// hk4e-sdk-os.hoyoverse.com
express.all("/hk4e_global/mdk/shopwindow/shopwindow/listPriceTier", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"suggest_currency\":\"USD\",\"tiers\":[]}}"));
express.get("/hk4e/announcement/*", AnnouncementsHandler::getPageResources);
express.get("/sw.js", AnnouncementsHandler::getPageResources);
express.get("/dora/lib/vue/2.6.11/vue.min.js", AnnouncementsHandler::getPageResources);
}
private static void getAnnouncement(Request request, Response response) {
if (Objects.equals(request.baseUrl(), "/common/hk4e_global/announcement/api/getAnnContent")) {
String data = readToString(Paths.get(DATA("GameAnnouncement.json")).toFile());
response.send("{\"retcode\":0,\"message\":\"OK\",\"data\":" + data + "}");
} else if (Objects.equals(request.baseUrl(), "/common/hk4e_global/announcement/api/getAnnList")) {
String data = readToString(Paths.get(DATA("GameAnnouncementList.json")).toFile())
.replace("System.currentTimeMillis()", String.valueOf(System.currentTimeMillis()));
response.send("{\"retcode\":0,\"message\":\"OK\",\"data\": " + data + "}");
}
}
private static void getPageResources(Request request, Response response) {
var path = request.path();
switch(path) {
case "/sw.js" -> response.send(swjs);
case "/hk4e/announcement/index.html" -> response.send(template);
case "/dora/lib/vue/2.6.11/vue.min.js" -> response.send(vue);
default -> {
File renderFile = new File(Utils.toFilePath(DATA(path)));
if(!renderFile.exists()) {
Grasscutter.getLogger().info("File not exist: " + path);
return;
}
String ext = path.substring(path.lastIndexOf(".") + 1);
if ("css".equals(ext)) {
response.type("text/css");
response.send(FileUtils.read(renderFile));
} else {
response.send(FileUtils.read(renderFile));
}
}
}
}
@SuppressWarnings("ResultOfMethodCallIgnored")
private static String readToString(File file) {
long length = file.length();
byte[] content = new byte[(int) length];
try {
FileInputStream in = new FileInputStream(file);
in.read(content); in.close();
} catch (IOException ignored) {
Grasscutter.getLogger().warn("File not found: " + file.getAbsolutePath());
}
return new String(content);
}
}

View File

@ -0,0 +1,136 @@
package emu.grasscutter.server.http.handlers;
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.GachaManager;
import emu.grasscutter.game.player.Player;
import emu.grasscutter.server.http.Router;
import emu.grasscutter.tools.Tools;
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 io.javalin.http.staticfiles.Location;
import java.io.File;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
import static emu.grasscutter.Configuration.DATA;
import static emu.grasscutter.utils.Language.translate;
/**
* Handles all gacha-related HTTP requests.
*/
public final class GachaHandler implements Router {
private final String gachaMappings;
private static String recordsTemplate = "";
private static String detailsTemplate = "";
public GachaHandler() {
this.gachaMappings = Utils.toFilePath(DATA("/gacha_mappings.js"));
if(!(new File(this.gachaMappings).exists())) {
try {
Tools.createGachaMapping(this.gachaMappings);
} catch (Exception exception) {
Grasscutter.getLogger().warn("Failed to create gacha mappings.", exception);
}
}
var templateFile = new File(DATA("/gacha_records.html"));
recordsTemplate = templateFile.exists() ? new String(FileUtils.read(templateFile)) : "{{REPLACE_RECORD}}";
templateFile = new File(Utils.toFilePath(DATA("/gacha_details.html")));
detailsTemplate = templateFile.exists() ? new String(FileUtils.read(templateFile)) : null;
}
@Override public void applyRoutes(Express express, Javalin handle) {
express.get("/gacha", GachaHandler::gachaRecords);
express.get("/gacha/details", GachaHandler::gachaDetails);
express.useStaticFallback("/gacha/mappings", this.gachaMappings, Location.EXTERNAL);
}
private static void gachaRecords(Request request, Response response) {
var sessionKey = request.query("s");
int page = 0, gachaType = 0;
if(request.query("p") != null)
page = Integer.parseInt(request.query("p"));
if(request.query("gachaType") != null)
gachaType = Integer.parseInt(request.query("gachaType"));
// Get account from session key.
var account = DatabaseHelper.getAccountBySessionKey(sessionKey);
if(account == null) // Send response.
response.status(404).send("Unable to find account.");
else {
String records = DatabaseHelper.getGachaRecords(account.getPlayerUid(), gachaType, page).toString();
long maxPage = DatabaseHelper.getGachaRecordsMaxPage(account.getPlayerUid(), page, gachaType);
response.send(recordsTemplate
.replace("{{REPLACE_RECORD}}", records)
.replace("{{REPLACE_MAXPAGE}}", String.valueOf(maxPage)));
}
}
private static void gachaDetails(Request request, Response response) {
String template = detailsTemplate;
// Get player info (for langauge).
String sessionKey = request.query("s");
Account account = DatabaseHelper.getAccountBySessionKey(sessionKey);
Player player = Grasscutter.getGameServer().getPlayerByUid(account.getPlayerUid());
// If the template was not loaded, return an error.
if (detailsTemplate == null) {
response.send(translate(player, "gacha.details.template_missing"));
return;
}
// Add translated title etc. to the page.
template = template.replace("{{TITLE}}", translate(player, "gacha.details.title"))
.replace("{{AVAILABLE_FIVE_STARS}}", translate(player, "gacha.details.available_five_stars"))
.replace("{{AVAILABLE_FOUR_STARS}}", translate(player, "gacha.details.available_four_stars"))
.replace("{{AVAILABLE_THREE_STARS}}", translate(player, "gacha.details.available_three_stars"))
.replace("{{LANGUAGE}}", Utils.getLanguageCode(account.getLocale()));
// Get the banner info for the banner we want.
int gachaType = Integer.parseInt(request.query("gachaType"));
GachaManager manager = Grasscutter.getGameServer().getGachaManager();
GachaBanner banner = manager.getGachaBanners().get(gachaType);
// Add 5-star items.
Set<String> fiveStarItems = new LinkedHashSet<>();
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()).forEach(i -> fiveStarItems.add(Integer.toString(i)));
template = template.replace("{{FIVE_STARS}}", "[" + String.join(",", fiveStarItems) + "]");
// Add 4-star items.
Set<String> fourStarItems = new LinkedHashSet<>();
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()).forEach(i -> fourStarItems.add(Integer.toString(i)));
template = template.replace("{{FOUR_STARS}}", "[" + String.join(",", fourStarItems) + "]");
// Add 3-star items.
Set<String> threeStarItems = new LinkedHashSet<>();
Arrays.stream(banner.getFallbackItems3()).forEach(i -> threeStarItems.add(Integer.toString(i)));
template = template.replace("{{THREE_STARS}}", "[" + String.join(",", threeStarItems) + "]");
// Done.
response.send(template);
}
}

View File

@ -0,0 +1,55 @@
package emu.grasscutter.server.http.handlers;
import emu.grasscutter.GameConstants;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.server.http.objects.HttpJsonResponse;
import emu.grasscutter.server.http.Router;
import express.Express;
import express.http.Request;
import express.http.Response;
import io.javalin.Javalin;
/**
* Handles all generic, hard-coded responses.
*/
public final class GenericHandler implements Router {
@Override public void applyRoutes(Express express, Javalin handle) {
// hk4e-sdk-os.hoyoverse.com
express.get("/hk4e_global/mdk/agreement/api/getAgreementInfos", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"marketing_agreements\":[]}}"));
// hk4e-sdk-os.hoyoverse.com
// this could be either GET or POST based on the observation of different clients
express.all("/hk4e_global/combo/granter/api/compareProtocolVersion", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"modified\":true,\"protocol\":{\"id\":0,\"app_id\":4,\"language\":\"en\",\"user_proto\":\"\",\"priv_proto\":\"\",\"major\":7,\"minimum\":0,\"create_time\":\"0\",\"teenager_proto\":\"\",\"third_proto\":\"\"}}}"));
// api-account-os.hoyoverse.com
express.post("/account/risky/api/check", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"id\":\"none\",\"action\":\"ACTION_NONE\",\"geetest\":null}}"));
// sdk-os-static.hoyoverse.com
express.get("/combo/box/api/config/sdk/combo", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"vals\":{\"disable_email_bind_skip\":\"false\",\"email_bind_remind_interval\":\"7\",\"email_bind_remind\":\"true\"}}}"));
// hk4e-sdk-os-static.hoyoverse.com
express.get("/hk4e_global/combo/granter/api/getConfig", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"protocol\":true,\"qr_enabled\":false,\"log_level\":\"INFO\",\"announce_url\":\"https://webstatic-sea.hoyoverse.com/hk4e/announcement/index.html?sdk_presentation_style=fullscreen\\u0026sdk_screen_transparent=true\\u0026game_biz=hk4e_global\\u0026auth_appid=announcement\\u0026game=hk4e#/\",\"push_alias_type\":2,\"disable_ysdk_guard\":false,\"enable_announce_pic_popup\":true}}"));
// hk4e-sdk-os-static.hoyoverse.com
express.get("/hk4e_global/mdk/shield/api/loadConfig", new HttpJsonResponse("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"id\":6,\"game_key\":\"hk4e_global\",\"client\":\"PC\",\"identity\":\"I_IDENTITY\",\"guest\":false,\"ignore_versions\":\"\",\"scene\":\"S_NORMAL\",\"name\":\"原神海外\",\"disable_regist\":false,\"enable_email_captcha\":false,\"thirdparty\":[\"fb\",\"tw\"],\"disable_mmt\":false,\"server_guest\":false,\"thirdparty_ignore\":{\"tw\":\"\",\"fb\":\"\"},\"enable_ps_bind_account\":false,\"thirdparty_login_configs\":{\"tw\":{\"token_type\":\"TK_GAME_TOKEN\",\"game_token_expires_in\":604800},\"fb\":{\"token_type\":\"TK_GAME_TOKEN\",\"game_token_expires_in\":604800}}}}"));
// Test api?
// abtest-api-data-sg.hoyoverse.com
express.post("/data_abtest_api/config/experiment/list", new HttpJsonResponse("{\"retcode\":0,\"success\":true,\"message\":\"\",\"data\":[{\"code\":1000,\"type\":2,\"config_id\":\"14\",\"period_id\":\"6036_99\",\"version\":\"1\",\"configs\":{\"cardType\":\"old\"}}]}"));
// log-upload-os.mihoyo.com
express.all("/log/sdk/upload", new HttpJsonResponse("{\"code\":0}"));
express.all("/sdk/upload", new HttpJsonResponse("{\"code\":0}"));
express.post("/sdk/dataUpload", new HttpJsonResponse("{\"code\":0}"));
// /perf/config/verify?device_id=xxx&platform=x&name=xxx
express.all("/perf/config/verify", new HttpJsonResponse("{\"code\":0}"));
// webstatic-sea.hoyoverse.com
express.get("/admin/mi18n/plat_oversea/m202003048/m202003048-version.json", new HttpJsonResponse("{\"version\":51}"));
express.get("/status/server", GenericHandler::serverStatus);
}
private static void serverStatus(Request request, Response response) {
int playerCount = Grasscutter.getGameServer().getPlayers().size();
String version = GameConstants.VERSION;
response.send("{\"retcode\":0,\"status\":{\"playerCount\":" + playerCount + ",\"version\":\"" + version + "\"}}");
}
}

View File

@ -0,0 +1,24 @@
package emu.grasscutter.server.http.handlers;
import emu.grasscutter.server.http.Router;
import express.Express;
import express.http.Request;
import express.http.Response;
import io.javalin.Javalin;
/**
* Handles logging requests made to the server.
*/
public final class LogHandler implements Router {
@Override public void applyRoutes(Express express, Javalin handle) {
// overseauspider.yuanshen.com
express.post("/log", LogHandler::log);
// log-upload-os.mihoyo.com
express.post("/crash/dataUpload", LogHandler::log);
}
private static void log(Request request, Response response) {
// TODO: Figure out how to dump request body and log to file.
response.send("{\"code\":0}");
}
}

View File

@ -1,4 +1,4 @@
package emu.grasscutter.server.dispatch.json; package emu.grasscutter.server.http.objects;
public class ComboTokenReqJson { public class ComboTokenReqJson {
public int app_id; public int app_id;

View File

@ -1,4 +1,4 @@
package emu.grasscutter.server.dispatch.json; package emu.grasscutter.server.http.objects;
public class ComboTokenResJson { public class ComboTokenResJson {
public String message; public String message;

View File

@ -1,4 +1,4 @@
package emu.grasscutter.server.dispatch; package emu.grasscutter.server.http.objects;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
@ -13,7 +13,7 @@ import express.http.Response;
import static emu.grasscutter.utils.Language.translate; import static emu.grasscutter.utils.Language.translate;
import static emu.grasscutter.Configuration.*; import static emu.grasscutter.Configuration.*;
public final class DispatchHttpJsonHandler implements HttpContextHandler { public final class HttpJsonResponse implements HttpContextHandler {
private final String response; private final String response;
private final String[] missingRoutes = { // TODO: When http requests for theses routes are found please remove it from this list and update the route request type in the DispatchServer private final String[] missingRoutes = { // TODO: When http requests for theses routes are found please remove it from this list and update the route request type in the DispatchServer
"/common/hk4e_global/announcement/api/getAlertPic", "/common/hk4e_global/announcement/api/getAlertPic",
@ -28,7 +28,7 @@ public final class DispatchHttpJsonHandler implements HttpContextHandler {
"/crash/dataUpload" "/crash/dataUpload"
}; };
public DispatchHttpJsonHandler(String response) { public HttpJsonResponse(String response) {
this.response = response; this.response = response;
} }

View File

@ -1,4 +1,4 @@
package emu.grasscutter.server.dispatch.json; package emu.grasscutter.server.http.objects;
public class LoginAccountRequestJson { public class LoginAccountRequestJson {
public String account; public String account;

View File

@ -1,4 +1,4 @@
package emu.grasscutter.server.dispatch.json; package emu.grasscutter.server.http.objects;
public class LoginResultJson { public class LoginResultJson {
public String message; public String message;

View File

@ -1,4 +1,4 @@
package emu.grasscutter.server.dispatch.json; package emu.grasscutter.server.http.objects;
public class LoginTokenRequestJson { public class LoginTokenRequestJson {
public String uid; public String uid;

View File

@ -9,11 +9,13 @@ import emu.grasscutter.net.proto.PlayerLoginRspOuterClass.PlayerLoginRsp;
import emu.grasscutter.net.proto.QueryCurrRegionHttpRspOuterClass; import emu.grasscutter.net.proto.QueryCurrRegionHttpRspOuterClass;
import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo; import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo;
import emu.grasscutter.server.game.GameSession; import emu.grasscutter.server.game.GameSession;
import emu.grasscutter.server.http.dispatch.RegionHandler;
import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.Crypto;
import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.FileUtils;
import java.io.File; import java.io.File;
import java.util.Base64; import java.util.Base64;
import java.util.Objects;
import static emu.grasscutter.Configuration.*; import static emu.grasscutter.Configuration.*;
@ -46,7 +48,7 @@ public class PacketPlayerLoginRsp extends BasePacket {
info = regionCache.getRegionInfo(); info = regionCache.getRegionInfo();
} else { } else {
info = Grasscutter.getDispatchServer().getCurrRegion().getRegionInfo(); info = Objects.requireNonNull(RegionHandler.getCurrentRegion()).getRegionInfo();
} }
PlayerLoginRsp p = PlayerLoginRsp.newBuilder() PlayerLoginRsp p = PlayerLoginRsp.newBuilder()

View File

@ -17,7 +17,7 @@ import static emu.grasscutter.Grasscutter.config;
*/ */
public class ConfigContainer { public class ConfigContainer {
private static int version() { private static int version() {
return 2; return 3;
} }
/** /**
@ -96,8 +96,10 @@ public class ConfigContainer {
public ServerDebugMode debugLevel = ServerDebugMode.NONE; public ServerDebugMode debugLevel = ServerDebugMode.NONE;
public ServerRunMode runMode = ServerRunMode.HYBRID; public ServerRunMode runMode = ServerRunMode.HYBRID;
public Dispatch dispatch = new Dispatch(); public HTTP http = new HTTP();
public Game game = new Game(); public Game game = new Game();
public Dispatch dispatch = new Dispatch();
} }
public static class Language { public static class Language {
@ -112,7 +114,7 @@ public class ConfigContainer {
/* Server options. */ /* Server options. */
public static class Dispatch { public static class HTTP {
public String bindAddress = "0.0.0.0"; public String bindAddress = "0.0.0.0";
/* This is the address used in URLs. */ /* This is the address used in URLs. */
public String accessAddress = "127.0.0.1"; public String accessAddress = "127.0.0.1";
@ -123,9 +125,7 @@ public class ConfigContainer {
public Encryption encryption = new Encryption(); public Encryption encryption = new Encryption();
public Policies policies = new Policies(); public Policies policies = new Policies();
public Region[] regions = {}; public Files files = new Files();
public String defaultName = "Grasscutter";
} }
public static class Game { public static class Game {
@ -144,6 +144,12 @@ public class ConfigContainer {
/* Data containers. */ /* Data containers. */
public static class Dispatch {
public Region[] regions = {};
public String defaultName = "Grasscutter";
}
public static class Encryption { public static class Encryption {
public boolean useEncryption = true; public boolean useEncryption = true;
/* Should 'https' be appended to URLs? */ /* Should 'https' be appended to URLs? */
@ -223,9 +229,26 @@ public class ConfigContainer {
public String signature = "Welcome to Grasscutter!"; public String signature = "Welcome to Grasscutter!";
} }
public static class Files {
public String indexFile = "./index.html";
public String errorFile = "./404.html";
}
/* Objects. */ /* Objects. */
public static class Region { public static class Region {
public Region() { }
public Region(
String name, String title,
String address, int port
) {
this.Name = name;
this.Title = title;
this.Ip = address;
this.Port = port;
}
public String Name = "os_usa"; public String Name = "os_usa";
public String Title = "Grasscutter"; public String Title = "Grasscutter";
public String Ip = "127.0.0.1"; public String Ip = "127.0.0.1";

View File

@ -6,10 +6,7 @@ import java.nio.file.Files;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.time.*; import java.time.*;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.HashMap; import java.util.*;
import java.util.Map;
import java.util.Random;
import java.util.Locale;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
@ -309,13 +306,6 @@ public final class Utils {
return map; return map;
} }
/**
* get language code from Locale
*/
public static String getLanguageCode(Locale locale) {
return String.format("%s-%s", locale.getLanguage(), locale.getCountry());
}
/** /**
* Performs a linear interpolation using a table of fixed points to create an effective piecewise f(x) = y function. * Performs a linear interpolation using a table of fixed points to create an effective piecewise f(x) = y function.
* @param x * @param x
@ -380,4 +370,44 @@ public final class Utils {
} }
return temp.toIntArray(); return temp.toIntArray();
} }
/**
* Gets the language code from a given locale.
* @param locale A locale.
* @return A string in the format of 'XX-XX'.
*/
public static String getLanguageCode(Locale locale) {
return String.format("%s-%s", locale.getLanguage(), locale.getCountry());
}
/**
* Base64 encodes a given byte array.
* @param toEncode An array of bytes.
* @return A base64 encoded string.
*/
public static String base64Encode(byte[] toEncode) {
return Base64.getEncoder().encodeToString(toEncode);
}
/**
* Base64 decodes a given string.
* @param toDecode A base64 encoded string.
* @return An array of bytes.
*/
public static byte[] base64Decode(String toDecode) {
return Base64.getDecoder().decode(toDecode);
}
/**
* Safely JSON decodes a given string.
* @param jsonData The JSON-encoded data.
* @return JSON decoded data, or null if an exception occurred.
*/
public static <T> T jsonDecode(String jsonData, Class<T> classType) {
try {
return Grasscutter.getGsonFactory().fromJson(jsonData, classType);
} catch (Exception ignored) {
return null;
}
}
} }

View File

@ -36,7 +36,8 @@
"session_key_error": "Wrong session key.", "session_key_error": "Wrong session key.",
"username_error": "Username not found.", "username_error": "Username not found.",
"username_create_error": "Username not found, create failed." "username_create_error": "Username not found, create failed."
} },
"router_error": "[Dispatch] Unable to attach router."
}, },
"status": { "status": {
"free_software": "Grasscutter is FREE software. If you have paid for this, you may have been scammed. Homepage: https://github.com/Grasscutters/Grasscutter", "free_software": "Grasscutter is FREE software. If you have paid for this, you may have been scammed. Homepage: https://github.com/Grasscutters/Grasscutter",