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 0aa724c3b235fc67f9d78cc8c08f8d9d2d886622
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, of no files selected and of a lost
      connection, and reads anything else as a failed upload. The script
      reports HTTP errors, answers that are not JSON and requests that time
      out as uploadFailed, and a request that got no answer as
      uploadConnectionLost, which the field
      words "The upload failed: the connection to the server was lost.". The
      check is the protected getUploadErrorMessage(String), so a subclass
      whose own script reports other keys can word them; the field still
      never looks up a key it does not know, since the browser can send any.
      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, as did a timeout the before-send
      callback set on the request. 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
      or a timeout 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, and Wicket no longer calls it. The default
      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. The default save() throws an
      UnsupportedOperationException saying to implement store(): save() used
      to be abstract, and with two defaults calling each other an
      implementation overriding neither would compile and then fail with a
      StackOverflowError.
    - 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.
    - FileUploadToResourceField gets the constants UPLOAD_FAILED_RESOURCE_KEY
      and CONNECTION_LOST_RESOURCE_KEY, and getUploadErrorMessage(String)
      becomes protected. Both are additions.
    
    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. An upload whose connection breaks off
    reads "The upload failed: the connection to the server was lost.", and
    so does one the server refuses by resetting the connection, for a limit
    the field does not check before sending. The client-side error callback
    receives only resource keys as errorMessage, uploadConnectionLost among
    them. A before-send callback returning false now keeps the upload from
    being sent, and an upload that times out calls the error callback and
    onUploadFailure() with uploadFailed. Code calling the deprecated save()
    on a manager that implements only store() gets an
    UnsupportedOperationException, and calls store() instead.
    
    migration-to-11.md describes the migration in detail: for each change,
    what it was before, what it is now, who has to change something and how.
    The comment of the MigrateToWicket11 recipe has the short version, as for
    the other changes of Wicket 11 that need manual migration. Draft for the
    migration guide on the wiki:
    
      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.
---
 migration-to-11.md                                 | 218 ++++++++++++
 testing/wicket-js-tests/Gruntfile.js               |   3 +-
 .../FileUploadToResourceFieldSecurityTest.java     |   8 +-
 .../FileUploadToResourceFieldTest$UploadPage.html  |   5 +
 .../resource/FileUploadToResourceFieldTest.java    | 380 +++++++++++++++++++++
 .../resource/FolderUploadsFileManagerTest.java     |  38 +++
 .../upload/resource/IUploadsFileManagerTest.java   | 100 ++++++
 .../resource/AbstractFileUploadResource.java       |  97 ++----
 .../resource/FileUploadResourceReference.java      |  22 +-
 .../upload/resource/FileUploadToResourceField.java | 143 ++++++--
 .../upload/resource/FileUploadToResourceField.js   | 142 ++++++--
 .../resource/FileUploadToResourceField.properties  |   4 +-
 .../upload/resource/FolderUploadsFileManager.java  |  37 +-
 .../form/upload/resource/IUploadsFileManager.java  |  56 ++-
 wicket-core/src/test/js/all.html                   |   7 +
 wicket-core/src/test/js/upload-to-resource.js      | 240 +++++++++++++
 .../src/main/resources/META-INF/rewrite/wicket.yml |  11 +
 17 files changed, 1353 insertions(+), 158 deletions(-)

diff --git a/migration-to-11.md b/migration-to-11.md
new file mode 100644
index 0000000000..d80ab57180
--- /dev/null
+++ b/migration-to-11.md
@@ -0,0 +1,218 @@
+# Migrating to Wicket 11
+
+This file describes changes in Wicket 11 that break existing code or change 
behaviour, and what
+an application has to do about them. The official guide is going to be the 
wiki page
+[Migration to Wicket 
11.0](https://cwiki.apache.org/confluence/display/WICKET/Migration+to+Wicket+11.0),
+which does not exist yet.
+
+The OpenRewrite recipe `org.apache.wicket.MigrateToWicket11`
+(`wicket-migration/src/main/resources/META-INF/rewrite/wicket.yml`) handles 
the mechanical part
+of the migration. Its comment lists the other Wicket 11 changes that need 
manual migration.
+
+## Uploads to a resource (`FileUploadToResourceField`)
+
+Pull request [#1671](https://github.com/apache/wicket/pull/1671), issue
+[#1670](https://github.com/apache/wicket/issues/1670). All classes are in
+`org.apache.wicket.markup.html.form.upload.resource`. None of these changes is 
mechanical, so
+there is no OpenRewrite recipe for them.
+
+### Who has to change something
+
+You have nothing to do if you use `FileUploadToResourceField` with the default
+`FileUploadResourceReference`, and reach the uploaded files only through 
`UploadInfo#getFile()`.
+
+You do have to act if you:
+
+- implement `IUploadsFileManager`;
+- find files stored by `FolderUploadsFileManager` by their client file name;
+- extend `AbstractFileUploadResource` or `FileUploadResourceReference`;
+- rely on `UploadInfo#equals()`, on the error texts of the field, or on what 
the browser-side
+  callbacks receive.
+
+### Why the API changed
+
+`FolderUploadsFileManager` stored each file under its client file name, so two 
files of the same
+name overwrote each other, in one upload or in two, while the field was told 
of both. Keeping
+them apart needs a name for each stored file that the file manager chooses, 
and that name has to
+travel from the file manager through the browser back to the field. That is 
why the file manager,
+the resource and `UploadInfo` all gained a file name.
+
+### `IUploadsFileManager`
+
+Before:
+
+```java
+void save(FileUpload fileItem, String uploadFieldId);
+
+File getFile(String uploadFieldId, String clientFileName);
+```
+
+After:
+
+```java
+// new, @since 11.0.0
+default String store(FileUpload fileItem, String uploadFieldId)
+{
+       save(fileItem, uploadFieldId);
+       return fileItem.getClientFileName();
+}
+
+@Deprecated(since = "11.0.0", forRemoval = true)
+default void save(FileUpload fileItem, String uploadFieldId)
+{
+       throw new UnsupportedOperationException(...);
+}
+
+File getFile(String uploadFieldId, String fileName);
+```
+
+To migrate, implement `store()` instead of `save()`:
+
+- Store the file under a name of your own choice and return that name. It must 
be unique among
+  the files of the upload field.
+- `getFile()` then receives that name.
+- The name goes to the browser and comes back from it, so it must not reveal 
anything the user
+  is not supposed to know.
+- Since the browser can change the name, `getFile()` must never resolve it to 
a file of another
+  upload field.
+
+An existing implementation of `save()` still compiles, with a removal warning, 
and keeps working
+unchanged. The default `store()` calls it and returns the client file name, 
which means files of
+the same name still overwrite each other until you implement `store()`.
+
+Watch out:
+
+- `save()` used to be abstract, and the compiler made you implement it. Now it 
has a default, so
+  an implementation that overrides neither method compiles. At its first 
upload it fails with an
+  `UnsupportedOperationException` that says to implement `store()`.
+- Wicket no longer calls `save()`. If your own code calls it, call `store()` 
instead. On a manager
+  that implements only `store()`, the deprecated `save()` throws an
+  `UnsupportedOperationException`. `FolderUploadsFileManager` still implements 
both.
+
+### `FolderUploadsFileManager`
+
+| | Before | After |
+|---|---|---|
+| Stores with | `save()` | `store()`; the deprecated `save()` still works as 
before |
+| File on disk | `<folder>/<uploadFieldId>/<client file name>` | 
`<folder>/<uploadFieldId>/<random UUID>` |
+| `getFile(uploadFieldId, name)` | `name` is the client file name | `name` is 
the stored name |
+
+To migrate, find the files with `UploadInfo#getFile()` or 
`UploadInfo#getFileName()`, not by
+their client file name. Files stored before the upgrade keep their client file 
name on disk, and
+`getFile(uploadFieldId, clientFileName)` still finds them. The checks against 
path traversal are
+unchanged.
+
+### `AbstractFileUploadResource`
+
+Before:
+
+```java
+protected void saveFiles(List<FileUpload> fileItems, String uploadId)
+
+protected void prepareResponse(ResourceResponse resourceResponse, 
ServletWebRequest webRequest,
+       List<FileUpload> fileItems)
+
+protected abstract String generateJsonResponse(ResourceResponse 
resourceResponse,
+       ServletWebRequest webRequest, List<FileUpload> files);
+```
+
+After:
+
+```java
+protected List<String> saveFiles(List<FileUpload> fileItems, String uploadId)
+
+protected void prepareResponse(ResourceResponse resourceResponse, 
ServletWebRequest webRequest,
+       List<FileUpload> fileItems, List<String> fileNames)
+
+protected abstract String generateJsonResponse(ResourceResponse 
resourceResponse,
+       ServletWebRequest webRequest, List<FileUpload> files, List<String> 
fileNames);
+```
+
+`fileNames` holds the stored name of each file, in the order of the files. 
There are no
+deprecated overloads with the old signatures, on purpose: these methods are 
hooks that the
+resource calls, so an override of an old signature would compile and then 
never be called. With
+`@Override` it now fails to compile, which tells you to migrate.
+
+To migrate:
+
+- Add the `List<String> fileNames` parameter to your overrides.
+- In `generateJsonResponse()`, write each name into the JSON of its file as 
`fileName`.
+- An override of `saveFiles()` returns the names that 
`IUploadsFileManager#store()` returned.
+
+How the resource answers errors has changed as well:
+
+| | Before | After |
+|---|---|---|
+| Answer to a failure | `{"error": true, "errorMessage": "<key>"}` | Unchanged 
|
+| Exception other than a `FileUploadException` (the file manager failing, for 
example) | Not logged. With debug logging on, the exception message was sent as 
`errorMessage` | Logged at ERROR. The key `uploadFailed` is sent, never the 
exception message |
+
+### `FileUploadResourceReference`
+
+Before:
+
+```java
+protected void generateFileInfo(JSONObject fileJson, FileUpload fileItem)
+```
+
+After:
+
+```java
+protected void generateFileInfo(JSONObject fileJson, FileUpload fileItem, 
String fileName)
+```
+
+The JSON of each file gets `fileName` next to `clientFileName`, `size` and 
`contentType`. To
+migrate, add the parameter to your override, and keep calling `super` or put 
`fileName` in
+yourself.
+
+### `FileUploadToResourceField.UploadInfo`
+
+| | Before | After |
+|---|---|---|
+| Constructor | `UploadInfo(String clientFileName, long size, String 
contentType)` | `UploadInfo(String fileName, String clientFileName, long size, 
String contentType)`. The old one is deprecated and uses the client file name 
as the stored name |
+| Stored name | None | `getFileName()` |
+| `equals()` and `hashCode()` | By client file name | By stored name: two 
files of the same client name are no longer equal |
+| `fromJson()` | Reads `clientFileName`, `size`, `contentType` | Also reads 
`fileName`, and falls back to `clientFileName` when it is missing, as in the 
answer of a resource written for Wicket 10 |
+
+### `FileUploadToResourceField`: errors
+
+| | Before | After |
+|---|---|---|
+| Failure other than a limit (`uploadFailed`) | The text interpolates 
`${exception.localizedMessage}`, where the field had put a String, so the Ajax 
request failed and the user landed on the error page | `onUploadFailure()` 
receives "The upload failed." (key `uploadToResourceFailed`) |
+| HTTP error | The status text was looked up as a resource key: 
`MissingResourceException`, and the error page | Read as a failed upload |
+| Empty key (HTTP/2, network failure) | "File must be less than …" 
(`uploadTooLarge`) | Network failure: "The upload failed: the connection to the 
server was lost." (key `uploadConnectionLost`). HTTP/2 error: read as a failed 
upload |
+| Unknown key | `MissingResourceException`, and the error page | Read as a 
failed upload |
+| Choosing the text for a key | Private | `protected String 
getUploadErrorMessage(String errorKey)` |
+
+To migrate:
+
+- If you changed the text of `uploadFailed` to work around the error page, 
change
+  `uploadToResourceFailed` instead.
+- To word errors that your own script reports, override 
`getUploadErrorMessage()`. Answer the
+  keys you know and leave the others to `super`. Never look up a key you were 
given as it is: it
+  comes from the browser.
+
+New public constants: `UPLOAD_FAILED_RESOURCE_KEY` 
(`"uploadToResourceFailed"`) and
+`CONNECTION_LOST_RESOURCE_KEY` (`"uploadConnectionLost"`).
+
+New resource keys, in `FileUploadToResourceField.properties`:
+
+| Key | Text |
+|---|---|
+| `uploadToResourceFailed` | The upload failed. |
+| `uploadConnectionLost` | The upload failed: the connection to the server was 
lost. |
+
+### `FileUploadToResourceField`: browser side
+
+| | Before | After |
+|---|---|---|
+| `getClientBeforeSendCallBack()` default | `function (xhr, settings) { return 
true; }`. The return value was ignored and `settings` never passed | `function 
(xhr) { return true; }`. Returning `false` keeps the upload from being sent and 
calls `onUploadCanceled()` |
+| `errorMessage` given to `getClientSideUploadErrorCallBack()` | A resource 
key, an HTTP status text, or an exception message | Only keys: 
`uploadTooLarge`, `uploadSingleFileTooLarge`, `uploadTooManyFiles`, 
`wicket.no.files.selected`, `uploadFailed`, `uploadConnectionLost` |
+| Upload started while one runs | A second request was sent, and the two broke 
each other | Refused: `upload()` returns `false` and logs it |
+| Files beyond a limit of the field | Sent, then refused by the server, often 
with a reset connection | Not sent. The error callback gets the key of the 
limit, `onUploadFailure()` its text |
+| Telling the field which files arrived | `GET` with the files in the URL. 
Beyond about 65 files the server answered 414, and `onUploadSuccess()` was 
never called | `POST` |
+| `cancel()` after the upload ended | Called the cancel callback and logged 
"canceled" | Only logs that there is nothing to cancel |
+| Answer that is not JSON | The field waited forever | The upload fails with 
`uploadFailed` |
+| Timeout, set on the request by the before-send callback | Neither the error 
callback nor `onUploadFailure()` was called | The upload fails with 
`uploadFailed` |
+
+To migrate, check a before-send callback that returns `false`: it used to be 
ignored, and now
+cancels the upload. Also check an error callback that expects HTTP status 
texts.
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..da0f6739ce
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceFieldTest.java
@@ -0,0 +1,380 @@
+/*
+ * 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 answersAnUploadWithoutFilesWithTheKeyOfNoFilesSelected()
+       {
+               startPage();
+
+               tester.getRequest().setMethod("POST");
+               tester.getRequest().setUseMultiPartContentType(true);
+               
tester.getRequest().getPostParameters().setParameterValue("other", "value");
+               tester.executeUrl(page.field.buildUploadUrl());
+
+               JSONObject answer = new 
JSONObject(tester.getLastResponseAsString());
+               assertTrue(answer.getBoolean("error"));
+               assertEquals(AbstractFileUploadResource.NO_FILE_SELECTED, 
answer.getString("errorMessage"));
+       }
+
+       @Test
+       void answersFilesBeyondALimitWithTheKeyOfTheLimit() throws IOException
+       {
+               startPage();
+
+               submitUpload(createUploadFile("1", "file.txt", "1"), 
createUploadFile("2", "file.txt", "2"),
+                       createUploadFile("3", "file.txt", "3"), 
createUploadFile("4", "file.txt", "4"));
+
+               JSONObject answer = new 
JSONObject(tester.getLastResponseAsString());
+               assertTrue(answer.getBoolean("error"));
+               assertEquals("uploadTooManyFiles", 
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 callsOnUploadFailureWithTheTextOfALostConnection()
+       {
+               startPage();
+
+               report("error", "true", "errorMessage",
+                       FileUploadToResourceField.CONNECTION_LOST_RESOURCE_KEY);
+
+               assertEquals(List.of("The upload failed: the connection to the 
server was lost."),
+                       page.field.failures);
+       }
+
+       @Test
+       void callsOnUploadFailureWithTheTextOfAnErrorASubclassWords()
+       {
+               startPage();
+
+               report("error", "true", "errorMessage", TestField.PAUSED_KEY);
+               report("error", "true", "errorMessage", "uploadTooManyFiles");
+
+               assertEquals(List.of("The upload was paused.", "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());
+       }
+
+       @SuppressWarnings("removal")
+       @Test
+       void anUploadInfoWithoutStoredNameIsStoredUnderItsClientFileName()
+       {
+               FileUploadToResourceField.UploadInfo info =
+                       new FileUploadToResourceField.UploadInfo("file.txt", 3, 
"text/plain");
+
+               assertEquals("file.txt", info.getFileName());
+               assertEquals("file.txt", info.getClientFileName());
+       }
+
+       @Test
+       void uploadInfosAreEqualByTheirStoredName()
+       {
+               FileUploadToResourceField.UploadInfo info = new 
FileUploadToResourceField.UploadInfo(
+                       "stored-1", "same.txt", 3, "text/plain");
+               FileUploadToResourceField.UploadInfo sameStoredName =
+                       new FileUploadToResourceField.UploadInfo("stored-1", 
"other.txt", 5, "text/html");
+               FileUploadToResourceField.UploadInfo otherStoredName =
+                       new FileUploadToResourceField.UploadInfo("stored-2", 
"same.txt", 3, "text/plain");
+
+               assertEquals(info, sameStoredName);
+               assertEquals(info.hashCode(), sameStoredName.hashCode());
+               assertNotEquals(info, otherStoredName);
+       }
+
+       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;
+
+               static final String PAUSED_KEY = "uploadPaused";
+
+               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;
+               }
+
+               @Override
+               protected String getUploadErrorMessage(String errorKey)
+               {
+                       return PAUSED_KEY.equals(errorKey) ? "The upload was 
paused."
+                               : super.getUploadErrorMessage(errorKey);
+               }
+
+               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-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManagerTest.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManagerTest.java
new file mode 100644
index 0000000000..e936815f6f
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/IUploadsFileManagerTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.wicket.markup.html.form.upload.FileUpload;
+import org.apache.wicket.util.file.File;
+import org.junit.jupiter.api.Test;
+
+class IUploadsFileManagerTest
+{
+       @Test
+       void storeOfAManagerWrittenForSaveSavesTheFileUnderItsClientFileName()
+       {
+               List<String> saved = new ArrayList<>();
+               IUploadsFileManager manager = new IUploadsFileManager()
+               {
+                       @SuppressWarnings("removal")
+                       @Override
+                       public void save(FileUpload fileItem, String 
uploadFieldId)
+                       {
+                               saved.add(uploadFieldId + '/' + 
fileItem.getClientFileName());
+                       }
+
+                       @Override
+                       public File getFile(String uploadFieldId, String 
fileName)
+                       {
+                               return null;
+                       }
+               };
+
+               assertEquals("file.txt", 
manager.store(newFileUpload("file.txt"), "field"));
+               assertEquals(List.of("field/file.txt"), saved);
+       }
+
+       @SuppressWarnings("removal")
+       @Test
+       void saveOfAManagerWrittenForStoreSaysToCallStore()
+       {
+               IUploadsFileManager manager = new IUploadsFileManager()
+               {
+                       @Override
+                       public String store(FileUpload fileItem, String 
uploadFieldId)
+                       {
+                               return "stored-name";
+                       }
+
+                       @Override
+                       public File getFile(String uploadFieldId, String 
fileName)
+                       {
+                               return null;
+                       }
+               };
+
+               UnsupportedOperationException exception = assertThrows(
+                       UnsupportedOperationException.class,
+                       () -> manager.save(newFileUpload("file.txt"), "field"));
+               assertTrue(exception.getMessage().contains("store(FileUpload, 
String)"));
+       }
+
+       @Test
+       void 
storeOfAManagerImplementingNeitherMethodFailsSayingWhichToImplement()
+       {
+               IUploadsFileManager manager = (uploadFieldId, fileName) -> null;
+
+               UnsupportedOperationException exception = assertThrows(
+                       UnsupportedOperationException.class,
+                       () -> manager.store(newFileUpload("file.txt"), 
"field"));
+               assertTrue(exception.getMessage().contains("store(FileUpload, 
String)"));
+       }
+
+       private static FileUpload newFileUpload(String clientFileName)
+       {
+               FileUpload fileUpload = mock(FileUpload.class);
+               when(fileUpload.getClientFileName()).thenReturn(clientFileName);
+               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..5ce7eec0cd 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,26 @@ 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";
+
+    /**
+     * The resource key of the text of an upload whose connection broke off 
before the server
+     * answered, which the browser reports under this key.
+     *
+     * @since 11.0.0
+     */
+    public static final String CONNECTION_LOST_RESOURCE_KEY = 
"uploadConnectionLost";
+
+    private static final Set<String> ERROR_RESOURCE_KEYS = Set.of(
+        AbstractFileUploadResource.NO_FILE_SELECTED, 
CONNECTION_LOST_RESOURCE_KEY,
+        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 +236,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 +307,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 +333,30 @@ public abstract class FileUploadToResourceField extends 
FileUploadField
         add(ajaxBehavior);
     }
 
+    /**
+     * Words the error of a failed upload, which {@link 
#onUploadFailure(AjaxRequestTarget, String)}
+     * then receives. The key comes from the browser, which can send any, so 
it is never looked up
+     * as given: a key this method does not know gets the text of
+     * {@link #UPLOAD_FAILED_RESOURCE_KEY}. An override words the keys it 
knows, such as those its
+     * own script reports, and leaves the others to this method.
+     *
+     * @param errorKey
+     *            the key of the error the browser reported, or {@code null}
+     * @return the text of a limit, of no files selected or of
+     *         {@link #CONNECTION_LOST_RESOURCE_KEY}, else that of {@link 
#UPLOAD_FAILED_RESOURCE_KEY}
+     * @since 11.0.0
+     */
+    protected String getUploadErrorMessage(String errorKey)
+    {
+        final Map<String, Object> model = new HashMap<>();
+        model.put("maxSize", getMaxSize());
+        model.put("fileMaxSize", getFileMaxSize());
+        model.put("fileCountMax", getFileCountMax());
+        String key = errorKey != null && 
ERROR_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.
@@ -332,7 +397,7 @@ public abstract class FileUploadToResourceField extends 
FileUploadField
      *  Override to do something on a non successful upload
      *
      * @param target The {@link AjaxRequestTarget}
-     * @param errorInfo The cause of the failure
+     * @param errorInfo The cause of the failure, as {@link 
#getUploadErrorMessage(String)} words it
      */
     protected void onUploadFailure(AjaxRequestTarget target, String errorInfo)
     {
@@ -469,7 +534,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 +564,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 +584,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 +592,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..4ae7b50823 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,141 @@
         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 CONNECTION_LOST = 'uploadConnectionLost';
+    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(CONNECTION_LOST);
+        };
+
+        xhr.ontimeout = function () {
+            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 +185,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..c614ece583 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,6 @@
 #  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.
+uploadConnectionLost = The upload failed: the connection to the server was 
lost.
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..790f1ebfd5 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,72 @@ 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. An
+        * implementation has to implement one of the two methods.
         *
         * @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>
+        * Wicket no longer calls this method, only the default {@link 
#store(FileUpload, String)} does.
+        * The default implementation throws an {@link 
UnsupportedOperationException}: an
+        * implementation that implements neither method fails at its first 
upload with an exception
+        * saying so.
+        *
+        * @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 and call {@link #store(FileUpload, String)} 
instead.
+        */
+       @Deprecated(since = "11.0.0", forRemoval = true)
+       default void save(FileUpload fileItem, String uploadFieldId)
+       {
+               throw new UnsupportedOperationException(getClass().getName() +
+                       " does not implement the deprecated save(FileUpload, 
String): it has to implement " +
+                       "store(FileUpload, String), which is to be called 
instead");
+       }
+
+       /**
+        * 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..baac2feb0e
--- /dev/null
+++ b/wicket-core/src/test/js/upload-to-resource.js
@@ -0,0 +1,240 @@
+/*
+ * 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 */
+
+;(function (undefined) {
+       "use strict";
+
+       Wicket.Event.add(window, 'domready', function() {
+               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();
+               };
+               FakeXhr.prototype.timeOut = function () {
+                       this.ontimeout();
+               };
+
+               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 lost connection', assert 
=> {
+                       chooseFiles([newFile('a.txt', 3)]);
+                       newField().upload();
+
+                       FakeXhr.instances[0].onerror();
+
+                       assert.deepEqual(calls, ['error:uploadConnectionLost']);
+                       assert.deepEqual(reports[0].ep, {'error': true, 
'errorMessage': 'uploadConnectionLost'});
+               });
+
+               test('reports a timed-out upload as a failed upload, and 
another one can start', assert => {
+                       chooseFiles([newFile('a.txt', 3)]);
+                       const field = newField();
+                       field.upload();
+
+                       FakeXhr.instances[0].timeOut();
+
+                       assert.deepEqual(calls, ['error:uploadFailed']);
+                       assert.deepEqual(reports.map(report => report.ep), 
[{'error': true, 'errorMessage': 'uploadFailed'}]);
+                       assert.true(field.upload(), 'sent again');
+                       assert.equal(FakeXhr.instances.length, 2);
+               });
+
+               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..66cdaaa26f 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,17 @@ 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. Wicket no longer calls save(), and its default throws: 
code calling it calls
+# store() instead. 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

Reply via email to