Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions backend/src/api/deps.py
Original file line number Diff line number Diff line change
@@ -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)]
27 changes: 27 additions & 0 deletions backend/src/api/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -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"},
)
11 changes: 11 additions & 0 deletions backend/src/api/routers/alerts.py
Original file line number Diff line number Diff line change
@@ -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()
49 changes: 49 additions & 0 deletions backend/src/api/routers/files.py
Original file line number Diff line number Diff line change
@@ -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)
87 changes: 21 additions & 66 deletions backend/src/app.py
Original file line number Diff line number Diff line change
@@ -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()
38 changes: 38 additions & 0 deletions backend/src/core/config.py
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 14 additions & 0 deletions backend/src/core/database.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions backend/src/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class FileNotFound(Exception):
pass


class EmptyFileUpload(Exception):
pass


class StoredFileMissing(Exception):
pass
38 changes: 38 additions & 0 deletions backend/src/repositories.py
Original file line number Diff line number Diff line change
@@ -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)
Loading