From 1a9e42b49d814cf54836eeff4a72ff8216ca9e95 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 25 Aug 2015 20:52:30 -0400 Subject: [PATCH] Support multipart filename with charset StandardMultipartHttpServletRequest now supports filenames with charset information. Issue: SPR-13319 --- .../StandardMultipartHttpServletRequest.java | 49 ++++++++++++++-- .../RequestPartIntegrationTests.java | 57 ++++++++++++++++--- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java index b842d9e9cf4..a86f713e67f 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java @@ -20,6 +20,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -44,6 +45,7 @@ import org.springframework.web.multipart.MultipartFile; * methods - without any custom processing on our side. * * @author Juergen Hoeller + * @author Rossen Stoyanchev * @since 3.1 */ public class StandardMultipartHttpServletRequest extends AbstractMultipartHttpServletRequest { @@ -52,6 +54,11 @@ public class StandardMultipartHttpServletRequest extends AbstractMultipartHttpSe private static final String FILENAME_KEY = "filename="; + private static final String FILENAME_WITH_CHARSET_KEY = "filename*="; + + private static final Charset US_ASCII = Charset.forName("us-ascii"); + + private Set multipartParameterNames; @@ -86,7 +93,11 @@ public class StandardMultipartHttpServletRequest extends AbstractMultipartHttpSe this.multipartParameterNames = new LinkedHashSet(parts.size()); MultiValueMap files = new LinkedMultiValueMap(parts.size()); for (Part part : parts) { - String filename = extractFilename(part.getHeader(CONTENT_DISPOSITION)); + String disposition = part.getHeader(CONTENT_DISPOSITION); + String filename = extractFilename(disposition); + if (filename == null) { + filename = extractFilenameWithCharset(disposition); + } if (filename != null) { files.add(part.getName(), new StandardMultipartFile(part, filename)); } @@ -102,15 +113,18 @@ public class StandardMultipartHttpServletRequest extends AbstractMultipartHttpSe } private String extractFilename(String contentDisposition) { + return extractFilename(contentDisposition, FILENAME_KEY); + } + + private String extractFilename(String contentDisposition, String key) { if (contentDisposition == null) { return null; } - // TODO: can only handle the typical case at the moment - int startIndex = contentDisposition.indexOf(FILENAME_KEY); + int startIndex = contentDisposition.indexOf(key); if (startIndex == -1) { return null; } - String filename = contentDisposition.substring(startIndex + FILENAME_KEY.length()); + String filename = contentDisposition.substring(startIndex + key.length()); if (filename.startsWith("\"")) { int endIndex = filename.indexOf("\"", 1); if (endIndex != -1) { @@ -126,6 +140,33 @@ public class StandardMultipartHttpServletRequest extends AbstractMultipartHttpSe return filename; } + private String extractFilenameWithCharset(String contentDisposition) { + String filename = extractFilename(contentDisposition, FILENAME_WITH_CHARSET_KEY); + if (filename == null) { + return null; + } + int index = filename.indexOf("'"); + if (index != -1) { + Charset charset = null; + try { + charset = Charset.forName(filename.substring(0, index)); + } + catch (IllegalArgumentException ex) { + // ignore + } + filename = filename.substring(index + 1); + // Skip language information.. + index = filename.indexOf("'"); + if (index != -1) { + filename = filename.substring(index + 1); + } + if (charset != null) { + filename = new String(filename.getBytes(US_ASCII), charset); + } + } + return filename; + } + @Override protected void initializeMultipart() { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartIntegrationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartIntegrationTests.java index a1bc88b761c..bc4f322ade7 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartIntegrationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartIntegrationTests.java @@ -19,9 +19,9 @@ package org.springframework.web.servlet.mvc.method.annotation; import java.net.URI; import java.nio.charset.Charset; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; import java.util.List; - +import java.util.Map; import javax.servlet.MultipartConfigElement; import org.eclipse.jetty.server.Connector; @@ -29,7 +29,6 @@ import org.eclipse.jetty.server.NetworkConnector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; - import org.junit.AfterClass; import org.junit.Assert; import org.junit.Before; @@ -43,6 +42,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.converter.ByteArrayHttpMessageConverter; @@ -52,9 +52,9 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.stereotype.Controller; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.client.RestTemplate; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; @@ -66,7 +66,8 @@ import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.springframework.web.bind.annotation.RequestMethod.POST; /** * Test access to parts of a multipart request with {@link RequestPart}. @@ -117,7 +118,7 @@ public class RequestPartIntegrationTests { @Before public void setUp() { ByteArrayHttpMessageConverter emptyBodyConverter = new ByteArrayHttpMessageConverter(); - emptyBodyConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON)); + emptyBodyConverter.setSupportedMediaTypes(Collections.singletonList(MediaType.APPLICATION_JSON)); List> converters = new ArrayList<>(3); converters.add(emptyBodyConverter); @@ -129,7 +130,7 @@ public class RequestPartIntegrationTests { converter.setPartConverters(converters); restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory()); - restTemplate.setMessageConverters(Arrays.>asList(converter)); + restTemplate.setMessageConverters(Collections.singletonList(converter)); } @AfterClass @@ -150,6 +151,37 @@ public class RequestPartIntegrationTests { testCreate(baseUrl + "/standard-resolver/test"); } + // SPR-13319 + + @Test + public void standardMultipartResolverWithEncodedFileName() throws Exception { + + byte[] boundary = MimeTypeUtils.generateMultipartBoundary(); + String boundaryText = new String(boundary, "US-ASCII"); + Map params = Collections.singletonMap("boundary", boundaryText); + + String content = + "--" + boundaryText + "\n" + + "Content-Disposition: form-data; name=\"file\"; filename*=\"utf-8''%C3%A9l%C3%A8ve.txt\"\n" + + "Content-Type: text/plain\n" + + "Content-Length: 7\n" + + "\n" + + "content\n" + + "--" + boundaryText + "--"; + + RequestEntity requestEntity = + RequestEntity.post(new URI(baseUrl + "/standard-resolver/spr13319")) + .contentType(new MediaType(MediaType.MULTIPART_FORM_DATA, params)) + .body(content.getBytes(Charset.forName("us-ascii"))); + + ByteArrayHttpMessageConverter converter = new ByteArrayHttpMessageConverter(); + converter.setSupportedMediaTypes(Collections.singletonList(MediaType.MULTIPART_FORM_DATA)); + this.restTemplate.setMessageConverters(Collections.singletonList(converter)); + + ResponseEntity responseEntity = restTemplate.exchange(requestEntity, Void.class); + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + } + private void testCreate(String url) { MultiValueMap parts = new LinkedMultiValueMap(); parts.add("json-data", new HttpEntity(new TestData("Jason"))); @@ -176,6 +208,7 @@ public class RequestPartIntegrationTests { } @Configuration + @SuppressWarnings("unused") static class CommonsMultipartResolverTestConfig extends RequestPartTestConfig { @Bean @@ -185,6 +218,7 @@ public class RequestPartIntegrationTests { } @Configuration + @SuppressWarnings("unused") static class StandardMultipartResolverTestConfig extends RequestPartTestConfig { @Bean @@ -194,9 +228,10 @@ public class RequestPartIntegrationTests { } @Controller + @SuppressWarnings("unused") private static class RequestPartTestController { - @RequestMapping(value = "/test", method = RequestMethod.POST, consumes = { "multipart/mixed", "multipart/form-data" }) + @RequestMapping(value = "/test", method = POST, consumes = { "multipart/mixed", "multipart/form-data" }) public ResponseEntity create(@RequestPart(name = "json-data") TestData testData, @RequestPart("file-data") MultipartFile file, @RequestPart(name = "empty-data", required = false) TestData emptyData, @@ -209,6 +244,12 @@ public class RequestPartIntegrationTests { headers.setLocation(URI.create(url)); return new ResponseEntity(headers, HttpStatus.CREATED); } + + @RequestMapping(value = "/spr13319", method = POST, consumes = "multipart/form-data") + public ResponseEntity create(@RequestPart("file") MultipartFile multipartFile) { + assertEquals("%C3%A9l%C3%A8ve.txt", multipartFile.getOriginalFilename()); + return ResponseEntity.ok().build(); + } } @SuppressWarnings("unused")