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.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
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [
"celery[redis]>=5.6.3",
"fastapi>=0.135.3",
"pydantic>=2.12.5",
"pydantic-settings>=2.8.1",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.48",
"uvicorn>=0.42.0",
Expand Down
64 changes: 44 additions & 20 deletions backend/src/app.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from fastapi import FastAPI, HTTPException
from fastapi import File, Form, UploadFile
from fastapi import FastAPI, HTTPException, Depends, File, Form, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from starlette import status
from sqlalchemy.ext.asyncio import AsyncSession

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.repository import FileRepository, AlertRepository
from src.service import FileService
from src.database import get_async_session
from src.tasks import scan_file_for_threats

app = FastAPI()

app.add_middleware(
CORSMiddleware,
allow_origins=[
Expand All @@ -20,52 +24,72 @@
)


async def get_file_service(session: AsyncSession = Depends(get_async_session)) -> FileService:
file_repo = FileRepository(session)
alert_repo = AlertRepository(session)
return FileService(file_repo, alert_repo)


@app.get("/files", response_model=list[FileItem])
async def list_files_view():
return await list_files()
async def list_files_view(service: FileService = Depends(get_file_service)):
return await service.list_files()


@app.get("/alerts", response_model=list[AlertItem])
async def list_alerts_view():
return await list_alerts()
async def list_alerts_view(service: FileService = Depends(get_file_service)):
return await service.list_alerts()


@app.post("/files", response_model=FileItem, status_code=201)
@app.post("/files", response_model=FileItem, status_code=status.HTTP_201_CREATED)
async def create_file_view(
title: str = Form(...),
file: UploadFile = File(...),
service: FileService = Depends(get_file_service),
):
file_item = await create_file(title=title, upload_file=file)
if file.size == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")

file_item = await service.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)
async def get_file_view(file_id: str, service: FileService = Depends(get_file_service)):
file_item = await service.get_file(file_id)
if not file_item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
return file_item


@app.patch("/files/{file_id}", response_model=FileItem)
async def update_file_view(
file_id: str,
payload: FileUpdate,
service: FileService = Depends(get_file_service),
):
return await update_file(file_id=file_id, title=payload.title)
file_item = await service.update_file(file_id=file_id, title=payload.title)
if not file_item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
return file_item


@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")
async def download_file(file_id: str, service: FileService = Depends(get_file_service)):
result = await service.get_file_path(file_id)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File or stored content not found")

file_item, stored_path = result
return FileResponse(
path=stored_path,
media_type=file_item.mime_type,
filename=file_item.original_name,
)


@app.delete("/files/{file_id}", status_code=204)
async def delete_file_view(file_id: str):
await delete_file(file_id)
@app.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_file_view(file_id: str, service: FileService = Depends(get_file_service)):
success = await service.delete_file(file_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
28 changes: 28 additions & 0 deletions backend/src/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict

BASE_DIR = Path(__file__).resolve().parent.parent


class Settings(BaseSettings):
POSTGRES_USER: str
POSTGRES_PASSWORD: str
POSTGRES_HOST: str
PGPORT: int
POSTGRES_DB: str

STORAGE_DIR: Path = BASE_DIR / "storage" / "files"
CHUNK_SIZE: int = 1024 * 1024 # 1MB

@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:"
f"{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:"
f"{self.PGPORT}/{self.POSTGRES_DB}"
)

model_config = SettingsConfigDict(env_file=str(BASE_DIR / ".env"), extra="ignore")


settings = Settings()
11 changes: 11 additions & 0 deletions backend/src/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from src.config import settings

engine = create_async_engine(settings.database_url)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)


async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
50 changes: 50 additions & 0 deletions backend/src/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import StoredFile, Alert


class FileRepository:
def __init__(self, session: AsyncSession):
self.session = session

async def list_all(self) -> Sequence[StoredFile]:
result = await self.session.execute(
select(StoredFile).order_by(StoredFile.created_at.desc())
)
return result.scalars().all()

async def get_by_id(self, file_id: str) -> StoredFile | None:
return await self.session.get(StoredFile, file_id)

async def create(self, file_item: StoredFile) -> StoredFile:
self.session.add(file_item)
await self.session.commit()
await self.session.refresh(file_item)
return file_item

async def update(self, file_item: StoredFile) -> StoredFile:
await self.session.commit()
await self.session.refresh(file_item)
return file_item

async def delete(self, file_item: StoredFile) -> None:
await self.session.delete(file_item)
await self.session.commit()


class AlertRepository:
def __init__(self, session: AsyncSession):
self.session = session

async def list_all(self) -> Sequence[Alert]:
result = await self.session.execute(
select(Alert).order_by(Alert.created_at.desc())
)
return result.scalars().all()

async def create(self, alert: Alert) -> Alert:
self.session.add(alert)
await self.session.commit()
await self.session.refresh(alert)
return alert
Loading