Part 1: registration, login and character creation written from scratch

Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без ESX,
без QBCore, без скачанных с форума скриптов.

- resources/justrp — свой ресурс на Lua: регистрация, вход, создание персонажа, спавн
- resources/justrp/html — экраны NUI: кинематографичная загрузка, вход, редактор персонажа
- api/api.py — внутренний HTTP-API к MariaDB, пароли через scrypt
- server.cfg.example — пример конфига

Секреты (пароль БД, внутренний ключ API) вынесены в переменные окружения и convar.
Ключ Cfx.re в репозиторий не попадает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
RedlHosting
2026-08-06 03:28:58 +00:00
co-authored by Claude Opus 5
parent 968d047f87
commit 0144e83f05
8 changed files with 2975 additions and 3 deletions
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""JustRP Database API — internal HTTP service on port 8787"""
import json
import hashlib
import os
import socket
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
try:
import pymysql
import pymysql.cursors
except ImportError:
import subprocess
subprocess.run(["pip3", "install", "pymysql"], check=True)
import pymysql
import pymysql.cursors
# Секреты берутся из окружения. Заведите их перед запуском, например в systemd-юните:
# Environment=JUSTRP_API_SECRET=... Environment=JUSTRP_DB_PASS=...
API_SECRET = os.environ.get("JUSTRP_API_SECRET", "change-me")
DB = {
"host": os.environ.get("JUSTRP_DB_HOST", "localhost"),
"user": os.environ.get("JUSTRP_DB_USER", "fivem"),
"password": os.environ.get("JUSTRP_DB_PASS", "change-me"),
"database": os.environ.get("JUSTRP_DB_NAME", "fivem"),
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
}
def db():
return pymysql.connect(**DB)
def hash_pw(password: str, salt: str) -> str:
return hashlib.scrypt(
password.encode(), salt=salt.encode(), n=16384, r=8, p=1
).hex()
def new_salt() -> str:
return os.urandom(16).hex()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass # silence default logging
def send_json(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def auth_check(self):
return self.headers.get("X-API-Key") == API_SECRET
def read_body(self):
n = int(self.headers.get("Content-Length", 0))
if n == 0:
return {}
raw = self.rfile.read(n)
try:
return json.loads(raw)
except Exception:
return {}
def do_POST(self):
if not self.auth_check():
return self.send_json(403, {"error": "forbidden"})
path = urlparse(self.path).path
body = self.read_body()
if path == "/auth/register":
self.handle_register(body)
elif path == "/auth/login":
self.handle_login(body)
elif path == "/character/create":
self.handle_char_create(body)
elif path == "/character/save":
self.handle_char_save(body)
elif path == "/account/ban":
self.handle_ban(body)
else:
self.send_json(404, {"error": "not found"})
def do_GET(self):
if not self.auth_check():
return self.send_json(403, {"error": "forbidden"})
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
if path == "/characters":
self.handle_chars_list(qs)
elif path == "/character":
self.handle_char_get(qs)
elif path == "/account":
self.handle_account_get(qs)
else:
self.send_json(404, {"error": "not found"})
# --- Auth ---
def handle_register(self, body):
license_ = body.get("license", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not license_ or not username or not password:
return self.send_json(400, {"success": False, "error": "Missing fields"})
if len(username) < 3 or len(username) > 20:
return self.send_json(400, {"success": False, "error": "Username must be 320 characters"})
if len(password) < 6:
return self.send_json(400, {"success": False, "error": "Password must be at least 6 characters"})
salt = new_salt()
pw_hash = hash_pw(password, salt)
stored = f"{salt}:{pw_hash}"
try:
conn = db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM accounts WHERE username = %s", (username,))
if cur.fetchone():
conn.close()
return self.send_json(409, {"success": False, "error": "Username already taken"})
cur.execute("SELECT id FROM accounts WHERE license = %s", (license_,))
if cur.fetchone():
conn.close()
return self.send_json(409, {"success": False, "error": "A JustRP account already exists on this device"})
cur.execute(
"INSERT INTO accounts (license, username, password_hash) VALUES (%s, %s, %s)",
(license_, username, stored),
)
account_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "account_id": account_id, "username": username})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_login(self, body):
license_ = body.get("license", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not license_ or not username or not password:
return self.send_json(400, {"success": False, "error": "Missing fields"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, banned, ban_reason FROM accounts WHERE username = %s",
(username,),
)
row = cur.fetchone()
if not row:
conn.close()
return self.send_json(401, {"success": False, "error": "Invalid username or password"})
if row["banned"]:
conn.close()
return self.send_json(403, {"success": False, "error": f"Banned: {row['ban_reason'] or 'No reason given'}"})
stored = row["password_hash"]
salt, expected = stored.split(":", 1)
actual = hash_pw(password, salt)
if actual != expected:
conn.close()
return self.send_json(401, {"success": False, "error": "Invalid username or password"})
cur.execute("UPDATE accounts SET last_login = NOW() WHERE id = %s", (row["id"],))
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "account_id": row["id"], "username": username})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
# --- Characters ---
def handle_chars_list(self, qs):
account_id = int(qs.get("account_id", ["0"])[0])
if not account_id:
return self.send_json(400, {"error": "missing account_id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM characters WHERE account_id = %s ORDER BY slot",
(account_id,),
)
rows = cur.fetchall()
conn.close()
return self.send_json(200, {"success": True, "characters": rows})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_get(self, qs):
char_id = int(qs.get("id", ["0"])[0])
if not char_id:
return self.send_json(400, {"error": "missing id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute("SELECT * FROM characters WHERE id = %s", (char_id,))
row = cur.fetchone()
conn.close()
if not row:
return self.send_json(404, {"success": False, "error": "Character not found"})
return self.send_json(200, {"success": True, "character": row})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_create(self, body):
required = ["account_id", "slot", "firstname", "lastname"]
for f in required:
if f not in body:
return self.send_json(400, {"success": False, "error": f"Missing field: {f}"})
fields = {
"account_id": body["account_id"],
"slot": body["slot"],
"firstname": body["firstname"][:32],
"lastname": body["lastname"][:32],
"backstory": body.get("backstory", ""),
"gender": body.get("gender", 0),
"face_blend": body.get("face_blend", 0.5),
"face_shape": body.get("face_shape", 0.5),
"skin_tone": body.get("skin_tone", 0.5),
"eye_color": body.get("eye_color", 0),
"hair_style": body.get("hair_style", 0),
"hair_color": body.get("hair_color", 0),
"body_weight": body.get("body_weight", 0.5),
"outfit_top": body.get("outfit_top", 0),
"outfit_pants": body.get("outfit_pants", 0),
"outfit_shoes": body.get("outfit_shoes", 0),
}
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) as cnt FROM characters WHERE account_id = %s",
(fields["account_id"],),
)
if cur.fetchone()["cnt"] >= 3:
conn.close()
return self.send_json(400, {"success": False, "error": "Maximum 3 characters per account"})
cols = ", ".join(fields.keys())
placeholders = ", ".join(["%s"] * len(fields))
cur.execute(
f"INSERT INTO characters ({cols}) VALUES ({placeholders})",
list(fields.values()),
)
char_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json(200, {"success": True, "character_id": char_id})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_char_save(self, body):
char_id = body.get("character_id")
if not char_id:
return self.send_json(400, {"success": False, "error": "Missing character_id"})
allowed = {
"last_x", "last_y", "last_z", "last_heading",
"cash", "bank", "playtime",
"face_blend", "face_shape", "skin_tone", "eye_color",
"hair_style", "hair_color", "body_weight",
"outfit_top", "outfit_pants", "outfit_shoes",
}
updates = {k: v for k, v in body.items() if k in allowed}
if not updates:
return self.send_json(200, {"success": True})
set_clause = ", ".join(f"{k} = %s" for k in updates)
values = list(updates.values()) + [char_id]
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
f"UPDATE characters SET {set_clause} WHERE id = %s",
values,
)
conn.commit()
conn.close()
return self.send_json(200, {"success": True})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_account_get(self, qs):
license_ = qs.get("license", [""])[0]
if not license_:
return self.send_json(400, {"error": "missing license"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, banned, ban_reason FROM accounts WHERE license = %s",
(license_,),
)
row = cur.fetchone()
conn.close()
if not row:
return self.send_json(404, {"success": False, "error": "not found"})
return self.send_json(200, {"success": True, "account": row})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
def handle_ban(self, body):
account_id = body.get("account_id")
reason = body.get("reason", "No reason given")
if not account_id:
return self.send_json(400, {"success": False, "error": "missing account_id"})
try:
conn = db()
with conn.cursor() as cur:
cur.execute(
"UPDATE accounts SET banned = 1, ban_reason = %s WHERE id = %s",
(reason, account_id),
)
conn.commit()
conn.close()
return self.send_json(200, {"success": True})
except Exception as e:
return self.send_json(500, {"success": False, "error": str(e)})
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8787), Handler)
print("[JustRP API] Listening on 127.0.0.1:8787", flush=True)
server.serve_forever()