FiveRP: the server from the hosting account, not the earlier one

Wrong server was published before. This is the one that runs on the
hosting account the video was made on: FiveRP - two-step registration
(account, then an identity printed onto a passport), login that returns
you to your resident, and a loading screen driven by the game's own
streaming events.

  resources/[local]/fiverp-auth          NUI + scrypt hashes + oxmysql
  resources/[local]/fiverp-characters    identity, character load, spawn
  resources/[local]/fiverp-loadscreen    loading screen
  sql/schema.sql                         accounts, characters
  docs/DESIGN.md                         the design system every screen follows
  docs/screens/                          shot from the live server

Only our own code is in here. cfx-server-data and oxmysql are fetched by
install.sh instead of being vendored, which keeps the repo at 3.7 MB.

install.sh does the whole box in one command: recommended FXServer
build, cfx-server-data, oxmysql, MariaDB with the schema, resources,
server.cfg (mode 600 - it carries the key and the database password),
boot entry, then it waits for the Cfx registration. Tested end to end on
a spare install root: 29 resources scanned, database and schema created,
and it stopped exactly where a wrong licence key should stop it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 5
2026-08-12 23:42:20 +00:00
co-authored by Claude Opus 5
parent 71856b15e9
commit c857979a55
88 changed files with 3904 additions and 7463 deletions
+391
View File
@@ -0,0 +1,391 @@
(() => {
'use strict';
const RESOURCE = 'fiverp-auth';
const stage = document.getElementById('stage');
const slab = document.getElementById('slab');
const railFill = document.getElementById('railFill');
const permit = document.getElementById('permit');
const sheen = document.getElementById('sheen');
const seal = document.getElementById('seal');
const cardNo = document.getElementById('cardNo');
const cardFirst = document.getElementById('cardFirst');
const cardLast = document.getElementById('cardLast');
const cardDob = document.getElementById('cardDob');
const cardIssue = document.getElementById('cardIssue');
const cardExpiry = document.getElementById('cardExpiry');
const cardSign = document.getElementById('cardSign');
const mrz1 = document.getElementById('mrz1');
const mrz2 = document.getElementById('mrz2');
const stampDate = document.getElementById('stampDate');
const views = {
login: document.getElementById('formLogin'),
account: document.getElementById('formAccount'),
identity: document.getElementById('formIdentity')
};
let current = 'login';
let busy = false;
// Set when sign-in finds an account whose registration never reached step 2.
let resuming = false;
// ------------------------------------------------------------------ post
function post(endpoint, data) {
return fetch(`https://${RESOURCE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
// ------------------------------------------------------------------ views
function show(view) {
current = view;
slab.dataset.view = view;
Object.entries(views).forEach(([name, form]) => {
form.classList.toggle('is-current', name === view);
});
document.querySelectorAll('.rail-step').forEach((step) => {
const which = step.dataset.step;
step.classList.toggle('is-active', which === view);
step.classList.toggle('is-done', which === 'account' && view === 'identity');
});
railFill.classList.toggle('is-full', view === 'identity');
const first = views[view].querySelector('input');
if (first) setTimeout(() => first.focus(), 240);
}
function notice(view, message, good) {
const el = views[view].querySelector('[data-notice]');
if (!message) {
el.classList.remove('is-shown', 'is-good');
el.textContent = '';
return;
}
el.textContent = message;
el.classList.add('is-shown');
el.classList.toggle('is-good', !!good);
}
function setBusy(view, state) {
busy = state;
const btn = views[view].querySelector('.btn');
btn.classList.toggle('is-busy', state);
btn.disabled = state;
}
function values(view) {
const out = {};
views[view].querySelectorAll('input').forEach((input) => {
out[input.name] = input.value.trim();
});
return out;
}
function markBad(view, names) {
views[view].querySelectorAll('input').forEach((input) => {
input.classList.toggle('is-bad', names.includes(input.name));
});
}
// =========================================================== the passport
// Everything on the data page is derived from the name, so the same resident
// always gets the same document — it reads as a record, not a random mock-up.
function seedOf(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return hash;
}
function passportNumber(seed) {
return String(seed % 1000000000).padStart(9, '0');
}
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
function human(date) {
return String(date.getDate()).padStart(2, '0') + ' ' +
MONTHS[date.getMonth()] + ' ' + date.getFullYear();
}
// MRZ dates are YYMMDD.
function mrzDate(date) {
return String(date.getFullYear() % 100).padStart(2, '0') +
String(date.getMonth() + 1).padStart(2, '0') +
String(date.getDate()).padStart(2, '0');
}
// ICAO 9303 check digit: weights cycle 7-3-1 over the field.
function checkDigit(field) {
const WEIGHTS = [7, 3, 1];
let sum = 0;
for (let i = 0; i < field.length; i++) {
const c = field[i];
let v;
if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48;
else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55;
else v = 0; // '<' filler
sum += v * WEIGHTS[i % 3];
}
return String(sum % 10);
}
function pad(text, length) {
return (text + '<'.repeat(length)).slice(0, length);
}
function buildDocument(firstName, lastName) {
const seed = seedOf((firstName + lastName).toLowerCase());
const number = passportNumber(seed);
const now = new Date();
const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate());
// A plausible adult birth date, stable for this name.
const dob = new Date(
now.getFullYear() - (21 + (seed % 34)),
(seed >>> 7) % 12,
1 + ((seed >>> 13) % 28)
);
const surname = lastName.toUpperCase();
const given = firstName.toUpperCase();
const line1 = pad('P<USA' + surname + '<<' + given, 44);
const numCd = checkDigit(number);
const dobCd = checkDigit(mrzDate(dob));
const expCd = checkDigit(mrzDate(expiry));
const personal = '<'.repeat(14);
const personalCd = checkDigit(personal);
const composite = number + numCd + mrzDate(dob) + dobCd +
mrzDate(expiry) + expCd + personal + personalCd;
const line2 = number + numCd + 'USA' + mrzDate(dob) + dobCd + 'X' +
mrzDate(expiry) + expCd + personal + personalCd +
checkDigit(composite);
return { number, issue, expiry, dob, line1, line2 };
}
function printName(el, text) {
if (!text) { el.textContent = ''; return; }
if (el.textContent === text) return;
el.textContent = '';
const span = document.createElement('span');
span.className = 'printed';
span.textContent = text;
el.appendChild(span);
}
const BLANK1 = 'P<USA' + '<'.repeat(39);
const BLANK2 = '<'.repeat(10) + 'USA' + '<'.repeat(31);
function fillPassport(firstName, lastName) {
printName(cardFirst, firstName.toUpperCase());
printName(cardLast, lastName.toUpperCase());
// Below two letters there is not enough to derive a document from.
if (firstName.length < 2 || lastName.length < 2) {
cardNo.textContent = '—';
cardDob.textContent = '—';
cardIssue.textContent = '—';
cardExpiry.textContent = '—';
cardSign.textContent = '';
mrz1.textContent = BLANK1;
mrz2.textContent = BLANK2;
return;
}
const doc = buildDocument(firstName, lastName);
cardNo.textContent = doc.number;
cardDob.textContent = human(doc.dob);
cardIssue.textContent = human(doc.issue);
cardExpiry.textContent = human(doc.expiry);
cardSign.textContent = firstName + ' ' + lastName;
mrz1.textContent = doc.line1;
mrz2.textContent = doc.line2;
stampDate.textContent = human(doc.issue);
}
function syncPassport() {
const { firstName, lastName } = values('identity');
fillPassport(firstName, lastName);
}
function issuePassport() {
permit.classList.add('is-issued');
seal.classList.add('is-stamped');
sheen.style.opacity = '0.85';
}
// Laminate glare tracks the cursor across the page.
permit.addEventListener('pointermove', (e) => {
const box = permit.getBoundingClientRect();
sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`);
sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`);
});
// --------------------------------------------------------- password meter
const pwInput = views.account.querySelector('input[name="password"]');
const meter = views.account.querySelector('[data-meter]');
pwInput.addEventListener('input', () => {
const v = pwInput.value;
meter.classList.toggle('is-live', v.length > 0);
let level = 1;
if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2;
if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3;
meter.dataset.level = String(level);
meter.querySelector('i').style.width = `${level * 33.4}%`;
});
views.identity.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => {
// Keep the document honest: only English letters ever reach it.
input.value = input.value.replace(/[^A-Za-z]/g, '');
syncPassport();
});
});
// ------------------------------------------------------------ validation
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/;
const RE_NAME = /^[A-Za-z]{2,24}$/;
function checkAccount(v) {
if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']];
if (!RE_USERNAME.test(v.username)) return ['Username must be 332 characters, letters, numbers or underscore.', ['username']];
if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']];
return null;
}
function checkIdentity(v) {
if (!RE_NAME.test(v.firstName)) return ['First name must be 224 English letters.', ['firstName']];
if (!RE_NAME.test(v.lastName)) return ['Last name must be 224 English letters.', ['lastName']];
return null;
}
// ---------------------------------------------------------------- submits
views.login.addEventListener('submit', (e) => {
e.preventDefault();
if (busy) return;
const v = values('login');
if (!v.username || !v.password) {
markBad('login', [!v.username ? 'username' : 'password']);
return notice('login', 'Enter your username and password.');
}
markBad('login', []);
notice('login', '');
setBusy('login', true);
post('login', v);
});
views.account.addEventListener('submit', (e) => {
e.preventDefault();
if (busy) return;
const v = values('account');
const bad = checkAccount(v);
if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); }
markBad('account', []);
notice('account', '');
setBusy('account', true);
post('registerAccount', v);
});
views.identity.addEventListener('submit', (e) => {
e.preventDefault();
if (busy) return;
const v = values('identity');
const bad = checkIdentity(v);
if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); }
markBad('identity', []);
notice('identity', '');
setBusy('identity', true);
post(resuming ? 'resumeIdentity' : 'registerIdentity', v);
});
document.querySelectorAll('[data-goto]').forEach((btn) => {
btn.addEventListener('click', () => {
if (busy) return;
notice(current, '');
show(btn.dataset.goto);
});
});
// ------------------------------------------------------ messages from Lua
window.addEventListener('message', (event) => {
const msg = event.data || {};
const p = msg.payload || {};
switch (msg.action) {
case 'open':
stage.classList.add('is-open');
stage.setAttribute('aria-hidden', 'false');
show('login');
break;
case 'close':
stage.classList.remove('is-open');
stage.setAttribute('aria-hidden', 'true');
break;
case 'accountResult':
setBusy('account', false);
if (p.ok) {
notice('account', '');
show('identity');
syncPassport();
} else {
notice('account', p.error || 'Something went wrong.');
}
break;
case 'identityResult':
setBusy('identity', false);
if (p.ok) {
fillPassport(String(p.firstName), String(p.lastName));
issuePassport();
notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true);
views.identity.querySelector('.btn span').textContent = 'Entering the city…';
views.identity.querySelector('.btn').disabled = true;
// Nothing left to go back to once the document exists.
views.identity.querySelector('.switch').style.display = 'none';
} else {
if (p.reset) { resuming = false; show('account'); }
notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.');
}
break;
case 'loginResult':
setBusy('login', false);
if (p.ok && p.needsIdentity) {
// Account is real but has no resident yet — finish step 2.
resuming = true;
show('identity');
syncPassport();
notice('identity', 'Your file is open. Name your resident to finish.', true);
} else if (p.ok) {
notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true);
views.login.querySelector('.btn span').textContent = 'Entering the city…';
views.login.querySelector('.btn').disabled = true;
} else {
markBad('login', ['username', 'password']);
notice('login', p.error || 'Something went wrong.');
}
break;
}
});
// Clear the error ring as soon as the player starts fixing the field.
document.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => input.classList.remove('is-bad'));
});
})();