Merge pull request #2216 from BEDOLAGA-DEV/main

w
This commit is contained in:
Egor
2025-12-27 10:55:00 +03:00
committed by GitHub
9 changed files with 241 additions and 13 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(),

View File

@@ -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"📦 <b>Резервная копия</b>\n\n"
if temp_zip_path:
caption += f"🔐 <b>Архив защищён паролем</b>\n\n"
caption += f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
send_kwargs = {
'chat_id': chat_id,
'document': FSInputFile(file_path),
'caption': (
f"📦 <b>Резервная копия</b>\n\n"
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
),
'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()

View File

@@ -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,
)

View File

@@ -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

View File

@@ -41,3 +41,6 @@ aiofiles==23.2.1
# Вебхуки PayPalych (Flask)
Flask==3.1.0
# Архивирование с паролем
pyzipper==0.3.6