Files

1012 lines
45 KiB
Python
Executable File
Raw Permalink 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
"""
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()