diff --git a/.env.example b/.env.example index f07df1f1..742841e8 100644 --- a/.env.example +++ b/.env.example @@ -485,6 +485,8 @@ BACKUP_SEND_ENABLED=true BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! # ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика +# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES +BACKUP_ARCHIVE_PASSWORD= # ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА ===== VERSION_CHECK_ENABLED=true diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index de86d419..8c7b2124 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.2-$(git rev-parse --short HEAD)" + VERSION="v2.9.3-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 7ae15e95..b45fe175 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.2-$(git rev-parse --short HEAD)" + VERSION="v2.9.3-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index 47ec74e5..4ad136a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.9.2" +ARG VERSION="v2.9.3" ARG BUILD_DATE ARG VCS_REF diff --git a/app/config.py b/app/config.py index 3f15335b..32c1f597 100644 --- a/app/config.py +++ b/app/config.py @@ -439,6 +439,7 @@ class Settings(BaseSettings): BACKUP_SEND_ENABLED: bool = False BACKUP_SEND_CHAT_ID: Optional[str] = None BACKUP_SEND_TOPIC_ID: Optional[int] = None + BACKUP_ARCHIVE_PASSWORD: Optional[str] = None EXTERNAL_ADMIN_TOKEN: Optional[str] = None EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None @@ -1449,6 +1450,10 @@ class Settings(BaseSettings): return (self.BACKUP_SEND_ENABLED and self.get_backup_send_chat_id() is not None) + def get_backup_archive_password(self) -> Optional[str]: + password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip() + return password if password else None + def get_referral_settings(self) -> Dict: return { "program_enabled": self.is_referral_program_enabled(), diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 02db9f4c..14ff3b2e 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import aiofiles +import pyzipper from aiogram.types import FSInputFile from sqlalchemy import inspect, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -1534,13 +1535,24 @@ class BackupService: if not chat_id: return + password = settings.get_backup_archive_password() + file_to_send = file_path + temp_zip_path = None + + if password: + temp_zip_path = await self._create_password_protected_archive(file_path, password) + if temp_zip_path: + file_to_send = temp_zip_path + + caption = f"📦 Резервная копия\n\n" + if temp_zip_path: + caption += f"🔐 Архив защищён паролем\n\n" + caption += f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" + send_kwargs = { 'chat_id': chat_id, - 'document': FSInputFile(file_path), - 'caption': ( - f"📦 Резервная копия\n\n" - f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" - ), + 'document': FSInputFile(file_to_send), + 'caption': caption, 'parse_mode': 'HTML' } @@ -1549,8 +1561,43 @@ class BackupService: await self.bot.send_document(**send_kwargs) logger.info(f"Бекап отправлен в чат {chat_id}") + + if temp_zip_path and Path(temp_zip_path).exists(): + try: + Path(temp_zip_path).unlink() + except Exception as cleanup_error: + logger.warning(f"Не удалось удалить временный архив: {cleanup_error}") + except Exception as e: logger.error(f"Ошибка отправки бекапа в чат: {e}") + async def _create_password_protected_archive(self, file_path: str, password: str) -> Optional[str]: + try: + source_path = Path(file_path) + if not source_path.exists(): + logger.error(f"Исходный файл бекапа не найден: {file_path}") + return None + + zip_filename = source_path.stem + ".zip" + zip_path = source_path.parent / zip_filename + + def create_zip(): + with pyzipper.AESZipFile( + zip_path, + 'w', + compression=pyzipper.ZIP_DEFLATED, + encryption=pyzipper.WZ_AES + ) as zf: + zf.setpassword(password.encode('utf-8')) + zf.write(source_path, arcname=source_path.name) + + await asyncio.to_thread(create_zip) + logger.info(f"Создан защищённый паролем архив: {zip_path}") + return str(zip_path) + + except Exception as e: + logger.error(f"Ошибка создания защищённого архива: {e}") + return None + backup_service = BackupService() diff --git a/app/webapi/routes/backups.py b/app/webapi/routes/backups.py index b9f3aa00..2d681f7c 100644 --- a/app/webapi/routes/backups.py +++ b/app/webapi/routes/backups.py @@ -1,9 +1,12 @@ from __future__ import annotations +import os from datetime import datetime +from pathlib import Path from typing import Any, Optional -from fastapi import APIRouter, HTTPException, Query, Security, status +from fastapi import APIRouter, HTTPException, Query, Security, UploadFile, File, status +from fastapi.responses import FileResponse from app.services.backup_service import backup_service @@ -11,8 +14,11 @@ from ..background.backup_tasks import backup_task_manager from ..dependencies import require_api_token from ..schemas.backups import ( BackupCreateResponse, + BackupDeleteResponse, BackupInfo, BackupListResponse, + BackupRestoreRequest, + BackupRestoreResponse, BackupStatusResponse, BackupTaskInfo, BackupTaskListResponse, @@ -157,3 +163,149 @@ async def list_backup_tasks( ] return BackupTaskListResponse(items=items, total=len(items)) + + +@router.get( + "/download/{filename:path}", + summary="Скачать файл резервной копии", + responses={ + 200: { + "content": {"application/octet-stream": {}}, + "description": "Файл резервной копии", + } + }, +) +async def download_backup( + filename: str, + _: Any = Security(require_api_token), +) -> FileResponse: + backup_path = backup_service.backup_dir / filename + + if not backup_path.exists(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found") + + if not backup_path.is_file(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid backup path") + + resolved_path = backup_path.resolve() + backup_dir_resolved = backup_service.backup_dir.resolve() + if not str(resolved_path).startswith(str(backup_dir_resolved)): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + + return FileResponse( + path=str(backup_path), + filename=filename, + media_type="application/octet-stream", + ) + + +@router.post( + "/restore/{filename:path}", + response_model=BackupRestoreResponse, + summary="Восстановить из резервной копии", +) +async def restore_backup( + filename: str, + payload: BackupRestoreRequest, + _: Any = Security(require_api_token), +) -> BackupRestoreResponse: + backup_path = backup_service.backup_dir / filename + + if not backup_path.exists(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found") + + resolved_path = backup_path.resolve() + backup_dir_resolved = backup_service.backup_dir.resolve() + if not str(resolved_path).startswith(str(backup_dir_resolved)): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + + success, message = await backup_service.restore_backup( + str(backup_path), + clear_existing=payload.clear_existing + ) + + return BackupRestoreResponse( + success=success, + message=message, + ) + + +@router.post( + "/upload", + response_model=BackupRestoreResponse, + summary="Загрузить и восстановить из файла резервной копии", +) +async def upload_and_restore_backup( + file: UploadFile = File(..., description="Файл резервной копии (.tar.gz, .json, .json.gz)"), + clear_existing: bool = Query(False, description="Очистить существующие данные"), + _: Any = Security(require_api_token), +) -> BackupRestoreResponse: + if not file.filename: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Filename is required") + + safe_filename = Path(file.filename).name + if not safe_filename or safe_filename in ('.', '..'): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid filename") + + allowed_extensions = ('.tar.gz', '.json', '.json.gz', '.tar') + if not any(safe_filename.endswith(ext) for ext in allowed_extensions): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Invalid file type. Allowed: {', '.join(allowed_extensions)}" + ) + + temp_path = backup_service.backup_dir / f"uploaded_{safe_filename}" + + resolved_path = temp_path.resolve() + backup_dir_resolved = backup_service.backup_dir.resolve() + if not str(resolved_path).startswith(str(backup_dir_resolved)): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Invalid file path") + + try: + content = await file.read() + with open(temp_path, 'wb') as f: + f.write(content) + + success, message = await backup_service.restore_backup( + str(temp_path), + clear_existing=clear_existing + ) + + return BackupRestoreResponse( + success=success, + message=message, + ) + + finally: + if temp_path.exists(): + try: + temp_path.unlink() + except Exception: + pass + + +@router.delete( + "/{filename:path}", + response_model=BackupDeleteResponse, + summary="Удалить резервную копию", +) +async def delete_backup( + filename: str, + _: Any = Security(require_api_token), +) -> BackupDeleteResponse: + backup_path = backup_service.backup_dir / filename + + resolved_path = backup_path.resolve() + backup_dir_resolved = backup_service.backup_dir.resolve() + if not str(resolved_path).startswith(str(backup_dir_resolved)): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + + success, message = await backup_service.delete_backup(filename) + + if not success: + raise HTTPException(status.HTTP_404_NOT_FOUND, message) + + return BackupDeleteResponse( + success=success, + message=message, + ) diff --git a/app/webapi/schemas/backups.py b/app/webapi/schemas/backups.py index 8c46809f..5a535c85 100644 --- a/app/webapi/schemas/backups.py +++ b/app/webapi/schemas/backups.py @@ -53,3 +53,22 @@ class BackupTaskInfo(BackupStatusResponse): class BackupTaskListResponse(BaseModel): items: list[BackupTaskInfo] total: int + + +class BackupRestoreRequest(BaseModel): + clear_existing: bool = Field( + default=False, + description="Очистить существующие данные перед восстановлением" + ) + + +class BackupRestoreResponse(BaseModel): + success: bool + message: str + tables_restored: Optional[int] = None + records_restored: Optional[int] = None + + +class BackupDeleteResponse(BaseModel): + success: bool + message: str diff --git a/requirements.txt b/requirements.txt index af50155d..055ee40e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,3 +41,6 @@ aiofiles==23.2.1 # Вебхуки PayPalych (Flask) Flask==3.1.0 + +# Архивирование с паролем +pyzipper==0.3.6