diff --git a/[core]/es_extended/client/functions.lua b/[core]/es_extended/client/functions.lua index 535c24e7..b665f346 100644 --- a/[core]/es_extended/client/functions.lua +++ b/[core]/es_extended/client/functions.lua @@ -157,7 +157,7 @@ end ---@param position? string The position of the notification ---@return nil function ESX.ShowNotification(message, notifyType, length, title, position) - return IsResourceFound('esx_notify') and exports['esx_notify']:Notify(notifyType, length, message, title, position) + return IsResourceFound('esx_notify') and exports['esx_notify']:Notify(notifyType or "info", length or 5000, message, title, position) end function ESX.TextUI(...) diff --git a/[core]/es_extended/fxmanifest.lua b/[core]/es_extended/fxmanifest.lua index 4171d44a..3a04fea2 100644 --- a/[core]/es_extended/fxmanifest.lua +++ b/[core]/es_extended/fxmanifest.lua @@ -35,7 +35,9 @@ server_scripts { 'server/bridge/**/*.lua', 'server/modules/npwd.lua', - 'server/modules/createJob.lua' + 'server/modules/createJob.lua', + 'server/migration/**/main.lua', + 'server/migration/main.lua', } client_scripts { diff --git a/[core]/es_extended/server/classes/player.lua b/[core]/es_extended/server/classes/player.lua index ee8f0a3e..19e18088 100644 --- a/[core]/es_extended/server/classes/player.lua +++ b/[core]/es_extended/server/classes/player.lua @@ -43,6 +43,7 @@ ---@field hash string|number # Component hash or identifier. ---@class StaticPlayer +---@field src number # Player's server ID. --- Money Functions ---@field setMoney fun(money: number) # Set player's cash balance. ---@field getMoney fun(): number # Get player's current cash balance. diff --git a/[core]/es_extended/server/functions.lua b/[core]/es_extended/server/functions.lua index 9510ea71..e28920ba 100644 --- a/[core]/es_extended/server/functions.lua +++ b/[core]/es_extended/server/functions.lua @@ -826,8 +826,9 @@ function Core.IsPlayerAdmin(playerSrc) end -- Generates a unique 9-digit SSN in dashed format (XXX-XX-XXXX). +---@param skipUniqueCheck boolean? ---@return string -function Core.generateSSN() +function Core.generateSSN(skipUniqueCheck) local reservedSSNs = { ["078-05-1120"] = true, ["219-09-9999"] = true, @@ -860,6 +861,10 @@ function Core.generateSSN() goto continue end + if skipUniqueCheck then + return candidate + end + local exists = MySQL.scalar.await("SELECT 1 FROM `users` WHERE `ssn` = ? LIMIT 1", { candidate }) if not exists then diff --git a/[core]/es_extended/server/migration/main.lua b/[core]/es_extended/server/migration/main.lua new file mode 100644 index 00000000..378894fd --- /dev/null +++ b/[core]/es_extended/server/migration/main.lua @@ -0,0 +1,46 @@ +RegisterCommand("resetmigrations", function(src) + if src > 0 then + print("^1[ERROR]^7 This command can only be run from the server console.") + return + end + + for version, _ in pairs(Core.Migrations or {}) do + DeleteResourceKvp(("esx_migration:%s"):format(version)) + end + print("^2[SUCCESS]^7 Reset all migrations. This will re-run all migrations on the next server start.") +end) + +local migrationsRan = 0 +local restartRequired = false + +for esxVersion, migrations in pairs(Core.Migrations or {}) do + ---@cast esxVersion string + ---@cast migrations table + + if ESX.Table.SizeOf(migrations) > 0 then + print(("^4[INFO]^7 Running migrations for ESX version %s"):format(esxVersion)) + + for migrationName, migration in pairs(migrations) do + local success, result = pcall(migration) + if not success then + local err = result --[[@as string]] + error(("^1[ERROR]^7 Failed migration ^4['%s.%s']^7: %s"):format(esxVersion, migrationName, err)) + end + + local migrationRequiresRestart = result --[[@as boolean]] + + if not restartRequired and migrationRequiresRestart then + restartRequired = true + end + end + + SetResourceKvpInt(("esx_migration:%s"):format(esxVersion), 1) + print(("^2[SUCCESS]^7 Successfully completed migrations for ESX version %s"):format(esxVersion)) + migrationsRan += 1 + end +end + +while restartRequired do + print(("^4[INFO]^7 Ran migrations for %d ESX version(s). ^1Server restart required!^7"):format(migrationsRan)) + Wait(500) +end diff --git a/[core]/es_extended/server/migration/v1.13.3/ssn/main.lua b/[core]/es_extended/server/migration/v1.13.3/ssn/main.lua new file mode 100644 index 00000000..83b36e9b --- /dev/null +++ b/[core]/es_extended/server/migration/v1.13.3/ssn/main.lua @@ -0,0 +1,70 @@ +local esxVersion = "v1.13.3" + +Core.Migrations = Core.Migrations or {} +Core.Migrations[esxVersion] = Core.Migrations[esxVersion] or {} + +if GetResourceKvpInt(("esx_migration:%s"):format(esxVersion)) == 1 then + return +end + +---@return boolean restartRequired +Core.Migrations[esxVersion].ssn = function() + print("^4[esx_migration:v.1.13.3:ssn]^7 Adding SSN column to users table.") + local col = MySQL.scalar.await([[ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'ssn' +]]) + + local idx = MySQL.scalar.await([[ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND INDEX_NAME = 'unique_ssn' +]]) + + if col == 0 and idx == 0 then + MySQL.update.await([[ + ALTER TABLE `users` + ADD COLUMN `ssn` VARCHAR(11) NULL DEFAULT NULL AFTER `identifier`, + ADD UNIQUE KEY `unique_ssn` (`ssn`) + ]]) + elseif col == 0 then + MySQL.update.await("ALTER TABLE `users` ADD COLUMN `ssn` VARCHAR(11) NULL DEFAULT NULL AFTER `identifier`") + elseif idx == 0 then + MySQL.update.await("ALTER TABLE `users` ADD UNIQUE KEY `unique_ssn` (`ssn`)") + end + + + local Result = MySQL.query.await("SELECT `identifier` FROM `users` WHERE `ssn` IS NULL") + if #Result == 0 then + print("^4[esx_migration:v.1.13.3:ssn]^7 No users found without SSN, migration not needed.") + return false + end + + print("^4[esx_migration:v.1.13.3:ssn]^7 Generating SSN for existing users.") + local GeneratedSSNs = {} + local Parameters = {} + for i = 1, #Result do + local ssn + repeat + ssn = Core.generateSSN(true) + until not GeneratedSSNs[ssn] + + GeneratedSSNs[ssn] = true + Parameters[i] = { ssn, Result[i].identifier } + end + + print("^4[esx_migration:v.1.13.3:ssn]^7 Updating users with generated SSN. This may take a minute...") + MySQL.prepare.await("UPDATE `users` SET `ssn` = ? WHERE `identifier` = ?", Parameters) + + print("^4[esx_migration:v.1.13.3:ssn]^7 Removing SSN default value.") + MySQL.update.await("ALTER TABLE `users` MODIFY `ssn` VARCHAR(11) NOT NULL") + + print(("^4[esx_migration:v.1.13.3:ssn]^7 Successfully migrated %d users."):format(#Parameters)) + + return true +end diff --git a/[core]/esx_identity/server/main.lua b/[core]/esx_identity/server/main.lua index c7c8de76..584425b9 100644 --- a/[core]/esx_identity/server/main.lua +++ b/[core]/esx_identity/server/main.lua @@ -12,6 +12,8 @@ local function deleteIdentityFromDatabase(xPlayer) end end +---@param xPlayer StaticPlayer|xPlayer +---@param data {firstName:string?, lastName:string?, dateOfBirth:string?, height:number?, sex:"m"|"f"?} function SetPlayerData(xPlayer, data) local name = ("%s %s"):format(data.firstName, data.lastName) xPlayer.setName(name) @@ -21,7 +23,7 @@ function SetPlayerData(xPlayer, data) xPlayer.set("sex", data.sex) xPlayer.set("height", data.height) - local state = Player(xPlayer.source).state + local state = Player(xPlayer.getSource()).state state:set("name", name, true) state:set("firstName", data.firstName, true) state:set("lastName", data.lastName, true) @@ -30,12 +32,13 @@ function SetPlayerData(xPlayer, data) state:set("height", data.height, true) end +---@param xPlayer xPlayer local function deleteIdentity(xPlayer) if not alreadyRegistered[xPlayer.identifier] then return end - SetPlayerData(xPlayer, {firstName = nil, lastName = nil, dateOfBirth = nil, sex = nil, height = nil}) + SetPlayerData(xPlayer, { firstName = nil, lastName = nil, dateOfBirth = nil, sex = nil, height = nil }) deleteIdentityFromDatabase(xPlayer) end @@ -69,7 +72,7 @@ local function checkDOBFormat(dob) local minYear = currentYear - Config.MaxAge local maxYear = currentYear - 18 - if year < minYear or year > maxYear then return false end + if year < minYear or year > maxYear then return false end if month < 1 or month > 12 then return false end -- Days in each month (starting from January.) @@ -124,33 +127,76 @@ local function formatName(name) return convertFirstLetterToUpper(loweredName) end +---@param xPlayer StaticPlayer local function setIdentity(xPlayer) - if not alreadyRegistered[xPlayer.identifier] then - return - end - local currentIdentity = playerIdentity[xPlayer.identifier] - SetPlayerData(xPlayer, currentIdentity) + local playerIdentifier = xPlayer.getIdentifier() + if not alreadyRegistered[playerIdentifier] then + return + end + local currentIdentity = playerIdentity[playerIdentifier] + SetPlayerData(xPlayer, currentIdentity) - TriggerClientEvent("esx_identity:setPlayerData", xPlayer.source, currentIdentity) - if currentIdentity.saveToDatabase then - saveIdentityToDatabase(xPlayer.identifier, currentIdentity) - end + TriggerClientEvent("esx_identity:setPlayerData", xPlayer.src, currentIdentity) + if currentIdentity.saveToDatabase then + saveIdentityToDatabase(playerIdentifier, currentIdentity) + end - playerIdentity[xPlayer.identifier] = nil + playerIdentity[playerIdentifier] = nil end - local function checkIdentity(xPlayer) - MySQL.single("SELECT firstname, lastname, dateofbirth, sex, height FROM users WHERE identifier = ?", { xPlayer.identifier }, function(result) +---@param xPlayer StaticPlayer +local function checkIdentity(xPlayer) + local playerIdentifier = xPlayer.getIdentifier() + MySQL.single("SELECT firstname, lastname, dateofbirth, sex, height FROM users WHERE identifier = ?", { playerIdentifier }, function(result) + if not result then + return TriggerClientEvent("esx_identity:showRegisterIdentity", xPlayer.src) + end + if not result.firstname then + playerIdentity[playerIdentifier] = nil + alreadyRegistered[playerIdentifier] = false + return TriggerClientEvent("esx_identity:showRegisterIdentity", xPlayer.src) + end + + playerIdentity[playerIdentifier] = { + firstName = result.firstname, + lastName = result.lastname, + dateOfBirth = result.dateofbirth, + sex = result.sex, + height = result.height, + } + + alreadyRegistered[playerIdentifier] = true + setIdentity(xPlayer) + end) +end + +if not multichar then + AddEventHandler("playerConnecting", function(_, _, deferrals) + deferrals.defer() + local _, identifier = source, nil + + local correctLicense, _ = pcall(function() + identifier = ESX.GetIdentifier(source) + end) + + Wait(40) + + if not identifier or not correctLicense then + return deferrals.done(TranslateCap("no_identifier")) + end + MySQL.single("SELECT firstname, lastname, dateofbirth, sex, height FROM users WHERE identifier = ?", { identifier }, function(result) if not result then - return TriggerClientEvent("esx_identity:showRegisterIdentity", xPlayer.source) + playerIdentity[identifier] = nil + alreadyRegistered[identifier] = false + return deferrals.done() end if not result.firstname then - playerIdentity[xPlayer.identifier] = nil - alreadyRegistered[xPlayer.identifier] = false - return TriggerClientEvent("esx_identity:showRegisterIdentity", xPlayer.source) + playerIdentity[identifier] = nil + alreadyRegistered[identifier] = false + return deferrals.done() end - playerIdentity[xPlayer.identifier] = { + playerIdentity[identifier] = { firstName = result.firstname, lastName = result.lastname, dateOfBirth = result.dateofbirth, @@ -158,166 +204,127 @@ end height = result.height, } - alreadyRegistered[xPlayer.identifier] = true - setIdentity(xPlayer) + alreadyRegistered[identifier] = true + + deferrals.done() end) - end + end) - if not multichar then - AddEventHandler("playerConnecting", function(_, _, deferrals) - deferrals.defer() - local _, identifier = source, nil - - local correctLicense, _ = pcall(function() - identifier = ESX.GetIdentifier(source) - end) - - Wait(40) - - if not identifier or not correctLicense then - return deferrals.done(TranslateCap("no_identifier")) - end - MySQL.single("SELECT firstname, lastname, dateofbirth, sex, height FROM users WHERE identifier = ?", { identifier }, function(result) - if not result then - playerIdentity[identifier] = nil - alreadyRegistered[identifier] = false - return deferrals.done() - end - if not result.firstname then - playerIdentity[identifier] = nil - alreadyRegistered[identifier] = false - return deferrals.done() - end - - playerIdentity[identifier] = { - firstName = result.firstname, - lastName = result.lastname, - dateOfBirth = result.dateofbirth, - sex = result.sex, - height = result.height, - } - - alreadyRegistered[identifier] = true - - deferrals.done() - end) - end) - - AddEventHandler("onResourceStart", function(resource) - if resource ~= GetCurrentResourceName() then - return - end - Wait(300) - - while not ESX do - Wait(0) - end - - local xPlayers = ESX.GetExtendedPlayers() - - for i = 1, #xPlayers do - if xPlayers[i] then - checkIdentity(xPlayers[i]) - end - end - end) - - RegisterNetEvent("esx:playerLoaded", function(_, xPlayer) - local currentIdentity = playerIdentity[xPlayer.identifier] - - if currentIdentity and alreadyRegistered[xPlayer.identifier] then - SetPlayerData(xPlayer, currentIdentity) - - TriggerClientEvent("esx_identity:setPlayerData", xPlayer.source, currentIdentity) - if currentIdentity.saveToDatabase then - saveIdentityToDatabase(xPlayer.identifier, currentIdentity) - end - - Wait(0) - - TriggerClientEvent("esx_identity:alreadyRegistered", xPlayer.source) - - playerIdentity[xPlayer.identifier] = nil - else - TriggerClientEvent("esx_identity:showRegisterIdentity", xPlayer.source) - end - end) - end - - ESX.RegisterServerCallback("esx_identity:registerIdentity", function(source, cb, data) - local xPlayer = ESX.Player(source) - - if not checkNameFormat(data.firstname) then - TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_firstname_format"), "error") - return cb(false) + AddEventHandler("onResourceStart", function(resource) + if resource ~= GetCurrentResourceName() then + return end - if not checkNameFormat(data.lastname) then - TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_lastname_format"), "error") - return cb(false) - end - if not checkSexFormat(data.sex) then - TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_sex_format"), "error") - return cb(false) - end - if not checkDOBFormat(data.dateofbirth) then - TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_dob_format"), "error") - return cb(false) - end - if not checkHeightFormat(data.height) then - TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_height_format"), "error") - return cb(false) + Wait(300) + + while not ESX do + Wait(0) end - if xPlayer then - local identifier = xPlayer.getIdentifier() - if alreadyRegistered[identifier] then - xPlayer.showNotification(TranslateCap("already_registered"), "error") - return cb(false) + local xPlayers = ESX.ExtendedPlayers() --[=[@as StaticPlayer[]]=] + + for i = 1, #xPlayers do + if xPlayers[i] then + checkIdentity(xPlayers[i]) end + end + end) - playerIdentity[identifier] = { - firstName = formatName(data.firstname), - lastName = formatName(data.lastname), - dateOfBirth = formatDate(data.dateofbirth), - sex = data.sex, - height = data.height, - } - - local currentIdentity = playerIdentity[identifier] + RegisterNetEvent("esx:playerLoaded", function(_, xPlayer) + local currentIdentity = playerIdentity[xPlayer.identifier] + if currentIdentity and alreadyRegistered[xPlayer.identifier] then SetPlayerData(xPlayer, currentIdentity) - TriggerClientEvent("esx_identity:setPlayerData", xPlayer.src, currentIdentity) - saveIdentityToDatabase(identifier, currentIdentity) - alreadyRegistered[identifier] = true - playerIdentity[identifier] = nil - return cb(true) - end + TriggerClientEvent("esx_identity:setPlayerData", xPlayer.source, currentIdentity) + if currentIdentity.saveToDatabase then + saveIdentityToDatabase(xPlayer.identifier, currentIdentity) + end - if not multichar then - TriggerClientEvent("esx:showNotification", source, TranslateCap("data_incorrect"), "error") + Wait(0) + + TriggerClientEvent("esx_identity:alreadyRegistered", xPlayer.source) + + playerIdentity[xPlayer.identifier] = nil + else + TriggerClientEvent("esx_identity:showRegisterIdentity", xPlayer.source) + end + end) +end + +ESX.RegisterServerCallback("esx_identity:registerIdentity", function(source, cb, data) + local xPlayer = ESX.Player(source) + + if not checkNameFormat(data.firstname) then + TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_firstname_format"), "error") + return cb(false) + end + if not checkNameFormat(data.lastname) then + TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_lastname_format"), "error") + return cb(false) + end + if not checkSexFormat(data.sex) then + TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_sex_format"), "error") + return cb(false) + end + if not checkDOBFormat(data.dateofbirth) then + TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_dob_format"), "error") + return cb(false) + end + if not checkHeightFormat(data.height) then + TriggerClientEvent("esx:showNotification", source, TranslateCap("invalid_height_format"), "error") + return cb(false) + end + + if xPlayer then + local identifier = xPlayer.getIdentifier() + if alreadyRegistered[identifier] then + xPlayer.showNotification(TranslateCap("already_registered"), "error") return cb(false) end - local formattedFirstName = formatName(data.firstname) - local formattedLastName = formatName(data.lastname) - local formattedDate = formatDate(data.dateofbirth) - - data.firstname = formattedFirstName - data.lastname = formattedLastName - data.dateofbirth = formattedDate - local Identity = { - firstName = formattedFirstName, - lastName = formattedLastName, - dateOfBirth = formattedDate, + playerIdentity[identifier] = { + firstName = formatName(data.firstname), + lastName = formatName(data.lastname), + dateOfBirth = formatDate(data.dateofbirth), sex = data.sex, height = data.height, } - TriggerEvent("esx_identity:completedRegistration", source, data) - TriggerClientEvent("esx_identity:setPlayerData", source, Identity) - cb(true) - end) + local currentIdentity = playerIdentity[identifier] + + SetPlayerData(xPlayer, currentIdentity) + + TriggerClientEvent("esx_identity:setPlayerData", xPlayer.src, currentIdentity) + saveIdentityToDatabase(identifier, currentIdentity) + alreadyRegistered[identifier] = true + playerIdentity[identifier] = nil + return cb(true) + end + + if not multichar then + TriggerClientEvent("esx:showNotification", source, TranslateCap("data_incorrect"), "error") + return cb(false) + end + + local formattedFirstName = formatName(data.firstname) + local formattedLastName = formatName(data.lastname) + local formattedDate = formatDate(data.dateofbirth) + + data.firstname = formattedFirstName + data.lastname = formattedLastName + data.dateofbirth = formattedDate + local Identity = { + firstName = formattedFirstName, + lastName = formattedLastName, + dateOfBirth = formattedDate, + sex = data.sex, + height = data.height, + } + + TriggerEvent("esx_identity:completedRegistration", source, data) + TriggerClientEvent("esx_identity:setPlayerData", source, Identity) + cb(true) +end) if Config.EnableCommands then ESX.RegisterCommand("char", "user", function(xPlayer)