#!/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()