diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index eadd89b3c9..fd9abe2e0d 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -175,8 +175,14 @@ type AdminServer struct { // s3PublicEndpoint is the client-facing S3 address from admin.toml // (s3.public_endpoint); discovered S3 servers are the fallback. s3PublicEndpoint string + + // uploadMaxSize caps the total bytes of one file browser upload; 0 means no limit. + uploadMaxSize int64 } +// DefaultUploadMaxSizeMB is the default total size limit of one file browser upload. +const DefaultUploadMaxSizeMB = 500 + // Type definitions moved to types.go func NewAdminServer(masters string, filerGroup string, templateFS http.FileSystem, dataDir string, icebergPort, lancePort int, s3PublicEndpoint string) *AdminServer { @@ -217,6 +223,7 @@ func NewAdminServer(masters string, filerGroup string, templateFS http.FileSyste icebergPort: icebergPort, lancePort: lancePort, s3PublicEndpoint: normalizeS3PublicEndpoint(s3PublicEndpoint), + uploadMaxSize: DefaultUploadMaxSizeMB << 20, pluginLock: lockManager, adminPresenceLock: presenceLock, bgCancel: bgCancel, @@ -1574,6 +1581,18 @@ func (s *AdminServer) GetS3Endpoint() string { return s.s3PublicEndpoint } +// SetUploadMaxSize sets the total size limit in bytes of one file browser +// upload; zero or less removes the limit. +func (s *AdminServer) SetUploadMaxSize(maxBytes int64) { + s.uploadMaxSize = max(maxBytes, 0) +} + +// GetUploadMaxSize returns the total size limit in bytes of one file browser +// upload, or 0 when uploads are unlimited. +func (s *AdminServer) GetUploadMaxSize() int64 { + return s.uploadMaxSize +} + // normalizeS3PublicEndpoint trims a trailing slash and drops, with a warning, // a value that is not a plain absolute http or https URL, so the file browser // hides its URL actions instead of copying broken links. Checking the string diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index 37385f5fb4..7fd14a3e6b 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -53,6 +53,8 @@ type FileBrowserData struct { HasNextPage bool `json:"has_next_page"` LastFileName string `json:"last_file_name"` // Cursor for next page CurrentLastFileName string `json:"current_last_file_name"` // Cursor from current request (for page size changes) + + UploadMaxSize int64 `json:"upload_max_size"` } // GetFileBrowser retrieves file browser data for a given path with cursor-based @@ -240,6 +242,8 @@ func (s *AdminServer) GetFileBrowser(dir string, prefix string, lastFileName str HasNextPage: hasNextPage, LastFileName: lastEntryName, // Store for next page navigation CurrentLastFileName: lastFileName, // Store input cursor for page size changes + + UploadMaxSize: s.uploadMaxSize, }, nil } diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index 085679b768..fb5f122b72 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" + "errors" "fmt" "io" "mime" @@ -247,25 +248,46 @@ func (h *FileBrowserHandlers) CreateFolder(w http.ResponseWriter, r *http.Reques // UploadFile handles file upload requests func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) { - // Get the current path + maxSize := h.adminServer.GetUploadMaxSize() + if maxSize > 0 { + r.Body = http.MaxBytesReader(w, r.Body, maxSize+uploadFormOverhead) + } + + // Parts beyond 32MB spill to temporary files instead of memory + err := r.ParseMultipartForm(32 << 20) + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeJSONError(w, http.StatusRequestEntityTooLarge, uploadTooLargeMessage(maxSize)) + return + } + writeJSONError(w, http.StatusBadRequest, "Failed to parse multipart form: "+err.Error()) + return + } + // The router hands us a copy of the request, so the server cannot remove the spooled parts itself + defer r.MultipartForm.RemoveAll() + currentPath := r.FormValue("path") if currentPath == "" { currentPath = "/" } - // Parse multipart form - err := r.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads - if err != nil { - writeJSONError(w, http.StatusBadRequest, "Failed to parse multipart form: "+err.Error()) - return - } - // Get uploaded files (supports multiple files) files := r.MultipartForm.File["files"] if len(files) == 0 { writeJSONError(w, http.StatusBadRequest, "No files uploaded") return } + if maxSize > 0 { + var totalSize int64 + for _, fileHeader := range files { + totalSize += fileHeader.Size + } + if totalSize > maxSize { + writeJSONError(w, http.StatusRequestEntityTooLarge, uploadTooLargeMessage(maxSize)) + return + } + } var uploadResults []map[string]interface{} var failedUploads []string @@ -327,6 +349,15 @@ func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) } } +// 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. +const uploadFormOverhead = 1 << 20 + +func uploadTooLargeMessage(maxSize int64) string { + return fmt.Sprintf("Total upload size exceeds the %d MB limit", maxSize>>20) +} + // uploadFileToFiler uploads a file to the cluster via filer gRPC + volume // HTTP. This works whether or not the filer is running with -disableHttp=true, // since the bytes never traverse the filer's HTTP listener. The multipart diff --git a/weed/admin/handlers/file_browser_handlers_test.go b/weed/admin/handlers/file_browser_handlers_test.go index f02fead273..179be2a6a0 100644 --- a/weed/admin/handlers/file_browser_handlers_test.go +++ b/weed/admin/handlers/file_browser_handlers_test.go @@ -1,8 +1,12 @@ package handlers import ( + "bytes" + "fmt" + "mime/multipart" "net/http" "net/http/httptest" + "os" "slices" "strings" "testing" @@ -143,3 +147,72 @@ func TestListFolders_RejectsPathOutsideBuckets(t *testing.T) { } } } + +func TestUploadFile_RejectsOversizedUpload(t *testing.T) { + adminServer := &dash.AdminServer{} + adminServer.SetUploadMaxSize(1 << 20) + h := &FileBrowserHandlers{adminServer: adminServer} + + // 1.5MB and two 600KB files fit the body allowance but exceed the file total; + // 3MB trips the body cap. + for _, sizes := range [][]int{{3 << 19}, {600 << 10, 600 << 10}, {3 << 20}} { + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for i, size := range sizes { + part, err := mw.CreateFormFile("files", fmt.Sprintf("big%d.bin", i)) + if err != nil { + t.Fatal(err) + } + part.Write(make([]byte, size)) + } + mw.WriteField("path", "/buckets/b") + 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) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("sizes %v: expected 413, got %d (body: %s)", sizes, w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "1 MB limit") { + t.Errorf("sizes %v: expected the limit in the error, got %s", sizes, w.Body.String()) + } + } +} + +func TestUploadFile_RemovesSpooledParts(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("TMPDIR", tmpDir) + + adminServer := &dash.AdminServer{} + adminServer.SetUploadMaxSize(34 << 20) + h := &FileBrowserHandlers{adminServer: adminServer} + + // The 33MB part spills to disk; together the parts exceed the limit, so the upload stops there. + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for i, size := range []int{33 << 20, 3 << 19} { + part, err := mw.CreateFormFile("files", fmt.Sprintf("part%d.bin", i)) + if err != nil { + t.Fatal(err) + } + part.Write(make([]byte, size)) + } + 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.WithContext(req.Context())) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d (body: %s)", w.Code, w.Body.String()) + } + if left, _ := os.ReadDir(tmpDir); len(left) != 0 { + t.Errorf("spooled parts left behind: %v", left) + } +} diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js index 5de9b8bd64..77681f26fc 100644 --- a/weed/admin/static/js/admin.js +++ b/weed/admin/static/js/admin.js @@ -1180,10 +1180,9 @@ async function submitUploadFile() { const files = Array.from(fileInput.files); const totalSize = files.reduce((sum, file) => sum + file.size, 0); - // Validate total file size (limit to 500MB for admin interface) - const maxSize = 500 * 1024 * 1024; // 500MB total - if (totalSize > maxSize) { - showErrorMessage('Total file size exceeds 500MB limit. Please select fewer or smaller files.'); + const maxSize = Number(document.getElementById('uploadFileForm').dataset.maxSize) || 0; + if (maxSize > 0 && totalSize > maxSize) { + showErrorMessage(`Total file size exceeds the ${formatBytes(maxSize)} limit. Please select fewer or smaller files.`); return; } diff --git a/weed/admin/static_gz/js/admin.js.gz b/weed/admin/static_gz/js/admin.js.gz index a4ebc6fd53..d6a6220718 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 4367d74698..7dc84c9069 100644 --- a/weed/admin/view/app/file_browser.templ +++ b/weed/admin/view/app/file_browser.templ @@ -310,12 +310,15 @@ templ FileBrowser(data dash.FileBrowserData) {