Files
gridbot/consult_endpoint.py

160 lines
6.1 KiB
Python
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.
"""
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