Merge pull request #118 from scub-france/feature/configurable-max-file-size

feat: make document max size configurable via MAX_FILE_SIZE_MB
This commit is contained in:
Pier-Jean Malandrino 2026-04-08 10:29:58 +02:00 committed by GitHub
commit ce639d934b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 81 additions and 23 deletions

View file

@ -15,6 +15,9 @@
# Max parallel analysis jobs (default: 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_PAGE_COUNT=0

View file

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

View file

@ -20,6 +20,7 @@ class Settings:
default_table_mode: str = "accurate" # "accurate" or "fast"
max_page_count: int = 0 # 0 = unlimited (upload validation)
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
upload_dir: str = "./uploads"
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})")
if self.max_file_size < 0:
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:
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
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"),
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_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
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:
result["maxPageCount"] = settings.max_page_count
if settings.max_file_size_mb > 0:
result["maxFileSizeMb"] = settings.max_file_size_mb
return result

View file

@ -16,7 +16,7 @@ from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__)
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
@ -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.
"""
if len(file_content) > MAX_FILE_SIZE:
raise ValueError("File too large (max 5 MB)")
if MAX_FILE_SIZE > 0 and len(file_content) > MAX_FILE_SIZE:
raise ValueError(f"File too large (max {settings.max_file_size_mb} MB)")
if not file_content[:4].startswith(_PDF_MAGIC):
raise ValueError("Invalid file: not a PDF document")

View file

@ -33,6 +33,12 @@ class TestHealthEndpoint:
assert "engine" 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:
@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.lock_timeout == 300
assert s.max_page_count == 0
assert s.max_file_size_mb == 50
assert s.batch_page_size == 0
assert s.upload_dir == "./uploads"
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"):
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):
import pytest
@ -129,6 +144,7 @@ class TestSettingsFromEnv:
monkeypatch.setenv("DOCUMENT_TIMEOUT", "60.0")
monkeypatch.setenv("LOCK_TIMEOUT", "600")
monkeypatch.setenv("MAX_PAGE_COUNT", "20")
monkeypatch.setenv("MAX_FILE_SIZE_MB", "100")
monkeypatch.setenv("BATCH_PAGE_SIZE", "15")
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
monkeypatch.setenv("DB_PATH", "/data/test.db")
@ -145,6 +161,7 @@ class TestSettingsFromEnv:
assert s.lock_timeout == 600
assert s.document_timeout == 60.0
assert s.max_page_count == 20
assert s.max_file_size_mb == 100
assert s.batch_page_size == 15
assert s.upload_dir == "/data/uploads"
assert s.db_path == "/data/test.db"

View file

@ -30,7 +30,7 @@
</header>
<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">
&times;
</button>
@ -52,9 +52,11 @@ import { AppSidebar } from '../shared/ui/index'
import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store'
import { useFeatureFlag } from '../features/feature-flags'
import { useFeatureFlagStore } from '../features/feature-flags/store'
import { useI18n } from '../shared/i18n'
useSettingsStore()
const flagStore = useFeatureFlagStore()
const { t } = useI18n()
const router = useRouter()
const documentStore = useDocumentStore()

View file

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

View file

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

View file

@ -68,6 +68,20 @@ describe('useFeatureFlagStore', () => {
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 () => {
mockApiFetch.mockRejectedValue(new Error('Network error'))
const store = useFeatureFlagStore()

View file

@ -10,6 +10,7 @@ interface HealthResponse {
engine: ConversionEngine
deploymentMode?: DeploymentMode
maxPageCount?: number
maxFileSizeMb?: number
}
export type FeatureFlag = 'chunking' | 'disclaimer'
@ -39,6 +40,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
const engine = ref<ConversionEngine | null>(null)
const deploymentMode = ref<DeploymentMode | null>(null)
const maxPageCount = ref<number>(0)
const maxFileSizeMb = ref<number>(0)
const loaded = ref(false)
const error = ref<string | null>(null)
@ -59,6 +61,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
engine.value = data.engine
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
maxPageCount.value = data.maxPageCount ?? 0
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
loaded.value = true
error.value = null
} 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.drop': 'Déposez un PDF ici ou cliquez pour importer',
'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.tooLarge': 'Fichier trop volumineux (max 5 Mo).',
'upload.tooLarge': 'Fichier trop volumineux (max {n} Mo).',
'upload.maxPages': 'Max {n} pages',
// History
@ -135,7 +135,7 @@ const messages: Messages = {
// Disclaimer
'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: {
'nav.home': 'Home',
@ -218,9 +218,9 @@ const messages: Messages = {
'upload.drop': 'Drop a PDF here or click to upload',
'upload.uploading': 'Uploading...',
'upload.maxSize': 'Max 5MB',
'upload.maxSize': 'Max {n}MB',
'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',
'history.title': 'History',
@ -255,7 +255,7 @@ const messages: Messages = {
// Disclaimer
'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.',
},
}