feat: make document max size configurable via MAX_FILE_SIZE_MB env var

Replace hardcoded 5 MB upload limit with a configurable setting.
Backend exposes the value via /api/health, frontend reads it
dynamically for validation and UI messages.

Closes #48
This commit is contained in:
Pier-Jean Malandrino 2026-04-08 10:26:51 +02:00
parent d62af058ed
commit 704ba550d4
13 changed files with 81 additions and 23 deletions

View file

@ -15,6 +15,9 @@
# Max parallel analysis jobs (default: 3) # Max parallel analysis jobs (default: 3)
# MAX_CONCURRENT_ANALYSES=3 # MAX_CONCURRENT_ANALYSES=3
# Max upload file size in MB (default: 50, 0 = unlimited)
# MAX_FILE_SIZE_MB=50
# Max pages per PDF (default: 0 = unlimited). Set to 20 for HF Spaces. # Max pages per PDF (default: 0 = unlimited). Set to 20 for HF Spaces.
# MAX_PAGE_COUNT=0 # MAX_PAGE_COUNT=0

View file

@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile
from fastapi.responses import Response from fastapi.responses import Response
from api.schemas import DocumentResponse from api.schemas import DocumentResponse
from infra.settings import settings
from services import document_service from services import document_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,16 +35,18 @@ async def upload(file: UploadFile) -> DocumentResponse:
raise HTTPException(status_code=400, detail="No filename provided") raise HTTPException(status_code=400, detail="No filename provided")
# Reject early if Content-Length exceeds limit (before reading body) # Reject early if Content-Length exceeds limit (before reading body)
if file.size and file.size > document_service.MAX_FILE_SIZE: _max = document_service.MAX_FILE_SIZE
raise HTTPException(status_code=413, detail="File too large (max 5 MB)") _detail = f"File too large (max {settings.max_file_size_mb} MB)"
if _max > 0 and file.size and file.size > _max:
raise HTTPException(status_code=413, detail=_detail)
# Read in chunks to avoid holding the full upload in a single allocation # Read in chunks to avoid holding the full upload in a single allocation
chunks: list[bytes] = [] chunks: list[bytes] = []
total = 0 total = 0
while chunk := await file.read(_READ_CHUNK_SIZE): while chunk := await file.read(_READ_CHUNK_SIZE):
total += len(chunk) total += len(chunk)
if total > document_service.MAX_FILE_SIZE: if _max > 0 and total > _max:
raise HTTPException(status_code=413, detail="File too large (max 5 MB)") raise HTTPException(status_code=413, detail=_detail)
chunks.append(chunk) chunks.append(chunk)
content = b"".join(chunks) content = b"".join(chunks)

View file

@ -20,6 +20,7 @@ class Settings:
default_table_mode: str = "accurate" # "accurate" or "fast" default_table_mode: str = "accurate" # "accurate" or "fast"
max_page_count: int = 0 # 0 = unlimited (upload validation) max_page_count: int = 0 # 0 = unlimited (upload validation)
max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes) max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes)
max_file_size_mb: int = 50 # upload limit in MB (0 = unlimited)
batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch
upload_dir: str = "./uploads" upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db" db_path: str = "./data/docling_studio.db"
@ -43,6 +44,8 @@ class Settings:
errors.append(f"max_page_count must be >= 0 (got {self.max_page_count})") errors.append(f"max_page_count must be >= 0 (got {self.max_page_count})")
if self.max_file_size < 0: if self.max_file_size < 0:
errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})") errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})")
if self.max_file_size_mb < 0:
errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})")
if self.batch_page_size < 0: if self.batch_page_size < 0:
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})") errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
if self.default_table_mode not in ("accurate", "fast"): if self.default_table_mode not in ("accurate", "fast"):
@ -81,6 +84,7 @@ class Settings:
default_table_mode=os.environ.get("DEFAULT_TABLE_MODE", "accurate"), default_table_mode=os.environ.get("DEFAULT_TABLE_MODE", "accurate"),
max_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")), max_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")),
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")), max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")), batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"), upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"), db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),

View file

@ -122,4 +122,6 @@ async def health() -> dict[str, str | int]:
} }
if settings.max_page_count > 0: if settings.max_page_count > 0:
result["maxPageCount"] = settings.max_page_count result["maxPageCount"] = settings.max_page_count
if settings.max_file_size_mb > 0:
result["maxFileSizeMb"] = settings.max_file_size_mb
return result return result

View file

@ -16,7 +16,7 @@ from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
UPLOAD_DIR = settings.upload_dir UPLOAD_DIR = settings.upload_dir
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB MAX_FILE_SIZE = settings.max_file_size_mb * 1024 * 1024 if settings.max_file_size_mb > 0 else 0
MAX_PAGE_COUNT = settings.max_page_count # 0 = unlimited MAX_PAGE_COUNT = settings.max_page_count # 0 = unlimited
@ -32,8 +32,8 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
Writes the file in fixed-size chunks to keep peak memory usage low. Writes the file in fixed-size chunks to keep peak memory usage low.
""" """
if len(file_content) > MAX_FILE_SIZE: if MAX_FILE_SIZE > 0 and len(file_content) > MAX_FILE_SIZE:
raise ValueError("File too large (max 5 MB)") raise ValueError(f"File too large (max {settings.max_file_size_mb} MB)")
if not file_content[:4].startswith(_PDF_MAGIC): if not file_content[:4].startswith(_PDF_MAGIC):
raise ValueError("Invalid file: not a PDF document") raise ValueError("Invalid file: not a PDF document")

View file

@ -33,6 +33,12 @@ class TestHealthEndpoint:
assert "engine" in data assert "engine" in data
assert "database" in data assert "database" in data
def test_health_exposes_max_file_size_mb(self, client):
resp = client.get("/api/health")
data = resp.json()
assert "maxFileSizeMb" in data
assert data["maxFileSizeMb"] == 50
class TestDocumentEndpoints: class TestDocumentEndpoints:
@patch("services.document_service.find_all", new_callable=AsyncMock) @patch("services.document_service.find_all", new_callable=AsyncMock)

View file

@ -17,6 +17,7 @@ class TestSettingsDefaults:
assert s.document_timeout == 120.0 assert s.document_timeout == 120.0
assert s.lock_timeout == 300 assert s.lock_timeout == 300
assert s.max_page_count == 0 assert s.max_page_count == 0
assert s.max_file_size_mb == 50
assert s.batch_page_size == 0 assert s.batch_page_size == 0
assert s.upload_dir == "./uploads" assert s.upload_dir == "./uploads"
assert s.db_path == "./data/docling_studio.db" assert s.db_path == "./data/docling_studio.db"
@ -68,6 +69,20 @@ class TestSettingsValidation:
with pytest.raises(ValueError, match="max_file_size must be >= 0"): with pytest.raises(ValueError, match="max_file_size must be >= 0"):
Settings(max_file_size=-1) Settings(max_file_size=-1)
def test_negative_max_file_size_mb_rejected(self):
import pytest
with pytest.raises(ValueError, match="max_file_size_mb must be >= 0"):
Settings(max_file_size_mb=-1)
def test_zero_max_file_size_mb_accepted(self):
s = Settings(max_file_size_mb=0)
assert s.max_file_size_mb == 0
def test_positive_max_file_size_mb_accepted(self):
s = Settings(max_file_size_mb=100)
assert s.max_file_size_mb == 100
def test_negative_batch_page_size_rejected(self): def test_negative_batch_page_size_rejected(self):
import pytest import pytest
@ -129,6 +144,7 @@ class TestSettingsFromEnv:
monkeypatch.setenv("DOCUMENT_TIMEOUT", "60.0") monkeypatch.setenv("DOCUMENT_TIMEOUT", "60.0")
monkeypatch.setenv("LOCK_TIMEOUT", "600") monkeypatch.setenv("LOCK_TIMEOUT", "600")
monkeypatch.setenv("MAX_PAGE_COUNT", "20") monkeypatch.setenv("MAX_PAGE_COUNT", "20")
monkeypatch.setenv("MAX_FILE_SIZE_MB", "100")
monkeypatch.setenv("BATCH_PAGE_SIZE", "15") monkeypatch.setenv("BATCH_PAGE_SIZE", "15")
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads") monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
monkeypatch.setenv("DB_PATH", "/data/test.db") monkeypatch.setenv("DB_PATH", "/data/test.db")
@ -145,6 +161,7 @@ class TestSettingsFromEnv:
assert s.lock_timeout == 600 assert s.lock_timeout == 600
assert s.document_timeout == 60.0 assert s.document_timeout == 60.0
assert s.max_page_count == 20 assert s.max_page_count == 20
assert s.max_file_size_mb == 100
assert s.batch_page_size == 15 assert s.batch_page_size == 15
assert s.upload_dir == "/data/uploads" assert s.upload_dir == "/data/uploads"
assert s.db_path == "/data/test.db" assert s.db_path == "/data/test.db"

View file

@ -30,7 +30,7 @@
</header> </header>
<div v-if="showDisclaimer" class="disclaimer-banner" role="alert"> <div v-if="showDisclaimer" class="disclaimer-banner" role="alert">
{{ t('disclaimer.banner') }} {{ t('disclaimer.banner').replace('{n}', String(flagStore.maxFileSizeMb || 50)) }}
<button class="disclaimer-close" @click="dismissDisclaimer" aria-label="Close"> <button class="disclaimer-close" @click="dismissDisclaimer" aria-label="Close">
&times; &times;
</button> </button>
@ -52,9 +52,11 @@ import { AppSidebar } from '../shared/ui/index'
import { useSettingsStore } from '../features/settings/store' import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store' import { useDocumentStore } from '../features/document/store'
import { useFeatureFlag } from '../features/feature-flags' import { useFeatureFlag } from '../features/feature-flags'
import { useFeatureFlagStore } from '../features/feature-flags/store'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
useSettingsStore() useSettingsStore()
const flagStore = useFeatureFlagStore()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const documentStore = useDocumentStore() const documentStore = useDocumentStore()

View file

@ -1,11 +1,11 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { Document } from '../../shared/types' import type { Document } from '../../shared/types'
import { useFeatureFlagStore } from '../feature-flags/store'
import * as api from './api' import * as api from './api'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
export const useDocumentStore = defineStore('document', () => { export const useDocumentStore = defineStore('document', () => {
const flags = useFeatureFlagStore()
const documents = ref<Document[]>([]) const documents = ref<Document[]>([])
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const uploading = ref(false) const uploading = ref(false)
@ -26,8 +26,9 @@ export const useDocumentStore = defineStore('document', () => {
} }
async function upload(file: File): Promise<Document> { async function upload(file: File): Promise<Document> {
if (file.size > MAX_FILE_SIZE) { const maxMb = flags.maxFileSizeMb
error.value = 'File too large (max 5 MB)' if (maxMb > 0 && file.size > maxMb * 1024 * 1024) {
error.value = `File too large (max ${maxMb} MB)`
throw new Error(error.value) throw new Error(error.value)
} }
uploading.value = true uploading.value = true

View file

@ -44,11 +44,14 @@ const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false) const dragging = ref(false)
const uploadHint = computed(() => { const uploadHint = computed(() => {
const size = t('upload.maxSize') const parts: string[] = []
if (flags.maxPageCount > 0) { if (flags.maxFileSizeMb > 0) {
return `${size} · ${t('upload.maxPages').replace('{n}', String(flags.maxPageCount))}` parts.push(t('upload.maxSize').replace('{n}', String(flags.maxFileSizeMb)))
} }
return size if (flags.maxPageCount > 0) {
parts.push(t('upload.maxPages').replace('{n}', String(flags.maxPageCount)))
}
return parts.join(' · ')
}) })
function openFilePicker() { function openFilePicker() {

View file

@ -68,6 +68,20 @@ describe('useFeatureFlagStore', () => {
expect(store.isEnabled('disclaimer')).toBe(false) expect(store.isEnabled('disclaimer')).toBe(false)
}) })
it('reads maxFileSizeMb from health response', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local', maxFileSizeMb: 100 })
const store = useFeatureFlagStore()
await store.load()
expect(store.maxFileSizeMb).toBe(100)
})
it('defaults maxFileSizeMb to 0 when missing', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
const store = useFeatureFlagStore()
await store.load()
expect(store.maxFileSizeMb).toBe(0)
})
it('handles health endpoint failure gracefully', async () => { it('handles health endpoint failure gracefully', async () => {
mockApiFetch.mockRejectedValue(new Error('Network error')) mockApiFetch.mockRejectedValue(new Error('Network error'))
const store = useFeatureFlagStore() const store = useFeatureFlagStore()

View file

@ -10,6 +10,7 @@ interface HealthResponse {
engine: ConversionEngine engine: ConversionEngine
deploymentMode?: DeploymentMode deploymentMode?: DeploymentMode
maxPageCount?: number maxPageCount?: number
maxFileSizeMb?: number
} }
export type FeatureFlag = 'chunking' | 'disclaimer' export type FeatureFlag = 'chunking' | 'disclaimer'
@ -39,6 +40,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
const engine = ref<ConversionEngine | null>(null) const engine = ref<ConversionEngine | null>(null)
const deploymentMode = ref<DeploymentMode | null>(null) const deploymentMode = ref<DeploymentMode | null>(null)
const maxPageCount = ref<number>(0) const maxPageCount = ref<number>(0)
const maxFileSizeMb = ref<number>(0)
const loaded = ref(false) const loaded = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -59,6 +61,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
engine.value = data.engine engine.value = data.engine
deploymentMode.value = data.deploymentMode ?? 'self-hosted' deploymentMode.value = data.deploymentMode ?? 'self-hosted'
maxPageCount.value = data.maxPageCount ?? 0 maxPageCount.value = data.maxPageCount ?? 0
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
loaded.value = true loaded.value = true
error.value = null error.value = null
} catch (e) { } catch (e) {
@ -67,5 +70,5 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
} }
} }
return { engine, deploymentMode, maxPageCount, loaded, error, isEnabled, load } return { engine, deploymentMode, maxPageCount, maxFileSizeMb, loaded, error, isEnabled, load }
}) })

View file

@ -94,9 +94,9 @@ const messages: Messages = {
// Upload // Upload
'upload.drop': 'Déposez un PDF ici ou cliquez pour importer', 'upload.drop': 'Déposez un PDF ici ou cliquez pour importer',
'upload.uploading': 'Import en cours...', 'upload.uploading': 'Import en cours...',
'upload.maxSize': 'Max 5Mo', 'upload.maxSize': 'Max {n}Mo',
'upload.invalidFormat': 'Format invalide — seuls les fichiers PDF sont acceptés.', 'upload.invalidFormat': 'Format invalide — seuls les fichiers PDF sont acceptés.',
'upload.tooLarge': 'Fichier trop volumineux (max 5 Mo).', 'upload.tooLarge': 'Fichier trop volumineux (max {n} Mo).',
'upload.maxPages': 'Max {n} pages', 'upload.maxPages': 'Max {n} pages',
// History // History
@ -135,7 +135,7 @@ const messages: Messages = {
// Disclaimer // Disclaimer
'disclaimer.banner': 'disclaimer.banner':
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max 5 Mo). Ne pas envoyer de fichiers confidentiels.', 'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
}, },
en: { en: {
'nav.home': 'Home', 'nav.home': 'Home',
@ -218,9 +218,9 @@ const messages: Messages = {
'upload.drop': 'Drop a PDF here or click to upload', 'upload.drop': 'Drop a PDF here or click to upload',
'upload.uploading': 'Uploading...', 'upload.uploading': 'Uploading...',
'upload.maxSize': 'Max 5MB', 'upload.maxSize': 'Max {n}MB',
'upload.invalidFormat': 'Invalid format — only PDF files are accepted.', 'upload.invalidFormat': 'Invalid format — only PDF files are accepted.',
'upload.tooLarge': 'File too large (max 5 MB).', 'upload.tooLarge': 'File too large (max {n} MB).',
'upload.maxPages': 'Max {n} pages', 'upload.maxPages': 'Max {n} pages',
'history.title': 'History', 'history.title': 'History',
@ -255,7 +255,7 @@ const messages: Messages = {
// Disclaimer // Disclaimer
'disclaimer.banner': 'disclaimer.banner':
'Demo instance \u2014 uploaded documents are shared and temporary (max 5 MB). Do not upload confidential files.', 'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',
}, },
} }