diff --git a/app/config.py b/app/config.py index 49f4a067..4d2f184a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,3 @@ -import hashlib import logging import os import re @@ -277,8 +276,6 @@ class Settings(BaseSettings): BACKUP_SEND_CHAT_ID: Optional[str] = None BACKUP_SEND_TOPIC_ID: Optional[int] = None - EXTERNAL_ADMIN_TOKEN: Optional[str] = None - @field_validator('SERVER_STATUS_MODE', mode='before') @classmethod def normalize_server_status_mode(cls, value: Optional[str]) -> str: @@ -559,20 +556,6 @@ class Settings(BaseSettings): def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL - - def build_external_admin_token(self, bot_username: str) -> str: - """Генерирует детерминированный токен внешней админки по username.""" - normalized = (bot_username or "").strip().lstrip("@").lower() - if not normalized: - raise ValueError("Bot username is required to build external admin token") - - seed = f"remnawave.external_admin::{normalized}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - return digest[:48] - - def get_external_admin_token(self) -> Optional[str]: - token = (self.EXTERNAL_ADMIN_TOKEN or "").strip() - return token or None def is_traffic_selectable(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "selectable" diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 9b1e2cc1..ad0bea38 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -18,10 +18,7 @@ from app.config import settings from app.services.remnawave_service import RemnaWaveService from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) +from app.services.system_settings_service import bot_configuration_service from app.states import BotConfigStates from app.utils.decorators import admin_required, error_handler from app.utils.currency_converter import currency_converter @@ -110,12 +107,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "icon": "⚡", "categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"), }, - "external_admin": { - "title": "🛡️ Внешняя админка", - "description": "Токен, по которому внешняя админка проверяет запросы.", - "icon": "🛡️", - "categories": ("EXTERNAL_ADMIN",), - }, } CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( @@ -132,7 +123,6 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( "server", "maintenance", "advanced", - "external_admin", ) CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple( @@ -697,12 +687,6 @@ async def apply_preset( try: await bot_configuration_service.set_value(db, setting_key, value) applied.append(setting_key) - except ReadOnlySettingError: - logging.getLogger(__name__).info( - "Пропускаем настройку %s из пресета %s: только для чтения", - setting_key, - preset_key, - ) except Exception as error: logging.getLogger(__name__).warning( "Не удалось применить пресет %s для %s: %s", @@ -870,14 +854,8 @@ async def handle_import_message( errors.append(f"{setting_key}: {error}") continue - if bot_configuration_service.is_read_only(setting_key): - skipped.append(setting_key) - continue - try: - await bot_configuration_service.set_value(db, setting_key, value_to_apply) - applied.append(setting_key) - except ReadOnlySettingError: - skipped.append(setting_key) + await bot_configuration_service.set_value(db, setting_key, value_to_apply) + applied.append(setting_key) await db.commit() @@ -1383,10 +1361,9 @@ def _build_setting_keyboard( definition = bot_configuration_service.get_definition(key) rows: list[list[types.InlineKeyboardButton]] = [] callback_token = bot_configuration_service.get_callback_token(key) - is_read_only = bot_configuration_service.is_read_only(key) choice_options = bot_configuration_service.get_choice_options(key) - if choice_options and not is_read_only: + if choice_options: current_value = bot_configuration_service.get_current_value(key) choice_buttons: list[types.InlineKeyboardButton] = [] for option in choice_options: @@ -1408,7 +1385,7 @@ def _build_setting_keyboard( for chunk in _chunk(choice_buttons, 2): rows.append(list(chunk)) - if definition.python_type is bool and not is_read_only: + if definition.python_type is bool: rows.append([ types.InlineKeyboardButton( text="🔁 Переключить", @@ -1418,17 +1395,16 @@ def _build_setting_keyboard( ) ]) - if not is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="✏️ Изменить", - callback_data=( - f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ]) + rows.append([ + types.InlineKeyboardButton( + text="✏️ Изменить", + callback_data=( + f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ) + ]) - if bot_configuration_service.has_override(key) and not is_read_only: + if bot_configuration_service.has_override(key): rows.append([ types.InlineKeyboardButton( text="♻️ Сбросить", @@ -1438,14 +1414,6 @@ def _build_setting_keyboard( ) ]) - if is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="🔒 Только для чтения", - callback_data="botcfg_group:noop", - ) - ]) - rows.append([ types.InlineKeyboardButton( text="⬅️ Назад", @@ -1470,11 +1438,6 @@ def _render_setting_text(key: str) -> str: f"📌 Текущее: {summary['current']}", f"📦 По умолчанию: {summary['original']}", f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}", - *( - ["🔒 Режим: Только для чтения (управляется автоматически)"] - if summary.get("is_read_only") - else [] - ), "", f"📘 Описание: {guidance['description']}", f"📐 Формат: {guidance['format']}", @@ -2103,9 +2066,6 @@ async def start_edit_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return definition = bot_configuration_service.get_definition(key) summary = bot_configuration_service.get_setting_summary(key) @@ -2171,23 +2131,13 @@ async def handle_edit_setting( await state.clear() return - if bot_configuration_service.is_read_only(key): - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return - try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: await message.answer(f"⚠️ {error}") return - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError: - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return + await bot_configuration_service.set_value(db, key, value) await db.commit() text = _render_setting_text(key) @@ -2222,23 +2172,13 @@ async def handle_direct_setting_input( if not key: return - if bot_configuration_service.is_read_only(key): - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return - try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: await message.answer(f"⚠️ {error}") return - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError: - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return + await bot_configuration_service.set_value(db, key, value) await db.commit() text = _render_setting_text(key) @@ -2280,14 +2220,7 @@ async def reset_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return - try: - await bot_configuration_service.reset_value(db, key) - except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return + await bot_configuration_service.reset_value(db, key) await db.commit() text = _render_setting_text(key) @@ -2327,16 +2260,9 @@ async def toggle_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return current = bot_configuration_service.get_current_value(key) new_value = not bool(current) - try: - await bot_configuration_service.set_value(db, key, new_value) - except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return + await bot_configuration_service.set_value(db, key, new_value) await db.commit() text = _render_setting_text(key) @@ -2378,9 +2304,6 @@ async def apply_setting_choice( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return try: value = bot_configuration_service.resolve_choice_token(key, choice_token) @@ -2388,11 +2311,7 @@ async def apply_setting_choice( await callback.answer("Это значение больше недоступно", show_alert=True) return - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return + await bot_configuration_service.set_value(db, key, value) await db.commit() text = _render_setting_text(key) diff --git a/app/services/external_admin_service.py b/app/services/external_admin_service.py deleted file mode 100644 index 469b6ca5..00000000 --- a/app/services/external_admin_service.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Утилиты для синхронизации токена внешней админки.""" - -from __future__ import annotations - -import logging -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.exc import SQLAlchemyError - -from app.config import settings -from app.database.database import AsyncSessionLocal -from app.database.models import SystemSetting -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) - - -logger = logging.getLogger(__name__) - - -async def ensure_external_admin_token(bot_username: Optional[str]) -> Optional[str]: - """Генерирует и сохраняет токен внешней админки, если требуется.""" - - username_raw = (bot_username or "").strip() - if not username_raw: - logger.warning( - "⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует", - ) - return None - - normalized_username = username_raw.lstrip("@").lower() - if not normalized_username: - logger.warning( - "⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации", - ) - return None - - try: - token = settings.build_external_admin_token(normalized_username) - except Exception as error: # pragma: no cover - защитный блок - logger.error("❌ Ошибка генерации токена внешней админки: %s", error) - return None - - try: - async with AsyncSessionLocal() as session: - result = await session.execute( - select(SystemSetting.value).where(SystemSetting.key == "EXTERNAL_ADMIN_TOKEN") - ) - existing = result.scalar_one_or_none() - - if existing == token: - if settings.get_external_admin_token() != token: - settings.EXTERNAL_ADMIN_TOKEN = token - return token - - try: - await bot_configuration_service.set_value( - session, - "EXTERNAL_ADMIN_TOKEN", - token, - force=True, - ) - await session.commit() - logger.info( - "✅ Токен внешней админки синхронизирован для @%s", - normalized_username, - ) - except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение - await session.rollback() - logger.warning( - "⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа", - ) - return None - - return token - except SQLAlchemyError as error: - logger.error("❌ Ошибка сохранения токена внешней админки: %s", error) - return None - diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index f5a1bb9a..e8529e0d 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -56,16 +56,9 @@ class ChoiceOption: description: Optional[str] = None -class ReadOnlySettingError(RuntimeError): - """Исключение, выбрасываемое при попытке изменить настройку только для чтения.""" - - class BotConfigurationService: EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"} - READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN"} - PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN"} - CATEGORY_TITLES: Dict[str, str] = { "CORE": "🤖 Основные настройки", "SUPPORT": "💬 Поддержка и тикеты", @@ -78,7 +71,6 @@ class BotConfigurationService: "TRIBUTE": "🎁 Tribute", "MULENPAY": "💰 MulenPay", "PAL24": "🏦 PAL24 / PayPalych", - "EXTERNAL_ADMIN": "🛡️ Внешняя админка", "SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты", "PERIODS": "📆 Периоды подписок", "SUBSCRIPTION_PRICES": "💵 Стоимость тарифов", @@ -126,7 +118,6 @@ class BotConfigurationService: "PAL24": "PAL24 / PayPalych подключения и лимиты.", "TRIBUTE": "Tribute и донат-сервисы.", "TELEGRAM": "Telegram Stars и их стоимость.", - "EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.", "SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.", "PERIODS": "Доступные периоды подписок и продлений.", "SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.", @@ -264,7 +255,6 @@ class BotConfigurationService: "MULENPAY_": "MULENPAY", "PAL24_": "PAL24", "PAYMENT_": "PAYMENT", - "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", "CONNECT_BUTTON_HAPP": "HAPP", "HAPP_": "HAPP", "SKIP_": "SKIP", @@ -404,13 +394,6 @@ class BotConfigurationService: "warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.", "dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD", }, - "EXTERNAL_ADMIN_TOKEN": { - "description": "Детерминированный токен, который использует внешняя админка для проверки запросов.", - "format": "Значение генерируется автоматически из username бота и доступно только для чтения.", - "example": "Генерируется автоматически", - "warning": "Токен обновится только при смене username бота.", - "dependencies": "Username телеграм-бота", - }, } @classmethod @@ -422,10 +405,6 @@ class BotConfigurationService: definition = cls.get_definition(key) return definition.python_type is bool - @classmethod - def is_read_only(cls, key: str) -> bool: - return key in cls.READ_ONLY_KEYS - @classmethod def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]: if isinstance(value, bool): @@ -476,8 +455,6 @@ class BotConfigurationService: cleaned = value.strip() if not cleaned: return "—" - if key in cls.PLAIN_TEXT_KEYS: - return cleaned if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")): return "••••••••" items = cls._split_comma_values(cleaned) @@ -883,17 +860,7 @@ class BotConfigurationService: return parsed_value @classmethod - async def set_value( - cls, - db: AsyncSession, - key: str, - value: Any, - *, - force: bool = False, - ) -> None: - if cls.is_read_only(key) and not force: - raise ReadOnlySettingError(f"Setting {key} is read-only") - + async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None: raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) cls._overrides_raw[key] = raw_value @@ -903,16 +870,7 @@ class BotConfigurationService: await cls._sync_default_web_api_token() @classmethod - async def reset_value( - cls, - db: AsyncSession, - key: str, - *, - force: bool = False, - ) -> None: - if cls.is_read_only(key) and not force: - raise ReadOnlySettingError(f"Setting {key} is read-only") - + async def reset_value(cls, db: AsyncSession, key: str) -> None: await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) original = cls.get_original_value(key) @@ -967,7 +925,6 @@ class BotConfigurationService: "category_key": definition.category_key, "category_label": definition.category_label, "has_override": has_override, - "is_read_only": cls.is_read_only(key), } diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py index 46965da4..92869266 100644 --- a/app/webapi/routes/config.py +++ b/app/webapi/routes/config.py @@ -5,10 +5,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) +from app.services.system_settings_service import bot_configuration_service from ..dependencies import get_db_session, require_api_token from ..schemas.config import ( @@ -97,7 +94,6 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe current=current, original=original, has_override=has_override, - read_only=bot_configuration_service.is_read_only(definition.key), choices=choices, ) @@ -157,10 +153,7 @@ async def update_setting( raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error value = _coerce_value(key, payload.value) - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError as error: - raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error + await bot_configuration_service.set_value(db, key, value) await db.commit() return _serialize_definition(definition) @@ -177,9 +170,6 @@ async def reset_setting( except KeyError as error: raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error - try: - await bot_configuration_service.reset_value(db, key) - except ReadOnlySettingError as error: - raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error + await bot_configuration_service.reset_value(db, key) await db.commit() return _serialize_definition(definition) diff --git a/app/webapi/schemas/config.py b/app/webapi/schemas/config.py index 7497444d..39aedbad 100644 --- a/app/webapi/schemas/config.py +++ b/app/webapi/schemas/config.py @@ -45,7 +45,6 @@ class SettingDefinition(BaseModel): current: Any | None = Field(default=None) original: Any | None = Field(default=None) has_override: bool - read_only: bool = Field(default=False) choices: list[SettingChoice] = Field(default_factory=list) model_config = ConfigDict(extra="forbid") diff --git a/main.py b/main.py index e1cd4350..51d6bfa2 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,6 @@ from app.services.backup_service import backup_service from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service -from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service from app.utils.startup_timeline import StartupTimeline @@ -187,22 +186,6 @@ async def main(): payment_service = PaymentService(bot) - async with timeline.stage( - "Внешняя админка", - "🛡️", - success_message="Токен внешней админки готов", - ) as stage: - try: - bot_user = await bot.get_me() - token = await ensure_external_admin_token(bot_user.username) - if token: - stage.log("Токен синхронизирован") - else: - stage.warning("Не удалось получить токен внешней админки") - except Exception as error: # pragma: no cover - защитный блок - stage.warning(f"Ошибка подготовки внешней админки: {error}") - logger.error("❌ Ошибка подготовки внешней админки: %s", error) - webhook_needed = ( settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled()