Merge pull request #426 from christianlouis/copilot/support-directory-upload

feat(upload): directory drag-and-drop, adaptive 429 throttling, full Gotenberg file type support
This commit is contained in:
Christian Krakau-Louis
2026-02-27 00:56:17 +01:00
committed by GitHub
15 changed files with 900 additions and 278 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_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) 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) # **File Upload Size Limits** (Security - see SECURITY_AUDIT.md)
# Maximum file upload size in bytes. Default: 1GB (1073741824 bytes) # Maximum file upload size in bytes. Default: 1GB (1073741824 bytes)
# Prevents resource exhaustion attacks. Adjust based on your server capacity. # 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.models import FileRecord, ProcessingLog
from app.tasks.convert_to_pdf import convert_to_pdf from app.tasks.convert_to_pdf import convert_to_pdf
from app.tasks.process_document import process_document 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_queries import apply_status_filter
from app.utils.file_status import get_files_processing_status from app.utils.file_status import get_files_processing_status
from app.utils.filename_utils import sanitize_filename 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}'") logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
file_size = written_size 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 # Determine if the file is a PDF or needs conversion
mime_type, _ = mimetypes.guess_type(target_path) mime_type, _ = mimetypes.guess_type(target_path)
file_ext = os.path.splitext(target_path)[1].lower() 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 # If it's a PDF, process directly
task = process_document.delay(target_path, original_filename=safe_filename) task = process_document.delay(target_path, original_filename=safe_filename)
logger.info(f"Enqueued PDF for processing: {target_path}") logger.info(f"Enqueued PDF for processing: {target_path}")
elif mime_type in IMAGE_MIME_TYPES or any( elif mime_type in IMAGE_MIME_TYPES or file_ext in {
file_ext.endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".svg"] ".jpg",
): ".jpeg",
".png",
".gif",
".bmp",
".tiff",
".tif",
".webp",
".svg",
}:
# If it's an image, convert to PDF first # If it's an image, convert to PDF first
task = convert_to_pdf.delay(target_path, original_filename=safe_filename) task = convert_to_pdf.delay(target_path, original_filename=safe_filename)
logger.info(f"Enqueued image for PDF conversion: {target_path}") logger.info(f"Enqueued image for PDF conversion: {target_path}")
elif mime_type in ALLOWED_MIME_TYPES or any( elif mime_type in ALLOWED_MIME_TYPES or file_ext in ALLOWED_EXTENSIONS:
file_ext.endswith(ext) # Office document, HTML, Markdown, or other Gotenberg-supported format
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
task = convert_to_pdf.delay(target_path, original_filename=safe_filename) 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: else:
# For any other file type, attempt conversion but log a warning # For any other file type, attempt conversion but log a warning
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion") 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.auth import require_login
from app.config import settings from app.config import settings
from app.tasks.process_document import process_document 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 from app.utils.filename_utils import sanitize_filename
# Set up logging # Set up logging
@@ -108,7 +109,7 @@ def validate_url_safety(url: str) -> None:
def validate_file_type(content_type: str, filename: str) -> bool: 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: Args:
content_type: MIME type from response headers content_type: MIME type from response headers
@@ -117,44 +118,18 @@ def validate_file_type(content_type: str, filename: str) -> bool:
Returns: Returns:
True if file type is allowed 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 # Check content type from header
if content_type: if content_type:
# Handle content-type with charset (e.g., "application/pdf; charset=utf-8") # Handle content-type with charset (e.g., "application/pdf; charset=utf-8")
base_content_type = content_type.split(";", maxsplit=1)[0].strip().lower() 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 return True
# Also check by extension as fallback # Also check by extension as fallback
_, ext = os.path.splitext(filename) _, ext = os.path.splitext(filename)
if ext: if ext:
guessed_type, _ = mimetypes.guess_type(filename) 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 True
return False return False
+16
View File
@@ -232,6 +232,22 @@ class Settings(BaseSettings):
description="Delay in seconds between each task submission when throttling in /processall", description="Delay in seconds between each task submission when throttling in /processall",
) )
# Client-side upload throttling settings (applied when uploading files via the web UI)
upload_concurrency: int = Field(
default=3,
description=(
"Maximum number of files uploaded simultaneously from the browser. "
"Limits parallel uploads to prevent API overload when dragging directories. Default: 3."
),
)
upload_queue_delay_ms: int = Field(
default=500,
description=(
"Delay in milliseconds between starting each upload slot when queue is active. "
"Staggers upload starts to smooth out server load. Default: 500 ms."
),
)
# Notification settings # Notification settings
notification_urls: Union[List[str], str] = Field( notification_urls: Union[List[str], str] = Field(
default_factory=list, default_factory=list,
+4 -16
View File
@@ -13,6 +13,7 @@ from celery import shared_task
from app.config import settings from app.config import settings
from app.tasks.convert_to_pdf import convert_to_pdf # new conversion task 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.tasks.process_document import process_document # Updated import
from app.utils.allowed_types import ALLOWED_EXTENSIONS, ALLOWED_MIME_TYPES
logger = logging.getLogger(__name__) 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. 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 has_attachment = False
for part in email_message.walk(): for part in email_message.walk():
if part.get_content_maintype() == "multipart": 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") is_pdf_by_extension = filename.lower().endswith(".pdf")
mime_type = part.get_content_type() mime_type = part.get_content_type()
# Accept file if it has an allowed MIME type OR it's a PDF by extension file_ext = os.path.splitext(filename)[1].lower()
if mime_type not in ALLOWED_MIME_TYPES and not is_pdf_by_extension: # 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) logger.info("Skipping attachment %s with MIME type %s", filename, mime_type)
continue 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",
}
+22
View File
@@ -1057,6 +1057,28 @@ SETTING_METADATA = {
"required": False, "required": False,
"restart_required": False, "restart_required": False,
}, },
"upload_concurrency": {
"category": "Processing",
"description": (
"Maximum number of files uploaded simultaneously from the browser. "
"Limits parallel uploads to prevent API overload when dragging directories. Default: 3."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"upload_queue_delay_ms": {
"category": "Processing",
"description": (
"Delay in milliseconds between starting each upload slot when queue is active. "
"Staggers upload starts to smooth out server load. Default: 500 ms."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"enable_text_quality_check": { "enable_text_quality_check": {
"category": "Processing", "category": "Processing",
"description": ( "description": (
+6
View File
@@ -33,6 +33,8 @@ def files_page(
""" """
Return the 'files.html' template with server-side pagination, sorting, and filtering Return the 'files.html' template with server-side pagination, sorting, and filtering
""" """
from app.config import settings
try: try:
# Import the model here to avoid circular imports # Import the model here to avoid circular imports
from sqlalchemy import asc, desc from sqlalchemy import asc, desc
@@ -111,6 +113,8 @@ def files_page(
"mime_type": mime_type or "", "mime_type": mime_type or "",
"status": status or "", "status": status or "",
"mime_types": mime_types, "mime_types": mime_types,
"upload_concurrency": settings.upload_concurrency,
"upload_queue_delay_ms": settings.upload_queue_delay_ms,
}, },
) )
except Exception as e: except Exception as e:
@@ -124,6 +128,8 @@ def files_page(
"files": [], "files": [],
"pagination": {"page": 1, "per_page": per_page, "total_items": 0, "total_pages": 0}, "pagination": {"page": 1, "per_page": per_page, "total_items": 0, "total_pages": 0},
"error": str(e), "error": str(e),
"upload_concurrency": settings.upload_concurrency,
"upload_queue_delay_ms": settings.upload_queue_delay_ms,
}, },
) )
+10 -1
View File
@@ -99,7 +99,16 @@ async def serve_imprint(request: Request):
@require_login @require_login
async def serve_upload(request: Request): async def serve_upload(request: Request):
"""Serve the upload page.""" """Serve the upload page."""
return templates.TemplateResponse("upload.html", {"request": request}) from app.config import settings
return templates.TemplateResponse(
"upload.html",
{
"request": request,
"upload_concurrency": settings.upload_concurrency,
"upload_queue_delay_ms": settings.upload_queue_delay_ms,
},
)
@router.get("/favicon.ico", include_in_schema=False) @router.get("/favicon.ico", include_in_schema=False)
+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 - Total queue time: (25-1) × 3 = 72 seconds
- Prevents API rate limit issues and ensures smooth processing - 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 ### 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. **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.
+415 -153
View File
@@ -1,221 +1,492 @@
// frontend/static/js/upload.js // frontend/static/js/upload.js
// Reusable drag-and-drop upload functionality for DocuElevate // Reusable drag-and-drop upload functionality for DocuElevate
// Configuration // ── Configuration ─────────────────────────────────────────────────────────────
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
// Allowed file types /** Fallback upload throttling values when window.uploadConfig is not set. */
const DEFAULT_UPLOAD_CONCURRENCY = 3;
const DEFAULT_UPLOAD_QUEUE_DELAY_MS = 500;
/** 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 = { const ACCEPTED_TYPES = {
// PDF files // PDF
'application/pdf': true, 'application/pdf': true,
// Word
// 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
'application/msword': true, 'application/msword': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': true,
'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true, 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': true,
'application/vnd.ms-word.document.macroEnabled.12': true, 'application/vnd.ms-word.document.macroEnabled.12': true,
'application/vnd.ms-word.template.macroEnabled.12': true,
// Excel // Excel
'application/vnd.ms-excel': true, 'application/vnd.ms-excel': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true,
'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true, 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': true,
'application/vnd.ms-excel.sheet.macroEnabled.12': true, 'application/vnd.ms-excel.sheet.macroEnabled.12': true,
'application/vnd.ms-excel.sheet.binary.macroEnabled.12': true,
// PowerPoint // PowerPoint
'application/vnd.ms-powerpoint': true, 'application/vnd.ms-powerpoint': true,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': true, 'application/vnd.openxmlformats-officedocument.presentationml.presentation': true,
'application/vnd.openxmlformats-officedocument.presentationml.template': true, 'application/vnd.openxmlformats-officedocument.presentationml.template': true,
'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true, 'application/vnd.openxmlformats-officedocument.presentationml.slideshow': true,
'application/vnd.ms-powerpoint.presentation.macroEnabled.12': true,
// Other common formats // 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/plain': true,
'text/csv': true, 'text/csv': true,
'application/rtf': true, 'application/rtf': true,
'text/rtf': true, 'text/rtf': true,
// HTML (Gotenberg Chromium route)
'text/html': true, 'text/html': true,
'application/xml': true, // Markdown (Gotenberg Chromium/Markdown route)
'text/xml': true 'text/markdown': true,
'text/x-markdown': true,
}; };
// File extensions that are always allowed (even if mime type is not recognized) // File extensions always accepted even when the browser reports no / wrong MIME type.
const ACCEPTED_EXTENSIONS = [ const ACCEPTED_EXTENSIONS = new Set([
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', // PDF
'.odt', '.ods', '.odp', '.rtf', '.txt', '.csv', '.pdf',
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.md' // 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',
]);
// ── 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();
}
/** /**
* Process a list of files for upload * Called when an HTTP 429 Too Many Requests response is received.
* @param {FileList} files - Files to process * Pauses the queue and applies exponential backoff.
* @param {HTMLElement} progressContainer - Container element for progress display * @param {number} retryAfterSeconds - Value of the Retry-After header (0 = absent).
* @param {HTMLElement} statusMessage - Element for status message display
*/ */
function processFiles(files, progressContainer, statusMessage) { function _onRateLimited(retryAfterSeconds) {
if (files.length === 0) return; // 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);
if (statusMessage) { _adaptiveState.pauseUntil = Date.now() + waitMs;
statusMessage.textContent = `Processing ${files.length} file(s)...`; // 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) {
// Clear previous upload progress _adaptiveState.concurrency = Math.min(_adaptiveState.concurrency + 1, cfgConc);
if (progressContainer) { if (_adaptiveState.concurrency >= cfgConc) _adaptiveState.concurrency = null; // fully recovered
progressContainer.innerHTML = "";
} }
}
// Process each file // ── Directory traversal helpers ───────────────────────────────────────────────
for (let i = 0; i < files.length; i++) {
const file = files[i]; /**
validateAndUpload(file, progressContainer, statusMessage); * 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[]>}
*/
function readAllDirectoryEntries(reader) {
return new Promise((resolve, reject) => {
const entries = [];
function readBatch() {
reader.readEntries((batch) => {
if (batch.length === 0) { resolve(entries); return; }
entries.push(...batch);
readBatch();
}, reject);
}
readBatch();
});
}
/**
* Recursively collect all File objects from a FileSystemEntry tree.
* @param {FileSystemEntry} entry
* @param {File[]} files - accumulator
* @returns {Promise<void>}
*/
async function traverseFileEntry(entry, files) {
if (entry.isFile) {
await new Promise((resolve) => {
entry.file((file) => { files.push(file); resolve(); }, resolve);
});
} else if (entry.isDirectory) {
const subEntries = await readAllDirectoryEntries(entry.createReader());
for (const sub of subEntries) {
await traverseFileEntry(sub, files);
}
} }
} }
/** /**
* Validate and upload a single file * Extract all File objects from a DataTransfer, recursively expanding any
* @param {File} file - File to validate and upload * dropped directories. Falls back gracefully to dataTransfer.files when the
* @param {HTMLElement} progressContainer - Container element for progress display * FileSystem Entry API is unavailable (Safari < 11.1, some mobile browsers).
* @param {HTMLElement} statusMessage - Element for status message display * @param {DataTransfer} dataTransfer
* @returns {Promise<File[]>}
*/ */
function validateAndUpload(file, progressContainer, statusMessage) { async function getFilesFromDataTransfer(dataTransfer) {
// Create progress element for this file if (dataTransfer.items && dataTransfer.items.length > 0) {
const fileProgress = document.createElement("div"); const files = [];
fileProgress.className = "flex flex-col mb-2"; for (let i = 0; i < dataTransfer.items.length; i++) {
fileProgress.innerHTML = ` const item = dataTransfer.items[i];
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
await traverseFileEntry(entry, files);
} else if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
return files;
}
return Array.from(dataTransfer.files || []);
}
// ── Core queue runner ─────────────────────────────────────────────────────────
/**
* Validate and queue files for upload with adaptive throttling.
*
* 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) {
const fileArray = Array.from(files);
if (!fileArray.length) return;
if (statusMessage) {
statusMessage.textContent = `Queued ${fileArray.length} file(s) for upload…`;
}
if (progressContainer) progressContainer.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"> <div class="flex justify-between">
<span class="text-sm truncate" title="${file.name}">${file.name}</span> <span class="text-sm truncate" title="${file.name}">${file.name}</span>
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span> <span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
</div> </div>
<div class="w-full bg-gray-200 h-2 rounded-full mt-1"> <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 class="file-progress-bar bg-gray-300 h-2 rounded-full" style="width:0%"></div>
</div> </div>
<div class="file-status text-xs text-gray-600 mt-1">Validating...</div> <div class="file-status text-xs text-gray-400 mt-1">Queued</div>
`; `;
if (progressContainer) progressContainer.appendChild(row);
return {
file,
progressBar: row.querySelector('.file-progress-bar'),
statusEl: row.querySelector('.file-status'),
retryCount: 0,
};
});
if (progressContainer) { // Mutable queue rate-limited items are pushed back to the front.
progressContainer.appendChild(fileProgress); const queue = [...queueItems];
} let active = 0;
const progressBar = fileProgress.querySelector(".file-progress-bar"); function scheduleNext() {
const statusEl = fileProgress.querySelector(".file-status"); // Respect global backoff pause.
const pauseRemaining = _adaptiveState.pauseUntil - Date.now();
// Validate file type by checking both MIME type and extension if (pauseRemaining > 0) {
const isValidMimeType = ACCEPTED_TYPES[file.type] || false; setTimeout(scheduleNext, pauseRemaining + 50);
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
const isValidExtension = ACCEPTED_EXTENSIONS.includes(fileExtension);
if (!isValidMimeType && !isValidExtension) {
statusEl.textContent = `Error: ${file.name} - Unsupported file type`;
statusEl.className = "text-xs text-red-500 mt-1";
return; return;
} }
// Validate file size while (active < _effectiveConcurrency() && queue.length > 0) {
if (file.size > MAX_FILE_SIZE) { const item = queue.shift();
statusEl.textContent = `Error: ${file.name} - File size exceeds 500MB limit`; active++;
statusEl.className = "text-xs text-red-500 mt-1";
return; // 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--;
updateOverallStatus(statusMessage);
// No HTTP request skip straight to next without adding delay.
scheduleNext();
continue;
} }
// Upload the file if (item.file.size > MAX_FILE_SIZE) {
uploadFile(file, progressBar, statusEl, statusMessage); 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());
}
});
}
}
scheduleNext();
} }
/** /**
* Upload a file to the server * Check whether a file passes MIME type and extension validation.
* @param {File} file - File to upload * @param {File} file
* @param {HTMLElement} progressBar - Progress bar element * @returns {boolean}
* @param {HTMLElement} statusEl - Status element
* @param {HTMLElement} statusMessage - Overall status message element
*/ */
async function uploadFile(file, progressBar, statusEl, statusMessage) { function _isAcceptedFile(file) {
statusEl.textContent = `Uploading...`; if (ACCEPTED_TYPES[file.type]) return true;
try { const ext = '.' + file.name.split('.').pop().toLowerCase();
let formData = new FormData(); return ACCEPTED_EXTENSIONS.has(ext);
formData.append("file", file); }
/**
* 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<{rateLimited: boolean, retryAfterSeconds: number}>}
*/
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) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ui-upload", true); 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() : ''; const csrfToken = typeof getCsrfToken === 'function' ? getCsrfToken() : '';
if (csrfToken) { if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken);
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
}
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) { if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100; const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percentComplete + "%"; progressBar.style.width = pct + '%';
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`; statusEl.textContent = `Uploading: ${pct}%`;
} }
}; };
xhr.onload = function() { xhr.onload = () => {
if (xhr.status === 200) { if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText); const result = JSON.parse(xhr.responseText);
progressBar.style.width = "100%"; progressBar.style.width = '100%';
progressBar.className = "file-progress-bar bg-green-500 h-2 rounded-full"; progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
statusEl.textContent = `Success: Task ID: ${result.task_id}`; statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = "text-xs text-green-600 mt-1"; statusEl.className = 'text-xs text-green-600 mt-1';
_onUploadSuccess();
updateOverallStatus(statusMessage); updateOverallStatus(statusMessage);
resolve({ rateLimited: false, retryAfterSeconds: 0 });
} 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 { } else {
throw new Error(`Upload failed with status ${xhr.status}`); 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({ rateLimited: false, retryAfterSeconds: 0 });
} }
}; };
xhr.onerror = function() { xhr.onerror = () => {
throw new Error("Network error occurred"); 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({ rateLimited: false, retryAfterSeconds: 0 });
}; };
xhr.send(formData); 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";
updateOverallStatus(statusMessage);
}
} }
// ── Legacy single-file entry point (kept for backward compat) ─────────────────
/** /**
* Update the overall status message based on file statuses * Validate and upload a single file (legacy path wraps the queue runner).
* @param {HTMLElement} statusMessage - Status message element * @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) { function updateOverallStatus(statusMessage) {
if (!statusMessage) return; if (!statusMessage) return;
// Count success/failure
const fileStatuses = document.querySelectorAll('.file-status'); const fileStatuses = document.querySelectorAll('.file-status');
let completed = 0; let done = 0;
let total = fileStatuses.length; const total = fileStatuses.length;
fileStatuses.forEach(status => { fileStatuses.forEach((s) => {
if (status.textContent.includes('Success') || status.textContent.includes('Error')) { const t = s.textContent;
completed++; if (
} t.startsWith('Success') ||
t.startsWith('Error') ||
t.startsWith('Unsupported') ||
t.startsWith('Exceeds') ||
t.startsWith('Failed:')
) done++;
}); });
if (completed === total) { if (done === total && total > 0) {
statusMessage.textContent = `All uploads completed (${completed}/${total})`; statusMessage.textContent = `All uploads completed (${done}/${total})`;
window.dispatchEvent(new CustomEvent('allUploadsComplete', { detail: { total, completed: done } }));
// Trigger a custom event when all uploads are complete
const allUploadsComplete = new CustomEvent('allUploadsComplete', {
detail: { total: total, completed: completed }
});
window.dispatchEvent(allUploadsComplete);
} else { } else {
statusMessage.textContent = `Uploading files (${completed}/${total})`; statusMessage.textContent = `Uploading files (${done}/${total})`;
} }
} }
/** /**
* Format file size for display * Format a byte count for human-readable display.
* @param {number} bytes - File size in bytes * @param {number} bytes
* @returns {string} Formatted file size * @returns {string}
*/ */
function formatFileSize(bytes) { function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
@@ -225,52 +496,43 @@ function formatFileSize(bytes) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
// ── Drag-and-drop initialiser ─────────────────────────────────────────────────
/** /**
* Initialize drag-and-drop on an element * Wire up drag-and-drop on an element with full directory-traversal support.
* @param {HTMLElement} element - Element to enable drag-and-drop on * @param {HTMLElement} element
* @param {HTMLElement} progressContainer - Container for progress display * @param {HTMLElement} progressContainer
* @param {HTMLElement} statusMessage - Element for status messages * @param {HTMLElement} statusMessage
* @param {Object} options - Additional options * @param {Object} [options]
* @param {string} [options.dragOverClass] - CSS class added during drag-over
*/ */
function initDragAndDrop(element, progressContainer, statusMessage, options = {}) { function initDragAndDrop(element, progressContainer, statusMessage, options = {}) {
if (!element) { if (!element) {
console.error("Element not found for drag-and-drop initialization"); console.error('[DocuElevate] Element not found for drag-and-drop initialization');
return; return;
} }
// Add event listeners for drag-and-drop element.addEventListener('dragover', (e) => {
element.addEventListener("dragover", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = "copy"; e.dataTransfer.dropEffect = 'copy';
if (options.dragOverClass) element.classList.add(options.dragOverClass);
// Add visual feedback
if (options.dragOverClass) {
element.classList.add(options.dragOverClass);
}
}); });
element.addEventListener("dragleave", (e) => { element.addEventListener('dragleave', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (options.dragOverClass) element.classList.remove(options.dragOverClass);
// Remove visual feedback
if (options.dragOverClass) {
element.classList.remove(options.dragOverClass);
}
}); });
element.addEventListener("drop", (e) => { element.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (options.dragOverClass) element.classList.remove(options.dragOverClass);
// Remove visual feedback const files = await getFilesFromDataTransfer(e.dataTransfer);
if (options.dragOverClass) { if (files.length > 0) processFiles(files, progressContainer, statusMessage);
element.classList.remove(options.dragOverClass);
}
if (e.dataTransfer.files.length) {
processFiles(e.dataTransfer.files, progressContainer, statusMessage);
}
}); });
} }
+13 -6
View File
@@ -3,6 +3,12 @@
{% block head_extra %} {% block head_extra %}
<script src="/static/js/common.js"></script> <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> <script src="/static/js/upload.js"></script>
<style> <style>
/* Drop overlay styles - covers entire page for drag-and-drop anywhere */ /* Drop overlay styles - covers entire page for drag-and-drop anywhere */
@@ -342,8 +348,8 @@
<div id="dropOverlay" class="drop-overlay"> <div id="dropOverlay" class="drop-overlay">
<div class="drop-message"> <div class="drop-message">
<i class="fas fa-cloud-upload-alt"></i> <i class="fas fa-cloud-upload-alt"></i>
<p>Drop files anywhere to upload</p> <p>Drop files or folders anywhere to upload</p>
<div class="drop-hint">Supports PDF, Office docs, images, and more</div> <div class="drop-hint">Supports PDF, Office docs, images, HTML, Markdown, and more folders are processed recursively</div>
</div> </div>
</div> </div>
@@ -902,18 +908,19 @@
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
}); });
window.addEventListener('drop', (e) => { window.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
dropOverlay.classList.remove('active'); dropOverlay.classList.remove('active');
if (e.dataTransfer.files.length > 0) { const files = await getFilesFromDataTransfer(e.dataTransfer);
if (files.length > 0) {
// Show upload modal // Show upload modal
uploadModal.classList.add('active'); uploadModal.classList.add('active');
uploadProgressContainer.innerHTML = ''; uploadProgressContainer.innerHTML = '';
// Process the dropped files // Process the dropped files (queue-based, with adaptive throttling)
processFiles(e.dataTransfer.files, uploadProgressContainer, uploadStatusMessage); processFiles(files, uploadProgressContainer, uploadStatusMessage);
} }
}); });
+17 -9
View File
@@ -12,12 +12,11 @@
<div <div
id="dropZone" id="dropZone"
class="border-4 border-dashed border-gray-300 rounded-lg p-6 sm:p-8 bg-white text-center w-full" class="border-4 border-dashed border-gray-300 rounded-lg p-6 sm:p-8 bg-white text-center w-full"
ondrop="handleDrop(event)"
ondragover="handleDragOver(event)" ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)" ondragleave="handleDragLeave(event)"
> >
<p class="text-gray-500 mb-4 hidden sm:block"> <p class="text-gray-500 mb-4 hidden sm:block">
Drag &amp; drop files here, or click to select files. Drag &amp; drop files or folders here, or click to select files.
</p> </p>
<p class="text-gray-500 mb-4 sm:hidden"> <p class="text-gray-500 mb-4 sm:hidden">
Tap to select files or use the camera button below. Tap to select files or use the camera button below.
@@ -42,7 +41,7 @@
</button> </button>
<div class="text-sm text-gray-500 mt-2"> <div class="text-sm text-gray-500 mt-2">
<p>Allowed types: PDF, Office documents (Word, Excel, PowerPoint, etc.), Images</p> <p>Allowed types: PDF, Office documents (Word, Excel, PowerPoint, etc.), Images</p>
<p>Maximum size: 500MB per file</p> <p>Maximum size: 500MB per file &bull; Directories are uploaded recursively</p>
</div> </div>
</div> </div>
@@ -122,6 +121,13 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script>
// Expose server-side upload throttling configuration to upload.js
window.uploadConfig = {
concurrency: {{ upload_concurrency }},
queueDelayMs: {{ upload_queue_delay_ms }}
};
</script>
<script src="/static/js/upload.js"></script> <script src="/static/js/upload.js"></script>
<script> <script>
// Drag-and-drop / file input logic // Drag-and-drop / file input logic
@@ -143,10 +149,12 @@
dropZone.classList.remove("bg-gray-100"); dropZone.classList.remove("bg-gray-100");
} }
function handleDrop(e) { dropZone.addEventListener("drop", async (e) => {
e.preventDefault(); e.preventDefault();
dropZone.classList.remove("bg-gray-100"); dropZone.classList.remove("bg-gray-100");
if (e.dataTransfer.files.length) {
const files = await getFilesFromDataTransfer(e.dataTransfer);
if (files.length) {
// Clear previous upload progress // Clear previous upload progress
uploadProgress.innerHTML = ""; uploadProgress.innerHTML = "";
@@ -155,10 +163,10 @@
progressContainer.className = "space-y-2"; progressContainer.className = "space-y-2";
uploadProgress.appendChild(progressContainer); uploadProgress.appendChild(progressContainer);
// Use the shared processFiles function // Use the shared processFiles function (queue-based)
processFiles(e.dataTransfer.files, progressContainer, statusMessage); processFiles(files, progressContainer, statusMessage);
}
} }
});
function handleFileSelect(e) { function handleFileSelect(e) {
if (e.target.files.length) { if (e.target.files.length) {
@@ -171,7 +179,7 @@
uploadProgress.appendChild(progressContainer); uploadProgress.appendChild(progressContainer);
// Use the shared processFiles function // Use the shared processFiles function
processFiles(e.target.files, progressContainer, statusMessage); processFiles(Array.from(e.target.files), progressContainer, statusMessage);
} }
} }
+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
+1 -1
View File
@@ -90,4 +90,4 @@ class TestFilesView:
# Check for drag-and-drop event handlers # Check for drag-and-drop event handlers
assert "dragenter" in content or "drag" in content, "Drag event handlers should be present" assert "dragenter" in content or "drag" in content, "Drag event handlers should be present"
assert "Drop files anywhere to upload" in content, "Drop message should be present" assert "Drop files or folders anywhere to upload" in content, "Drop message should be present"