Hakan 47510e3166
Feature Uploaded Files/Folders List (#70)
* added file listing feature

* added filename sanitizer

* added file renaming feature

* Improve file action rendering and security checks

Replaces innerHTML-based file action rendering in index.html with DOM API for better safety and maintainability. Enhances path traversal security checks in files.js to ensure paths are strictly within the upload directory. Adds safe Content-Disposition header generation for file downloads to prevent header injection and support international filenames.

* Improve filename sanitization and validation

Updated regex in createSafeContentDisposition for Unicode compatibility and improved ASCII checks. Added validation to reject empty filenames after sanitization in the rename route. sanitizePathPreserveDirsSafe now removes '.' and '..' path navigation tokens for enhanced security.

---------

Co-authored-by: abite <aleksbite@yahoo.com>
2025-11-03 09:23:39 -06:00

1554 lines
68 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{SITE_TITLE}} - Simple File Upload</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="toastify/toastify.css">
<script src="toastify/toastify.js"></script>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="assets/icon.svg">
</head>
<body>
<div class="container">
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg xmlns="http://www.w3.org/2000/svg" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Moon icon (shown in light mode) -->
<path class="moon" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
<!-- Sun icon (shown in dark mode) -->
<circle class="sun" cx="12" cy="12" r="5" style="display:none"/>
<line class="sun" x1="12" y1="1" x2="12" y2="3" style="display:none"/>
<line class="sun" x1="12" y1="21" x2="12" y2="23" style="display:none"/>
<line class="sun" x1="4.22" y1="4.22" x2="5.64" y2="5.64" style="display:none"/>
<line class="sun" x1="18.36" y1="18.36" x2="19.78" y2="19.78" style="display:none"/>
<line class="sun" x1="1" y1="12" x2="3" y2="12" style="display:none"/>
<line class="sun" x1="21" y1="12" x2="23" y2="12" style="display:none"/>
<line class="sun" x1="4.22" y1="19.78" x2="5.64" y2="18.36" style="display:none"/>
<line class="sun" x1="18.36" y1="5.64" x2="19.78" y2="4.22" style="display:none"/>
</svg>
</button>
<h1>{{SITE_TITLE}}</h1>
<div class="upload-container" id="dropZone">
<div class="upload-content">
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p>Drag and drop files or folders here<br>or</p>
<input type="file" id="fileInput" multiple hidden>
<input type="file" id="folderInput" webkitdirectory directory multiple hidden>
<div class="button-group">
<button onclick="document.getElementById('fileInput').click()">Browse Files</button>
<button onclick="document.getElementById('folderInput').click()">Browse Folders</button>
</div>
</div>
</div>
<div id="uploadProgress"></div>
<div id="fileList" class="file-list"></div>
<button id="uploadButton" class="upload-button" style="display: none;">Upload Files</button>
<!-- File listing section (controlled by SHOW_FILE_LIST environment variable) -->
<div id="uploadedFilesList" class="uploaded-files-section" style="display: none;">
<div class="uploaded-files-header">
<h2>Uploaded Files</h2>
<div class="uploaded-files-stats">
<span id="totalFiles">0 files</span>
<span id="totalSize">0 B</span>
<button id="refreshFilesBtn" class="refresh-btn">🔄 Refresh</button>
</div>
</div>
<div id="uploadedFilesContent" class="uploaded-files-content">
<div class="loading-message">Loading files...</div>
</div>
</div>
</div>
<!-- Rename Modal -->
<div id="renameModal" class="rename-modal">
<div class="rename-modal-content">
<h3>Rename Item</h3>
<input type="text" id="renameInput" class="rename-input" placeholder="Enter new name...">
<div class="rename-actions">
<button class="modal-btn modal-btn-cancel" onclick="fileListManager.cancelRename()">Cancel</button>
<button class="modal-btn modal-btn-confirm" onclick="fileListManager.confirmRename()">Rename</button>
</div>
</div>
</div>
<script defer>
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
const RETRY_DELAY = 1000; // 1 second delay between retries
// Read MAX_RETRIES from the injected server value, with a fallback
const MAX_RETRIES_STR = '{{MAX_RETRIES}}';
let maxRetries = 5; // Default value
if (MAX_RETRIES_STR && MAX_RETRIES_STR !== '{{MAX_RETRIES}}') {
const parsedRetries = parseInt(MAX_RETRIES_STR, 10);
if (!isNaN(parsedRetries) && parsedRetries >= 0) {
maxRetries = parsedRetries;
} else {
console.warn(`Invalid MAX_RETRIES value "${MAX_RETRIES_STR}" received from server, defaulting to ${maxRetries}.`);
}
} else {
console.warn('MAX_RETRIES not injected by server, defaulting to 5.');
}
window.MAX_RETRIES = maxRetries; // Assign to window for potential global use/debugging
console.log(`Max retries for chunk uploads: ${window.MAX_RETRIES}`);
const AUTO_UPLOAD_STR = '{{AUTO_UPLOAD}}';
const AUTO_UPLOAD = ['true', '1', 'yes'].includes(AUTO_UPLOAD_STR.toLowerCase());
const SHOW_FILE_LIST_STR = '{{SHOW_FILE_LIST}}';
const SHOW_FILE_LIST = ['true', '1', 'yes'].includes(SHOW_FILE_LIST_STR.toLowerCase());
// Utility function to generate a unique batch ID
function generateBatchId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Utility function to format file sizes
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Security helper to escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
class FileUploader {
constructor(file, batchId) {
this.file = file;
this.batchId = batchId;
this.uploadId = null;
this.position = 0;
this.progressElement = null;
this.chunkSize = 1024 * 1024; // 1MB chunks
this.lastUploadedBytes = 0;
this.lastUploadTime = null;
this.uploadRate = 0;
this.maxRetries = window.MAX_RETRIES; // Use configured retries
this.retryDelay = RETRY_DELAY; // Use constant
}
async start() {
try {
this.updateProgress(0); // Initial progress update
await this.initUpload();
if (this.file.size > 0) { // Only upload chunks if file is not empty
await this.uploadChunks();
} else {
console.log(`Skipping chunk upload for zero-byte file: ${this.file.name}`);
// Server handles zero-byte completion in /init
this.updateProgress(100); // Mark as complete on client too
}
return true;
} catch (error) {
console.error('Upload failed:', error);
if (this.progressElement) {
this.progressElement.infoSpan.textContent = `Error: ${error.message}`;
this.progressElement.infoSpan.style.color = 'var(--danger-color)';
}
return false;
}
}
async initUpload() {
// Always use webkitRelativePath if available, otherwise fallback to name
const uploadPath = this.file.webkitRelativePath || this.file.name;
console.log('Initializing upload:', {
path: uploadPath,
size: this.file.size,
batchId: this.batchId
});
const headers = {
'Content-Type': 'application/json'
};
if (this.batchId) {
headers['X-Batch-ID'] = this.batchId;
}
// Remove leading slash from API path before concatenating
const apiUrl = '/api/upload/init'.startsWith('/') ? '/api/upload/init'.substring(1) : '/api/upload/init';
const response = await fetch(apiUrl, {
method: 'POST',
headers,
body: JSON.stringify({
filename: uploadPath.replace(/\\/g, '/'), // Ensure forward slashes
fileSize: this.file.size
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.details || error.error || 'Upload initialization failed');
}
const data = await response.json();
this.uploadId = data.uploadId;
}
async uploadChunks() {
this.createProgressElement();
let currentChunkStartPosition = this.position; // Track start position for retries
while (this.position < this.file.size) {
const chunk = await this.readChunk(); // Reads based on current this.position
try {
// Attempt to upload the chunk with retry logic
// Pass the position *before* reading the chunk, as that's the start of the data being sent
await this.uploadChunkWithRetry(chunk, currentChunkStartPosition);
// If successful, update the start position for the *next* chunk read
// this.position is updated internally by readChunk, so currentChunkStartPosition reflects the next read point
currentChunkStartPosition = this.position;
} catch (error) {
// If uploadChunkWithRetry fails after all retries, propagate the error
console.error(`UploadChunks failed after retries for chunk starting at ${currentChunkStartPosition}. File: ${this.file.webkitRelativePath || this.file.name}`);
throw error; // Propagate up to the start() method's catch block
}
}
}
async readChunk() {
const start = this.position;
const end = Math.min(this.position + this.chunkSize, this.file.size);
const blob = this.file.slice(start, end);
this.position = end;
return await blob.arrayBuffer();
}
async uploadChunkWithRetry(chunk, chunkStartPosition) {
const chunkApiUrlPath = `/api/upload/chunk/${this.uploadId}`;
const chunkApiUrl = chunkApiUrlPath.startsWith('/') ? chunkApiUrlPath.substring(1) : chunkApiUrlPath;
let lastError = null;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
if (attempt > 0) {
console.warn(`Retrying chunk (start: ${chunkStartPosition}) upload for ${this.file.webkitRelativePath || this.file.name} (Attempt ${attempt}/${this.maxRetries})...`);
this.updateProgressElementInfo(`Retrying attempt ${attempt}...`, 'var(--warning-color)');
}
// Use AbortController for potential timeout or cancellation during fetch
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30-second timeout per attempt
const response = await fetch(chunkApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-Batch-ID': this.batchId
// Consider adding 'Content-Range': `bytes ${chunkStartPosition}-${chunkStartPosition + chunk.byteLength - 1}/${this.file.size}`
// If the server supports handling potential duplicate chunks via Content-Range
},
body: chunk,
signal: controller.signal // Add abort signal
});
clearTimeout(timeoutId); // Clear timeout if fetch completes
if (response.ok) {
const data = await response.json();
if (attempt > 0) {
console.log(`Chunk upload successful on retry attempt ${attempt} for ${this.file.webkitRelativePath || this.file.name}`);
}
// Update progress based on server response
// this.position is updated by readChunk(), so progress reflects total uploaded
this.updateProgress(data.progress);
// Success! Exit the retry loop.
this.updateProgressElementInfo('uploading...'); // Reset info message
return;
} else {
// Server responded with an error status (4xx, 5xx)
let errorText = 'Unknown server error';
try {
errorText = await response.text();
} catch (textError) { /* ignore if reading text fails */ }
// --- Add Special 404 Handling ---
if (response.status === 404 && attempt > 0) {
console.warn(`Received 404 Not Found on retry attempt ${attempt} for ${this.file.webkitRelativePath || this.file.name}. Assuming upload completed previously.`);
this.updateProgress(100); // Mark as complete
return; // Exit retry loop successfully
}
// --- End Special 404 Handling ---
lastError = new Error(`Failed to upload chunk: ${response.status} ${response.statusText}. Server response: ${errorText}`);
console.error(`Chunk upload attempt ${attempt} failed: ${lastError.message}`);
this.updateProgressElementInfo(`Attempt ${attempt} failed: ${response.statusText}`, 'var(--danger-color)');
}
} catch (error) {
// Network error, fetch failed completely, or timeout
lastError = error;
if (error.name === 'AbortError') {
console.error(`Chunk upload attempt ${attempt} timed out after 30 seconds.`);
this.updateProgressElementInfo(`Attempt ${attempt} timed out`, 'var(--danger-color)');
} else {
console.error(`Chunk upload attempt ${attempt} failed with network error: ${error.message}`);
this.updateProgressElementInfo(`Attempt ${attempt} network error`, 'var(--danger-color)');
}
}
// If not the last attempt, wait before retrying
if (attempt < this.maxRetries) {
// Exponential backoff: 1s, 2s, 4s, ... but capped
const delay = Math.min(this.retryDelay * Math.pow(2, attempt), 30000); // Max 30s delay
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// If we exit the loop, all retries have failed.
// Position reset is tricky. If the server *did* receive a chunk but failed to respond OK,
// simply resending might corrupt data unless the server handles it idempotently.
// Failing the whole upload is often safer.
// this.position = chunkStartPosition; // Re-enable if server can handle duplicate chunks safely
console.error(`Chunk upload failed permanently after ${this.maxRetries} retries for ${this.file.webkitRelativePath || this.file.name}, chunk starting at ${chunkStartPosition}.`);
this.updateProgressElementInfo(`Upload failed after ${this.maxRetries} retries`, 'var(--danger-color)');
throw lastError || new Error(`Chunk upload failed after ${this.maxRetries} retries.`);
}
createProgressElement() {
const container = document.createElement('div');
container.className = 'progress-container';
const label = document.createElement('div');
label.className = 'progress-label';
const fileName = this.file.webkitRelativePath || this.file.name;
label.textContent = escapeHtml(fileName);
const progress = document.createElement('div');
progress.className = 'progress';
const bar = document.createElement('div');
bar.className = 'progress-bar';
const status = document.createElement('div');
status.className = 'progress-status';
const info = document.createElement('div');
info.className = 'progress-info';
const details = document.createElement('div');
details.className = 'progress-details';
status.appendChild(info);
status.appendChild(details);
progress.appendChild(bar);
container.appendChild(label);
container.appendChild(progress);
container.appendChild(status);
document.getElementById('uploadProgress').appendChild(container);
this.progressElement = { container, bar, infoSpan: info, detailsSpan: details };
this.lastUploadTime = Date.now();
}
updateProgress(percent) {
if (this.progressElement) {
this.progressElement.bar.style.width = `${percent}%`;
// Calculate upload rate
const currentTime = Date.now();
const timeDiff = (currentTime - this.lastUploadTime) / 1000; // Convert to seconds
const bytesDiff = this.position - this.lastUploadedBytes;
if (timeDiff > 0) {
this.uploadRate = bytesDiff / timeDiff; // bytes per second
}
// Format upload rate
let rateText = '0.0 B/s';
if (this.uploadRate > 0) {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let unitIndex = 0;
let rate = this.uploadRate;
while (rate >= 1024 && unitIndex < units.length - 1) {
rate /= 1024;
unitIndex++;
}
rateText = `${rate.toFixed(1)} ${units[unitIndex]}`;
}
// Update progress info
const statusText = percent < 100 ? 'uploading...' : 'complete';
// Use the helper for info updates, only update if not showing a retry message
if (!this.progressElement.infoSpan.textContent.startsWith('Retry') && !this.progressElement.infoSpan.textContent.startsWith('Attempt')) {
this.updateProgressElementInfo(`${rateText} · ${statusText}`);
}
this.progressElement.detailsSpan.textContent =
`${formatFileSize(this.position)} of ${formatFileSize(this.file.size)} (${percent.toFixed(1)}%)`;
// Update tracking variables
this.lastUploadedBytes = this.position;
this.lastUploadTime = currentTime;
if (percent === 100) {
setTimeout(() => {
this.progressElement.container.remove();
}, 1000);
}
}
}
// Helper to update the info message and color in the progress element
updateProgressElementInfo(message, color = '') {
if (this.progressElement && this.progressElement.infoSpan) {
this.progressElement.infoSpan.textContent = message;
this.progressElement.infoSpan.style.color = color; // Reset if color is empty string
}
}
// Helper to attempt cancellation on the server
async cancelUploadOnServer() {
if (!this.uploadId) return;
console.log(`Attempting to cancel upload ${this.uploadId} on server due to error.`);
try {
const cancelApiUrlPath = `/api/upload/cancel/${this.uploadId}`;
const cancelApiUrl = cancelApiUrlPath.startsWith('/') ? cancelApiUrlPath.substring(1) : cancelApiUrlPath;
// No need to wait for response here, just fire and forget
fetch(cancelApiUrl, { method: 'POST' }).catch(err => {
console.warn(`Sending cancel request failed for upload ${this.uploadId}:`, err);
});
} catch (cancelError) {
// Catch synchronous errors, though unlikely with fetch
console.warn(`Error initiating cancel request for upload ${this.uploadId}:`, cancelError);
} // Add closing brace for try block
}
}
// UI Event Handlers
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const fileList = document.getElementById('fileList');
const uploadButton = document.getElementById('uploadButton');
let files = [];
// For drag and drop folders
async function getAllFileEntries(dataTransferItems) {
console.debug('Starting getAllFileEntries with items:', Array.from(dataTransferItems).map(item => ({
kind: item.kind,
type: item.type
})));
let fileEntries = [];
let rootFolderName = null;
async function traverseEntry(entry, path = '') {
console.debug('Traversing entry:', {
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
currentPath: path
});
if (entry.isFile) {
const file = await new Promise((resolve, reject) => {
entry.file(file => {
console.debug('Processing file in traverseEntry:', {
name: file.name,
size: file.size,
type: file.type,
currentPath: path,
rootFolderName: rootFolderName
});
// If this is the first file and we don't have a root folder name yet,
// use its parent folder name
if (!rootFolderName && path) {
rootFolderName = path.split('/')[0];
console.debug('Set root folder name from file path:', rootFolderName);
}
const fullPath = path ? `${path}/${entry.name}` : entry.name;
console.debug('Constructed full path:', {
path: path,
entryName: entry.name,
fullPath: fullPath
});
const fileWithPath = new File([file], entry.name, {
type: file.type,
lastModified: file.lastModified
});
if (rootFolderName) {
const relativePath = fullPath.startsWith(rootFolderName) ?
fullPath :
`${rootFolderName}/${fullPath}`;
// Use Object.defineProperty to ensure webkitRelativePath is properly set
Object.defineProperty(fileWithPath, 'webkitRelativePath', {
value: relativePath,
writable: false,
configurable: true
});
console.debug('Set webkitRelativePath with root folder:', {
fullPath: fullPath,
rootFolderName: rootFolderName,
relativePath: relativePath,
hasWebkitRelativePath: 'webkitRelativePath' in fileWithPath,
webkitRelativePath: fileWithPath.webkitRelativePath
});
} else {
Object.defineProperty(fileWithPath, 'webkitRelativePath', {
value: fullPath,
writable: false,
configurable: true
});
console.debug('Set webkitRelativePath without root folder:', {
fullPath: fullPath,
hasWebkitRelativePath: 'webkitRelativePath' in fileWithPath,
webkitRelativePath: fileWithPath.webkitRelativePath
});
}
resolve(fileWithPath);
}, reject);
});
fileEntries.push(file);
} else if (entry.isDirectory) {
console.debug('Processing directory:', {
name: entry.name,
currentPath: path,
isRootLevel: !path,
currentRootFolderName: rootFolderName
});
if (!path && !rootFolderName) {
rootFolderName = entry.name;
console.debug('Set root folder name from directory:', rootFolderName);
}
const dirReader = entry.createReader();
let entries = [];
let readEntries = await new Promise((resolve, reject) => {
const readNextBatch = () => {
dirReader.readEntries(batch => {
console.debug('Read directory batch:', {
directoryName: entry.name,
batchSize: batch.length,
entries: batch.map(e => ({
name: e.name,
isFile: e.isFile,
isDirectory: e.isDirectory
}))
});
if (batch.length > 0) {
entries = entries.concat(batch);
readNextBatch();
} else {
resolve(entries);
}
}, reject);
};
readNextBatch();
});
const dirPath = path ? `${path}/${entry.name}` : entry.name;
console.debug('Processing directory contents:', {
directoryName: entry.name,
currentPath: path,
newDirPath: dirPath,
totalEntries: entries.length
});
for (const childEntry of entries) {
await traverseEntry(childEntry, dirPath);
}
}
}
try {
for (const item of dataTransferItems) {
const entry = item.webkitGetAsEntry();
if (entry) {
console.debug('Processing root level item:', {
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory
});
await traverseEntry(entry);
}
}
console.debug('Final file entries before sorting:', fileEntries.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size,
hasWebkitRelativePath: 'webkitRelativePath' in f,
webkitRelativePath: f.webkitRelativePath
})));
fileEntries.sort((a, b) => a.webkitRelativePath.localeCompare(b.webkitRelativePath));
console.debug('Final sorted file entries:', fileEntries.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size,
hasWebkitRelativePath: 'webkitRelativePath' in f,
webkitRelativePath: f.webkitRelativePath
})));
return fileEntries;
} catch (error) {
console.error('Error in getAllFileEntries:', error);
throw error;
}
}
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
dropZone.addEventListener('drop', handleDrop, false);
fileInput.addEventListener('change', handleFiles, false);
folderInput.addEventListener('change', handleFolders, false);
uploadButton.addEventListener('click', startUploads);
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function highlight(e) {
dropZone.classList.add('highlight');
}
function unhighlight(e) {
dropZone.classList.remove('highlight');
}
function handleDrop(e) {
const items = e.dataTransfer.items;
if (items && items[0].webkitGetAsEntry) {
// Show loading state
const loadingItem = document.createElement('div');
loadingItem.className = 'file-item loading';
loadingItem.textContent = 'Processing dropped items...';
fileList.appendChild(loadingItem);
getAllFileEntries(items).then(newFiles => {
// Remove loading state
loadingItem.remove();
if (newFiles.length === 0) {
console.warn('No valid files found in drop');
return;
}
files = newFiles;
updateFileList();
if (AUTO_UPLOAD) startUploads();
}).catch(error => {
console.error('Error processing dropped items:', error);
loadingItem.textContent = `Error: ${error.message}`;
loadingItem.style.color = 'var(--danger-color)';
setTimeout(() => loadingItem.remove(), 3000);
});
} else {
// Handle regular files (not folders)
files = [...e.dataTransfer.files];
updateFileList();
if (AUTO_UPLOAD) startUploads();
}
}
function handleFiles(e) {
// Reset the input to allow selecting the same file again
const input = e.target;
files = [...input.files];
updateFileList();
if (AUTO_UPLOAD) {
startUploads().finally(() => {
// Reset the input value after upload
input.value = '';
});
}
}
function handleFolders(e) {
// Reset the input to allow selecting the same folder again
const input = e.target;
files = [...input.files];
// Check for webkitRelativePath support
const missingRelPath = files.some(f => !('webkitRelativePath' in f) || !f.webkitRelativePath);
if (missingRelPath) {
alert('Your browser does not support folder uploads with structure. Please use a modern browser like Chrome or Edge.');
files = [];
updateFileList();
input.value = '';
return;
}
console.log('Folder selection files:', files.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size
})));
updateFileList();
if (AUTO_UPLOAD) {
startUploads().finally(() => {
// Reset the input value after upload
input.value = '';
});
}
}
function updateFileList() {
console.debug('Starting updateFileList with files:', files.map(f => ({
name: f.name,
path: f.webkitRelativePath,
size: f.size,
hasWebkitRelativePath: 'webkitRelativePath' in f,
webkitRelativePath: f.webkitRelativePath
})));
fileList.innerHTML = '';
const folders = new Map();
let rootFiles = [];
// First, determine if we have a root folder structure
const hasRootFolder = files.some(file => {
const hasPath = 'webkitRelativePath' in file && file.webkitRelativePath;
console.debug('Checking file for root folder:', {
name: file.name,
hasWebkitRelativePath: 'webkitRelativePath' in file,
webkitRelativePath: file.webkitRelativePath,
hasPath: hasPath
});
return hasPath && file.webkitRelativePath.includes('/');
});
console.debug('Folder structure detection:', {
hasRootFolder,
totalFiles: files.length
});
files.forEach(file => {
const path = file.webkitRelativePath || file.name;
console.debug('Processing file in updateFileList:', {
name: file.name,
path: path,
size: file.size,
hasWebkitRelativePath: 'webkitRelativePath' in file
});
if (hasRootFolder) {
const parts = path.split('/');
console.debug('Path parts:', {
path: path,
parts: parts,
length: parts.length
});
if (parts.length > 1) {
const folderName = parts[0];
console.debug('Processing folder:', {
folderName: folderName,
pathParts: parts,
fileName: file.name
});
if (!folders.has(folderName)) {
folders.set(folderName, {
name: folderName,
files: [],
size: 0,
subfolders: new Map()
});
console.debug('Created new folder:', folderName);
}
const folder = folders.get(folderName);
folder.files.push(file);
folder.size += file.size;
if (parts.length > 2) {
console.debug('Processing subfolder structure:', {
folderName: folderName,
subfolderPath: parts.slice(1, -1),
fileName: parts[parts.length - 1]
});
let currentFolder = folder;
const subfolderPath = parts.slice(1, -1);
for (const subfolder of subfolderPath) {
if (!currentFolder.subfolders.has(subfolder)) {
currentFolder.subfolders.set(subfolder, {
name: subfolder,
files: [],
size: 0,
subfolders: new Map()
});
console.debug('Created new subfolder:', {
parentFolder: currentFolder.name,
subfolder: subfolder
});
}
currentFolder = currentFolder.subfolders.get(subfolder);
currentFolder.files.push(file);
currentFolder.size += file.size;
}
}
} else {
console.debug('Adding root file:', file.name);
rootFiles.push(file);
}
} else {
console.debug('Adding file as root file:', file.name);
rootFiles.push(file);
}
});
console.debug('Final structure:', {
rootFiles: rootFiles.map(f => f.name),
folders: Array.from(folders.entries()).map(([name, folder]) => ({
name,
fileCount: folder.files.length,
size: folder.size,
subfolders: Array.from(folder.subfolders.keys())
}))
});
// Function to render a folder's contents
function renderFolder(folder, level = 0) {
const folderItem = document.createElement('div');
folderItem.className = 'file-item folder';
// Calculate total files including subfolders
const totalFiles = folder.files.filter(f => {
const relativePath = f.webkitRelativePath.substring(folder.name.length + 1);
return relativePath.split('/').length === 1;
}).length;
folderItem.textContent = `📁 ${escapeHtml(folder.name)}/ (${formatFileSize(folder.size)} - ${totalFiles} files)`;
// Add files in folder
const filesList = document.createElement('div');
filesList.className = 'folder-files';
filesList.style.marginLeft = `${level * 20}px`;
// First add direct files
folder.files
.filter(f => f.webkitRelativePath.substring(folder.name.length + 1).split('/').length === 1)
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item nested';
const relativePath = file.webkitRelativePath.substring(folder.name.length + 1);
fileItem.textContent = `📄 ${escapeHtml(relativePath)} (${formatFileSize(file.size)})`;
filesList.appendChild(fileItem);
});
// Then add subfolders recursively
if (folder.subfolders.size > 0) {
Array.from(folder.subfolders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(subfolder => {
filesList.appendChild(renderFolder(subfolder, level + 1));
});
}
folderItem.appendChild(filesList);
return folderItem;
}
// Add root files first
rootFiles.sort((a, b) => a.name.localeCompare(b.name))
.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.textContent = `📄 ${escapeHtml(file.name)} (${formatFileSize(file.size)})`;
fileList.appendChild(fileItem);
});
// Add folders
Array.from(folders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(folder => {
fileList.appendChild(renderFolder(folder));
});
uploadButton.style.display = (!AUTO_UPLOAD && files.length > 0) ? 'block' : 'none';
}
// Add this CSS to the existing styles
const style = document.createElement('style');
style.textContent = `
.folder-files {
margin-left: 20px;
border-left: 1px solid var(--border-color);
padding-left: 10px;
margin-top: 5px;
}
.file-item.nested {
font-size: 0.9em;
margin-top: 3px;
}
.file-item.loading {
text-align: center;
padding: 15px;
background: var(--container-bg);
border-radius: 5px;
margin: 10px 0;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
/* Uploaded files listing styles */
.uploaded-files-section {
margin-top: 30px;
padding: 20px;
background: var(--container-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
}
.uploaded-files-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.uploaded-files-header h2 {
margin: 0;
color: var(--text-color);
font-size: 1.5em;
}
.uploaded-files-stats {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-color-secondary);
font-size: 0.9em;
}
.refresh-btn {
padding: 5px 10px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.85em;
transition: background 0.3s;
}
.refresh-btn:hover {
background: var(--primary-hover);
}
.uploaded-files-content {
max-height: 400px;
overflow-y: auto;
}
.uploaded-file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
margin: 5px 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 5px;
transition: background 0.3s;
}
.uploaded-file-item:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
[data-theme="dark"] .uploaded-file-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.uploaded-file-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.uploaded-file-name {
font-weight: 500;
color: var(--text-color);
margin-bottom: 2px;
word-break: break-all;
}
.uploaded-file-details {
font-size: 0.85em;
color: var(--text-color-secondary);
}
.uploaded-file-actions {
display: flex;
gap: 5px;
margin-left: 10px;
}
.action-btn {
padding: 5px 8px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 0.8em;
transition: all 0.3s;
min-width: 60px;
}
.download-btn {
background: var(--success-color);
color: white;
}
.download-btn:hover {
background: var(--success-hover, #27ae60);
}
.rename-btn {
background: var(--warning-color, #f39c12);
color: white;
}
.rename-btn:hover {
background: var(--warning-hover, #e67e22);
}
.delete-btn {
background: var(--danger-color);
color: white;
}
.delete-btn:hover {
background: var(--danger-hover, #c0392b);
}
.directory-item {
border-left: 3px solid var(--primary-color);
}
.directory-children {
margin-left: 20px;
border-left: 1px solid var(--border-color);
padding-left: 10px;
margin-top: 5px;
}
.loading-message, .error-message {
text-align: center;
padding: 20px;
color: var(--text-color-secondary);
}
.error-message {
color: var(--danger-color);
}
.empty-message {
text-align: center;
padding: 40px 20px;
color: var(--text-color-secondary);
font-style: italic;
}
/* Rename modal styles */
.rename-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.rename-modal.show {
display: flex;
}
.rename-modal-content {
background: var(--container-bg);
padding: 20px;
border-radius: 10px;
border: 1px solid var(--border-color);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.rename-modal h3 {
margin: 0 0 15px 0;
color: var(--text-color);
}
.rename-input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-color);
color: var(--text-color);
font-size: 14px;
margin-bottom: 15px;
}
.rename-input:focus {
outline: none;
border-color: var(--primary-color);
}
.rename-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-btn {
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.modal-btn-cancel {
background: var(--secondary-color, #6c757d);
color: white;
}
.modal-btn-cancel:hover {
background: var(--secondary-hover, #5a6268);
}
.modal-btn-confirm {
background: var(--primary-color);
color: white;
}
.modal-btn-confirm:hover {
background: var(--primary-hover);
}
`;
document.head.appendChild(style);
async function startUploads() {
try {
uploadButton.disabled = true;
document.getElementById('uploadProgress').innerHTML = '';
const batchId = generateBatchId();
const results = [];
// Process files sequentially within the same batch to prevent overwhelming the server
for (const file of files) {
const uploader = new FileUploader(file, batchId);
const result = await uploader.start();
results.push(result);
}
const successful = results.filter(r => r).length;
const total = results.length;
Toastify({
text: `Uploaded ${successful} of ${total} files`,
duration: 3000,
gravity: "bottom",
position: "right",
style: {
background: successful === total ? "#4CAF50" : "#f44336"
}
}).showToast();
// Reset file inputs
fileInput.value = '';
folderInput.value = '';
files = [];
updateFileList();
// Refresh the uploaded files list if it's enabled
if (SHOW_FILE_LIST && fileListManager) {
fileListManager.loadFiles();
}
} catch (error) {
console.error('Upload failed:', error);
Toastify({
text: `Upload failed: ${error.message}`,
duration: 3000,
gravity: "bottom",
position: "right",
style: {
background: "#f44336"
}
}).showToast();
} finally {
uploadButton.disabled = false;
}
}
// File listing functionality
class FileListManager {
constructor() {
this.uploadedFilesList = document.getElementById('uploadedFilesList');
this.uploadedFilesContent = document.getElementById('uploadedFilesContent');
this.totalFilesSpan = document.getElementById('totalFiles');
this.totalSizeSpan = document.getElementById('totalSize');
this.refreshBtn = document.getElementById('refreshFilesBtn');
this.renameModal = document.getElementById('renameModal');
this.renameInput = document.getElementById('renameInput');
this.currentRenameData = null;
if (SHOW_FILE_LIST) {
this.init();
}
}
init() {
this.uploadedFilesList.style.display = 'block';
this.refreshBtn.addEventListener('click', () => this.loadFiles());
this.setupModalEventListeners();
this.loadFiles();
}
setupModalEventListeners() {
// Ensure modal closes when clicking outside
this.renameModal.addEventListener('click', (e) => {
if (e.target === this.renameModal) {
this.cancelRename();
}
});
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (this.renameModal.classList.contains('show')) {
if (e.key === 'Escape') {
this.cancelRename();
}
}
});
}
async loadFiles() {
try {
this.showLoading();
const response = await fetch('/api/files');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.displayFiles(data);
} catch (error) {
console.error('Failed to load files:', error);
this.showError('Failed to load files: ' + error.message);
}
}
showLoading() {
this.uploadedFilesContent.innerHTML = '<div class="loading-message">Loading files...</div>';
}
showError(message) {
this.uploadedFilesContent.innerHTML = `<div class="error-message">${escapeHtml(message)}</div>`;
}
displayFiles(data) {
this.totalFilesSpan.textContent = `${data.totalFiles} file${data.totalFiles !== 1 ? 's' : ''}`;
this.totalSizeSpan.textContent = data.formattedTotalSize || formatFileSize(data.totalSize);
if (data.items.length === 0) {
this.uploadedFilesContent.innerHTML = '<div class="empty-message">No files uploaded yet</div>';
return;
}
this.uploadedFilesContent.innerHTML = '';
this.renderItems(data.items, this.uploadedFilesContent);
}
renderItems(items, container, level = 0) {
items.forEach(item => {
const itemElement = this.createItemElement(item, level);
container.appendChild(itemElement);
if (item.type === 'directory' && item.children && item.children.length > 0) {
const childrenContainer = document.createElement('div');
childrenContainer.className = 'directory-children';
this.renderItems(item.children, childrenContainer, level + 1);
container.appendChild(childrenContainer);
}
});
}
createItemElement(item, level) {
const itemDiv = document.createElement('div');
itemDiv.className = `uploaded-file-item ${item.type === 'directory' ? 'directory-item' : ''}`;
const icon = item.type === 'directory' ? '📁' : '📄';
const name = item.name;
const details = item.type === 'directory'
? `${item.formattedSize}${this.countFilesInDirectory(item)} files`
: `${item.formattedSize}${new Date(item.uploadDate).toLocaleDateString()}`;
// Create info section
const infoDiv = document.createElement('div');
infoDiv.className = 'uploaded-file-info';
const nameDiv = document.createElement('div');
nameDiv.className = 'uploaded-file-name';
nameDiv.textContent = `${icon} ${name}`;
const detailsDiv = document.createElement('div');
detailsDiv.className = 'uploaded-file-details';
detailsDiv.textContent = details;
infoDiv.appendChild(nameDiv);
infoDiv.appendChild(detailsDiv);
// Create actions section
const actionsDiv = document.createElement('div');
actionsDiv.className = 'uploaded-file-actions';
// Download button (only for files)
if (item.type === 'file') {
const downloadBtn = document.createElement('button');
downloadBtn.className = 'action-btn download-btn';
downloadBtn.textContent = 'Download';
downloadBtn.dataset.path = item.path;
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.downloadFile(downloadBtn.dataset.path);
});
actionsDiv.appendChild(downloadBtn);
}
// Rename button
const renameBtn = document.createElement('button');
renameBtn.className = 'action-btn rename-btn';
renameBtn.textContent = 'Rename';
renameBtn.dataset.path = item.path;
renameBtn.dataset.type = item.type;
renameBtn.dataset.name = item.name;
renameBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.renameItem(renameBtn.dataset.path, renameBtn.dataset.type, renameBtn.dataset.name);
});
actionsDiv.appendChild(renameBtn);
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'action-btn delete-btn';
deleteBtn.textContent = 'Delete';
deleteBtn.dataset.path = item.path;
deleteBtn.dataset.type = item.type;
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteItem(deleteBtn.dataset.path, deleteBtn.dataset.type);
});
actionsDiv.appendChild(deleteBtn);
// Assemble the item
itemDiv.appendChild(infoDiv);
itemDiv.appendChild(actionsDiv);
return itemDiv;
}
countFilesInDirectory(dir) {
if (!dir.children) return 0;
return dir.children.reduce((count, child) => {
return count + (child.type === 'file' ? 1 : this.countFilesInDirectory(child));
}, 0);
}
async downloadFile(filePath) {
try {
// Encode each part of the path separately to handle subdirectories
const encodedPath = filePath.split('/').map(part => encodeURIComponent(part)).join('/');
const url = `/api/files/download/${encodedPath}`;
window.open(url, '_blank');
} catch (error) {
console.error('Download failed:', error);
Toastify({
text: `Download failed: ${error.message}`,
duration: 3000,
gravity: "bottom",
position: "right",
style: { background: "#f44336" }
}).showToast();
}
}
async deleteItem(itemPath, itemType) {
const itemName = itemPath.split('/').pop();
const confirmMessage = itemType === 'directory'
? `Are you sure you want to delete the folder "${itemName}" and all its contents?`
: `Are you sure you want to delete the file "${itemName}"?`;
if (!confirm(confirmMessage)) {
return;
}
try {
// Encode each part of the path separately to handle subdirectories
const encodedPath = itemPath.split('/').map(part => encodeURIComponent(part)).join('/');
const response = await fetch(`/api/files/${encodedPath}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Delete failed');
}
Toastify({
text: `${itemType === 'directory' ? 'Folder' : 'File'} deleted successfully`,
duration: 3000,
gravity: "bottom",
position: "right",
style: { background: "#4CAF50" }
}).showToast();
// Reload the file list
this.loadFiles();
} catch (error) {
console.error('Delete failed:', error);
Toastify({
text: `Delete failed: ${error.message}`,
duration: 3000,
gravity: "bottom",
position: "right",
style: { background: "#f44336" }
}).showToast();
}
}
async renameItem(itemPath, itemType, currentName) {
this.currentRenameData = { itemPath, itemType, currentName };
this.renameInput.value = currentName;
this.renameModal.classList.add('show');
this.renameInput.focus();
this.renameInput.select();
// Add Enter key support for this specific rename session
const handleKeydown = (e) => {
if (e.key === 'Enter') {
this.confirmRename();
}
};
this.renameInput.addEventListener('keydown', handleKeydown);
// Store the handler so we can remove it later
this.currentKeydownHandler = handleKeydown;
}
cancelRename() {
this.renameModal.classList.remove('show');
this.currentRenameData = null;
// Clean up event listener
if (this.currentKeydownHandler) {
this.renameInput.removeEventListener('keydown', this.currentKeydownHandler);
this.currentKeydownHandler = null;
}
}
async confirmRename() {
if (!this.currentRenameData) return;
const newName = this.renameInput.value.trim();
if (!newName) {
alert('Please enter a valid name');
return;
}
if (newName === this.currentRenameData.currentName) {
this.cancelRename();
return;
}
try {
// Encode each part of the path separately to handle subdirectories
const encodedPath = this.currentRenameData.itemPath.split('/').map(part => encodeURIComponent(part)).join('/');
const response = await fetch(`/api/files/rename/${encodedPath}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ newName })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Rename failed');
}
const result = await response.json();
Toastify({
text: result.message,
duration: 3000,
gravity: "bottom",
position: "right",
style: { background: "#4CAF50" }
}).showToast();
// Close modal and reload file list
this.cancelRename();
this.loadFiles();
} catch (error) {
console.error('Rename failed:', error);
Toastify({
text: `Rename failed: ${error.message}`,
duration: 3000,
gravity: "bottom",
position: "right",
style: { background: "#f44336" }
}).showToast();
}
}
}
// Initialize file list manager
const fileListManager = new FileListManager();
// Theme management
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Update icon
const moonPaths = document.querySelectorAll('.moon');
const sunPaths = document.querySelectorAll('.sun');
if (theme === 'dark') {
moonPaths.forEach(path => path.style.display = 'none');
sunPaths.forEach(path => path.style.display = '');
} else {
moonPaths.forEach(path => path.style.display = '');
sunPaths.forEach(path => path.style.display = 'none');
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
setTheme(next);
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
setTheme(savedTheme);
</script>
</body>
</html>