diff --git a/backend/migrations/env.py b/backend/migrations/env.py index e9e9f01b..60da6d61 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -4,14 +4,14 @@ from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context -from src.service import DB_URL +from src.core.config import settings from src.models import Base import src.models # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config -config.set_main_option('sqlalchemy.url', DB_URL) +config.set_main_option('sqlalchemy.url', settings.database_url) # Interpret the config file for Python logging. # This line sets up loggers basically. diff --git a/backend/src/api/deps.py b/backend/src/api/deps.py new file mode 100644 index 00000000..10c8b085 --- /dev/null +++ b/backend/src/api/deps.py @@ -0,0 +1,30 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.config import settings +from src.core.database import get_session +from src.repositories import AlertRepository, FileRepository +from src.services import AlertService, FileService, StorageService + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +def get_storage_service() -> StorageService: + return StorageService(settings.storage_dir) + + +def get_file_service( + session: SessionDep, + storage: Annotated[StorageService, Depends(get_storage_service)], +) -> FileService: + return FileService(session, FileRepository(session), storage) + + +def get_alert_service(session: SessionDep) -> AlertService: + return AlertService(session, AlertRepository(session)) + + +FileServiceDep = Annotated[FileService, Depends(get_file_service)] +AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] diff --git a/backend/src/api/exception_handlers.py b/backend/src/api/exception_handlers.py new file mode 100644 index 00000000..f19e54da --- /dev/null +++ b/backend/src/api/exception_handlers.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + +from src.exceptions import EmptyFileUpload, FileNotFound, StoredFileMissing + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(FileNotFound) + async def handle_file_not_found(request: Request, exc: FileNotFound) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": "File not found"}, + ) + + @app.exception_handler(StoredFileMissing) + async def handle_stored_file_missing(request: Request, exc: StoredFileMissing) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": "Stored file not found"}, + ) + + @app.exception_handler(EmptyFileUpload) + async def handle_empty_file_upload(request: Request, exc: EmptyFileUpload) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": "File is empty"}, + ) diff --git a/backend/src/api/routers/alerts.py b/backend/src/api/routers/alerts.py new file mode 100644 index 00000000..7f30a061 --- /dev/null +++ b/backend/src/api/routers/alerts.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from src.api.deps import AlertServiceDep +from src.schemas import AlertItem + +router = APIRouter(prefix="/alerts", tags=["alerts"]) + + +@router.get("", response_model=list[AlertItem]) +async def list_alerts(service: AlertServiceDep): + return await service.list() diff --git a/backend/src/api/routers/files.py b/backend/src/api/routers/files.py new file mode 100644 index 00000000..bf29b9b7 --- /dev/null +++ b/backend/src/api/routers/files.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, File, Form, UploadFile, status +from fastapi.responses import FileResponse + +from src.api.deps import FileServiceDep +from src.schemas import FileItem, FileUpdate +from src.tasks import scan_file_for_threats + +router = APIRouter(prefix="/files", tags=["files"]) + + +@router.get("", response_model=list[FileItem]) +async def list_files(service: FileServiceDep): + return await service.list() + + +@router.post("", response_model=FileItem, status_code=status.HTTP_201_CREATED) +async def create_file( + service: FileServiceDep, + title: str = Form(...), + file: UploadFile = File(...), +): + stored = await service.create(title=title, upload_file=file) + scan_file_for_threats.delay(stored.id) + return stored + + +@router.get("/{file_id}", response_model=FileItem) +async def get_file(file_id: str, service: FileServiceDep): + return await service.get(file_id) + + +@router.patch("/{file_id}", response_model=FileItem) +async def update_file(file_id: str, payload: FileUpdate, service: FileServiceDep): + return await service.update(file_id=file_id, title=payload.title) + + +@router.get("/{file_id}/download") +async def download_file(file_id: str, service: FileServiceDep): + stored, path = await service.resolve_download(file_id) + return FileResponse( + path=path, + media_type=stored.mime_type, + filename=stored.original_name, + ) + + +@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_file(file_id: str, service: FileServiceDep): + await service.delete(file_id) diff --git a/backend/src/app.py b/backend/src/app.py index bec89a5f..3140237e 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,71 +1,26 @@ -from fastapi import FastAPI, HTTPException -from fastapi import File, Form, UploadFile +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from starlette import status -from src.schemas import AlertItem, FileItem, FileUpdate -from src.service import create_file, delete_file, get_file, list_alerts, list_files, update_file, STORAGE_DIR -from src.tasks import scan_file_for_threats -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://127.0.0.1:3000", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/files", response_model=list[FileItem]) -async def list_files_view(): - return await list_files() - - -@app.get("/alerts", response_model=list[AlertItem]) -async def list_alerts_view(): - return await list_alerts() - - -@app.post("/files", response_model=FileItem, status_code=201) -async def create_file_view( - title: str = Form(...), - file: UploadFile = File(...), -): - file_item = await create_file(title=title, upload_file=file) - scan_file_for_threats.delay(file_item.id) - return file_item - - -@app.get("/files/{file_id}", response_model=FileItem) -async def get_file_view(file_id: str): - return await get_file(file_id) - - -@app.patch("/files/{file_id}", response_model=FileItem) -async def update_file_view( - file_id: str, - payload: FileUpdate, -): - return await update_file(file_id=file_id, title=payload.title) - - -@app.get("/files/{file_id}/download") -async def download_file(file_id: str): - file_item = await get_file(file_id) - stored_path = STORAGE_DIR / file_item.stored_name - if not stored_path.exists(): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found") - return FileResponse( - path=stored_path, - media_type=file_item.mime_type, - filename=file_item.original_name, +from src.api.exception_handlers import register_exception_handlers +from src.api.routers import alerts, files + + +def create_app() -> FastAPI: + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://127.0.0.1:3000", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) + register_exception_handlers(app) + app.include_router(files.router) + app.include_router(alerts.router) + return app -@app.delete("/files/{file_id}", status_code=204) -async def delete_file_view(file_id: str): - await delete_file(file_id) +app = create_app() diff --git a/backend/src/core/config.py b/backend/src/core/config.py new file mode 100644 index 00000000..07398849 --- /dev/null +++ b/backend/src/core/config.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path + +from pydantic import BaseModel + + +class Settings(BaseModel): + postgres_user: str + postgres_password: str + postgres_host: str + postgres_db: str + pgport: int + redis_url: str + + @property + def database_url(self) -> str: + return ( + f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}" + f"@{self.postgres_host}:{self.pgport}/{self.postgres_db}" + ) + + @property + def storage_dir(self) -> Path: + return Path(__file__).resolve().parents[2] / "storage" / "files" + + +def load_settings() -> Settings: + return Settings( + postgres_user=os.environ["POSTGRES_USER"], + postgres_password=os.environ["POSTGRES_PASSWORD"], + postgres_host=os.environ["POSTGRES_HOST"], + postgres_db=os.environ["POSTGRES_DB"], + pgport=int(os.environ["PGPORT"]), + redis_url=os.environ.get("REDIS_URL", "redis://backend-redis:6379/0"), + ) + + +settings = load_settings() diff --git a/backend/src/core/database.py b/backend/src/core/database.py new file mode 100644 index 00000000..479b1557 --- /dev/null +++ b/backend/src/core/database.py @@ -0,0 +1,14 @@ +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.core.config import settings + + +engine = create_async_engine(settings.database_url) +async_session_maker = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncIterator[AsyncSession]: + async with async_session_maker() as session: + yield session diff --git a/backend/src/exceptions.py b/backend/src/exceptions.py new file mode 100644 index 00000000..f161b457 --- /dev/null +++ b/backend/src/exceptions.py @@ -0,0 +1,10 @@ +class FileNotFound(Exception): + pass + + +class EmptyFileUpload(Exception): + pass + + +class StoredFileMissing(Exception): + pass diff --git a/backend/src/repositories.py b/backend/src/repositories.py new file mode 100644 index 00000000..120a87ff --- /dev/null +++ b/backend/src/repositories.py @@ -0,0 +1,38 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models import Alert, StoredFile + + +class FileRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list(self) -> list[StoredFile]: + result = await self._session.execute( + select(StoredFile).order_by(StoredFile.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get(self, file_id: str) -> StoredFile | None: + return await self._session.get(StoredFile, file_id) + + def add(self, file: StoredFile) -> None: + self._session.add(file) + + async def delete(self, file: StoredFile) -> None: + await self._session.delete(file) + + +class AlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list(self) -> list[Alert]: + result = await self._session.execute( + select(Alert).order_by(Alert.created_at.desc()) + ) + return list(result.scalars().all()) + + def add(self, alert: Alert) -> None: + self._session.add(alert) diff --git a/backend/src/service.py b/backend/src/service.py deleted file mode 100644 index e707fdc7..00000000 --- a/backend/src/service.py +++ /dev/null @@ -1,109 +0,0 @@ -import mimetypes -import os -from pathlib import Path -from uuid import uuid4 - -from fastapi import HTTPException, UploadFile, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker - -from src.models import Alert, StoredFile - - -BASE_DIR = Path(__file__).resolve().parent.parent -STORAGE_DIR = BASE_DIR / "storage" / "files" -STORAGE_DIR.mkdir(parents=True, exist_ok=True) -DB_URL = ( - f"postgresql+asyncpg://{os.environ.get('POSTGRES_USER')}:" - f"{os.environ.get('POSTGRES_PASSWORD')}@{os.environ.get('POSTGRES_HOST')}:" - f"{os.environ.get('PGPORT')}/{os.environ.get('POSTGRES_DB')}" -) -engine = create_async_engine(DB_URL) -async_session_maker = async_sessionmaker(engine, expire_on_commit=False) - - -async def list_files() -> list[StoredFile]: - async with async_session_maker() as session: - result = await session.execute(select(StoredFile).order_by(StoredFile.created_at.desc())) - return list(result.scalars().all()) - - -async def list_alerts() -> list[Alert]: - async with async_session_maker() as session: - result = await session.execute(select(Alert).order_by(Alert.created_at.desc())) - return list(result.scalars().all()) - - -async def get_file(file_id: str) -> StoredFile: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - return file_item - - -async def create_file(title: str, upload_file: UploadFile) -> StoredFile: - content = await upload_file.read() - if not content: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty") - - file_id = str(uuid4()) - suffix = Path(upload_file.filename or "").suffix - stored_name = f"{file_id}{suffix}" - stored_path = STORAGE_DIR / stored_name - stored_path.write_bytes(content) - - file_item = StoredFile( - id=file_id, - title=title, - original_name=upload_file.filename or stored_name, - stored_name=stored_name, - mime_type=upload_file.content_type or mimetypes.guess_type(stored_name)[0] or "application/octet-stream", - size=len(content), - processing_status="uploaded", - ) - async with async_session_maker() as session: - session.add(file_item) - await session.commit() - await session.refresh(file_item) - return file_item - - -async def update_file(file_id: str, title: str) -> StoredFile: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - file_item.title = title - await session.commit() - await session.refresh(file_item) - return file_item - - -async def delete_file(file_id: str) -> None: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - stored_path = STORAGE_DIR / file_item.stored_name - if stored_path.exists(): - stored_path.unlink() - await session.delete(file_item) - await session.commit() - - -async def get_file_path(file_id: str) -> tuple[StoredFile, Path]: - file_item = await get_file(file_id) - stored_path = STORAGE_DIR / file_item.stored_name - if not stored_path.exists(): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found") - return file_item, stored_path - - -async def create_alert(file_id: str, level: str, message: str) -> Alert: - alert = Alert(file_id=file_id, level=level, message=message) - async with async_session_maker() as session: - session.add(alert) - await session.commit() - await session.refresh(alert) - return alert diff --git a/backend/src/services.py b/backend/src/services.py new file mode 100644 index 00000000..7bac2a31 --- /dev/null +++ b/backend/src/services.py @@ -0,0 +1,200 @@ +import mimetypes +import shutil +from pathlib import Path +from uuid import uuid4 + +from fastapi import UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from src.exceptions import EmptyFileUpload, FileNotFound, StoredFileMissing +from src.models import Alert, StoredFile +from src.repositories import AlertRepository, FileRepository + + +class StorageService: + def __init__(self, storage_dir: Path) -> None: + self._storage_dir = storage_dir + self._storage_dir.mkdir(parents=True, exist_ok=True) + + def save(self, stored_name: str, content: bytes) -> None: + (self._storage_dir / stored_name).write_bytes(content) + + def save_stream(self, stored_name: str, upload_file: UploadFile) -> int: + dest = self._storage_dir / stored_name + with dest.open("wb") as out: + shutil.copyfileobj(upload_file.file, out) + return dest.stat().st_size + + def resolve(self, stored_name: str) -> Path: + return self._storage_dir / stored_name + + def delete(self, stored_name: str) -> None: + path = self._storage_dir / stored_name + if path.exists(): + path.unlink() + + +class FileService: + def __init__( + self, + session: AsyncSession, + files: FileRepository, + storage: StorageService, + ) -> None: + self._session = session + self._files = files + self._storage = storage + + async def list(self) -> list[StoredFile]: + return await self._files.list() + + async def get(self, file_id: str) -> StoredFile: + file = await self._files.get(file_id) + if file is None: + raise FileNotFound(file_id) + return file + + async def create(self, title: str, upload_file: UploadFile) -> StoredFile: + file_id = str(uuid4()) + suffix = Path(upload_file.filename or "").suffix + stored_name = f"{file_id}{suffix}" + size = self._storage.save_stream(stored_name, upload_file) + if size == 0: + self._storage.delete(stored_name) + raise EmptyFileUpload() + + file = StoredFile( + id=file_id, + title=title, + original_name=upload_file.filename or stored_name, + stored_name=stored_name, + mime_type=( + upload_file.content_type + or mimetypes.guess_type(stored_name)[0] + or "application/octet-stream" + ), + size=size, + processing_status="uploaded", + ) + self._files.add(file) + await self._session.commit() + await self._session.refresh(file) + return file + + async def update(self, file_id: str, title: str) -> StoredFile: + file = await self.get(file_id) + file.title = title + await self._session.commit() + await self._session.refresh(file) + return file + + async def delete(self, file_id: str) -> None: + file = await self.get(file_id) + self._storage.delete(file.stored_name) + await self._files.delete(file) + await self._session.commit() + + async def resolve_download(self, file_id: str) -> tuple[StoredFile, Path]: + file = await self.get(file_id) + path = self._storage.resolve(file.stored_name) + if not path.exists(): + raise StoredFileMissing(file_id) + return file, path + + +class AlertService: + def __init__(self, session: AsyncSession, alerts: AlertRepository) -> None: + self._session = session + self._alerts = alerts + + async def list(self) -> list[Alert]: + return await self._alerts.list() + + async def create(self, file_id: str, level: str, message: str) -> Alert: + alert = Alert(file_id=file_id, level=level, message=message) + self._alerts.add(alert) + await self._session.commit() + await self._session.refresh(alert) + return alert + + +class ProcessingService: + def __init__( + self, + session: AsyncSession, + files: FileRepository, + storage: StorageService, + alerts: AlertService, + ) -> None: + self._session = session + self._files = files + self._storage = storage + self._alerts = alerts + + async def scan(self, file_id: str) -> bool: + file = await self._files.get(file_id) + if file is None: + return False + + file.processing_status = "processing" + reasons: list[str] = [] + extension = Path(file.original_name).suffix.lower() + + if extension in {".exe", ".bat", ".cmd", ".sh", ".js"}: + reasons.append(f"suspicious extension {extension}") + + if file.size > 10 * 1024 * 1024: + reasons.append("file is larger than 10 MB") + + if extension == ".pdf" and file.mime_type not in {"application/pdf", "application/octet-stream"}: + reasons.append("pdf extension does not match mime type") + + file.scan_status = "suspicious" if reasons else "clean" + file.scan_details = ", ".join(reasons) if reasons else "no threats found" + file.requires_attention = bool(reasons) + await self._session.commit() + return True + + async def extract_metadata(self, file_id: str) -> bool: + file = await self._files.get(file_id) + if file is None: + return False + + stored_path = self._storage.resolve(file.stored_name) + if not stored_path.exists(): + file.processing_status = "failed" + file.scan_status = file.scan_status or "failed" + file.scan_details = "stored file not found during metadata extraction" + await self._session.commit() + return True + + metadata = { + "extension": Path(file.original_name).suffix.lower(), + "size_bytes": file.size, + "mime_type": file.mime_type, + } + + if file.mime_type.startswith("text/"): + content = stored_path.read_text(encoding="utf-8", errors="ignore") + metadata["line_count"] = len(content.splitlines()) + metadata["char_count"] = len(content) + elif file.mime_type == "application/pdf": + content = stored_path.read_bytes() + metadata["approx_page_count"] = max(content.count(b"/Type /Page"), 1) + + file.metadata_json = metadata + file.processing_status = "processed" + await self._session.commit() + return True + + async def send_alert(self, file_id: str) -> None: + file = await self._files.get(file_id) + if file is None: + return + + if file.processing_status == "failed": + await self._alerts.create(file_id, "critical", "File processing failed") + elif file.requires_attention: + await self._alerts.create(file_id, "warning", f"File requires attention: {file.scan_details}") + else: + await self._alerts.create(file_id, "info", "File processed successfully") diff --git a/backend/src/tasks.py b/backend/src/tasks.py index 4583aded..2a68c2f5 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -1,12 +1,13 @@ import asyncio -import os -from pathlib import Path + from celery import Celery -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from src.models import Alert, StoredFile -from src.service import STORAGE_DIR, DB_URL +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.config import settings +from src.core.database import async_session_maker +from src.repositories import AlertRepository, FileRepository +from src.services import AlertService, ProcessingService, StorageService -REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0") _worker_loop: asyncio.AbstractEventLoop | None = None @@ -18,93 +19,35 @@ def run_in_worker_loop(coroutine): return _worker_loop.run_until_complete(coroutine) -celery_app = Celery("file_tasks", broker=REDIS_URL, backend=REDIS_URL) -engine = create_async_engine(DB_URL) -async_session_maker = async_sessionmaker(engine, expire_on_commit=False) - - -async def _scan_file_for_threats(file_id: str) -> None: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - return - - file_item.processing_status = "processing" - reasons: list[str] = [] - extension = Path(file_item.original_name).suffix.lower() - - if extension in {".exe", ".bat", ".cmd", ".sh", ".js"}: - reasons.append(f"suspicious extension {extension}") +celery_app = Celery("file_tasks", broker=settings.redis_url, backend=settings.redis_url) - if file_item.size > 10 * 1024 * 1024: - reasons.append("file is larger than 10 MB") - if extension == ".pdf" and file_item.mime_type not in {"application/pdf", "application/octet-stream"}: - reasons.append("pdf extension does not match mime type") +def _processing_service(session: AsyncSession) -> ProcessingService: + return ProcessingService( + session, + FileRepository(session), + StorageService(settings.storage_dir), + AlertService(session, AlertRepository(session)), + ) - file_item.scan_status = "suspicious" if reasons else "clean" - file_item.scan_details = ", ".join(reasons) if reasons else "no threats found" - file_item.requires_attention = bool(reasons) - await session.commit() - extract_file_metadata.delay(file_id) +async def _scan_file_for_threats(file_id: str) -> None: + async with async_session_maker() as session: + found = await _processing_service(session).scan(file_id) + if found: + extract_file_metadata.delay(file_id) async def _extract_file_metadata(file_id: str) -> None: async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - return - - stored_path = STORAGE_DIR / file_item.stored_name - if not stored_path.exists(): - file_item.processing_status = "failed" - file_item.scan_status = file_item.scan_status or "failed" - file_item.scan_details = "stored file not found during metadata extraction" - await session.commit() - send_file_alert.delay(file_id) - return - - metadata = { - "extension": Path(file_item.original_name).suffix.lower(), - "size_bytes": file_item.size, - "mime_type": file_item.mime_type, - } - - if file_item.mime_type.startswith("text/"): - content = stored_path.read_text(encoding="utf-8", errors="ignore") - metadata["line_count"] = len(content.splitlines()) - metadata["char_count"] = len(content) - elif file_item.mime_type == "application/pdf": - content = stored_path.read_bytes() - metadata["approx_page_count"] = max(content.count(b"/Type /Page"), 1) - - file_item.metadata_json = metadata - file_item.processing_status = "processed" - await session.commit() - - send_file_alert.delay(file_id) + found = await _processing_service(session).extract_metadata(file_id) + if found: + send_file_alert.delay(file_id) async def _send_file_alert(file_id: str) -> None: async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - return - - if file_item.processing_status == "failed": - alert = Alert(file_id=file_id, level="critical", message="File processing failed") - elif file_item.requires_attention: - alert = Alert( - file_id=file_id, - level="warning", - message=f"File requires attention: {file_item.scan_details}", - ) - else: - alert = Alert(file_id=file_id, level="info", message="File processed successfully") - - session.add(alert) - await session.commit() + await _processing_service(session).send_alert(file_id) @celery_app.task diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c0df035b..dc960a4e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,7 +25,7 @@ services: image: postgres:latest container_name: backend-db ports: - - "5433:5433" + - "5434:5433" env_file: ".env.dev" volumes: - backend-db-volume:/var/lib/postgresql diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 00000000..a4ca0c2c --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,3 @@ +# URL бэкенда для клиентских запросов (fetch в src/app/page.tsx). +# Префикс NEXT_PUBLIC_ обязателен — иначе Next.js не пробросит переменную в браузерный код. +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8f420e2e..e7200e4d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -15,6 +15,8 @@ import { Table, } from "react-bootstrap"; +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + type FileItem = { id: string; title: string; @@ -102,8 +104,8 @@ export default function Page() { try { const [filesResponse, alertsResponse] = await Promise.all([ - fetch(`http://localhost:8000/files`, { cache: "no-store" }), - fetch(`http://localhost:8000/alerts`, { cache: "no-store" }), + fetch(`${API_URL}/files`, { cache: "no-store" }), + fetch(`${API_URL}/alerts`, { cache: "no-store" }), ]); if (!filesResponse.ok || !alertsResponse.ok) { @@ -144,7 +146,7 @@ export default function Page() { formData.append("file", selectedFile); try { - const response = await fetch(`http://localhost:8000/files`, { + const response = await fetch(`${API_URL}/files`, { method: "POST", body: formData, }); @@ -258,7 +260,7 @@ export default function Page() {