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
12 changes: 12 additions & 0 deletions .env.dev.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=test
POSTGRES_HOST=backend-db
PGPORT=5432

CELERY_BROKER_URL=redis://backend-redis:6379/0

API_KEY=dev-api-key
MAX_UPLOAD_SIZE_BYTES=26214400
MAX_METADATA_READ_BYTES=2097152
ALLOWED_EXTENSIONS=txt,pdf,jpg,jpeg,png,csv,json
14 changes: 13 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ build/
dist/
wheels/
*.egg-info
.pytest_cache/
.mypy_cache/
.coverage

# Virtual environments
.venv
**/.venv/
.idea
**/.DS_Store
backend/storage/*
backend/storage/*

# Frontend build/deps
frontend/node_modules/
frontend/.next/

# Local env overrides (keep examples)
backend/.env.dev
frontend/.env.local
66 changes: 52 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,58 @@
## Тестовое задание на позицию Fullstack разработчика (Python + React)
## Тестовое задание: Fullstack (Python + React)

**Вводные:**
1. Здесь представлен MVP проект файлообменника. Он позволяет загружать файлы, проверяет их на подозрительный контент и отправляет алерты;
2. Репозиторий содержит в себе бэкенд и фронтенд части;
3. В обоих частях присутствуют баги, неоптимизированный код, неудачные архитектурные решения.
MVP-проект файлообменника:
- загрузка файлов;
- асинхронный анализ на подозрительный контент;
- генерация алертов по результатам обработки.

**Задачи:**
1. Проведите рефакторинг бэкенда, не ломая бизнес-логики: предложите свое видение архитектуры и реализуйте его;
2. (Дополнительно) На бэкенде есть возможность неочевидной оптимизации - выполните ее;
3. (Дополнительно) Разбейте логику фронтенда на слои;
### Стек

**Запуск:**
1. ```docker compose -f docker-compose.dev.yml up```
2. ```docker exec -it backend alembic upgrade head```
- Backend: FastAPI + SQLAlchemy + Postgres + Celery + Redis
- Frontend: Next.js + React-Bootstrap
- Инфраструктура: Docker Compose

### Структура

**Открыть фронт:** ```http://localhost:3000/test```
- `backend` - API, модели, миграции и celery worker
- `frontend` - UI для загрузки файлов и просмотра статусов
- `docker-compose.dev.yml` - dev-окружение

**Открыть бэк:** ```http://localhost:8000/docs```
### Prerequisites

- Docker + Docker Compose plugin
- Свободные порты: `3000`, `8000`, `5433`

### Настройка окружения

1. Создайте env-файл:
- `cp .env.dev.example .env.dev` (Linux/macOS)
- `copy .env.dev.example .env.dev` (Windows)
2. Для фронтенда создайте локальные переменные:
- `cp frontend/.env.local.example frontend/.env.local` (Linux/macOS)
- `copy frontend\.env.local.example frontend\.env.local` (Windows)

### Запуск

1. Поднимите сервисы:
- `docker compose -f docker-compose.dev.yml up --build -d`
2. Примените миграции:
- `docker compose -f docker-compose.dev.yml exec backend alembic upgrade head`
3. Проверьте статус:
- `docker compose -f docker-compose.dev.yml ps`

### Доступ

- Frontend: `http://localhost:3000`
- Backend docs: `http://localhost:8000/docs`

### Безопасность в dev

- API защищен заголовком `x-api-key`.
- Значение берется из `API_KEY` в `.env.dev`.
- Фронтенд использует `NEXT_PUBLIC_API_KEY` из `frontend/.env.local`.

### Что проверять после запуска

- Можно загрузить файл из UI.
- Файл появляется в таблице со статусом `uploaded -> processing -> processed`.
- После обработки создается запись в алертах.
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ dependencies = [
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.48",
"uvicorn>=0.42.0",
"pytest>=8.4.2",
]
34 changes: 26 additions & 8 deletions backend/src/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from fastapi import FastAPI, HTTPException
import os

from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi import File, Form, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
Expand All @@ -7,6 +9,7 @@
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

API_KEY = os.environ.get("API_KEY", "dev-api-key")
app = FastAPI()
app.add_middleware(
CORSMiddleware,
Expand All @@ -20,41 +23,56 @@
)


def require_api_key(x_api_key: str = Header(...)) -> None:
if x_api_key != API_KEY:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")


@app.get("/files", response_model=list[FileItem])
async def list_files_view():
return await list_files()
async def list_files_view(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_: None = Depends(require_api_key),
):
return await list_files(limit=limit, offset=offset)


@app.get("/alerts", response_model=list[AlertItem])
async def list_alerts_view():
return await list_alerts()
async def list_alerts_view(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_: None = Depends(require_api_key),
):
return await list_alerts(limit=limit, offset=offset)


@app.post("/files", response_model=FileItem, status_code=201)
async def create_file_view(
title: str = Form(...),
file: UploadFile = File(...),
_: None = Depends(require_api_key),
):
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):
async def get_file_view(file_id: str, _: None = Depends(require_api_key)):
return await get_file(file_id)


@app.patch("/files/{file_id}", response_model=FileItem)
async def update_file_view(
file_id: str,
payload: FileUpdate,
_: None = Depends(require_api_key),
):
return await update_file(file_id=file_id, title=payload.title)


@app.get("/files/{file_id}/download")
async def download_file(file_id: str):
async def download_file(file_id: str, _: None = Depends(require_api_key)):
file_item = await get_file(file_id)
stored_path = STORAGE_DIR / file_item.stored_name
if not stored_path.exists():
Expand All @@ -67,5 +85,5 @@ async def download_file(file_id: str):


@app.delete("/files/{file_id}", status_code=204)
async def delete_file_view(file_id: str):
async def delete_file_view(file_id: str, _: None = Depends(require_api_key)):
await delete_file(file_id)
98 changes: 89 additions & 9 deletions backend/src/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@
BASE_DIR = Path(__file__).resolve().parent.parent
STORAGE_DIR = BASE_DIR / "storage" / "files"
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
CHUNK_SIZE = 1024 * 1024
MAX_UPLOAD_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE_BYTES", str(25 * 1024 * 1024)))
DEFAULT_ALLOWED_EXTENSIONS = "txt,pdf,jpg,jpeg,png,csv,json"
ALLOWED_EXTENSIONS = {
item.strip().lower()
for item in os.environ.get("ALLOWED_EXTENSIONS", DEFAULT_ALLOWED_EXTENSIONS).split(",")
if item.strip()
}
ALLOWED_MIME_TYPES = {
"text/plain",
"application/pdf",
"image/jpeg",
"image/png",
"text/csv",
"application/json",
}
DB_URL = (
f"postgresql+asyncpg://{os.environ.get('POSTGRES_USER')}:"
f"{os.environ.get('POSTGRES_PASSWORD')}@{os.environ.get('POSTGRES_HOST')}:"
Expand All @@ -22,15 +38,45 @@
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)


async def list_files() -> list[StoredFile]:
def _normalize_extension(filename: str | None) -> str:
suffix = Path(filename or "").suffix.lower()
return suffix[1:] if suffix.startswith(".") else suffix


def _validate_upload_type(filename: str | None, content_type: str | None) -> None:
extension = _normalize_extension(filename)
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported extension: .{extension or 'unknown'}",
)

if content_type and content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported mime type: {content_type}",
)


async def list_files(limit: int = 50, offset: int = 0) -> list[StoredFile]:
async with async_session_maker() as session:
result = await session.execute(select(StoredFile).order_by(StoredFile.created_at.desc()))
result = await session.execute(
select(StoredFile)
.order_by(StoredFile.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())


async def list_alerts() -> list[Alert]:
async def list_alerts(limit: int = 50, offset: int = 0) -> list[Alert]:
async with async_session_maker() as session:
result = await session.execute(select(Alert).order_by(Alert.created_at.desc()))
result = await session.execute(
select(Alert)
.order_by(Alert.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())


Expand All @@ -43,23 +89,57 @@ async def get_file(file_id: str) -> StoredFile:


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")
_validate_upload_type(upload_file.filename, upload_file.content_type)

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)
total_size = 0
is_empty = True
first_chunk: bytes | None = None
try:
with open(stored_path, "wb") as stored_file:
while True:
chunk = await upload_file.read(CHUNK_SIZE)
if not chunk:
break
if first_chunk is None:
first_chunk = chunk
is_empty = False
total_size += len(chunk)
if total_size > MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File is too large. Limit is {MAX_UPLOAD_SIZE} bytes",
)
stored_file.write(chunk)
except HTTPException:
if stored_path.exists():
stored_path.unlink()
raise

if is_empty:
if stored_path.exists():
stored_path.unlink()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")

if (upload_file.content_type or "").lower() == "application/pdf":
if first_chunk is None or not first_chunk.startswith(b"%PDF"):
if stored_path.exists():
stored_path.unlink()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid PDF signature",
)

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),
size=total_size,
processing_status="uploaded",
)
async with async_session_maker() as session:
Expand Down
5 changes: 3 additions & 2 deletions backend/src/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from src.service import STORAGE_DIR, DB_URL

REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0")
MAX_METADATA_READ_BYTES = int(os.environ.get("MAX_METADATA_READ_BYTES", str(2 * 1024 * 1024)))
_worker_loop: asyncio.AbstractEventLoop | None = None


Expand Down Expand Up @@ -72,11 +73,11 @@ async def _extract_file_metadata(file_id: str) -> None:
}

if file_item.mime_type.startswith("text/"):
content = stored_path.read_text(encoding="utf-8", errors="ignore")
content = stored_path.read_text(encoding="utf-8", errors="ignore")[:MAX_METADATA_READ_BYTES]
metadata["line_count"] = len(content.splitlines())
metadata["char_count"] = len(content)
elif file_item.mime_type == "application/pdf":
content = stored_path.read_bytes()
content = stored_path.read_bytes()[:MAX_METADATA_READ_BYTES]
metadata["approx_page_count"] = max(content.count(b"/Type /Page"), 1)

file_item.metadata_json = metadata
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
30 changes: 30 additions & 0 deletions backend/tests/test_upload_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import os

import pytest
from fastapi import HTTPException

os.environ.setdefault("POSTGRES_USER", "postgres")
os.environ.setdefault("POSTGRES_PASSWORD", "postgres")
os.environ.setdefault("POSTGRES_HOST", "localhost")
os.environ.setdefault("PGPORT", "5432")
os.environ.setdefault("POSTGRES_DB", "test")

from src.service import _normalize_extension, _validate_upload_type


def test_normalize_extension_uses_lowercase_suffix() -> None:
assert _normalize_extension("Report.PDF") == "pdf"


def test_validate_upload_type_rejects_unknown_extension() -> None:
with pytest.raises(HTTPException) as exception_info:
_validate_upload_type("payload.exe", "application/octet-stream")

assert exception_info.value.status_code == 400


def test_validate_upload_type_rejects_unsupported_mime() -> None:
with pytest.raises(HTTPException) as exception_info:
_validate_upload_type("report.pdf", "application/xml")

assert exception_info.value.status_code == 400
Loading