1f0fef114b
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding via structlog contextvars (aiogram) and http_method/http_path (FastAPI) - Use bound_contextvars() context manager instead of clear_contextvars() to safely restore previous state instead of wiping all context - Register ContextVarsMiddleware as outermost middleware (before GlobalError) so all error logs include user context - Replace structlog.get_logger() with structlog.get_logger(__name__) across 270 calls in 265 files for meaningful logger names - Switch wrapper_class from BoundLogger to make_filtering_bound_logger() for pre-processor level filtering (performance optimization) - Migrate 1411 %-style positional arg logger calls to structlog kwargs style across 161 files via AST script - Migrate log_rotation_service.py from stdlib logging to structlog - Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking to Telegram notifications and general log files - Fix LoggingMiddleware: add from_user null-safety for channel posts, switch time.time() to time.monotonic() for duration measurement - Remove duplicate logger assignments in purchase.py, config.py, inline.py, and admin/payments.py
491 lines
22 KiB
Python
491 lines
22 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
from collections.abc import Iterable
|
|
|
|
import structlog
|
|
from aiogram import Bot
|
|
from aiohttp import web
|
|
|
|
from app.config import settings
|
|
from app.database.database import get_db
|
|
from app.services.payment_service import PaymentService
|
|
from app.services.tribute_service import TributeService
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class WebhookServer:
|
|
def __init__(self, bot: Bot):
|
|
self.bot = bot
|
|
self.app = None
|
|
self.runner = None
|
|
self.site = None
|
|
self.tribute_service = TributeService(bot)
|
|
|
|
async def create_app(self) -> web.Application:
|
|
self.app = web.Application()
|
|
|
|
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
|
|
|
if settings.is_mulenpay_enabled():
|
|
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
|
|
|
|
if settings.is_cryptobot_enabled():
|
|
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
|
|
|
if settings.is_freekassa_enabled():
|
|
self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler)
|
|
# Диагностика почему Freekassa не включена
|
|
elif settings.FREEKASSA_ENABLED:
|
|
missing = []
|
|
if settings.FREEKASSA_SHOP_ID is None:
|
|
missing.append('FREEKASSA_SHOP_ID')
|
|
if settings.FREEKASSA_API_KEY is None:
|
|
missing.append('FREEKASSA_API_KEY')
|
|
if settings.FREEKASSA_SECRET_WORD_1 is None:
|
|
missing.append('FREEKASSA_SECRET_WORD_1')
|
|
if settings.FREEKASSA_SECRET_WORD_2 is None:
|
|
missing.append('FREEKASSA_SECRET_WORD_2')
|
|
if missing:
|
|
logger.warning(
|
|
'Freekassa ENABLED=true, но webhook не зарегистрирован. Отсутствуют параметры',
|
|
value=', '.join(missing),
|
|
)
|
|
|
|
self.app.router.add_get('/health', self._health_check)
|
|
|
|
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
|
if settings.is_mulenpay_enabled():
|
|
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
|
if settings.is_cryptobot_enabled():
|
|
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
|
if settings.is_freekassa_enabled():
|
|
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
|
|
|
logger.info('Webhook сервер настроен:')
|
|
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
|
|
if settings.is_mulenpay_enabled():
|
|
mulenpay_name = settings.get_mulenpay_display_name()
|
|
logger.info(
|
|
'- webhook: POST', mulenpay_name=mulenpay_name, MULENPAY_WEBHOOK_PATH=settings.MULENPAY_WEBHOOK_PATH
|
|
)
|
|
if settings.is_cryptobot_enabled():
|
|
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
|
|
if settings.is_freekassa_enabled():
|
|
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
|
|
logger.info(' - Health check: GET /health')
|
|
|
|
return self.app
|
|
|
|
async def start(self):
|
|
try:
|
|
if not self.app:
|
|
await self.create_app()
|
|
|
|
self.runner = web.AppRunner(self.app)
|
|
await self.runner.setup()
|
|
|
|
self.site = web.TCPSite(self.runner, host=settings.TRIBUTE_WEBHOOK_HOST, port=settings.TRIBUTE_WEBHOOK_PORT)
|
|
|
|
await self.site.start()
|
|
|
|
logger.info(
|
|
'Webhook сервер запущен на',
|
|
TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST,
|
|
TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT,
|
|
)
|
|
logger.info(
|
|
'Tribute webhook URL: http://',
|
|
TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST,
|
|
TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT,
|
|
TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH,
|
|
)
|
|
if settings.is_mulenpay_enabled():
|
|
mulenpay_name = settings.get_mulenpay_display_name()
|
|
logger.info(
|
|
'webhook URL: http://',
|
|
mulenpay_name=mulenpay_name,
|
|
TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST,
|
|
TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT,
|
|
MULENPAY_WEBHOOK_PATH=settings.MULENPAY_WEBHOOK_PATH,
|
|
)
|
|
if settings.is_cryptobot_enabled():
|
|
logger.info(
|
|
'CryptoBot webhook URL: http://',
|
|
TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST,
|
|
TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT,
|
|
CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error('Ошибка запуска webhook сервера', error=e)
|
|
raise
|
|
|
|
async def stop(self):
|
|
try:
|
|
if self.site:
|
|
await self.site.stop()
|
|
logger.info('Webhook сайт остановлен')
|
|
|
|
if self.runner:
|
|
await self.runner.cleanup()
|
|
logger.info('Webhook runner очищен')
|
|
|
|
except Exception as e:
|
|
logger.error('Ошибка остановки webhook сервера', error=e)
|
|
|
|
async def _options_handler(self, request: web.Request) -> web.Response:
|
|
return web.Response(
|
|
status=200,
|
|
headers={
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
|
|
},
|
|
)
|
|
|
|
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
|
try:
|
|
mulenpay_name = settings.get_mulenpay_display_name()
|
|
logger.info('webhook', mulenpay_name=mulenpay_name, method=request.method, request_path=request.path)
|
|
logger.info('webhook headers', mulenpay_name=mulenpay_name, headers=dict(request.headers))
|
|
raw_body = await request.read()
|
|
|
|
if not raw_body:
|
|
logger.warning('Пустой webhook', mulenpay_name=mulenpay_name)
|
|
return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400)
|
|
|
|
# Временно отключаем проверку подписи для отладки
|
|
# TODO: Включить обратно после настройки MulenPay
|
|
if not self._verify_mulenpay_signature(request, raw_body):
|
|
logger.warning(
|
|
'webhook signature verification failed, but processing anyway for debugging',
|
|
mulenpay_name=mulenpay_name,
|
|
)
|
|
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
|
|
|
try:
|
|
payload = json.loads(raw_body.decode('utf-8'))
|
|
except json.JSONDecodeError as error:
|
|
logger.error('Ошибка парсинга webhook', mulenpay_name=mulenpay_name, error=error)
|
|
return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400)
|
|
|
|
payment_service = PaymentService(self.bot)
|
|
|
|
# Получаем соединение с БД
|
|
db_generator = get_db()
|
|
db = await db_generator.__anext__()
|
|
|
|
try:
|
|
success = await payment_service.process_mulenpay_callback(db, payload)
|
|
if success:
|
|
return web.json_response({'status': 'ok'}, status=200)
|
|
return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400)
|
|
except Exception as error:
|
|
logger.error('Ошибка обработки webhook', mulenpay_name=mulenpay_name, error=error, exc_info=True)
|
|
return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500)
|
|
finally:
|
|
try:
|
|
await db_generator.__anext__()
|
|
except StopAsyncIteration:
|
|
pass
|
|
|
|
except Exception as error:
|
|
mulenpay_name = settings.get_mulenpay_display_name()
|
|
logger.error('Критическая ошибка webhook', mulenpay_name=mulenpay_name, error=error, exc_info=True)
|
|
return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(error)}, status=500)
|
|
|
|
@staticmethod
|
|
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> str | None:
|
|
for header_name in header_names:
|
|
value = request.headers.get(header_name)
|
|
if value:
|
|
return value.strip()
|
|
return None
|
|
|
|
@staticmethod
|
|
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
|
secret_key = settings.MULENPAY_SECRET_KEY
|
|
display_name = settings.get_mulenpay_display_name()
|
|
if not secret_key:
|
|
logger.error('secret key is not configured', display_name=display_name)
|
|
return False
|
|
|
|
# Логируем все заголовки для отладки
|
|
logger.info('webhook headers for signature verification', display_name=display_name)
|
|
for header_name, header_value in request.headers.items():
|
|
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
|
logger.info('log event', header_name=header_name, header_value=header_value)
|
|
|
|
signature = WebhookServer._extract_mulenpay_header(
|
|
request,
|
|
(
|
|
'X-MulenPay-Signature',
|
|
'X-Mulenpay-Signature',
|
|
'X-MULENPAY-SIGNATURE',
|
|
'X-MulenPay-Webhook-Signature',
|
|
'X-Mulenpay-Webhook-Signature',
|
|
'X-MULENPAY-WEBHOOK-SIGNATURE',
|
|
'X-Signature',
|
|
'Signature',
|
|
'X-MulenPay-Sign',
|
|
'X-Mulenpay-Sign',
|
|
'X-MULENPAY-SIGN',
|
|
'MulenPay-Signature',
|
|
'Mulenpay-Signature',
|
|
'MULENPAY-SIGNATURE',
|
|
'signature',
|
|
'sign',
|
|
),
|
|
)
|
|
if signature:
|
|
normalized_signature = signature
|
|
if normalized_signature.lower().startswith('sha256='):
|
|
normalized_signature = normalized_signature.split('=', 1)[1].strip()
|
|
|
|
hmac_digest = hmac.new(
|
|
secret_key.encode('utf-8'),
|
|
raw_body,
|
|
hashlib.sha256,
|
|
).digest()
|
|
expected_hex_signature = hmac_digest.hex()
|
|
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
|
|
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
|
|
|
|
normalized_signature_lower = normalized_signature.lower()
|
|
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
|
|
return True
|
|
|
|
normalized_signature_no_padding = normalized_signature.rstrip('=')
|
|
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
|
|
return True
|
|
|
|
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
|
return True
|
|
|
|
logger.error('Неверная подпись webhook', display_name=display_name)
|
|
return False
|
|
|
|
authorization_header = request.headers.get('Authorization')
|
|
if authorization_header:
|
|
scheme, _, value = authorization_header.partition(' ')
|
|
scheme_lower = scheme.lower()
|
|
token = value.strip() if value else scheme.strip()
|
|
|
|
if scheme_lower in ('bearer', 'token'):
|
|
if hmac.compare_digest(token, secret_key):
|
|
return True
|
|
|
|
logger.error('Неверный токен webhook', scheme=scheme, display_name=display_name)
|
|
return False
|
|
|
|
if not value and hmac.compare_digest(token, secret_key):
|
|
return True
|
|
|
|
fallback_token = WebhookServer._extract_mulenpay_header(
|
|
request,
|
|
(
|
|
'X-MulenPay-Token',
|
|
'X-Mulenpay-Token',
|
|
'X-Webhook-Token',
|
|
),
|
|
)
|
|
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
|
return True
|
|
|
|
logger.info(
|
|
'%s webhook headers received: %s',
|
|
display_name,
|
|
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()},
|
|
)
|
|
|
|
logger.error('Отсутствует подпись webhook', display_name=display_name)
|
|
return False
|
|
|
|
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
|
try:
|
|
logger.info('Получен Tribute webhook', method=request.method, path=request.path)
|
|
logger.info('Headers', value=dict(request.headers))
|
|
|
|
raw_body = await request.read()
|
|
|
|
if not raw_body:
|
|
logger.warning('Получен пустой webhook от Tribute')
|
|
return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400)
|
|
|
|
payload = raw_body.decode('utf-8')
|
|
logger.info('Payload', payload=payload)
|
|
|
|
try:
|
|
webhook_data = json.loads(payload)
|
|
logger.info('Распарсенные данные', webhook_data=webhook_data)
|
|
except json.JSONDecodeError as e:
|
|
logger.error('Ошибка парсинга JSON', error=e)
|
|
return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400)
|
|
|
|
signature = request.headers.get('trbt-signature')
|
|
logger.info('Signature', signature=signature)
|
|
|
|
if not signature:
|
|
logger.error('Отсутствует заголовок подписи Tribute webhook')
|
|
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
|
|
|
|
if settings.TRIBUTE_API_KEY:
|
|
from app.external.tribute import TributeService as TributeAPI
|
|
|
|
tribute_api = TributeAPI()
|
|
if not tribute_api.verify_webhook_signature(payload, signature):
|
|
logger.error('Неверная подпись Tribute webhook')
|
|
return web.json_response({'status': 'error', 'reason': 'invalid_signature'}, status=401)
|
|
|
|
result = await self.tribute_service.process_webhook(payload)
|
|
|
|
if result:
|
|
logger.info('Tribute webhook обработан успешно', result=result)
|
|
return web.json_response({'status': 'ok', 'result': result}, status=200)
|
|
logger.error('Ошибка обработки Tribute webhook')
|
|
return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400)
|
|
|
|
except Exception as e:
|
|
logger.error('Критическая ошибка обработки Tribute webhook', error=e, exc_info=True)
|
|
return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(e)}, status=500)
|
|
|
|
async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
|
|
try:
|
|
logger.info('Получен CryptoBot webhook', method=request.method, path=request.path)
|
|
logger.info('Headers', value=dict(request.headers))
|
|
|
|
raw_body = await request.read()
|
|
|
|
if not raw_body:
|
|
logger.warning('Получен пустой CryptoBot webhook')
|
|
return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400)
|
|
|
|
payload = raw_body.decode('utf-8')
|
|
logger.info('CryptoBot Payload', payload=payload)
|
|
|
|
try:
|
|
webhook_data = json.loads(payload)
|
|
logger.info('CryptoBot данные', webhook_data=webhook_data)
|
|
except json.JSONDecodeError as e:
|
|
logger.error('Ошибка парсинга CryptoBot JSON', error=e)
|
|
return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400)
|
|
|
|
signature = request.headers.get('Crypto-Pay-API-Signature')
|
|
logger.info('CryptoBot Signature', signature=signature)
|
|
|
|
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
|
from app.external.cryptobot import CryptoBotService
|
|
|
|
cryptobot_service = CryptoBotService()
|
|
if not cryptobot_service.verify_webhook_signature(payload, signature):
|
|
logger.error('Неверная подпись CryptoBot webhook')
|
|
return web.json_response({'status': 'error', 'reason': 'invalid_signature'}, status=401)
|
|
|
|
from app.database.database import AsyncSessionLocal
|
|
from app.services.payment_service import PaymentService
|
|
|
|
payment_service = PaymentService(self.bot)
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
|
|
|
|
if result:
|
|
logger.info('CryptoBot webhook обработан успешно')
|
|
return web.json_response({'status': 'ok'}, status=200)
|
|
logger.error('Ошибка обработки CryptoBot webhook')
|
|
return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400)
|
|
|
|
except Exception as e:
|
|
logger.error('Критическая ошибка обработки CryptoBot webhook', error=e, exc_info=True)
|
|
return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(e)}, status=500)
|
|
|
|
async def _health_check(self, request: web.Request) -> web.Response:
|
|
return web.json_response(
|
|
{
|
|
'status': 'ok',
|
|
'service': 'payment-webhooks',
|
|
'tribute_enabled': settings.TRIBUTE_ENABLED,
|
|
'cryptobot_enabled': settings.is_cryptobot_enabled(),
|
|
'freekassa_enabled': settings.is_freekassa_enabled(),
|
|
'port': settings.TRIBUTE_WEBHOOK_PORT,
|
|
'tribute_path': settings.TRIBUTE_WEBHOOK_PATH,
|
|
'cryptobot_path': settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None,
|
|
'freekassa_path': settings.FREEKASSA_WEBHOOK_PATH if settings.is_freekassa_enabled() else None,
|
|
}
|
|
)
|
|
|
|
async def _freekassa_webhook_handler(self, request: web.Request) -> web.Response:
|
|
"""
|
|
Обработчик webhook от Freekassa.
|
|
|
|
Freekassa отправляет POST запрос с form-data:
|
|
- MERCHANT_ID: ID магазина
|
|
- AMOUNT: Сумма платежа
|
|
- MERCHANT_ORDER_ID: Наш order_id
|
|
- SIGN: Подпись MD5(shop_id:amount:secret2:order_id)
|
|
- intid: ID транзакции Freekassa
|
|
- CUR_ID: ID валюты/платежной системы
|
|
"""
|
|
try:
|
|
logger.info('Получен Freekassa webhook', method=request.method, path=request.path)
|
|
|
|
# Получаем IP клиента
|
|
client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
|
if not client_ip:
|
|
client_ip = request.remote or 'unknown'
|
|
logger.info('Freekassa webhook IP', client_ip=client_ip)
|
|
|
|
# Freekassa отправляет form-data
|
|
try:
|
|
form_data = await request.post()
|
|
except Exception as e:
|
|
logger.error('Ошибка парсинга Freekassa form-data', error=e)
|
|
return web.Response(text='NO', status=400)
|
|
|
|
logger.info('Freekassa webhook data', value=dict(form_data))
|
|
|
|
# Извлекаем параметры
|
|
merchant_id = int(form_data.get('MERCHANT_ID', 0))
|
|
amount = float(form_data.get('AMOUNT', 0))
|
|
order_id = form_data.get('MERCHANT_ORDER_ID', '')
|
|
sign = form_data.get('SIGN', '')
|
|
intid = form_data.get('intid', '')
|
|
cur_id = form_data.get('CUR_ID')
|
|
|
|
if not order_id or not sign:
|
|
logger.warning('Freekassa webhook: отсутствуют обязательные параметры')
|
|
return web.Response(text='NO', status=400)
|
|
|
|
# Обрабатываем платеж через PaymentService
|
|
from app.database.database import AsyncSessionLocal
|
|
from app.services.payment_service import PaymentService
|
|
|
|
payment_service = PaymentService(self.bot)
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
success = await payment_service.process_freekassa_webhook(
|
|
db=db,
|
|
merchant_id=merchant_id,
|
|
amount=amount,
|
|
order_id=order_id,
|
|
sign=sign,
|
|
intid=intid,
|
|
cur_id=int(cur_id) if cur_id else None,
|
|
client_ip=client_ip,
|
|
)
|
|
|
|
if success:
|
|
logger.info('Freekassa webhook обработан успешно: order_id', order_id=order_id)
|
|
# Freekassa ожидает YES в ответе
|
|
return web.Response(text='YES', status=200)
|
|
logger.error('Ошибка обработки Freekassa webhook: order_id', order_id=order_id)
|
|
return web.Response(text='NO', status=400)
|
|
|
|
except Exception as e:
|
|
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
|
return web.Response(text='NO', status=500)
|