Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user