Files
redl-gamepanel/server-code/data/resources/[rp]/rp_session/server/main.lua
T
redlandClaude Fable 5 80c1d75d2f Part 1 final build: Los Santos RP written on camera by the agent
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>
2026-08-06 19:27:48 +00:00

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)