rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db), county-records NUI: residency-file auth, identity-record character intake, survey-grid spawn, procedural night-city loading screen. Secrets replaced with .example files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
439 lines
16 KiB
Lua
439 lines
16 KiB
Lua
-- ---------------------------------------------------------------------------
|
|
-- rp_session - everything between "player opened the server" and "player is
|
|
-- standing in the world": ban gate, accounts, characters, spawn.
|
|
--
|
|
-- The client drives the UI, but every decision is made here. The client may
|
|
-- only ever ask; it can never assert that it is logged in, that a character
|
|
-- belongs to it, or where it spawned.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local sessions = {} -- [src] = { license, ip, accountId, username, role, stage }
|
|
|
|
-- This build of FXServer ships no `hardcap` resource (it only advertises the
|
|
-- name in /info.json), and the bundled `chat` and `monitor` resources are not
|
|
-- started, so capacity has to be enforced here - see the deferral below.
|
|
-- Kept as a guard in case a future artefact does ship one.
|
|
CreateThread(function()
|
|
if GetResourceState('hardcap') == 'started' then
|
|
StopResource('hardcap')
|
|
print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource')
|
|
end
|
|
end)
|
|
|
|
local STAGE_AUTH = 'auth'
|
|
local STAGE_CHARS = 'characters'
|
|
local STAGE_SPAWN = 'spawn'
|
|
local STAGE_LIVE = 'live'
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- helpers
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function licenseOf(src)
|
|
for i = 0, GetNumPlayerIdentifiers(src) - 1 do
|
|
local id = GetPlayerIdentifier(src, i)
|
|
if id and id:sub(1, 8) == 'license:' then return id:sub(9) end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function hashPassword(pw)
|
|
local p = promise.new()
|
|
exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end)
|
|
local r = Citizen.Await(p)
|
|
return r.hash, r.err
|
|
end
|
|
|
|
local function verifyPassword(pw, stored)
|
|
local p = promise.new()
|
|
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
|
|
local r = Citizen.Await(p)
|
|
if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end
|
|
return r.ok == true
|
|
end
|
|
|
|
local function recordAttempt(identity, ip, success)
|
|
DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)',
|
|
{ identity, ip or DB.NULL, success and 1 or 0 })
|
|
end
|
|
|
|
--- True when this identity has burned through its allowance recently.
|
|
local function throttled(identity)
|
|
local n = DB.Scalar(
|
|
'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)',
|
|
{ identity, Config.Session.attemptWindowS })
|
|
return (tonumber(n) or 0) >= Config.Session.maxAttempts
|
|
end
|
|
|
|
local function characterRows(accountId)
|
|
return DB.Query([[
|
|
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
|
|
cash, bank, job, job_grade, playtime, last_played_at
|
|
FROM characters
|
|
WHERE account_id = ? AND deleted = 0
|
|
ORDER BY slot ASC, id ASC
|
|
]], { accountId }) or {}
|
|
end
|
|
|
|
--- Trim a DB row down to what the character-select screen is allowed to see.
|
|
local function characterCard(row)
|
|
return {
|
|
id = row.id,
|
|
firstName = row.first_name,
|
|
lastName = row.last_name,
|
|
dob = tostring(row.dob):sub(1, 10),
|
|
gender = row.gender,
|
|
backstory = row.backstory,
|
|
cash = math.floor(tonumber(row.cash) or 0),
|
|
bank = math.floor(tonumber(row.bank) or 0),
|
|
job = row.job,
|
|
playtime = math.floor(tonumber(row.playtime) or 0),
|
|
lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil,
|
|
appearance= row.appearance and json.decode(row.appearance) or nil,
|
|
}
|
|
end
|
|
|
|
local function sessionOf(src, requiredStage)
|
|
local s = sessions[src]
|
|
if not s then return nil, 'no session' end
|
|
if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end
|
|
return s
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- connection gate
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
AddEventHandler('playerConnecting', function(_, _, deferrals)
|
|
local src = source
|
|
deferrals.defer()
|
|
Wait(0)
|
|
|
|
local license = licenseOf(src)
|
|
if not license then
|
|
return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.')
|
|
end
|
|
|
|
-- Capacity. This replaces the stock `hardcap` resource, which is stopped in
|
|
-- server.cfg so that everything running here is our own code.
|
|
local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48
|
|
local online = #GetPlayers() -- the joining player is not counted yet
|
|
if online >= maxClients then
|
|
return deferrals.done(
|
|
('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients))
|
|
end
|
|
|
|
deferrals.update('Checking your record...')
|
|
|
|
if not DB.WaitReady(15000) then
|
|
return deferrals.done('The server database is not available right now. Try again in a minute.')
|
|
end
|
|
|
|
local ban = DB.Single([[
|
|
SELECT id, ban_reason, ban_until
|
|
FROM accounts
|
|
WHERE license = ? AND banned = 1
|
|
AND (ban_until IS NULL OR ban_until > NOW())
|
|
LIMIT 1
|
|
]], { license })
|
|
|
|
if ban then
|
|
local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently'
|
|
return deferrals.done(('You are banned%s.\n\nReason: %s')
|
|
:format(until_, ban.ban_reason or 'no reason recorded'))
|
|
end
|
|
|
|
-- clear bans that have run out so the account is usable again
|
|
DB.UpdateAsync([[
|
|
UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL
|
|
WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW()
|
|
]], { license })
|
|
|
|
sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH }
|
|
deferrals.done()
|
|
end)
|
|
|
|
AddEventHandler('playerDropped', function()
|
|
sessions[source] = nil
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- callbacks: authentication
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
Core.RegisterCallback('session:state', function(src)
|
|
local s = sessions[src]
|
|
if not s then return nil, 'no session' end
|
|
return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars }
|
|
end)
|
|
|
|
Core.RegisterCallback('auth:register', function(src, data)
|
|
local s, e = sessionOf(src, STAGE_AUTH)
|
|
if not s then return nil, e end
|
|
if type(data) ~= 'table' then return nil, 'malformed request' end
|
|
|
|
local username = Util.trim(data.username or '')
|
|
local password = data.password or ''
|
|
|
|
local ok, why = Rules.checkUsername(username)
|
|
if not ok then return nil, why end
|
|
ok, why = Rules.checkPassword(password)
|
|
if not ok then return nil, why end
|
|
|
|
if throttled(s.license) then
|
|
return nil, 'Too many attempts. Wait a few minutes and try again.'
|
|
end
|
|
|
|
local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username })
|
|
if taken then
|
|
recordAttempt(s.license, s.ip, false)
|
|
return nil, 'That username is already taken'
|
|
end
|
|
|
|
local hash, herr = hashPassword(password)
|
|
if not hash then
|
|
print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr)))
|
|
return nil, 'Could not create the account, try again'
|
|
end
|
|
|
|
local id, ierr = DB.Insert(
|
|
'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)',
|
|
{ username, hash, s.license, s.ip or DB.NULL })
|
|
|
|
if not id then
|
|
-- unique key race: someone registered the same name a moment ago
|
|
print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr)))
|
|
return nil, 'That username is already taken'
|
|
end
|
|
|
|
recordAttempt(s.license, s.ip, true)
|
|
|
|
s.accountId = id
|
|
s.username = username
|
|
s.role = 'player'
|
|
s.stage = STAGE_CHARS
|
|
|
|
exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license })
|
|
print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src)))
|
|
|
|
return { username = username, characters = {}, maxChars = Config.MaxChars }
|
|
end)
|
|
|
|
Core.RegisterCallback('auth:login', function(src, data)
|
|
local s, e = sessionOf(src, STAGE_AUTH)
|
|
if not s then return nil, e end
|
|
if type(data) ~= 'table' then return nil, 'malformed request' end
|
|
|
|
local username = Util.trim(data.username or '')
|
|
local password = data.password or ''
|
|
if username == '' or password == '' then return nil, 'Enter your username and password' end
|
|
|
|
if throttled(s.license) then
|
|
return nil, 'Too many failed attempts. Wait a few minutes and try again.'
|
|
end
|
|
|
|
local acc = DB.Single(
|
|
'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1',
|
|
{ username })
|
|
|
|
-- Always run a verification so a missing username and a wrong password take
|
|
-- the same amount of time and cannot be told apart from outside.
|
|
local stored = acc and acc.password_hash
|
|
or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
|
local good = verifyPassword(password, stored)
|
|
|
|
if not acc or not good then
|
|
recordAttempt(s.license, s.ip, false)
|
|
return nil, 'Incorrect username or password'
|
|
end
|
|
|
|
if acc.banned == 1 then
|
|
-- re-checked against NOW() so an expired ban does not keep locking them out
|
|
local active = DB.Scalar(
|
|
'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())',
|
|
{ acc.id })
|
|
if active == 1 then
|
|
return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded')
|
|
end
|
|
end
|
|
|
|
recordAttempt(s.license, s.ip, true)
|
|
|
|
DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?',
|
|
{ s.ip or DB.NULL, s.license, acc.id })
|
|
|
|
s.accountId = acc.id
|
|
s.username = acc.username
|
|
s.role = acc.role
|
|
s.stage = STAGE_CHARS
|
|
|
|
exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license })
|
|
|
|
local cards = {}
|
|
for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end
|
|
|
|
print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username))
|
|
return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars }
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- callbacks: characters
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
Core.RegisterCallback('char:list', function(src)
|
|
local s, e = sessionOf(src, STAGE_CHARS)
|
|
if not s then return nil, e end
|
|
local cards = {}
|
|
for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end
|
|
return { characters = cards, maxChars = Config.MaxChars }
|
|
end)
|
|
|
|
Core.RegisterCallback('char:create', function(src, data)
|
|
local s, e = sessionOf(src, STAGE_CHARS)
|
|
if not s then return nil, e end
|
|
if type(data) ~= 'table' then return nil, 'malformed request' end
|
|
|
|
local first = Util.properName(data.firstName or '')
|
|
local last = Util.properName(data.lastName or '')
|
|
|
|
local ok, why = Rules.checkName(first)
|
|
if not ok then return nil, 'First name: ' .. why end
|
|
ok, why = Rules.checkName(last)
|
|
if not ok then return nil, 'Last name: ' .. why end
|
|
ok, why = Rules.checkDob(data.dob or '')
|
|
if not ok then return nil, 'Date of birth: ' .. why end
|
|
|
|
local gender = (data.gender == 'f') and 'f' or 'm'
|
|
local backstory = Util.trim(data.backstory or '')
|
|
if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end
|
|
|
|
local existing = tonumber(DB.Scalar(
|
|
'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0
|
|
if existing >= Config.MaxChars then
|
|
return nil, ('You already have %d characters'):format(Config.MaxChars)
|
|
end
|
|
|
|
local clash = DB.Scalar(
|
|
'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last })
|
|
if clash then return nil, 'Someone on this server already has that name' end
|
|
|
|
local appearance = Appearance.Sanitise(data.appearance)
|
|
|
|
local id, ierr = DB.Insert([[
|
|
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory,
|
|
appearance, cash, bank)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
]], {
|
|
s.accountId, existing, first, last, data.dob, gender,
|
|
backstory ~= '' and backstory or DB.NULL,
|
|
json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank,
|
|
})
|
|
|
|
if not id then
|
|
print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr)))
|
|
return nil, 'Could not create that character'
|
|
end
|
|
|
|
print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id))
|
|
|
|
local row = DB.Single([[
|
|
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
|
|
cash, bank, job, job_grade, playtime, last_played_at
|
|
FROM characters WHERE id = ?
|
|
]], { id })
|
|
|
|
return { character = characterCard(row) }
|
|
end)
|
|
|
|
Core.RegisterCallback('char:delete', function(src, data)
|
|
local s, e = sessionOf(src, STAGE_CHARS)
|
|
if not s then return nil, e end
|
|
local id = tonumber(type(data) == 'table' and data.id or nil)
|
|
if not id then return nil, 'malformed request' end
|
|
|
|
-- ownership is checked in the WHERE clause, so a forged id changes nothing
|
|
local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0',
|
|
{ id, s.accountId })
|
|
if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end
|
|
|
|
print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id))
|
|
return { deleted = id }
|
|
end)
|
|
|
|
Core.RegisterCallback('char:select', function(src, data)
|
|
local s, e = sessionOf(src, STAGE_CHARS)
|
|
if not s then return nil, e end
|
|
local id = tonumber(type(data) == 'table' and data.id or nil)
|
|
if not id then return nil, 'malformed request' end
|
|
|
|
local row = DB.Single([[
|
|
SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0
|
|
]], { id, s.accountId })
|
|
if not row then return nil, 'That character is not yours' end
|
|
|
|
exports.rp_core:attachCharacter(src, row)
|
|
s.stage = STAGE_SPAWN
|
|
s.charId = id
|
|
|
|
local last = row.position and json.decode(row.position) or nil
|
|
local spawns = {}
|
|
for _, sp in ipairs(Config.SpawnPoints) do
|
|
spawns[#spawns + 1] = {
|
|
id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map,
|
|
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w },
|
|
cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z },
|
|
look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } },
|
|
}
|
|
end
|
|
|
|
return {
|
|
character = characterCard(row),
|
|
appearance = row.appearance and json.decode(row.appearance) or nil,
|
|
spawns = spawns,
|
|
lastPosition = last,
|
|
}
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- callbacks: spawn
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
Core.RegisterCallback('spawn:confirm', function(src, data)
|
|
local s, e = sessionOf(src, STAGE_SPAWN)
|
|
if not s then return nil, e end
|
|
|
|
local choice = type(data) == 'table' and data.id or nil
|
|
local coords
|
|
|
|
if choice == 'last' then
|
|
local p = exports.rp_core:getCharacter(src)
|
|
local last = p and p.position
|
|
if last and last.x then
|
|
coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 }
|
|
end
|
|
end
|
|
|
|
if not coords then
|
|
for _, sp in ipairs(Config.SpawnPoints) do
|
|
if sp.id == choice then
|
|
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if not coords then
|
|
local sp = Config.SpawnPoints[1]
|
|
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
|
|
end
|
|
|
|
s.stage = STAGE_LIVE
|
|
exports.rp_core:setSpawned(src, true)
|
|
|
|
local char = exports.rp_core:getCharacter(src)
|
|
print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName))
|
|
|
|
TriggerClientEvent('rp:session:spawned', src, coords)
|
|
return { coords = coords, character = char }
|
|
end)
|