Files
Claude Opus 5andClaude Opus 5 c857979a55 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>
2026-08-12 23:42:20 +00:00

392 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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'));
});
})();