From d861508a0c63586506066c663d32ac2130646b3b Mon Sep 17 00:00:00 2001 From: nkl Date: Thu, 7 May 2026 15:57:21 +0700 Subject: [PATCH 01/13] fix: add .env file for proper docker build --- frontend/.env.production | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 frontend/.env.production diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 00000000..e69de29b From 58a1ddfc5e5116496c265e91caf67ab6081ad43e Mon Sep 17 00:00:00 2001 From: nkl Date: Thu, 7 May 2026 16:02:12 +0700 Subject: [PATCH 02/13] chore: update .gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e3870959..f03fda14 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ wheels/ .venv .idea **/.DS_Store -backend/storage/* \ No newline at end of file +backend/storage/* + +# Other +.cp-images/ +data/ From 4eeec9fb1179ac0d2cbe8c11892c18fcf3442ffa Mon Sep 17 00:00:00 2001 From: nkl Date: Thu, 7 May 2026 17:26:36 +0700 Subject: [PATCH 03/13] style(backend): ruff format --- backend/src/app.py | 19 ++++++++++++++----- backend/src/models.py | 14 ++++++++++---- backend/src/service.py | 32 ++++++++++++++++++++++---------- backend/src/tasks.py | 18 +++++++++++++----- 4 files changed, 59 insertions(+), 24 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index bec89a5f..ef0f6405 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,11 +1,18 @@ -from fastapi import FastAPI, HTTPException -from fastapi import File, Form, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, UploadFile 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.service import ( + STORAGE_DIR, + create_file, + delete_file, + get_file, + list_alerts, + list_files, + update_file, +) from src.tasks import scan_file_for_threats +from starlette import status app = FastAPI() app.add_middleware( @@ -58,7 +65,9 @@ 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") + 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, diff --git a/backend/src/models.py b/backend/src/models.py index ad5e515b..7f6a5beb 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, func +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -17,11 +17,15 @@ class StoredFile(Base): stored_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) mime_type: Mapped[str] = mapped_column(String(255), nullable=False) size: Mapped[int] = mapped_column(Integer, nullable=False) - processing_status: Mapped[str] = mapped_column(String(50), nullable=False, default="uploaded") + processing_status: Mapped[str] = mapped_column( + String(50), nullable=False, default="uploaded" + ) scan_status: Mapped[str | None] = mapped_column(String(50), nullable=True) scan_details: Mapped[str | None] = mapped_column(String(500), nullable=True) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) - requires_attention: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + requires_attention: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), @@ -39,7 +43,9 @@ class Alert(Base): __tablename__ = "alerts" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - file_id: Mapped[str] = mapped_column(String(36), ForeignKey("files.id"), nullable=False) + file_id: Mapped[str] = mapped_column( + String(36), ForeignKey("files.id"), nullable=False + ) level: Mapped[str] = mapped_column(String(50), nullable=False) message: Mapped[str] = mapped_column(String(500), nullable=False) created_at: Mapped[datetime] = mapped_column( diff --git a/backend/src/service.py b/backend/src/service.py index e707fdc7..5734287e 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -5,11 +5,9 @@ from fastapi import HTTPException, UploadFile, status from sqlalchemy import select -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker - +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 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) @@ -24,7 +22,9 @@ 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())) + result = await session.execute( + select(StoredFile).order_by(StoredFile.created_at.desc()) + ) return list(result.scalars().all()) @@ -38,14 +38,18 @@ 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") + 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") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty" + ) file_id = str(uuid4()) suffix = Path(upload_file.filename or "").suffix @@ -58,7 +62,9 @@ async def create_file(title: str, upload_file: UploadFile) -> StoredFile: 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", + mime_type=upload_file.content_type + or mimetypes.guess_type(stored_name)[0] + or "application/octet-stream", size=len(content), processing_status="uploaded", ) @@ -73,7 +79,9 @@ 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") + 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) @@ -84,7 +92,9 @@ 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") + 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() @@ -96,7 +106,9 @@ 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") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found" + ) return file_item, stored_path diff --git a/backend/src/tasks.py b/backend/src/tasks.py index 4583aded..cf52058e 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -1,10 +1,11 @@ import asyncio import os from pathlib import Path + from celery import Celery -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from src.models import Alert, StoredFile -from src.service import STORAGE_DIR, DB_URL +from src.service import DB_URL, STORAGE_DIR REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0") _worker_loop: asyncio.AbstractEventLoop | None = None @@ -39,7 +40,10 @@ async def _scan_file_for_threats(file_id: str) -> None: 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"}: + if extension == ".pdf" and file_item.mime_type not in { + "application/pdf", + "application/octet-stream", + }: reasons.append("pdf extension does not match mime type") file_item.scan_status = "suspicious" if reasons else "clean" @@ -93,7 +97,9 @@ async def _send_file_alert(file_id: str) -> None: return if file_item.processing_status == "failed": - alert = Alert(file_id=file_id, level="critical", message="File processing failed") + alert = Alert( + file_id=file_id, level="critical", message="File processing failed" + ) elif file_item.requires_attention: alert = Alert( file_id=file_id, @@ -101,7 +107,9 @@ async def _send_file_alert(file_id: str) -> None: message=f"File requires attention: {file_item.scan_details}", ) else: - alert = Alert(file_id=file_id, level="info", message="File processed successfully") + alert = Alert( + file_id=file_id, level="info", message="File processed successfully" + ) session.add(alert) await session.commit() From c10f8df2a3cf94de5bd51efc2deb6ea22c3642cd Mon Sep 17 00:00:00 2001 From: nkl Date: Thu, 7 May 2026 17:59:27 +0700 Subject: [PATCH 04/13] refactor(backend): remove unused code --- backend/src/service.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/backend/src/service.py b/backend/src/service.py index 5734287e..29f590c2 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -100,22 +100,3 @@ async def delete_file(file_id: str) -> None: 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 From 0fb4883024d9a388002cfae9945b098b3e2de87e Mon Sep 17 00:00:00 2001 From: nkl Date: Fri, 8 May 2026 14:04:05 +0700 Subject: [PATCH 05/13] refactor(backend): extract database setup --- backend/src/database.py | 12 ++++++++++++ backend/src/service.py | 10 +--------- backend/src/tasks.py | 6 ++---- 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 backend/src/database.py diff --git a/backend/src/database.py b/backend/src/database.py new file mode 100644 index 00000000..e913a68b --- /dev/null +++ b/backend/src/database.py @@ -0,0 +1,12 @@ +import os + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +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) diff --git a/backend/src/service.py b/backend/src/service.py index 29f590c2..890cf583 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -1,23 +1,15 @@ 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 async_sessionmaker, create_async_engine +from src.database import async_session_maker 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]: diff --git a/backend/src/tasks.py b/backend/src/tasks.py index cf52058e..bbcd1397 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -3,9 +3,9 @@ from pathlib import Path from celery import Celery -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from src.database import async_session_maker from src.models import Alert, StoredFile -from src.service import DB_URL, STORAGE_DIR +from src.service import STORAGE_DIR REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0") _worker_loop: asyncio.AbstractEventLoop | None = None @@ -20,8 +20,6 @@ def run_in_worker_loop(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: From 1677c604a1279dc561f6898ef91925748648fb24 Mon Sep 17 00:00:00 2001 From: nkl Date: Fri, 8 May 2026 14:22:29 +0700 Subject: [PATCH 06/13] refactor(backend): centralize app config --- backend/migrations/env.py | 2 +- backend/src/{database.py => config.py} | 7 +++++++ backend/src/service.py | 6 +----- backend/src/tasks.py | 5 +---- 4 files changed, 10 insertions(+), 10 deletions(-) rename backend/src/{database.py => config.py} (63%) diff --git a/backend/migrations/env.py b/backend/migrations/env.py index e9e9f01b..d086802c 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -4,7 +4,7 @@ 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 DB_URL from src.models import Base import src.models diff --git a/backend/src/database.py b/backend/src/config.py similarity index 63% rename from backend/src/database.py rename to backend/src/config.py index e913a68b..16c49237 100644 --- a/backend/src/database.py +++ b/backend/src/config.py @@ -1,7 +1,14 @@ import os +from pathlib import Path from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +BASE_DIR = Path(__file__).resolve().parent.parent +STORAGE_DIR = BASE_DIR / "storage" / "files" +STORAGE_DIR.mkdir(parents=True, exist_ok=True) + +REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0") + DB_URL = ( f"postgresql+asyncpg://{os.environ.get('POSTGRES_USER')}:" f"{os.environ.get('POSTGRES_PASSWORD')}@{os.environ.get('POSTGRES_HOST')}:" diff --git a/backend/src/service.py b/backend/src/service.py index 890cf583..b7dde20c 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -4,13 +4,9 @@ from fastapi import HTTPException, UploadFile, status from sqlalchemy import select -from src.database import async_session_maker +from src.config import STORAGE_DIR, async_session_maker 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) - async def list_files() -> list[StoredFile]: async with async_session_maker() as session: diff --git a/backend/src/tasks.py b/backend/src/tasks.py index bbcd1397..7f448a7b 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -1,13 +1,10 @@ import asyncio -import os from pathlib import Path from celery import Celery -from src.database import async_session_maker +from src.config import REDIS_URL, STORAGE_DIR, async_session_maker from src.models import Alert, StoredFile -from src.service import STORAGE_DIR -REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0") _worker_loop: asyncio.AbstractEventLoop | None = None From e1c95721f904e724ad2ad41da33be26e396c8da6 Mon Sep 17 00:00:00 2001 From: nkl Date: Fri, 8 May 2026 16:51:58 +0700 Subject: [PATCH 07/13] refactor(backend): extract magic values to constants --- backend/src/tasks.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/tasks.py b/backend/src/tasks.py index 7f448a7b..e6557e12 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -18,6 +18,9 @@ def run_in_worker_loop(coroutine): celery_app = Celery("file_tasks", broker=REDIS_URL, backend=REDIS_URL) +MAX_FILE_SIZE = 10 * 1024 * 1024 +SUSPICIOUS_EXTENSIONS = {".exe", ".bat", ".cmd", ".sh", ".js"} + async def _scan_file_for_threats(file_id: str) -> None: async with async_session_maker() as session: @@ -29,11 +32,11 @@ async def _scan_file_for_threats(file_id: str) -> None: reasons: list[str] = [] extension = Path(file_item.original_name).suffix.lower() - if extension in {".exe", ".bat", ".cmd", ".sh", ".js"}: + if extension in SUSPICIOUS_EXTENSIONS: reasons.append(f"suspicious extension {extension}") - if file_item.size > 10 * 1024 * 1024: - reasons.append("file is larger than 10 MB") + if file_item.size > MAX_FILE_SIZE: + reasons.append(f"file is larger than {MAX_FILE_SIZE // (1024 * 1024)} MB") if extension == ".pdf" and file_item.mime_type not in { "application/pdf", From 7d65a31eba4a833e6e2aa65423cfdbb0245f6306 Mon Sep 17 00:00:00 2001 From: nkl Date: Sat, 9 May 2026 11:58:40 +0700 Subject: [PATCH 08/13] fix(backend): commit status before scan starts --- backend/src/tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/tasks.py b/backend/src/tasks.py index e6557e12..0fa7f3a9 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -29,6 +29,8 @@ async def _scan_file_for_threats(file_id: str) -> None: return file_item.processing_status = "processing" + await session.commit() + reasons: list[str] = [] extension = Path(file_item.original_name).suffix.lower() From 2739d79fec192636d5f01674d77890e9fa1ce1e6 Mon Sep 17 00:00:00 2001 From: nkl Date: Sat, 9 May 2026 12:49:45 +0700 Subject: [PATCH 09/13] refactor(backend): deduplicate Alert construction --- backend/src/tasks.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/src/tasks.py b/backend/src/tasks.py index 0fa7f3a9..37d3d5b3 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -97,19 +97,16 @@ async def _send_file_alert(file_id: str) -> None: return if file_item.processing_status == "failed": - alert = Alert( - file_id=file_id, level="critical", message="File processing failed" - ) + level, message = "critical", "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}", + level, message = ( + "warning", + f"File requires attention: {file_item.scan_details}", ) else: - alert = Alert( - file_id=file_id, level="info", message="File processed successfully" - ) + level, message = "info", "File processed successfully" + + alert = Alert(file_id=file_id, level=level, message=message) session.add(alert) await session.commit() From 83e63a852cf5a3a71e3751e29866cbd4d1958336 Mon Sep 17 00:00:00 2001 From: nkl Date: Sat, 9 May 2026 15:45:16 +0700 Subject: [PATCH 10/13] refactor(backend): move download logic to service layer --- backend/src/app.py | 14 ++++---------- backend/src/service.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index ef0f6405..3613c6f5 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,18 +1,17 @@ -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi import FastAPI, File, Form, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from src.schemas import AlertItem, FileItem, FileUpdate from src.service import ( - STORAGE_DIR, create_file, delete_file, get_file, + get_file_for_download, list_alerts, list_files, update_file, ) from src.tasks import scan_file_for_threats -from starlette import status app = FastAPI() app.add_middleware( @@ -61,13 +60,8 @@ async def update_file_view( @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_view(file_id: str): + file_item, stored_path = await get_file_for_download(file_id) return FileResponse( path=stored_path, media_type=file_item.mime_type, diff --git a/backend/src/service.py b/backend/src/service.py index b7dde20c..dcc39572 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -76,6 +76,16 @@ async def update_file(file_id: str, title: str) -> StoredFile: return file_item +async def get_file_for_download(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 delete_file(file_id: str) -> None: async with async_session_maker() as session: file_item = await session.get(StoredFile, file_id) From 05d4010bbb3a9389ccb0a9acdfdcbaece100f5df Mon Sep 17 00:00:00 2001 From: nkl Date: Sat, 9 May 2026 15:49:38 +0700 Subject: [PATCH 11/13] chore(backend): add TODO for silent returns --- backend/src/tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/tasks.py b/backend/src/tasks.py index 37d3d5b3..1fd4d099 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -26,6 +26,7 @@ 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: + # TODO: should log a warning or raise so Celery marks the task as FAILED return file_item.processing_status = "processing" @@ -58,6 +59,7 @@ 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: + # TODO: should log a warning or raise so Celery marks the task as FAILED return stored_path = STORAGE_DIR / file_item.stored_name @@ -94,6 +96,7 @@ 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: + # TODO: should log a warning or raise so Celery marks the task as FAILED return if file_item.processing_status == "failed": From 27300cff659645e92ff1f057e22cd2e5369963fd Mon Sep 17 00:00:00 2001 From: nkl Date: Sat, 9 May 2026 15:51:32 +0700 Subject: [PATCH 12/13] docs: fix formatting and add notes for README --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 00da8f4c..925be2bf 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,30 @@ ## Тестовое задание на позицию Fullstack разработчика (Python + React) **Вводные:** + 1. Здесь представлен MVP проект файлообменника. Он позволяет загружать файлы, проверяет их на подозрительный контент и отправляет алерты; 2. Репозиторий содержит в себе бэкенд и фронтенд части; 3. В обоих частях присутствуют баги, неоптимизированный код, неудачные архитектурные решения. **Задачи:** + 1. Проведите рефакторинг бэкенда, не ломая бизнес-логики: предложите свое видение архитектуры и реализуйте его; 2. (Дополнительно) На бэкенде есть возможность неочевидной оптимизации - выполните ее; 3. (Дополнительно) Разбейте логику фронтенда на слои; **Запуск:** -1. ```docker compose -f docker-compose.dev.yml up``` -2. ```docker exec -it backend alembic upgrade head``` +1. `docker compose -f docker-compose.dev.yml up` +2. `docker exec -it backend alembic upgrade head` + +**Открыть фронт:** `http://localhost:3000/test` + +**Открыть бэк:** `http://localhost:8000/docs` + +## Решения при рефакторинге + +**Celery оставлен намеренно.** Текущие проверки просты, и технически можно было бы обойтись FastAPI `BackgroundTasks`. Однако если предположить, что это эмуляция реального сканирования (ClamAV, VirusTotal), то Celery оправдан — задачи могут занимать секунды, и `BackgroundTasks` не даст retry-логики и независимого масштабирования воркеров -**Открыть фронт:** ```http://localhost:3000/test``` +**Ограничение архитектуры: Celery + async.** Celery не поддерживает `async def` таски нативно, поэтому используется `run_in_worker_loop` с ручным управлением event loop. Это хрупкое решение — глобальный loop может закрыться неожиданно при перезапуске воркера. В продакшне стоит рассмотреть переход на `gevent`-воркеры: это позволит убрать бойлерплейт и объявлять таски напрямую как `async def`. Не реализовано, так как выходит за рамки рефакторинга -**Открыть бэк:** ```http://localhost:8000/docs``` +**Статусные поля без Enum.** `processing_status`, `scan_status`, `level` хранятся как строки без ограничений на уровне БД или Python. В идеале — `Enum`, чтобы исключить опечатки и гарантировать консистентность значений во всём коде. Не реализовано, так как требует миграции схемы БД, выходит за рамки рефакторинга From a0fbe9889397d4a5d3dc40b2f5c2c9c2f37ebac9 Mon Sep 17 00:00:00 2001 From: nkl Date: Sat, 9 May 2026 15:59:52 +0700 Subject: [PATCH 13/13] refactor(backend): replace delay calls with chain --- backend/src/app.py | 9 +++++++-- backend/src/tasks.py | 5 ----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index 3613c6f5..aa52d23e 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,3 +1,4 @@ +from celery import chain from fastapi import FastAPI, File, Form, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -11,7 +12,7 @@ list_files, update_file, ) -from src.tasks import scan_file_for_threats +from src.tasks import extract_file_metadata, scan_file_for_threats, send_file_alert app = FastAPI() app.add_middleware( @@ -42,7 +43,11 @@ async def create_file_view( file: UploadFile = File(...), ): file_item = await create_file(title=title, upload_file=file) - scan_file_for_threats.delay(file_item.id) + chain( + scan_file_for_threats.si(file_item.id), + extract_file_metadata.si(file_item.id), + send_file_alert.si(file_item.id), + ).delay() return file_item diff --git a/backend/src/tasks.py b/backend/src/tasks.py index 1fd4d099..1ebe1d9c 100644 --- a/backend/src/tasks.py +++ b/backend/src/tasks.py @@ -52,8 +52,6 @@ async def _scan_file_for_threats(file_id: str) -> None: file_item.requires_attention = bool(reasons) await session.commit() - extract_file_metadata.delay(file_id) - async def _extract_file_metadata(file_id: str) -> None: async with async_session_maker() as session: @@ -68,7 +66,6 @@ async def _extract_file_metadata(file_id: str) -> None: 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 = { @@ -89,8 +86,6 @@ async def _extract_file_metadata(file_id: str) -> None: file_item.processing_status = "processed" await session.commit() - send_file_alert.delay(file_id) - async def _send_file_alert(file_id: str) -> None: async with async_session_maker() as session: