""" consult_endpoint.py — Flask Blueprint с POST /api/consult для лендинга it.kolp.pro. Изолированный модуль: НЕ импортирует ничего из gridbot (main.py, grid.py, config.py). Регистрация в api.py — одна строка: from consult_endpoint import consult_bp app.register_blueprint(consult_bp) Содержит: - Валидацию JSON - Rate-limit (5 заявок/час с одного IP, in-memory) - Защиту от header injection (sanitization в consult_mail) - Try/except глухой — никакое исключение не должно валить Flask-приложение gridbot-а - 200 / 400 / 429 / 500 — без утечки внутренних деталей наружу """ import time import logging from collections import defaultdict, deque from threading import Lock from typing import Tuple from flask import Blueprint, request, jsonify from consult_mail import send_consult_email, ConfigurationError # === Blueprint === consult_bp = Blueprint("consult", __name__) # === Rate-limit (in-memory, не сохраняется через рестарт) === # {ip: deque[timestamp_float]} _rate_buckets: dict = defaultdict(deque) _rate_lock = Lock() _RATE_WINDOW_SEC = 3600 # 1 час _RATE_MAX = 5 # 5 заявок/час (env override ниже) try: _RATE_MAX = int(__import__("os").getenv("CONSULT_RATE_LIMIT_PER_HOUR", "5")) except (TypeError, ValueError): _RATE_MAX = 5 def _check_rate_limit(ip: str) -> Tuple[bool, int]: """ Проверяет, не превысил ли IP rate-limit. :return: (allowed, retry_after_seconds) """ now = time.time() with _rate_lock: bucket = _rate_buckets[ip] # Удаляем старые timestamps while bucket and (now - bucket[0]) > _RATE_WINDOW_SEC: bucket.popleft() if len(bucket) >= _RATE_MAX: # Через сколько секунд освободится слот retry_after = int(_RATE_WINDOW_SEC - (now - bucket[0])) + 1 return False, retry_after bucket.append(now) return True, 0 # === Логгер (тот же файл, что и в consult_mail) === import os as _os _LOG_FILE = _os.getenv("CONSULT_LOG_FILE", "/root/grid-bot/consult.log") def _get_logger() -> logging.Logger: log = logging.getLogger("consult_endpoint") if log.handlers: return log log.setLevel(logging.INFO) try: handler = logging.FileHandler(_LOG_FILE) except (PermissionError, OSError): handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) log.addHandler(handler) log.propagate = False return log # === Helpers === def _client_ip() -> str: """Получаем IP клиента с учётом reverse proxy (nginx).""" fwd = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() return fwd or request.remote_addr or "unknown" # === Route === @consult_bp.route("/api/consult", methods=["POST"]) def api_consult(): log = _get_logger() ip = _client_ip() ua = request.headers.get("User-Agent", "unknown")[:300] ftype = "consultation" # lead-magnet пока не подключаем (TODO) # 1) Rate limit allowed, retry_after = _check_rate_limit(ip) if not allowed: log.warning("RATE-LIMIT IP=%s ua=%s retry_after=%ss", ip, ua, retry_after) resp = jsonify({ "ok": False, "error": "rate_limited", "message": f"Слишком много заявок. Попробуйте через {retry_after // 60} мин.", "retry_after": retry_after, }) resp.status_code = 429 resp.headers["Retry-After"] = str(retry_after) return resp # 2) Парсим JSON try: data = request.get_json(force=True, silent=False) except Exception: log.warning("BAD JSON IP=%s ua=%s", ip, ua) return jsonify({"ok": False, "error": "bad_request", "message": "Невалидный JSON"}), 400 if not isinstance(data, dict): return jsonify({"ok": False, "error": "bad_request", "message": "Ожидается объект"}), 400 # 3) Передаём в mail-модуль data["_ip"] = ip data["_ua"] = ua data["_type"] = ftype try: ok = send_consult_email(data) except ValueError as e: log.info("VALIDATION FAIL IP=%s: %s", ip, str(e)[:200]) return jsonify({"ok": False, "error": "validation", "message": str(e)}), 400 except ConfigurationError as e: # Критично — endpoint не сконфигурирован, но gridbot должен жить log.error("CONFIGURATION ERROR: %s — endpoint не настроен", str(e)) return jsonify({"ok": False, "error": "server_error", "message": "Сервер временно не принимает заявки. Напишите на kolpakov.andrej@gmail.com"}), 500 except Exception as e: # Глухой catch-all — ни одно исключение не должно валить Flask log.exception("UNEXPECTED ERROR in send_consult_email: %s", str(e)[:200]) return jsonify({"ok": False, "error": "server_error", "message": "Сервер временно не принимает заявки. Напишите на kolpakov.andrej@gmail.com"}), 500 if not ok: return jsonify({"ok": False, "error": "send_failed", "message": "Не удалось отправить. Попробуйте позже или напишите на kolpakov.andrej@gmail.com"}), 500 log.info("OK IP=%s name=%r", ip, data.get("name", "")[:50]) return jsonify({"ok": True, "message": "Заявка принята. Отвечу в течение 24 часов."}), 200 # === Health-check (опционально, для отладки) === @consult_bp.route("/api/consult/health", methods=["GET"]) def api_consult_health(): """Простой ping, не зависит от SMTP.""" return jsonify({"ok": True, "rate_limit_per_hour": _RATE_MAX}), 200