mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-21 03:40:55 +00:00
@@ -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
|
||||
|
||||
6
.github/workflows/docker-hub.yml
vendored
6
.github/workflows/docker-hub.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/docker-registry.yml
vendored
6
.github/workflows/docker-registry.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,3 +41,6 @@ aiofiles==23.2.1
|
||||
|
||||
# Вебхуки PayPalych (Flask)
|
||||
Flask==3.1.0
|
||||
|
||||
# Архивирование с паролем
|
||||
pyzipper==0.3.6
|
||||
|
||||
Reference in New Issue
Block a user