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