From 3aede8fe95de83214ba5f4e7ab589f79d58cb2d1 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 27 May 2026 13:53:30 +0300 Subject: [PATCH] feat: add backups feature --- .env.example | 13 + README.md | 4 +- backend/bot/app/web/admin_api.py | 2 + backend/bot/app/web/admin_api_impl/backups.py | 141 ++++ backend/bot/app/web/admin_api_impl/routes.py | 3 + .../bot/app/web/admin_settings_manifest.py | 58 +- backend/bot/app/web/webapp/routes.py | 2 +- backend/bot/services/backup_archive.py | 108 +++ .../bot/services/backup_restore_service.py | 660 ++++++++++++++++++ backend/bot/services/backup_worker.py | 419 +++++++++++ .../bot/services/locale_override_service.py | 2 + .../bot/services/settings_override_service.py | 29 + backend/config/settings.py | 41 +- backend/main_worker.py | 3 + deploy/docker/Dockerfile | 14 + deploy/examples/caddy/.env.example | 10 + deploy/examples/caddy/docker-compose.yml | 2 + deploy/examples/newt/.env.example | 10 + deploy/examples/newt/docker-compose.yml | 2 + deploy/examples/nginx/.env.example | 10 + deploy/examples/nginx/docker-compose.yml | 2 + deploy/examples/no-proxy/.env.example | 11 + deploy/examples/no-proxy/docker-compose.yml | 2 + docker-compose.yml | 2 + docs-site/astro.config.mjs | 3 + docs-site/scripts/sync-docs.mjs | 5 +- docs/configuration/env-vars.md | 25 + docs/features/admin-panel.md | 5 + docs/features/backups.md | 142 ++++ docs/features/core.md | 2 +- docs/features/email-login.md | 108 +++ docs/features/support.md | 2 +- docs/features/telegram-auth.md | 98 +++ docs/features/web-app.md | 56 +- docs/getting-started/configuration.md | 4 +- docs/getting-started/deployment.md | 2 + docs/getting-started/overview.md | 4 +- docs/getting-started/setup.md | 4 +- docs/index.md | 4 +- docs/troubleshooting/logs.md | 2 +- docs/troubleshooting/maintenance.md | 15 +- frontend/src/admin/AdminPanel.svelte | 14 + .../src/admin/sections/BackupsSection.svelte | 351 ++++++++++ .../src/admin/sections/SettingsSection.svelte | 3 + frontend/src/lib/admin/stores/backupsStore.js | 93 +++ frontend/src/lib/components/ui/icons.js | 1 + frontend/src/lib/webapp/mockApi.js | 63 ++ locales/en.json | 42 ++ locales/ru.json | 42 ++ tests/test_admin_settings_manifest_i18n.py | 42 +- tests/test_backup_restore_service.py | 199 ++++++ tests/test_backup_worker.py | 168 +++++ tests/test_main_bot_startup.py | 19 +- tests/test_settings.py | 22 + tests/test_webapp_route_contract.py | 12 + 55 files changed, 3032 insertions(+), 70 deletions(-) create mode 100644 backend/bot/app/web/admin_api_impl/backups.py create mode 100644 backend/bot/services/backup_archive.py create mode 100644 backend/bot/services/backup_restore_service.py create mode 100644 backend/bot/services/backup_worker.py create mode 100644 docs/features/backups.md create mode 100644 docs/features/email-login.md create mode 100644 docs/features/telegram-auth.md create mode 100644 frontend/src/admin/sections/BackupsSection.svelte create mode 100644 frontend/src/lib/admin/stores/backupsStore.js create mode 100644 tests/test_backup_restore_service.py create mode 100644 tests/test_backup_worker.py diff --git a/.env.example b/.env.example index 1a0be7b..2c4ccdf 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,16 @@ FRONTEND_PORT=8082 # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. # Keep loopback for local proxy; add your proxy network if needed. TRUSTED_PROXIES=127.0.0.1,::1 + +# Periodic backups from the worker container. Disabled by default. +# The archive contains a pg_dump custom-format dump and a snapshot of the +# compose directory mounted read-only into /app/compose-source. +BACKUP_ENABLED=False +BACKUP_CHAT_ID= +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_LOCAL_RETENTION=100 +BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True +BACKUP_ARCHIVE_SIGNATURE_SECRET= +BACKUP_COMPOSE_ENABLED=True +COMPOSE_BACKUP_SOURCE=. +COMPOSE_RESTORE_MODE=rw diff --git a/README.md b/README.md index 6e1adb5..6a96d9a 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,11 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси. - [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку. - [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам. +- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку. - [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика. - [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога. -- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, Telegram OAuth, вход по email, инструкции установки и реферальные ссылки. +- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки. +- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина. - [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. - [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`. diff --git a/backend/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py index ad06c70..29e0f0a 100644 --- a/backend/bot/app/web/admin_api.py +++ b/backend/bot/app/web/admin_api.py @@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import ( _runtime as _runtime, ads as _ads, auth as _auth, + backups as _backups, broadcast as _broadcast, common as _common, logs as _logs, @@ -36,6 +37,7 @@ _MODULES = ( _broadcast, _sync, _ads, + _backups, _settings, _tariffs, _themes, diff --git a/backend/bot/app/web/admin_api_impl/backups.py b/backend/bot/app/web/admin_api_impl/backups.py new file mode 100644 index 0000000..49e9286 --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/backups.py @@ -0,0 +1,141 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +import secrets +import subprocess + +from bot.infra.redis import redis_lock +from bot.services.backup_restore_service import ( + BACKUP_UPLOAD_MAX_BYTES, + BackupArchiveError, + BackupArchiveInfo, + BackupRestoreError, + BackupRestoreService, +) + + +def _backup_archive_payload(archive) -> Dict[str, Any]: + return archive.to_payload() + + +async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo: + settings: Settings = request.app["settings"] + service = BackupRestoreService(settings) + backup_dir = service.backup_dir() + temp_path: Optional[Path] = None + + reader = await request.multipart() + try: + async for part in reader: + if part.name != "file": + continue + + original_filename = part.filename or "backup.zip" + temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp" + size = 0 + with temp_path.open("wb") as handle: + while True: + chunk = await part.read_chunk(size=1024 * 1024) + if not chunk: + break + size += len(chunk) + if size > BACKUP_UPLOAD_MAX_BYTES: + raise BackupArchiveError("Backup archive is too large") + handle.write(chunk) + if size <= 0: + raise BackupArchiveError("Uploaded archive is empty") + archive = service.import_uploaded_archive(temp_path, original_filename) + temp_path = None + return archive + finally: + if temp_path is not None and temp_path.exists(): + try: + temp_path.unlink() + except OSError: + logger.warning("Failed to remove temporary backup upload %s", temp_path) + + raise BackupArchiveError("file field is required") + + +async def admin_backups_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + try: + service = BackupRestoreService(settings) + archives = service.list_archives() + except OSError as exc: + logger.exception("Failed to list backup archives") + return _error(500, "backup_list_failed", str(exc)) + return _ok( + { + "backup_dir": str(service.backup_dir()), + "archives": [_backup_archive_payload(archive) for archive in archives], + } + ) + + +async def admin_backups_upload_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + if not content_type.startswith("multipart/form-data"): + return _error(400, "invalid_backup_archive", "multipart file upload is required") + try: + archive = await _read_uploaded_backup_file(request) + except BackupArchiveError as exc: + return _error(400, "invalid_backup_archive", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded backup archive") + return _error(500, "backup_upload_failed", str(exc)) + return _ok({"archive": _backup_archive_payload(archive)}) + + +async def admin_backups_restore_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + + archive_name = str(payload.get("archive_name") or "").strip() + restore_database = bool(payload.get("restore_database")) + restore_compose = bool(payload.get("restore_compose")) + confirm = bool(payload.get("confirm")) + if not confirm: + return _error(400, "restore_confirmation_required") + + service = BackupRestoreService(settings) + ttl_seconds = max( + 60, + int( + max( + getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200, + getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800, + ) + ), + ) + try: + async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired: + if not acquired: + return _error(409, "backup_restore_busy", "Backup or restore is already running") + result = await service.restore_archive( + archive_name, + restore_database=restore_database, + restore_compose=restore_compose, + ) + except BackupArchiveError as exc: + return _error(400, "invalid_backup_archive", str(exc)) + except BackupRestoreError as exc: + logger.exception("Backup restore failed") + return _error(500, "backup_restore_failed", str(exc)) + except (OSError, subprocess.SubprocessError, TimeoutError) as exc: + logger.exception("Backup restore failed") + return _error(500, "backup_restore_failed", str(exc)) + + if result.database_restored: + try: + from db import database_setup + + if database_setup.async_engine is not None: + await database_setup.async_engine.dispose() + except Exception: + logger.exception("Failed to dispose DB engine after backup restore") + + return _ok({"result": result.to_payload()}) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index b5d5682..b3f8a31 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -75,4 +75,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_put("/api/admin/themes", admin_themes_save_route) router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route) + router.add_get("/api/admin/backups", admin_backups_list_route) + router.add_post("/api/admin/backups/upload", admin_backups_upload_route) + router.add_post("/api/admin/backups/restore", admin_backups_restore_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 2cf93d8..de1d1fe 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -434,6 +434,55 @@ SETTINGS_MANIFEST: List[SettingField] = [ "ID треда поддержки", "Тред лог-чата для уведомлений о тикетах поддержки.", ), + SettingField( + "BACKUP_ENABLED", + "bool", + "backups", + "Бэкапы включены", + "Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.", + ), + SettingField( + "BACKUP_CHAT_ID", + "int", + "backups", + "ID чата для бэкапов", + "Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.", + ), + SettingField( + "BACKUP_THREAD_ID", + "int", + "backups", + "ID треда для бэкапов", + "Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.", + ), + SettingField( + "BACKUP_INTERVAL_SECONDS", + "int", + "backups", + "Период бэкапов (сек.)", + "По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).", + optional=False, + min=60, + ), + SettingField( + "BACKUP_LOCAL_RETENTION", + "int", + "backups", + "Сколько архивов хранить", + "Сколько последних ZIP-архивов оставлять в data/backups на сервере.", + optional=False, + min=1, + ), + SettingField( + "BACKUP_COMPOSE_ENABLED", + "bool", + "backups", + "Добавлять compose-папку", + ( + "Добавляет snapshot /app/compose-source. Если папка не смонтирована, " + "бэкап БД все равно будет создан." + ), + ), SettingField( "SUPPORT_TICKETS_ENABLED", "bool", @@ -561,6 +610,8 @@ def coerce_value(field: SettingField, raw: Any) -> Any: return text if raw is None or (isinstance(raw, str) and raw.strip() == ""): + if not field.optional: + raise ValueError(f"{field.key}: value required") return None if field.type == "bool": @@ -629,7 +680,8 @@ def manifest_payload() -> List[dict]: "referral": 6, "notifications": 7, "support": 8, - "devices": 9, + "backups": 9, + "devices": 10, "subscription_guides": 10, } exclusive_map = { @@ -678,6 +730,10 @@ def manifest_payload() -> List[dict]: "optional": field.optional, "secret": field.secret, } + if field.min is not None: + item["min"] = field.min + if field.max is not None: + item["max"] = field.max if field.key in exclusive_map: item["mutually_exclusive_key"] = exclusive_map[field.key] if default_value is not None: diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 95151e3..8ee4448 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -19,7 +19,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get( ( "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" - "appearance|settings|translations|support}" + "appearance|settings|translations|support|backups}" ), index_route, ) diff --git a/backend/bot/services/backup_archive.py b/backend/bot/services/backup_archive.py new file mode 100644 index 0000000..9c0ecd9 --- /dev/null +++ b/backend/bot/services/backup_archive.py @@ -0,0 +1,108 @@ +import hashlib +import hmac +import json +import zipfile +from pathlib import Path +from typing import Any + +from config.settings import Settings + +BACKUP_APP_ID = "remnawave-minishop" +BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-" +BACKUP_FORMAT_VERSION = 1 +BACKUP_MANIFEST_NAME = "manifest.json" + + +def backup_signature_secret(settings: Settings) -> str: + configured = str(getattr(settings, "BACKUP_ARCHIVE_SIGNATURE_SECRET", "") or "").strip() + return configured or settings.BOT_TOKEN + + +def canonical_manifest_payload(manifest: dict[str, Any]) -> bytes: + payload = json.loads(json.dumps(manifest, ensure_ascii=False)) + archive = payload.get("archive") + if isinstance(archive, dict): + archive.pop("signature", None) + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def sign_manifest(manifest: dict[str, Any], settings: Settings) -> str: + return hmac.new( + backup_signature_secret(settings).encode("utf-8"), + canonical_manifest_payload(manifest), + hashlib.sha256, + ).hexdigest() + + +def verify_manifest_signature(manifest: dict[str, Any], settings: Settings) -> bool: + archive = manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {} + signature = str(archive.get("signature") or "") + if not signature: + return False + expected = sign_manifest(manifest, settings) + return hmac.compare_digest(signature, expected) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_file_records(source_dir: Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for path in sorted(source_dir.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(source_dir).as_posix() + if relative == BACKUP_MANIFEST_NAME: + continue + stat = path.stat() + records.append( + { + "path": relative, + "size_bytes": int(stat.st_size), + "sha256": file_sha256(path), + } + ) + return records + + +def attach_archive_integrity( + manifest: dict[str, Any], + *, + file_records: list[dict[str, Any]], + settings: Settings, +) -> None: + manifest["app"] = BACKUP_APP_ID + manifest["format_version"] = BACKUP_FORMAT_VERSION + manifest["archive"] = { + "files": file_records, + } + manifest["archive"]["signature"] = sign_manifest(manifest, settings) + + +def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None: + (source_dir / BACKUP_MANIFEST_NAME).write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def write_zip_from_directory(source_dir: Path, archive_path: Path) -> None: + with zipfile.ZipFile( + archive_path, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=6, + ) as archive: + for path in sorted(source_dir.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(source_dir).as_posix()) diff --git a/backend/bot/services/backup_restore_service.py b/backend/bot/services/backup_restore_service.py new file mode 100644 index 0000000..6a745c5 --- /dev/null +++ b/backend/bot/services/backup_restore_service.py @@ -0,0 +1,660 @@ +import asyncio +import hashlib +import hmac +import json +import logging +import os +import re +import shutil +import subprocess +import tempfile +import zipfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Optional + +from bot.services.backup_archive import ( + BACKUP_APP_ID, + BACKUP_FILENAME_PREFIX, + BACKUP_FORMAT_VERSION, + BACKUP_MANIFEST_NAME, + attach_archive_integrity, + build_file_records, + verify_manifest_signature, + write_manifest, + write_zip_from_directory, +) +from bot.services.backup_worker import ( + DEFAULT_COMPOSE_EXCLUDED_DIRS, +) +from config.settings import Settings + +logger = logging.getLogger(__name__) + +BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024 +BACKUP_MAX_MEMBERS = 20_000 +BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024 +BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024 +BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024 +BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024 +BACKUP_MAX_COMPRESSION_RATIO = 200 +BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024 +COMPOSE_PRE_RESTORE_PREFIX = "remnawave-minishop-compose-pre-restore-" +SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$") + + +class BackupArchiveError(ValueError): + """The selected archive cannot be used for restore.""" + + +class BackupRestoreError(RuntimeError): + """Restore command failed after archive validation.""" + + +@dataclass +class BackupArchiveInfo: + name: str + path: Path + size_bytes: int + modified_at: datetime + created_at: Optional[str] = None + created_at_local: Optional[str] = None + has_database: bool = False + has_compose: bool = False + database_name: Optional[str] = None + compose_files_count: int = 0 + warnings: list[str] = field(default_factory=list) + manifest: dict[str, Any] = field(default_factory=dict) + + def to_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "size_bytes": self.size_bytes, + "modified_at": self.modified_at.isoformat(), + "created_at": self.created_at, + "created_at_local": self.created_at_local, + "has_database": self.has_database, + "has_compose": self.has_compose, + "database_name": self.database_name, + "compose_files_count": self.compose_files_count, + "warnings": self.warnings, + "manifest": self.manifest, + } + + +@dataclass +class BackupRestoreResult: + archive_name: str + started_at: datetime + completed_at: datetime + database_restored: bool = False + compose_files_restored: int = 0 + compose_target_dir: Optional[str] = None + compose_pre_restore_archive: Optional[str] = None + warnings: list[str] = field(default_factory=list) + + def to_payload(self) -> dict[str, Any]: + return { + "archive_name": self.archive_name, + "started_at": self.started_at.isoformat(), + "completed_at": self.completed_at.isoformat(), + "database_restored": self.database_restored, + "compose_files_restored": self.compose_files_restored, + "compose_target_dir": self.compose_target_dir, + "compose_pre_restore_archive": self.compose_pre_restore_archive, + "warnings": self.warnings, + } + + +class BackupRestoreService: + def __init__(self, settings: Settings): + self.settings = settings + + def backup_dir(self) -> Path: + path = Path(self.settings.BACKUP_DIR).expanduser() + path.mkdir(parents=True, exist_ok=True) + return path + + def list_archives(self) -> list[BackupArchiveInfo]: + backup_dir = self.backup_dir() + archives = [] + for path in backup_dir.glob("*.zip"): + if not path.is_file(): + continue + try: + archives.append(self.inspect_archive(path)) + except BackupArchiveError as exc: + logger.warning("Skipping invalid backup archive %s: %s", path, exc) + return sorted(archives, key=lambda item: item.modified_at, reverse=True) + + def archive_path_for_name(self, archive_name: str) -> Path: + raw_name = str(archive_name or "").strip() + safe_name = Path(raw_name).name + if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name): + raise BackupArchiveError("Invalid archive name") + + backup_dir = self.backup_dir().resolve() + archive_path = (backup_dir / safe_name).resolve() + try: + archive_path.relative_to(backup_dir) + except ValueError as exc: + raise BackupArchiveError("Archive path escapes backup directory") from exc + if not archive_path.is_file(): + raise BackupArchiveError("Archive does not exist") + return archive_path + + def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo: + if not zipfile.is_zipfile(archive_path): + raise BackupArchiveError("Archive is not a valid ZIP file") + + stat = archive_path.stat() + warnings: list[str] = [] + with zipfile.ZipFile(archive_path) as archive: + self._validate_zip_members(archive.infolist()) + manifest = self._read_manifest(archive) + signature_valid = self._archive_signature_valid(manifest) + signature_required = getattr( + self.settings, + "BACKUP_ARCHIVE_SIGNATURE_REQUIRED", + True, + ) + if signature_required and not signature_valid: + raise BackupArchiveError("Archive manifest signature is not valid") + if not signature_valid: + warnings.append("manifest signature is not valid") + has_database = self._find_database_dump_member(archive) is not None + compose_members = self._compose_file_members(archive) + + manifest_warnings = manifest.get("warnings") + if isinstance(manifest_warnings, list): + warnings.extend(str(item) for item in manifest_warnings if item) + + postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {} + compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {} + return BackupArchiveInfo( + name=archive_path.name, + path=archive_path, + size_bytes=int(stat.st_size), + modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + created_at=str(manifest.get("created_at") or "") or None, + created_at_local=str(manifest.get("created_at_local") or "") or None, + has_database=has_database, + has_compose=bool(compose_members), + database_name=str(postgres.get("database") or "") or None, + compose_files_count=int(compose.get("files_count") or len(compose_members)), + warnings=warnings, + manifest=manifest, + ) + + def import_uploaded_archive( + self, + temp_path: Path, + original_filename: str = "", + ) -> BackupArchiveInfo: + self._validate_archive_for_restore(temp_path) + digest = self._file_digest(temp_path) + stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") + safe_original = self._safe_original_stem(original_filename) + archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}-{safe_original}.zip" + target_path = self._unique_archive_path(archive_name) + temp_path.replace(target_path) + return self.inspect_archive(target_path) + + async def restore_archive( + self, + archive_name: str, + *, + restore_database: bool, + restore_compose: bool, + ) -> BackupRestoreResult: + return await asyncio.to_thread( + self.restore_archive_sync, + archive_name, + restore_database=restore_database, + restore_compose=restore_compose, + ) + + def restore_archive_sync( + self, + archive_name: str, + *, + restore_database: bool, + restore_compose: bool, + ) -> BackupRestoreResult: + if not restore_database and not restore_compose: + raise BackupArchiveError("Select at least one restore target") + + archive_path = self.archive_path_for_name(archive_name) + self._validate_archive_for_restore(archive_path) + started_at = datetime.now(timezone.utc) + warnings: list[str] = [] + + with tempfile.TemporaryDirectory( + prefix=f"restore-{archive_path.stem}-", + dir=self.backup_dir(), + ) as tmp: + temp_dir = Path(tmp) + with zipfile.ZipFile(archive_path) as archive: + self._validate_zip_members(archive.infolist()) + db_member = self._find_database_dump_member(archive) if restore_database else None + compose_members = self._compose_file_members(archive) if restore_compose else [] + + if restore_database and db_member is None: + raise BackupArchiveError("Archive does not contain a database dump") + if restore_compose and not compose_members: + raise BackupArchiveError("Archive does not contain compose files") + + compose_target_dir: Optional[Path] = None + compose_pre_restore_archive: Optional[Path] = None + if restore_compose: + compose_target_dir = self._compose_restore_target_dir() + self._assert_compose_target_writable(compose_target_dir) + compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir) + + database_restored = False + if db_member is not None: + dump_path = self._extract_database_dump(archive, db_member, temp_dir) + self._run_pg_restore(dump_path) + database_restored = True + + compose_files_restored = 0 + if compose_target_dir is not None: + compose_files_restored = self._restore_compose_members( + archive, + compose_members, + compose_target_dir, + ) + + return BackupRestoreResult( + archive_name=archive_path.name, + started_at=started_at, + completed_at=datetime.now(timezone.utc), + database_restored=database_restored, + compose_files_restored=compose_files_restored, + compose_target_dir=str(compose_target_dir) if compose_target_dir else None, + compose_pre_restore_archive=str(compose_pre_restore_archive) + if compose_pre_restore_archive + else None, + warnings=warnings, + ) + + def _run_pg_restore(self, dump_path: Path) -> None: + pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "") + pg_restore_path = pg_restore_path or "pg_restore" + if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path: + raise BackupRestoreError( + "pg_restore executable was not found. Rebuild the backend image with " + "PostgreSQL client tools." + ) + + env = os.environ.copy() + env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD + command = [ + pg_restore_path, + "-h", + self.settings.POSTGRES_HOST, + "-p", + str(self.settings.POSTGRES_PORT), + "-U", + self.settings.POSTGRES_USER, + "-d", + self.settings.POSTGRES_DB, + "--clean", + "--if-exists", + "--no-owner", + "--no-privileges", + str(dump_path), + ] + timeout = max( + 30, + int( + getattr( + self.settings, + "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", + self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS, + ) + or 1800 + ), + ) + result = subprocess.run( + command, + check=False, + capture_output=True, + env=env, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + stderr = (result.stderr or result.stdout or "").strip() + raise BackupRestoreError( + f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}" + ) + + def _compose_restore_target_dir(self) -> Path: + target_raw = ( + getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None) + or self.settings.BACKUP_COMPOSE_SOURCE_DIR + or "" + ) + if not str(target_raw).strip(): + raise BackupArchiveError("Compose restore directory is not configured") + return Path(str(target_raw)).expanduser() + + def _assert_compose_target_writable(self, target_dir: Path) -> None: + if not target_dir.exists() or not target_dir.is_dir(): + raise BackupArchiveError( + f"Compose restore directory is unavailable: {target_dir}. " + "Mount the compose folder into the backend container." + ) + probe = target_dir / f".restore-write-test-{os.getpid()}" + try: + probe.write_text("", encoding="utf-8") + probe.unlink() + except OSError as exc: + raise BackupArchiveError( + f"Compose restore directory is not writable: {target_dir}" + ) from exc + + def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]: + stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") + archive_path = self.backup_dir() / f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip" + excluded_dirs = self._compose_excluded_dirs() + files_count = 0 + with tempfile.TemporaryDirectory( + prefix=f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}-", + dir=self.backup_dir(), + ) as tmp: + staging_dir = Path(tmp) + compose_dir = staging_dir / "compose" + for path in sorted(target_dir.rglob("*")): + relative = path.relative_to(target_dir) + if any(part in excluded_dirs for part in relative.parts): + continue + if path.is_dir() or path.is_symlink(): + continue + destination = compose_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + files_count += 1 + if files_count <= 0: + return None + + completed_at = datetime.now(timezone.utc) + manifest = { + "app": BACKUP_APP_ID, + "format_version": BACKUP_FORMAT_VERSION, + "type": "compose-pre-restore", + "created_at": completed_at.isoformat(), + "created_at_local": completed_at.astimezone().isoformat(), + "postgres": { + "database": self.settings.POSTGRES_DB, + "included": False, + }, + "compose": { + "source_dir": str(target_dir), + "included": True, + "files_count": files_count, + }, + "warnings": [], + } + attach_archive_integrity( + manifest, + file_records=build_file_records(staging_dir), + settings=self.settings, + ) + write_manifest(staging_dir, manifest) + tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp") + try: + write_zip_from_directory(staging_dir, tmp_archive) + tmp_archive.replace(archive_path) + finally: + if tmp_archive.exists(): + try: + tmp_archive.unlink() + except OSError: + logger.warning("Failed to remove temporary snapshot %s", tmp_archive) + return archive_path + + def _restore_compose_members( + self, + archive: zipfile.ZipFile, + members: list[zipfile.ZipInfo], + target_dir: Path, + ) -> int: + target_root = target_dir.resolve() + restored = 0 + for member in members: + relative = PurePosixPath(member.filename).relative_to("compose") + destination = target_root.joinpath(*relative.parts).resolve() + try: + destination.relative_to(target_root) + except ValueError as exc: + raise BackupArchiveError( + f"Unsafe compose archive member: {member.filename}" + ) from exc + destination.parent.mkdir(parents=True, exist_ok=True) + temp_destination = destination.with_name( + f".{destination.name}.restore-{os.getpid()}.tmp" + ) + try: + with archive.open(member) as source, temp_destination.open("wb") as target: + shutil.copyfileobj(source, target) + temp_destination.replace(destination) + finally: + if temp_destination.exists(): + try: + temp_destination.unlink() + except OSError: + logger.warning( + "Failed to remove temporary restore file %s", + temp_destination, + ) + restored += 1 + return restored + + def _extract_database_dump( + self, + archive: zipfile.ZipFile, + member: zipfile.ZipInfo, + temp_dir: Path, + ) -> Path: + dump_dir = temp_dir / "database" + dump_dir.mkdir(parents=True, exist_ok=True) + dump_path = dump_dir / Path(member.filename).name + with archive.open(member) as source, dump_path.open("wb") as target: + shutil.copyfileobj(source, target) + return dump_path + + def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]: + candidates = [ + item + for item in archive.infolist() + if not item.is_dir() + and item.filename.startswith("database/") + and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"} + ] + return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None + + def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]: + members = [ + item + for item in archive.infolist() + if not item.is_dir() and item.filename.startswith("compose/") + ] + self._validate_compose_members(members) + return members + + def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None: + if len(members) > BACKUP_MAX_MEMBERS: + raise BackupArchiveError("Archive contains too many files") + + seen: set[str] = set() + total_size = 0 + for member in members: + filename = member.filename + if "\\" in filename or "\x00" in filename: + raise BackupArchiveError(f"Unsafe archive member path: {filename}") + path = PurePosixPath(member.filename) + if ( + not path.parts + or path.is_absolute() + or ".." in path.parts + or any(part in {"", "."} for part in path.parts) + ): + raise BackupArchiveError(f"Unsafe archive member path: {member.filename}") + if member.is_dir(): + continue + if filename in seen: + raise BackupArchiveError(f"Duplicate archive member path: {filename}") + seen.add(filename) + if member.file_size > BACKUP_MAX_MEMBER_BYTES: + raise BackupArchiveError(f"Archive member is too large: {filename}") + total_size += int(member.file_size) + if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES: + raise BackupArchiveError("Archive uncompressed size is too large") + compressed = max(1, int(member.compress_size or 1)) + ratio = int(member.file_size) / compressed + if ( + member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES + and ratio > BACKUP_MAX_COMPRESSION_RATIO + ): + raise BackupArchiveError( + f"Archive member compression ratio is too high: {filename}" + ) + + def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None: + total_size = 0 + for member in members: + if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES: + raise BackupArchiveError(f"Compose archive member is too large: {member.filename}") + total_size += int(member.file_size) + if total_size > BACKUP_MAX_COMPOSE_BYTES: + raise BackupArchiveError("Compose archive contents are too large") + + def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]: + if BACKUP_MANIFEST_NAME not in archive.namelist(): + raise BackupArchiveError("Archive does not contain manifest.json") + try: + manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise BackupArchiveError("manifest.json is not valid JSON") from exc + if not isinstance(manifest, dict): + raise BackupArchiveError("manifest.json must contain an object") + if manifest.get("app") != BACKUP_APP_ID: + raise BackupArchiveError("Archive manifest belongs to another application") + try: + format_version = int(manifest.get("format_version") or 0) + except (TypeError, ValueError) as exc: + raise BackupArchiveError("Archive manifest format is not supported") from exc + if format_version != BACKUP_FORMAT_VERSION: + raise BackupArchiveError("Archive manifest format is not supported") + return manifest + + def _archive_signature_valid(self, manifest: dict[str, Any]) -> bool: + return verify_manifest_signature(manifest, self.settings) + + def _validate_archive_for_restore(self, archive_path: Path) -> None: + if not zipfile.is_zipfile(archive_path): + raise BackupArchiveError("Archive is not a valid ZIP file") + with zipfile.ZipFile(archive_path) as archive: + self._validate_zip_members(archive.infolist()) + manifest = self._read_manifest(archive) + if getattr(self.settings, "BACKUP_ARCHIVE_SIGNATURE_REQUIRED", True): + if not self._archive_signature_valid(manifest): + raise BackupArchiveError("Archive manifest signature is not valid") + self._validate_archive_integrity(archive, manifest) + + def _validate_archive_integrity( + self, + archive: zipfile.ZipFile, + manifest: dict[str, Any], + ) -> None: + archive_manifest = ( + manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {} + ) + file_records = archive_manifest.get("files") + if not isinstance(file_records, list): + raise BackupArchiveError("Archive manifest does not contain file checksums") + + expected: dict[str, dict[str, Any]] = {} + for record in file_records: + if not isinstance(record, dict): + raise BackupArchiveError("Archive manifest contains invalid file record") + filename = str(record.get("path") or "") + if not filename: + raise BackupArchiveError("Archive manifest contains empty file path") + if filename in expected: + raise BackupArchiveError( + f"Archive manifest contains duplicate file path: {filename}" + ) + expected[filename] = record + + actual = { + item.filename + for item in archive.infolist() + if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME + } + if actual != set(expected): + raise BackupArchiveError("Archive contents do not match manifest") + + for info in archive.infolist(): + if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME: + continue + record = expected[info.filename] + try: + expected_size = int(record.get("size_bytes") or -1) + except (TypeError, ValueError) as exc: + raise BackupArchiveError( + f"Archive manifest size is invalid: {info.filename}" + ) from exc + expected_hash = str(record.get("sha256") or "") + if expected_size != int(info.file_size): + raise BackupArchiveError( + f"Archive member size does not match manifest: {info.filename}" + ) + if not re.fullmatch(r"[0-9a-f]{64}", expected_hash): + raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}") + digest = hashlib.sha256() + with archive.open(info) as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + if not hmac.compare_digest(digest.hexdigest(), expected_hash): + raise BackupArchiveError( + f"Archive member checksum does not match manifest: {info.filename}" + ) + + def _compose_excluded_dirs(self) -> set[str]: + configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS) + return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured) + + @staticmethod + def _split_csv(value: Optional[str]) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + @staticmethod + def _file_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest()[:16] + + @staticmethod + def _safe_original_stem(filename: str) -> str: + stem = Path(str(filename or "backup")).stem + safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-") + return (safe or "backup")[:72] + + def _unique_archive_path(self, archive_name: str) -> Path: + backup_dir = self.backup_dir() + stem = Path(archive_name).stem + suffix = Path(archive_name).suffix + candidate = backup_dir / archive_name + counter = 2 + while candidate.exists(): + candidate = backup_dir / f"{stem}-{counter}{suffix}" + counter += 1 + return candidate diff --git a/backend/bot/services/backup_worker.py b/backend/bot/services/backup_worker.py new file mode 100644 index 0000000..84fe2e6 --- /dev/null +++ b/backend/bot/services/backup_worker.py @@ -0,0 +1,419 @@ +import asyncio +import logging +import os +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Iterable, Optional + +from aiogram import Bot +from aiogram.types import FSInputFile + +from bot.infra.redis import redis_lock +from bot.services.backup_archive import ( + BACKUP_APP_ID, + BACKUP_FILENAME_PREFIX, + BACKUP_FORMAT_VERSION, + attach_archive_integrity, + build_file_records, + write_manifest, + write_zip_from_directory, +) +from config.settings import Settings + +COMPOSE_MARKER_FILES = { + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +} +DEFAULT_COMPOSE_EXCLUDED_DIRS = { + ".git", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "backups", + "node_modules", + "postgres-data", + "redis-data", + "shop-data", +} +BACKUP_RUNTIME_SETTING_KEYS = { + "BACKUP_ENABLED", + "BACKUP_CHAT_ID", + "BACKUP_THREAD_ID", + "BACKUP_INTERVAL_SECONDS", + "BACKUP_LOCAL_RETENTION", + "BACKUP_POSTGRES_DUMP_ENABLED", + "BACKUP_PG_DUMP_PATH", + "BACKUP_PG_DUMP_TIMEOUT_SECONDS", + "BACKUP_COMPOSE_ENABLED", + "BACKUP_COMPOSE_SOURCE_DIR", + "BACKUP_COMPOSE_EXCLUDE_DIRS", +} + + +@dataclass +class BackupResult: + archive_path: Path + started_at: datetime + completed_at: datetime + db_dump_included: bool + compose_files_count: int + size_bytes: int + warnings: list[str] = field(default_factory=list) + + +class BackupWorker: + SETTINGS_REFRESH_SECONDS = 60 + + def __init__(self, settings: Settings, bot: Bot, session_factory=None): + self.settings = settings + self.bot = bot + self.session_factory = session_factory + + async def run(self) -> None: + while True: + await self._refresh_settings() + if not self.settings.BACKUP_ENABLED: + await asyncio.sleep(self.SETTINGS_REFRESH_SECONDS) + continue + + interval = self._interval_seconds() + delay_seconds = self._seconds_until_next_slot(interval) + if delay_seconds > 0: + should_run = await self._sleep_until_next_slot(delay_seconds, interval) + if not should_run: + continue + + await self._refresh_settings() + if not self.settings.BACKUP_ENABLED: + continue + + try: + ttl_seconds = max( + 60, + int(getattr(self.settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200), + ) + async with redis_lock( + self.settings, + "backup-worker", + ttl_seconds=ttl_seconds, + ) as acquired: + if acquired: + started = time.monotonic() + result = await self.create_and_send_backup() + logging.info( + "metric worker_tick_duration_seconds=%.3f worker=backup size_bytes=%s", + time.monotonic() - started, + result.size_bytes, + ) + else: + logging.info( + "Backup worker tick skipped because another worker holds the lock" + ) + except Exception as exc: + logging.exception("Backup worker tick failed") + await self._notify_failure(exc) + + async def create_and_send_backup(self) -> BackupResult: + result = await self.create_backup() + try: + await self.send_backup(result) + finally: + self.prune_old_backups() + return result + + async def create_backup(self) -> BackupResult: + started_at = datetime.now(timezone.utc) + stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") + archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip" + backup_dir = Path(self.settings.BACKUP_DIR).expanduser() + backup_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory( + prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-", + dir=backup_dir, + ) as tmp: + staging_dir = Path(tmp) + warnings: list[str] = [] + db_dump_included = False + compose_files_count = 0 + + if self.settings.BACKUP_POSTGRES_DUMP_ENABLED: + dump_dir = staging_dir / "database" + dump_dir.mkdir(parents=True, exist_ok=True) + dump_path = dump_dir / f"{self.settings.POSTGRES_DB}.dump" + await self._dump_database(dump_path) + db_dump_included = True + + if self.settings.BACKUP_COMPOSE_ENABLED: + compose_files_count = self._stage_compose_source(staging_dir / "compose", warnings) + + completed_at = datetime.now(timezone.utc) + manifest = { + "app": BACKUP_APP_ID, + "format_version": BACKUP_FORMAT_VERSION, + "type": "scheduled", + "created_at": completed_at.isoformat(), + "created_at_local": completed_at.astimezone().isoformat(), + "postgres": { + "host": self.settings.POSTGRES_HOST, + "port": self.settings.POSTGRES_PORT, + "database": self.settings.POSTGRES_DB, + "user": self.settings.POSTGRES_USER, + "dump_format": "pg_dump custom", + "included": db_dump_included, + }, + "compose": { + "source_dir": self.settings.BACKUP_COMPOSE_SOURCE_DIR, + "included": compose_files_count > 0, + "files_count": compose_files_count, + }, + "warnings": warnings, + } + attach_archive_integrity( + manifest, + file_records=build_file_records(staging_dir), + settings=self.settings, + ) + write_manifest(staging_dir, manifest) + + tmp_archive = backup_dir / f"{archive_name}.tmp" + archive_path = backup_dir / archive_name + write_zip_from_directory(staging_dir, tmp_archive) + tmp_archive.replace(archive_path) + + return BackupResult( + archive_path=archive_path, + started_at=started_at, + completed_at=completed_at, + db_dump_included=db_dump_included, + compose_files_count=compose_files_count, + size_bytes=archive_path.stat().st_size, + warnings=warnings, + ) + + async def _dump_database(self, dump_path: Path) -> None: + await asyncio.to_thread(self._run_pg_dump, dump_path) + + def _run_pg_dump(self, dump_path: Path) -> None: + pg_dump_path = str(self.settings.BACKUP_PG_DUMP_PATH or "pg_dump") + if shutil.which(pg_dump_path) is None and Path(pg_dump_path).name == pg_dump_path: + raise RuntimeError( + "pg_dump executable was not found. Rebuild the worker image with " + "PostgreSQL client tools." + ) + + env = os.environ.copy() + env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD + command = [ + pg_dump_path, + "-h", + self.settings.POSTGRES_HOST, + "-p", + str(self.settings.POSTGRES_PORT), + "-U", + self.settings.POSTGRES_USER, + "-d", + self.settings.POSTGRES_DB, + "--format=custom", + "--no-owner", + "--no-privileges", + "--file", + str(dump_path), + ] + timeout = max(30, int(self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS or 1800)) + result = subprocess.run( + command, + check=False, + capture_output=True, + env=env, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + stderr = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"pg_dump failed with exit code {result.returncode}: {stderr[:500]}") + + def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int: + source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip() + if not source_raw: + warnings.append("Compose source directory is not configured.") + return 0 + + source_dir = Path(source_raw).expanduser() + if not source_dir.exists() or not source_dir.is_dir(): + warnings.append(f"Compose source directory is unavailable: {source_dir}") + return 0 + + if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES): + warnings.append(f"Compose source directory has no compose file marker: {source_dir}") + + excluded_dirs = self._compose_excluded_dirs() + files_count = 0 + target_dir.mkdir(parents=True, exist_ok=True) + + for path in source_dir.rglob("*"): + relative = path.relative_to(source_dir) + if any(part in excluded_dirs for part in relative.parts): + continue + if path.is_dir() or path.is_symlink(): + continue + if path.name.startswith(f"{BACKUP_FILENAME_PREFIX}") and path.suffix == ".zip": + continue + destination = target_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(path, destination) + files_count += 1 + except OSError as exc: + warnings.append(f"Skipped compose file {relative.as_posix()}: {exc}") + + return files_count + + def _compose_excluded_dirs(self) -> set[str]: + configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS) + return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured) + + @staticmethod + def _split_csv(value: Optional[str]) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + async def send_backup(self, result: BackupResult) -> None: + chat_id = self._target_chat_id() + if chat_id is None: + logging.warning( + "Backup archive created at %s but BACKUP_CHAT_ID/LOG_CHAT_ID is not configured", + result.archive_path, + ) + return + + kwargs = { + "chat_id": chat_id, + "document": FSInputFile(result.archive_path), + "caption": self._caption(result), + } + thread_id = self._target_thread_id() + if thread_id is not None: + kwargs["message_thread_id"] = thread_id + await self.bot.send_document(**kwargs) + + def prune_old_backups(self) -> None: + retention = int(getattr(self.settings, "BACKUP_LOCAL_RETENTION", 3) or 0) + if retention <= 0: + return + + backup_dir = Path(self.settings.BACKUP_DIR).expanduser() + archives = sorted( + backup_dir.glob(f"{BACKUP_FILENAME_PREFIX}*.zip"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for archive in archives[retention:]: + try: + archive.unlink() + except OSError: + logging.exception("Failed to delete old backup archive %s", archive) + + def _target_chat_id(self) -> Optional[int]: + return self.settings.BACKUP_CHAT_ID or self.settings.LOG_CHAT_ID + + def _target_thread_id(self) -> Optional[int]: + return self.settings.BACKUP_THREAD_ID or self.settings.LOG_THREAD_ID + + def _caption(self, result: BackupResult) -> str: + completed_at = result.completed_at.astimezone() + lines = [ + "Remnawave Minishop backup", + f"Created: {completed_at.strftime('%Y-%m-%d %H:%M:%S %Z')}", + f"Database dump: {'yes' if result.db_dump_included else 'no'}", + f"Compose files: {result.compose_files_count}", + f"Archive size: {self._human_size(result.size_bytes)}", + ] + if result.warnings: + lines.append(f"Warnings: {len(result.warnings)}") + return "\n".join(lines) + + @staticmethod + def _human_size(size_bytes: int) -> str: + units: Iterable[str] = ("B", "KB", "MB", "GB") + size = float(size_bytes) + unit = "B" + for unit in units: + if size < 1024 or unit == "GB": + break + size /= 1024 + if unit == "B": + return f"{int(size)} {unit}" + return f"{size:.1f} {unit}" + + async def _refresh_settings(self) -> None: + if self.session_factory is None: + return + try: + from bot.services.settings_override_service import refresh_overrides_from_db + + await refresh_overrides_from_db( + self.settings, + self.session_factory, + keys=BACKUP_RUNTIME_SETTING_KEYS, + ) + except Exception: + logging.exception("Failed to refresh backup settings from DB") + + def _interval_seconds(self) -> int: + try: + interval = int(self.settings.BACKUP_INTERVAL_SECONDS or 0) + except (TypeError, ValueError): + interval = 0 + return max(60, interval) + + def _seconds_until_next_slot(self, interval_seconds: int) -> float: + now = datetime.now().astimezone() + if interval_seconds <= 0: + return 0.0 + if interval_seconds <= 24 * 60 * 60: + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + elapsed = (now - midnight).total_seconds() + remainder = elapsed % interval_seconds + else: + remainder = time.time() % interval_seconds + if remainder < 0.5: + return 0.0 + return max(0.0, interval_seconds - remainder) + + async def _sleep_until_next_slot(self, delay_seconds: float, interval_seconds: int) -> bool: + deadline = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds) + while True: + remaining = (deadline - datetime.now(timezone.utc)).total_seconds() + if remaining <= 0: + return True + await asyncio.sleep(min(remaining, self.SETTINGS_REFRESH_SECONDS)) + await self._refresh_settings() + if not self.settings.BACKUP_ENABLED: + return False + if self._interval_seconds() != interval_seconds: + return False + + async def _notify_failure(self, exc: Exception) -> None: + chat_id = self._target_chat_id() + if chat_id is None: + return + kwargs = { + "chat_id": chat_id, + "text": f"Remnawave Minishop backup failed: {type(exc).__name__}. Check worker logs.", + } + thread_id = self._target_thread_id() + if thread_id is not None: + kwargs["message_thread_id"] = thread_id + try: + await self.bot.send_message(**kwargs) + except Exception: + logging.exception("Failed to send backup failure notification") diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py index 86a0455..9318a45 100644 --- a/backend/bot/services/locale_override_service.py +++ b/backend/bot/services/locale_override_service.py @@ -53,6 +53,7 @@ LOCALE_GROUPS = [ "admin_sort_", "admin_status_", "admin_badge_", + "admin_backups_", "admin_aria_", "admin_search", "admin_clear", @@ -257,6 +258,7 @@ LOCALE_GROUPS = [ "audience": "internal", "prefixes": ( "admin_settings_field_log_", + "admin_settings_field_backup_", "admin_settings_field_support_", "admin_settings_field_subscription_notifications_", "admin_settings_field_subscription_notify_", diff --git a/backend/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py index 9f2e650..822b0ec 100644 --- a/backend/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -263,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess return applied +async def refresh_overrides_from_db( + settings: Settings, + async_session_factory: sessionmaker, + *, + keys: Optional[set[str]] = None, +) -> int: + """Refresh already-known runtime overrides without startup restore side effects.""" + + try: + async with async_session_factory() as session: + overrides = await app_settings_dal.get_all_overrides(session) + except Exception as exc: + logger.warning("Could not refresh setting overrides from DB: %s", exc) + return 0 + if keys is not None: + try: + env_only = Settings() + for key in keys: + if key in overrides: + continue + attr_name = _resolve_attribute_name(env_only, key) + if attr_name and hasattr(env_only, attr_name): + setattr(settings, attr_name, getattr(env_only, attr_name)) + except Exception as exc: + logger.warning("Failed to restore env defaults while refreshing overrides: %s", exc) + overrides = {key: value for key, value in overrides.items() if key in keys} + return apply_overrides(settings, overrides) + + async def update_overrides( settings: Settings, async_session_factory: sessionmaker, diff --git a/backend/config/settings.py b/backend/config/settings.py index c23a5e0..87e33b9 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -114,6 +114,35 @@ class Settings(BaseSettings): TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240) TARIFF_WORKER_TICK_SECONDS: int = Field(default=300) TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50) + BACKUP_ENABLED: bool = Field( + default=False, + description="Run periodic backup jobs from the worker container.", + ) + BACKUP_INTERVAL_SECONDS: int = Field(default=60 * 60) + BACKUP_LOCK_TTL_SECONDS: int = Field(default=2 * 60 * 60) + BACKUP_DIR: str = Field(default="data/backups") + BACKUP_LOCAL_RETENTION: int = Field(default=100) + BACKUP_CHAT_ID: Optional[int] = Field( + default=None, + description="Telegram chat ID for backup archives. Falls back to LOG_CHAT_ID.", + ) + BACKUP_THREAD_ID: Optional[int] = Field( + default=None, + description="Telegram topic/thread ID for backup archives. Falls back to LOG_THREAD_ID.", + ) + BACKUP_POSTGRES_DUMP_ENABLED: bool = Field(default=True) + BACKUP_PG_DUMP_PATH: str = Field(default="pg_dump") + BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60) + BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore") + BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60) + BACKUP_ARCHIVE_SIGNATURE_REQUIRED: bool = Field(default=True) + BACKUP_ARCHIVE_SIGNATURE_SECRET: Optional[str] = Field(default=None) + BACKUP_COMPOSE_ENABLED: bool = Field(default=True) + BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source") + BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None) + BACKUP_COMPOSE_EXCLUDE_DIRS: str = Field( + default=".git,node_modules,__pycache__,.pytest_cache,.ruff_cache,postgres-data,redis-data,shop-data,backups" + ) DEFAULT_LANGUAGE: str = Field(default="ru") DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB") @@ -947,7 +976,14 @@ class Settings(BaseSettings): return v return secrets.token_urlsafe(32) - @field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", "LOG_SUPPORT_THREAD_ID", mode="before") + @field_validator( + "LOG_CHAT_ID", + "LOG_THREAD_ID", + "LOG_SUPPORT_THREAD_ID", + "BACKUP_CHAT_ID", + "BACKUP_THREAD_ID", + mode="before", + ) @classmethod def validate_optional_int_fields(cls, v): """Convert empty strings to None for optional integer fields""" @@ -969,6 +1005,9 @@ class Settings(BaseSettings): "SMTP_FROM_EMAIL", "SMTP_FROM_NAME", "SMTP_FALLBACK_PORTS", + "BACKUP_ARCHIVE_SIGNATURE_SECRET", + "BACKUP_COMPOSE_SOURCE_DIR", + "BACKUP_COMPOSE_RESTORE_DIR", mode="before", ) @classmethod diff --git a/backend/main_worker.py b/backend/main_worker.py index 59cff48..846c14f 100644 --- a/backend/main_worker.py +++ b/backend/main_worker.py @@ -23,6 +23,7 @@ from bot.payment_providers.yookassa import ( process_cancelled_payment, process_successful_payment, ) +from bot.services.backup_worker import BackupWorker from bot.services.locale_override_service import load_locale_overrides from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager @@ -191,6 +192,8 @@ async def main() -> None: tasks = [] if settings.tariffs_config: tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) + backup_worker = BackupWorker(settings, bot, session_factory=session_factory) + tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker")) tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services))) for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)): tasks.append( diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 678def4..ea7bbfb 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -53,6 +53,20 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg; \ + . /etc/os-release; \ + install -d /usr/share/postgresql-common/pgdg; \ + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg; \ + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list; \ + apt-get update; \ + apt-get install -y --no-install-recommends postgresql-client-17; \ + apt-get purge -y --auto-remove curl gnupg; \ + rm -rf /var/lib/apt/lists/* + RUN useradd -u 10001 -m appuser COPY backend/requirements.txt backend/requirements.txt diff --git a/deploy/examples/caddy/.env.example b/deploy/examples/caddy/.env.example index 06a6800..4f8cbd7 100644 --- a/deploy/examples/caddy/.env.example +++ b/deploy/examples/caddy/.env.example @@ -39,3 +39,13 @@ PANEL_WEBHOOK_SECRET=change_me # Caddy and Docker network ranges that may set X-Forwarded-For. TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Periodic backups from the worker container. Disabled by default. +BACKUP_ENABLED=False +BACKUP_CHAT_ID= +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_LOCAL_RETENTION=100 +BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True +BACKUP_ARCHIVE_SIGNATURE_SECRET= +BACKUP_COMPOSE_ENABLED=True +COMPOSE_BACKUP_SOURCE=. +COMPOSE_RESTORE_MODE=rw diff --git a/deploy/examples/caddy/docker-compose.yml b/deploy/examples/caddy/docker-compose.yml index 72c7044..711fea6 100644 --- a/deploy/examples/caddy/docker-compose.yml +++ b/deploy/examples/caddy/docker-compose.yml @@ -67,6 +67,7 @@ services: TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} networks: - remnawave-shop depends_on: @@ -91,6 +92,7 @@ services: <<: *app_environment volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro networks: - remnawave-shop depends_on: diff --git a/deploy/examples/newt/.env.example b/deploy/examples/newt/.env.example index e0542ec..64eebd1 100644 --- a/deploy/examples/newt/.env.example +++ b/deploy/examples/newt/.env.example @@ -40,3 +40,13 @@ PANEL_WEBHOOK_SECRET=change_me # Pangolin/Newt and Docker network ranges that may set X-Forwarded-For. TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Periodic backups from the worker container. Disabled by default. +BACKUP_ENABLED=False +BACKUP_CHAT_ID= +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_LOCAL_RETENTION=100 +BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True +BACKUP_ARCHIVE_SIGNATURE_SECRET= +BACKUP_COMPOSE_ENABLED=True +COMPOSE_BACKUP_SOURCE=. +COMPOSE_RESTORE_MODE=rw diff --git a/deploy/examples/newt/docker-compose.yml b/deploy/examples/newt/docker-compose.yml index f68dc52..237d6ed 100644 --- a/deploy/examples/newt/docker-compose.yml +++ b/deploy/examples/newt/docker-compose.yml @@ -67,6 +67,7 @@ services: TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} networks: - remnawave-shop depends_on: @@ -91,6 +92,7 @@ services: <<: *app_environment volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro networks: - remnawave-shop depends_on: diff --git a/deploy/examples/nginx/.env.example b/deploy/examples/nginx/.env.example index f0ab28f..549d3a3 100644 --- a/deploy/examples/nginx/.env.example +++ b/deploy/examples/nginx/.env.example @@ -39,3 +39,13 @@ PANEL_WEBHOOK_SECRET=change_me # Nginx and Docker network ranges that may set X-Forwarded-For. TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Periodic backups from the worker container. Disabled by default. +BACKUP_ENABLED=False +BACKUP_CHAT_ID= +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_LOCAL_RETENTION=100 +BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True +BACKUP_ARCHIVE_SIGNATURE_SECRET= +BACKUP_COMPOSE_ENABLED=True +COMPOSE_BACKUP_SOURCE=. +COMPOSE_RESTORE_MODE=rw diff --git a/deploy/examples/nginx/docker-compose.yml b/deploy/examples/nginx/docker-compose.yml index db857c5..51c95fe 100644 --- a/deploy/examples/nginx/docker-compose.yml +++ b/deploy/examples/nginx/docker-compose.yml @@ -67,6 +67,7 @@ services: TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} networks: - remnawave-shop depends_on: @@ -91,6 +92,7 @@ services: <<: *app_environment volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro networks: - remnawave-shop depends_on: diff --git a/deploy/examples/no-proxy/.env.example b/deploy/examples/no-proxy/.env.example index 763dee3..81e14f7 100644 --- a/deploy/examples/no-proxy/.env.example +++ b/deploy/examples/no-proxy/.env.example @@ -39,3 +39,14 @@ PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me TRUSTED_PROXIES=127.0.0.1,::1 + +# Periodic backups from the worker container. Disabled by default. +BACKUP_ENABLED=False +BACKUP_CHAT_ID= +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_LOCAL_RETENTION=100 +BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True +BACKUP_ARCHIVE_SIGNATURE_SECRET= +BACKUP_COMPOSE_ENABLED=True +COMPOSE_BACKUP_SOURCE=. +COMPOSE_RESTORE_MODE=rw diff --git a/deploy/examples/no-proxy/docker-compose.yml b/deploy/examples/no-proxy/docker-compose.yml index 4d983cb..e1847ab 100644 --- a/deploy/examples/no-proxy/docker-compose.yml +++ b/deploy/examples/no-proxy/docker-compose.yml @@ -69,6 +69,7 @@ services: - "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080" volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} networks: - remnawave-shop depends_on: @@ -93,6 +94,7 @@ services: <<: *app_environment volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro networks: - remnawave-shop depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 6dc946f..6b779ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,7 @@ services: condition: service_completed_successfully volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} networks: - remnawave-shop ports: @@ -82,6 +83,7 @@ services: condition: service_completed_successfully volumes: - shop-data:/app/data + - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro networks: - remnawave-shop diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index ff66b52..416c902 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -79,8 +79,11 @@ export default defineConfig({ { label: 'Подписки', slug: 'features/subscriptions' }, { label: 'Тарифы', slug: 'features/tariffs' }, { label: 'Веб-приложение / Mini App', slug: 'features/web-app' }, + { label: 'Telegram-авторизация', slug: 'features/telegram-auth' }, + { label: 'Вход по email', slug: 'features/email-login' }, { label: 'Темы Web App', slug: 'features/webapp-themes' }, { label: 'Админ-панель', slug: 'features/admin-panel' }, + { label: 'Бэкапы и восстановление', slug: 'features/backups' }, { label: 'Поддержка пользователей / тикеты', slug: 'features/support' }, ], }, diff --git a/docs-site/scripts/sync-docs.mjs b/docs-site/scripts/sync-docs.mjs index 600ee50..d871c1b 100644 --- a/docs-site/scripts/sync-docs.mjs +++ b/docs-site/scripts/sync-docs.mjs @@ -19,9 +19,12 @@ const descriptions = { 'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.', 'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.', 'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.', - 'features/web-app.md': 'Telegram Mini App, авторизация, публичные инструкции и проксирование.', + 'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.', + 'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.', + 'features/email-login.md': 'SMTP, одноразовые коды, magic link, парольный вход и привязка email-аккаунтов.', 'features/webapp-themes.md': 'Кастомные темы, CSS-токены, ассеты и пайплайн создания темы.', 'features/admin-panel.md': 'Возможности админ-панели, управление пользователями, настройками, тарифами и поддержкой.', + 'features/backups.md': 'Автоматические бэкапы, отправка архивов в Telegram, локальное хранение и восстановление БД/compose-папки из админки.', 'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.', 'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.', 'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.', diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 800c350..8543702 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -49,6 +49,8 @@ Обычно эти значения не требуют правки. +Настройки `BACKUP_*` управляют автоматическими бэкапами и восстановлением. Практический сценарий, mount compose-папки и проверки архивов описаны в [разделе про бэкапы](../features/backups.md). + | Переменная | Назначение | | --- | --- | | `WEBAPP_ME_CACHE_TTL_SECONDS` | TTL кеша `/api/me`. | @@ -71,6 +73,25 @@ | `TARIFF_WORKER_LOCK_TTL_SECONDS` | TTL Redis lock для tariff worker. | | `TARIFF_WORKER_TICK_SECONDS` | Интервал tariff worker. | | `TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD` | Порог активных подписок для bulk fetch пользователей панели. | +| `BACKUP_ENABLED` | Включает периодические бэкапы в worker-контейнере. По умолчанию `False`. | +| `BACKUP_INTERVAL_SECONDS` | Интервал между бэкапами. По умолчанию `3600`; запуск выравнивается на границу часа: 12:00, 13:00 и т.д. | +| `BACKUP_CHAT_ID` | Telegram chat ID для архивов. Если пусто, используется `LOG_CHAT_ID`. | +| `BACKUP_THREAD_ID` | Topic/thread ID для архивов. Если пусто, используется `LOG_THREAD_ID`. | +| `BACKUP_DIR` | Локальная папка архивов внутри контейнера. По умолчанию `data/backups` в volume `shop-data`. | +| `BACKUP_LOCAL_RETENTION` | Сколько локальных ZIP-архивов хранить после отправки. По умолчанию `100`. | +| `BACKUP_POSTGRES_DUMP_ENABLED` | Добавлять в архив `pg_dump` базы PostgreSQL. | +| `BACKUP_PG_DUMP_PATH` | Путь к `pg_dump` внутри worker-контейнера. | +| `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. | +| `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. | +| `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. | +| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись `manifest.json` при upload/restore. По умолчанию `True`. | +| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи backup-архивов. Если пусто, используется `BOT_TOKEN`. | +| `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. | +| `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. | +| `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. | +| `BACKUP_COMPOSE_EXCLUDE_DIRS` | Имена директорий, которые не попадают в compose snapshot. | +| `COMPOSE_BACKUP_SOURCE` | Host-путь, который Docker Compose монтирует как `/app/compose-source`. По умолчанию текущая папка compose-файла. | +| `COMPOSE_RESTORE_MODE` | Режим mount для backend: `rw` позволяет восстанавливать compose-папку из админки, `ro` оставляет только чтение. | ## Общие настройки @@ -109,6 +130,8 @@ Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**. +Практическая настройка Mini App вынесена в [веб-приложение](../features/web-app.md), а вход через Telegram - в [Telegram-авторизацию](../features/telegram-auth.md). + | Переменная | Где менять | Назначение | | --- | --- | --- | | `WEBAPP_ENABLED` | `.env` / админка | Включает Web App. Если `False`, пользовательский Web App и админка недоступны до включения через `.env` и рестарта. | @@ -143,6 +166,8 @@ Вход по email появляется только если заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` и `SMTP_FROM_EMAIL`. +Практический сценарий настройки SMTP, magic link и парольного входа описан в [разделе входа по email](../features/email-login.md). + | Переменная | Назначение | | --- | --- | | `SMTP_HOST` | SMTP host. | diff --git a/docs/features/admin-panel.md b/docs/features/admin-panel.md index b8f5cf6..02c0a55 100644 --- a/docs/features/admin-panel.md +++ b/docs/features/admin-panel.md @@ -11,6 +11,7 @@ - редактор разрешенных настроек приложения из manifest-файла; - раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config; +- раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки; - редактор JSON-каталога тарифов; - загрузка Internal Squads из Remnawave для выбора в тарифах. @@ -58,6 +59,10 @@ Для каждого платежного метода в разделе провайдера доступны настройки отображения `PAYMENT__WEBAPP_LABEL_RU`, `PAYMENT__WEBAPP_LABEL_EN`, `PAYMENT__WEBAPP_ICON`, `PAYMENT__TELEGRAM_LABEL_RU`, `PAYMENT__TELEGRAM_LABEL_EN` и `PAYMENT__TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычное значение по умолчанию из модуля платежного провайдера. Иконка Web App выбирается из уже подключенных lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке. +## Бэкапы + +Раздел **Система -> Бэкапы** показывает архивы из `data/backups`, принимает upload ZIP-файла и запускает восстановление БД/compose-папки с предварительной проверкой архива. Подробная эксплуатационная инструкция: [бэкапы и восстановление](backups.md). + ## Переводы Раздел **Система -> Переводы** позволяет переопределять отдельные строки из `locales/ru.json` и `locales/en.json` без монтирования полного файла локализации. Строки сгруппированы по месту применения: админка, Mini App, Telegram-бот, платежи, подписки, поддержка и другие группы. diff --git a/docs/features/backups.md b/docs/features/backups.md new file mode 100644 index 0000000..92c1bea --- /dev/null +++ b/docs/features/backups.md @@ -0,0 +1,142 @@ +# Бэкапы и восстановление + +Minishop умеет автоматически собирать ZIP-бэкапы в worker-контейнере, хранить последние архивы на сервере, отправлять их в Telegram и восстанавливать БД/compose-папку из админки. + +## Что попадает в архив + +Архив создается в `BACKUP_DIR`, по умолчанию `data/backups` внутри volume `shop-data`. + +Типовой файл называется так: + +```text +remnawave-minishop-backup-20260527-120000+0300.zip +``` + +Внутри: + +- `database/.dump` - `pg_dump` в custom format для `pg_restore`; +- `compose/` - snapshot папки с `docker-compose.yml`, `.env` и соседними конфигами; +- `manifest.json` - дата создания, сведения о БД, compose snapshot и предупреждения. + +Если compose-папка не смонтирована или недоступна, worker не роняет весь бэкап: архив будет создан с дампом БД и предупреждением в `manifest.json`. + +## Настройка + +Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**. + +Минимальный `.env`: + +```env +BACKUP_ENABLED=True +BACKUP_CHAT_ID=-1001234567890 +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_LOCAL_RETENTION=100 +BACKUP_COMPOSE_ENABLED=True +COMPOSE_BACKUP_SOURCE=. +COMPOSE_RESTORE_MODE=rw +``` + +`BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа: 12:00, 13:00 и т.д. Значение по умолчанию для локального хранения - 100 последних ZIP-архивов. + +`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`. + +Каждый архив подписывается HMAC-подписью в `manifest.json` и содержит SHA-256 каждого файла. По умолчанию restore принимает только архивы с валидной подписью этого инстанса. Если нужен отдельный стабильный ключ подписи, задайте `BACKUP_ARCHIVE_SIGNATURE_SECRET`; если ключ пустой, используется `BOT_TOKEN`. + +## Mount compose-папки + +В стандартных compose-файлах есть два mount: + +- `worker`: `${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro` - только читает папку для создания snapshot; +- `backend`: `${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}` - читает список архивов и может восстановить compose-папку из админки. + +`COMPOSE_BACKUP_SOURCE=.` означает папку рядом с текущим `docker-compose.yml`. Если compose лежит в другом месте, укажите абсолютный host-путь. + +Если нужно запретить восстановление compose-файлов из контейнера, задайте: + +```env +COMPOSE_RESTORE_MODE=ro +``` + +В этом режиме восстановление БД останется доступным, а восстановление compose-папки вернет понятную ошибку о недоступной записи. + +## Восстановление из админки + +Откройте **Система -> Бэкапы**. В разделе можно: + +- выбрать архив, уже лежащий в `data/backups`; +- загрузить ZIP-архив вручную; +- отметить, что восстанавливать: `БД`, `compose-папка` или оба варианта; +- запустить восстановление после подтверждения. + +БД восстанавливается через `pg_restore --clean --if-exists --no-owner --no-privileges`. На время восстановления лучше не запускать платежи, рассылки, массовую синхронизацию и ручные изменения подписок. + +Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами: + +```text +remnawave-minishop-compose-pre-restore-YYYYMMDD-HHMMSS+ZZZZ.zip +``` + +После восстановления compose-папки перезапустите нужные сервисы, чтобы изменения `docker-compose.yml`, `.env`, Caddyfile/Nginx-конфигов и других файлов реально применились: + +```bash +docker compose up -d --build backend worker +docker compose ps +``` + +Если менялись proxy-конфиги, перезапустите соответствующий сервис (`caddy`, `nginx`, `newt`). + +## Проверка архива перед восстановлением + +Backend валидирует архив до восстановления: + +- файл должен быть валидным ZIP; +- `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата; +- HMAC-подпись manifest должна быть валидной, если `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True`; +- SHA-256 и размер каждого файла должны совпадать с manifest; +- выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется; +- пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли; +- архивы с подозрительно большим числом файлов, размером или zip-bomb compression ratio отклоняются; +- для восстановления БД нужен `database/*.dump` или `database/*.backup`; +- для восстановления compose нужны файлы внутри `compose/`; +- compose restore стартует только если целевая папка существует и доступна на запись; +- backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно. + +Это защищает от случайной загрузки мусорного файла, zip-slip-архивов, поврежденных ZIP и структурно похожих архивов, которые не были созданы этим инстансом. Если вы сознательно восстанавливаете старый неподписанный архив, временно выставьте `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=False`, восстановите архив и верните проверку обратно. + +## Ручное восстановление БД + +Если админка недоступна, можно восстановить дамп вручную: + +```bash +unzip remnawave-minishop-backup-YYYYMMDD-HHMMSS+ZZZZ.zip -d restore +docker compose cp restore/database/remnawave_minishop.dump postgres:/tmp/remnawave_minishop.dump +docker compose stop backend worker +docker compose exec postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges /tmp/remnawave_minishop.dump' +docker compose up -d backend worker +``` + +После ручного восстановления проверьте миграции и healthcheck: + +```bash +docker compose run --rm migrate +docker compose ps +docker compose logs -f backend worker +``` + +## Переменные + +Полный справочник лежит в [переменных окружения](../configuration/env-vars.md#кеши-rate-limits-и-worker). Основные ключи: + +| Переменная | Назначение | +| --- | --- | +| `BACKUP_ENABLED` | Включает периодические бэкапы. | +| `BACKUP_CHAT_ID` / `BACKUP_THREAD_ID` | Куда отправлять архивы в Telegram. | +| `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. | +| `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. | +| `BACKUP_DIR` | Каталог ZIP-архивов. | +| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись manifest при upload/restore. | +| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи архивов; если пустой, используется `BOT_TOKEN`. | +| `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. | +| `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. | +| `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. | +| `BACKUP_PG_DUMP_PATH` / `BACKUP_PG_RESTORE_PATH` | Пути к `pg_dump` и `pg_restore` внутри контейнеров. | diff --git a/docs/features/core.md b/docs/features/core.md index d3430a4..bd20b35 100644 --- a/docs/features/core.md +++ b/docs/features/core.md @@ -4,7 +4,7 @@ Minishop закрывает путь от регистрации пользов ## Для пользователей -- Регистрация через Telegram Mini App или email-код. +- Регистрация через [Telegram Mini App](telegram-auth.md) или [email-код](email-login.md). - Просмотр подписки, срока действия, трафика и ссылки подключения. - Покупка подписки, пакетов трафика и дополнительных устройств. - Пробный период, промокоды и реферальные сценарии. diff --git a/docs/features/email-login.md b/docs/features/email-login.md new file mode 100644 index 0000000..720100f --- /dev/null +++ b/docs/features/email-login.md @@ -0,0 +1,108 @@ +# Вход по email + +Email-вход позволяет пользователю зарегистрироваться или войти в Mini App без Telegram. Пользователь вводит email, получает одноразовый код и может подтвердить вход кодом или magic link из письма. После входа email можно связать с Telegram-аккаунтом в настройках профиля. + +Аккаунты только с email не получают права администратора: админка проверяет Telegram ID из `ADMIN_IDS`. + +## Когда форма появляется + +Кнопка входа по email показывается только если заполнены все обязательные SMTP-настройки: + +```ini +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=no-reply@domain.com +``` + +Если хотя бы одно из этих полей пустое, backend вернет `email_auth_enabled=false` в bootstrap, а frontend скроет email-login. + +Для magic link также нужен корректный `SUBSCRIPTION_MINI_APP_URL`, потому что ссылка в письме строится на его основе. + +## SMTP-настройка + +Типовой пример: + +```ini +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_FALLBACK_PORTS=2525,465 +SMTP_TIMEOUT_SECONDS=30 +SMTP_STARTTLS=True +SMTP_USE_SSL=False +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=no-reply@domain.com +SMTP_FROM_NAME=Remnawave Minishop + +EMAIL_CODE_TTL_SECONDS=600 +EMAIL_CODE_RESEND_SECONDS=60 +EMAIL_CODE_MAX_ATTEMPTS=5 +BRUTE_FORCE_MAX_FAILURES=5 +BRUTE_FORCE_WINDOW_SECONDS=900 +BRUTE_FORCE_LOCK_SECONDS=900 +``` + +Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически. + +`SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App. + +Полный справочник переменных: [SMTP и вход по email](../configuration/env-vars.md#smtp-и-вход-по-email). + +## Как работает вход + +1. Пользователь вводит email в форме входа. +2. Backend проверяет rate limit и создает одноразовый код. +3. Письмо отправляется через SMTP. Если `SUBSCRIPTION_MINI_APP_URL` валиден, в письме также есть magic link. +4. Пользователь вводит код в Mini App или открывает magic link. +5. Backend создает нового email-пользователя или находит существующего по email. +6. Если в URL был referral-параметр, он применяется к новой или существующей записи. +7. Пользователь получает Web App-сессию. + +Коды хранятся в базе в хешированном виде, устаревают по `EMAIL_CODE_TTL_SECONDS`, повторная отправка ограничена `EMAIL_CODE_RESEND_SECONDS`, а количество попыток ввода ограничено `EMAIL_CODE_MAX_ATTEMPTS` и общими brute-force настройками. + +## Парольный вход + +После подтверждения email пользователь может задать пароль в настройках профиля. Пароль хранится как PBKDF2-SHA256 hash с солью. + +После установки пароля доступен путь: + +```text +https://app.domain.com/login/password +``` + +Если парольный вход не удался, frontend предлагает fallback на обычный email-код. Установка или изменение пароля тоже подтверждается email-кодом. + +## Привязка аккаунтов + +В настройках профиля пользователь может: + +- привязать email к Telegram-аккаунту через код; +- привязать Telegram к email-аккаунту через Telegram Mini Apps `initData` или Telegram OAuth; +- задать или сменить пароль для email-входа. + +Если email уже принадлежит другой записи, backend выполняет безопасное объединение по существующим правилам аккаунтов и инвалидирует старые Web App-кеши. + +## Проверка после настройки + +1. Перезапустите backend/frontend после изменения `.env`. +2. Откройте `https://app.domain.com/` вне Telegram. +3. Убедитесь, что форма email-входа видна. +4. Запросите код на тестовый адрес. +5. Проверьте письмо, magic link и ручной ввод 6-значного кода. +6. Проверьте логи backend, если письмо не пришло: + +```bash +docker compose logs -f backend +``` + +## Частые ошибки + +- Форма email не видна: не заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` или `SMTP_FROM_EMAIL`. +- Письмо не отправляется: проверьте порт, STARTTLS/SSL режим, SMTP login/API key и подтверждение отправителя. +- Magic link ведет не туда: исправьте `SUBSCRIPTION_MINI_APP_URL`, он должен быть публичным HTTPS URL Mini App без `/api` и `/auth`. +- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере. +- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки. + +Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md). diff --git a/docs/features/support.md b/docs/features/support.md index 006a36a..589f5b9 100644 --- a/docs/features/support.md +++ b/docs/features/support.md @@ -45,7 +45,7 @@ - `SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS` - пауза для Telegram/log уведомлений; - `SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS` - пауза для email-уведомлений. -Email-уведомления администраторам включаются через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True`. Письма отправляются только администраторам из `ADMIN_IDS`, у которых в базе есть email. Для отправки нужен рабочий SMTP-конфиг, как и для входа по email. +Email-уведомления администраторам включаются через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True`. Письма отправляются только администраторам из `ADMIN_IDS`, у которых в базе есть email. Для отправки нужен рабочий SMTP-конфиг, как и для [входа по email](email-login.md). Ответ администратора и закрытие тикета дополнительно отправляются пользователю в Telegram, если у него есть Telegram-аккаунт, и на email, если он привязан. diff --git a/docs/features/telegram-auth.md b/docs/features/telegram-auth.md new file mode 100644 index 0000000..4e60734 --- /dev/null +++ b/docs/features/telegram-auth.md @@ -0,0 +1,98 @@ +# Telegram-авторизация + +Telegram-вход в Mini App работает двумя способами: + +- внутри Telegram Mini App backend проверяет Telegram Mini Apps `initData`; +- при открытии сайта в обычном браузере используется Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, `nonce`, callback `/auth/telegram/callback` и серверной проверкой `id_token` по JWKS Telegram. + +`initData` не требует отдельного OAuth-секрета, но требует корректного `BOT_TOKEN`, публичного HTTPS Mini App URL и настройки Mini Apps в BotFather. OAuth нужен для входа через кнопку Telegram вне клиента Telegram и для привязки Telegram к email-аккаунту из настроек профиля. + +## Что нужно заранее + +Минимальные переменные: + +```ini +WEBAPP_ENABLED=True +SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ +WEBAPP_SESSION_SECRET= +WEBAPP_AUTH_MAX_AGE_SECONDS=86400 +WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 + +TELEGRAM_OAUTH_CLIENT_ID= +TELEGRAM_OAUTH_CLIENT_SECRET= +TELEGRAM_OAUTH_REQUEST_ACCESS=write +``` + +`SUBSCRIPTION_MINI_APP_URL` должен быть публичным HTTPS URL именно frontend/Mini App-домена. Не добавляйте сюда `/api`, `/auth`, webhook-путь или конкретную страницу. + +`WEBAPP_SESSION_SECRET` должен быть стабильным между рестартами, иначе Web App-сессии и OAuth state-cookie станут невалидными. + +`WEBAPP_AUTH_MAX_AGE_SECONDS` ограничивает возраст Telegram Mini Apps `initData` и OAuth `id_token`. По умолчанию это 24 часа. Слишком маленькое значение может ломать вход на устройствах с неточными часами. + +`WEBAPP_LOGIN_TOKEN_TTL_SECONDS` управляет TTL OAuth state, nonce и login-token. По умолчанию 10 минут. + +`TELEGRAM_OAUTH_CLIENT_ID` можно не задавать, если client id совпадает с bot id: приложение возьмет его из префикса `BOT_TOKEN`. `TELEGRAM_OAUTH_CLIENT_SECRET` для браузерного OAuth обязателен. + +`TELEGRAM_OAUTH_REQUEST_ACCESS=write` добавляет scope `telegram:bot_access`, чтобы бот мог написать пользователю после логина. Если это не нужно, оставьте переменную пустой. Также поддерживается `phone`, если вы осознанно запрашиваете телефон. + +Полный справочник переменных: [Веб-приложение, внешний вид и Telegram Login](../configuration/env-vars.md#веб-приложение-внешний-вид-и-telegram-login). + +## Настройка в BotFather + +1. Откройте `@BotFather` -> `/mybots` -> выберите бота. +2. В `Bot Settings` -> `Domain` укажите домен Web App без протокола и пути, например `app.domain.com`. +3. В `Bot Settings` -> `Mini Apps` укажите URL, например `https://app.domain.com/`. +4. В `Bot Settings` -> `Web Login` включите OpenID Connect Login, если BotFather предлагает переключение. +5. Скопируйте client id и client secret в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`. +6. В `Web Login` -> `Allowed URLs` добавьте: + +```text +https://app.domain.com/ +https://app.domain.com/auth/telegram/callback +``` + +После изменения `.env` перезапустите backend и frontend: + +```bash +docker compose up -d --force-recreate backend frontend +``` + +## Проксирование + +Публичный домен `SUBSCRIPTION_MINI_APP_URL` должен идти в контейнер `frontend:80`. Frontend nginx сам проксирует `/api/*` и `/auth/*` во внутренний WebApp-сервер backend на `backend:8081`. + +Если используете собственный reverse proxy, не отправляйте `/auth/telegram/start` и `/auth/telegram/callback` напрямую в webhook-сервер `backend:8080`: эти маршруты принадлежат Web App API на `backend:8081` и штатно проходят через frontend. + +Готовые схемы Caddy, Nginx, Newt и прямой публикации описаны в [развертывании](../getting-started/deployment.md#готовые-папки-запуска). + +## Как проверить + +Внутри Telegram: + +1. Откройте Mini App кнопкой бота или через URL, настроенный в BotFather. +2. Проверьте, что пользователь входит без OAuth-redirect и видит личный кабинет. +3. Если вход не проходит, проверьте `SUBSCRIPTION_MINI_APP_URL`, домен BotFather и возраст `initData`. + +В обычном браузере: + +1. Откройте `https://app.domain.com/`. +2. Нажмите вход через Telegram. +3. Проверьте redirect на Telegram OAuth и возврат на `https://app.domain.com/auth/telegram/callback`. +4. После успешного callback пользователь должен вернуться на `/` со статусом `telegram_auth=success`, который frontend очистит из URL. + +Для диагностики полезны: + +```bash +curl -i https://app.domain.com/auth/telegram/start +docker compose logs -f backend frontend +``` + +## Частые ошибки + +- `telegram_oauth_not_configured` или `telegram_auth=not_configured`: не задан `TELEGRAM_OAUTH_CLIENT_SECRET` или client id не удалось получить из `TELEGRAM_OAUTH_CLIENT_ID`/`BOT_TOKEN`. +- `Telegram OAuth nonce mismatch`: сессия/state устарели, поменялся `WEBAPP_SESSION_SECRET`, пользователь открыл старую вкладку или callback пришел с другого домена. +- `Telegram OAuth ID token is stale`: `WEBAPP_AUTH_MAX_AGE_SECONDS` слишком маленький или на сервере/клиенте сбито время. +- `Telegram OAuth callback failed`: проверьте allowed URL в BotFather и что `/auth/*` доходит до frontend/WebApp API. +- Mini App не открывается внутри Telegram: домен в BotFather должен совпадать с `SUBSCRIPTION_MINI_APP_URL`, а URL должен быть HTTPS. + +Общие логи по авторизации собраны в [разделе диагностики логов](../troubleshooting/logs.md#авторизация-mini-app-и-telegram-oauth). diff --git a/docs/features/web-app.md b/docs/features/web-app.md index b39b9ad..65baad8 100644 --- a/docs/features/web-app.md +++ b/docs/features/web-app.md @@ -38,20 +38,6 @@ WEBAPP_SESSION_TTL_SECONDS=86400 WEBAPP_AUTH_MAX_AGE_SECONDS=86400 WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 -TELEGRAM_OAUTH_CLIENT_ID= -TELEGRAM_OAUTH_CLIENT_SECRET= -TELEGRAM_OAUTH_REQUEST_ACCESS=write - -SMTP_HOST=smtp-relay.brevo.com -SMTP_PORT=587 -SMTP_FALLBACK_PORTS=2525,465 -SMTP_STARTTLS=True -SMTP_USE_SSL=False -SMTP_USERNAME= -SMTP_PASSWORD= -SMTP_FROM_EMAIL=no-reply@domain.com -SMTP_FROM_NAME=Remnawave Minishop - SUPPORT_LINK=https://t.me/your_support_link SUPPORT_TICKETS_ENABLED=True SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 @@ -83,43 +69,17 @@ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). -Если SMTP-настройки не заполнены, вход по email скрывается. +## Авторизация + +Mini App поддерживает вход через Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect вне Telegram и email-код. Подробная настройка вынесена в отдельные разделы: + +- [Telegram-авторизация](telegram-auth.md) - BotFather, Mini Apps, Web Login, callback `/auth/telegram/callback`, OAuth-переменные и типичные ошибки. +- [Вход по email](email-login.md) - SMTP, одноразовые коды, magic link, парольный вход и проверки доставки писем. + +Если SMTP-настройки не заполнены, вход по email скрывается. Если Telegram OAuth не настроен, вход через Telegram продолжает работать внутри Telegram Mini App через `initData`, но внешняя браузерная авторизация не сможет стартовать. Тикеты поддержки включаются через `SUPPORT_TICKETS_ENABLED`; внешний резервный контакт задается `SUPPORT_LINK`. Полный сценарий пользователя, админа и уведомлений описан в разделе [поддержка пользователей / тикеты](support.md). -## Telegram-авторизация - -Внутри Telegram Mini App пользователь авторизуется через Telegram Mini Apps `initData`. При открытии страницы вне Telegram используется Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, callback `/auth/telegram/callback`, `nonce` и серверной проверкой `id_token` по JWKS Telegram. - -Настройка в BotFather: - -1. Откройте `@BotFather` -> `/mybots` -> выберите бота. -2. В `Bot Settings` -> `Domain` укажите домен Web App без протокола и пути, например `app.domain.com`. -3. В `Bot Settings` -> `Mini Apps` укажите URL, например `https://app.domain.com/`. -4. В `Bot Settings` -> `Web Login` включите OpenID Connect Login, если BotFather предлагает переключение. -5. Скопируйте идентификатор клиента и секрет клиента в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`. -6. В `Web Login` -> `Allowed URLs` добавьте: - -```text -https://app.domain.com/ -https://app.domain.com/auth/telegram/callback -``` - -`TELEGRAM_OAUTH_REQUEST_ACCESS=write` разрешает боту написать пользователю после логина. Если дополнительные разрешения не нужны, оставьте переменную пустой. - -## Вход по email - -Вход по email работает через одноразовый код: - -1. Пользователь вводит email. -2. Бот отправляет код через SMTP. -3. Код вводится в модальном окне Web App. -4. После подтверждения создается или находится пользователь, а email можно связать с Telegram-аккаунтом. - -Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL. - -Полный список переменных, обязательные поля для включения входа по email и типичные ошибки подключения описаны в разделе **SMTP и вход по email** в [configuration.md](../getting-started/configuration.md). - ## Проксирование Рекомендуемая продакшен-схема - два публичных домена: diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 7b5ff90..a660c67 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -105,6 +105,8 @@ docker compose exec backend sh -lc 'id; touch /app/data/themes/test && rm /app/d - [configuration/env-vars.md](../configuration/env-vars.md) - полный справочник переменных `.env`. - [features/admin-panel.md](../features/admin-panel.md) - как устроены overrides и allowlist настроек. - [features/tariffs.md](../features/tariffs.md) - JSON-каталог тарифов и редактор тарифов. -- [Веб-приложение / Mini App](../features/web-app.md) - домен Mini App, Telegram OAuth и вход по email. +- [Веб-приложение / Mini App](../features/web-app.md) - домен Mini App, инструкции установки и проксирование. +- [Telegram-авторизация](../features/telegram-auth.md) - BotFather, Mini Apps и OAuth. +- [Вход по email](../features/email-login.md) - SMTP, коды, magic link и парольный вход. - [Поддержка пользователей / тикеты](../features/support.md) - тикеты поддержки и уведомления. - [Развертывание](deployment.md) - Docker Compose, обратный прокси, Caddy/Nginx и обновления. diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index 6fb8858..a8951c2 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -356,6 +356,8 @@ docker compose exec backend sh -lc 'id; touch /app/data/themes/test && rm /app/d ## Резервная копия PostgreSQL +Для штатных автоматических ZIP-бэкапов, отправки в Telegram и восстановления через админку используйте раздел [бэкапы и восстановление](../features/backups.md). Команды ниже - минимальный ручной fallback для PostgreSQL. + ```bash docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql ``` diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 5490692..4095f46 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -14,5 +14,5 @@ Remnawave Minishop состоит из Telegram-бота, backend API, worker-п - [Установка](setup.md) - базовый запуск через Compose. - [Развертывание](../deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси. -- [Настройка Telegram бота](../features/web-app.md#telegram-авторизация) - Telegram OAuth и Telegram Mini App. -- [Настройка SMTP](../features/web-app.md#вход-по-email) - Вход и регистрация по email. +- [Настройка Telegram бота](../features/telegram-auth.md) - Telegram OAuth и Telegram Mini App. +- [Настройка SMTP](../features/email-login.md) - Вход и регистрация по email. diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index dc5b6f2..55275a3 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -39,8 +39,8 @@ docker compose up -d ## Настройки для веб апп -- [Настройка Telegram бота](../features/web-app.md#telegram-авторизация) - Telegram OAuth и Telegram Mini App. -- [Настройка SMTP](../features/web-app.md#вход-по-email) - Вход и регистрация по email. +- [Настройка Telegram бота](../features/telegram-auth.md) - Telegram OAuth и Telegram Mini App. +- [Настройка SMTP](../features/email-login.md) - Вход и регистрация по email. ## После первого входа diff --git a/docs/index.md b/docs/index.md index ff2637c..fae9edb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,5 +8,5 @@ Remnawave Minishop - Telegram-бот и Mini App для продажи и упр - **Продажа подписок** - тарифы на срок и по трафику, докупки трафика, HWID-устройства, [premium-сквады](features/tariffs.md#premium-сквады-и-отдельный-лимит) - **Жизненный цикл пользователей** - регистрация, пробный период, продление, синхронизация с панелью и предупреждения по трафику. -- **Mini App** - личный кабинет, инструкции установки, Telegram OAuth, вход по email и публичные реферальные ссылки. -- **Операционные инструменты** - админка, тикеты поддержки, промокоды, рассылки, логи и настройки поверх `.env`. +- **Mini App** - личный кабинет, инструкции установки, [Telegram OAuth](features/telegram-auth.md), [вход по email](features/email-login.md) и публичные реферальные ссылки. +- **Операционные инструменты** - админка, тикеты поддержки, промокоды, рассылки, логи, [бэкапы и восстановление](features/backups.md), настройки поверх `.env`. diff --git a/docs/troubleshooting/logs.md b/docs/troubleshooting/logs.md index 8ba838d..dfccd5c 100644 --- a/docs/troubleshooting/logs.md +++ b/docs/troubleshooting/logs.md @@ -92,7 +92,7 @@ docker compose logs -f backend - `/auth/telegram/start` и `/auth/telegram/callback` проходят через frontend nginx в `backend:8081`; - в браузере после callback нет статуса `telegram_auth=invalid_state`, `invalid_token`, `not_configured`, `unauthorized` или `failed`. -Подробности по маршрутам и настройке OAuth: [веб-приложение / Mini App](../features/web-app.md). +Подробности по маршрутам и настройке OAuth: [Telegram-авторизация](../features/telegram-auth.md). ## После изменения конфигурации diff --git a/docs/troubleshooting/maintenance.md b/docs/troubleshooting/maintenance.md index b4502c7..1f24906 100644 --- a/docs/troubleshooting/maintenance.md +++ b/docs/troubleshooting/maintenance.md @@ -1,6 +1,6 @@ # Обслуживание -Плановое обслуживание обычно сводится к обновлению образов, проверке миграций, логов и резервных копий PostgreSQL. +Плановое обслуживание обычно сводится к обновлению образов, проверке миграций, логов и резервных копий. Подробная инструкция по автоматическим ZIP-бэкапам и восстановлению вынесена в [бэкапы и восстановление](../features/backups.md). ## Обновление @@ -16,6 +16,19 @@ docker compose logs -f migrate backend worker docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql ``` +## Автоматические бэкапы + +Worker может собирать ZIP-архивы с дампом PostgreSQL и snapshot compose-папки, отправлять их в Telegram и хранить последние архивы в `data/backups`. Настройка и восстановление описаны в [отдельном разделе](../features/backups.md). + +После изменения backup-настроек в `.env` перезапустите backend и worker: + +```bash +docker compose up -d --build backend worker +docker compose logs -f backend worker +``` + +Восстановление из архива доступно в админке **Система -> Бэкапы**. Там же можно загрузить ZIP вручную, выбрать `БД` и/или `compose-папка`, а backend проверит архив перед запуском. Подробности: [бэкапы и восстановление](../features/backups.md#восстановление-из-админки). + ## Проверки после работ - `docker compose ps` diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index 64da25d..e31cdbc 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -5,6 +5,7 @@ ChevronsUpDown, Coins, CreditCard, + Database, Download, FileText, Globe2, @@ -29,6 +30,7 @@ import BrandMark from "$lib/webapp/BrandMark.svelte"; import AdsSection from "./sections/AdsSection.svelte"; + import BackupsSection from "./sections/BackupsSection.svelte"; import BroadcastSection from "./sections/BroadcastSection.svelte"; import LogsSection from "./sections/LogsSection.svelte"; import PaymentDetailModal from "./sections/PaymentDetailModal.svelte"; @@ -44,6 +46,7 @@ import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; + import { createBackupsStore } from "../lib/admin/stores/backupsStore.js"; import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js"; import { createLogsStore } from "../lib/admin/stores/logsStore.js"; import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js"; @@ -133,6 +136,7 @@ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, { id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush }, { id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages }, + { id: "backups", label: at("nav_backups", {}, "Бэкапы"), icon: Database }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -191,6 +195,10 @@ "Оверрайды строк локализации из базы данных и data/locales-overrides.json" ), }, + backups: { + title: at("section_backups_title", {}, "Бэкапы"), + subtitle: at("section_backups_subtitle", {}, "Архивы, загрузка и восстановление БД/compose"), + }, settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), @@ -227,6 +235,7 @@ } const adsStore = createAdsStore({ api, onToast: flash, at }); + const backupsStore = createBackupsStore({ api, onToast: flash, at }); const broadcastStore = createBroadcastStore({ api, onToast: flash, at }); const logsStore = createLogsStore({ api, at }); const paymentsStore = createPaymentsStore({ api, onToast: flash, at }); @@ -241,6 +250,7 @@ setContext("promosStore", promosStore); setContext("adsStore", adsStore); + setContext("backupsStore", backupsStore); setContext("broadcastStore", broadcastStore); setContext("logsStore", logsStore); setContext("paymentsStore", paymentsStore); @@ -790,6 +800,10 @@ {/if} + {#if active === "backups"} + + {/if} + {#if active === "translations"} {/if} diff --git a/frontend/src/admin/sections/BackupsSection.svelte b/frontend/src/admin/sections/BackupsSection.svelte new file mode 100644 index 0000000..f440f43 --- /dev/null +++ b/frontend/src/admin/sections/BackupsSection.svelte @@ -0,0 +1,351 @@ + + +
+
+
+ backupsStore.loadArchives()} disabled={backupsLoading}> + + {at("btn_refresh", {}, "Обновить")} + + fileInput?.click()} disabled={backupsUploading}> + + {backupsUploading + ? at("backups_uploading", {}, "Загрузка...") + : at("backups_upload", {}, "Загрузить архив")} + + +
+
+ {at("backups_dir", {}, "Каталог")} + {backupDir || "data/backups"} +
+
+ +
+
+
+

{at("backups_restore_title", {}, "Восстановление")}

+ {#if selectedArchive} + {selectedArchive.name} + {/if} +
+ {#if lastRestore} + + + {at("backups_last_restore_done", {}, "Готово")} + + {/if} +
+
+ + + + + {backupsRestoring + ? at("backups_restoring", {}, "Восстановление...") + : at("backups_restore_run", {}, "Запустить")} + +
+ {#if lastRestore?.compose_pre_restore_archive} +
+ {at( + "backups_pre_restore_snapshot", + { path: lastRestore.compose_pre_restore_archive }, + "Текущая compose-папка сохранена перед заменой." + )} +
+ {/if} +
+ +
+ {#if backupsLoading} + + {:else if !archives?.length} + + {at("backups_empty", {}, "Архивов пока нет")} + + {:else} + + + + + {at("backups_col_archive", {}, "Архив")} + {at("backups_col_created", {}, "Создан")} + {at("backups_col_size", {}, "Размер")} + {at("backups_col_contents", {}, "Состав")} + {at("backups_col_warnings", {}, "Предупреждения")} + + + + {#each archives as archive (archive.name)} + + + (selectedName = archive.name)} + aria-label={archive.name} + /> + + + {archive.name} + + {fmtDate(archiveDate(archive))} + {formatSize(archive.size_bytes)} + + + {#if archive.has_database} + {at("backups_badge_db", {}, "БД")} + {/if} + {#if archive.has_compose} + + {at("backups_badge_compose", {}, "Compose")} + + {/if} + + + + {#if archive.warnings?.length} + + + {archive.warnings.length} + + {:else} + - + {/if} + + + {/each} + + + {/if} +
+
+ + diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 499ba5f..2c6ef95 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -339,6 +339,7 @@ trial: "Триал", referral: "Реферальная программа", notifications: "Уведомления", + backups: "Бэкапы", support: "Поддержка", devices: "Устройства", subscription_guides: "Connection guides", @@ -560,6 +561,8 @@ class="input" type="number" step={field.type === "float" ? "0.1" : "1"} + min={field.min ?? undefined} + max={field.max ?? undefined} placeholder={fieldPlaceholderText(field)} value={valueFor(field) ?? ""} oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} diff --git a/frontend/src/lib/admin/stores/backupsStore.js b/frontend/src/lib/admin/stores/backupsStore.js new file mode 100644 index 0000000..f1e9035 --- /dev/null +++ b/frontend/src/lib/admin/stores/backupsStore.js @@ -0,0 +1,93 @@ +import { writable } from "svelte/store"; + +export function createBackupsStore({ api, onToast, at }) { + const state = writable({ + archives: [], + backupDir: "", + backupsLoading: false, + backupsUploading: false, + backupsRestoring: false, + lastRestore: null, + }); + + async function loadArchives() { + state.update((s) => ({ ...s, backupsLoading: true })); + try { + const data = await api("/admin/backups"); + if (data?.ok) { + state.update((s) => ({ + ...s, + archives: data.archives || [], + backupDir: data.backup_dir || "", + })); + } else { + onToast(data?.message || data?.error || at("backups_load_failed", {}, "Не удалось загрузить бэкапы")); + } + } finally { + state.update((s) => ({ ...s, backupsLoading: false })); + } + } + + async function uploadArchive(file) { + if (!file) return null; + state.update((s) => ({ ...s, backupsUploading: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/backups/upload", { + method: "POST", + body, + }); + if (data?.ok) { + onToast(at("backups_upload_done", {}, "Архив загружен")); + await loadArchives(); + return data.archive || null; + } + onToast(data?.message || data?.error || at("backups_upload_failed", {}, "Не удалось загрузить архив")); + return null; + } finally { + state.update((s) => ({ ...s, backupsUploading: false })); + } + } + + async function restoreArchive({ archiveName, restoreDatabase, restoreCompose }) { + const archive_name = String(archiveName || "").trim(); + if (!archive_name) { + onToast(at("backups_select_archive", {}, "Выберите архив")); + return false; + } + if (!restoreDatabase && !restoreCompose) { + onToast(at("backups_select_target", {}, "Выберите, что восстановить")); + return false; + } + + state.update((s) => ({ ...s, backupsRestoring: true, lastRestore: null })); + try { + const data = await api("/admin/backups/restore", { + method: "POST", + body: JSON.stringify({ + archive_name, + restore_database: Boolean(restoreDatabase), + restore_compose: Boolean(restoreCompose), + confirm: true, + }), + }); + if (data?.ok) { + state.update((s) => ({ ...s, lastRestore: data.result || null })); + onToast(at("backups_restore_done", {}, "Восстановление завершено")); + return true; + } + onToast(data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить")); + return false; + } finally { + state.update((s) => ({ ...s, backupsRestoring: false })); + } + } + + return { + subscribe: state.subscribe, + loadArchives, + uploadArchive, + restoreArchive, + }; +} diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js index 20df700..8a37e92 100644 --- a/frontend/src/lib/components/ui/icons.js +++ b/frontend/src/lib/components/ui/icons.js @@ -63,6 +63,7 @@ export { TrendingDown, TrendingUp, TriangleAlert, + Upload, User, UserMinus, UserPlus, diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 59f9ce1..aba1bdc 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -270,6 +270,34 @@ export async function mockApi(path, options = {}, context = {}) { } return out; })(); + const mockBackups = [ + { + name: "remnawave-minishop-backup-20260527-120000+0300.zip", + size_bytes: 184320, + modified_at: "2026-05-27T09:00:00Z", + created_at: "2026-05-27T09:00:00Z", + created_at_local: "2026-05-27T12:00:00+03:00", + has_database: true, + has_compose: true, + database_name: "remnawave_minishop", + compose_files_count: 6, + warnings: [], + manifest: {}, + }, + { + name: "remnawave-minishop-backup-20260527-110000+0300.zip", + size_bytes: 153600, + modified_at: "2026-05-27T08:00:00Z", + created_at: "2026-05-27T08:00:00Z", + created_at_local: "2026-05-27T11:00:00+03:00", + has_database: true, + has_compose: false, + database_name: "remnawave_minishop", + compose_files_count: 0, + warnings: ["Compose source directory is unavailable"], + manifest: {}, + }, + ]; if (path === "/admin/stats") { return { ok: true, @@ -440,6 +468,41 @@ export async function mockApi(path, options = {}, context = {}) { }, }; } + if (path === "/admin/backups") { + return { + ok: true, + backup_dir: "data/backups", + archives: clone(mockBackups), + }; + } + if (path === "/admin/backups/upload") { + return { + ok: true, + archive: { + ...mockBackups[0], + name: `remnawave-minishop-backup-uploaded-${Date.now()}.zip`, + modified_at: new Date().toISOString(), + created_at: new Date().toISOString(), + created_at_local: new Date().toISOString(), + }, + }; + } + if (path === "/admin/backups/restore") { + return { + ok: true, + result: { + archive_name: mockBackups[0].name, + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + database_restored: true, + compose_files_restored: 6, + compose_target_dir: "/app/compose-source", + compose_pre_restore_archive: + "data/backups/remnawave-minishop-compose-pre-restore-20260527-121500+0300.zip", + warnings: [], + }, + }; + } if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") { try { const body = options?.body ? JSON.parse(String(options.body)) : {}; diff --git a/locales/en.json b/locales/en.json index db91b04..08f3a89 100644 --- a/locales/en.json +++ b/locales/en.json @@ -871,6 +871,7 @@ "admin_nav_system": "System", "admin_nav_tariffs": "Tariffs", "admin_nav_themes": "Themes", + "admin_nav_backups": "Backups", "admin_nav_settings": "Settings", "admin_section_stats_title": "Dashboard", "admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments", @@ -890,8 +891,36 @@ "admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits", "admin_section_themes_title": "Web App themes", "admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance", + "admin_section_backups_title": "Backups", + "admin_section_backups_subtitle": "Archives, uploads, and database/compose restore", "admin_section_settings_title": "App Settings", "admin_section_settings_subtitle": "Overrides for .env, applied instantly", + "admin_backups_load_failed": "Failed to load backups", + "admin_backups_upload_done": "Archive uploaded", + "admin_backups_upload_failed": "Failed to upload archive", + "admin_backups_select_archive": "Select an archive", + "admin_backups_select_target": "Select what to restore", + "admin_backups_restore_done": "Restore completed", + "admin_backups_restore_failed": "Restore failed", + "admin_backups_col_archive": "Archive", + "admin_backups_col_created": "Created", + "admin_backups_col_size": "Size", + "admin_backups_col_contents": "Contents", + "admin_backups_col_warnings": "Warnings", + "admin_backups_target_database": "Database", + "admin_backups_target_compose": "compose folder", + "admin_backups_restore_confirm": "Start restore from {name}: {components}?", + "admin_backups_uploading": "Uploading...", + "admin_backups_upload": "Upload archive", + "admin_backups_dir": "Directory", + "admin_backups_restore_title": "Restore", + "admin_backups_last_restore_done": "Done", + "admin_backups_restoring": "Restoring...", + "admin_backups_restore_run": "Start", + "admin_backups_pre_restore_snapshot": "Current compose folder was saved before replacement: {path}", + "admin_backups_empty": "No archives yet", + "admin_backups_badge_db": "DB", + "admin_backups_badge_compose": "Compose", "admin_filter_all": "All", "admin_filter_not_banned": "Not banned", "admin_filter_banned": "Banned", @@ -975,6 +1004,7 @@ "admin_settings_section_trial": "Trial", "admin_settings_section_referral": "Referral program", "admin_settings_section_notifications": "Notifications", + "admin_settings_section_backups": "Backups", "admin_settings_section_devices": "Devices", "admin_settings_section_support": "Support", "admin_settings_subsection_common": "Common", @@ -1553,6 +1583,18 @@ "admin_settings_field_support_ticket_max_subject_length_description": "Maximum number of characters in a ticket subject.", "admin_settings_field_support_ticket_rate_limit_per_hour_label": "Ticket limit per hour", "admin_settings_field_support_ticket_rate_limit_per_hour_description": "How many new tickets a user can create per hour. 0 means unlimited.", + "admin_settings_field_backup_enabled_label": "Backups enabled", + "admin_settings_field_backup_enabled_description": "The worker periodically builds a ZIP archive and sends it to Telegram.", + "admin_settings_field_backup_chat_id_label": "Backup chat ID", + "admin_settings_field_backup_chat_id_description": "Where ZIP archives are sent. Falls back to LOG_CHAT_ID when empty.", + "admin_settings_field_backup_thread_id_label": "Backup thread ID", + "admin_settings_field_backup_thread_id_description": "Optional topic/thread ID. Falls back to LOG_THREAD_ID when empty.", + "admin_settings_field_backup_interval_seconds_label": "Backup period (sec.)", + "admin_settings_field_backup_interval_seconds_description": "Default is 3600: run on the hour boundary (12:00, 13:00, etc.).", + "admin_settings_field_backup_local_retention_label": "Archives to keep", + "admin_settings_field_backup_local_retention_description": "How many latest ZIP archives to keep in data/backups on the server.", + "admin_settings_field_backup_compose_enabled_label": "Include compose folder", + "admin_settings_field_backup_compose_enabled_description": "Adds a /app/compose-source snapshot. If the folder is not mounted, the DB backup is still created.", "admin_settings_field_my_devices_section_enabled_label": "My Devices Section Enabled", "admin_settings_field_user_hwid_device_limit_label": "User HWID Device Limit", "admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb", diff --git a/locales/ru.json b/locales/ru.json index 3524c82..3be1f3f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -871,6 +871,7 @@ "admin_nav_system": "Система", "admin_nav_tariffs": "Тарифы", "admin_nav_themes": "Темы", + "admin_nav_backups": "Бэкапы", "admin_nav_settings": "Настройки", "admin_section_stats_title": "Дашборд", "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи", @@ -890,8 +891,36 @@ "admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты", "admin_section_themes_title": "Темы Web App", "admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App", + "admin_section_backups_title": "Бэкапы", + "admin_section_backups_subtitle": "Архивы, загрузка и восстановление БД/compose", "admin_section_settings_title": "Настройки приложения", "admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно", + "admin_backups_load_failed": "Не удалось загрузить бэкапы", + "admin_backups_upload_done": "Архив загружен", + "admin_backups_upload_failed": "Не удалось загрузить архив", + "admin_backups_select_archive": "Выберите архив", + "admin_backups_select_target": "Выберите, что восстановить", + "admin_backups_restore_done": "Восстановление завершено", + "admin_backups_restore_failed": "Не удалось восстановить", + "admin_backups_col_archive": "Архив", + "admin_backups_col_created": "Создан", + "admin_backups_col_size": "Размер", + "admin_backups_col_contents": "Состав", + "admin_backups_col_warnings": "Предупреждения", + "admin_backups_target_database": "БД", + "admin_backups_target_compose": "compose-папка", + "admin_backups_restore_confirm": "Запустить восстановление из {name}: {components}?", + "admin_backups_uploading": "Загрузка...", + "admin_backups_upload": "Загрузить архив", + "admin_backups_dir": "Каталог", + "admin_backups_restore_title": "Восстановление", + "admin_backups_last_restore_done": "Готово", + "admin_backups_restoring": "Восстановление...", + "admin_backups_restore_run": "Запустить", + "admin_backups_pre_restore_snapshot": "Текущая compose-папка сохранена перед заменой: {path}", + "admin_backups_empty": "Архивов пока нет", + "admin_backups_badge_db": "БД", + "admin_backups_badge_compose": "Compose", "admin_filter_all": "Все", "admin_filter_not_banned": "Не забанены", "admin_filter_banned": "Забанены", @@ -975,6 +1004,7 @@ "admin_settings_section_trial": "Триал", "admin_settings_section_referral": "Реферальная программа", "admin_settings_section_notifications": "Уведомления", + "admin_settings_section_backups": "Бэкапы", "admin_settings_section_devices": "Устройства", "admin_settings_section_support": "Поддержка", "admin_settings_subsection_common": "Общие", @@ -1553,6 +1583,18 @@ "admin_settings_field_support_ticket_max_subject_length_description": "Максимальное количество символов в теме тикета.", "admin_settings_field_support_ticket_rate_limit_per_hour_label": "Лимит тикетов в час", "admin_settings_field_support_ticket_rate_limit_per_hour_description": "Сколько новых тикетов пользователь может создать за час. 0 — без лимита.", + "admin_settings_field_backup_enabled_label": "Бэкапы включены", + "admin_settings_field_backup_enabled_description": "Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.", + "admin_settings_field_backup_chat_id_label": "ID чата для бэкапов", + "admin_settings_field_backup_chat_id_description": "Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.", + "admin_settings_field_backup_thread_id_label": "ID треда для бэкапов", + "admin_settings_field_backup_thread_id_description": "Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.", + "admin_settings_field_backup_interval_seconds_label": "Период бэкапов (сек.)", + "admin_settings_field_backup_interval_seconds_description": "По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).", + "admin_settings_field_backup_local_retention_label": "Сколько архивов хранить", + "admin_settings_field_backup_local_retention_description": "Сколько последних ZIP-архивов оставлять в data/backups на сервере.", + "admin_settings_field_backup_compose_enabled_label": "Добавлять compose-папку", + "admin_settings_field_backup_compose_enabled_description": "Добавляет snapshot /app/compose-source. Если папка не смонтирована, бэкап БД все равно будет создан.", "admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»", "admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)", "admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 6910cbc..d1f9ea0 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -2,7 +2,9 @@ import json import re from pathlib import Path -from bot.app.web.admin_settings_manifest import manifest_payload +import pytest + +from bot.app.web.admin_settings_manifest import coerce_value, get_field_by_key, manifest_payload from bot.middlewares.i18n import resolve_locale_key REPO_ROOT = Path(__file__).resolve().parents[1] @@ -33,6 +35,15 @@ SUBSCRIPTION_GUIDE_SETTINGS = ( "SUBSCRIPTION_PAGE_CONFIG_JSON", ) +BACKUP_SETTINGS = ( + "BACKUP_ENABLED", + "BACKUP_CHAT_ID", + "BACKUP_THREAD_ID", + "BACKUP_INTERVAL_SECONDS", + "BACKUP_LOCAL_RETENTION", + "BACKUP_COMPOSE_ENABLED", +) + ADMIN_TARIFF_SETTINGS_PAGE_KEYS = { "admin_tariffs_trial_title", "admin_tariffs_trial_subtitle", @@ -171,6 +182,31 @@ def test_subscription_guide_settings_i18n_keys_exist(): assert field["i18n_description_key"] in messages +def test_backup_settings_i18n_keys_exist(): + manifest = _manifest_by_key() + + assert manifest["BACKUP_ENABLED"]["section"] == "backups" + assert manifest["BACKUP_ENABLED"]["section_order"] == 9 + assert manifest["BACKUP_INTERVAL_SECONDS"]["min"] == 60 + assert manifest["BACKUP_INTERVAL_SECONDS"]["optional"] is False + assert manifest["BACKUP_LOCAL_RETENTION"]["min"] == 1 + assert manifest["BACKUP_LOCAL_RETENTION"]["optional"] is False + + for language in ("ru", "en"): + messages = _locale(language) + + assert "admin_settings_section_backups" in messages + for setting_key in BACKUP_SETTINGS: + field = manifest[setting_key] + assert field["i18n_label_key"] in messages + assert field["i18n_description_key"] in messages + + +def test_backup_required_numeric_settings_reject_empty_values(): + with pytest.raises(ValueError): + coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "") + + def test_payment_provider_settings_include_webhook_metadata(): manifest = _manifest_by_key() @@ -190,9 +226,7 @@ def test_payment_provider_admin_only_toggles_are_mutually_exclusive(): manifest["PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "PLATEGA_CRYPTO_ENABLED" ) - assert ( - manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED" - ) + assert manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED" def test_legacy_tariff_settings_are_separated_from_payment_settings(): diff --git a/tests/test_backup_restore_service.py b/tests/test_backup_restore_service.py new file mode 100644 index 0000000..e4efdb1 --- /dev/null +++ b/tests/test_backup_restore_service.py @@ -0,0 +1,199 @@ +import asyncio +import tempfile +import zipfile +from pathlib import Path + +import pytest + +from bot.services.backup_archive import ( + attach_archive_integrity, + build_file_records, + write_manifest, + write_zip_from_directory, +) +from bot.services.backup_restore_service import ( + BackupArchiveError, + BackupRestoreService, +) +from bot.services.backup_worker import BACKUP_FILENAME_PREFIX +from config.settings import Settings + + +def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings: + values = { + "BOT_TOKEN": "token", + "POSTGRES_USER": "app_user", + "POSTGRES_PASSWORD": "app_password", + "POSTGRES_DB": "shop", + "BACKUP_DIR": str(tmp_path / "backups"), + "BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir), + "_env_file": None, + } + values.update(overrides) + return Settings(**values) + + +def _write_backup_archive( + settings: Settings, + path: Path, + *, + include_db=True, + include_compose=True, + unsafe=False, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=path.parent) as tmp: + staging_dir = Path(tmp) + if include_db: + dump_dir = staging_dir / "database" + dump_dir.mkdir(parents=True) + (dump_dir / "shop.dump").write_bytes(b"fake dump") + if include_compose: + compose_dir = staging_dir / "compose" + compose_dir.mkdir(parents=True) + (compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") + (compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8") + manifest = { + "app": "remnawave-minishop", + "format_version": 1, + "type": "test", + "created_at": "2026-05-27T09:00:00+00:00", + "postgres": {"database": "shop", "included": include_db}, + "compose": {"included": include_compose, "files_count": 2 if include_compose else 0}, + "warnings": [], + } + attach_archive_integrity( + manifest, + file_records=build_file_records(staging_dir), + settings=settings, + ) + write_manifest(staging_dir, manifest) + write_zip_from_directory(staging_dir, path) + if unsafe: + # Add a malicious member after signing; validation must reject before restore. + with zipfile.ZipFile(path, "a") as archive: + archive.writestr("compose/../evil.txt", "nope") + + +def test_backup_restore_service_lists_archives_with_contents(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings(tmp_path, compose_dir) + archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" + _write_backup_archive(settings, archive_path) + + archives = BackupRestoreService(settings).list_archives() + + assert [item.name for item in archives] == [archive_path.name] + assert archives[0].has_database is True + assert archives[0].has_compose is True + assert archives[0].database_name == "shop" + assert archives[0].compose_files_count == 2 + + +def test_backup_restore_service_rejects_path_traversal_archive_name(tmp_path): + settings = _settings(tmp_path, tmp_path / "compose") + service = BackupRestoreService(settings) + + with pytest.raises(BackupArchiveError): + service.archive_path_for_name("../backup.zip") + + +def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + (compose_dir / "docker-compose.yml").write_text("old: true\n", encoding="utf-8") + + settings = _settings(tmp_path, compose_dir) + archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" + _write_backup_archive(settings, archive_path, include_db=False) + + service = BackupRestoreService(settings) + result = service.restore_archive_sync( + archive_path.name, + restore_database=False, + restore_compose=True, + ) + + assert result.database_restored is False + assert result.compose_files_restored == 2 + assert (compose_dir / "docker-compose.yml").read_text(encoding="utf-8") == "services: {}\n" + assert (compose_dir / ".env").read_text(encoding="utf-8") == "POSTGRES_PASSWORD=secret\n" + assert result.compose_pre_restore_archive + assert Path(result.compose_pre_restore_archive).is_file() + snapshot = service.inspect_archive(Path(result.compose_pre_restore_archive)) + assert snapshot.has_compose is True + assert snapshot.compose_files_count == 1 + + +def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings(tmp_path, compose_dir) + archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" + _write_backup_archive(settings, archive_path, include_db=False, unsafe=True) + + with pytest.raises(BackupArchiveError): + BackupRestoreService(settings).restore_archive_sync( + archive_path.name, + restore_database=False, + restore_compose=True, + ) + + assert not (tmp_path / "evil.txt").exists() + + +def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings(tmp_path, compose_dir) + archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" + _write_backup_archive(settings, archive_path, include_compose=False) + service = BackupRestoreService(settings) + restored_payloads = [] + + def fake_pg_restore(dump_path: Path) -> None: + restored_payloads.append(dump_path.read_bytes()) + + service._run_pg_restore = fake_pg_restore + + result = asyncio.run( + service.restore_archive( + archive_path.name, + restore_database=True, + restore_compose=False, + ) + ) + + assert result.database_restored is True + assert restored_payloads == [b"fake dump"] + + +def test_backup_restore_service_validates_uploaded_zip(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings(tmp_path, compose_dir) + temp_path = tmp_path / "not-a-backup.zip" + temp_path.write_text("not zip", encoding="utf-8") + + with pytest.raises(BackupArchiveError): + BackupRestoreService(settings).import_uploaded_archive(temp_path, "backup.zip") + + +def test_backup_restore_service_rejects_tampered_archive(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings(tmp_path, compose_dir) + archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" + _write_backup_archive(settings, archive_path, include_compose=False) + + tampered_path = archive_path.with_name("tampered.zip") + with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target: + for member in source.infolist(): + payload = source.read(member.filename) + if member.filename == "database/shop.dump": + payload = b"not the signed dump" + target.writestr(member, payload) + + with pytest.raises(BackupArchiveError): + BackupRestoreService(settings).import_uploaded_archive(tampered_path, "tampered.zip") diff --git a/tests/test_backup_worker.py b/tests/test_backup_worker.py new file mode 100644 index 0000000..5f5d56b --- /dev/null +++ b/tests/test_backup_worker.py @@ -0,0 +1,168 @@ +import asyncio +import json +import os +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from bot.services.backup_worker import BACKUP_FILENAME_PREFIX, BackupWorker +from bot.services.settings_override_service import refresh_overrides_from_db +from config.settings import Settings + + +class _FakeBot: + def __init__(self): + self.send_document = AsyncMock() + self.send_message = AsyncMock() + + +class _FakePgDumpBackupWorker(BackupWorker): + def _run_pg_dump(self, dump_path: Path) -> None: + dump_path.write_bytes(b"fake custom pg dump") + + +class _FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeSessionFactory: + def __call__(self): + return _FakeSession() + + +def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings: + values = { + "BOT_TOKEN": "token", + "POSTGRES_USER": "app_user", + "POSTGRES_PASSWORD": "app_password", + "POSTGRES_DB": "shop", + "BACKUP_DIR": str(tmp_path / "backups"), + "BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir), + "BACKUP_CHAT_ID": 123, + "BACKUP_LOCAL_RETENTION": 1, + "_env_file": None, + } + values.update(overrides) + return Settings(**values) + + +def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + (compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") + (compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8") + (compose_dir / "Caddyfile").write_text("example.com\n", encoding="utf-8") + (compose_dir / "node_modules").mkdir() + (compose_dir / "node_modules" / "ignored.txt").write_text("ignored", encoding="utf-8") + + settings = _settings(tmp_path, compose_dir) + backup_dir = Path(settings.BACKUP_DIR) + backup_dir.mkdir(parents=True) + old_archive = backup_dir / f"{BACKUP_FILENAME_PREFIX}old.zip" + old_archive.write_text("old", encoding="utf-8") + os.utime(old_archive, (1, 1)) + + bot = _FakeBot() + worker = _FakePgDumpBackupWorker(settings, bot) + + result = asyncio.run(worker.create_and_send_backup()) + + assert result.archive_path.is_file() + assert result.db_dump_included is True + assert result.compose_files_count == 3 + assert not old_archive.exists() + bot.send_document.assert_awaited_once() + send_kwargs = bot.send_document.await_args.kwargs + assert send_kwargs["chat_id"] == 123 + assert "Database dump: yes" in send_kwargs["caption"] + + with zipfile.ZipFile(result.archive_path) as archive: + names = set(archive.namelist()) + manifest = json.loads(archive.read("manifest.json").decode("utf-8")) + + assert "database/shop.dump" in names + assert "compose/docker-compose.yml" in names + assert "compose/.env" in names + assert "compose/Caddyfile" in names + assert all("node_modules" not in name for name in names) + assert manifest["postgres"]["database"] == "shop" + assert manifest["compose"]["files_count"] == 3 + + +def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings( + tmp_path, + compose_dir, + BACKUP_CHAT_ID="", + BACKUP_THREAD_ID="", + LOG_CHAT_ID=-100123, + LOG_THREAD_ID=77, + BACKUP_POSTGRES_DUMP_ENABLED=False, + BACKUP_COMPOSE_ENABLED=False, + ) + bot = _FakeBot() + worker = _FakePgDumpBackupWorker(settings, bot) + + result = asyncio.run(worker.create_and_send_backup()) + + assert result.db_dump_included is False + bot.send_document.assert_awaited_once() + send_kwargs = bot.send_document.await_args.kwargs + assert send_kwargs["chat_id"] == -100123 + assert send_kwargs["message_thread_id"] == 77 + + +def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path): + missing_compose_dir = tmp_path / "missing-compose" + settings = _settings( + tmp_path, + missing_compose_dir, + BACKUP_POSTGRES_DUMP_ENABLED=True, + BACKUP_COMPOSE_ENABLED=True, + ) + bot = _FakeBot() + worker = _FakePgDumpBackupWorker(settings, bot) + + result = asyncio.run(worker.create_and_send_backup()) + + assert result.archive_path.is_file() + assert result.db_dump_included is True + assert result.compose_files_count == 0 + assert any("Compose source directory is unavailable" in item for item in result.warnings) + with zipfile.ZipFile(result.archive_path) as archive: + names = set(archive.namelist()) + assert "database/shop.dump" in names + + +def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch): + from bot.services import settings_override_service + + settings = SimpleNamespace(BACKUP_ENABLED=True) + monkeypatch.setattr( + settings_override_service.app_settings_dal, + "get_all_overrides", + AsyncMock(return_value={}), + ) + monkeypatch.setattr( + settings_override_service, + "Settings", + lambda: SimpleNamespace(BACKUP_ENABLED=False), + ) + + applied = asyncio.run( + refresh_overrides_from_db( + settings, + _FakeSessionFactory(), + keys={"BACKUP_ENABLED"}, + ) + ) + + assert applied == 0 + assert settings.BACKUP_ENABLED is False diff --git a/tests/test_main_bot_startup.py b/tests/test_main_bot_startup.py index 55eeefd..144a82f 100644 --- a/tests/test_main_bot_startup.py +++ b/tests/test_main_bot_startup.py @@ -15,8 +15,7 @@ def test_backend_startup_does_not_run_panel_sync_inline(): forbidden_imports = [ node for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module == "bot.handlers.admin.sync_admin" + if isinstance(node, ast.ImportFrom) and node.module == "bot.handlers.admin.sync_admin" ] forbidden_calls = [ node @@ -30,6 +29,22 @@ def test_backend_startup_does_not_run_panel_sync_inline(): assert forbidden_calls == [] +def test_worker_starts_backup_task_without_enabled_guard(): + source = Path("backend/main_worker.py").read_text(encoding="utf-8") + tree = ast.parse(source) + + guarded_backup_tasks = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.If) + and isinstance(node.test, ast.Attribute) + and node.test.attr == "BACKUP_ENABLED" + ] + + assert "BackupWorker" in source + assert guarded_backup_tasks == [] + + def test_telegram_startup_network_error_retries_until_success_without_traceback(caplog): calls = [] diff --git a/tests/test_settings.py b/tests/test_settings.py index 40bedd6..f35fa8c 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -190,6 +190,28 @@ class SettingsTests(unittest.TestCase): self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED) + def test_backup_defaults_are_safe_and_blank_targets_use_log_fallback(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + BACKUP_CHAT_ID="", + BACKUP_THREAD_ID="", + ) + + self.assertFalse(settings.BACKUP_ENABLED) + self.assertEqual(settings.BACKUP_INTERVAL_SECONDS, 3600) + self.assertEqual(settings.BACKUP_DIR, "data/backups") + self.assertEqual(settings.BACKUP_LOCAL_RETENTION, 100) + self.assertIsNone(settings.BACKUP_CHAT_ID) + self.assertIsNone(settings.BACKUP_THREAD_ID) + self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source") + self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR) + self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore") + self.assertTrue(settings.BACKUP_ARCHIVE_SIGNATURE_REQUIRED) + self.assertIsNone(settings.BACKUP_ARCHIVE_SIGNATURE_SECRET) + def test_subscription_purchase_description_is_localized_and_toggleable(self): settings = Settings( _env_file=None, diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index a38d138..48ca360 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -186,6 +186,9 @@ class WebAppRouteContractTests(unittest.TestCase): ("PUT", "/api/admin/themes"): "admin_themes_save_route", ("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route", ("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route", + ("GET", "/api/admin/backups"): "admin_backups_list_route", + ("POST", "/api/admin/backups/upload"): "admin_backups_upload_route", + ("POST", "/api/admin/backups/restore"): "admin_backups_restore_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } @@ -219,6 +222,15 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertEqual(match_info.handler.__name__, "index_route") + def test_admin_backups_page_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/backups", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "index_route") + def test_webapp_favicon_asset_route_is_registered(self): app = web.Application() subscription_webapp.setup_subscription_webapp_routes(app)