The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.
Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):
resources/[rp]/ rp_db, rp_core, rp_session, rp_loading, rp_ui,
rp_selftest, rp_dbtest
bin/ supervise.sh (keep-alive + console FIFO), rcon, init script
etc/schema.sql accounts, characters, transactions, inventory, vehicles
assets/fonts/ the bundled subsets
docs/SERVER.md how it is put together, resource by resource
install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.
No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
5.8 KiB
Lua
123 lines
5.8 KiB
Lua
-- Self-test for the hand-written MySQL driver. Run with: ensure rp_dbtest
|
|
|
|
local passed, failed = 0, 0
|
|
|
|
local function check(name, ok, detail)
|
|
if ok then
|
|
passed = passed + 1
|
|
print(('^2 PASS^7 %s'):format(name))
|
|
else
|
|
failed = failed + 1
|
|
print(('^1 FAIL^7 %s^1 %s^7'):format(name, detail or ''))
|
|
end
|
|
end
|
|
|
|
CreateThread(function()
|
|
print('^5[dbtest]^7 waiting for the pool...')
|
|
if not DB.WaitReady(30000) then
|
|
print('^1[dbtest] database never became ready^7')
|
|
return
|
|
end
|
|
|
|
-- 1. round trip through the text protocol -------------------------------
|
|
local two = DB.Scalar('SELECT 1 + 1')
|
|
check('scalar arithmetic', two == 2, ('got %s (%s)'):format(tostring(two), type(two)))
|
|
|
|
local row = DB.Single('SELECT ? AS s, ? AS n, ? AS f', { 'hello', 42, 1.5 })
|
|
check('typed columns', row and row.s == 'hello' and row.n == 42 and row.f == 1.5,
|
|
row and json.encode(row) or 'nil')
|
|
|
|
-- 2. hostile strings must survive verbatim ------------------------------
|
|
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
|
|
|
|
local nasty = [[O'Brien \ "quoted" ]] .. '\n\t' .. [[ 100% ` ; -- /* */ ?]]
|
|
local id = DB.Insert(
|
|
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
|
|
{ 'ztest_a', nasty })
|
|
check('insert returns id', type(id) == 'number' and id > 0, tostring(id))
|
|
|
|
local back = DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id })
|
|
check('hostile string round trip', back == nasty,
|
|
('stored %q'):format(tostring(back)))
|
|
|
|
-- 3. a parameter can never become SQL -----------------------------------
|
|
local inject = "'); DROP TABLE accounts; -- "
|
|
DB.Insert('INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
|
|
{ 'ztest_b', inject })
|
|
local stillThere = DB.Scalar(
|
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'accounts'")
|
|
check('injection is inert', stillThere == 1, 'accounts table missing!')
|
|
local storedInject = DB.Scalar("SELECT password_hash FROM accounts WHERE username = 'ztest_b'")
|
|
check('injection stored literally', storedInject == inject, tostring(storedInject))
|
|
|
|
-- 4. question marks inside literals must not shift binding ---------------
|
|
local q = DB.Single("SELECT 'what? really?' AS lit, ? AS bound", { 'bound-value' })
|
|
check('literal ? not treated as placeholder',
|
|
q and q.lit == 'what? really?' and q.bound == 'bound-value',
|
|
q and json.encode(q) or 'nil')
|
|
|
|
-- 5. NULL handling -------------------------------------------------------
|
|
DB.Update('UPDATE accounts SET last_ip = ? WHERE id = ?', { DB.NULL, id })
|
|
local isNull = DB.Scalar('SELECT last_ip IS NULL FROM accounts WHERE id = ?', { id })
|
|
check('DB.NULL writes a real NULL', isNull == 1, tostring(isNull))
|
|
local nilRow = DB.Single('SELECT last_ip FROM accounts WHERE id = ?', { id })
|
|
check('NULL reads back as nil', nilRow ~= nil and nilRow.last_ip == nil,
|
|
nilRow and json.encode(nilRow) or 'nil')
|
|
|
|
-- 6. unicode -------------------------------------------------------------
|
|
local uni = 'Ünïcødé — 日本語 — 🚓'
|
|
DB.Update('UPDATE accounts SET password_hash = ? WHERE id = ?', { uni, id })
|
|
check('utf8mb4 round trip', DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id }) == uni)
|
|
|
|
-- 7. empty result sets ---------------------------------------------------
|
|
local none = DB.Query('SELECT * FROM accounts WHERE id = ?', { -1 })
|
|
check('empty select returns empty table', type(none) == 'table' and #none == 0)
|
|
check('missing single returns nil', DB.Single('SELECT * FROM accounts WHERE id = ?', { -1 }) == nil)
|
|
|
|
-- 8. transactions --------------------------------------------------------
|
|
local ok = DB.Transaction({
|
|
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'admin', id } },
|
|
{ query = 'UPDATE accounts SET banned = ? WHERE id = ?', values = { 1, id } },
|
|
})
|
|
local after = DB.Single('SELECT role, banned FROM accounts WHERE id = ?', { id })
|
|
check('transaction commits', ok and after.role == 'admin' and after.banned == 1,
|
|
json.encode(after or {}))
|
|
|
|
local bad, err = DB.Transaction({
|
|
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'mod', id } },
|
|
{ query = 'UPDATE accounts SET nonexistent_column = 1 WHERE id = ?', values = { id } },
|
|
})
|
|
local rolled = DB.Scalar('SELECT role FROM accounts WHERE id = ?', { id })
|
|
check('failed transaction rolls back', bad == false and rolled == 'admin',
|
|
('role is now %s'):format(tostring(rolled)))
|
|
|
|
-- 9. errors surface instead of failing silently --------------------------
|
|
local res, qerr = DB.Query('SELECT * FROM table_that_does_not_exist')
|
|
check('bad query returns an error', res == nil and type(qerr) == 'string' and #qerr > 0,
|
|
tostring(qerr))
|
|
|
|
-- 10. wrong parameter count is caught, not guessed -----------------------
|
|
local _, cerr = DB.Query('SELECT ?, ?', { 1 })
|
|
check('parameter count mismatch reported', cerr ~= nil, tostring(cerr))
|
|
|
|
-- 11. a large payload exercises multi-packet framing in both directions --
|
|
local big = string.rep('x', 700000)
|
|
check('700KB payload round trip', DB.Scalar('SELECT ? AS big', { big }) == big)
|
|
|
|
-- 12. concurrency: many queries in flight across the pool ----------------
|
|
local done, results = 0, {}
|
|
for i = 1, 40 do
|
|
DB.ScalarAsync('SELECT ?', { i }, function(v) done = done + 1; results[i] = v end)
|
|
end
|
|
local waited = 0
|
|
while done < 40 and waited < 10000 do Wait(50); waited = waited + 50 end
|
|
local allGood = done == 40
|
|
for i = 1, 40 do if results[i] ~= i then allGood = false end end
|
|
check('40 concurrent queries all answered correctly', allGood, ('done=%d'):format(done))
|
|
|
|
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
|
|
|
|
print(('^5[dbtest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
|
|
if failed == 0 then print('^2[dbtest] driver OK^7') else print('^1[dbtest] DRIVER HAS PROBLEMS^7') end
|
|
end)
|