1012 lines
45 KiB
Python
Executable File
1012 lines
45 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Backend Server for Plants vs. Zombies (ai.qpid.pro)
|
||
Zero external dependencies.
|
||
Manages:
|
||
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/register, /api/auth/save, /api/auth/sync, /api/auth/sessions)
|
||
- Campaign Levels API (/api/campaign/levels, /api/admin/campaign/*)
|
||
- Community Bug Reports & Feedback (/api/bugs)
|
||
- Public Game Configurator & Balance API (/api/config)
|
||
- Admin Management API (/api/admin/*, /api/admin/logs, /api/admin/sessions, /api/admin/password/change)
|
||
"""
|
||
|
||
import http.server
|
||
import json
|
||
import os
|
||
import sys
|
||
import html
|
||
import urllib.parse
|
||
import secrets
|
||
import re
|
||
import threading
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
|
||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||
|
||
import db
|
||
|
||
PORT = 8001
|
||
MAX_BODY_SIZE = 5 * 1024 * 1024 # 5MB (for backups)
|
||
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
|
||
BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json")
|
||
BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
|
||
|
||
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||
db.init_db()
|
||
|
||
# Run maintenance upon server initialization
|
||
try:
|
||
_init_maint = db.perform_system_maintenance()
|
||
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Initial system maintenance: {_init_maint}\n")
|
||
except Exception as _e:
|
||
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Initial system maintenance warning: {_e}\n")
|
||
|
||
# Daemon background thread for daily system maintenance (24-hour interval)
|
||
_maintenance_thread = None
|
||
|
||
def start_daily_maintenance_thread(interval_seconds=86400):
|
||
global _maintenance_thread
|
||
if _maintenance_thread is not None and _maintenance_thread.is_alive():
|
||
return _maintenance_thread
|
||
|
||
def maintenance_loop():
|
||
while True:
|
||
time.sleep(interval_seconds)
|
||
try:
|
||
summary = db.perform_system_maintenance()
|
||
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Daily system maintenance completed: {summary}\n")
|
||
except Exception as e:
|
||
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Daily system maintenance error: {e}\n")
|
||
|
||
_maintenance_thread = threading.Thread(target=maintenance_loop, daemon=True, name="DailyMaintenanceThread")
|
||
_maintenance_thread.start()
|
||
return _maintenance_thread
|
||
|
||
start_daily_maintenance_thread()
|
||
|
||
ADMIN_TOKENS = {} # token -> expires_at timestamp
|
||
|
||
def parse_device_info(user_agent):
|
||
"""Extract friendly device and browser name from User-Agent string."""
|
||
return db.parse_device_info(user_agent)
|
||
|
||
def create_admin_token(duration_days=7):
|
||
token = "adm_" + secrets.token_hex(24)
|
||
expires_at = (datetime.now() + timedelta(days=duration_days)).timestamp()
|
||
ADMIN_TOKENS[token] = expires_at
|
||
return token
|
||
|
||
def is_valid_admin_token(token):
|
||
if not token or not isinstance(token, str):
|
||
return False
|
||
token = token.strip()
|
||
if token in ADMIN_TOKENS:
|
||
if datetime.now().timestamp() < ADMIN_TOKENS[token]:
|
||
return True
|
||
else:
|
||
del ADMIN_TOKENS[token]
|
||
return False
|
||
# Check if this token belongs to an admin user session in DB
|
||
user = db.validate_session(token)
|
||
if user and user.get("isAdmin"):
|
||
return True
|
||
return False
|
||
|
||
# In-memory rate limiter for failed login attempts
|
||
FAILED_LOGIN_ATTEMPTS = {} # ip -> list of timestamps
|
||
|
||
def is_login_rate_limited(ip, max_attempts=5, window_seconds=60):
|
||
now = datetime.now().timestamp()
|
||
attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < window_seconds]
|
||
FAILED_LOGIN_ATTEMPTS[ip] = attempts
|
||
return len(attempts) >= max_attempts
|
||
|
||
def record_failed_login(ip):
|
||
now = datetime.now().timestamp()
|
||
attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < 60]
|
||
attempts.append(now)
|
||
FAILED_LOGIN_ATTEMPTS[ip] = attempts
|
||
|
||
def clear_failed_logins(ip):
|
||
FAILED_LOGIN_ATTEMPTS.pop(ip, None)
|
||
|
||
DEFAULT_BUGS = [
|
||
{
|
||
"id": 1,
|
||
"author": "Player_Kopri",
|
||
"category": "bug",
|
||
"text": "Зомби после поедания растения не шел дальше, а оставался стоять на месте.",
|
||
"status": "исправлено",
|
||
"timestamp": "2026-09-16 09:30",
|
||
"adminReply": "Исправлено в v1.6: сброшен флаг isEating и таймер поедания."
|
||
},
|
||
{
|
||
"id": 2,
|
||
"author": "DaveFan",
|
||
"category": "bug",
|
||
"text": "Режим 'Я, Зомби' не работал: сразу после старта выводилось сообщение 'победа'.",
|
||
"status": "исправлено",
|
||
"timestamp": "2026-09-16 09:45",
|
||
"adminReply": "Исправлено в v1.6: устранено состояние гонки при инициализации газона."
|
||
},
|
||
{
|
||
"id": 3,
|
||
"author": "ZombossHater",
|
||
"category": "visual",
|
||
"text": "Элементы главного меню и кнопки вываливаются за границы экрана на небольших дисплеях.",
|
||
"status": "исправлено",
|
||
"timestamp": "2026-09-16 10:50",
|
||
"adminReply": "Исправлено в v2.2: адаптивная панель навигации, компактные карточки и вертикальный скролл."
|
||
}
|
||
]
|
||
|
||
def load_bugs():
|
||
if not os.path.exists(BUGS_JSON):
|
||
save_bugs(DEFAULT_BUGS)
|
||
return DEFAULT_BUGS
|
||
try:
|
||
with open(BUGS_JSON, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return DEFAULT_BUGS
|
||
|
||
def save_bugs(bugs):
|
||
with open(BUGS_JSON, "w", encoding="utf-8") as f:
|
||
json.dump(bugs, f, ensure_ascii=False, indent=2)
|
||
sync_markdown(bugs)
|
||
|
||
def sync_markdown(bugs):
|
||
lines = [
|
||
"# Журнал баг-репортов и обратной связи от игроков",
|
||
"",
|
||
"> Данный файл обновляется автоматически при поступлении новых баг-репортов из игры.",
|
||
"> При каждом обновлении и новой итерации разработки ассистент анализирует этот список.",
|
||
"",
|
||
f"**Всего репортов:** {len(bugs)} | **Последнее обновление:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
"",
|
||
"| ID | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс |",
|
||
"|:---:|:---:|:---|:---:|:---:|:---|:---|"
|
||
]
|
||
for b in reversed(bugs):
|
||
bid = b.get("id", 0)
|
||
ts = b.get("timestamp", "")
|
||
author = str(b.get("author", "Аноним")).replace("|", "/")
|
||
cat = b.get("category", "bug")
|
||
cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс"))
|
||
st = b.get("status", "новый")
|
||
st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else ("⚪ Отклонено" if st == "отклонено" else "🔴 Новый"))
|
||
text = str(b.get("text", "")).replace("|", "/").replace("\n", " ")
|
||
reply = str(b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
|
||
lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |")
|
||
|
||
lines.append("")
|
||
with open(BUGS_MD, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(lines))
|
||
|
||
class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||
def get_client_ip(self):
|
||
xff = self.headers.get("X-Forwarded-For")
|
||
if xff:
|
||
return xff.split(",")[0].strip()
|
||
x_real = self.headers.get("X-Real-IP")
|
||
if x_real:
|
||
return x_real.strip()
|
||
return self.client_address[0]
|
||
|
||
def _set_cors(self):
|
||
origin = self.headers.get("Origin", "")
|
||
if not origin:
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
else:
|
||
try:
|
||
parsed = urllib.parse.urlparse(origin)
|
||
host = parsed.hostname or ""
|
||
if host in ["localhost", "127.0.0.1", "ai.qpid.pro", "pvz.qpid.pro", "qpid.pro", "ai", "racks"] or host.endswith(".qpid.pro") or host.endswith(".ai") or host.endswith(".racks"):
|
||
self.send_header("Access-Control-Allow-Origin", origin)
|
||
else:
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
except Exception:
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
|
||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Auth-Token, X-Session-Token")
|
||
self.send_header("Access-Control-Allow-Credentials", "true")
|
||
|
||
def _send_json(self, data, status=200):
|
||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(status)
|
||
self._set_cors()
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def extract_token(self, payload=None):
|
||
"""Extracts Bearer token, custom header, cookie, query param, or JSON payload token."""
|
||
auth_header = self.headers.get("Authorization", "")
|
||
if auth_header.startswith("Bearer "):
|
||
return auth_header[7:].strip()
|
||
for h in ["X-Admin-Token", "X-Auth-Token", "X-Session-Token"]:
|
||
if h in self.headers and self.headers[h]:
|
||
return self.headers.get(h, "").strip()
|
||
|
||
# Check cookies
|
||
cookie_header = self.headers.get("Cookie", "")
|
||
if cookie_header:
|
||
for part in cookie_header.split(";"):
|
||
part = part.strip()
|
||
if part.startswith("admin_token="):
|
||
return part.split("=", 1)[1].strip()
|
||
if part.startswith("token="):
|
||
return part.split("=", 1)[1].strip()
|
||
if part.startswith("session_token="):
|
||
return part.split("=", 1)[1].strip()
|
||
|
||
# Check query parameters
|
||
if "?" in self.path:
|
||
parsed_query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||
if "token" in parsed_query and parsed_query["token"]:
|
||
return parsed_query["token"][0].strip()
|
||
if "adminToken" in parsed_query and parsed_query["adminToken"]:
|
||
return parsed_query["adminToken"][0].strip()
|
||
|
||
# Check payload
|
||
if payload and isinstance(payload, dict):
|
||
if "token" in payload and payload["token"]:
|
||
return str(payload["token"]).strip()
|
||
if "adminToken" in payload and payload["adminToken"]:
|
||
return str(payload["adminToken"]).strip()
|
||
if "sessionToken" in payload and payload["sessionToken"]:
|
||
return str(payload["sessionToken"]).strip()
|
||
|
||
return None
|
||
|
||
def extract_admin_token(self, payload=None):
|
||
return self.extract_token(payload)
|
||
|
||
def require_admin(self, payload=None):
|
||
token = self.extract_admin_token(payload)
|
||
if not token or not is_valid_admin_token(token):
|
||
self._send_json({"success": False, "message": "Доступ запрещен: требуется токен администратора"}, 401)
|
||
return False
|
||
return True
|
||
|
||
def do_OPTIONS(self):
|
||
self.send_response(204)
|
||
self._set_cors()
|
||
self.end_headers()
|
||
|
||
def do_GET(self):
|
||
parsed_url = urllib.parse.urlparse(self.path)
|
||
path = parsed_url.path
|
||
query_params = urllib.parse.parse_qs(parsed_url.query)
|
||
|
||
# 1. Health check
|
||
if path == "/health":
|
||
self.send_response(200)
|
||
self._set_cors()
|
||
self.send_header("Content-Type", "text/plain")
|
||
self.end_headers()
|
||
self.wfile.write(b"OK")
|
||
return
|
||
|
||
# 2. Public Game Balance Config
|
||
if path in ["/api/config", "/api/bugs/config"]:
|
||
config = db.get_game_config()
|
||
self._send_json(config, 200)
|
||
return
|
||
|
||
# 3. Campaign Levels Public API (GET /api/campaign/levels)
|
||
if path in ["/api/campaign/levels", "/api/bugs/campaign/levels"]:
|
||
level_num = query_params.get("level", [None])[0]
|
||
if level_num is not None:
|
||
lvl = db.get_campaign_levels(level_num)
|
||
if not lvl:
|
||
self._send_json({"success": False, "message": "Уровень не найден"}, 404)
|
||
return
|
||
self._send_json({"success": True, "level": lvl}, 200)
|
||
return
|
||
levels = db.get_campaign_levels()
|
||
self._send_json({"success": True, "levels": levels, "count": len(levels)}, 200)
|
||
return
|
||
|
||
# 4. List Registered Users in Database
|
||
if path in ["/api/users", "/api/bugs/users", "/api/auth/users"]:
|
||
users = db.list_all_users()
|
||
self._send_json({"success": True, "users": users}, 200)
|
||
return
|
||
|
||
# 5. Bug Reports Feed
|
||
if path == "/api/bugs" or path == "/bugs" or path == "/api/bugs/list":
|
||
bugs = load_bugs()
|
||
self._send_json(bugs, 200)
|
||
return
|
||
|
||
# 6. User Active Sessions (GET /api/auth/sessions)
|
||
if path in ["/api/auth/sessions", "/api/bugs/auth/sessions"]:
|
||
token = self.extract_token()
|
||
if not token:
|
||
self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401)
|
||
return
|
||
user = db.validate_session(token)
|
||
if not user:
|
||
self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401)
|
||
return
|
||
|
||
client_ip = self.get_client_ip()
|
||
db.update_session_activity(token, client_ip)
|
||
|
||
user_sessions = db.get_user_sessions(user["id"])
|
||
for s in user_sessions:
|
||
s["isCurrent"] = (s["token"] == token)
|
||
s["is_current"] = (s["token"] == token)
|
||
|
||
self._send_json({"success": True, "sessions": user_sessions, "total": len(user_sessions)}, 200)
|
||
return
|
||
|
||
# 7. Public / User Profile Fetch (/api/auth/profile, /api/auth/me, /api/auth/sync)
|
||
if path in ["/api/auth/profile", "/api/auth/me", "/api/auth/sync", "/api/bugs/auth/profile", "/api/bugs/auth/me"]:
|
||
token = self.extract_token()
|
||
if not token:
|
||
self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401)
|
||
return
|
||
|
||
user = db.validate_session(token)
|
||
if not user:
|
||
self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401)
|
||
return
|
||
|
||
client_ip = self.get_client_ip()
|
||
db.update_session_activity(token, client_ip)
|
||
|
||
profile_data = db.get_user_profile(user["username"])
|
||
# Client has synchronized with the server: clear admin_updated_at flag
|
||
try:
|
||
conn_sync = db.get_connection()
|
||
c_sync = conn_sync.cursor()
|
||
c_sync.execute("UPDATE profiles SET admin_updated_at = NULL WHERE user_id = ?", (user["id"],))
|
||
conn_sync.commit()
|
||
conn_sync.close()
|
||
except Exception:
|
||
pass
|
||
|
||
self._send_json({
|
||
"success": True,
|
||
"token": token,
|
||
"user_id": user["id"],
|
||
"username": user["username"],
|
||
"isAdmin": user.get("isAdmin", False),
|
||
"data": profile_data
|
||
}, 200)
|
||
return
|
||
|
||
# 8. Global Community Leaderboard API (GET)
|
||
if path in ["/api/leaderboard", "/api/leaderboards", "/api/bugs/leaderboard"]:
|
||
mode_param = query_params.get("mode", [None])[0]
|
||
records = db.get_leaderboard_scores(mode_param)
|
||
self._send_json({"success": True, "records": records}, 200)
|
||
return
|
||
|
||
# ====================================================
|
||
# ADMIN API (GET)
|
||
# ====================================================
|
||
|
||
# Admin: Dashboard Stats
|
||
if path == "/api/admin/stats":
|
||
if not self.require_admin():
|
||
return
|
||
stats = db.admin_get_stats()
|
||
self._send_json({"success": True, "stats": stats}, 200)
|
||
return
|
||
|
||
# Admin: Game Config & Presets
|
||
if path == "/api/admin/config":
|
||
if not self.require_admin():
|
||
return
|
||
config = db.get_game_config()
|
||
self._send_json({"success": True, "config": config}, 200)
|
||
return
|
||
|
||
# Admin: Campaign Levels Editor List
|
||
if path in ["/api/admin/campaign/levels", "/api/admin/campaign"]:
|
||
if not self.require_admin():
|
||
return
|
||
levels = db.get_campaign_levels()
|
||
self._send_json({"success": True, "levels": levels, "count": len(levels)}, 200)
|
||
return
|
||
|
||
# Admin: Login Audit Logs
|
||
if path == "/api/admin/logs":
|
||
if not self.require_admin():
|
||
return
|
||
limit = int(query_params.get("limit", [100])[0])
|
||
username = query_params.get("username", [None])[0]
|
||
ip = query_params.get("ip", [None])[0]
|
||
logs = db.get_login_logs(limit=limit, username=username, ip=ip)
|
||
self._send_json({"success": True, "logs": logs, "count": len(logs)}, 200)
|
||
return
|
||
|
||
# Admin: All Player Active Sessions
|
||
if path in ["/api/admin/sessions", "/api/admin/sessions/list"]:
|
||
if not self.require_admin():
|
||
return
|
||
sessions = db.get_all_active_sessions()
|
||
self._send_json({"success": True, "sessions": sessions, "count": len(sessions)}, 200)
|
||
return
|
||
|
||
# Admin: List all users with detail
|
||
if path == "/api/admin/users":
|
||
if not self.require_admin():
|
||
return
|
||
users = db.admin_list_users()
|
||
self._send_json({"success": True, "users": users}, 200)
|
||
return
|
||
|
||
# Admin: Get single user detail (/api/admin/user/<id>)
|
||
m_user = re.match(r"^/api/admin/user/(\d+)$", path)
|
||
if m_user:
|
||
if not self.require_admin():
|
||
return
|
||
user_id = int(m_user.group(1))
|
||
detail = db.admin_get_user_detail(user_id)
|
||
if not detail:
|
||
self._send_json({"success": False, "message": "Пользователь не найден"}, 404)
|
||
return
|
||
self._send_json({"success": True, "user": detail}, 200)
|
||
return
|
||
|
||
# Admin: Bug Reports List
|
||
if path == "/api/admin/bugs":
|
||
if not self.require_admin():
|
||
return
|
||
bugs = load_bugs()
|
||
self._send_json({"success": True, "bugs": bugs}, 200)
|
||
return
|
||
|
||
# Admin: Database Backup Export
|
||
if path == "/api/admin/backup/export":
|
||
if not self.require_admin():
|
||
return
|
||
export_data = db.admin_export_db()
|
||
self._send_json({"success": True, "data": export_data}, 200)
|
||
return
|
||
|
||
# Admin: System Maintenance & Cleanup (GET)
|
||
if path in ["/api/admin/system/cleanup", "/api/admin/cleanup"]:
|
||
if not self.require_admin():
|
||
return
|
||
summary = db.perform_system_maintenance()
|
||
self._send_json(summary, 200)
|
||
return
|
||
|
||
self._send_json({"error": "Not Found", "path": path}, 404)
|
||
|
||
def do_POST(self):
|
||
parsed_url = urllib.parse.urlparse(self.path)
|
||
path = parsed_url.path
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
|
||
# Check payload size limit
|
||
if length > MAX_BODY_SIZE:
|
||
self._send_json({"success": False, "message": "Payload Too Large (Max 5MB)"}, 413)
|
||
self.close_connection = True
|
||
return
|
||
|
||
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
||
|
||
try:
|
||
payload = json.loads(body) if body else {}
|
||
except Exception as e:
|
||
self._send_json({"error": f"Invalid JSON: {e}"}, 400)
|
||
return
|
||
|
||
client_ip = self.get_client_ip()
|
||
user_agent = self.headers.get("User-Agent", "")
|
||
device_name = parse_device_info(user_agent)
|
||
|
||
# ====================================================
|
||
# ADMIN AUTHENTICATION & MANAGEMENT
|
||
# ====================================================
|
||
if path == "/api/admin/auth":
|
||
password = str(payload.get("password", "")).strip()
|
||
if not password:
|
||
self._send_json({"success": False, "message": "Введите пароль администратора"}, 400)
|
||
return
|
||
|
||
if db.verify_admin_password(password):
|
||
token = create_admin_token()
|
||
self._send_json({
|
||
"success": True,
|
||
"token": token,
|
||
"admin": True,
|
||
"message": "Успешная авторизация администратора"
|
||
}, 200)
|
||
return
|
||
|
||
self._send_json({"success": False, "message": "Неверный пароль администратора"}, 401)
|
||
return
|
||
|
||
# Admin: Change Master Password
|
||
if path == "/api/admin/password/change":
|
||
admin_token = self.extract_admin_token(payload)
|
||
is_authed = admin_token and is_valid_admin_token(admin_token)
|
||
|
||
current_password = str(payload.get("currentPassword", payload.get("current_password", ""))).strip()
|
||
new_password = str(payload.get("newPassword", payload.get("new_password", payload.get("password", "")))).strip()
|
||
|
||
if not new_password:
|
||
self._send_json({"success": False, "message": "Новый пароль не может быть пустым"}, 400)
|
||
return
|
||
|
||
if not is_authed:
|
||
if not current_password or not db.verify_admin_password(current_password):
|
||
self._send_json({"success": False, "message": "Неверный текущий пароль администратора"}, 401)
|
||
return
|
||
|
||
res = db.set_admin_password(new_password)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Terminate Any Session (POST /api/admin/session/terminate)
|
||
if path in ["/api/admin/session/terminate", "/api/admin/sessions/terminate"]:
|
||
if not self.require_admin(payload):
|
||
return
|
||
target_token = payload.get("targetToken") or payload.get("target_token") or payload.get("token")
|
||
if not target_token:
|
||
self._send_json({"success": False, "message": "Укажите токен сессии для завершения"}, 400)
|
||
return
|
||
db.terminate_session(str(target_token).strip())
|
||
self._send_json({"success": True, "message": "Сессия успешно завершена"}, 200)
|
||
return
|
||
|
||
# Admin: System Maintenance & Cleanup (POST /api/admin/system/cleanup)
|
||
if path in ["/api/admin/system/cleanup", "/api/admin/cleanup"]:
|
||
if not self.require_admin(payload):
|
||
return
|
||
audit_days = payload.get("audit_days", payload.get("auditDays", 30))
|
||
session_days = payload.get("session_days", payload.get("sessionDays", 30))
|
||
try:
|
||
audit_days = int(audit_days)
|
||
except (ValueError, TypeError):
|
||
audit_days = 30
|
||
try:
|
||
session_days = int(session_days)
|
||
except (ValueError, TypeError):
|
||
session_days = 30
|
||
|
||
summary = db.perform_system_maintenance(audit_days=audit_days, session_days=session_days)
|
||
self._send_json(summary, 200)
|
||
return
|
||
|
||
# Admin: Save Campaign Level (POST /api/admin/campaign/level/save)
|
||
if path in ["/api/admin/campaign/level/save", "/api/admin/campaign/save"]:
|
||
if not self.require_admin(payload):
|
||
return
|
||
level_data = payload.get("level") or payload
|
||
try:
|
||
saved = db.save_campaign_level(level_data)
|
||
self._send_json({"success": True, "message": "Уровень кампании сохранен", "level": saved}, 200)
|
||
except Exception as e:
|
||
self._send_json({"success": False, "message": f"Ошибка сохранения уровня: {e}"}, 400)
|
||
return
|
||
|
||
# Admin: Reset Campaign Level (POST /api/admin/campaign/level/reset)
|
||
if path in ["/api/admin/campaign/level/reset", "/api/admin/campaign/reset"]:
|
||
if not self.require_admin(payload):
|
||
return
|
||
target_lvl = payload.get("level")
|
||
res = db.reset_campaign_levels(target_lvl)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Save / Apply Game Balance Config
|
||
if path == "/api/admin/config":
|
||
if not self.require_admin(payload):
|
||
return
|
||
config_data = payload.get("config") or payload
|
||
preset_name = payload.get("preset") or config_data.get("preset", "Custom")
|
||
|
||
# If applying a standard preset from default catalogue
|
||
if "applyPreset" in payload and payload["applyPreset"] in db.DEFAULT_GAME_CONFIG["presets"]:
|
||
target_preset = payload["applyPreset"]
|
||
preset_obj = db.DEFAULT_GAME_CONFIG["presets"][target_preset]
|
||
current_cfg = db.get_game_config()
|
||
current_cfg["preset"] = target_preset
|
||
current_cfg["economy"].update(preset_obj.get("economy", {}))
|
||
current_cfg["zombies"].update(preset_obj.get("zombies", {}))
|
||
current_cfg["plants"].update(preset_obj.get("plants", {}))
|
||
current_cfg["zenGarden"].update(preset_obj.get("zenGarden", {}))
|
||
current_cfg["events"].update(preset_obj.get("events", {}))
|
||
saved = db.save_game_config(current_cfg, target_preset)
|
||
self._send_json({"success": True, "message": f"Пресет '{target_preset}' успешно применен", "config": saved}, 200)
|
||
return
|
||
|
||
try:
|
||
saved = db.save_game_config(config_data, preset_name)
|
||
self._send_json({"success": True, "message": "Конфигурация баланса сохранена", "config": saved}, 200)
|
||
except Exception as e:
|
||
self._send_json({"success": False, "message": f"Ошибка сохранения: {e}"}, 400)
|
||
return
|
||
|
||
# Admin: Create User
|
||
if path == "/api/admin/user/create":
|
||
if not self.require_admin(payload):
|
||
return
|
||
username = str(payload.get("username", "")).strip()
|
||
password = str(payload.get("password", "")).strip()
|
||
initial_data = payload.get("initialData", payload.get("profile", {}))
|
||
is_admin = 1 if payload.get("isAdmin") else 0
|
||
|
||
if not username or not password:
|
||
self._send_json({"success": False, "message": "Укажите имя пользователя и пароль"}, 400)
|
||
return
|
||
|
||
res = db.admin_create_user(username, password, initial_data, is_admin)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Edit User Profile (/api/admin/user/<id>/edit)
|
||
m_edit = re.match(r"^/api/admin/user/(\d+)/edit$", path)
|
||
if m_edit:
|
||
if not self.require_admin(payload):
|
||
return
|
||
user_id = int(m_edit.group(1))
|
||
updates = payload.get("updates", payload)
|
||
res = db.admin_update_user_profile(user_id, updates)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Ban / Unban User (/api/admin/user/<id>/ban)
|
||
m_ban = re.match(r"^/api/admin/user/(\d+)/ban$", path)
|
||
if m_ban:
|
||
if not self.require_admin(payload):
|
||
return
|
||
user_id = int(m_ban.group(1))
|
||
is_banned = payload.get("is_banned", payload.get("isBanned", True))
|
||
reason = payload.get("reason", payload.get("ban_reason", ""))
|
||
res = db.admin_set_user_ban(user_id, is_banned, reason)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Delete User (/api/admin/user/<id>/delete)
|
||
m_del = re.match(r"^/api/admin/user/(\d+)/delete$", path)
|
||
if m_del:
|
||
if not self.require_admin(payload):
|
||
return
|
||
user_id = int(m_del.group(1))
|
||
res = db.admin_delete_user(user_id)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Reset Password (/api/admin/user/<id>/password)
|
||
m_pwd = re.match(r"^/api/admin/user/(\d+)/password$", path)
|
||
if m_pwd:
|
||
if not self.require_admin(payload):
|
||
return
|
||
user_id = int(m_pwd.group(1))
|
||
new_password = str(payload.get("password", payload.get("newPassword", ""))).strip()
|
||
res = db.admin_reset_user_password(user_id, new_password)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# Admin: Moderate Bug Report (/api/admin/bugs/<id>/status)
|
||
m_bug = re.match(r"^/api/admin/bugs/(\d+)/status$", path)
|
||
if m_bug:
|
||
if not self.require_admin(payload):
|
||
return
|
||
bug_id = int(m_bug.group(1))
|
||
bugs = load_bugs()
|
||
|
||
if payload.get("delete"):
|
||
bugs = [b for b in bugs if b.get("id") != bug_id]
|
||
save_bugs(bugs)
|
||
self._send_json({"success": True, "message": f"Баг #{bug_id} удален"}, 200)
|
||
return
|
||
|
||
found = False
|
||
for b in bugs:
|
||
if b.get("id") == bug_id:
|
||
found = True
|
||
if "status" in payload:
|
||
b["status"] = str(payload["status"]).strip()
|
||
if "adminReply" in payload:
|
||
b["adminReply"] = str(payload["adminReply"]).strip()
|
||
break
|
||
|
||
if not found:
|
||
self._send_json({"success": False, "message": "Баг-репорт не найден"}, 404)
|
||
return
|
||
|
||
save_bugs(bugs)
|
||
self._send_json({"success": True, "message": "Статус баг-репорта обновлен", "bugId": bug_id}, 200)
|
||
return
|
||
|
||
# Admin: Database Backup Import
|
||
if path == "/api/admin/backup/import":
|
||
if not self.require_admin(payload):
|
||
return
|
||
import_data = payload.get("data", payload)
|
||
res = db.admin_import_db(import_data)
|
||
if res.get("success") and "bugs" in import_data:
|
||
save_bugs(import_data["bugs"])
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# ====================================================
|
||
# USER AUTHENTICATION, SESSIONS & PROGRESS SAVE
|
||
# ====================================================
|
||
|
||
# 1a. User Login with Rate Limiting & Audit Logging
|
||
if path in ["/api/auth/login", "/api/bugs/auth/login"]:
|
||
if is_login_rate_limited(client_ip):
|
||
db.record_login_log(None, payload.get("username", ""), client_ip, user_agent, "rate_limited", device_name)
|
||
self._send_json({
|
||
"success": False,
|
||
"message": "Слишком много неудачных попыток входа. Пожалуйста, подождите 1 минуту."
|
||
}, 429)
|
||
return
|
||
|
||
username = str(payload.get("username", "")).strip()
|
||
password_hash = str(payload.get("passwordHash", "")).strip()
|
||
|
||
if not username or not password_hash:
|
||
record_failed_login(client_ip)
|
||
db.record_login_log(None, username, client_ip, user_agent, "empty_credentials", device_name)
|
||
self._send_json({"success": False, "message": "Введите логин и пароль"}, 400)
|
||
return
|
||
|
||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
|
||
record_failed_login(client_ip)
|
||
db.record_login_log(None, username, client_ip, user_agent, "invalid_username_format", device_name)
|
||
self._send_json({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}, 400)
|
||
return
|
||
|
||
user = db.get_user_by_username(username)
|
||
if not user:
|
||
record_failed_login(client_ip)
|
||
db.record_login_log(None, username, client_ip, user_agent, "user_not_found", device_name)
|
||
self._send_json({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}, 404)
|
||
return
|
||
|
||
res = db.authenticate_user(username, password_hash, ip_address=client_ip, user_agent=user_agent, device_name=device_name)
|
||
if not res or not res.get("success"):
|
||
record_failed_login(client_ip)
|
||
self._send_json(res or {"success": False, "message": "Неверный логин или пароль"}, 401)
|
||
return
|
||
|
||
clear_failed_logins(client_ip)
|
||
self._send_json(res, 200)
|
||
return
|
||
|
||
# 1b. User Registration
|
||
if path in ["/api/auth/register", "/api/bugs/auth/register"]:
|
||
username = str(payload.get("username", "")).strip()
|
||
password_hash = str(payload.get("passwordHash", "")).strip()
|
||
initial_data = payload.get("initialData")
|
||
|
||
if not username or not password_hash:
|
||
self._send_json({"success": False, "message": "Введите логин и пароль для регистрации"}, 400)
|
||
return
|
||
|
||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
|
||
self._send_json({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}, 400)
|
||
return
|
||
|
||
if len(password_hash) < 1:
|
||
self._send_json({"success": False, "message": "Пароль не может быть пустым"}, 400)
|
||
return
|
||
|
||
if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]:
|
||
self._send_json({"success": False, "message": "Это имя пользователя зарезервировано"}, 400)
|
||
return
|
||
|
||
user = db.get_user_by_username(username)
|
||
if user:
|
||
self._send_json({"success": False, "message": "Пользователь с таким логином уже существует"}, 409)
|
||
return
|
||
|
||
res = db.register_user(username, password_hash, initial_data, ip_address=client_ip, user_agent=user_agent, device_name=device_name)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# 1c. User Logout
|
||
if path in ["/api/auth/logout", "/api/bugs/auth/logout"]:
|
||
token = self.extract_token(payload)
|
||
if token:
|
||
db.delete_session(token)
|
||
self._send_json({"success": True, "message": "Сессия успешно завершена"}, 200)
|
||
return
|
||
|
||
# 1d. User Active Sessions List (POST /api/auth/sessions)
|
||
if path in ["/api/auth/sessions", "/api/bugs/auth/sessions"]:
|
||
token = self.extract_token(payload)
|
||
if not token:
|
||
self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401)
|
||
return
|
||
user = db.validate_session(token)
|
||
if not user:
|
||
self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401)
|
||
return
|
||
|
||
db.update_session_activity(token, client_ip)
|
||
|
||
user_sessions = db.get_user_sessions(user["id"])
|
||
for s in user_sessions:
|
||
s["isCurrent"] = (s["token"] == token)
|
||
s["is_current"] = (s["token"] == token)
|
||
|
||
self._send_json({"success": True, "sessions": user_sessions, "total": len(user_sessions)}, 200)
|
||
return
|
||
|
||
# 1e. User Session Termination (POST /api/auth/sessions/terminate)
|
||
if path in ["/api/auth/sessions/terminate", "/api/bugs/auth/sessions/terminate"]:
|
||
token = self.extract_token(payload)
|
||
if not token:
|
||
self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401)
|
||
return
|
||
user = db.validate_session(token)
|
||
if not user:
|
||
self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401)
|
||
return
|
||
|
||
db.update_session_activity(token, client_ip)
|
||
|
||
# Terminate all other sessions if requested
|
||
if payload.get("all_others") or payload.get("allOthers"):
|
||
db.terminate_all_user_sessions(user["id"], except_token=token)
|
||
self._send_json({"success": True, "message": "Все остальные сессии успешно завершены"}, 200)
|
||
return
|
||
|
||
target_token = payload.get("targetToken") or payload.get("target_token")
|
||
if not target_token:
|
||
# If no specific target provided, terminate requesting token (logout)
|
||
target_token = token
|
||
|
||
db.terminate_session(str(target_token).strip(), user_id=user["id"])
|
||
self._send_json({"success": True, "message": "Сессия успешно завершена"}, 200)
|
||
return
|
||
|
||
# 1f. User Profile Fetch via POST
|
||
if path in ["/api/auth/profile", "/api/auth/me", "/api/bugs/auth/profile", "/api/bugs/auth/me"]:
|
||
token = self.extract_token(payload)
|
||
if not token:
|
||
self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401)
|
||
return
|
||
|
||
session_user = db.validate_session(token)
|
||
if not session_user:
|
||
self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401)
|
||
return
|
||
|
||
db.update_session_activity(token, client_ip)
|
||
|
||
profile_data = db.get_user_profile(session_user["username"])
|
||
# Client has synchronized with the server: clear admin_updated_at flag
|
||
try:
|
||
conn_sync = db.get_connection()
|
||
c_sync = conn_sync.cursor()
|
||
c_sync.execute("UPDATE profiles SET admin_updated_at = NULL WHERE user_id = ?", (session_user["id"],))
|
||
conn_sync.commit()
|
||
conn_sync.close()
|
||
except Exception:
|
||
pass
|
||
|
||
self._send_json({
|
||
"success": True,
|
||
"token": token,
|
||
"user_id": session_user["id"],
|
||
"username": session_user["username"],
|
||
"isAdmin": session_user.get("isAdmin", False),
|
||
"data": profile_data
|
||
}, 200)
|
||
return
|
||
|
||
# 2. User Progress Profile Save & Sync (Token Authenticated & Server Authoritative)
|
||
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
|
||
token = self.extract_token(payload)
|
||
if not token:
|
||
self._send_json({"success": False, "message": "Требуется авторизация (отсутствует токен сессии)"}, 401)
|
||
return
|
||
|
||
session_user = db.validate_session(token)
|
||
if not session_user:
|
||
self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401)
|
||
return
|
||
|
||
username = str(payload.get("username", "")).strip() or session_user["username"]
|
||
if session_user["username"].lower() != username.lower():
|
||
self._send_json({"success": False, "message": "Доступ запрещен (несоответствие пользователя)"}, 403)
|
||
return
|
||
|
||
user_data = payload.get("data")
|
||
if user_data is None:
|
||
# No data sent -> return fresh authoritative profile from database
|
||
db.update_session_activity(token, client_ip)
|
||
profile_data = db.get_user_profile(username)
|
||
self._send_json({
|
||
"success": True,
|
||
"token": token,
|
||
"user_id": session_user["id"],
|
||
"username": username,
|
||
"isAdmin": session_user.get("isAdmin", False),
|
||
"data": profile_data
|
||
}, 200)
|
||
return
|
||
|
||
res = db.save_user_profile(username, user_data, token=token, ip_address=client_ip)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# 2b. Global Leaderboard Score Submission (POST)
|
||
if path in ["/api/leaderboard", "/api/leaderboards", "/api/bugs/leaderboard"]:
|
||
token = self.extract_token(payload)
|
||
user_id = None
|
||
player_name = str(payload.get("name", payload.get("player_name", payload.get("username", "")))).strip()
|
||
|
||
if token:
|
||
session_user = db.validate_session(token)
|
||
if session_user:
|
||
user_id = session_user["id"]
|
||
db.update_session_activity(token, client_ip)
|
||
if not player_name or player_name.lower() in ["гость", "guest", "player"]:
|
||
player_name = session_user["username"]
|
||
|
||
if not player_name:
|
||
player_name = "Гость"
|
||
|
||
mode = str(payload.get("mode", "survival")).strip()
|
||
score = payload.get("score", 0)
|
||
detail = str(payload.get("detail", "")).strip()
|
||
|
||
res = db.submit_leaderboard_score(user_id, player_name, mode, score, detail)
|
||
self._send_json(res, 200 if res.get("success") else 400)
|
||
return
|
||
|
||
# 3. Bug Report Submission (Sanitized)
|
||
if path.startswith("/api/bugs") or path.startswith("/bugs"):
|
||
raw_text = str(payload.get("text", "")).strip()
|
||
if not raw_text:
|
||
self._send_json({"error": "Empty report text"}, 400)
|
||
return
|
||
|
||
raw_author = str(payload.get("author", "Гость")).strip() or "Гость"
|
||
category = str(payload.get("category", "bug")).strip()
|
||
if category not in ["bug", "feature", "visual", "balance"]:
|
||
category = "bug"
|
||
|
||
clean_author = html.escape(raw_author)[:30]
|
||
clean_text = html.escape(raw_text)[:1000]
|
||
|
||
bugs = load_bugs()
|
||
new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1
|
||
new_bug = {
|
||
"id": new_id,
|
||
"author": clean_author,
|
||
"category": category,
|
||
"text": clean_text,
|
||
"status": "новый",
|
||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||
"adminReply": None
|
||
}
|
||
bugs.append(new_bug)
|
||
save_bugs(bugs)
|
||
|
||
self._send_json({"success": True, "report": new_bug, "total": len(bugs)}, 201)
|
||
return
|
||
|
||
self._send_json({"error": "Not Found", "path": path}, 404)
|
||
|
||
def log_message(self, format, *args):
|
||
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] {self.address_string()} - {format%args}\n")
|
||
|
||
if __name__ == "__main__":
|
||
load_bugs()
|
||
server = http.server.HTTPServer(("0.0.0.0", PORT), FeedbackHandler)
|
||
print(f"PvZ Backend Server running on port {PORT} with Admin Panel & SQLite database...")
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\nShutting down server.")
|
||
server.server_close()
|