diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index fb5f122b72..dbe49b9f9c 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -4,6 +4,7 @@ import ( "bufio" "compress/gzip" "context" + "encoding/json" "errors" "fmt" "io" @@ -289,13 +290,26 @@ func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) } } + // A folder upload names each file by its path inside the folder; the multipart + // file name keeps only the base name. + var relativePaths []string + if v, ok := r.MultipartForm.Value["relativePaths"]; ok { + if len(v) != 1 || json.Unmarshal([]byte(v[0]), &relativePaths) != nil || len(relativePaths) != len(files) { + writeJSONError(w, http.StatusBadRequest, "relativePaths must list one path per uploaded file") + return + } + } + var uploadResults []map[string]interface{} var failedUploads []string // Process each uploaded file - for _, fileHeader := range files { + for i, fileHeader := range files { // Validate file name fileName := fileHeader.Filename + if relativePaths != nil { + fileName = relativePaths[i] + } if fileName == "" { failedUploads = append(failedUploads, "invalid filename") continue @@ -303,6 +317,10 @@ func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) // Normalize Windows-style backslashes to forward slashes fileName = util.CleanWindowsPath(fileName) + if relativePaths != nil && !isRelativeUploadPath(fileName) { + failedUploads = append(failedUploads, fmt.Sprintf("%s: invalid relative path", fileName)) + continue + } // Create full path for the file using path.Join for URL path semantics // path.Join handles double slashes and is not OS-specific like filepath.Join @@ -349,6 +367,16 @@ func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) } } +// isRelativeUploadPath reports whether name stays inside the upload directory. +func isRelativeUploadPath(name string) bool { + for _, part := range strings.Split(name, "/") { + if part == "" || part == "." || part == ".." { + return false + } + } + return true +} + // uploadFormOverhead allows for the multipart headers and the path field on // top of the file bytes an upload may carry. Go's multipart parser accepts at // most 1000 parts, whose headers take about half of this. diff --git a/weed/admin/handlers/file_browser_handlers_test.go b/weed/admin/handlers/file_browser_handlers_test.go index 179be2a6a0..1566ebacb3 100644 --- a/weed/admin/handlers/file_browser_handlers_test.go +++ b/weed/admin/handlers/file_browser_handlers_test.go @@ -216,3 +216,58 @@ func TestUploadFile_RemovesSpooledParts(t *testing.T) { t.Errorf("spooled parts left behind: %v", left) } } + +func TestIsRelativeUploadPath(t *testing.T) { + for name, want := range map[string]bool{ + "a.txt": true, + "photos/a.jpg": true, + "a/b/c/..x.txt": true, + "/etc/a": false, + "a/../../b": false, + "..": false, + "a//b": false, + "./a": false, + "a/": false, + } { + if got := isRelativeUploadPath(name); got != want { + t.Errorf("isRelativeUploadPath(%q) = %v, want %v", name, got, want) + } + } +} + +func TestUploadFile_FolderRelativePaths(t *testing.T) { + h := &FileBrowserHandlers{adminServer: &dash.AdminServer{}} + upload := func(relativePaths string) *httptest.ResponseRecorder { + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for _, name := range []string{"a.txt", "b.txt"} { + part, err := mw.CreateFormFile("files", name) + if err != nil { + t.Fatal(err) + } + part.Write([]byte(name)) + } + mw.WriteField("path", "/buckets/b") + mw.WriteField("relativePaths", relativePaths) + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/files/upload", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.UploadFile(w, req) + return w + } + + for _, paths := range []string{`["dir/a.txt"]`, `not json`, ``, `null`} { + if w := upload(paths); w.Code != http.StatusBadRequest { + t.Errorf("relativePaths %s: expected 400, got %d (body: %s)", paths, w.Code, w.Body.String()) + } + } + + w := upload(`["../a.txt", "dir\\..\\..\\b.txt"]`) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected every escaping path to fail, got %d (body: %s)", w.Code, w.Body.String()) + } + if strings.Count(w.Body.String(), "invalid relative path") != 2 { + t.Errorf("expected both paths rejected before upload, got %s", w.Body.String()) + } +} diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js index 77681f26fc..ebabb25a32 100644 --- a/weed/admin/static/js/admin.js +++ b/weed/admin/static/js/admin.js @@ -1167,18 +1167,132 @@ async function submitCreateFolder() { } } +// Files dropped on the listing, each with its path under the current directory; +// null when the modal's inputs hold the selection. +let droppedUploadFiles = null; +// Bumped by every drop and picker change, so a slow folder read cannot replace a newer selection. +let uploadSelection = 0; + +function selectedUploadFiles() { + if (droppedUploadFiles) { + return droppedUploadFiles; + } + const selected = []; + ['fileInput', 'folderInput'].forEach(id => { + const input = document.getElementById(id); + Array.from((input && input.files) || []).forEach(file => { + selected.push({ file, path: file.webkitRelativePath || file.name }); + }); + }); + return selected; +} + +// Lists the dropped files, walking into dropped folders and stopping once +// isCurrent turns false. The entries must be taken before the first await, +// while the drop event still exposes them. +async function collectDroppedFiles(dataTransfer, isCurrent) { + const entries = Array.from(dataTransfer.items || []) + .map(item => item.webkitGetAsEntry && item.webkitGetAsEntry()) + .filter(Boolean); + if (entries.length === 0) { + return Array.from(dataTransfer.files).map(file => ({ file, path: file.name })); + } + const collected = []; + async function walk(entry) { + if (!isCurrent()) { + return; + } + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + collected.push({ file, path: entry.fullPath.replace(/^\/+/, '') }); + return; + } + const reader = entry.createReader(); + while (isCurrent()) { + const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + if (batch.length === 0) { + return; + } + for (const child of batch) { + await walk(child); + } + } + } + for (const entry of entries) { + await walk(entry); + } + return collected; +} + +// Go's multipart parser rejects a form of more than 1000 parts, so a large +// selection goes out in several requests. +const uploadBatchSize = 500; +// Longer file lists show this many rows and a count of the rest. +const uploadListLimit = 200; +// Set once files are uploaded while the modal stays open, so closing it reloads the listing. +let uploadChangedListing = false; +// The batch in flight, aborted when the modal closes. +let activeUploadRequest = null; + +function postUploadBatch(batch, currentPath, onProgress) { + const formData = new FormData(); + batch.forEach(item => { + formData.append('files', item.file); + }); + if (batch.some(item => item.path !== item.file.name)) { + formData.append('relativePaths', JSON.stringify(batch.map(item => item.path))); + } + formData.append('path', currentPath); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('progress', function (e) { + if (e.lengthComputable) { + onProgress(e.loaded / e.total); + } + }); + xhr.addEventListener('load', function () { + let response = null; + try { + response = JSON.parse(xhr.responseText); + } catch (e) { + // reported below + } + // Per-file failures come back with counts, even when all of them failed. + if (response && typeof response.uploaded === 'number') { + resolve(response); + return; + } + if (xhr.status === 200) { + reject(new Error('Upload completed but response format was unexpected')); + return; + } + const errorMessage = (response && (response.error || response.message)) || `Server returned status ${xhr.status}`; + reject(new Error(`Failed to upload files: ${errorMessage}`)); + }); + xhr.addEventListener('error', function () { + reject(new Error('Failed to upload files. Please check your connection and try again.')); + }); + xhr.addEventListener('abort', function () { + reject(new Error('File upload was cancelled.')); + }); + xhr.open('POST', basePath('/api/files/upload')); + activeUploadRequest = xhr; + xhr.send(formData); + }); +} + // Submit upload file form async function submitUploadFile() { - const fileInput = document.getElementById('fileInput'); const currentPath = document.getElementById('uploadPath').value; + const selected = selectedUploadFiles(); - if (!fileInput.files || fileInput.files.length === 0) { - showErrorMessage('Please select at least one file to upload'); + if (selected.length === 0) { + showErrorMessage('Please select at least one file or folder to upload'); return; } - const files = Array.from(fileInput.files); - const totalSize = files.reduce((sum, file) => sum + file.size, 0); + const totalSize = selected.reduce((sum, item) => sum + item.file.size, 0); const maxSize = Number(document.getElementById('uploadFileForm').dataset.maxSize) || 0; if (maxSize > 0 && totalSize > maxSize) { @@ -1186,14 +1300,6 @@ async function submitUploadFile() { return; } - // Individual file size validation removed - no limit per file - - const formData = new FormData(); - files.forEach(file => { - formData.append('files', file); - }); - formData.append('path', currentPath); - // Show progress bar and disable button const progressContainer = document.getElementById('uploadProgress'); const progressBar = progressContainer.querySelector('.progress-bar'); @@ -1201,100 +1307,71 @@ async function submitUploadFile() { const submitButton = document.querySelector('#uploadFileModal .btn-primary'); const originalText = submitButton.innerHTML; + const showProgress = percentComplete => { + progressBar.style.width = percentComplete + '%'; + progressBar.setAttribute('aria-valuenow', percentComplete); + progressBar.textContent = percentComplete + '%'; + uploadStatus.textContent = `Uploading ${selected.length} file(s)... ${percentComplete}%`; + }; + progressContainer.style.display = 'block'; - progressBar.style.width = '0%'; - progressBar.setAttribute('aria-valuenow', '0'); - progressBar.textContent = '0%'; - uploadStatus.textContent = `Uploading ${files.length} file(s)...`; + showProgress(0); + uploadStatus.textContent = `Uploading ${selected.length} file(s)...`; submitButton.disabled = true; submitButton.innerHTML = 'Uploading...'; + const result = { uploaded: 0, failed: 0, errors: [] }; + let failure = ''; try { - const xhr = new XMLHttpRequest(); - - // Handle progress - xhr.upload.addEventListener('progress', function (e) { - if (e.lengthComputable) { - const percentComplete = Math.round((e.loaded / e.total) * 100); - progressBar.style.width = percentComplete + '%'; - progressBar.setAttribute('aria-valuenow', percentComplete); - progressBar.textContent = percentComplete + '%'; - uploadStatus.textContent = `Uploading ${files.length} file(s)... ${percentComplete}%`; + let sentSize = 0; + for (let start = 0; start < selected.length; start += uploadBatchSize) { + const batch = selected.slice(start, start + uploadBatchSize); + const batchSize = batch.reduce((sum, item) => sum + item.file.size, 0); + const response = await postUploadBatch(batch, currentPath, fraction => { + const done = totalSize > 0 + ? (sentSize + fraction * batchSize) / totalSize + : (start + fraction * batch.length) / selected.length; + showProgress(Math.round(done * 100)); + }); + sentSize += batchSize; + result.uploaded += response.uploaded; + result.failed += response.failed; + result.errors.push(...(response.errors || [])); + if (response.uploaded > 0) { + uploadChangedListing = true; } - }); - - // Handle completion - xhr.addEventListener('load', function () { - if (xhr.status === 200) { - try { - const response = JSON.parse(xhr.responseText); - - if (response.uploaded > 0) { - if (response.failed === 0) { - showSuccessMessage(`Successfully uploaded ${response.uploaded} file(s)`); - } else { - showSuccessMessage(response.message); - // Show details of failed uploads - if (response.errors && response.errors.length > 0) { - console.warn('Upload errors:', response.errors); - } - } - - // Hide modal and refresh page - const modal = bootstrap.Modal.getInstance(document.getElementById('uploadFileModal')); - modal.hide(); - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - let errorMessage = response.message || 'All file uploads failed'; - if (response.errors && response.errors.length > 0) { - errorMessage += ': ' + response.errors.join(', '); - } - showErrorMessage(errorMessage); - } - } catch (e) { - showErrorMessage('Upload completed but response format was unexpected'); - } - progressContainer.style.display = 'none'; - } else { - let errorMessage = 'Unknown error'; - try { - const error = JSON.parse(xhr.responseText); - errorMessage = error.error || error.message || errorMessage; - } catch (e) { - errorMessage = `Server returned status ${xhr.status}`; - } - showErrorMessage(`Failed to upload files: ${errorMessage}`); - progressContainer.style.display = 'none'; - } - }); - - // Handle errors - xhr.addEventListener('error', function () { - showErrorMessage('Failed to upload files. Please check your connection and try again.'); - progressContainer.style.display = 'none'; - }); - - // Handle abort - xhr.addEventListener('abort', function () { - showErrorMessage('File upload was cancelled.'); - progressContainer.style.display = 'none'; - }); - - // Send request - xhr.open('POST', basePath('/api/files/upload')); - xhr.send(formData); - + } } catch (error) { console.error('Upload error:', error); - showErrorMessage('Failed to upload files. Please try again.'); - progressContainer.style.display = 'none'; - } finally { - // Re-enable the button - submitButton.disabled = false; - submitButton.innerHTML = originalText; + uploadChangedListing = true; + failure = error.message || 'Failed to upload files. Please try again.'; + if (result.uploaded > 0) { + failure += ` (${result.uploaded} file(s) were uploaded before this)`; + } } + activeUploadRequest = null; + + submitButton.disabled = false; + submitButton.innerHTML = originalText; + + if (!failure && result.failed === 0) { + uploadChangedListing = false; + progressContainer.style.display = 'none'; + showSuccessMessage(`Successfully uploaded ${result.uploaded} file(s)`); + bootstrap.Modal.getInstance(document.getElementById('uploadFileModal')).hide(); + setTimeout(() => { + window.location.reload(); + }, 1000); + return; + } + + if (!failure) { + failure = result.uploaded === 0 ? 'All file uploads failed' : `Uploaded ${result.uploaded} file(s), ${result.failed} failed`; + } + showErrorMessage(escapeHtml(failure)); + uploadStatus.innerHTML = `
${escapeHtml(failure)}
` + + result.errors.slice(0, uploadListLimit).map(error => `
${escapeHtml(error)}
`).join('') + + (result.errors.length > uploadListLimit ? `
and ${result.errors.length - uploadListLimit} more
` : ''); } // Export file list to CSV @@ -1430,12 +1507,16 @@ function setupFileManagerEventHandlers() { } // Handle file selection change to show preview - const fileInput = document.getElementById('fileInput'); - if (fileInput) { - fileInput.addEventListener('change', function (e) { - updateFileListPreview(); - }); - } + ['fileInput', 'folderInput'].forEach(id => { + const input = document.getElementById(id); + if (input) { + input.addEventListener('change', function (e) { + uploadSelection++; + droppedUploadFiles = null; + updateFileListPreview(); + }); + } + }); // Setup checkbox event listeners for file selection const checkboxes = document.querySelectorAll('.file-checkbox'); @@ -1460,10 +1541,20 @@ function setupFileManagerEventHandlers() { const uploadFileModal = document.getElementById('uploadFileModal'); if (uploadFileModal) { uploadFileModal.addEventListener('hidden.bs.modal', function () { + const cancelled = activeUploadRequest; + if (cancelled) { + cancelled.abort(); + } + if (cancelled || uploadChangedListing) { + window.location.reload(); + return; + } const fileInput = document.getElementById('fileInput'); const progressContainer = document.getElementById('uploadProgress'); const fileListPreview = document.getElementById('fileListPreview'); fileInput.value = ''; + document.getElementById('folderInput').value = ''; + droppedUploadFiles = null; progressContainer.style.display = 'none'; fileListPreview.style.display = 'none'; }); @@ -1540,43 +1631,42 @@ function setupDragAndDrop() { } } - function handleDrop(e) { - const dt = e.dataTransfer; - const files = dt.files; - - if (files.length > 0) { - // Open upload modal and set files - const fileInput = document.getElementById('fileInput'); - if (fileInput) { - // Create a new FileList-like object - const fileArray = Array.from(files); - - // Set files to input (this is a bit tricky with file inputs) - const dataTransfer = new DataTransfer(); - fileArray.forEach(file => dataTransfer.items.add(file)); - fileInput.files = dataTransfer.files; - - // Update preview and show modal - updateFileListPreview(); - const modal = new bootstrap.Modal(uploadModal); - modal.show(); + async function handleDrop(e) { + const selection = ++uploadSelection; + let dropped; + try { + dropped = await collectDroppedFiles(e.dataTransfer, () => selection === uploadSelection); + } catch (error) { + console.error('Drop error:', error); + if (selection === uploadSelection) { + showErrorMessage('Failed to read the dropped files.'); } + return; } + if (selection !== uploadSelection || dropped.length === 0) { + return; + } + document.getElementById('fileInput').value = ''; + document.getElementById('folderInput').value = ''; + droppedUploadFiles = dropped; + updateFileListPreview(); + const modal = new bootstrap.Modal(uploadModal); + modal.show(); } } // Update file list preview when files are selected function updateFileListPreview() { - const fileInput = document.getElementById('fileInput'); const fileListPreview = document.getElementById('fileListPreview'); const selectedFilesList = document.getElementById('selectedFilesList'); + const selected = selectedUploadFiles(); - if (!fileInput.files || fileInput.files.length === 0) { + if (selected.length === 0) { fileListPreview.style.display = 'none'; return; } - const files = Array.from(fileInput.files); + const files = selected.map(item => item.file); const totalSize = files.reduce((sum, file) => sum + file.size, 0); let html = `
@@ -1584,16 +1674,19 @@ function updateFileListPreview() { Total: ${formatBytes(totalSize)}
`; - files.forEach((file, index) => { + selected.slice(0, uploadListLimit).forEach(({ file, path }, index) => { const fileIcon = getFileIconByName(file.name); html += `
- ${file.name} + ${escapeHtml(path)}
${formatBytes(file.size)}
`; }); + if (selected.length > uploadListLimit) { + html += `
and ${selected.length - uploadListLimit} more
`; + } selectedFilesList.innerHTML = html; fileListPreview.style.display = 'block'; diff --git a/weed/admin/static_gz/js/admin.js.gz b/weed/admin/static_gz/js/admin.js.gz index d6a6220718..328f9f92f0 100644 Binary files a/weed/admin/static_gz/js/admin.js.gz and b/weed/admin/static_gz/js/admin.js.gz differ diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ index 7dc84c9069..24db7870df 100644 --- a/weed/admin/view/app/file_browser.templ +++ b/weed/admin/view/app/file_browser.templ @@ -313,7 +313,7 @@ templ FileBrowser(data dash.FileBrowserData) {
- +
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking. if data.UploadMaxSize > 0 { @@ -321,6 +321,13 @@ templ FileBrowser(data dash.FileBrowserData) { }
+
+ + +
+ Uploads every file in the folder, keeping its subfolders. Empty subfolders are skipped. +
+
@@ -401,11 +408,6 @@ templ FileBrowser(data dash.FileBrowserData) { break; } }); - - // Initialize file manager event handlers from admin.js - if (typeof setupFileManagerEventHandlers === 'function') { - setupFileManagerEventHandlers(); - } }); // File browser specific functions diff --git a/weed/admin/view/app/file_browser_templ.go b/weed/admin/view/app/file_browser_templ.go index f994bdd27a..f2a556bb15 100644 --- a/weed/admin/view/app/file_browser_templ.go +++ b/weed/admin/view/app/file_browser_templ.go @@ -865,7 +865,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\">
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking. ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\">
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking. ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -888,20 +888,20 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
Uploads every file in the folder, keeping its subfolders. Empty subfolders are skipped.
0%
Preparing upload...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\">
0%
Preparing upload...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/server/filer_ui/filer.html b/weed/server/filer_ui/filer.html index 935f7f808a..5653f41dd8 100644 --- a/weed/server/filer_ui/filer.html +++ b/weed/server/filer_ui/filer.html @@ -39,7 +39,7 @@ background: #ddd; } - #fileElem { + #fileElem, #dirElem { display: none; } @@ -94,6 +94,9 @@ +