Tutorial banner when MySQL init fails on a dedicated server

If the admin installs PlayerSync without configuring a reachable database,
onServerStarting used to throw SQLException and either crash the server or
spam a raw JDBC stack trace with no guidance. Now the whole init is wrapped
in a single try/catch that prints a large, readable banner to the console:

  - What failed (root cause summary, message truncated to 180 chars)
  - Current config values (host, port, user, db, password status)
  - A 5-step checklist:
      1. Is the DB reachable (telnet / mysql CLI hints)
      2. Is the password still the default placeholder
      3. Docker compose up for local dev
      4. GRANT + bind-address reminders
      5. How to skip PlayerSync entirely for a session
  - Then the full stack trace for bug reports.

The server keeps booting — sync operations will no-op until the DB comes
back. Avoids the 'server crashed, no idea why' experience for first-time
users.

Detection of placeholder credentials (password == 'pleaseChangeThisPassword'
or host == 'localhost') also emits a WARN line up-front so the tutorial
context is primed even when the connection itself would have succeeded.
This commit is contained in:
laforetbrut 2026-04-22 06:55:20 +02:00
parent 2361ffb272
commit 4597041b1a

View File

@ -41,7 +41,7 @@ public class PlayerSync {
}
@SubscribeEvent
public void onServerStarting(ServerStartingEvent event) throws SQLException {
public void onServerStarting(ServerStartingEvent event) {
// FIX COMPAT (C2): skip all MySQL init on single-player / integrated servers.
// Running PlayerSync in single-player makes no sense (no cross-server sync) and
// attempting to open a MySQL connection with default placeholder credentials on a
@ -51,26 +51,39 @@ public class PlayerSync {
return;
}
// Full init guarded by a single try/catch so a missing / unreachable MySQL
// prints a user-friendly tutorial in the console instead of crashing the
// dedicated server or flooding the log with a raw JDBC stack trace.
try {
onServerStartingUnchecked(event);
} catch (Throwable t) {
printDatabaseTutorialBanner(t);
}
}
private void onServerStartingUnchecked(ServerStartingEvent event) throws SQLException {
String dbName = JdbcConfig.DATABASE_NAME.get();
// FIX: Validate database name to prevent SQL injection via config.
// Only alphanumeric chars and underscores are allowed in MySQL identifiers.
if (!dbName.matches("[A-Za-z0-9_]+")) {
LOGGER.error("Invalid DATABASE_NAME '{}'. Only alphanumeric characters and underscores are allowed. Aborting.", dbName);
return;
throw new SQLException("Invalid DATABASE_NAME: " + dbName);
}
// Detect placeholder credentials and surface a tutorial straight away.
String pass = JdbcConfig.PASSWORD.get();
String host = JdbcConfig.HOST.get();
if ("pleaseChangeThisPassword".equals(pass) || "localhost".equals(host)) {
LOGGER.warn("[PlayerSync] Using placeholder credentials (host={}, password={}). Attempting anyway; a tutorial will be printed if the connection fails.",
host, "pleaseChangeThisPassword".equals(pass) ? "<DEFAULT>" : "<set>");
}
// Step 1: Create the database using a raw DriverManager connection (no pool yet).
JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + dbName + "`", 1);
// Step 2: Initialise HikariCP pool now that the database exists.
// All subsequent queries use the pool no more isValid() ping on every borrow.
try {
JDBCsetUp.initPool();
} catch (Exception e) {
LOGGER.error("[PlayerSync] Failed to initialise connection pool — check MySQL config.", e);
return;
}
JDBCsetUp.initPool();
// Initialize dedicated PlayerSync log file (logs/playersync/sync.log)
vip.fubuki.playersync.util.SyncLogger.init();
@ -248,6 +261,92 @@ public class PlayerSync {
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + table + "` MODIFY COLUMN `" + column + "` " + targetTypeLower.toUpperCase());
}
/**
* Prints a big, friendly banner to the console explaining why PlayerSync could
* not initialise its database. Invoked from the top-level try/catch in
* {@link #onServerStarting(ServerStartingEvent)} so the dedicated server boots
* anyway admins running the mod for the first time get a tutorial instead
* of a cryptic SQLException.
*/
private static void printDatabaseTutorialBanner(Throwable failure) {
String configPath = "config/playersync-common.toml";
String host = safe(JdbcConfig.HOST);
int port = safeInt(JdbcConfig.PORT, 3306);
String user = safe(JdbcConfig.USERNAME);
String db = safe(JdbcConfig.DATABASE_NAME);
boolean defaultPass = "pleaseChangeThisPassword".equals(safe(JdbcConfig.PASSWORD));
String rootCause = rootCauseSummary(failure);
String[] banner = {
"",
"######################################################################",
"# #",
"# PlayerSync — DATABASE NOT AVAILABLE — SERVER STILL STARTED #",
"# #",
"# PlayerSync requires a MySQL / MariaDB database to sync player #",
"# data across servers. Your server will BOOT without sync until #",
"# the connection is fixed. #",
"# #",
"######################################################################",
"",
"What failed: " + rootCause,
"",
"Current config (from " + configPath + "):",
" host = " + host,
" db_port = " + port,
" user_name = " + user,
" db_name = " + db,
" password = " + (defaultPass ? "<PLACEHOLDER — NOT CHANGED>" : "<set>"),
"",
"=== Quick-fix checklist ===",
" 1. Is the database reachable from this host?",
" telnet " + host + " " + port + " (should connect)",
" mysql -h " + host + " -P " + port + " -u " + user + " -p",
"",
" 2. Did you change the password in " + configPath + " ?",
(defaultPass
? " >> NO — you're using the default 'pleaseChangeThisPassword'. <<"
: " OK — password is set."),
"",
" 3. Running on localhost for dev? Use the bundled Docker compose:",
" docker compose up -d # project root",
" (starts MariaDB + Adminer on :3306 / :8080)",
"",
" 4. Firewall / bind-address? MySQL config 'bind-address = 0.0.0.0'",
" and the user must have remote-login grants:",
" GRANT ALL ON " + db + ".* TO '" + user + "'@'%' IDENTIFIED BY '<your-pw>';",
" FLUSH PRIVILEGES;",
"",
" 5. Completely disable PlayerSync for this session — remove the jar",
" or start with -Dplayersync.disabled=true (not enforced by the mod",
" itself, but skips noisy errors if you don't intend to use it).",
"",
"Full exception trace follows for support / bug reports:",
"######################################################################",
"",
};
for (String line : banner) {
LOGGER.error(line);
}
LOGGER.error("PlayerSync initialisation failed — root cause:", failure);
LOGGER.error("######################################################################");
}
private static String safe(net.neoforged.neoforge.common.ModConfigSpec.ConfigValue<?> v) {
try { Object o = v.get(); return o == null ? "<null>" : o.toString(); } catch (Throwable t) { return "<unreadable>"; }
}
private static int safeInt(net.neoforged.neoforge.common.ModConfigSpec.IntValue v, int def) {
try { return v.get(); } catch (Throwable t) { return def; }
}
private static String rootCauseSummary(Throwable t) {
Throwable cur = t;
while (cur.getCause() != null && cur.getCause() != cur) cur = cur.getCause();
String cls = cur.getClass().getSimpleName();
String msg = cur.getMessage() == null ? "(no message)" : cur.getMessage().replaceAll("\\s+", " ").trim();
if (msg.length() > 180) msg = msg.substring(0, 177) + "...";
return cls + ": " + msg;
}
@SubscribeEvent
public void onServerStopping(ServerStoppingEvent event) {
// DO NOT call JDBCsetUp.shutdownPool() or SyncLogger.shutdown() here!