diff --git a/api/app/controllers/backup_router.py b/api/app/controllers/backup_router.py index 659f441..d9262f6 100644 --- a/api/app/controllers/backup_router.py +++ b/api/app/controllers/backup_router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, File, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import FileResponse @@ -60,6 +60,21 @@ async def create_backup( return await backup_service.create_backup(user.id) +@router.post( + '/upload/', + response_model=BackupResponseEntity, + summary='Upload a backup', + description='Upload a backup', +) +async def upload_backup( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user=Depends(require_admin), +): + backup_service = BackupService(db) + return await backup_service.upload_backup(file, user) + + @router.delete( '/{backup_id}/', response_model=BackupResponseEntity, diff --git a/api/app/database/migrations/versions/4f3877d7a2b1_0007_добавил_в_backups_поле_is_by_user.py b/api/app/database/migrations/versions/4f3877d7a2b1_0007_добавил_в_backups_поле_is_by_user.py new file mode 100644 index 0000000..f8d7de7 --- /dev/null +++ b/api/app/database/migrations/versions/4f3877d7a2b1_0007_добавил_в_backups_поле_is_by_user.py @@ -0,0 +1,30 @@ +"""0007 добавил в backups поле is_by_user + +Revision ID: 4f3877d7a2b1 +Revises: b58238896c0f +Create Date: 2025-07-01 18:54:27.796866 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4f3877d7a2b1' +down_revision: Union[str, None] = 'b58238896c0f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('backups', sa.Column('is_by_user', sa.Boolean(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('backups', 'is_by_user') + # ### end Alembic commands ### diff --git a/api/app/domain/entities/responses/backup.py b/api/app/domain/entities/responses/backup.py index 125bc24..6937d0a 100644 --- a/api/app/domain/entities/responses/backup.py +++ b/api/app/domain/entities/responses/backup.py @@ -8,5 +8,6 @@ class BackupResponseEntity(BaseModel): timestamp: datetime.datetime path: str filename: str + is_by_user: bool user_id: int diff --git a/api/app/domain/models/backups.py b/api/app/domain/models/backups.py index 81f923f..163fc5e 100644 --- a/api/app/domain/models/backups.py +++ b/api/app/domain/models/backups.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import Column, DateTime, String, Integer, ForeignKey +from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, Boolean from app.domain.models.base import BaseModel @@ -11,7 +11,6 @@ class Backup(BaseModel): timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now) path = Column(String, nullable=False) filename = Column(String, nullable=False) + is_by_user = Column(Boolean, nullable=False, default=False) user_id = Column(Integer, ForeignKey('users.id'), nullable=False) - - diff --git a/api/app/infrastructure/backup_service.py b/api/app/infrastructure/backup_service.py index d09eb2f..b814cbc 100644 --- a/api/app/infrastructure/backup_service.py +++ b/api/app/infrastructure/backup_service.py @@ -1,16 +1,20 @@ +import io import subprocess import os import tarfile import datetime from typing import Any, Coroutine, Optional -from fastapi import HTTPException +import aiofiles +from fastapi import HTTPException, UploadFile +from magic import magic from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import FileResponse +from werkzeug.utils import secure_filename from app.application.backups_repository import BackupsRepository from app.domain.entities.responses.backup import BackupResponseEntity -from app.domain.models import Backup +from app.domain.models import Backup, User class BackupService: @@ -80,6 +84,28 @@ class BackupService: filename=backup.filename, ) + async def upload_backup(self, file: UploadFile, user: User) -> BackupResponseEntity: + file_bytes = await file.read() + file.file.seek(0) + self.validate_file_type(file) + + if not self.validate_backup_archive(file_bytes, + expected_app_files_dir_name=os.path.basename(self.app_files_dir)): + raise HTTPException(400, "Неверная структура архива резервной копии") + + filename = self.generate_filename(file) + backup_path = os.path.join(self.backup_dir, filename) + async with aiofiles.open(backup_path, 'wb') as out_file: + await out_file.write(file_bytes) + + backup_record = Backup( + filename=filename, + path=backup_path, + user_id=user.id, + ) + await self.backup_repository.create(backup_record) + return self.model_to_entity(backup_record) + async def delete_backup(self, backup_id: int) -> Optional[BackupResponseEntity]: backup = await self.backup_repository.get_by_id(backup_id) @@ -96,6 +122,24 @@ class BackupService: await self.backup_repository.delete(backup) ) + @staticmethod + def generate_filename(file: UploadFile) -> str: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + return secure_filename(f"uploaded_{timestamp}_{file.filename}") + + @staticmethod + def validate_backup_archive(file_bytes: bytes, expected_app_files_dir_name: str) -> bool: + try: + with tarfile.open(fileobj=io.BytesIO(file_bytes), mode="r:gz") as tar: + members = tar.getnames() + if "db_dump.sql" not in members: + return False + if not any(name.startswith(expected_app_files_dir_name) for name in members): + return False + return True + except Exception: + return False + @staticmethod def model_to_entity(backup: Backup): return BackupResponseEntity( @@ -103,5 +147,27 @@ class BackupService: timestamp=backup.timestamp, path=backup.path, filename=backup.filename, + is_by_user=backup.is_by_user, user_id=backup.user_id, ) + + @staticmethod + def validate_file_type(file: UploadFile): + mime = magic.Magic(mime=True) + file_type = mime.from_buffer(file.file.read(1024)) + file.file.seek(0) + content = file.file.read() + print(content.decode('utf-8')) + if file_type not in ["application/zip", "application/gzip"]: + raise HTTPException(400, "Неправильный формат файла") + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + if extension in ['zip']: + return "application/zip" + + if extension == 'tar.gz': + return "application/gzip" + + return "application/octet-stream" diff --git a/web-app/src/Api/backupsApi.js b/web-app/src/Api/backupsApi.js index 8692d66..20a74d3 100644 --- a/web-app/src/Api/backupsApi.js +++ b/web-app/src/Api/backupsApi.js @@ -25,6 +25,22 @@ export const backupsApi = createApi({ }), invalidatesTags: ['Backup'], }), + uploadBackup: builder.mutation({ + query: (file) => { + const formData = new FormData(); + formData.append('file', file); + + return { + url: '/backups/upload/', + method: 'POST', + credentials: 'include', + formData: true, + body: formData, + }; + }, + invalidatesTags: ['Backup'], + }), + }), }); @@ -32,4 +48,5 @@ export const { useGetBackupsQuery, useCreateBackupMutation, useDeleteBackupMutation, + useUploadBackupMutation, } = backupsApi; diff --git a/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/BackupManageTab.jsx b/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/BackupManageTab.jsx index b3bf92f..12bc0d8 100644 --- a/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/BackupManageTab.jsx +++ b/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/BackupManageTab.jsx @@ -1,5 +1,5 @@ -import {Button, Divider, List, Result, Space, Typography, Upload} from "antd"; -import {CloudDownloadOutlined, UploadOutlined} from "@ant-design/icons"; +import {Button, Divider, List, Result, Space, Tooltip, Typography, Upload} from "antd"; +import {CloudDownloadOutlined, DeleteOutlined, UploadOutlined} from "@ant-design/icons"; import useBackupManageTab from "./useBackupManageTab.js"; import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; @@ -24,13 +24,16 @@ const BackupManageTab = () => { loading={backupManageTabData.isCreatingBackup} > - Создать и скачать резервную копию + Создать резервную копию { + backupManageTabData.uploadBackupHandler(file); + return false; + }} > - + + + + + + )} /> diff --git a/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/useBackupManageTab.js b/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/useBackupManageTab.js index 2ac08fd..a580147 100644 --- a/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/useBackupManageTab.js +++ b/web-app/src/Components/Pages/AdminPage/Components/BackupManageTab/useBackupManageTab.js @@ -1,7 +1,7 @@ import { useCreateBackupMutation, useDeleteBackupMutation, - useGetBackupsQuery + useGetBackupsQuery, useUploadBackupMutation } from "../../../../../Api/backupsApi.js"; import {useDispatch} from "react-redux"; import {notification} from "antd"; @@ -16,6 +16,7 @@ const useBackupManageTab = () => { }); const [createBackup, {isLoading: isCreatingBackup}] = useCreateBackupMutation(); const [deleteBackup, {isLoading: isDeletingBackup}] = useDeleteBackupMutation(); + const [uploadBackup, {isLoading: isUploadingBackup}] = useUploadBackupMutation(); const [isDownloadingBackup, setDownloadingFiles] = useState(false); @@ -100,6 +101,31 @@ const useBackupManageTab = () => { } }; + const uploadBackupHandler = async (file) => { + try { + if (!file || !(file instanceof File)) { + notification.error({ + message: "Ошибка", + description: "Файл не выбран", + placement: "topRight", + }); + return; + } + await uploadBackup(file).unwrap(); + notification.success({ + message: "Успех", + description: "Резервная копия успешно загружена", + placement: "topRight", + }); + } catch (e) { + notification.error({ + message: "Ошибка", + description: e.message || e?.data?.detail || "Не удалось загрузить резервную копию", + placement: "topRight", + }); + } + }; + return { backups, isLoadingBackups, @@ -107,9 +133,11 @@ const useBackupManageTab = () => { isCreatingBackup, isDownloadingBackup, isDeletingBackup, + isUploadingBackup, createBackupHandler, downloadBackupHandler, deleteBackupHandler, + uploadBackupHandler, } };