The server itself: repo now mirrors the machine it runs on, plus install.sh

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>
This commit is contained in:
Claude Opus 5
2026-08-12 23:23:33 +00:00
co-authored by Claude Opus 5
parent 80c1d75d2f
commit 71856b15e9
66 changed files with 2030 additions and 3814 deletions
+111
View File
@@ -0,0 +1,111 @@
-- ---------------------------------------------------------------------------
-- Small shared helpers. Kept deliberately thin.
-- ---------------------------------------------------------------------------
Util = {}
function Util.trim(s)
if type(s) ~= 'string' then return '' end
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end
function Util.clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
function Util.round(v, places)
local m = 10 ^ (places or 0)
return math.floor(v * m + 0.5) / m
end
--- Deep copy; cycles are not expected in config/state tables.
function Util.copy(t)
if type(t) ~= 'table' then return t end
local out = {}
for k, v in pairs(t) do out[k] = Util.copy(v) end
return out
end
function Util.count(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
--- Group separated money, e.g. 1234567 -> "1,234,567"
function Util.money(n)
local s = tostring(math.floor(math.abs(n or 0)))
local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse()
out = out:gsub('^,', '')
return (n or 0) < 0 and ('-' .. out) or out
end
--- Names are stored capitalised regardless of how they were typed.
function Util.properName(s)
s = Util.trim(s):lower()
return (s:gsub("([^%s'-]+)", function(word)
return word:sub(1, 1):upper() .. word:sub(2)
end))
end
--- Validation shared by client (live feedback) and server (enforcement), so
--- the two can never disagree about what is acceptable.
Rules = {}
Rules.username = {
min = 3, max = 20,
pattern = '^[%w_]+$',
hint = '3-20 characters, letters, numbers and underscore only',
}
Rules.password = {
min = 8, max = 72,
hint = 'at least 8 characters, with a letter and a number',
}
Rules.name = {
min = 2, max = 20,
pattern = "^[%a][%a'%-]*$",
hint = "letters, apostrophes and hyphens",
}
function Rules.checkUsername(v)
v = Util.trim(v or '')
if #v < Rules.username.min then return false, 'Too short - ' .. Rules.username.hint end
if #v > Rules.username.max then return false, 'Too long - ' .. Rules.username.hint end
if not v:match(Rules.username.pattern) then return false, 'Only letters, numbers and underscore' end
return true
end
function Rules.checkPassword(v)
v = v or ''
if #v < Rules.password.min then return false, 'At least 8 characters' end
if #v > Rules.password.max then return false, 'Too long' end
if not v:match('%a') then return false, 'Needs at least one letter' end
if not v:match('%d') then return false, 'Needs at least one number' end
return true
end
function Rules.checkName(v)
v = Util.trim(v or '')
if #v < Rules.name.min then return false, 'Too short' end
if #v > Rules.name.max then return false, 'Too long' end
if not v:match(Rules.name.pattern) then return false, "Letters, ' and - only" end
return true
end
--- Date of birth: accepts YYYY-MM-DD, must be a real date and an adult age.
function Rules.checkDob(v)
v = Util.trim(v or '')
local y, m, d = v:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
if not y then return false, 'Use YYYY-MM-DD' end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if m < 1 or m > 12 then return false, 'Month must be 01-12' end
local mdays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
if (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0 then mdays[2] = 29 end
if d < 1 or d > mdays[m] then return false, 'That day does not exist' end
if y < 1930 or y > 2010 then return false, 'Year must be between 1930 and 2010' end
return true
end