feat(upload): add directory traversal, queue throttling, and upload config settings
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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": (
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ router = APIRouter()
|
|||||||
_FILE_NOT_FOUND = "File not found"
|
_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")
|
@router.get("/files")
|
||||||
@require_login
|
@require_login
|
||||||
def files_page(
|
def files_page(
|
||||||
@@ -111,6 +131,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": _get_upload_concurrency(),
|
||||||
|
"upload_queue_delay_ms": _get_upload_queue_delay_ms(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -124,6 +146,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": _get_upload_concurrency(),
|
||||||
|
"upload_queue_delay_ms": _get_upload_queue_delay_ms(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -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)
|
||||||
|
|||||||
+222
-74
@@ -4,6 +4,10 @@
|
|||||||
// Configuration
|
// Configuration
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||||
|
|
||||||
|
// Upload throttling defaults (overridden by window.uploadConfig when available)
|
||||||
|
const DEFAULT_UPLOAD_CONCURRENCY = 3;
|
||||||
|
const DEFAULT_UPLOAD_QUEUE_DELAY_MS = 500;
|
||||||
|
|
||||||
// Allowed file types
|
// Allowed file types
|
||||||
const ACCEPTED_TYPES = {
|
const ACCEPTED_TYPES = {
|
||||||
// PDF files
|
// PDF files
|
||||||
@@ -49,17 +53,101 @@ const ACCEPTED_EXTENSIONS = [
|
|||||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.md'
|
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.md'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Directory traversal helpers (FileSystem Access API)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a list of files for upload
|
* Read all file entries from a DirectoryReader, handling the 100-entry limit
|
||||||
* @param {FileList} files - Files to process
|
* by calling readEntries() repeatedly until it returns an empty batch.
|
||||||
|
* @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);
|
||||||
|
} else {
|
||||||
|
entries.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
}
|
||||||
|
}, reject);
|
||||||
|
}
|
||||||
|
readBatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect all File objects from a FileSystemEntry tree.
|
||||||
|
* @param {FileSystemEntry} entry
|
||||||
|
* @param {File[]} files - accumulator array
|
||||||
|
* @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 reader = entry.createReader();
|
||||||
|
const subEntries = await readAllDirectoryEntries(reader);
|
||||||
|
for (const sub of subEntries) {
|
||||||
|
await traverseFileEntry(sub, 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.
|
||||||
|
* @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];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Fallback: plain FileList (no directory support)
|
||||||
|
return Array.from(dataTransfer.files || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queue-based upload runner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a list of files using a concurrency-limited queue with a configurable
|
||||||
|
* delay between slot starts to prevent server overload.
|
||||||
|
*
|
||||||
|
* @param {File[]} files - Files to upload
|
||||||
* @param {HTMLElement} progressContainer - Container element for progress display
|
* @param {HTMLElement} progressContainer - Container element for progress display
|
||||||
* @param {HTMLElement} statusMessage - Element for status message display
|
* @param {HTMLElement} statusMessage - Element for status message display
|
||||||
*/
|
*/
|
||||||
function processFiles(files, progressContainer, statusMessage) {
|
function processFiles(files, progressContainer, statusMessage) {
|
||||||
if (files.length === 0) return;
|
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;
|
||||||
|
|
||||||
if (statusMessage) {
|
if (statusMessage) {
|
||||||
statusMessage.textContent = `Processing ${files.length} file(s)...`;
|
statusMessage.textContent = `Queued ${files.length} file(s) for upload…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear previous upload progress
|
// Clear previous upload progress
|
||||||
@@ -67,15 +155,79 @@ function processFiles(files, progressContainer, statusMessage) {
|
|||||||
progressContainer.innerHTML = "";
|
progressContainer.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each file
|
// Pre-create all progress elements so the user sees the full list immediately
|
||||||
for (let i = 0; i < files.length; i++) {
|
const progressElements = files.map((file) => {
|
||||||
const file = files[i];
|
const fileProgress = document.createElement("div");
|
||||||
validateAndUpload(file, progressContainer, statusMessage);
|
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-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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue runner
|
||||||
|
let index = 0;
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
function startNext() {
|
||||||
|
while (active < concurrency && index < files.length) {
|
||||||
|
const i = index++;
|
||||||
|
active++;
|
||||||
|
const progressBar = progressElements[i].querySelector(".file-progress-bar");
|
||||||
|
const statusEl = progressElements[i].querySelector(".file-status");
|
||||||
|
validateAndUploadQueued(files[i], progressBar, statusEl, statusMessage).finally(() => {
|
||||||
|
active--;
|
||||||
|
setTimeout(startNext, delayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and upload a single file
|
* Validate and upload a single file (used by the queue runner).
|
||||||
|
* @param {File} file
|
||||||
|
* @param {HTMLElement} progressBar
|
||||||
|
* @param {HTMLElement} statusEl
|
||||||
|
* @param {HTMLElement} statusMessage
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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 {File} file - File to validate and upload
|
||||||
* @param {HTMLElement} progressContainer - Container element for progress display
|
* @param {HTMLElement} progressContainer - Container element for progress display
|
||||||
* @param {HTMLElement} statusMessage - Element for status message display
|
* @param {HTMLElement} statusMessage - Element for status message display
|
||||||
@@ -102,26 +254,7 @@ function validateAndUpload(file, progressContainer, statusMessage) {
|
|||||||
const progressBar = fileProgress.querySelector(".file-progress-bar");
|
const progressBar = fileProgress.querySelector(".file-progress-bar");
|
||||||
const statusEl = fileProgress.querySelector(".file-status");
|
const statusEl = fileProgress.querySelector(".file-status");
|
||||||
|
|
||||||
// Validate file type by checking both MIME type and extension
|
validateAndUploadQueued(file, progressBar, statusEl, statusMessage);
|
||||||
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 = `Error: ${file.name} - Unsupported file type`;
|
|
||||||
statusEl.className = "text-xs text-red-500 mt-1";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
statusEl.textContent = `Error: ${file.name} - File size exceeds 500MB limit`;
|
|
||||||
statusEl.className = "text-xs text-red-500 mt-1";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload the file
|
|
||||||
uploadFile(file, progressBar, statusEl, statusMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,55 +263,68 @@ function validateAndUpload(file, progressContainer, statusMessage) {
|
|||||||
* @param {HTMLElement} progressBar - Progress bar element
|
* @param {HTMLElement} progressBar - Progress bar element
|
||||||
* @param {HTMLElement} statusEl - Status element
|
* @param {HTMLElement} statusEl - Status element
|
||||||
* @param {HTMLElement} statusMessage - Overall status message element
|
* @param {HTMLElement} statusMessage - Overall status message element
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function uploadFile(file, progressBar, statusEl, statusMessage) {
|
function uploadFile(file, progressBar, statusEl, statusMessage) {
|
||||||
statusEl.textContent = `Uploading...`;
|
statusEl.textContent = `Uploading…`;
|
||||||
try {
|
statusEl.className = "text-xs text-gray-600 mt-1";
|
||||||
let formData = new FormData();
|
progressBar.className = "file-progress-bar bg-blue-500 h-2 rounded-full";
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
return new Promise((resolve) => {
|
||||||
xhr.open("POST", "/api/ui-upload", true);
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
// Attach CSRF token so the server-side CSRF middleware accepts the request.
|
const xhr = new XMLHttpRequest();
|
||||||
const csrfToken = typeof getCsrfToken === 'function' ? getCsrfToken() : '';
|
xhr.open("POST", "/api/ui-upload", true);
|
||||||
if (csrfToken) {
|
|
||||||
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (e) => {
|
// Attach CSRF token so the server-side CSRF middleware accepts the request.
|
||||||
if (e.lengthComputable) {
|
const csrfToken = typeof getCsrfToken === 'function' ? getCsrfToken() : '';
|
||||||
const percentComplete = (e.loaded / e.total) * 100;
|
if (csrfToken) {
|
||||||
progressBar.style.width = percentComplete + "%";
|
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
||||||
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = function() {
|
xhr.upload.onprogress = (e) => {
|
||||||
if (xhr.status === 200) {
|
if (e.lengthComputable) {
|
||||||
const result = JSON.parse(xhr.responseText);
|
const percentComplete = (e.loaded / e.total) * 100;
|
||||||
progressBar.style.width = "100%";
|
progressBar.style.width = percentComplete + "%";
|
||||||
progressBar.className = "file-progress-bar bg-green-500 h-2 rounded-full";
|
statusEl.textContent = `Uploading: ${Math.round(percentComplete)}%`;
|
||||||
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
|
}
|
||||||
statusEl.className = "text-xs text-green-600 mt-1";
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
updateOverallStatus(statusMessage);
|
updateOverallStatus(statusMessage);
|
||||||
} else {
|
resolve();
|
||||||
throw new Error(`Upload failed with status ${xhr.status}`);
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = function() {
|
xhr.onerror = function () {
|
||||||
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();
|
||||||
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
|
} catch (err) {
|
||||||
} catch (err) {
|
statusEl.textContent = `Error: ${err.message}`;
|
||||||
statusEl.textContent = `Error: ${err.message}`;
|
statusEl.className = "text-xs text-red-500 mt-1";
|
||||||
statusEl.className = "text-xs text-red-500 mt-1";
|
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
|
||||||
progressBar.className = "file-progress-bar bg-red-500 h-2 rounded-full";
|
updateOverallStatus(statusMessage);
|
||||||
updateOverallStatus(statusMessage);
|
resolve();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,10 +337,10 @@ function updateOverallStatus(statusMessage) {
|
|||||||
// Count success/failure
|
// Count success/failure
|
||||||
const fileStatuses = document.querySelectorAll('.file-status');
|
const fileStatuses = document.querySelectorAll('.file-status');
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
let total = fileStatuses.length;
|
const total = fileStatuses.length;
|
||||||
|
|
||||||
fileStatuses.forEach(status => {
|
fileStatuses.forEach(status => {
|
||||||
if (status.textContent.includes('Success') || status.textContent.includes('Error')) {
|
if (status.textContent.includes('Success') || status.textContent.includes('Error') || status.textContent.includes('Unsupported') || status.textContent.includes('Exceeds')) {
|
||||||
completed++;
|
completed++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -226,7 +372,7 @@ function formatFileSize(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize drag-and-drop on an element
|
* Initialize drag-and-drop on an element with directory support.
|
||||||
* @param {HTMLElement} element - Element to enable drag-and-drop on
|
* @param {HTMLElement} element - Element to enable drag-and-drop on
|
||||||
* @param {HTMLElement} progressContainer - Container for progress display
|
* @param {HTMLElement} progressContainer - Container for progress display
|
||||||
* @param {HTMLElement} statusMessage - Element for status messages
|
* @param {HTMLElement} statusMessage - Element for status messages
|
||||||
@@ -260,7 +406,7 @@ function initDragAndDrop(element, progressContainer, statusMessage, options = {}
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
element.addEventListener("drop", (e) => {
|
element.addEventListener("drop", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@@ -269,8 +415,10 @@ function initDragAndDrop(element, progressContainer, statusMessage, options = {}
|
|||||||
element.classList.remove(options.dragOverClass);
|
element.classList.remove(options.dragOverClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.dataTransfer.files.length) {
|
const files = await getFilesFromDataTransfer(e.dataTransfer);
|
||||||
processFiles(e.dataTransfer.files, progressContainer, statusMessage);
|
if (files.length > 0) {
|
||||||
|
processFiles(files, progressContainer, statusMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & drop files here, or click to select files.
|
Drag & 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 • 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user