This is an automated email from the ASF dual-hosted git repository. reiern70 pushed a commit to branch upload-to-resource in repository https://gitbox.apache.org/repos/asf/wicket.git
commit a75841a9de31f8f4e50737d6b6bd4732ebcc848c Author: reiern70 <[email protected]> AuthorDate: Thu Oct 15 10:50:51 2026 -0500 Make uploads to a resource fail cleanly and keep files of one name apart GitHub issue #1670 FileUploadToResourceField and the resource it uploads to had several problems: - A failed upload ended on the error page instead of in onUploadFailure(). For a failure other than a limit the resource answers the key uploadFailed, whose text expects an exception in ${exception.localizedMessage}; the field put the key, a String, in its place, and the interpolation threw. Such failures now have a text of their own, uploadToResourceFailed ("The upload failed."). - The field took whatever the browser reported as a resource key. The status text of an HTTP error, or the exception message the resource sent with debug logging on, threw MissingResourceException, and an empty one (HTTP/2, a network failure) read "File must be less than ...". The field now accepts the keys of the limits and of no files selected, and reads anything else as a failed upload. The script reports HTTP errors, network failures and answers that are not JSON as uploadFailed. The resource no longer sends exception messages to the browser; it logs the exception as an error, which it did not do at all before. - The script checks the limits of the field before sending: files beyond them are reported with the key of the limit and not sent. Before, the server refused them, often by resetting the connection, which the browser saw as a network failure. - A second upload started while one ran sent a second request, which shared the progress information of the first in the session. Whichever ended first removed it, and the other failed with an IllegalStateException. The script now refuses to start an upload while one of the field runs. - The script reported the received files to the field by GET, with their JSON in the URL. Beyond some 65 files that exceeded the 8 KB request line of Jetty and Tomcat: the server answered 414 and onUploadSuccess() was never called, although the files were stored. They are reported by POST now. - FolderUploadsFileManager stored each file under its client file name, so two files of one name, in one upload or in two, overwrote each other while the field was told of both. Files are now stored under a random name, which the resource sends to the browser with the client file name and UploadInfo carries back to the field. - cancel() after an upload had ended still claimed to cancel it, the return value of the before-send callback was ignored, and an answer that was not JSON left the field waiting. Now cancel() does nothing once the upload has ended, false from the before-send callback keeps the upload from being sent and calls onUploadCanceled(), and an answer that is not JSON fails the upload. The Javadoc of AbstractFileUploadResource said the field uploads a single file with jQuery; it uploads all the files of its input with a plain XMLHttpRequest. Keeping files of one name apart needs a name for each stored file that travels from the file manager through the browser back to the field, so the API changes: - IUploadsFileManager gets store(FileUpload, String), which returns the name the file was stored under, and getFile(String, String) takes that name. save() is deprecated. Its default calls store(), and the default of store() calls save() and returns the client file name, so an implementation written for save() keeps working, with the old behaviour for files of one name. - AbstractFileUploadResource.saveFiles() returns the stored names, and prepareResponse() and generateJsonResponse() receive them. These are hooks for subclasses: an old signature kept as deprecated would no longer be called, so the signatures change and an override fails to compile rather than being skipped silently. - FileUploadResourceReference.generateFileInfo() receives the stored name and puts it into the JSON as fileName. - UploadInfo carries the stored name (getFileName() and a new constructor), and equals() and hashCode() use it instead of the client file name. The old constructor is deprecated. A JSON answer without fileName, from a custom resource, falls back to the client file name. What an application sees after the change: files stored by FolderUploadsFileManager no longer have their client file name on disk, so code looking them up with getFile(fieldId, clientFileName) has to use UploadInfo#getFile() or #getFileName() instead. A failed upload calls onUploadFailure() with "The upload failed."; an application that had changed the text of uploadFailed to work around the error page changes uploadToResourceFailed instead. The client-side error callback receives only resource keys as errorMessage. A before-send callback returning false now keeps the upload from being sent. Draft for the migration guide: Uploads to a resource (FileUploadToResourceField): implement IUploadsFileManager#store(FileUpload, String) instead of the deprecated save(). It stores the file under a name of its own and returns that name, which getFile(uploadFieldId, fileName) then receives; the name goes to the browser and back, so getFile() must not return a file of another upload field. FolderUploadsFileManager stores files under random names: find them with UploadInfo#getFile() or UploadInfo#getFileName(), not by client file name. Subclasses of AbstractFileUploadResource receive the stored names in saveFiles(), prepareResponse() and generateJsonResponse(), subclasses of FileUploadResourceReference in generateFileInfo(). A failed upload reads "The upload failed." (resource key uploadToResourceFailed) instead of ending on the error page. --- testing/wicket-js-tests/Gruntfile.js | 3 +- .../FileUploadToResourceFieldSecurityTest.java | 8 +- .../FileUploadToResourceFieldTest$UploadPage.html | 5 + .../resource/FileUploadToResourceFieldTest.java | 293 +++++++++++++++++++++ .../resource/FolderUploadsFileManagerTest.java | 38 +++ .../resource/AbstractFileUploadResource.java | 97 +++---- .../resource/FileUploadResourceReference.java | 22 +- .../upload/resource/FileUploadToResourceField.java | 129 ++++++--- .../upload/resource/FileUploadToResourceField.js | 137 +++++++--- .../resource/FileUploadToResourceField.properties | 3 +- .../upload/resource/FolderUploadsFileManager.java | 37 ++- .../form/upload/resource/IUploadsFileManager.java | 50 +++- wicket-core/src/test/js/all.html | 7 + wicket-core/src/test/js/upload-to-resource.js | 221 ++++++++++++++++ .../src/main/resources/META-INF/rewrite/wicket.yml | 10 + 15 files changed, 903 insertions(+), 157 deletions(-) diff --git a/testing/wicket-js-tests/Gruntfile.js b/testing/wicket-js-tests/Gruntfile.js index 848722e083..e9cfb0d78e 100644 --- a/testing/wicket-js-tests/Gruntfile.js +++ b/testing/wicket-js-tests/Gruntfile.js @@ -46,7 +46,8 @@ module.exports = function(grunt) { "../../wicket-core/src/test/js/dom.js", "../../wicket-core/src/test/js/channels.js", "../../wicket-core/src/test/js/event.js", - "../../wicket-core/src/test/js/timer.js" + "../../wicket-core/src/test/js/timer.js", + "../../wicket-core/src/test/js/upload-to-resource.js" ], extensionsTestsJs = [ "../../wicket-extensions/src/test/js/palette-test.js", diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldSecurityTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldSecurityTest.java index 0251ba071a..cc1dc7b01c 100644 --- a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldSecurityTest.java +++ b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldSecurityTest.java @@ -33,6 +33,7 @@ import org.apache.wicket.util.file.File; import org.apache.wicket.util.tester.WicketTestCase; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import com.github.openjson.JSONArray; class FileUploadToResourceFieldSecurityTest extends WicketTestCase { @@ -70,7 +71,9 @@ class FileUploadToResourceFieldSecurityTest extends WicketTestCase File upload = createUploadFile("allowed.txt", 256); submitUpload(page.field.buildUploadUrl(), upload); - File storedFile = fileManager.getFile(page.field.getMarkupId(), upload.getName()); + String fileName = new JSONArray(tester.getLastResponseAsString()).getJSONObject(0) + .getString("fileName"); + File storedFile = fileManager.getFile(page.field.getMarkupId(), fileName); assertTrue(storedFile.exists()); assertEquals(upload.length(), storedFile.length()); } @@ -93,8 +96,7 @@ class FileUploadToResourceFieldSecurityTest extends WicketTestCase submitUpload(tamperedUrl, upload); - File storedFile = fileManager.getFile(page.field.getMarkupId(), upload.getName()); - assertFalse(storedFile.exists()); + assertFalse(Files.exists(tempDir.resolve(page.field.getMarkupId()))); assertTrue(tester.getLastResponseAsString().contains("uploadFailed")); } diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest$UploadPage.html b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest$UploadPage.html new file mode 100644 index 0000000000..2658b563d3 --- /dev/null +++ b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest$UploadPage.html @@ -0,0 +1,5 @@ +<html xmlns:wicket="http://wicket.apache.org"> +<body> + <input type="file" wicket:id="upload"/> +</body> +</html> diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest.java new file mode 100644 index 0000000000..faec0fdbbd --- /dev/null +++ b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest.java @@ -0,0 +1,293 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.markup.html.form.upload.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.form.upload.FileUpload; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.util.file.File; +import org.apache.wicket.util.lang.Bytes; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.openjson.JSONArray; +import com.github.openjson.JSONObject; + +/** + * Tests the upload of files to a resource by {@link FileUploadToResourceField}, and how the field + * takes what the browser reports of it. + */ +class FileUploadToResourceFieldTest extends WicketTestCase +{ + @TempDir + Path tempDir; + + private UploadPage page; + + @Test + void storesFilesOfTheSameClientFileNameApart() throws IOException + { + FolderUploadsFileManager fileManager = startPage(); + + submitUpload(createUploadFile("first", "same.txt", "first content"), + createUploadFile("second", "same.txt", "second content")); + + JSONArray filesInfo = new JSONArray(tester.getLastResponseAsString()); + assertEquals(2, filesInfo.length()); + JSONObject first = filesInfo.getJSONObject(0); + JSONObject second = filesInfo.getJSONObject(1); + assertEquals("same.txt", first.getString("clientFileName")); + assertEquals("same.txt", second.getString("clientFileName")); + assertNotEquals(first.getString("fileName"), second.getString("fileName")); + assertEquals("first content", readStoredFile(fileManager, first.getString("fileName"))); + assertEquals("second content", readStoredFile(fileManager, second.getString("fileName"))); + } + + @Test + void givesTheStoredFilesToOnUploadSuccess() throws IOException + { + startPage(); + submitUpload(createUploadFile("first", "same.txt", "first content"), + createUploadFile("second", "same.txt", "second content")); + + report("error", "false", "filesInfo", tester.getLastResponseAsString()); + + List<FileUploadToResourceField.UploadInfo> uploadInfos = page.field.uploadInfos; + assertEquals(2, uploadInfos.size()); + assertNotEquals(uploadInfos.get(0), uploadInfos.get(1)); + assertEquals("first content", read(uploadInfos.get(0).getFile())); + assertEquals("second content", read(uploadInfos.get(1).getFile())); + } + + @Test + void answersAFailureOfTheFileManagerWithTheKeyOfAFailedUpload() throws IOException + { + startPage(new FolderUploadsFileManager(new File(tempDir.resolve("uploads").toFile())) + { + @Override + public String store(FileUpload fileItem, String uploadFieldId) + { + throw new IllegalStateException("disk full"); + } + }); + + submitUpload(createUploadFile("first", "file.txt", "content")); + + JSONObject answer = new JSONObject(tester.getLastResponseAsString()); + assertTrue(answer.getBoolean("error")); + assertEquals("uploadFailed", answer.getString("errorMessage")); + } + + @Test + void callsOnUploadFailureForAFailedUpload() + { + startPage(); + + report("error", "true", "errorMessage", "uploadFailed"); + + assertEquals(List.of("The upload failed."), page.field.failures); + } + + @Test + void callsOnUploadFailureForAnErrorItHasNoTextFor() + { + startPage(); + + report("error", "true", "errorMessage", "Internal Server Error"); + + assertEquals(List.of("The upload failed."), page.field.failures); + } + + @Test + void callsOnUploadFailureForAnErrorWithoutKey() + { + startPage(); + + report("error", "true"); + + assertEquals(List.of("The upload failed."), page.field.failures); + } + + @Test + void callsOnUploadFailureWithTheTextOfABrokenLimit() + { + startPage(); + + report("error", "true", "errorMessage", "uploadTooManyFiles"); + + assertEquals(List.of("There should be no more than 3 files."), page.field.failures); + } + + @Test + void callsOnUploadCanceledForACanceledUpload() + { + startPage(); + + report("error", "true", "errorMessage", FileUploadToResourceField.UPLOAD_CANCELED); + + assertTrue(page.field.canceled); + assertTrue(page.field.failures.isEmpty()); + } + + private FolderUploadsFileManager startPage() + { + FolderUploadsFileManager fileManager = new FolderUploadsFileManager( + new File(tempDir.resolve("uploads").toFile())); + startPage(fileManager); + return fileManager; + } + + private void startPage(IUploadsFileManager fileManager) + { + page = new UploadPage(new TestFileUploadResourceReference(fileManager)); + tester.startPage(page); + } + + private File createUploadFile(String folder, String fileName, String content) + throws IOException + { + Path filePath = Files.createDirectories(tempDir.resolve(folder)).resolve(fileName); + Files.writeString(filePath, content, StandardCharsets.UTF_8); + return new File(filePath.toFile()); + } + + private void submitUpload(File... uploads) + { + tester.getRequest().setMethod("POST"); + for (File upload : uploads) + { + tester.getRequest().addFile(AbstractFileUploadResource.PARAM_NAME, upload, "text/plain"); + } + tester.executeUrl(page.field.buildUploadUrl()); + } + + /** + * Reports to the field how the upload ended, as its script does. + */ + private void report(String... parameters) + { + tester.getRequest().setMethod("POST"); + for (int i = 0; i < parameters.length; i += 2) + { + tester.getRequest().getPostParameters().setParameterValue(parameters[i], + parameters[i + 1]); + } + tester.executeBehavior(page.field.getBehaviors(AbstractDefaultAjaxBehavior.class).get(0)); + } + + private String readStoredFile(FolderUploadsFileManager fileManager, String fileName) + throws IOException + { + return read(fileManager.getFile(page.field.getMarkupId(), fileName)); + } + + private static String read(java.io.File file) throws IOException + { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + + static class UploadPage extends WebPage + { + private static final long serialVersionUID = 1L; + + final TestField field; + + UploadPage(TestFileUploadResourceReference resourceReference) + { + field = new TestField("upload", resourceReference); + field.setMaxSize(Bytes.kilobytes(1)); + field.setFileCountMax(3); + add(field); + } + } + + static class TestField extends FileUploadToResourceField + { + private static final long serialVersionUID = 1L; + + private final TestFileUploadResourceReference resourceReference; + + List<UploadInfo> uploadInfos; + + final List<String> failures = new ArrayList<>(); + + boolean canceled; + + TestField(String id, TestFileUploadResourceReference resourceReference) + { + super(id); + this.resourceReference = resourceReference; + } + + @Override + protected FileUploadResourceReference getFileUploadResourceReference() + { + return resourceReference; + } + + @Override + protected void onUploadSuccess(AjaxRequestTarget target, List<UploadInfo> fileInfos) + { + uploadInfos = fileInfos; + } + + @Override + protected void onUploadFailure(AjaxRequestTarget target, String errorInfo) + { + failures.add(errorInfo); + } + + @Override + protected void onUploadCanceled(AjaxRequestTarget target) + { + canceled = true; + } + + String buildUploadUrl() + { + return urlFor(resourceReference, new PageParameters()).toString() + '?' + + AbstractFileUploadResource.UPLOAD_ID + '=' + getMarkupId() + '&' + + AbstractFileUploadResource.MAX_SIZE + '=' + getMaxSize().bytes() + '&' + + AbstractFileUploadResource.FILE_COUNT_MAX + '=' + getFileCountMax() + '&' + + AbstractFileUploadResource.UPLOAD_TOKEN + '=' + + AbstractFileUploadResource.createUploadToken(getMarkupId(), getMaxSize(), + getFileMaxSize(), getFileCountMax()); + } + } + + static class TestFileUploadResourceReference extends FileUploadResourceReference + { + private static final long serialVersionUID = 1L; + + TestFileUploadResourceReference(IUploadsFileManager uploadFileManager) + { + super(uploadFileManager); + } + } +} diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManagerTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManagerTest.java index 4930aed2d7..92b4e4d526 100644 --- a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManagerTest.java +++ b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManagerTest.java @@ -17,6 +17,7 @@ package org.apache.wicket.markup.html.form.upload.resource; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -54,6 +55,31 @@ class FolderUploadsFileManagerTest assertThrows(SecurityException.class, () -> manager.getFile("uploadField", "../../etc/passwd")); } + @Test + void storeRejectsTraversalInUploadFieldId() throws IOException + { + FolderUploadsFileManager manager = new FolderUploadsFileManager(new File(tempDir.toFile())); + FileUpload fileUpload = newFileUpload("safe.txt", "content"); + + assertThrows(SecurityException.class, () -> manager.store(fileUpload, "../escaped")); + } + + @Test + void storeKeepsFilesOfTheSameClientFileNameApart() throws IOException + { + FolderUploadsFileManager manager = new FolderUploadsFileManager(new File(tempDir.toFile())); + + String first = manager.store(newFileUpload("same.txt", "first"), "uploadField"); + String second = manager.store(newFileUpload("same.txt", "second"), "uploadField"); + + assertNotEquals(first, second); + assertEquals("first", + Files.readString(manager.getFile("uploadField", first).toPath(), StandardCharsets.UTF_8)); + assertEquals("second", + Files.readString(manager.getFile("uploadField", second).toPath(), StandardCharsets.UTF_8)); + } + + @SuppressWarnings("removal") @Test void saveRejectsTraversalInUploadFieldId() throws IOException { @@ -66,6 +92,7 @@ class FolderUploadsFileManagerTest assertThrows(SecurityException.class, () -> manager.save(fileUpload, "../escaped")); } + @SuppressWarnings("removal") @Test void saveRejectsTraversalInClientFileName() throws IOException { @@ -78,6 +105,7 @@ class FolderUploadsFileManagerTest assertThrows(SecurityException.class, () -> manager.save(fileUpload, "uploadField")); } + @SuppressWarnings("removal") @Test void saveSucceedsWithValidUploadFieldIdAndClientFileName() throws IOException { @@ -93,4 +121,14 @@ class FolderUploadsFileManagerTest assertTrue(Files.exists(savedFile)); assertEquals("content", Files.readString(savedFile, StandardCharsets.UTF_8)); } + + private static FileUpload newFileUpload(String clientFileName, String content) + throws IOException + { + FileUpload fileUpload = mock(FileUpload.class); + when(fileUpload.getClientFileName()).thenReturn(clientFileName); + when(fileUpload.getInputStream()) + .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + return fileUpload; + } } diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/AbstractFileUploadResource.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/AbstractFileUploadResource.java index c20e7a5ce8..e71f2d5cfa 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/AbstractFileUploadResource.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/AbstractFileUploadResource.java @@ -55,7 +55,8 @@ import jakarta.servlet.http.HttpServletResponse; * <p> * The main difference is that there some JQuery plugin is used at client side (and it supports multiple uploads + * some UI allowing to delete/preview files and so on). - * Here we are just using plain jQuery code at client side to upload a single file. + * Here {@link FileUploadToResourceField} sends all the files of its input in one request, with a + * plain {@code XMLHttpRequest}. */ public abstract class AbstractFileUploadResource extends AbstractResource { @@ -134,76 +135,28 @@ public abstract class AbstractFileUploadResource extends AbstractResource Map<String, List<FileItem>> files = multiPartRequest.getFiles(); List<FileItem> fileItems = files.get(PARAM_NAME); - if (fileItems != null) + if (fileItems == null) { - List<FileUpload> fileUploads = new ArrayList<>(); - for (FileItem fileItem : fileItems) - { - fileUploads.add(new FileUpload(fileItem)); - } - saveFiles(fileUploads, uploadId); - prepareResponse(resourceResponse, webRequest, fileUploads); + return createErrorResponse(resourceResponse, NO_FILE_SELECTED); } - else + List<FileUpload> fileUploads = new ArrayList<>(); + for (FileItem fileItem : fileItems) { - resourceResponse.setContentType("application/json"); - resourceResponse.setWriteCallback(new WriteCallback() - { - @Override - public void writeData(Attributes attributes) throws IOException - { - JSONObject json = new JSONObject(); - json.put("error", true); - json.put("errorMessage", NO_FILE_SELECTED); - String error = json.toString(); - attributes.getResponse().write(error); - } - }); + fileUploads.add(new FileUpload(fileItem)); } + List<String> fileNames = saveFiles(fileUploads, uploadId); + prepareResponse(resourceResponse, webRequest, fileUploads, fileNames); return resourceResponse; - } catch (FileUploadException fux) { - resourceResponse.setContentType("application/json"); - // even when this is an exceptional situation we want the request to be successful - // as we are just sending back to the client some JSON with error messages, - // which will in turn be sent to wicket component (who will handle the errors) - resourceResponse.setStatusCode(HttpServletResponse.SC_OK); - JSONObject json = new JSONObject(); - json.put("error", true); - String errorMessage = getFileUploadExceptionKey(fux); - json.put("errorMessage", errorMessage); - String error = json.toString(); - resourceResponse.setWriteCallback(new WriteCallback() - { - @Override - public void writeData(Attributes attributes) throws IOException - { - attributes.getResponse().write(error); - } - }); - return resourceResponse; + return createErrorResponse(resourceResponse, getFileUploadExceptionKey(fux)); } - catch (Exception fux) + catch (Exception e) { - resourceResponse.setContentType("application/json"); - JSONObject json = new JSONObject(); - json.put("error", true); - String errorMessage = LOG.isDebugEnabled() ? fux.getMessage() : Form.UPLOAD_FAILED_RESOURCE_KEY; - json.put("errorMessage", errorMessage); - String error = json.toString(); - resourceResponse.setWriteCallback(new WriteCallback() - { - @Override - public void writeData(Attributes attributes) throws IOException - { - attributes.getResponse().write(error); - } - }); + LOG.error("Upload to '{}' failed", uploadId, e); + return createErrorResponse(resourceResponse, Form.UPLOAD_FAILED_RESOURCE_KEY); } - - return resourceResponse; } private static String serializeUploadSettings(String uploadId, Bytes maxSize, Bytes fileMaxSize, @@ -247,6 +200,7 @@ public abstract class AbstractFileUploadResource extends AbstractResource private ResourceResponse createErrorResponse(ResourceResponse resourceResponse, String errorMessage) { resourceResponse.setContentType("application/json"); + // the error goes to the field in the JSON, which it reads from successful answers only resourceResponse.setStatusCode(HttpServletResponse.SC_OK); JSONObject json = new JSONObject(); json.put("error", true); @@ -293,11 +247,15 @@ public abstract class AbstractFileUploadResource extends AbstractResource * ServletWebRequest * @param fileItems * List<FileUpload> + * @param fileNames + * the names the files were stored under, in the order of {@code fileItems} */ - protected void prepareResponse(ResourceResponse resourceResponse, ServletWebRequest webRequest, List<FileUpload> fileItems) + protected void prepareResponse(ResourceResponse resourceResponse, ServletWebRequest webRequest, + List<FileUpload> fileItems, List<String> fileNames) { resourceResponse.setContentType("application/json"); - final String responseContent = generateJsonResponse(resourceResponse, webRequest, fileItems); + final String responseContent = generateJsonResponse(resourceResponse, webRequest, fileItems, + fileNames); resourceResponse.setWriteCallback(new WriteCallback() { @@ -314,13 +272,19 @@ public abstract class AbstractFileUploadResource extends AbstractResource * * @param fileItems * List<FileUpload> + * @param uploadId + * the unique ID of the upload field + * @return the names the files were stored under, in the order of {@code fileItems} + * @see IUploadsFileManager#store(FileUpload, String) */ - protected void saveFiles(List<FileUpload> fileItems, String uploadId) + protected List<String> saveFiles(List<FileUpload> fileItems, String uploadId) { + List<String> fileNames = new ArrayList<>(); for (FileUpload fileItem : fileItems) { - fileManager.save(fileItem, uploadId); + fileNames.add(fileManager.store(fileItem, uploadId)); } + return fileNames; } // Use getQueryParameters() instead of getRequestParameters() to avoid reading POST parameters @@ -389,9 +353,12 @@ public abstract class AbstractFileUploadResource extends AbstractResource * ServletWebRequest * @param files * List<FileUpload> + * @param fileNames + * the names the files were stored under, in the order of {@code files}; the browser + * sends them back for {@link FileUploadToResourceField} to find the files * @return The generated JSON */ protected abstract String generateJsonResponse(ResourceResponse resourceResponse, - ServletWebRequest webRequest, List<FileUpload> files); + ServletWebRequest webRequest, List<FileUpload> files, List<String> fileNames); } diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadResourceReference.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadResourceReference.java index b57644503c..1a4db65e90 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadResourceReference.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadResourceReference.java @@ -83,17 +83,18 @@ public class FileUploadResourceReference extends ResourceReference { @Override - protected String generateJsonResponse(ResourceResponse resourceResponse, ServletWebRequest webRequest, List<FileUpload> files) + protected String generateJsonResponse(ResourceResponse resourceResponse, + ServletWebRequest webRequest, List<FileUpload> files, List<String> fileNames) { JSONArray json = new JSONArray(); - for (FileUpload fileItem : files) + for (int i = 0; i < files.size(); i++) { JSONObject fileJson = new JSONObject(); try { - generateFileInfo(fileJson, fileItem); + generateFileInfo(fileJson, files.get(i), fileNames.get(i)); json.put(fileJson); } catch (JSONException e) @@ -111,8 +112,21 @@ public class FileUploadResourceReference extends ResourceReference return uploadFileManager; } - protected void generateFileInfo(JSONObject fileJson, FileUpload fileItem) + /** + * Puts the information about an uploaded file the field receives into the JSON answering the + * upload. + * + * @param fileJson + * the JSON of the file + * @param fileItem + * the uploaded file + * @param fileName + * the name the file was stored under, see + * {@link IUploadsFileManager#store(FileUpload, String)} + */ + protected void generateFileInfo(JSONObject fileJson, FileUpload fileItem, String fileName) { + fileJson.put("fileName", fileName); fileJson.put("clientFileName", fileItem.getClientFileName()); fileJson.put("size", fileItem.getSize()); fileJson.put("contentType", fileItem.getContentType()); diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java index 47d10fae0e..97d5822508 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java @@ -25,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; import org.apache.commons.io.IOUtils; import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior; @@ -34,17 +35,17 @@ import org.apache.wicket.core.request.handler.IPartialPageRequestHandler; import org.apache.wicket.markup.head.IHeaderResponse; import org.apache.wicket.markup.head.JavaScriptHeaderItem; import org.apache.wicket.markup.head.OnDomReadyHeaderItem; +import org.apache.wicket.markup.html.form.Form; import org.apache.wicket.markup.html.form.upload.FileUpload; import org.apache.wicket.markup.html.form.upload.FileUploadField; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; -import org.apache.wicket.request.Request; +import org.apache.wicket.request.IRequestParameters; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.JavaScriptResourceReference; import org.apache.wicket.resource.CoreLibrariesContributor; import org.apache.wicket.util.lang.Bytes; -import org.apache.wicket.util.string.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.github.openjson.JSONArray; @@ -65,19 +66,42 @@ public abstract class FileUploadToResourceField extends FileUploadField public static final class UploadInfo { private File file; + private final String fileName; private final String clientFileName; private final long size; private final String contentType; - public UploadInfo(String clientFileName, long size, String contentType) + /** + * @param fileName + * the name the {@link IUploadsFileManager} stored the file under + * @param clientFileName + * the name of the file on the client + * @param size + * the size of the file in bytes + * @param contentType + * the content type of the file + * @since 11.0.0 + */ + public UploadInfo(String fileName, String clientFileName, long size, String contentType) { + this.fileName = fileName; this.clientFileName = clientFileName; this.size = size; this.contentType = contentType; } + /** + * @deprecated the file is then looked up by its client file name, which several files can + * share. Use {@link #UploadInfo(String, String, long, String)} instead. + */ + @Deprecated(since = "11.0.0", forRemoval = true) + public UploadInfo(String clientFileName, long size, String contentType) + { + this(clientFileName, clientFileName, size, contentType); + } + public File getFile() { return file; @@ -88,6 +112,15 @@ public abstract class FileUploadToResourceField extends FileUploadField this.file = file; } + /** + * @return the name the {@link IUploadsFileManager} stored the file under + * @since 11.0.0 + */ + public String getFileName() + { + return fileName; + } + public String getClientFileName() { return clientFileName; @@ -109,7 +142,7 @@ public abstract class FileUploadToResourceField extends FileUploadField if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UploadInfo fileInfo = (UploadInfo) o; - return Objects.equals(clientFileName, fileInfo.clientFileName); + return Objects.equals(fileName, fileInfo.fileName); } /** @@ -147,7 +180,7 @@ public abstract class FileUploadToResourceField extends FileUploadField @Override public int hashCode() { - return Objects.hash(clientFileName); + return Objects.hash(fileName); } public static List<UploadInfo> fromJson(String json) @@ -157,7 +190,10 @@ public abstract class FileUploadToResourceField extends FileUploadField for (int i = 0; i < jsonArray.length(); i++) { JSONObject jsonObject = jsonArray.getJSONObject(i); - infos.add(new UploadInfo(jsonObject.getString("clientFileName"), jsonObject.getLong("size"), jsonObject.getString("contentType"))); + String clientFileName = jsonObject.getString("clientFileName"); + // a resource written before files had names of their own stores them by client name + infos.add(new UploadInfo(jsonObject.optString("fileName", clientFileName), + clientFileName, jsonObject.getLong("size"), jsonObject.getString("contentType"))); } return infos; } @@ -167,6 +203,17 @@ public abstract class FileUploadToResourceField extends FileUploadField public static String UPLOAD_CANCELED = "upload.canceled"; + /** + * The resource key of the text of an upload that failed for a reason other than a limit. + * + * @since 11.0.0 + */ + public static final String UPLOAD_FAILED_RESOURCE_KEY = "uploadToResourceFailed"; + + private static final Set<String> LIMIT_RESOURCE_KEYS = Set.of( + Form.UPLOAD_TOO_LARGE_RESOURCE_KEY, Form.UPLOAD_SINGLE_FILE_TOO_LARGE_RESOURCE_KEY, + Form.UPLOAD_TOO_MANY_FILES_RESOURCE_KEY); + private static abstract class FileModel implements IModel<List<UploadInfo>> { @@ -180,7 +227,7 @@ public abstract class FileUploadToResourceField extends FileUploadField // at this point files were stored at the server side by resource // UploadFieldId acts as a discriminator at application level // so that uploaded files are isolated. - uploadInfo.setFile(fileManager().getFile(getUploadFieldId(), uploadInfo.clientFileName)); + uploadInfo.setFile(fileManager().getFile(getUploadFieldId(), uploadInfo.getFileName())); } return fileInfos; } @@ -251,38 +298,23 @@ public abstract class FileUploadToResourceField extends FileUploadField @Override protected void respond(AjaxRequestTarget target) { - Request request = RequestCycle.get().getRequest(); - boolean error = request.getRequestParameters().getParameterValue("error").toBoolean(true); + IRequestParameters parameters = RequestCycle.get().getRequest().getRequestParameters(); + boolean error = parameters.getParameterValue("error").toBoolean(true); if (!error) { - String filesIfo = request.getRequestParameters().getParameterValue("filesInfo").toString(); + String filesIfo = parameters.getParameterValue("filesInfo").toString(); fileUploadInfos = UploadInfo.fromJson(filesIfo); onUploadSuccess(target, getFileUploadInfos()); } else { - String errorMessage = request.getRequestParameters().getParameterValue("errorMessage").toString(null); - if (UPLOAD_CANCELED.equals(errorMessage)) + String errorKey = parameters.getParameterValue("errorMessage").toString(null); + if (UPLOAD_CANCELED.equals(errorKey)) { onUploadCanceled(target); } else { - if (AbstractFileUploadResource.NO_FILE_SELECTED.equals(errorMessage)) - { - errorMessage = getString(AbstractFileUploadResource.NO_FILE_SELECTED); - } - else - { - final Map<String, Object> model = new HashMap<>(); - if (Strings.isEmpty(errorMessage)) { - errorMessage = "uploadTooLarge"; - } - model.put("exception", errorMessage); - model.put("maxSize", getMaxSize()); - model.put("fileMaxSize", getFileMaxSize()); - model.put("fileCountMax", getFileCountMax()); - errorMessage = getString(errorMessage, Model.ofMap(model)); - } + String errorMessage = getUploadErrorMessage(errorKey); error(errorMessage); onUploadFailure(target, errorMessage); } @@ -292,6 +324,27 @@ public abstract class FileUploadToResourceField extends FileUploadField add(ajaxBehavior); } + /** + * @param errorKey + * the key of the error the browser reported, which the browser can make up + * @return the text of a limit or of no files selected, else that of + * {@link #UPLOAD_FAILED_RESOURCE_KEY} + */ + private String getUploadErrorMessage(String errorKey) + { + if (AbstractFileUploadResource.NO_FILE_SELECTED.equals(errorKey)) + { + return getString(AbstractFileUploadResource.NO_FILE_SELECTED); + } + final Map<String, Object> model = new HashMap<>(); + model.put("maxSize", getMaxSize()); + model.put("fileMaxSize", getFileMaxSize()); + model.put("fileCountMax", getFileCountMax()); + String key = errorKey != null && LIMIT_RESOURCE_KEYS.contains(errorKey) ? errorKey + : UPLOAD_FAILED_RESOURCE_KEY; + return getString(key, Model.ofMap(model)); + } + private List<UploadInfo> getFileUploadInfos() { // mind that this makes files to be added. @@ -469,7 +522,10 @@ public abstract class FileUploadToResourceField extends FileUploadField } /** - * Starts the upload via an AJAX request. + * Starts the upload via an AJAX request. Only one upload of the field runs at a time: while one + * runs, starting another one does nothing. Files beyond the limits of the field are not sent: + * {@link #onUploadFailure(AjaxRequestTarget, String)} is called with the text of the limit + * instead. * * @param target The {@link AjaxRequestTarget} */ @@ -496,13 +552,13 @@ public abstract class FileUploadToResourceField extends FileUploadField } /** - * See jQuery.ajax documentation. - * - * @return A JavaScript function to be executed on beforeSend the upload request. + * @return A JavaScript function called with the {@code XMLHttpRequest} of the upload before it + * is sent. If it returns {@code false}, the upload is not sent, and + * {@link #onUploadCanceled(AjaxRequestTarget)} is called. */ protected CharSequence getClientBeforeSendCallBack() { - return "function (xhr, settings) { return true; }"; + return "function (xhr) { return true; }"; } /** @@ -516,7 +572,7 @@ public abstract class FileUploadToResourceField extends FileUploadField /** * @return A JavaScript function to be executed on upload canceled. This is, besides the normal wicket - * AJAX request (see {@link #onUploadFailure(AjaxRequestTarget, String)}). + * AJAX request (see {@link #onUploadCanceled(AjaxRequestTarget)}). */ protected CharSequence getClientSideCancelCallBack() { @@ -524,9 +580,10 @@ public abstract class FileUploadToResourceField extends FileUploadField } /** - * @return A JavaScript function to be executed on upload canceled. This is, besides the normal wicket + * @return A JavaScript function to be executed on upload failed. This is, besides the normal wicket * AJAX request (see {@link #onUploadFailure(AjaxRequestTarget, String)}). It receives as parameter a JSON object - * like <code>{'error': true, errorMessage: 'xxx'}</code>. + * like <code>{'error': true, errorMessage: 'uploadTooLarge'}</code>, where {@code errorMessage} is the + * resource key of the error. */ protected CharSequence getClientSideUploadErrorCallBack() { diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js index 5d79c59f0e..31a7580d61 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js @@ -43,65 +43,136 @@ this.uploadErrorCallBack = uploadErrorCallBack; }; + // the keys of the errors, as the field knows them + const UPLOAD_TOO_LARGE = 'uploadTooLarge'; + const UPLOAD_SINGLE_FILE_TOO_LARGE = 'uploadSingleFileTooLarge'; + const UPLOAD_TOO_MANY_FILES = 'uploadTooManyFiles'; + const UPLOAD_FAILED = 'uploadFailed'; + const UPLOAD_CANCELED = 'upload.canceled'; + + /** + * Uploads the files of the input, unless an upload of the field is running already, or the + * files break a limit of the field: the server is told of those as of an upload it refused, + * without the files being sent. + * + * @returns {boolean} whether the upload was sent + */ Wicket.FileUploadToResourceField.prototype.upload = function() { + if (this.xhr) { + Wicket.Log.log("An upload of field '" + this.inputName + "' is running: it has to end before another one starts."); + return false; + } // get a fresh reference to input this.input = document.getElementById(this.inputName); + const files = this.input.files; + const exceededLimit = this.exceededLimit(files); + if (exceededLimit) { + this.failed(exceededLimit); + return false; + } // we add the files to a FormData object. const formData = new FormData(); - const totalfiles = this.input.files.length; - for (let index = 0; index < totalfiles; index++) { - formData.append("WICKET-FILE-UPLOAD",this.input.files[index]); + for (let index = 0; index < files.length; index++) { + formData.append("WICKET-FILE-UPLOAD", files[index]); } const self = this; // we post the files to the resource (this.resourceUrl) with a plain XMLHttpRequest // and keep a reference to it in order to be able to cancel the upload const xhr = new XMLHttpRequest(); - this.xhr = xhr; xhr.open("POST", this.resourceUrl); xhr.onload = function () { - let res, ep; - if (xhr.status >= 200 && xhr.status < 300) { - try { - res = xhr.responseText ? JSON.parse(xhr.responseText) : {}; - } catch (parseError) { - // this error will only happen is generated JSON at server side is faulty - Wicket.Log.log(xhr.responseText); - return; - } - // do clean up on success - if (res.error) { - self.uploadErrorCallBack(res); - Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": res}); - } else { - self.clientSideSuccessCallBack(); - ep = {'error': false, 'filesInfo': JSON.stringify(res)}; - Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep}); - } + if (xhr.status < 200 || xhr.status >= 300) { + self.failed(UPLOAD_FAILED); + return; + } + let res; + try { + res = xhr.responseText ? JSON.parse(xhr.responseText) : {}; + } catch (parseError) { + Wicket.Log.error("The answer to the upload of field '" + self.inputName + "' is not JSON: " + xhr.responseText); + self.failed(UPLOAD_FAILED); + return; + } + if (res.error) { + self.failed(res.errorMessage); } else { - ep = {'error': true, "errorMessage": xhr.statusText}; - self.uploadErrorCallBack(ep); - Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep}); + delete self.xhr; + self.clientSideSuccessCallBack(); + self.report({'error': false, 'filesInfo': JSON.stringify(res)}); } }; xhr.onerror = function () { - const ep = {'error': true, "errorMessage": xhr.statusText}; - self.uploadErrorCallBack(ep); - Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep}); + self.failed(UPLOAD_FAILED); }; xhr.onabort = function () { // user aborted the upload. - const ep = {'error': true, 'errorMessage': 'upload.canceled'}; - Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep}); + delete self.xhr; + self.report({'error': true, 'errorMessage': UPLOAD_CANCELED}); }; - self.clientBeforeSendCallBack(xhr); + if (this.clientBeforeSendCallBack(xhr) === false) { + Wicket.Log.log("The upload of field '" + this.inputName + "' was not sent: the before-send callback returned false."); + this.report({'error': true, 'errorMessage': UPLOAD_CANCELED}); + return false; + } + this.xhr = xhr; xhr.send(formData); + return true; + }; + + /** + * @param files the files to upload + * @returns {string|null} the key of the first limit of the field the files break, if any + */ + Wicket.FileUploadToResourceField.prototype.exceededLimit = function (files) + { + const settings = this.settings; + let totalSize = 0; + let largestSize = 0; + for (let index = 0; index < files.length; index++) { + totalSize += files[index].size; + largestSize = Math.max(largestSize, files[index].size); + } + if (totalSize > settings.maxSize) { + return UPLOAD_TOO_LARGE; + } + if (settings.fileCountMax >= 0 && files.length > settings.fileCountMax) { + return UPLOAD_TOO_MANY_FILES; + } + if (settings.fileMaxSize != null && largestSize > settings.fileMaxSize) { + return UPLOAD_SINGLE_FILE_TOO_LARGE; + } + return null; + }; + + /** + * Ends an upload that failed, and tells the page and the server why. + * + * @param errorMessage the key of the error + */ + Wicket.FileUploadToResourceField.prototype.failed = function (errorMessage) + { + delete this.xhr; + const ep = {'error': true, 'errorMessage': errorMessage}; + this.uploadErrorCallBack(ep); + this.report(ep); + }; + + /** + * Tells the server how an upload ended. A POST carries the information about the files in its + * body, which would make the URL of a GET too long for the server with many files. + * + * @param ep the parameters of the request + */ + Wicket.FileUploadToResourceField.prototype.report = function (ep) + { + Wicket.Ajax.post({"u": this.ajaxCallBackUrl, "ep": ep}); }; // cancel the upload @@ -109,11 +180,11 @@ // we have a reference to the request we can cancel it. if (this.xhr) { this.xhr.abort(); + delete (this.xhr); this.clientSideCancelCallBack(); Wicket.Log.log("The upload associated with field '" + this.inputName + "' has been canceled!"); - delete (this.xhr); } else { - Wicket.Log.log("Too late to cancel upload for field '" + this.inputName + "': the upload has already finished."); + Wicket.Log.log("No upload of field '" + this.inputName + "' to cancel: it has already ended, or never started."); } }; })(); diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties index 5c115a887c..2073fe09c5 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties @@ -12,4 +12,5 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -wicket.no.files.selected = Nothing to upload: no files were selected. \ No newline at end of file +wicket.no.files.selected = Nothing to upload: no files were selected. +uploadToResourceFailed = The upload failed. diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManager.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManager.java index f877acc9a6..90414da153 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManager.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FolderUploadsFileManager.java @@ -22,6 +22,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.util.UUID; import org.apache.wicket.WicketRuntimeException; import org.apache.wicket.markup.html.form.upload.FileUpload; import org.apache.wicket.util.file.File; @@ -31,7 +32,8 @@ import org.apache.wicket.util.lang.Args; /** * Implementation of {@link IUploadsFileManager} that stores files in sub-folders of a given folder. * Sub-folders are named using the input field ID (and those are unique per application). Thus, there - * is no possibility that uploads get mixed up. + * is no possibility that uploads get mixed up. In its sub-folder, each file gets a random name of its + * own, so files of the same client file name do not replace each other. */ public class FolderUploadsFileManager implements IUploadsFileManager { @@ -84,12 +86,12 @@ public class FolderUploadsFileManager implements IUploadsFileManager } /** - * Validates {@code uploadFieldId} and {@code clientFileName} against the base folder to + * Validates {@code uploadFieldId} and {@code fileName} against the base folder to * prevent path traversal, and returns the fully resolved, canonical target file. * * @throws SecurityException if either component would escape the base folder */ - private java.io.File resolveTargetFile(String uploadFieldId, String clientFileName) + private java.io.File resolveTargetFile(String uploadFieldId, String fileName) throws IOException { Path baseFolderPath = getFolder().toPath().toRealPath(); @@ -98,21 +100,40 @@ public class FolderUploadsFileManager implements IUploadsFileManager { throw new SecurityException("Path traversal detected in uploadFieldId"); } - java.io.File target = new File(uploadFieldFolder, clientFileName).getCanonicalFile(); + java.io.File target = new File(uploadFieldFolder, fileName).getCanonicalFile(); Path uploadFieldFolderPath = getPathForComparison(uploadFieldFolder); if (!target.toPath().startsWith(uploadFieldFolderPath)) { - throw new SecurityException("Path traversal detected in client filename"); + throw new SecurityException("Path traversal detected in file name"); } return target; } + @Override + public String store(FileUpload fileItem, String uploadFieldId) + { + String fileName = UUID.randomUUID().toString(); + write(fileItem, uploadFieldId, fileName); + return fileName; + } + + /** + * @deprecated files saved under their client file name replace each other when they share it. + * Use {@link #store(FileUpload, String)} instead. + */ + @Deprecated(since = "11.0.0", forRemoval = true) + @SuppressWarnings("removal") @Override public void save(FileUpload fileItem, String uploadFieldId) + { + write(fileItem, uploadFieldId, fileItem.getClientFileName()); + } + + private void write(FileUpload fileItem, String uploadFieldId, String fileName) { try { - java.io.File target = resolveTargetFile(uploadFieldId, fileItem.getClientFileName()); + java.io.File target = resolveTargetFile(uploadFieldId, fileName); Files.createDirectories(target.toPath().getParent()); try (InputStream in = fileItem.getInputStream(); FileOutputStream out = new FileOutputStream(target)) @@ -127,11 +148,11 @@ public class FolderUploadsFileManager implements IUploadsFileManager } @Override - public File getFile(String uploadFieldId, String clientFileName) + public File getFile(String uploadFieldId, String fileName) { try { - return new File(resolveTargetFile(uploadFieldId, clientFileName)); + return new File(resolveTargetFile(uploadFieldId, fileName)); } catch (IOException e) { diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManager.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManager.java index 78a1208503..aeca43c8b6 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManager.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManager.java @@ -25,28 +25,66 @@ import org.apache.wicket.util.file.File; * how to store file. Same identifier is used to retrieve the file, once uploaded, from component in page. * Mind that uploader resource is a singleton => identifier needs to be unique among different sessions * (and pages in a session). + * <p> + * An implementation implements {@link #store(FileUpload, String)} and + * {@link #getFile(String, String)}. */ public interface IUploadsFileManager { /** - * Saves an uploaded files into some persistent storage (e,g, disk). + * Saves an uploaded file into some persistent storage (e.g. disk), under a name of its own: + * files of the same client file name, sent in one upload or in several, must not replace each + * other. + * <p> + * The name goes to the browser, which sends it back for {@link #getFile(String, String)} to + * find the file. It must not reveal anything the user is not supposed to know. + * <p> + * The default implementation saves the file with {@link #save(FileUpload, String)} and returns + * its client file name, for implementations written before this method existed. * * @param fileItem * The {@link FileUpload} * @param uploadFieldId * The unique ID of the upload field. + * @return the name the file was saved under, unique among the files of the upload field + * @since 11.0.0 */ - void save(FileUpload fileItem, String uploadFieldId); + default String store(FileUpload fileItem, String uploadFieldId) + { + save(fileItem, uploadFieldId); + return fileItem.getClientFileName(); + } /** - * Retrieves the file based on an uploadFieldId and clientFileName. + * Saves an uploaded file into some persistent storage (e.g. disk), under its client file name. + * <p> + * The default implementation delegates to {@link #store(FileUpload, String)}. + * + * @param fileItem + * The {@link FileUpload} + * @param uploadFieldId + * The unique ID of the upload field. + * @deprecated files saved under their client file name replace each other when they share it. + * Implement {@link #store(FileUpload, String)} instead. + */ + @Deprecated(since = "11.0.0", forRemoval = true) + default void save(FileUpload fileItem, String uploadFieldId) + { + store(fileItem, uploadFieldId); + } + + /** + * Retrieves the file saved under a name by {@link #store(FileUpload, String)}. + * <p> + * The name comes back from the browser, which can change it: an implementation must never + * return a file that does not belong to the upload field. * * @param uploadFieldId * The unique ID of the upload field. - * @param clientFileName - * The client file name + * @param fileName + * The name {@link #store(FileUpload, String)} returned for the file * @return File the file */ - File getFile(String uploadFieldId, String clientFileName); + File getFile(String uploadFieldId, String fileName); } diff --git a/wicket-core/src/test/js/all.html b/wicket-core/src/test/js/all.html index ae6c778aad..83f4085202 100644 --- a/wicket-core/src/test/js/all.html +++ b/wicket-core/src/test/js/all.html @@ -156,6 +156,11 @@ <div id="usedAsContextWicket5025"></div> + <!-- + FileUploadToResourceField + --> + <input type="file" id="uploadToResourceInput" multiple="multiple"/> + <div id="ajaxIndicator"></div> </div> @@ -179,6 +184,7 @@ document.write("<scr"+"ipt src='../../main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js'></scr"+"ipt>"); } </script> + <script type="text/javascript" src="../../main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js"></script> <script type="text/javascript" src="qunit/qunit.js"></script> <script type="text/javascript" src="qunit/blanket.min.js"></script> @@ -194,5 +200,6 @@ <script type="text/javascript" src="head.js"></script> <script type="text/javascript" src="ajax.js"></script> <script type="text/javascript" src="timer.js"></script> + <script type="text/javascript" src="upload-to-resource.js"></script> </body> </html> diff --git a/wicket-core/src/test/js/upload-to-resource.js b/wicket-core/src/test/js/upload-to-resource.js new file mode 100644 index 0000000000..d43314ea20 --- /dev/null +++ b/wicket-core/src/test/js/upload-to-resource.js @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*global QUnit: true, DataTransfer: true */ + +Wicket.Event.add(window, 'domready', function() { + "use strict"; + + const { module, test } = QUnit; + + const INPUT_ID = 'uploadToResourceInput'; + + // stands in for the XMLHttpRequest the field uploads with + function FakeXhr() { + FakeXhr.instances.push(this); + this.status = 0; + this.responseText = ''; + } + FakeXhr.prototype.open = function (method, url) { + this.method = method; + this.url = url; + }; + FakeXhr.prototype.send = function (body) { + this.body = body; + }; + FakeXhr.prototype.abort = function () { + this.aborted = true; + this.onabort(); + }; + FakeXhr.prototype.respond = function (status, responseText) { + this.status = status; + this.responseText = responseText; + this.onload(); + }; + + let originalXhr, originalPost, reports, calls; + + function chooseFiles(files) { + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + document.getElementById(INPUT_ID).files = dataTransfer.files; + } + + function newFile(name, size) { + return new File([new Uint8Array(size)], name, {type: 'text/plain'}); + } + + function newField(settings, beforeSend) { + return new Wicket.FileUploadToResourceField(Object.assign({ + inputName: INPUT_ID, + resourceUrl: 'resource', + ajaxCallBackUrl: 'callback', + maxSize: 100, + fileCountMax: -1, + uploadToken: 'token' + }, settings), + beforeSend || function () { return true; }, + function () { calls.push('success'); }, + function () { calls.push('cancel'); }, + function (res) { calls.push('error:' + res.errorMessage); }); + } + + module('Wicket.FileUploadToResourceField', { + beforeEach: function () { + originalXhr = window.XMLHttpRequest; + originalPost = Wicket.Ajax.post; + FakeXhr.instances = []; + window.XMLHttpRequest = FakeXhr; + reports = []; + calls = []; + Wicket.Ajax.post = function (attrs) { + reports.push(attrs); + }; + }, + afterEach: function () { + window.XMLHttpRequest = originalXhr; + Wicket.Ajax.post = originalPost; + } + }); + + test('sends all the files of the input in one request', assert => { + chooseFiles([newFile('a.txt', 3), newFile('b.txt', 4)]); + + assert.true(newField().upload()); + + assert.equal(FakeXhr.instances.length, 1); + const xhr = FakeXhr.instances[0]; + assert.equal(xhr.method, 'POST'); + assert.equal(xhr.body.getAll('WICKET-FILE-UPLOAD').length, 2); + }); + + test('reports the files received by POST', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + const filesInfo = '[{"fileName":"f1","clientFileName":"a.txt","size":3,"contentType":"text/plain"}]'; + FakeXhr.instances[0].respond(200, filesInfo); + + assert.deepEqual(calls, ['success']); + assert.equal(reports.length, 1); + assert.equal(reports[0].u, 'callback'); + assert.deepEqual(reports[0].ep, {'error': false, 'filesInfo': JSON.stringify(JSON.parse(filesInfo))}); + }); + + test('reports the error the resource answered', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + FakeXhr.instances[0].respond(200, '{"error":true,"errorMessage":"uploadTooManyFiles"}'); + + assert.deepEqual(calls, ['error:uploadTooManyFiles']); + assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'uploadTooManyFiles'}); + }); + + test('reports an HTTP error as a failed upload', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + FakeXhr.instances[0].respond(500, 'Internal Server Error'); + + assert.deepEqual(calls, ['error:uploadFailed']); + assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'uploadFailed'}); + }); + + test('reports a network failure as a failed upload', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + FakeXhr.instances[0].onerror(); + + assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'uploadFailed'}); + }); + + test('reports an answer that is not JSON as a failed upload', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + FakeXhr.instances[0].respond(200, '<html>not JSON</html>'); + + assert.deepEqual(calls, ['error:uploadFailed']); + assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'uploadFailed'}); + }); + + test('does not send files beyond the limits of the field', assert => { + const field = newField({maxSize: 10, fileMaxSize: 4, fileCountMax: 2}); + + chooseFiles([newFile('a.txt', 6), newFile('b.txt', 6)]); + assert.false(field.upload(), 'beyond maxSize'); + chooseFiles([newFile('a.txt', 1), newFile('b.txt', 1), newFile('c.txt', 1)]); + assert.false(field.upload(), 'beyond fileCountMax'); + chooseFiles([newFile('a.txt', 5)]); + assert.false(field.upload(), 'beyond fileMaxSize'); + + assert.equal(FakeXhr.instances.length, 0); + assert.deepEqual(reports.map(report => report.ep.errorMessage), + ['uploadTooLarge', 'uploadTooManyFiles', 'uploadSingleFileTooLarge']); + }); + + test('refuses a second upload while one is running', assert => { + chooseFiles([newFile('a.txt', 3)]); + const field = newField(); + + assert.true(field.upload()); + assert.false(field.upload(), 'refused while the first one runs'); + assert.equal(FakeXhr.instances.length, 1); + + FakeXhr.instances[0].respond(200, '[]'); + assert.true(field.upload(), 'sent once the first one ended'); + assert.equal(FakeXhr.instances.length, 2); + }); + + test('cancels the running upload', assert => { + chooseFiles([newFile('a.txt', 3)]); + const field = newField(); + field.upload(); + + field.cancel(); + + assert.true(FakeXhr.instances[0].aborted); + assert.deepEqual(calls, ['cancel']); + assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'upload.canceled'}); + }); + + test('does not cancel an upload that has ended', assert => { + chooseFiles([newFile('a.txt', 3)]); + const field = newField(); + field.upload(); + FakeXhr.instances[0].respond(200, '[]'); + + field.cancel(); + + assert.notOk(FakeXhr.instances[0].aborted); + assert.deepEqual(calls, ['success']); + }); + + test('does not send the upload if the before-send callback returns false', assert => { + chooseFiles([newFile('a.txt', 3)]); + const field = newField({}, function () { return false; }); + + assert.false(field.upload()); + + assert.notOk(FakeXhr.instances[0].body, 'not sent'); + assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'upload.canceled'}); + field.upload(); + assert.equal(FakeXhr.instances.length, 2, 'the field is not left busy'); + }); +}); diff --git a/wicket-migration/src/main/resources/META-INF/rewrite/wicket.yml b/wicket-migration/src/main/resources/META-INF/rewrite/wicket.yml index 7bd0e14305..e81195b61a 100644 --- a/wicket-migration/src/main/resources/META-INF/rewrite/wicket.yml +++ b/wicket-migration/src/main/resources/META-INF/rewrite/wicket.yml @@ -89,6 +89,16 @@ recipeList: # handed the object the model was holding before that object is discarded; the default onDetach() # delegates to it, so an override of onDetach() that does not call super suppresses it. These # require manual migration. +# +# org.apache.wicket.markup.html.form.upload.resource.IUploadsFileManager#save() is deprecated in +# favour of store(), which stores the file under a name of its own and returns it; getFile() then +# receives that name. The name travels through the browser, so getFile() must not resolve it to a +# file of another upload field. FolderUploadsFileManager stores files under random names, so code +# finding them by client file name uses UploadInfo#getFile() or #getFileName() instead. Subclasses +# of AbstractFileUploadResource receive the stored names in saveFiles(), prepareResponse() and +# generateJsonResponse(), subclasses of FileUploadResourceReference in generateFileInfo(). A +# failed upload reads "The upload failed." (resource key uploadToResourceFailed) instead of +# ending on the error page. These require manual migration. type: specs.openrewrite.org/v1beta/recipe name: org.apache.wicket.MigrateToWicket11 displayName: Migrate to Wicket 11.x
