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:
commit
ce639d934b
13 changed files with 81 additions and 23 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
×
|
||||
</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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue