fix(upload): configure chat attachment size limit (#2439)

This commit is contained in:
nubs
2026-06-07 20:42:24 +00:00
committed by GitHub
parent 8746c9c0df
commit 865e61450e
9 changed files with 106 additions and 4 deletions
+5
View File
@@ -147,6 +147,11 @@ SEARXNG_INSTANCE=http://localhost:8080
# if you intentionally want scheduled scripts to run remotely. # if you intentionally want scheduled scripts to run remotely.
# ODYSSEUS_SCRIPT_HOST=localhost # 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) # GPU support (Docker Compose)
# ============================================================ # ============================================================
+1
View File
@@ -396,6 +396,7 @@ Key settings:
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. | | `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`. | | `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint | | `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) ### Built-in MCP servers (optional setup)
+1
View File
@@ -59,6 +59,7 @@ services:
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} - 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:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+1
View File
@@ -58,6 +58,7 @@ services:
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} - 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:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+1
View File
@@ -47,6 +47,7 @@ services:
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} - 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:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+5 -2
View File
@@ -13,6 +13,8 @@ from fastapi import HTTPException
from fastapi import UploadFile from fastapi import UploadFile
from typing import List, Optional from typing import List, Optional
from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
logger = logging.getLogger(__name__) 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( raise HTTPException(
status_code=400, status_code=400,
detail={ detail={
"error": "FILE_TOO_LARGE", "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: except IOError as e:
+6 -2
View File
@@ -12,6 +12,10 @@ import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
def secure_filename(filename: str) -> str: def secure_filename(filename: str) -> str:
"""Sanitize a filename (replaces werkzeug.utils.secure_filename).""" """Sanitize a filename (replaces werkzeug.utils.secure_filename)."""
import unicodedata import unicodedata
@@ -73,7 +77,7 @@ class UploadHandler:
def __init__(self, base_dir: str, upload_dir: str): def __init__(self, base_dir: str, upload_dir: str):
self.base_dir = base_dir self.base_dir = base_dir
self.upload_dir = upload_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.max_concurrent_uploads = 3
self.cleanup_days = 30 self.cleanup_days = 30
# Per-IP per-minute cap. save_upload() counts EACH file, and the chat # 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: if file_size > self.max_upload_size:
raise HTTPException( raise HTTPException(
status_code=400, 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 # Get original filename and sanitize it
+22
View File
@@ -1,7 +1,12 @@
"""Small helpers for route-local upload size caps.""" """Small helpers for route-local upload size caps."""
import os
from fastapi import HTTPException, UploadFile 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: def format_byte_limit(limit: int) -> str:
if limit % (1024 * 1024) == 0: if limit % (1024 * 1024) == 0:
@@ -11,6 +16,23 @@ def format_byte_limit(limit: int) -> str:
return f"{limit} bytes" 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: async def read_upload_limited(upload: UploadFile, limit: int, label: str = "Upload") -> bytes:
"""Read an UploadFile with a hard byte cap.""" """Read an UploadFile with a hard byte cap."""
data = await upload.read(limit + 1) data = await upload.read(limit + 1)
+64
View File
@@ -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"