mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 12:53:41 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
302 lines
9.2 KiB
Python
302 lines
9.2 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, File, HTTPException, Query, Security, UploadFile, status
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.services.backup_service import backup_service
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _parse_datetime(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
|
|
try:
|
|
if value.endswith('Z'):
|
|
value = value.replace('Z', '+00:00')
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _to_int(value: Any) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _serialize_backup(raw: dict) -> BackupInfo:
|
|
timestamp = _parse_datetime(raw.get('timestamp'))
|
|
tables_count = _to_int(raw.get('tables_count'))
|
|
total_records = _to_int(raw.get('total_records'))
|
|
file_size_bytes = _to_int(raw.get('file_size_bytes')) or 0
|
|
file_size_mb = raw.get('file_size_mb')
|
|
try:
|
|
file_size_mb = float(file_size_mb)
|
|
except (TypeError, ValueError):
|
|
file_size_mb = round(file_size_bytes / 1024 / 1024, 2)
|
|
|
|
created_by = _to_int(raw.get('created_by'))
|
|
|
|
return BackupInfo(
|
|
filename=str(raw.get('filename')),
|
|
filepath=str(raw.get('filepath')),
|
|
timestamp=timestamp,
|
|
tables_count=tables_count,
|
|
total_records=total_records,
|
|
compressed=bool(raw.get('compressed', False)),
|
|
file_size_bytes=file_size_bytes,
|
|
file_size_mb=float(file_size_mb),
|
|
created_by=created_by,
|
|
database_type=raw.get('database_type'),
|
|
version=raw.get('version'),
|
|
error=raw.get('error'),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
'',
|
|
response_model=BackupCreateResponse,
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
summary='Запустить создание резервной копии',
|
|
)
|
|
async def create_backup_endpoint(
|
|
token: Any = Security(require_api_token),
|
|
) -> BackupCreateResponse:
|
|
created_by = getattr(token, 'id', None)
|
|
state = await backup_task_manager.enqueue(created_by=created_by)
|
|
return BackupCreateResponse(task_id=state.task_id, status=state.status)
|
|
|
|
|
|
@router.get(
|
|
'',
|
|
response_model=BackupListResponse,
|
|
summary='Список резервных копий',
|
|
)
|
|
async def list_backups(
|
|
_: Any = Security(require_api_token),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
) -> BackupListResponse:
|
|
backups = await backup_service.get_backup_list()
|
|
total = len(backups)
|
|
|
|
slice_backups = backups[offset : offset + limit]
|
|
items = [_serialize_backup(raw) for raw in slice_backups]
|
|
|
|
return BackupListResponse(
|
|
items=items,
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
'/status/{task_id}',
|
|
response_model=BackupStatusResponse,
|
|
summary='Статус создания резервной копии',
|
|
)
|
|
async def get_backup_status(
|
|
task_id: str,
|
|
_: Any = Security(require_api_token),
|
|
) -> BackupStatusResponse:
|
|
state = await backup_task_manager.get(task_id)
|
|
if not state:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Task not found')
|
|
|
|
return BackupStatusResponse(
|
|
task_id=state.task_id,
|
|
status=state.status,
|
|
message=state.message,
|
|
file_path=state.file_path,
|
|
created_by=state.created_by,
|
|
created_at=state.created_at,
|
|
updated_at=state.updated_at,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
'/tasks',
|
|
response_model=BackupTaskListResponse,
|
|
summary='Список фоновых задач бекапов',
|
|
)
|
|
async def list_backup_tasks(
|
|
_: Any = Security(require_api_token),
|
|
active_only: bool = Query(False, description='Вернуть только активные задачи'),
|
|
) -> BackupTaskListResponse:
|
|
states = await backup_task_manager.list(active_only=active_only)
|
|
|
|
items = [
|
|
BackupTaskInfo(
|
|
task_id=state.task_id,
|
|
status=state.status,
|
|
message=state.message,
|
|
file_path=state.file_path,
|
|
created_by=state.created_by,
|
|
created_at=state.created_at,
|
|
updated_at=state.updated_at,
|
|
)
|
|
for state in states
|
|
]
|
|
|
|
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,
|
|
)
|