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