Files
redl-fivem-rp/api/api.py
T
RedlHostingandClaude Opus 5 0144e83f05 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>
2026-08-06 03:28:58 +00:00

341 lines
13 KiB
Python
Raw 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.
#!/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()