diff --git a/.env.example b/.env.example index dbcdc9a..3534f11 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,15 @@ # DOCLING_SERVE_URL=http://localhost:5001 # DOCLING_SERVE_API_KEY= -# Max seconds per conversion (default: 600) -# CONVERSION_TIMEOUT=600 +# Max seconds per conversion (default: 900) +# CONVERSION_TIMEOUT=900 # Max parallel analysis jobs (default: 3) # MAX_CONCURRENT_ANALYSES=3 +# Max pages per PDF (default: 0 = unlimited). Set to 20 for HF Spaces. +# MAX_PAGE_COUNT=0 + # Deployment mode: "self-hosted" (default) or "huggingface" # Shows disclaimer banner when set to "huggingface" # DEPLOYMENT_MODE=self-hosted diff --git a/document-parser/api/documents.py b/document-parser/api/documents.py index 76b5c9f..f36fa81 100644 --- a/document-parser/api/documents.py +++ b/document-parser/api/documents.py @@ -35,7 +35,7 @@ async def upload(file: UploadFile) -> DocumentResponse: # 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 50 MB)") + raise HTTPException(status_code=413, detail="File too large (max 5 MB)") # Read in chunks to avoid holding the full upload in a single allocation chunks: list[bytes] = [] @@ -43,7 +43,7 @@ async def upload(file: UploadFile) -> DocumentResponse: 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 50 MB)") + raise HTTPException(status_code=413, detail="File too large (max 5 MB)") chunks.append(chunk) content = b"".join(chunks) diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index 4f15384..13ed454 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -13,8 +13,9 @@ class Settings: deployment_mode: str = "self-hosted" # "self-hosted" or "huggingface" docling_serve_url: str = "http://localhost:5001" docling_serve_api_key: str | None = None - conversion_timeout: int = 600 + conversion_timeout: int = 900 max_concurrent_analyses: int = 3 + max_page_count: int = 0 # 0 = unlimited upload_dir: str = "./uploads" db_path: str = "./data/docling_studio.db" cors_origins: list[str] = field( @@ -31,8 +32,9 @@ class Settings: deployment_mode=os.environ.get("DEPLOYMENT_MODE", "self-hosted"), docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"), docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"), - conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "600")), + conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "900")), max_concurrent_analyses=int(os.environ.get("MAX_CONCURRENT_ANALYSES", "3")), + max_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")), upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"), db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"), cors_origins=[o.strip() for o in cors_raw.split(",")], diff --git a/document-parser/main.py b/document-parser/main.py index 957cada..a88dbff 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -113,10 +113,13 @@ async def health() -> dict[str, str]: logger.warning("Health check: database unreachable", exc_info=True) status = "ok" if db_status == "ok" else "degraded" - return { + result: dict[str, str | int] = { "status": status, "version": settings.app_version, "engine": settings.conversion_engine, "deploymentMode": settings.deployment_mode, "database": db_status, } + if settings.max_page_count > 0: + result["maxPageCount"] = settings.max_page_count + return result diff --git a/document-parser/services/document_service.py b/document-parser/services/document_service.py index d54ebfb..a0d9479 100644 --- a/document-parser/services/document_service.py +++ b/document-parser/services/document_service.py @@ -16,7 +16,8 @@ from persistence import analysis_repo, document_repo logger = logging.getLogger(__name__) UPLOAD_DIR = settings.upload_dir -MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB +MAX_PAGE_COUNT = settings.max_page_count # 0 = unlimited # PDF magic bytes: %PDF @@ -32,7 +33,7 @@ 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 50 MB)") + raise ValueError("File too large (max 5 MB)") if not file_content[:4].startswith(_PDF_MAGIC): raise ValueError("Invalid file: not a PDF document") @@ -51,6 +52,10 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum # Count PDF pages page_count = _count_pages(file_content) + if MAX_PAGE_COUNT > 0 and page_count is not None and page_count > MAX_PAGE_COUNT: + os.unlink(file_path) + raise ValueError(f"Too many pages ({page_count}). Maximum allowed: {MAX_PAGE_COUNT}") + doc = Document( filename=filename, content_type=content_type, diff --git a/document-parser/tests/test_api_endpoints.py b/document-parser/tests/test_api_endpoints.py index 6eae888..11b7c4e 100644 --- a/document-parser/tests/test_api_endpoints.py +++ b/document-parser/tests/test_api_endpoints.py @@ -97,7 +97,7 @@ class TestDocumentEndpoints: @patch("services.document_service.upload", new_callable=AsyncMock) def test_upload_too_large(self, mock_upload, client): - mock_upload.side_effect = ValueError("File too large (max 50 MB)") + mock_upload.side_effect = ValueError("File too large (max 5 MB)") resp = client.post( "/api/documents/upload", diff --git a/document-parser/tests/test_document_service.py b/document-parser/tests/test_document_service.py index 10c9055..b385357 100644 --- a/document-parser/tests/test_document_service.py +++ b/document-parser/tests/test_document_service.py @@ -24,6 +24,50 @@ class TestUploadValidation: with pytest.raises(ValueError, match="not a PDF"): await document_service.upload("fake.pdf", "application/pdf", content) + @pytest.mark.asyncio + async def test_rejects_too_many_pages(self, tmp_path, monkeypatch): + monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path)) + monkeypatch.setattr(document_service, "MAX_PAGE_COUNT", 20) + + with patch.object(document_service, "_count_pages", return_value=40): + content = b"%PDF-1.4 fake pdf content" + with pytest.raises(ValueError, match="Too many pages"): + await document_service.upload("big.pdf", "application/pdf", content) + + # Verify temp file was cleaned up + assert len(os.listdir(tmp_path)) == 0 + + @pytest.mark.asyncio + async def test_allows_pdf_under_page_limit(self, tmp_path, monkeypatch): + monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path)) + monkeypatch.setattr(document_service, "MAX_PAGE_COUNT", 20) + + mock_insert = AsyncMock() + with ( + patch("persistence.document_repo.insert", mock_insert), + patch.object(document_service, "_count_pages", return_value=15), + ): + content = b"%PDF-1.4 fake pdf content" + doc = await document_service.upload("ok.pdf", "application/pdf", content) + + assert doc.page_count == 15 + mock_insert.assert_called_once() + + @pytest.mark.asyncio + async def test_unlimited_pages_when_zero(self, tmp_path, monkeypatch): + monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path)) + monkeypatch.setattr(document_service, "MAX_PAGE_COUNT", 0) + + mock_insert = AsyncMock() + with ( + patch("persistence.document_repo.insert", mock_insert), + patch.object(document_service, "_count_pages", return_value=100), + ): + content = b"%PDF-1.4 fake pdf content" + doc = await document_service.upload("big.pdf", "application/pdf", content) + + assert doc.page_count == 100 + @pytest.mark.asyncio async def test_accepts_valid_pdf(self, tmp_path, monkeypatch): monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path)) diff --git a/document-parser/tests/test_settings.py b/document-parser/tests/test_settings.py index ffe6a2a..a6f600c 100644 --- a/document-parser/tests/test_settings.py +++ b/document-parser/tests/test_settings.py @@ -13,7 +13,8 @@ class TestSettingsDefaults: assert s.deployment_mode == "self-hosted" assert s.docling_serve_url == "http://localhost:5001" assert s.docling_serve_api_key is None - assert s.conversion_timeout == 600 + assert s.conversion_timeout == 900 + assert s.max_page_count == 0 assert s.upload_dir == "./uploads" assert s.db_path == "./data/docling_studio.db" assert "http://localhost:3000" in s.cors_origins @@ -35,6 +36,7 @@ class TestSettingsFromEnv: monkeypatch.setenv("DOCLING_SERVE_URL", "http://serve:9000") monkeypatch.setenv("DOCLING_SERVE_API_KEY", "secret-key") monkeypatch.setenv("CONVERSION_TIMEOUT", "120") + monkeypatch.setenv("MAX_PAGE_COUNT", "20") monkeypatch.setenv("UPLOAD_DIR", "/data/uploads") monkeypatch.setenv("DB_PATH", "/data/test.db") monkeypatch.setenv("CORS_ORIGINS", "http://a.com, http://b.com") @@ -47,6 +49,7 @@ class TestSettingsFromEnv: assert s.docling_serve_url == "http://serve:9000" assert s.docling_serve_api_key == "secret-key" assert s.conversion_timeout == 120 + assert s.max_page_count == 20 assert s.upload_dir == "/data/uploads" assert s.db_path == "/data/test.db" assert s.cors_origins == ["http://a.com", "http://b.com"] @@ -60,6 +63,7 @@ class TestSettingsFromEnv: "DOCLING_SERVE_URL", "DOCLING_SERVE_API_KEY", "CONVERSION_TIMEOUT", + "MAX_PAGE_COUNT", "UPLOAD_DIR", "DB_PATH", "CORS_ORIGINS", @@ -70,7 +74,8 @@ class TestSettingsFromEnv: assert s.app_version == "dev" assert s.conversion_engine == "local" - assert s.conversion_timeout == 600 + assert s.conversion_timeout == 900 + assert s.max_page_count == 0 def test_cors_origins_split(self, monkeypatch): monkeypatch.setenv("CORS_ORIGINS", "http://a.com,http://b.com,http://c.com") diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e6bc5de..d6b58a4 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -18,8 +18,8 @@ server { proxy_pass http://document-parser:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - client_max_body_size 50M; + proxy_read_timeout 900s; + proxy_send_timeout 900s; + client_max_body_size 5M; } } diff --git a/frontend/src/features/document/store.ts b/frontend/src/features/document/store.ts index cff9f48..fcd9992 100644 --- a/frontend/src/features/document/store.ts +++ b/frontend/src/features/document/store.ts @@ -3,7 +3,7 @@ import { ref } from 'vue' import type { Document } from '../../shared/types' import * as api from './api' -const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB export const useDocumentStore = defineStore('document', () => { const documents = ref([]) @@ -27,7 +27,7 @@ export const useDocumentStore = defineStore('document', () => { async function upload(file: File): Promise { if (file.size > MAX_FILE_SIZE) { - error.value = 'File too large (max 50 MB)' + error.value = 'File too large (max 5 MB)' throw new Error(error.value) } uploading.value = true diff --git a/frontend/src/features/document/ui/DocumentUpload.vue b/frontend/src/features/document/ui/DocumentUpload.vue index c5a5d0c..8f34ce5 100644 --- a/frontend/src/features/document/ui/DocumentUpload.vue +++ b/frontend/src/features/document/ui/DocumentUpload.vue @@ -23,55 +23,67 @@ {{ t('upload.drop') }} - {{ t('upload.maxSize') }} + {{ uploadHint }} {{ store.error }} diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts index ac790a1..a063455 100644 --- a/frontend/src/features/feature-flags/store.ts +++ b/frontend/src/features/feature-flags/store.ts @@ -9,6 +9,7 @@ interface HealthResponse { status: string engine: ConversionEngine deploymentMode?: DeploymentMode + maxPageCount?: number } export type FeatureFlag = 'chunking' | 'disclaimer' @@ -37,6 +38,7 @@ const featureRegistry: Record = { export const useFeatureFlagStore = defineStore('feature-flags', () => { const engine = ref(null) const deploymentMode = ref(null) + const maxPageCount = ref(0) const loaded = ref(false) const error = ref(null) @@ -56,6 +58,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { const data = await apiFetch('/api/health') engine.value = data.engine deploymentMode.value = data.deploymentMode ?? 'self-hosted' + maxPageCount.value = data.maxPageCount ?? 0 loaded.value = true error.value = null } catch (e) { @@ -64,5 +67,5 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { } } - return { engine, deploymentMode, loaded, error, isEnabled, load } + return { engine, deploymentMode, maxPageCount, loaded, error, isEnabled, load } }) diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index d2b2ab0..f715537 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -94,7 +94,10 @@ const messages: Messages = { // Upload 'upload.drop': 'Déposez un PDF ici ou cliquez pour importer', 'upload.uploading': 'Import en cours...', - 'upload.maxSize': 'Max 50Mo', + 'upload.maxSize': 'Max 5Mo', + 'upload.invalidFormat': 'Format invalide — seuls les fichiers PDF sont acceptés.', + 'upload.tooLarge': 'Fichier trop volumineux (max 5 Mo).', + 'upload.maxPages': 'Max {n} pages', // History 'history.title': 'Historique', @@ -132,7 +135,7 @@ const messages: Messages = { // Disclaimer 'disclaimer.banner': - 'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max 50 Mo). Ne pas envoyer de fichiers confidentiels.', + 'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max 5 Mo). Ne pas envoyer de fichiers confidentiels.', }, en: { 'nav.home': 'Home', @@ -215,7 +218,10 @@ const messages: Messages = { 'upload.drop': 'Drop a PDF here or click to upload', 'upload.uploading': 'Uploading...', - 'upload.maxSize': 'Max 50MB', + 'upload.maxSize': 'Max 5MB', + 'upload.invalidFormat': 'Invalid format — only PDF files are accepted.', + 'upload.tooLarge': 'File too large (max 5 MB).', + 'upload.maxPages': 'Max {n} pages', 'history.title': 'History', 'history.tabAnalyses': 'Analyses', @@ -249,7 +255,7 @@ const messages: Messages = { // Disclaimer 'disclaimer.banner': - 'Demo instance \u2014 uploaded documents are shared and temporary (max 50 MB). Do not upload confidential files.', + 'Demo instance \u2014 uploaded documents are shared and temporary (max 5 MB). Do not upload confidential files.', }, } diff --git a/nginx.conf b/nginx.conf index 174939d..20aa914 100644 --- a/nginx.conf +++ b/nginx.conf @@ -18,8 +18,8 @@ server { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - client_max_body_size 50M; + proxy_read_timeout 900s; + proxy_send_timeout 900s; + client_max_body_size 5M; } }