Skip to content

Commit 1c777e1

Browse files
authored
fix: Use content type from header and name from X-Filename/Content-Disposition for XHR uploads (#22661)
Uses the filename from a X-Filename header (set by vaadin-upload). The filename is encoded using JavaScript's encodeURIComponent and decoded on the server using UrlUtil.decodeURIComponent (RFC 3986).
1 parent 3a9ff7e commit 1c777e1

File tree

4 files changed

+198
-4
lines changed

4 files changed

+198
-4
lines changed

flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import java.io.UnsupportedEncodingException;
2121
import java.net.URLEncoder;
2222
import java.nio.charset.StandardCharsets;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
2325

2426
/**
2527
* Internal utility class for URL handling.
@@ -31,6 +33,9 @@
3133
*/
3234
public class UrlUtil {
3335

36+
private static final Pattern PERCENT_ENCODED = Pattern
37+
.compile("%([0-9A-Fa-f]{2})");
38+
3439
private UrlUtil() {
3540
}
3641

@@ -105,6 +110,63 @@ public static String encodeURIComponent(String path) {
105110
}
106111
}
107112

113+
/**
114+
* Decodes a percent-encoded string according to RFC 3986.
115+
* <p>
116+
* Corresponds to decodeURIComponent in JavaScript
117+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent
118+
* <p>
119+
* Unlike {@link java.net.URLDecoder}, this method does not treat '+' as a
120+
* space character, making it suitable for decoding strings encoded with
121+
* JavaScript's {@code encodeURIComponent()} or
122+
* {@link #encodeURIComponent(String)}.
123+
*
124+
* @param encoded
125+
* the percent-encoded string
126+
* @return the decoded string
127+
*/
128+
public static String decodeURIComponent(String encoded) {
129+
if (encoded == null || encoded.isEmpty()) {
130+
return encoded;
131+
}
132+
133+
Matcher matcher = PERCENT_ENCODED.matcher(encoded);
134+
StringBuilder result = new StringBuilder();
135+
int lastEnd = 0;
136+
137+
while (matcher.find()) {
138+
// Append text before the match
139+
result.append(encoded, lastEnd, matcher.start());
140+
141+
// Decode the hex value
142+
String hex = matcher.group(1);
143+
int value = Integer.parseInt(hex, 16);
144+
result.append((char) value);
145+
146+
lastEnd = matcher.end();
147+
}
148+
149+
// Append remaining text
150+
result.append(encoded, lastEnd, encoded.length());
151+
152+
// Handle multi-byte UTF-8 sequences
153+
byte[] bytes = new byte[result.length()];
154+
boolean hasMultibyte = false;
155+
for (int i = 0; i < result.length(); i++) {
156+
char c = result.charAt(i);
157+
if (c > 127) {
158+
hasMultibyte = true;
159+
}
160+
bytes[i] = (byte) c;
161+
}
162+
163+
if (hasMultibyte) {
164+
return new String(bytes, StandardCharsets.UTF_8);
165+
}
166+
167+
return result.toString();
168+
}
169+
108170
/**
109171
* Returns the given absolute path as a path relative to the servlet path.
110172
*

flow-server/src/main/java/com/vaadin/flow/server/streams/TransferUtil.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import com.vaadin.flow.component.Component;
4343
import com.vaadin.flow.component.ComponentUtil;
4444
import com.vaadin.flow.dom.Element;
45+
import com.vaadin.flow.internal.UrlUtil;
4546
import com.vaadin.flow.internal.streams.UploadCompleteEvent;
4647
import com.vaadin.flow.internal.streams.UploadStartEvent;
4748
import com.vaadin.flow.server.VaadinRequest;
@@ -150,7 +151,6 @@ static void handleUpload(UploadHandler handler, VaadinRequest request,
150151
&& JakartaServletFileUpload
151152
.isMultipartContent((HttpServletRequest) request);
152153
try {
153-
String fileName;
154154
if (isMultipartUpload) {
155155
Collection<Part> parts = Collections.EMPTY_LIST;
156156
try {
@@ -227,9 +227,21 @@ static void handleUpload(UploadHandler handler, VaadinRequest request,
227227
}
228228
}
229229
} else {
230-
// These are unknown in filexhr ATM
231-
fileName = "unknown";
232-
String contentType = "unknown";
230+
// Extract filename from X-Filename header
231+
// The filename is encoded using JavaScript's encodeURIComponent
232+
String fileName = request.getHeader("X-Filename");
233+
234+
if (fileName == null || fileName.isEmpty()) {
235+
fileName = "unknown";
236+
} else {
237+
// Decode the percent-encoded filename
238+
fileName = UrlUtil.decodeURIComponent(fileName);
239+
}
240+
241+
String contentType = request.getHeader("Content-Type");
242+
if (contentType == null || contentType.isEmpty()) {
243+
contentType = "unknown";
244+
}
233245

234246
UploadEvent event = new UploadEvent(request, response, session,
235247
fileName, request.getContentLengthLong(), contentType,

flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,55 @@ private VaadinServletRequest createRequest(String contextPath,
128128
return new VaadinServletRequest(request,
129129
Mockito.mock(VaadinServletService.class));
130130
}
131+
132+
@Test
133+
public void decodeURIComponent_percentEncodedSpace_decoded() {
134+
String result = UrlUtil.decodeURIComponent("test%20file.txt");
135+
Assert.assertEquals("test file.txt", result);
136+
}
137+
138+
@Test
139+
public void decodeURIComponent_plusSign_notDecodedAsSpace() {
140+
// Plus signs should remain as plus signs (RFC 3986, not HTML form
141+
// encoding)
142+
String result = UrlUtil.decodeURIComponent("test+file.txt");
143+
Assert.assertEquals("test+file.txt", result);
144+
}
145+
146+
@Test
147+
public void decodeURIComponent_encodedPlusSign_decoded() {
148+
String result = UrlUtil.decodeURIComponent("test%2Bfile.txt");
149+
Assert.assertEquals("test+file.txt", result);
150+
}
151+
152+
@Test
153+
public void decodeURIComponent_unicodeCharacters_decoded() {
154+
// åäö.txt encoded as UTF-8 percent-encoded
155+
String result = UrlUtil.decodeURIComponent("%C3%A5%C3%A4%C3%B6.txt");
156+
Assert.assertEquals("åäö.txt", result);
157+
}
158+
159+
@Test
160+
public void decodeURIComponent_specialCharacters_decoded() {
161+
String result = UrlUtil.decodeURIComponent("special%26%3Dchars.txt");
162+
Assert.assertEquals("special&=chars.txt", result);
163+
}
164+
165+
@Test
166+
public void decodeURIComponent_nullValue_returnsNull() {
167+
String result = UrlUtil.decodeURIComponent(null);
168+
Assert.assertNull(result);
169+
}
170+
171+
@Test
172+
public void decodeURIComponent_emptyValue_returnsEmpty() {
173+
String result = UrlUtil.decodeURIComponent("");
174+
Assert.assertEquals("", result);
175+
}
176+
177+
@Test
178+
public void decodeURIComponent_noEncodedChars_returnsSame() {
179+
String result = UrlUtil.decodeURIComponent("simple.txt");
180+
Assert.assertEquals("simple.txt", result);
181+
}
131182
}

flow-server/src/test/java/com/vaadin/flow/server/communication/UploadHandlerTest.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,75 @@ public void doUploadHandleXhrFilePost_happyPath_setContentTypeAndResponseHandled
166166
Mockito.verify(response, Mockito.times(1)).setStatus(200);
167167
}
168168

169+
@Test
170+
public void xhrUpload_filenameFromHeader_extractedCorrectly()
171+
throws IOException {
172+
final String[] capturedFilename = new String[1];
173+
174+
UploadHandler handler = (event) -> {
175+
capturedFilename[0] = event.getFileName();
176+
};
177+
178+
Mockito.when(request.getHeader("X-Filename")).thenReturn("test.txt");
179+
180+
handler.handleRequest(request, response, session, element);
181+
182+
Assert.assertEquals("test.txt", capturedFilename[0]);
183+
}
184+
185+
@Test
186+
public void xhrUpload_encodedFilename_decodedCorrectly()
187+
throws IOException {
188+
final String[] capturedFilename = new String[1];
189+
190+
UploadHandler handler = (event) -> {
191+
capturedFilename[0] = event.getFileName();
192+
};
193+
194+
// encodeURIComponent("my file åäö.txt") in JavaScript
195+
Mockito.when(request.getHeader("X-Filename"))
196+
.thenReturn("my%20file%20%C3%A5%C3%A4%C3%B6.txt");
197+
198+
handler.handleRequest(request, response, session, element);
199+
200+
Assert.assertEquals("my file åäö.txt", capturedFilename[0]);
201+
}
202+
203+
@Test
204+
public void xhrUpload_contentTypeFromHeader_extractedCorrectly()
205+
throws IOException {
206+
final String[] capturedContentType = new String[1];
207+
208+
UploadHandler handler = (event) -> {
209+
capturedContentType[0] = event.getContentType();
210+
};
211+
212+
Mockito.when(request.getHeader("X-Filename")).thenReturn("test.txt");
213+
Mockito.when(request.getHeader("Content-Type"))
214+
.thenReturn("text/plain");
215+
216+
handler.handleRequest(request, response, session, element);
217+
218+
Assert.assertEquals("text/plain", capturedContentType[0]);
219+
}
220+
221+
@Test
222+
public void xhrUpload_missingContentTypeHeader_defaultsToUnknown()
223+
throws IOException {
224+
final String[] capturedContentType = new String[1];
225+
226+
UploadHandler handler = (event) -> {
227+
capturedContentType[0] = event.getContentType();
228+
};
229+
230+
Mockito.when(request.getHeader("X-Filename")).thenReturn("test.txt");
231+
Mockito.when(request.getHeader("Content-Type")).thenReturn(null);
232+
233+
handler.handleRequest(request, response, session, element);
234+
235+
Assert.assertEquals("unknown", capturedContentType[0]);
236+
}
237+
169238
@Test
170239
public void doUploadHandleXhrFilePost_unhappyPath_responseHandled()
171240
throws IOException {

0 commit comments

Comments
 (0)