diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index 54e2077..5da6d7f 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -43,10 +43,19 @@ async def build_and_start_web_app( telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL) if telegram_uses_webhook_mode: - telegram_webhook_path = f"/{settings.BOT_TOKEN}" - app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot)) + telegram_webhook_path = settings.telegram_webhook_path + app.router.add_post( + telegram_webhook_path, + SimpleRequestHandler( + dispatcher=dp, + bot=bot, + secret_token=settings.TELEGRAM_WEBHOOK_SECRET, + ), + ) logging.info( - f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" + "Telegram webhook route configured at: [POST] %s (secret_token=%s)", + telegram_webhook_path, + "set" if settings.TELEGRAM_WEBHOOK_SECRET else "not_set", ) from bot.handlers.user.payment import yookassa_webhook_route @@ -59,7 +68,7 @@ async def build_and_start_web_app( cp_path = settings.cryptopay_webhook_path if cp_path.startswith("/"): app.router.add_post(cp_path, cryptopay_webhook_route) - logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}") + logging.info("CryptoPay webhook route configured at: [POST] %s", cp_path) fk_path = settings.freekassa_webhook_path if fk_path.startswith("/"): diff --git a/bot/main_bot.py b/bot/main_bot.py index b236e53..ae15640 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -58,52 +58,47 @@ async def on_startup_configured(dispatcher: Dispatcher): telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL if telegram_webhook_url_to_set: - full_telegram_webhook_url = ( - f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}" - ) + full_telegram_webhook_url = settings.telegram_full_webhook_url + if not full_telegram_webhook_url: + logging.error( + "STARTUP: Telegram webhook URL could not be built (WEBHOOK_BASE_URL missing)." + ) + raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") logging.info( - f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}" + "STARTUP: Attempting to set Telegram webhook (path=%s)", + settings.telegram_webhook_path, ) - if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED": - try: - current_webhook_info = await bot.get_webhook_info() - logging.info( - f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" - ) + try: + current_webhook_info = await bot.get_webhook_info() + if current_webhook_info.url: + logging.info("STARTUP: Telegram webhook already set (non-empty URL).") + else: + logging.info("STARTUP: Telegram webhook currently empty (will set).") - set_success = await bot.set_webhook( - url=full_telegram_webhook_url, - drop_pending_updates=True, - allowed_updates=dispatcher.resolve_used_update_types(), - ) - if set_success: - logging.info( - f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)." - ) - else: - logging.error( - f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)." - ) + set_success = await bot.set_webhook( + url=full_telegram_webhook_url, + drop_pending_updates=True, + allowed_updates=dispatcher.resolve_used_update_types(), + secret_token=settings.TELEGRAM_WEBHOOK_SECRET, + ) + if set_success: + logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).") + else: + logging.error("STARTUP: bot.set_webhook returned FAILURE (False).") - new_webhook_info = await bot.get_webhook_info() - logging.info( - f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}" - ) - if not new_webhook_info.url: - logging.error( - "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." - ) - - except Exception as e_setwebhook: + new_webhook_info = await bot.get_webhook_info() + if not new_webhook_info.url: logging.error( - f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}", - exc_info=True, + "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." ) - else: + + except Exception as e_setwebhook: logging.error( - "STARTUP: Skipped setting Telegram webhook due to security or configuration error." + "STARTUP: EXCEPTION during set/get Telegram webhook: %s", + e_setwebhook, + exc_info=True, ) else: logging.error( diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index a86e594..e9aac94 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -59,6 +59,29 @@ class PanelApiService: headers["Authorization"] = f"Bearer {self.api_key}" return headers + @staticmethod + def _sanitize_payload_for_log(payload: Any) -> Any: + if isinstance(payload, dict): + redacted: Dict[str, Any] = {} + for key, value in payload.items(): + lowered = str(key).lower() + if any(mask_key in lowered for mask_key in ( + "token", + "secret", + "password", + "authorization", + "api_key", + "apikey", + "key", + )): + redacted[key] = "***" + else: + redacted[key] = PanelApiService._sanitize_payload_for_log(value) + return redacted + if isinstance(payload, list): + return [PanelApiService._sanitize_payload_for_log(item) for item in payload] + return payload + async def _request(self, method: str, endpoint: str, @@ -83,8 +106,8 @@ class PanelApiService: if current_params: try: url_with_params_for_log += "?" + urlencode(current_params) - except Exception: - pass + except Exception as exc: + logging.debug("Failed to encode params for panel API log URL: %s", exc) json_payload_for_log = kwargs.get('json') if method.upper() in [ "POST", "PATCH", "PUT" @@ -92,10 +115,11 @@ class PanelApiService: log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}" if json_payload_for_log: try: - payload_str = json.dumps(json_payload_for_log) + sanitized_payload = self._sanitize_payload_for_log(json_payload_for_log) + payload_str = json.dumps(sanitized_payload) log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}" except Exception: - log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." + log_prefix += " | Payload: " try: async with aiohttp_session.request(method.upper(), url_for_request, @@ -106,7 +130,8 @@ class PanelApiService: log_suffix = f"| Status: {response_status}" - if log_full_response or not (200 <= response_status < 300): + should_log_full_body = bool(log_full_response and self.settings.LOG_LEVEL == "DEBUG") + if should_log_full_body or not (200 <= response_status < 300): try: parsed_json_for_log = json.loads(response_text) pretty_response_text = json.dumps(parsed_json_for_log, @@ -404,7 +429,10 @@ class PanelApiService: return response logging.error( - f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" + "Failed to create panel user '%s'. Payload: %s, Response: %s", + username_on_panel, + self._sanitize_payload_for_log(payload), + response if not log_response else "(full response logged above)", ) return response @@ -426,7 +454,10 @@ class PanelApiService: return full_response.get("response") logging.error( - f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" + "Failed to update user %s details on panel. Payload: %s, Response: %s", + user_uuid, + self._sanitize_payload_for_log(update_payload), + full_response if not log_response else "(logged above)", ) return None diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index cee1320..76ef90b 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -136,16 +136,19 @@ class PanelWebhookService: ) async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response: - if self.settings.PANEL_WEBHOOK_SECRET: - if not signature_header: - return web.Response(status=403, text="no_signature") - expected_sig = hmac.new( - self.settings.PANEL_WEBHOOK_SECRET.encode(), - raw_body, - hashlib.sha256, - ).hexdigest() - if not hmac.compare_digest(expected_sig, signature_header): - return web.Response(status=403, text="invalid_signature") + if not self.settings.PANEL_WEBHOOK_SECRET: + logging.critical("Panel webhook rejected: PANEL_WEBHOOK_SECRET is not configured") + return web.Response(status=503, text="panel_webhook_secret_required") + + if not signature_header: + return web.Response(status=403, text="no_signature") + expected_sig = hmac.new( + self.settings.PANEL_WEBHOOK_SECRET.encode(), + raw_body, + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expected_sig, signature_header): + return web.Response(status=403, text="invalid_signature") try: payload = json.loads(raw_body.decode()) diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 4bd2252..a7d3855 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -81,7 +81,7 @@ class StarsService: title=description, description=description, payload=payload, - provider_token="", + provider_token=self.settings.STARS_PROVIDER_TOKEN or "", currency="XTR", prices=prices, ) diff --git a/config/settings.py b/config/settings.py index bd254b0..304eec1 100644 --- a/config/settings.py +++ b/config/settings.py @@ -77,6 +77,14 @@ class Settings(BaseSettings): ) WEBHOOK_BASE_URL: Optional[str] = None + TELEGRAM_WEBHOOK_PATH: str = Field( + default="/webhook/telegram", + description="Relative path for Telegram webhook endpoint", + ) + TELEGRAM_WEBHOOK_SECRET: Optional[str] = Field( + default=None, + description="Secret token for Telegram webhook header validation", + ) CRYPTOPAY_TOKEN: Optional[str] = None CRYPTOPAY_NETWORK: str = Field(default="mainnet") @@ -114,6 +122,10 @@ class Settings(BaseSettings): YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) + STARS_PROVIDER_TOKEN: Optional[str] = Field( + default="", + description="Provider token for Telegram invoices. For Stars (XTR) should stay empty.", + ) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", @@ -201,7 +213,7 @@ class Settings(BaseSettings): CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs") CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled") - WEB_SERVER_HOST: str = Field(default="0.0.0.0") + WEB_SERVER_HOST: str = Field(default="127.0.0.1") WEB_SERVER_PORT: int = Field(default=8080) LOGS_PAGE_SIZE: int = Field(default=10) @@ -287,6 +299,22 @@ class Settings(BaseSettings): return cleaned return None + @computed_field + @property + def telegram_webhook_path(self) -> str: + path = (self.TELEGRAM_WEBHOOK_PATH or "").strip() or "/webhook/telegram" + if not path.startswith("/"): + path = f"/{path}" + return path + + @computed_field + @property + def telegram_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.telegram_webhook_path}" + return None + @computed_field @property def yookassa_webhook_path(self) -> str: @@ -528,6 +556,18 @@ class Settings(BaseSettings): ) LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)") + LOG_STORE_MESSAGE_CONTENT: bool = Field( + default=False, + description="Store message/callback content in message logs", + ) + LOG_STORE_RAW_UPDATES: bool = Field( + default=False, + description="Store raw update previews in message logs", + ) + LOG_EXPORT_INCLUDE_SENSITIVE: bool = Field( + default=False, + description="Include content/raw update fields in admin CSV export", + ) @field_validator('LOG_LEVEL', mode='before') @classmethod @@ -557,12 +597,29 @@ class Settings(BaseSettings): sanitized[key] = value return sanitized + @field_validator( + 'TELEGRAM_WEBHOOK_PATH', + mode='before', + ) + @classmethod + def normalize_webhook_path(cls, v): + if not isinstance(v, str): + return "/webhook/telegram" + cleaned = v.strip() + if not cleaned: + return "/webhook/telegram" + if not cleaned.startswith("/"): + cleaned = f"/{cleaned}" + return cleaned + @field_validator( 'REQUIRED_CHANNEL_LINK', 'PLATEGA_RETURN_URL', 'PLATEGA_FAILED_URL', 'SEVERPAY_RETURN_URL', 'CRYPT4_REDIRECT_URL', + 'TELEGRAM_WEBHOOK_SECRET', + 'PANEL_WEBHOOK_SECRET', mode='before', ) @classmethod @@ -619,6 +676,11 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: PANEL_API_URL is not set. Panel integration will not work." ) + if _settings_instance.WEBHOOK_BASE_URL and not _settings_instance.TELEGRAM_WEBHOOK_SECRET: + logging.warning( + "WARNING: TELEGRAM_WEBHOOK_SECRET is empty while webhook mode is enabled. " + "Set TELEGRAM_WEBHOOK_SECRET to validate X-Telegram-Bot-Api-Secret-Token header." + ) if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY: logging.warning( "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." diff --git a/db/database_setup.py b/db/database_setup.py index 711c11b..ba084fa 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlsplit, urlunsplit from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import sessionmaker @@ -9,12 +10,27 @@ from .migrator import run_database_migrations async_engine = None +def _mask_db_url(url: str) -> str: + try: + parsed = urlsplit(url) + if parsed.username is None: + return url + username = parsed.username + host = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + netloc = f"{username}:***@{host}{port}" + return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + except Exception: + return "" + + def init_db_connection(settings: Settings) -> sessionmaker: global async_engine if async_engine is None: + masked_url = _mask_db_url(settings.DATABASE_URL) logging.info( - f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}" + f"Attempting to create SQLAlchemy engine with URL: {masked_url}" ) async_engine = create_async_engine( settings.DATABASE_URL,