Security audit: fix 7 critical/high issues from code review
1. CRITICAL - Anti-dupe: Player inventory mutations now run on the main server thread via server.execute(). DB reads stay async, but all setItem/setHealth/addEffect calls happen on the tick thread. CountDownLatch ensures the lock is held until apply completes. 2. CRITICAL - Resource leaks: 3 QueryResults in PlayerSync.java startup now use try-with-resources + PreparedStatements instead of raw String.format SQL. 3. HIGH - Curios save: UPDATE changed to REPLACE INTO to prevent silent no-ops when the curios row doesn't exist yet (new player who died before first init save). 4. HIGH - RS2 restore: Removed skip-if-exists check. DB is always the source of truth - stale local data was persisting permanently. 5. HIGH - Race conditions: Shutdown save now acquires per-player lock. All logout saves (curios, mod-compat, inventory) moved inside doPlayerLogout under a single lock acquisition. 6. HIGH - SQL injection: DATABASE_NAME validated against [A-Za-z0-9_]+ regex on startup to prevent injection via config. Vyrriox
This commit is contained in:
parent
46689a360c
commit
e907bcbfb0
|
|
@ -49,6 +49,13 @@ public class PlayerSync {
|
|||
public void onServerStarting(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;
|
||||
}
|
||||
|
||||
// Step 1: Create the database using a connection that does not select a database.
|
||||
JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + dbName + "`", 1);
|
||||
|
||||
|
|
@ -84,16 +91,14 @@ public class PlayerSync {
|
|||
);
|
||||
|
||||
// Check and alter player_data table if columns are missing
|
||||
JDBCsetUp.QueryResult queryResult = JDBCsetUp.executeQuery(
|
||||
"SELECT COUNT(*) AS column_count " +
|
||||
"FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'player_data';"
|
||||
);
|
||||
ResultSet resultSet = queryResult.resultSet();
|
||||
int columnCount = 0;
|
||||
if (resultSet.next()) {
|
||||
columnCount = resultSet.getInt("column_count");
|
||||
try (JDBCsetUp.QueryResult queryResult = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT COUNT(*) AS column_count FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'player_data'",
|
||||
dbName)) {
|
||||
ResultSet resultSet = queryResult.resultSet();
|
||||
if (resultSet.next()) {
|
||||
columnCount = resultSet.getInt("column_count");
|
||||
}
|
||||
}
|
||||
if (columnCount < 14) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
|
|
@ -163,40 +168,31 @@ public class PlayerSync {
|
|||
);
|
||||
|
||||
// Check if backpack_data table has the 'uuid' column
|
||||
JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery(
|
||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'backpack_data' " +
|
||||
"AND COLUMN_NAME = 'uuid';"
|
||||
);
|
||||
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
||||
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
|
||||
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
|
||||
// Add the missing column and set it as primary key.
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD PRIMARY KEY (uuid)", 1);
|
||||
try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'backpack_data' AND COLUMN_NAME = 'uuid'",
|
||||
dbName)) {
|
||||
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
||||
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
|
||||
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD PRIMARY KEY (uuid)", 1);
|
||||
}
|
||||
}
|
||||
rsBackpackCol.close();
|
||||
backpackColCheck.connection().close();
|
||||
}
|
||||
|
||||
// Check and alter the 'advancements' column in player_data if necessary
|
||||
JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executeQuery(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'player_data' " +
|
||||
"AND COLUMN_NAME = 'advancements';"
|
||||
);
|
||||
ResultSet rsAdvCol = advColCheck.resultSet();
|
||||
if (rsAdvCol.next()) {
|
||||
String dataType = rsAdvCol.getString("DATA_TYPE");
|
||||
if (!"mediumblob".equalsIgnoreCase(dataType)) {
|
||||
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
||||
try (JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'player_data' AND COLUMN_NAME = 'advancements'",
|
||||
dbName)) {
|
||||
ResultSet rsAdvCol = advColCheck.resultSet();
|
||||
if (rsAdvCol.next()) {
|
||||
String dataType = rsAdvCol.getString("DATA_TYPE");
|
||||
if (!"mediumblob".equalsIgnoreCase(dataType)) {
|
||||
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rsAdvCol.close();
|
||||
// ----- END NEW BLOCK -----
|
||||
|
||||
// Create generic mod_player_data table for mod compatibility (Accessories, CosmeticArmor, Aether, etc.)
|
||||
JDBCsetUp.executeUpdate(
|
||||
|
|
|
|||
|
|
@ -219,56 +219,47 @@ public class VanillaSync {
|
|||
public static Set<String> syncNotCompletedPlayer = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public static void doPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
ServerPlayer joinedPlayer = (ServerPlayer) event.getEntity();
|
||||
String player_uuid = joinedPlayer.getUUID().toString();
|
||||
if (joinedPlayer.isDeadOrDying()) {
|
||||
deadPlayerWhileLogging.add(player_uuid);
|
||||
joinedPlayer.removeTag("player_synced");
|
||||
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
|
||||
String player_uuid = serverPlayer.getUUID().toString();
|
||||
MinecraftServer server = serverPlayer.getServer();
|
||||
|
||||
// Simulate normal death behavior
|
||||
MinecraftServer server = joinedPlayer.getServer();
|
||||
if (server != null) {
|
||||
ResourceKey<Level> respawnLevel = joinedPlayer.getRespawnDimension();
|
||||
BlockPos respawnPos = joinedPlayer.getRespawnPosition();
|
||||
double respawnX;
|
||||
double respawnY;
|
||||
double respawnZ;
|
||||
if (server == null) {
|
||||
PlayerSync.LOGGER.error("Server is null for player {}", player_uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverPlayer.isDeadOrDying()) {
|
||||
deadPlayerWhileLogging.add(player_uuid);
|
||||
serverPlayer.removeTag("player_synced");
|
||||
server.execute(() -> {
|
||||
ResourceKey<Level> respawnLevel = serverPlayer.getRespawnDimension();
|
||||
BlockPos respawnPos = serverPlayer.getRespawnPosition();
|
||||
if (respawnPos != null) {
|
||||
ServerLevel level = server.getLevel(respawnLevel);
|
||||
respawnX = respawnPos.getX();
|
||||
respawnY = respawnPos.getY();
|
||||
respawnZ = respawnPos.getZ();
|
||||
if (level != null) {
|
||||
joinedPlayer.teleportTo(level, respawnX, respawnY + 1, respawnZ, 0, 0);
|
||||
serverPlayer.teleportTo(level, respawnPos.getX(), respawnPos.getY() + 1, respawnPos.getZ(), 0, 0);
|
||||
}
|
||||
} else {
|
||||
PlayerSync.LOGGER.debug("Player {} has no respawn point", player_uuid);
|
||||
}
|
||||
} else {
|
||||
PlayerSync.LOGGER.warn("Trying to get server,but got a null");
|
||||
}
|
||||
|
||||
joinedPlayer.setHealth(1);
|
||||
serverPlayer.setHealth(1);
|
||||
serverPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again."));
|
||||
});
|
||||
try {
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
|
||||
} catch (SQLException e) {
|
||||
PlayerSync.LOGGER.error("An error occurred while handling dead/dying player {}", e.getMessage());
|
||||
}
|
||||
joinedPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire per-player lock to prevent concurrent save/restore (anti-duplication)
|
||||
ReentrantLock lock = getPlayerLock(player_uuid);
|
||||
lock.lock();
|
||||
try {
|
||||
PlayerSync.LOGGER.info("Starting synchronization for player {}", player_uuid);
|
||||
|
||||
syncNotCompletedPlayer.add(player_uuid);
|
||||
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
|
||||
|
||||
// First query: check if player exists in DB
|
||||
// === PHASE 1: DB reads on background thread (thread-safe) ===
|
||||
|
||||
boolean playerExists;
|
||||
try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT uuid FROM player_data WHERE uuid=?", player_uuid)) {
|
||||
|
|
@ -276,28 +267,56 @@ public class VanillaSync {
|
|||
}
|
||||
|
||||
if (!playerExists) {
|
||||
// New player - init and save
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
modsSupport.doCuriosRestore(serverPlayer);
|
||||
store(event.getEntity(), true);
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
|
||||
PlayerSync.LOGGER.info("New player detected, init completed.");
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
server.execute(() -> {
|
||||
try {
|
||||
new ModsSupport().doCuriosRestore(serverPlayer);
|
||||
store(serverPlayer, true);
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
|
||||
serverPlayer.addTag("player_synced");
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error initializing new player {}", player_uuid, e);
|
||||
} finally {
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark player as online immediately to prevent race conditions
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
|
||||
|
||||
// Retrieve full player data
|
||||
// Read all DB data into local variables (background thread - safe)
|
||||
final int health, foodLevel, xp, score;
|
||||
final String leftHand, cursors, armorData, inventoryData, enderChestData, effectData;
|
||||
|
||||
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT * FROM player_data WHERE uuid=?", player_uuid)) {
|
||||
ResultSet rs2 = qr2.resultSet();
|
||||
if (!rs2.next()) {
|
||||
PlayerSync.LOGGER.warn("No data found for existing player {}", player_uuid);
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
return;
|
||||
}
|
||||
health = rs2.getInt("health");
|
||||
foodLevel = rs2.getInt("food_level");
|
||||
xp = rs2.getInt("xp");
|
||||
score = rs2.getInt("score");
|
||||
leftHand = rs2.getString("left_hand");
|
||||
cursors = rs2.getString("cursors");
|
||||
armorData = rs2.getString("armor");
|
||||
inventoryData = rs2.getString("inventory");
|
||||
enderChestData = rs2.getString("enderchest");
|
||||
effectData = rs2.getString("effects");
|
||||
}
|
||||
|
||||
if (rs2.next()) {
|
||||
// === ANTI-DUPLICATION: Clear all inventories BEFORE restoring from DB ===
|
||||
// === PHASE 2: Apply to player on MAIN SERVER THREAD ===
|
||||
// Minecraft entities are NOT thread-safe. Modifying inventory/health/effects
|
||||
// from a background thread causes duplication exploits and corruption.
|
||||
CountDownLatch applyLatch = new CountDownLatch(1);
|
||||
server.execute(() -> {
|
||||
try {
|
||||
// ANTI-DUPLICATION: Clear all inventories BEFORE restoring
|
||||
serverPlayer.getInventory().clearContent();
|
||||
serverPlayer.getEnderChestInventory().clearContent();
|
||||
serverPlayer.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
|
||||
|
|
@ -307,47 +326,27 @@ public class VanillaSync {
|
|||
}
|
||||
|
||||
// Restore basic attributes
|
||||
int health = rs2.getInt("health");
|
||||
if (health <= 0) {
|
||||
serverPlayer.setHealth(1);
|
||||
} else {
|
||||
serverPlayer.setHealth(health);
|
||||
}
|
||||
serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level"));
|
||||
serverPlayer.setHealth(health <= 0 ? 1 : health);
|
||||
serverPlayer.getFoodData().setFoodLevel(foodLevel);
|
||||
setXpForPlayer(serverPlayer, xp);
|
||||
serverPlayer.setScore(score);
|
||||
|
||||
setXpForPlayer(serverPlayer, rs2.getInt("xp"));
|
||||
serverPlayer.setScore(rs2.getInt("score"));
|
||||
// Restore items
|
||||
serverPlayer.setItemInHand(InteractionHand.OFF_HAND, deserializeAndCreatePlaceholderIfNeeded(leftHand));
|
||||
serverPlayer.containerMenu.setCarried(deserializeAndCreatePlaceholderIfNeeded(cursors));
|
||||
|
||||
// Restore left-hand item
|
||||
String leftHandEncoded = rs2.getString("left_hand");
|
||||
serverPlayer.setItemInHand(InteractionHand.OFF_HAND,
|
||||
deserializeAndCreatePlaceholderIfNeeded(leftHandEncoded));
|
||||
|
||||
// Restore cursor item
|
||||
String cursorsEncoded = rs2.getString("cursors");
|
||||
serverPlayer.containerMenu.setCarried(
|
||||
deserializeAndCreatePlaceholderIfNeeded(cursorsEncoded));
|
||||
|
||||
// Restore armor
|
||||
String armor_data = rs2.getString("armor");
|
||||
if (armor_data != null && armor_data.length() > 2) {
|
||||
Map<Integer, String> equipment = LocalJsonUtil.StringToEntryMap(armor_data);
|
||||
if (armorData != null && armorData.length() > 2) {
|
||||
Map<Integer, String> equipment = LocalJsonUtil.StringToEntryMap(armorData);
|
||||
for (Map.Entry<Integer, String> entry : equipment.entrySet()) {
|
||||
serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
// Restore inventory
|
||||
String inventoryData = rs2.getString("inventory");
|
||||
if (inventoryData != null && inventoryData.length() > 2) {
|
||||
Map<Integer, String> inventory = LocalJsonUtil.StringToEntryMap(inventoryData);
|
||||
for (Map.Entry<Integer, String> entry : inventory.entrySet()) {
|
||||
serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
// Restore Ender Chest
|
||||
String enderChestData = rs2.getString("enderchest");
|
||||
if (enderChestData != null && enderChestData.length() > 2) {
|
||||
Map<Integer, String> ender_chest = LocalJsonUtil.StringToEntryMap(enderChestData);
|
||||
for (Map.Entry<Integer, String> entry : ender_chest.entrySet()) {
|
||||
|
|
@ -355,12 +354,8 @@ public class VanillaSync {
|
|||
}
|
||||
}
|
||||
|
||||
// FIX: ALWAYS clear effects before restoring to prevent stale local effects
|
||||
// from persisting when DB has no saved effects (e.g. player had no effects on previous server)
|
||||
// Always clear effects, then restore from DB
|
||||
serverPlayer.removeAllEffects();
|
||||
|
||||
// Restore Effects from DB (if any)
|
||||
String effectData = rs2.getString("effects");
|
||||
if (effectData != null && effectData.length() > 2) {
|
||||
Map<Integer, String> effects = LocalJsonUtil.StringToEntryMap(effectData);
|
||||
for (Map.Entry<Integer, String> entry : effects.entrySet()) {
|
||||
|
|
@ -371,26 +366,34 @@ public class VanillaSync {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore mod data (these do their own DB reads internally, acceptable on main thread)
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
modsSupport.doCuriosRestore(serverPlayer);
|
||||
modsSupport.doBackPackRestore(serverPlayer);
|
||||
if (ModList.get().isLoaded("sophisticatedstorage")) {
|
||||
ModsSupport.restoreSophisticatedStorageItems(serverPlayer);
|
||||
}
|
||||
if (ModList.get().isLoaded("refinedstorage")) {
|
||||
ModsSupport.restoreRefinedStorageDisks(serverPlayer);
|
||||
}
|
||||
ModCompatSync.restoreAll(serverPlayer);
|
||||
|
||||
serverPlayer.addTag("player_synced");
|
||||
PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid);
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error applying sync data for player {}", player_uuid, e);
|
||||
} finally {
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
applyLatch.countDown();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Restore mod data AFTER main inventory is restored
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
modsSupport.doCuriosRestore(serverPlayer);
|
||||
modsSupport.doBackPackRestore(serverPlayer);
|
||||
if (ModList.get().isLoaded("sophisticatedstorage")) {
|
||||
ModsSupport.restoreSophisticatedStorageItems(serverPlayer);
|
||||
// Wait for main thread to finish applying (prevents lock release before data is applied)
|
||||
if (!applyLatch.await(15, TimeUnit.SECONDS)) {
|
||||
PlayerSync.LOGGER.error("Timeout waiting for main thread sync for player {}", player_uuid);
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
}
|
||||
if (ModList.get().isLoaded("refinedstorage")) {
|
||||
ModsSupport.restoreRefinedStorageDisks(serverPlayer);
|
||||
}
|
||||
// Restore mod compatibility data (Accessories/Aether, CosmeticArmor)
|
||||
ModCompatSync.restoreAll(serverPlayer);
|
||||
|
||||
serverPlayer.addTag("player_synced");
|
||||
|
||||
PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid);
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Internal Exception detected!", e);
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
|
|
@ -648,6 +651,10 @@ public class VanillaSync {
|
|||
if (server != null) {
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
if (player.getTags().contains("player_synced") && !player.isDeadOrDying()) {
|
||||
String puuid = player.getUUID().toString();
|
||||
// FIX: Acquire per-player lock to prevent race with queued logout save
|
||||
ReentrantLock lock = getPlayerLock(puuid);
|
||||
lock.lock();
|
||||
try {
|
||||
store(player, false);
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
|
|
@ -663,10 +670,12 @@ public class VanillaSync {
|
|||
if (ModList.get().isLoaded("refinedstorage")) {
|
||||
ModsSupport.storeRefinedStorageDisks(player);
|
||||
}
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player.getUUID().toString());
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", puuid);
|
||||
PlayerSync.LOGGER.info("Saved player {} data on server shutdown", player.getUUID());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error saving player {} on shutdown", player.getUUID(), e);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -674,14 +683,27 @@ public class VanillaSync {
|
|||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX: All save operations (inventory, curios, mod-compat) are now under the per-player lock
|
||||
* to prevent race conditions with concurrent auto-save tasks on the executor.
|
||||
*/
|
||||
public static void doPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException, IOException {
|
||||
String player_uuid = event.getEntity().getUUID().toString();
|
||||
// Acquire per-player lock to prevent concurrent save/restore (anti-duplication)
|
||||
Player player = event.getEntity();
|
||||
ReentrantLock lock = getPlayerLock(player_uuid);
|
||||
lock.lock();
|
||||
try {
|
||||
// FIX: Save data BEFORE marking offline to prevent data loss on quick reconnect
|
||||
store(event.getEntity(), false);
|
||||
// Save ALL data under lock: curios, mod-compat, then main inventory, then mark offline
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
if (player.isDeadOrDying()) {
|
||||
modsSupport.saveCuriosFromCacheOrApi(player);
|
||||
} else {
|
||||
modsSupport.onPlayerLeave(player);
|
||||
}
|
||||
}
|
||||
ModCompatSync.storeAll(player);
|
||||
store(player, false);
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
|
|
@ -701,20 +723,8 @@ public class VanillaSync {
|
|||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
} else {
|
||||
// Mod support - save curios
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
Player player = event.getEntity();
|
||||
|
||||
// FIX: If player is dead/dying, use curios cache instead of reading from API (which returns empty)
|
||||
if (player.isDeadOrDying()) {
|
||||
modsSupport.saveCuriosFromCacheOrApi(player);
|
||||
} else {
|
||||
modsSupport.onPlayerLeave(player);
|
||||
}
|
||||
|
||||
// Save mod compatibility data (Accessories/Aether, CosmeticArmor)
|
||||
ModCompatSync.storeAll(player);
|
||||
|
||||
// FIX: All saves moved inside doPlayerLogout under the per-player lock
|
||||
// to prevent race conditions with auto-save
|
||||
executorService.submit(() -> {
|
||||
try {
|
||||
doPlayerLogout(event);
|
||||
|
|
|
|||
|
|
@ -204,8 +204,8 @@ public class ModsSupport {
|
|||
// Use cached data from death event
|
||||
PlayerSync.LOGGER.info("Using cached curios data for dead player {}", playerUuid);
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE curios SET curios_item=? WHERE uuid=?",
|
||||
cached.serializedData, playerUuid.toString());
|
||||
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
|
||||
playerUuid.toString(), cached.serializedData);
|
||||
CuriosCache.curiosCache.remove(playerUuid);
|
||||
} else {
|
||||
// Fallback: try to read from API (may be empty for dead players)
|
||||
|
|
@ -234,16 +234,11 @@ public class ModsSupport {
|
|||
|
||||
String serializedData = flatMap.toString();
|
||||
|
||||
// Use prepared statements to prevent SQL injection / data corruption
|
||||
if (init) {
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"INSERT INTO curios (uuid, curios_item) VALUES (?, ?)",
|
||||
player.getUUID().toString(), serializedData);
|
||||
} else {
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE curios SET curios_item=? WHERE uuid=?",
|
||||
serializedData, player.getUUID().toString());
|
||||
}
|
||||
// FIX: Use REPLACE INTO instead of separate INSERT/UPDATE to prevent silent
|
||||
// no-ops when the row doesn't exist yet (e.g. new player who died before first save)
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
|
||||
player.getUUID().toString(), serializedData);
|
||||
}
|
||||
|
||||
// ============================
|
||||
|
|
@ -461,12 +456,9 @@ public class ModsSupport {
|
|||
com.refinedmods.refinedstorage.common.api.RefinedStorageApi.INSTANCE.getStorageRepository(sp.serverLevel());
|
||||
|
||||
for (UUID uuid : diskUuids) {
|
||||
// Check if storage already exists on this server (don't overwrite)
|
||||
if (repo.get(uuid).isPresent()) {
|
||||
PlayerSync.LOGGER.debug("RS2 storage {} already exists on this server, skipping restore", uuid);
|
||||
continue;
|
||||
}
|
||||
|
||||
// FIX: Always overwrite with DB data (source of truth). Previously skipped if storage
|
||||
// existed locally, causing stale data to persist when a player modified a disk on
|
||||
// another server and came back.
|
||||
restoreStorageContents(uuid, (nbt) -> {
|
||||
try {
|
||||
injectRS2StorageEntry(repo, nbt, sp);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user