From 865e61450e78366a739363c8334d89a858d1f2da Mon Sep 17 00:00:00 2001 From: nubs Date: Sun, 7 Jun 2026 20:42:24 +0000 Subject: [PATCH] fix(upload): configure chat attachment size limit (#2439) --- .env.example | 5 ++ README.md | 1 + docker-compose.gpu-amd.yml | 1 + docker-compose.gpu-nvidia.yml | 1 + docker-compose.yml | 1 + src/chat_helpers.py | 7 ++- src/upload_handler.py | 8 +++- src/upload_limits.py | 22 +++++++++ tests/test_chat_upload_limit_config.py | 64 ++++++++++++++++++++++++++ 9 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 tests/test_chat_upload_limit_config.py diff --git a/.env.example b/.env.example index 39c90b30d..10276433d 100644 --- a/.env.example +++ b/.env.example @@ -147,6 +147,11 @@ SEARXNG_INSTANCE=http://localhost:8080 # if you intentionally want scheduled scripts to run remotely. # ODYSSEUS_SCRIPT_HOST=localhost +# Chat / agent attachment size cap in bytes (default: 10 MB). +# Raise this for local installs that need larger PDFs or text documents. +# Example: 52428800 = 50 MB. +# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760 + # ============================================================ # GPU support (Docker Compose) # ============================================================ diff --git a/README.md b/README.md index 7833417d9..c99e7031e 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,7 @@ Key settings: | `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. | | `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. | | `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint | +| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. | ### Built-in MCP servers (optional setup) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 6d87cb6e3..b95dde1bf 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -59,6 +59,7 @@ services: - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} + - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index f61d22a4b..fa50896ba 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -58,6 +58,7 @@ services: - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} + - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.yml b/docker-compose.yml index b5b3fd93d..9841b1dca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} + - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/src/chat_helpers.py b/src/chat_helpers.py index 1c8d1c9f7..cea9cad08 100644 --- a/src/chat_helpers.py +++ b/src/chat_helpers.py @@ -13,6 +13,8 @@ from fastapi import HTTPException from fastapi import UploadFile from typing import List, Optional +from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes + logger = logging.getLogger(__name__) @@ -201,12 +203,13 @@ def validate_file_upload(file: UploadFile) -> UploadFile: } ) - if file_size > 10 * 1024 * 1024: + upload_limit = get_chat_upload_max_bytes() + if file_size > upload_limit: raise HTTPException( status_code=400, detail={ "error": "FILE_TOO_LARGE", - "message": "File size exceeds 10MB limit" + "message": f"File size exceeds {format_byte_limit(upload_limit)} limit" } ) except IOError as e: diff --git a/src/upload_handler.py b/src/upload_handler.py index bb0cb300f..95bce306d 100644 --- a/src/upload_handler.py +++ b/src/upload_handler.py @@ -12,6 +12,10 @@ import threading from datetime import datetime, timedelta from typing import Dict, Any, Optional from fastapi import HTTPException, UploadFile + +from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes + + def secure_filename(filename: str) -> str: """Sanitize a filename (replaces werkzeug.utils.secure_filename).""" import unicodedata @@ -73,7 +77,7 @@ class UploadHandler: def __init__(self, base_dir: str, upload_dir: str): self.base_dir = base_dir self.upload_dir = upload_dir - self.max_upload_size = 10 * 1024 * 1024 # 10MB + self.max_upload_size = get_chat_upload_max_bytes() self.max_concurrent_uploads = 3 self.cleanup_days = 30 # Per-IP per-minute cap. save_upload() counts EACH file, and the chat @@ -518,7 +522,7 @@ class UploadHandler: if file_size > self.max_upload_size: raise HTTPException( status_code=400, - detail=f"File size exceeds {self.max_upload_size/1024/1024}MB limit" + detail=f"File size exceeds {format_byte_limit(self.max_upload_size)} limit" ) # Get original filename and sanitize it diff --git a/src/upload_limits.py b/src/upload_limits.py index e81284703..d16835d21 100644 --- a/src/upload_limits.py +++ b/src/upload_limits.py @@ -1,7 +1,12 @@ """Small helpers for route-local upload size caps.""" +import os + from fastapi import HTTPException, UploadFile +DEFAULT_CHAT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024 +CHAT_UPLOAD_MAX_BYTES_ENV = "ODYSSEUS_CHAT_UPLOAD_MAX_BYTES" + def format_byte_limit(limit: int) -> str: if limit % (1024 * 1024) == 0: @@ -11,6 +16,23 @@ def format_byte_limit(limit: int) -> str: return f"{limit} bytes" +def read_byte_limit_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + try: + limit = int(raw) + except ValueError as exc: + raise ValueError(f"{name} must be an integer byte count") from exc + if limit < 1: + raise ValueError(f"{name} must be greater than 0") + return limit + + +def get_chat_upload_max_bytes() -> int: + return read_byte_limit_env(CHAT_UPLOAD_MAX_BYTES_ENV, DEFAULT_CHAT_UPLOAD_MAX_BYTES) + + async def read_upload_limited(upload: UploadFile, limit: int, label: str = "Upload") -> bytes: """Read an UploadFile with a hard byte cap.""" data = await upload.read(limit + 1) diff --git a/tests/test_chat_upload_limit_config.py b/tests/test_chat_upload_limit_config.py new file mode 100644 index 000000000..6d45c8835 --- /dev/null +++ b/tests/test_chat_upload_limit_config.py @@ -0,0 +1,64 @@ +import io + +import pytest +from fastapi import HTTPException, UploadFile + +from src.chat_helpers import validate_file_upload +from src.upload_handler import UploadHandler +from src.upload_limits import ( + DEFAULT_CHAT_UPLOAD_MAX_BYTES, + get_chat_upload_max_bytes, + read_byte_limit_env, +) + + +def _upload(name: str, data: bytes) -> UploadFile: + return UploadFile(filename=name, file=io.BytesIO(data)) + + +def test_chat_upload_limit_defaults_to_10mb(monkeypatch): + monkeypatch.delenv("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", raising=False) + + assert get_chat_upload_max_bytes() == DEFAULT_CHAT_UPLOAD_MAX_BYTES + + +def test_chat_upload_limit_uses_env_bytes(monkeypatch): + monkeypatch.setenv("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", "12345") + + assert get_chat_upload_max_bytes() == 12345 + + +def test_chat_upload_limit_rejects_invalid_env(monkeypatch): + monkeypatch.setenv("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", "not-bytes") + + with pytest.raises(ValueError, match="ODYSSEUS_CHAT_UPLOAD_MAX_BYTES"): + get_chat_upload_max_bytes() + + +def test_read_byte_limit_env_rejects_non_positive(monkeypatch): + monkeypatch.setenv("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", "0") + + with pytest.raises(ValueError, match="greater than 0"): + read_byte_limit_env("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", 10) + + +def test_validate_file_upload_uses_configured_chat_limit(monkeypatch): + monkeypatch.setenv("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", "4") + + with pytest.raises(HTTPException) as exc: + validate_file_upload(_upload("too-large.txt", b"abcde")) + + assert exc.value.status_code == 400 + assert exc.value.detail["error"] == "FILE_TOO_LARGE" + assert exc.value.detail["message"] == "File size exceeds 4 bytes limit" + + +def test_upload_handler_uses_configured_chat_limit(monkeypatch, tmp_path): + monkeypatch.setenv("ODYSSEUS_CHAT_UPLOAD_MAX_BYTES", "4") + handler = UploadHandler(base_dir=str(tmp_path), upload_dir=str(tmp_path / "uploads")) + + with pytest.raises(HTTPException) as exc: + handler.save_upload(_upload("too-large.txt", b"abcde"), client_ip="127.0.0.1") + + assert exc.value.status_code == 400 + assert exc.value.detail == "File size exceeds 4 bytes limit"