feat(upload): adaptive 429 backoff, full Gotenberg file types, directory traversal

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-26 23:24:26 +00:00
parent 35d2ed05e9
commit a05690bd5f
10 changed files with 764 additions and 369 deletions
+6
View File
@@ -16,6 +16,12 @@ ALLOW_FILE_DELETE=true # Allow deletion of file records
PROCESSALL_THROTTLE_THRESHOLD=20 # Number of files above which throttling is applied (default: 20)
PROCESSALL_THROTTLE_DELAY=3 # Delay in seconds between each task submission when throttling (default: 3)
# **Client-Side Upload Throttling**
# Controls pacing when the browser uploads files (especially large directory drops).
# The browser auto-detects rate-limit (HTTP 429) responses and backs off accordingly.
UPLOAD_CONCURRENCY=3 # Max simultaneous uploads from the browser (default: 3)
UPLOAD_QUEUE_DELAY_MS=500 # Delay (ms) between starting each upload slot (default: 500)
# **File Upload Size Limits** (Security - see SECURITY_AUDIT.md)
# Maximum file upload size in bytes. Default: 1GB (1073741824 bytes)
# Prevents resource exhaustion attacks. Adjust based on your server capacity.
+15 -49
View File
@@ -18,6 +18,7 @@ from app.database import get_db
from app.models import FileRecord, ProcessingLog
from app.tasks.convert_to_pdf import convert_to_pdf
from app.tasks.process_document import process_document
from app.utils.allowed_types import ALLOWED_EXTENSIONS, ALLOWED_MIME_TYPES, IMAGE_MIME_TYPES
from app.utils.file_queries import apply_status_filter
from app.utils.file_status import get_files_processing_status
from app.utils.filename_utils import sanitize_filename
@@ -1034,33 +1035,6 @@ async def ui_upload(request: Request, file: UploadFile = File(...)):
logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
file_size = written_size
# Same set of allowed file types as in the IMAP task
ALLOWED_MIME_TYPES = {
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/csv",
"application/rtf",
"text/rtf",
}
# Image MIME types that need conversion
IMAGE_MIME_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/bmp",
"image/tiff",
"image/webp",
"image/svg+xml",
}
# Determine if the file is a PDF or needs conversion
mime_type, _ = mimetypes.guess_type(target_path)
file_ext = os.path.splitext(target_path)[1].lower()
@@ -1114,32 +1088,24 @@ async def ui_upload(request: Request, file: UploadFile = File(...)):
# If it's a PDF, process directly
task = process_document.delay(target_path, original_filename=safe_filename)
logger.info(f"Enqueued PDF for processing: {target_path}")
elif mime_type in IMAGE_MIME_TYPES or any(
file_ext.endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".svg"]
):
elif mime_type in IMAGE_MIME_TYPES or file_ext in {
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".tiff",
".tif",
".webp",
".svg",
}:
# If it's an image, convert to PDF first
task = convert_to_pdf.delay(target_path, original_filename=safe_filename)
logger.info(f"Enqueued image for PDF conversion: {target_path}")
elif mime_type in ALLOWED_MIME_TYPES or any(
file_ext.endswith(ext)
for ext in [
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".rtf",
".txt",
".csv",
]
):
# If it's an office document, convert to PDF first
elif mime_type in ALLOWED_MIME_TYPES or file_ext in ALLOWED_EXTENSIONS:
# Office document, HTML, Markdown, or other Gotenberg-supported format
task = convert_to_pdf.delay(target_path, original_filename=safe_filename)
logger.info(f"Enqueued office document for PDF conversion: {target_path}")
logger.info(f"Enqueued document for PDF conversion: {target_path}")
else:
# For any other file type, attempt conversion but log a warning
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
+4 -29
View File
@@ -17,6 +17,7 @@ from pydantic import BaseModel, HttpUrl, field_validator
from app.auth import require_login
from app.config import settings
from app.tasks.process_document import process_document
from app.utils.allowed_types import ALLOWED_MIME_TYPES
from app.utils.filename_utils import sanitize_filename
# Set up logging
@@ -108,7 +109,7 @@ def validate_url_safety(url: str) -> None:
def validate_file_type(content_type: str, filename: str) -> bool:
"""
Validate that the file type is supported.
Validate that the file type is supported (i.e. processable by Gotenberg).
Args:
content_type: MIME type from response headers
@@ -117,44 +118,18 @@ def validate_file_type(content_type: str, filename: str) -> bool:
Returns:
True if file type is allowed
"""
# Same allowed types as regular upload
ALLOWED_MIME_TYPES = {
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/csv",
"application/rtf",
"text/rtf",
}
IMAGE_MIME_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/bmp",
"image/tiff",
"image/webp",
"image/svg+xml",
}
# Check content type from header
if content_type:
# Handle content-type with charset (e.g., "application/pdf; charset=utf-8")
base_content_type = content_type.split(";", maxsplit=1)[0].strip().lower()
if base_content_type in ALLOWED_MIME_TYPES or base_content_type in IMAGE_MIME_TYPES:
if base_content_type in ALLOWED_MIME_TYPES:
return True
# Also check by extension as fallback
_, ext = os.path.splitext(filename)
if ext:
guessed_type, _ = mimetypes.guess_type(filename)
if guessed_type and (guessed_type in ALLOWED_MIME_TYPES or guessed_type in IMAGE_MIME_TYPES):
if guessed_type and guessed_type in ALLOWED_MIME_TYPES:
return True
return False
+4 -16
View File
@@ -13,6 +13,7 @@ from celery import shared_task
from app.config import settings
from app.tasks.convert_to_pdf import convert_to_pdf # new conversion task
from app.tasks.process_document import process_document # Updated import
from app.utils.allowed_types import ALLOWED_EXTENSIONS, ALLOWED_MIME_TYPES
logger = logging.getLogger(__name__)
@@ -268,20 +269,6 @@ def fetch_attachments_and_enqueue(email_message):
Returns True if at least one allowed attachment was processed.
"""
ALLOWED_MIME_TYPES = {
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/csv",
"application/rtf",
"text/rtf",
}
has_attachment = False
for part in email_message.walk():
if part.get_content_maintype() == "multipart":
@@ -295,8 +282,9 @@ def fetch_attachments_and_enqueue(email_message):
is_pdf_by_extension = filename.lower().endswith(".pdf")
mime_type = part.get_content_type()
# Accept file if it has an allowed MIME type OR it's a PDF by extension
if mime_type not in ALLOWED_MIME_TYPES and not is_pdf_by_extension:
file_ext = os.path.splitext(filename)[1].lower()
# Accept file if it has an allowed MIME type, an allowed extension, OR is a PDF by extension
if mime_type not in ALLOWED_MIME_TYPES and file_ext not in ALLOWED_EXTENSIONS and not is_pdf_by_extension:
logger.info("Skipping attachment %s with MIME type %s", filename, mime_type)
continue
+133
View File
@@ -0,0 +1,133 @@
"""
Canonical file-type lists for DocuElevate uploads.
All file types listed here are supported by Gotenberg (the PDF-conversion service
used by DocuElevate) via its LibreOffice, Chromium, or Markdown routes. This
module is the single source of truth consumed by:
- app/api/files.py (ui-upload endpoint)
- app/api/url_upload.py (URL-upload endpoint)
- app/tasks/imap_tasks.py (IMAP email-attachment ingestion)
- frontend/static/js/upload.js (client-side validation mirror)
Keep in sync with the OFFICE_EXTENSIONS / IMAGE_EXTENSIONS sets defined in
app/tasks/convert_to_pdf.py.
"""
# ---------------------------------------------------------------------------
# Document / office MIME types (converted via Gotenberg LibreOffice route)
# ---------------------------------------------------------------------------
DOCUMENT_MIME_TYPES: set[str] = {
# PDF
"application/pdf",
# Word
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
"application/vnd.ms-word.document.macroEnabled.12",
"application/vnd.ms-word.template.macroEnabled.12",
# Excel
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template",
"application/vnd.ms-excel.sheet.macroEnabled.12",
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
# PowerPoint
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.presentationml.template",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
# OpenDocument (LibreOffice native)
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.graphics",
"application/vnd.oasis.opendocument.formula",
# Plain text / data
"text/plain",
"text/csv",
"application/rtf",
"text/rtf",
# HTML (converted via Gotenberg Chromium route)
"text/html",
# Markdown (converted via Gotenberg Chromium/Markdown route)
"text/markdown",
"text/x-markdown",
}
# ---------------------------------------------------------------------------
# Image MIME types (converted via Gotenberg LibreOffice route)
# ---------------------------------------------------------------------------
IMAGE_MIME_TYPES: set[str] = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/bmp",
"image/tiff",
"image/webp",
"image/svg+xml",
}
# ---------------------------------------------------------------------------
# Combined set every MIME type accepted by the upload endpoints
# ---------------------------------------------------------------------------
ALLOWED_MIME_TYPES: set[str] = DOCUMENT_MIME_TYPES | IMAGE_MIME_TYPES
# ---------------------------------------------------------------------------
# File extensions (lower-case, with leading dot) accepted by Gotenberg
# ---------------------------------------------------------------------------
ALLOWED_EXTENSIONS: set[str] = {
# PDF
".pdf",
# Word
".doc",
".docx",
".docm",
".dot",
".dotx",
".dotm",
# Excel
".xls",
".xlsx",
".xlsm",
".xlsb",
".xlt",
".xltx",
".xlw",
# PowerPoint
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
# OpenDocument
".odt",
".ods",
".odp",
".odg",
".odf",
# Text / data
".rtf",
".txt",
".csv",
# Images
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".tiff",
".tif",
".webp",
".svg",
# Web
".html",
".htm",
# Markdown
".md",
".markdown",
}
+6 -24
View File
@@ -17,26 +17,6 @@ router = APIRouter()
_FILE_NOT_FOUND = "File not found"
def _get_upload_concurrency() -> int:
"""Return the configured upload concurrency (falls back to default on error)."""
try:
from app.config import settings
return settings.upload_concurrency
except Exception:
return 3
def _get_upload_queue_delay_ms() -> int:
"""Return the configured upload queue delay in ms (falls back to default on error)."""
try:
from app.config import settings
return settings.upload_queue_delay_ms
except Exception:
return 500
@router.get("/files")
@require_login
def files_page(
@@ -53,6 +33,8 @@ def files_page(
"""
Return the 'files.html' template with server-side pagination, sorting, and filtering
"""
from app.config import settings
try:
# Import the model here to avoid circular imports
from sqlalchemy import asc, desc
@@ -131,8 +113,8 @@ def files_page(
"mime_type": mime_type or "",
"status": status or "",
"mime_types": mime_types,
"upload_concurrency": _get_upload_concurrency(),
"upload_queue_delay_ms": _get_upload_queue_delay_ms(),
"upload_concurrency": settings.upload_concurrency,
"upload_queue_delay_ms": settings.upload_queue_delay_ms,
},
)
except Exception as e:
@@ -146,8 +128,8 @@ def files_page(
"files": [],
"pagination": {"page": 1, "per_page": per_page, "total_items": 0, "total_pages": 0},
"error": str(e),
"upload_concurrency": _get_upload_concurrency(),
"upload_queue_delay_ms": _get_upload_queue_delay_ms(),
"upload_concurrency": settings.upload_concurrency,
"upload_queue_delay_ms": settings.upload_queue_delay_ms,
},
)
+13
View File
@@ -31,6 +31,19 @@ Control how the `/processall` endpoint handles large batches of files to prevent
- Total queue time: (25-1) × 3 = 72 seconds
- Prevents API rate limit issues and ensures smooth processing
### Client-Side Upload Throttling
Control how the web UI queues and paces file uploads to avoid overwhelming the backend, especially when dragging large directories (potentially thousands of files) onto the upload area.
| **Variable** | **Description** | **Default** |
|----------------------------|-------------------------------------------------------------------------------------------------------------------------------|-------------|
| `UPLOAD_CONCURRENCY` | Maximum number of files uploaded simultaneously from the browser. | `3` |
| `UPLOAD_QUEUE_DELAY_MS` | Delay in milliseconds between starting each upload slot. Staggers upload starts to smooth out server load. | `500` |
**Adaptive back-off**: The browser automatically slows down if the server responds with HTTP 429 (Too Many Requests). It reads the `Retry-After` header, pauses the queue for the indicated time, doubles the inter-slot delay (exponential back-off, capped at 30 s), and reduces concurrency to 1. After 5 consecutive successes it gradually recovers toward the configured values.
**Example**: With `UPLOAD_CONCURRENCY=3` and `UPLOAD_QUEUE_DELAY_MS=500`, a directory of 5,000 files is uploaded ≈ 3 at a time with 500 ms pacing the backend processes files at its own rate while the queue drains in the background without triggering API rate limits.
### File Upload Size Limits
**Security Feature**: Control file upload sizes to prevent resource exhaustion attacks. See [SECURITY_AUDIT.md](../SECURITY_AUDIT.md#5-file-upload-size-limits) for security details.
+359 -245
View File
@@ -1,65 +1,179 @@
// frontend/static/js/upload.js
// Reusable drag-and-drop upload functionality for DocuElevate
// Configuration
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
// ── Configuration ─────────────────────────────────────────────────────────────
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
// Upload throttling defaults (overridden by window.uploadConfig when available)
/** Fallback upload throttling values when window.uploadConfig is not set. */
const DEFAULT_UPLOAD_CONCURRENCY = 3;
const DEFAULT_UPLOAD_QUEUE_DELAY_MS = 500;
// Allowed file types
/** Maximum number of 429 retries before a file is permanently marked failed. */
const MAX_RATE_LIMIT_RETRIES = 5;
// ── Accepted MIME types (mirrors app/utils/allowed_types.py) ─────────────────
// All types processable by Gotenberg (LibreOffice, Chromium, or Markdown routes).
const ACCEPTED_TYPES = {
// PDF files
// PDF
'application/pdf': true,
// Image formats
'image/jpeg': true, 'image/jpg': true, 'image/png': true,
'image/gif': true, 'image/bmp': true, 'image/tiff': true,
'image/webp': true, 'image/svg+xml': true,
// Office document formats - Word
// Word
'application/msword': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true,
'application/vnd.ms-word.document.macroEnabled.12': true,
'application/vnd.ms-word.template.macroEnabled.12': true,
// Excel
'application/vnd.ms-excel': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true,
'application/vnd.ms-excel.sheet.macroEnabled.12': true,
'application/vnd.ms-excel.sheet.binary.macroEnabled.12': true,
// PowerPoint
'application/vnd.ms-powerpoint': true,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': true,
'application/vnd.openxmlformats-officedocument.presentationml.template': true,
'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true,
// Other common formats
'application/vnd.ms-powerpoint.presentation.macroEnabled.12': true,
// OpenDocument (LibreOffice native)
'application/vnd.oasis.opendocument.text': true,
'application/vnd.oasis.opendocument.spreadsheet': true,
'application/vnd.oasis.opendocument.presentation': true,
'application/vnd.oasis.opendocument.graphics': true,
'application/vnd.oasis.opendocument.formula': true,
// Images
'image/jpeg': true, 'image/jpg': true, 'image/png': true,
'image/gif': true, 'image/bmp': true, 'image/tiff': true,
'image/webp': true, 'image/svg+xml': true,
// Text / data
'text/plain': true,
'text/csv': true,
'application/rtf': true,
'text/rtf': true,
// HTML (Gotenberg Chromium route)
'text/html': true,
'application/xml': true,
'text/xml': true
// Markdown (Gotenberg Chromium/Markdown route)
'text/markdown': true,
'text/x-markdown': true,
};
// File extensions that are always allowed (even if mime type is not recognized)
const ACCEPTED_EXTENSIONS = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.rtf', '.txt', '.csv',
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.md'
];
// File extensions always accepted even when the browser reports no / wrong MIME type.
const ACCEPTED_EXTENSIONS = new Set([
// PDF
'.pdf',
// Word
'.doc', '.docx', '.docm', '.dot', '.dotx', '.dotm',
// Excel
'.xls', '.xlsx', '.xlsm', '.xlsb', '.xlt', '.xltx', '.xlw',
// PowerPoint
'.ppt', '.pptx', '.pptm', '.pps', '.ppsx', '.pot', '.potx',
// OpenDocument
'.odt', '.ods', '.odp', '.odg', '.odf',
// Text / data
'.rtf', '.txt', '.csv',
// Images
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.svg',
// Web
'.html', '.htm',
// Markdown
'.md', '.markdown',
]);
// ---------------------------------------------------------------------------
// Directory traversal helpers (FileSystem Access API)
// ---------------------------------------------------------------------------
// ── Adaptive throttle state ───────────────────────────────────────────────────
// Module-level so the backoff state persists across multiple drop/select events
// on the same page (rate limits are per-user on the server).
const _adaptiveState = {
/** Current effective inter-slot delay (ms). null = use window.uploadConfig value. */
delayMs: null,
/** Current effective concurrency. null = use window.uploadConfig value. */
concurrency: null,
/** Consecutive successful uploads without a 429. Resets on each 429 or recovery step. */
consecutiveOk: 0,
/** Date.now() timestamp after which the queue may resume (set on 429 backoff). */
pauseUntil: 0,
};
function _cfgDelay() {
return (window.uploadConfig && window.uploadConfig.queueDelayMs != null)
? window.uploadConfig.queueDelayMs
: DEFAULT_UPLOAD_QUEUE_DELAY_MS;
}
function _cfgConcurrency() {
return (window.uploadConfig && window.uploadConfig.concurrency != null)
? window.uploadConfig.concurrency
: DEFAULT_UPLOAD_CONCURRENCY;
}
/** Effective delay between upload slot starts (increased during backoff). */
function _effectiveDelay() {
return _adaptiveState.delayMs !== null ? _adaptiveState.delayMs : _cfgDelay();
}
/** Fallback backoff multiplier when no Retry-After header is present. */
const FALLBACK_BACKOFF_MULTIPLIER = 4;
/** Minimum fallback pause duration (ms) when no Retry-After header is present. */
const MIN_FALLBACK_BACKOFF_MS = 5000;
/** Maximum inter-slot delay after repeated exponential backoff (ms). */
const MAX_BACKOFF_DELAY_MS = 30000;
/** Effective concurrency (reduced to 1 during backoff). */
function _effectiveConcurrency() {
return _adaptiveState.concurrency !== null ? _adaptiveState.concurrency : _cfgConcurrency();
}
/**
* Read all file entries from a DirectoryReader, handling the 100-entry limit
* by calling readEntries() repeatedly until it returns an empty batch.
* Called when an HTTP 429 Too Many Requests response is received.
* Pauses the queue and applies exponential backoff.
* @param {number} retryAfterSeconds - Value of the Retry-After header (0 = absent).
*/
function _onRateLimited(retryAfterSeconds) {
// Determine how long to pause prefer the server's Retry-After; fall back to
// FALLBACK_BACKOFF_MULTIPLIER × the current delay (minimum MIN_FALLBACK_BACKOFF_MS).
const waitMs = retryAfterSeconds > 0
? retryAfterSeconds * 1000
: Math.max(_effectiveDelay() * FALLBACK_BACKOFF_MULTIPLIER, MIN_FALLBACK_BACKOFF_MS);
_adaptiveState.pauseUntil = Date.now() + waitMs;
// Exponential backoff on the inter-slot delay, capped at MAX_BACKOFF_DELAY_MS.
_adaptiveState.delayMs = Math.min(_effectiveDelay() * 2, MAX_BACKOFF_DELAY_MS);
// Serialize uploads while we recover.
_adaptiveState.concurrency = 1;
_adaptiveState.consecutiveOk = 0;
console.warn(
`[DocuElevate] Rate limited. Pausing ${waitMs} ms. ` +
`New delay: ${_adaptiveState.delayMs} ms, concurrency: 1.`
);
}
/**
* Called after each successful (non-429) upload.
* After 5 consecutive successes, gently recovers toward the configured values.
*/
function _onUploadSuccess() {
_adaptiveState.consecutiveOk++;
if (_adaptiveState.consecutiveOk < 5) return;
// One recovery step every 5 successes.
_adaptiveState.consecutiveOk = 0;
const cfgDelay = _cfgDelay();
const cfgConc = _cfgConcurrency();
if (_adaptiveState.delayMs !== null && _adaptiveState.delayMs > cfgDelay) {
_adaptiveState.delayMs = Math.max(Math.round(_adaptiveState.delayMs * 0.75), cfgDelay);
if (_adaptiveState.delayMs <= cfgDelay) _adaptiveState.delayMs = null; // fully recovered
}
if (_adaptiveState.concurrency !== null && _adaptiveState.concurrency < cfgConc) {
_adaptiveState.concurrency = Math.min(_adaptiveState.concurrency + 1, cfgConc);
if (_adaptiveState.concurrency >= cfgConc) _adaptiveState.concurrency = null; // fully recovered
}
}
// ── Directory traversal helpers ───────────────────────────────────────────────
/**
* Read all entries from a DirectoryReader, handling the browser's 100-entry
* per-batch limit by calling readEntries() repeatedly.
* @param {FileSystemDirectoryReader} reader
* @returns {Promise<FileSystemEntry[]>}
*/
@@ -68,12 +182,9 @@ function readAllDirectoryEntries(reader) {
const entries = [];
function readBatch() {
reader.readEntries((batch) => {
if (batch.length === 0) {
resolve(entries);
} else {
entries.push(...batch);
readBatch();
}
if (batch.length === 0) { resolve(entries); return; }
entries.push(...batch);
readBatch();
}, reject);
}
readBatch();
@@ -83,7 +194,7 @@ function readAllDirectoryEntries(reader) {
/**
* Recursively collect all File objects from a FileSystemEntry tree.
* @param {FileSystemEntry} entry
* @param {File[]} files - accumulator array
* @param {File[]} files - accumulator
* @returns {Promise<void>}
*/
async function traverseFileEntry(entry, files) {
@@ -92,8 +203,7 @@ async function traverseFileEntry(entry, files) {
entry.file((file) => { files.push(file); resolve(); }, resolve);
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const subEntries = await readAllDirectoryEntries(reader);
const subEntries = await readAllDirectoryEntries(entry.createReader());
for (const sub of subEntries) {
await traverseFileEntry(sub, files);
}
@@ -102,18 +212,16 @@ async function traverseFileEntry(entry, files) {
/**
* Extract all File objects from a DataTransfer, recursively expanding any
* dropped directories. Falls back gracefully to dataTransfer.files when
* the FileSystem Entry API is unavailable.
* dropped directories. Falls back gracefully to dataTransfer.files when the
* FileSystem Entry API is unavailable (Safari < 11.1, some mobile browsers).
* @param {DataTransfer} dataTransfer
* @returns {Promise<File[]>}
*/
async function getFilesFromDataTransfer(dataTransfer) {
// Use the FileSystem Entry API when available (all modern browsers)
if (dataTransfer.items && dataTransfer.items.length > 0) {
const files = [];
const itemList = dataTransfer.items;
for (let i = 0; i < itemList.length; i++) {
const item = itemList[i];
for (let i = 0; i < dataTransfer.items.length; i++) {
const item = dataTransfer.items[i];
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
await traverseFileEntry(entry, files);
@@ -124,244 +232,261 @@ async function getFilesFromDataTransfer(dataTransfer) {
}
return files;
}
// Fallback: plain FileList (no directory support)
return Array.from(dataTransfer.files || []);
}
// ---------------------------------------------------------------------------
// Queue-based upload runner
// ---------------------------------------------------------------------------
// ── Core queue runner ─────────────────────────────────────────────────────────
/**
* Upload a list of files using a concurrency-limited queue with a configurable
* delay between slot starts to prevent server overload.
* Validate and queue files for upload with adaptive throttling.
*
* @param {File[]} files - Files to upload
* @param {HTMLElement} progressContainer - Container element for progress display
* @param {HTMLElement} statusMessage - Element for status message display
* Files are pre-rendered as progress rows so the user immediately sees the
* full list. The queue runner respects the current effective concurrency and
* delay, slowing down automatically when the server signals rate limiting (429).
*
* @param {File[]|FileList} files
* @param {HTMLElement} progressContainer
* @param {HTMLElement} statusMessage
*/
function processFiles(files, progressContainer, statusMessage) {
if (files.length === 0) return;
const concurrency = (window.uploadConfig && window.uploadConfig.concurrency) || DEFAULT_UPLOAD_CONCURRENCY;
const delayMs = (window.uploadConfig && window.uploadConfig.queueDelayMs) || DEFAULT_UPLOAD_QUEUE_DELAY_MS;
const fileArray = Array.from(files);
if (!fileArray.length) return;
if (statusMessage) {
statusMessage.textContent = `Queued ${files.length} file(s) for upload…`;
statusMessage.textContent = `Queued ${fileArray.length} file(s) for upload…`;
}
if (progressContainer) progressContainer.innerHTML = '';
// Clear previous upload progress
if (progressContainer) {
progressContainer.innerHTML = "";
}
// Pre-create all progress elements so the user sees the full list immediately
const progressElements = files.map((file) => {
const fileProgress = document.createElement("div");
fileProgress.className = "flex flex-col mb-2";
fileProgress.innerHTML = `
// Pre-create one progress row per file.
const queueItems = fileArray.map((file) => {
const row = document.createElement('div');
row.className = 'flex flex-col mb-2';
row.innerHTML = `
<div class="flex justify-between">
<span class="text-sm truncate" title="${file.name}">${file.name}</span>
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
<div class="file-progress-bar bg-gray-300 h-2 rounded-full" style="width: 0%"></div>
<div class="file-progress-bar bg-gray-300 h-2 rounded-full" style="width:0%"></div>
</div>
<div class="file-status text-xs text-gray-400 mt-1">Queued</div>
`;
if (progressContainer) progressContainer.appendChild(fileProgress);
return fileProgress;
if (progressContainer) progressContainer.appendChild(row);
return {
file,
progressBar: row.querySelector('.file-progress-bar'),
statusEl: row.querySelector('.file-status'),
retryCount: 0,
};
});
// Queue runner
let index = 0;
// Mutable queue rate-limited items are pushed back to the front.
const queue = [...queueItems];
let active = 0;
function startNext() {
while (active < concurrency && index < files.length) {
const i = index++;
function scheduleNext() {
// Respect global backoff pause.
const pauseRemaining = _adaptiveState.pauseUntil - Date.now();
if (pauseRemaining > 0) {
setTimeout(scheduleNext, pauseRemaining + 50);
return;
}
while (active < _effectiveConcurrency() && queue.length > 0) {
const item = queue.shift();
active++;
const progressBar = progressElements[i].querySelector(".file-progress-bar");
const statusEl = progressElements[i].querySelector(".file-status");
validateAndUploadQueued(files[i], progressBar, statusEl, statusMessage).finally(() => {
// Validate before hitting the network.
if (!_isAcceptedFile(item.file)) {
item.progressBar.className = 'file-progress-bar bg-red-500 h-2 rounded-full';
item.statusEl.textContent = 'Unsupported file type';
item.statusEl.className = 'text-xs text-red-500 mt-1';
active--;
setTimeout(startNext, delayMs);
});
updateOverallStatus(statusMessage);
// No HTTP request skip straight to next without adding delay.
scheduleNext();
continue;
}
if (item.file.size > MAX_FILE_SIZE) {
item.progressBar.className = 'file-progress-bar bg-red-500 h-2 rounded-full';
item.statusEl.textContent = 'Exceeds 500 MB limit';
item.statusEl.className = 'text-xs text-red-500 mt-1';
active--;
updateOverallStatus(statusMessage);
scheduleNext();
continue;
}
_uploadSingleFile(item.file, item.progressBar, item.statusEl, statusMessage)
.then((result) => {
active--;
if (result.rateLimited) {
_onRateLimited(result.retryAfterSeconds);
item.retryCount++;
if (item.retryCount < MAX_RATE_LIMIT_RETRIES) {
// Re-insert at the front of the queue to retry after the pause.
queue.unshift(item);
} else {
item.progressBar.className = 'file-progress-bar bg-red-500 h-2 rounded-full';
item.statusEl.textContent = 'Failed: rate limit retries exhausted';
item.statusEl.className = 'text-xs text-red-500 mt-1';
updateOverallStatus(statusMessage);
}
// Resume after the backoff window.
const wait = Math.max(_adaptiveState.pauseUntil - Date.now() + 50, 0);
setTimeout(scheduleNext, wait);
} else {
// Success or permanent error wait the configured delay before next slot.
setTimeout(scheduleNext, _effectiveDelay());
}
});
}
}
startNext();
scheduleNext();
}
/**
* Validate and upload a single file (used by the queue runner).
* Check whether a file passes MIME type and extension validation.
* @param {File} file
* @returns {boolean}
*/
function _isAcceptedFile(file) {
if (ACCEPTED_TYPES[file.type]) return true;
const ext = '.' + file.name.split('.').pop().toLowerCase();
return ACCEPTED_EXTENSIONS.has(ext);
}
/**
* Upload a single file via XHR, returning a structured result.
* Detects HTTP 429 responses and reads the Retry-After / X-RateLimit-Reset
* headers so the caller can apply precise backoff.
*
* @param {File} file
* @param {HTMLElement} progressBar
* @param {HTMLElement} statusEl
* @param {HTMLElement} statusMessage
* @returns {Promise<void>}
* @returns {Promise<{rateLimited: boolean, retryAfterSeconds: number}>}
*/
function validateAndUploadQueued(file, progressBar, statusEl, statusMessage) {
// Validate file type by checking both MIME type and extension
const isValidMimeType = ACCEPTED_TYPES[file.type] || false;
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
const isValidExtension = ACCEPTED_EXTENSIONS.includes(fileExtension);
if (!isValidMimeType && !isValidExtension) {
statusEl.textContent = `Unsupported file type`;
statusEl.className = "text-xs text-red-500 mt-1";
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
updateOverallStatus(statusMessage);
return Promise.resolve();
}
if (file.size > MAX_FILE_SIZE) {
statusEl.textContent = `Exceeds 500 MB limit`;
statusEl.className = "text-xs text-red-500 mt-1";
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
updateOverallStatus(statusMessage);
return Promise.resolve();
}
return uploadFile(file, progressBar, statusEl, statusMessage);
}
/**
* Validate and upload a single file (legacy entry point kept for compatibility).
* @param {File} file - File to validate and upload
* @param {HTMLElement} progressContainer - Container element for progress display
* @param {HTMLElement} statusMessage - Element for status message display
*/
function validateAndUpload(file, progressContainer, statusMessage) {
// Create progress element for this file
const fileProgress = document.createElement("div");
fileProgress.className = "flex flex-col mb-2";
fileProgress.innerHTML = `
<div class="flex justify-between">
<span class="text-sm truncate" title="${file.name}">${file.name}</span>
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
<div class="file-progress-bar bg-blue-500 h-2 rounded-full" style="width: 0%"></div>
</div>
<div class="file-status text-xs text-gray-600 mt-1">Validating...</div>
`;
if (progressContainer) {
progressContainer.appendChild(fileProgress);
}
const progressBar = fileProgress.querySelector(".file-progress-bar");
const statusEl = fileProgress.querySelector(".file-status");
validateAndUploadQueued(file, progressBar, statusEl, statusMessage);
}
/**
* Upload a file to the server
* @param {File} file - File to upload
* @param {HTMLElement} progressBar - Progress bar element
* @param {HTMLElement} statusEl - Status element
* @param {HTMLElement} statusMessage - Overall status message element
* @returns {Promise<void>}
*/
function uploadFile(file, progressBar, statusEl, statusMessage) {
statusEl.textContent = `Uploading…`;
statusEl.className = "text-xs text-gray-600 mt-1";
progressBar.className = "file-progress-bar bg-blue-500 h-2 rounded-full";
function _uploadSingleFile(file, progressBar, statusEl, statusMessage) {
statusEl.textContent = 'Uploading…';
statusEl.className = 'text-xs text-gray-600 mt-1';
progressBar.style.width = '0%';
progressBar.className = 'file-progress-bar bg-blue-500 h-2 rounded-full';
return new Promise((resolve) => {
try {
const formData = new FormData();
formData.append("file", file);
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ui-upload", true);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/ui-upload', true);
// Attach CSRF token so the server-side CSRF middleware accepts the request.
const csrfToken = typeof getCsrfToken === 'function' ? getCsrfToken() : '';
if (csrfToken) {
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
const csrfToken = typeof getCsrfToken === 'function' ? getCsrfToken() : '';
if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
statusEl.textContent = `Uploading: ${pct}%`;
}
};
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + "%";
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`;
}
};
xhr.onload = function () {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = "100%";
progressBar.className = "file-progress-bar bg-green-500 h-2 rounded-full";
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = "text-xs text-green-600 mt-1";
} else {
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
statusEl.textContent = `Error: Upload failed (HTTP ${xhr.status})`;
statusEl.className = "text-xs text-red-500 mt-1";
}
xhr.onload = () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = '100%';
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = 'text-xs text-green-600 mt-1';
_onUploadSuccess();
updateOverallStatus(statusMessage);
resolve();
};
resolve({ rateLimited: false, retryAfterSeconds: 0 });
xhr.onerror = function () {
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
statusEl.textContent = `Error: Network error`;
statusEl.className = "text-xs text-red-500 mt-1";
} else if (xhr.status === 429) {
// Parse Retry-After (seconds integer).
let retryAfter = parseInt(xhr.getResponseHeader('Retry-After') || '0', 10);
if (!retryAfter) {
// Fall back to X-RateLimit-Reset (Unix timestamp).
const reset = parseInt(xhr.getResponseHeader('X-RateLimit-Reset') || '0', 10);
if (reset) retryAfter = Math.max(reset - Math.floor(Date.now() / 1000), 1);
}
progressBar.className = 'file-progress-bar bg-yellow-400 h-2 rounded-full';
statusEl.textContent = 'Rate limited queued to retry…';
statusEl.className = 'text-xs text-yellow-600 mt-1';
resolve({ rateLimited: true, retryAfterSeconds: retryAfter });
} else {
progressBar.className = 'file-progress-bar bg-red-500 h-2 rounded-full';
statusEl.textContent = `Error: HTTP ${xhr.status}`;
statusEl.className = 'text-xs text-red-500 mt-1';
updateOverallStatus(statusMessage);
resolve();
};
resolve({ rateLimited: false, retryAfterSeconds: 0 });
}
};
xhr.send(formData);
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
statusEl.className = "text-xs text-red-500 mt-1";
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
xhr.onerror = () => {
progressBar.className = 'file-progress-bar bg-red-500 h-2 rounded-full';
statusEl.textContent = 'Error: Network error';
statusEl.className = 'text-xs text-red-500 mt-1';
updateOverallStatus(statusMessage);
resolve();
}
resolve({ rateLimited: false, retryAfterSeconds: 0 });
};
xhr.send(formData);
});
}
// ── Legacy single-file entry point (kept for backward compat) ─────────────────
/**
* Update the overall status message based on file statuses
* @param {HTMLElement} statusMessage - Status message element
* Validate and upload a single file (legacy path wraps the queue runner).
* @param {File} file
* @param {HTMLElement} progressContainer
* @param {HTMLElement} statusMessage
*/
function validateAndUpload(file, progressContainer, statusMessage) {
processFiles([file], progressContainer, statusMessage);
}
// ── Status helpers ────────────────────────────────────────────────────────────
/**
* Re-calculate and display the overall upload status.
* Fires 'allUploadsComplete' when every item has a terminal status.
* @param {HTMLElement} statusMessage
*/
function updateOverallStatus(statusMessage) {
if (!statusMessage) return;
// Count success/failure
const fileStatuses = document.querySelectorAll('.file-status');
let completed = 0;
let done = 0;
const total = fileStatuses.length;
fileStatuses.forEach(status => {
if (status.textContent.includes('Success') || status.textContent.includes('Error') || status.textContent.includes('Unsupported') || status.textContent.includes('Exceeds')) {
completed++;
}
fileStatuses.forEach((s) => {
const t = s.textContent;
if (
t.startsWith('Success') ||
t.startsWith('Error') ||
t.startsWith('Unsupported') ||
t.startsWith('Exceeds') ||
t.startsWith('Failed:')
) done++;
});
if (completed === total) {
statusMessage.textContent = `All uploads completed (${completed}/${total})`;
// Trigger a custom event when all uploads are complete
const allUploadsComplete = new CustomEvent('allUploadsComplete', {
detail: { total: total, completed: completed }
});
window.dispatchEvent(allUploadsComplete);
if (done === total && total > 0) {
statusMessage.textContent = `All uploads completed (${done}/${total})`;
window.dispatchEvent(new CustomEvent('allUploadsComplete', { detail: { total, completed: done } }));
} else {
statusMessage.textContent = `Uploading files (${completed}/${total})`;
statusMessage.textContent = `Uploading files (${done}/${total})`;
}
}
/**
* Format file size for display
* @param {number} bytes - File size in bytes
* @returns {string} Formatted file size
* Format a byte count for human-readable display.
* @param {number} bytes
* @returns {string}
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
@@ -371,54 +496,43 @@ function formatFileSize(bytes) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// ── Drag-and-drop initialiser ─────────────────────────────────────────────────
/**
* Initialize drag-and-drop on an element with directory support.
* @param {HTMLElement} element - Element to enable drag-and-drop on
* @param {HTMLElement} progressContainer - Container for progress display
* @param {HTMLElement} statusMessage - Element for status messages
* @param {Object} options - Additional options
* Wire up drag-and-drop on an element with full directory-traversal support.
* @param {HTMLElement} element
* @param {HTMLElement} progressContainer
* @param {HTMLElement} statusMessage
* @param {Object} [options]
* @param {string} [options.dragOverClass] - CSS class added during drag-over
*/
function initDragAndDrop(element, progressContainer, statusMessage, options = {}) {
if (!element) {
console.error("Element not found for drag-and-drop initialization");
console.error('[DocuElevate] Element not found for drag-and-drop initialization');
return;
}
// Add event listeners for drag-and-drop
element.addEventListener("dragover", (e) => {
element.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
// Add visual feedback
if (options.dragOverClass) {
element.classList.add(options.dragOverClass);
}
e.dataTransfer.dropEffect = 'copy';
if (options.dragOverClass) element.classList.add(options.dragOverClass);
});
element.addEventListener("dragleave", (e) => {
element.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
// Remove visual feedback
if (options.dragOverClass) {
element.classList.remove(options.dragOverClass);
}
if (options.dragOverClass) element.classList.remove(options.dragOverClass);
});
element.addEventListener("drop", async (e) => {
element.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
// Remove visual feedback
if (options.dragOverClass) {
element.classList.remove(options.dragOverClass);
}
if (options.dragOverClass) element.classList.remove(options.dragOverClass);
const files = await getFilesFromDataTransfer(e.dataTransfer);
if (files.length > 0) {
processFiles(files, progressContainer, statusMessage);
}
if (files.length > 0) processFiles(files, progressContainer, statusMessage);
});
}
+13 -6
View File
@@ -3,6 +3,12 @@
{% block head_extra %}
<script src="/static/js/common.js"></script>
<script>
window.uploadConfig = {
concurrency: {{ upload_concurrency }},
queueDelayMs: {{ upload_queue_delay_ms }}
};
</script>
<script src="/static/js/upload.js"></script>
<style>
/* Drop overlay styles - covers entire page for drag-and-drop anywhere */
@@ -342,8 +348,8 @@
<div id="dropOverlay" class="drop-overlay">
<div class="drop-message">
<i class="fas fa-cloud-upload-alt"></i>
<p>Drop files anywhere to upload</p>
<div class="drop-hint">Supports PDF, Office docs, images, and more</div>
<p>Drop files or folders anywhere to upload</p>
<div class="drop-hint">Supports PDF, Office docs, images, HTML, Markdown, and more folders are processed recursively</div>
</div>
</div>
@@ -902,18 +908,19 @@
e.dataTransfer.dropEffect = 'copy';
});
window.addEventListener('drop', (e) => {
window.addEventListener('drop', async (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('active');
if (e.dataTransfer.files.length > 0) {
const files = await getFilesFromDataTransfer(e.dataTransfer);
if (files.length > 0) {
// Show upload modal
uploadModal.classList.add('active');
uploadProgressContainer.innerHTML = '';
// Process the dropped files
processFiles(e.dataTransfer.files, uploadProgressContainer, uploadStatusMessage);
// Process the dropped files (queue-based, with adaptive throttling)
processFiles(files, uploadProgressContainer, uploadStatusMessage);
}
});
+211
View File
@@ -0,0 +1,211 @@
"""Tests for app/utils/allowed_types.py canonical Gotenberg file-type lists."""
import pytest
from app.utils.allowed_types import (
ALLOWED_EXTENSIONS,
ALLOWED_MIME_TYPES,
DOCUMENT_MIME_TYPES,
IMAGE_MIME_TYPES,
)
@pytest.mark.unit
class TestAllowedTypesStructure:
"""Structural sanity checks on the exported sets."""
def test_allowed_mime_types_is_union(self):
"""ALLOWED_MIME_TYPES must equal DOCUMENT_MIME_TYPES IMAGE_MIME_TYPES."""
assert ALLOWED_MIME_TYPES == DOCUMENT_MIME_TYPES | IMAGE_MIME_TYPES
def test_sets_are_disjoint(self):
"""DOCUMENT_MIME_TYPES and IMAGE_MIME_TYPES should not overlap."""
assert DOCUMENT_MIME_TYPES.isdisjoint(IMAGE_MIME_TYPES)
def test_extensions_have_leading_dot(self):
"""All entries in ALLOWED_EXTENSIONS must start with '.'."""
for ext in ALLOWED_EXTENSIONS:
assert ext.startswith("."), f"Extension without leading dot: {ext!r}"
def test_extensions_are_lowercase(self):
"""All entries in ALLOWED_EXTENSIONS must be lower-case."""
for ext in ALLOWED_EXTENSIONS:
assert ext == ext.lower(), f"Non-lowercase extension: {ext!r}"
def test_no_empty_entries(self):
"""No set should contain empty strings."""
for s in (DOCUMENT_MIME_TYPES, IMAGE_MIME_TYPES, ALLOWED_EXTENSIONS):
assert "" not in s
@pytest.mark.unit
class TestGotenbergCoverageDocuments:
"""Verify that every extension Gotenberg handles is present."""
# These must match OFFICE_EXTENSIONS in app/tasks/convert_to_pdf.py
_office_extensions = {
".doc",
".docx",
".docm",
".dot",
".dotx",
".dotm",
".xls",
".xlsx",
".xlsm",
".xlsb",
".xlt",
".xltx",
".xlw",
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
".odt",
".ods",
".odp",
".odg",
".odf",
".rtf",
".txt",
".csv",
}
_image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".tiff",
".tif",
".webp",
".svg",
}
_html_extensions = {".html", ".htm"}
_markdown_extensions = {".md", ".markdown"}
def test_office_extensions_all_present(self):
missing = self._office_extensions - ALLOWED_EXTENSIONS
assert not missing, f"Office extensions missing from ALLOWED_EXTENSIONS: {missing}"
def test_image_extensions_all_present(self):
missing = self._image_extensions - ALLOWED_EXTENSIONS
assert not missing, f"Image extensions missing from ALLOWED_EXTENSIONS: {missing}"
def test_html_extensions_present(self):
missing = self._html_extensions - ALLOWED_EXTENSIONS
assert not missing, f"HTML extensions missing from ALLOWED_EXTENSIONS: {missing}"
def test_markdown_extensions_present(self):
missing = self._markdown_extensions - ALLOWED_EXTENSIONS
assert not missing, f"Markdown extensions missing from ALLOWED_EXTENSIONS: {missing}"
def test_pdf_extension_present(self):
assert ".pdf" in ALLOWED_EXTENSIONS
@pytest.mark.unit
class TestGotenbergCoverageMimeTypes:
"""Verify that key MIME types are present."""
@pytest.mark.parametrize(
"mime_type",
[
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
# OpenDocument
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
# Images
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
"image/svg+xml",
# Text
"text/plain",
"text/csv",
"text/html",
"text/markdown",
"text/x-markdown",
# RTF
"application/rtf",
"text/rtf",
],
)
def test_mime_type_in_allowed(self, mime_type):
assert mime_type in ALLOWED_MIME_TYPES, f"{mime_type} not in ALLOWED_MIME_TYPES"
@pytest.mark.parametrize(
"mime_type",
[
# New extended Office variants
"application/vnd.ms-word.document.macroEnabled.12",
"application/vnd.ms-word.template.macroEnabled.12",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
"application/vnd.ms-excel.sheet.macroEnabled.12",
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template",
"application/vnd.openxmlformats-officedocument.presentationml.template",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
"application/vnd.oasis.opendocument.graphics",
"application/vnd.oasis.opendocument.formula",
],
)
def test_extended_mime_type_in_allowed(self, mime_type):
"""Extended / macro-enabled Office and ODF types must be present."""
assert mime_type in ALLOWED_MIME_TYPES, f"{mime_type} not in ALLOWED_MIME_TYPES"
@pytest.mark.unit
class TestUploadViewsPassConfig:
"""Verify that the upload views inject uploadConfig into their templates."""
def test_upload_page_contains_upload_config(self, client):
"""The /upload page must render window.uploadConfig with numeric values."""
response = client.get("/upload")
assert response.status_code == 200
content = response.text
assert "window.uploadConfig" in content
# Verify that literal integers are rendered (not the placeholder names)
assert "concurrency: 3" in content or "concurrency:" in content
assert "queueDelayMs: 500" in content or "queueDelayMs:" in content
def test_files_page_contains_upload_config(self, client):
"""The /files page must render window.uploadConfig with numeric values."""
response = client.get("/files")
assert response.status_code == 200
content = response.text
assert "window.uploadConfig" in content
assert "concurrency: 3" in content or "concurrency:" in content
assert "queueDelayMs: 500" in content or "queueDelayMs:" in content
def test_upload_config_has_numeric_concurrency(self, client):
"""Concurrency in uploadConfig must be a positive integer."""
import re
response = client.get("/upload")
assert response.status_code == 200
match = re.search(r"concurrency:\s*(\d+)", response.text)
assert match is not None, "concurrency integer not found in window.uploadConfig"
assert int(match.group(1)) > 0
def test_upload_config_has_numeric_delay(self, client):
"""queueDelayMs in uploadConfig must be a non-negative integer."""
import re
response = client.get("/upload")
assert response.status_code == 200
match = re.search(r"queueDelayMs:\s*(\d+)", response.text)
assert match is not None, "queueDelayMs integer not found in window.uploadConfig"
assert int(match.group(1)) >= 0