Всё, что здесь лежит, написал ИИ-агент на живом сервере под запись — без 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>
341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/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 3–20 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()
|