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 = `