#792 #814 Implement SQLite migration, allow last IP to be nullable in MySQL

- Old SQLite setups have the last IP column as NOT NULL but without a default value. With the new concept (where we don't set a last IP on player registration) it fails.
  - Create an /authme debug child that allows to migrate SQLite (tricky because SQLite does not support dropping or modifying columns)
  - Allow last IP column to be NOT NULL in MySQL as well (extend MySQL /authme debug child)
- Add TODO comments with follow-up issue to extend our commands with new registration IP field
This commit is contained in:
ljacqu
2017-10-21 10:24:40 +02:00
parent b5ea48085c
commit 1651a61063
29 changed files with 688 additions and 130 deletions
@@ -27,6 +27,7 @@ public class AccountsCommand implements ExecutableCommand {
@Override
public void executeCommand(final CommandSender sender, List<String> arguments) {
// TODO #1366: last IP vs. registration IP?
final String playerName = arguments.isEmpty() ? sender.getName() : arguments.get(0);
// Assumption: a player name cannot contain '.'
@@ -52,6 +53,9 @@ public class AccountsCommand implements ExecutableCommand {
if (auth == null) {
commonService.send(sender, MessageKey.UNKNOWN_USER);
return;
} else if (auth.getLastIp() == null) {
sender.sendMessage("No known last IP address for player");
return;
}
List<String> accountList = dataSource.getAllAuthsByIp(auth.getLastIp());
@@ -72,10 +72,13 @@ class CountryLookup implements DebugSection {
sender.sendMessage("Note: if " + ProtectionSettings.ENABLE_PROTECTION + " is false no country is blocked");
}
// TODO #1366: Extend with registration IP?
private void outputInfoForPlayer(CommandSender sender, String name) {
PlayerAuth auth = dataSource.getAuth(name);
if (auth == null) {
sender.sendMessage("No player with name '" + name + "'");
} else if (auth.getLastIp() == null) {
sender.sendMessage("No last IP address known for '" + name + "'");
} else {
sender.sendMessage("Player '" + name + "' has IP address " + auth.getLastIp());
outputInfoForIpAddr(sender, auth.getLastIp());
@@ -21,7 +21,7 @@ public class DebugCommand implements ExecutableCommand {
private static final Set<Class<? extends DebugSection>> SECTION_CLASSES = ImmutableSet.of(
PermissionGroups.class, DataStatistics.class, CountryLookup.class, PlayerAuthViewer.class, InputValidator.class,
LimboPlayerViewer.class, CountryLookup.class, HasPermissionChecker.class, TestEmailSender.class,
SpawnLocationViewer.class, MySqlDefaultChanger.class);
SpawnLocationViewer.class, MySqlDefaultChanger.class, SqliteMigrater.class);
@Inject
private Factory<DebugSection> debugSectionFactory;
@@ -2,6 +2,8 @@ package fr.xephi.authme.command.executable.authme.debug;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.data.limbo.LimboService;
import fr.xephi.authme.datasource.CacheDataSource;
import fr.xephi.authme.datasource.DataSource;
import org.bukkit.Location;
import java.lang.reflect.Field;
@@ -85,7 +87,7 @@ final class DebugSectionUtils {
* @param function the function to apply to the map
* @param <U> the result type of the function
*
* @return player names for which there is a LimboPlayer (or error message upon failure)
* @return the value of the function applied to the map, or null upon error
*/
static <U> U applyToLimboPlayersMap(LimboService limboService, Function<Map, U> function) {
Field limboPlayerEntriesField = getLimboPlayerEntriesField();
@@ -98,4 +100,29 @@ final class DebugSectionUtils {
}
return null;
}
static <T> T castToTypeOrNull(Object object, Class<T> clazz) {
return clazz.isInstance(object) ? clazz.cast(object) : null;
}
/**
* Unwraps the "cache data source" and returns the underlying source. Returns the
* same as the input argument otherwise.
*
* @param dataSource the data source to unwrap if applicable
* @return the non-cache data source
*/
static DataSource unwrapSourceFromCacheDataSource(DataSource dataSource) {
if (dataSource instanceof CacheDataSource) {
try {
Field source = CacheDataSource.class.getDeclaredField("source");
source.setAccessible(true);
return (DataSource) source.get(dataSource);
} catch (NoSuchFieldException | IllegalAccessException e) {
ConsoleLogger.logException("Could not get source of CacheDataSource:", e);
return null;
}
}
return dataSource;
}
}
@@ -3,7 +3,6 @@ package fr.xephi.authme.command.executable.authme.debug;
import ch.jalu.configme.properties.Property;
import com.google.common.annotations.VisibleForTesting;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.datasource.CacheDataSource;
import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.datasource.MySQL;
import fr.xephi.authme.permission.DebugSectionPermissions;
@@ -15,21 +14,23 @@ import org.bukkit.command.CommandSender;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static fr.xephi.authme.command.executable.authme.debug.DebugSectionUtils.castToTypeOrNull;
import static fr.xephi.authme.command.executable.authme.debug.DebugSectionUtils.unwrapSourceFromCacheDataSource;
import static fr.xephi.authme.data.auth.PlayerAuth.DB_EMAIL_DEFAULT;
import static fr.xephi.authme.data.auth.PlayerAuth.DB_LAST_IP_DEFAULT;
import static fr.xephi.authme.data.auth.PlayerAuth.DB_LAST_LOGIN_DEFAULT;
import static fr.xephi.authme.datasource.SqlDataSourceUtils.isNotNullColumn;
import static java.lang.String.format;
/**
@@ -48,10 +49,7 @@ class MySqlDefaultChanger implements DebugSection {
@PostConstruct
void setMySqlField() {
DataSource dataSource = unwrapSourceFromCacheDataSource(this.dataSource);
if (dataSource instanceof MySQL) {
this.mySql = (MySQL) dataSource;
}
this.mySql = castToTypeOrNull(unwrapSourceFromCacheDataSource(this.dataSource), MySQL.class);
}
@Override
@@ -213,24 +211,6 @@ class MySqlDefaultChanger implements DebugSection {
}
}
private boolean isNotNullColumn(DatabaseMetaData metaData, String tableName,
String columnName) throws SQLException {
try (ResultSet rs = metaData.getColumns(null, null, tableName, columnName)) {
if (!rs.next()) {
throw new IllegalStateException("Did not find meta data for column '" + columnName
+ "' while migrating not-null columns (this should never happen!)");
}
int nullableCode = rs.getInt("NULLABLE");
if (nullableCode == DatabaseMetaData.columnNoNulls) {
return true;
} else if (nullableCode == DatabaseMetaData.columnNullableUnknown) {
ConsoleLogger.warning("Unknown nullable status for column '" + columnName + "'");
}
}
return false;
}
/**
* Gets the Connection object from the MySQL data source.
*
@@ -248,28 +228,6 @@ class MySqlDefaultChanger implements DebugSection {
}
}
/**
* Unwraps the "cache data source" and returns the underlying source. Returns the
* same as the input argument otherwise.
*
* @param dataSource the data source to unwrap if applicable
* @return the non-cache data source
*/
@VisibleForTesting
static DataSource unwrapSourceFromCacheDataSource(DataSource dataSource) {
if (dataSource instanceof CacheDataSource) {
try {
Field source = CacheDataSource.class.getDeclaredField("source");
source.setAccessible(true);
return (DataSource) source.get(dataSource);
} catch (NoSuchFieldException | IllegalAccessException e) {
ConsoleLogger.logException("Could not get source of CacheDataSource:", e);
return null;
}
}
return dataSource;
}
private static <E extends Enum<E>> E matchToEnum(List<String> arguments, int index, Class<E> clazz) {
if (arguments.size() <= index) {
return null;
@@ -290,6 +248,11 @@ class MySqlDefaultChanger implements DebugSection {
LASTLOGIN(DatabaseSettings.MYSQL_COL_LASTLOGIN,
"BIGINT", "BIGINT NOT NULL DEFAULT 0", DB_LAST_LOGIN_DEFAULT),
LASTIP(DatabaseSettings.MYSQL_COL_LAST_IP,
"VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin",
"VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '127.0.0.1'",
DB_LAST_IP_DEFAULT),
EMAIL(DatabaseSettings.MYSQL_COL_EMAIL,
"VARCHAR(255)", "VARCHAR(255) NOT NULL DEFAULT 'your@email.com'", DB_EMAIL_DEFAULT);
@@ -0,0 +1,178 @@
package fr.xephi.authme.command.executable.authme.debug;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.datasource.Columns;
import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.datasource.SQLite;
import fr.xephi.authme.permission.DebugSectionPermissions;
import fr.xephi.authme.permission.PermissionNode;
import fr.xephi.authme.settings.Settings;
import fr.xephi.authme.settings.properties.DatabaseSettings;
import fr.xephi.authme.util.RandomStringUtils;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import static fr.xephi.authme.command.executable.authme.debug.DebugSectionUtils.castToTypeOrNull;
import static fr.xephi.authme.command.executable.authme.debug.DebugSectionUtils.unwrapSourceFromCacheDataSource;
import static org.bukkit.ChatColor.BOLD;
import static org.bukkit.ChatColor.GOLD;
/**
* Performs a migration on the SQLite data source if necessary.
*/
class SqliteMigrater implements DebugSection {
@Inject
private DataSource dataSource;
@Inject
private Settings settings;
private SQLite sqLite;
private String confirmationCode;
@PostConstruct
void setSqLiteField() {
this.sqLite = castToTypeOrNull(unwrapSourceFromCacheDataSource(this.dataSource), SQLite.class);
}
@Override
public String getName() {
return "migratesqlite";
}
@Override
public String getDescription() {
return "Migrates the SQLite database";
}
// A migration can be forced even if SQLite says it doesn't need a migration by adding "force" as second argument
@Override
public void execute(CommandSender sender, List<String> arguments) {
if (sqLite == null) {
sender.sendMessage("This command migrates SQLite. You are currently not using a SQLite database.");
return;
}
if (!isMigrationRequired() && !isMigrationForced(arguments)) {
sender.sendMessage("Good news! No migration is required of your database");
} else if (checkConfirmationCodeAndInformSenderOnMismatch(sender, arguments)) {
final String tableName = settings.getProperty(DatabaseSettings.MYSQL_TABLE);
final Columns columns = new Columns(settings);
try {
recreateDatabaseWithNewDefinitions(tableName, columns);
sender.sendMessage(ChatColor.GREEN + "Successfully migrated your SQLite database!");
} catch (SQLException e) {
ConsoleLogger.logException("Failed to migrate SQLite database", e);
sender.sendMessage(ChatColor.RED
+ "An error occurred during SQLite migration. Please check the logs!");
}
}
}
private boolean checkConfirmationCodeAndInformSenderOnMismatch(CommandSender sender, List<String> arguments) {
boolean isMatch = !arguments.isEmpty() && arguments.get(0).equalsIgnoreCase(confirmationCode);
if (isMatch) {
confirmationCode = null;
return true;
} else {
confirmationCode = RandomStringUtils.generate(4).toUpperCase();
sender.sendMessage(new String[]{
BOLD.toString() + GOLD + "Please create a backup of your SQLite database before running this command!",
"Either copy your DB file or run /authme backup. Afterwards,",
String.format("run '/authme debug %s %s' to perform the migration. "
+ "The code confirms that you've made a backup!", getName(), confirmationCode)
});
return false;
}
}
@Override
public PermissionNode getRequiredPermission() {
return DebugSectionPermissions.MIGRATE_SQLITE;
}
private boolean isMigrationRequired() {
Connection connection = getConnection(sqLite);
try {
DatabaseMetaData metaData = connection.getMetaData();
return sqLite.isMigrationRequired(metaData);
} catch (SQLException e) {
throw new IllegalStateException("Could not check if SQLite migration is required", e);
}
}
private static boolean isMigrationForced(List<String> arguments) {
return arguments.size() >= 2 && "force".equals(arguments.get(1));
}
// Cannot rename or remove a column from SQLite, so we have to rename the table and create an updated one
// cf. https://stackoverflow.com/questions/805363/how-do-i-rename-a-column-in-a-sqlite-database-table
private void recreateDatabaseWithNewDefinitions(String tableName, Columns col) throws SQLException {
Connection connection = getConnection(sqLite);
String tempTable = "tmp_" + tableName;
try (Statement st = connection.createStatement()) {
st.execute("ALTER TABLE " + tableName + " RENAME TO " + tempTable + ";");
}
sqLite.reload();
connection = getConnection(sqLite);
try (Statement st = connection.createStatement()) {
String copySql = "INSERT INTO $table ($id, $name, $realName, $password, $lastIp, $lastLogin, $regIp, "
+ "$regDate, $locX, $locY, $locZ, $locWorld, $locPitch, $locYaw, $email, $isLogged)"
+ "SELECT $id, $name, $realName,"
+ " $password, CASE WHEN $lastIp = '127.0.0.1' OR $lastIp = '' THEN NULL else $lastIp END,"
+ " $lastLogin, $regIp, $regDate, $locX, $locY, $locZ, $locWorld, $locPitch, $locYaw,"
+ " CASE WHEN $email = 'your@email.com' THEN NULL ELSE $email END, $isLogged"
+ " FROM " + tempTable + ";";
int insertedEntries = st.executeUpdate(replaceColumnVariables(copySql, tableName, col));
ConsoleLogger.info("Copied over " + insertedEntries + " from the old table to the new one");
st.execute("DROP TABLE " + tempTable + ";");
}
}
private String replaceColumnVariables(String sql, String tableName, Columns col) {
String replacedSql = sql.replace("$table", tableName).replace("$id", col.ID)
.replace("$name", col.NAME).replace("$realName", col.REAL_NAME)
.replace("$password", col.PASSWORD).replace("$lastIp", col.LAST_IP)
.replace("$lastLogin", col.LAST_LOGIN).replace("$regIp", col.REGISTRATION_IP)
.replace("$regDate", col.REGISTRATION_DATE).replace("$locX", col.LASTLOC_X)
.replace("$locY", col.LASTLOC_Y).replace("$locZ", col.LASTLOC_Z)
.replace("$locWorld", col.LASTLOC_WORLD).replace("$locPitch", col.LASTLOC_PITCH)
.replace("$locYaw", col.LASTLOC_YAW).replace("$email", col.EMAIL)
.replace("$isLogged", col.IS_LOGGED);
if (replacedSql.contains("$")) {
throw new IllegalStateException("SQL still statement still has '$' in it - was a tag not replaced?"
+ " Replacement result: " + replacedSql);
}
return replacedSql;
}
/**
* Returns the connection from the given SQLite instance.
*
* @param sqLite the SQLite instance to process
* @return the connection to the SQLite database
*/
private static Connection getConnection(SQLite sqLite) {
try {
Field connectionField = SQLite.class.getDeclaredField("con");
connectionField.setAccessible(true);
return (Connection) connectionField.get(sqLite);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new IllegalStateException("Failed to get the connection from SQLite", e);
}
}
}
@@ -18,6 +18,8 @@ public class PlayerAuth {
public static final String DB_EMAIL_DEFAULT = "your@email.com";
/** Default last login value used in the database if the last login column is NOT NULL. */
public static final long DB_LAST_LOGIN_DEFAULT = 0;
/** Default last ip value used in the database if the last IP column is NOT NULL. */
public static final String DB_LAST_IP_DEFAULT = "127.0.0.1";
/** The player's name in lowercase, e.g. "xephi". */
private String nickname;
@@ -218,7 +220,7 @@ public class PlayerAuth {
auth.realName = firstNonNull(realName, "Player");
auth.password = firstNonNull(password, new HashedPassword(""));
auth.email = DB_EMAIL_DEFAULT.equals(email) ? null : email;
auth.lastIp = firstNonNull(lastIp, "127.0.0.1");
auth.lastIp = lastIp; // Don't check against default value 127.0.0.1 as it may be a legit value
auth.groupId = groupId;
auth.lastLogin = isEqualTo(lastLogin, DB_LAST_LOGIN_DEFAULT) ? null : lastLogin;
auth.registrationIp = registrationIp;
@@ -192,7 +192,7 @@ public class MySQL implements DataSource {
if (isColumnMissing(md, col.LAST_IP)) {
st.executeUpdate("ALTER TABLE " + tableName
+ " ADD COLUMN " + col.LAST_IP + " VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL;");
+ " ADD COLUMN " + col.LAST_IP + " VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin;");
}
if (isColumnMissing(md, col.LAST_LOGIN)) {
@@ -97,7 +97,7 @@ public class SQLite implements DataSource {
if (isColumnMissing(md, col.LAST_IP)) {
st.executeUpdate("ALTER TABLE " + tableName
+ " ADD COLUMN " + col.LAST_IP + " VARCHAR(40) NOT NULL DEFAULT '';");
+ " ADD COLUMN " + col.LAST_IP + " VARCHAR(40);");
}
if (isColumnMissing(md, col.LAST_LOGIN)) {
@@ -152,10 +152,30 @@ public class SQLite implements DataSource {
st.executeUpdate("ALTER TABLE " + tableName
+ " ADD COLUMN " + col.HAS_SESSION + " INT NOT NULL DEFAULT '0';");
}
if (isMigrationRequired(md)) {
ConsoleLogger.warning("READ ME! Your SQLite database is outdated and cannot save new players.");
ConsoleLogger.warning("Run /authme debug migratesqlite after making a backup");
}
}
ConsoleLogger.info("SQLite Setup finished");
}
/**
* Returns whether the database needs to be migrated.
* <p>
* Background: Before commit 22911a0 (July 2016), new SQLite databases initialized the last IP column to be NOT NULL
* without a default value. Allowing the last IP to be null (#792) is therefore not compatible.
*
* @param metaData the database meta data
* @return true if a migration is necessary, false otherwise
* @throws SQLException .
*/
public boolean isMigrationRequired(DatabaseMetaData metaData) throws SQLException {
return SqlDataSourceUtils.isNotNullColumn(metaData, tableName, col.LAST_IP)
&& SqlDataSourceUtils.getColumnDefaultValue(metaData, tableName, col.LAST_IP) == null;
}
private boolean isColumnMissing(DatabaseMetaData metaData, String columnName) throws SQLException {
try (ResultSet rs = metaData.getColumns(null, null, tableName, columnName)) {
return !rs.next();
@@ -2,13 +2,14 @@ package fr.xephi.authme.datasource;
import fr.xephi.authme.ConsoleLogger;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* Utilities for SQL data sources.
*/
final class SqlDataSourceUtils {
public final class SqlDataSourceUtils {
private SqlDataSourceUtils() {
}
@@ -18,7 +19,7 @@ final class SqlDataSourceUtils {
*
* @param e the exception to log
*/
static void logSqlException(SQLException e) {
public static void logSqlException(SQLException e) {
ConsoleLogger.logException("Error during SQL operation:", e);
}
@@ -31,8 +32,55 @@ final class SqlDataSourceUtils {
* @return the value (which may be null)
* @throws SQLException :)
*/
static Long getNullableLong(ResultSet rs, String columnName) throws SQLException {
public static Long getNullableLong(ResultSet rs, String columnName) throws SQLException {
long longValue = rs.getLong(columnName);
return rs.wasNull() ? null : longValue;
}
/**
* Returns whether the given column has a NOT NULL constraint.
*
* @param metaData the database meta data
* @param tableName the name of the table in which the column is
* @param columnName the name of the column to check
* @return true if the column is NOT NULL, false otherwise
* @throws SQLException :)
*/
public static boolean isNotNullColumn(DatabaseMetaData metaData, String tableName,
String columnName) throws SQLException {
try (ResultSet rs = metaData.getColumns(null, null, tableName, columnName)) {
if (!rs.next()) {
throw new IllegalStateException("Did not find meta data for column '"
+ columnName + "' while checking for not-null constraint");
}
int nullableCode = rs.getInt("NULLABLE");
if (nullableCode == DatabaseMetaData.columnNoNulls) {
return true;
} else if (nullableCode == DatabaseMetaData.columnNullableUnknown) {
ConsoleLogger.warning("Unknown nullable status for column '" + columnName + "'");
}
}
return false;
}
/**
* Returns the default value of a column (as per its SQL definition).
*
* @param metaData the database meta data
* @param tableName the name of the table in which the column is
* @param columnName the name of the column to check
* @return the default value of the column (may be null)
* @throws SQLException :)
*/
public static Object getColumnDefaultValue(DatabaseMetaData metaData, String tableName,
String columnName) throws SQLException {
try (ResultSet rs = metaData.getColumns(null, null, tableName, columnName)) {
if (!rs.next()) {
throw new IllegalStateException("Did not find meta data for column '"
+ columnName + "' while checking its default value");
}
return rs.getObject("COLUMN_DEF");
}
}
}
@@ -32,6 +32,9 @@ public enum DebugSectionPermissions implements PermissionNode {
/** Permission to change nullable status of MySQL columns. */
MYSQL_DEFAULT_CHANGER("authme.debug.mysqldef"),
/** Permission to perform a migration of SQLite. */
MIGRATE_SQLITE("authme.debug.migratesqlite"),
/** Permission to view spawn information. */
SPAWN_LOCATION("authme.debug.spawn"),