From 99a8510ace46af9b05b822e7c65f08aae885ca98 Mon Sep 17 00:00:00 2001 From: Sebastien Deleuze Date: Tue, 8 Nov 2016 00:52:07 +0100 Subject: [PATCH] Introduce HttpHeaders get/setContentDisposition() This commit introduces a new ContentDisposition class designed to parse and generate Content-Disposition header value as defined in RFC 2183. It supports the disposition type and the name, filename (or filename* when encoded according to RFC 5987) and size parameters. This new class is usually used thanks to HttpHeaders#getContentDisposition() and HttpHeaders#setContentDisposition(ContentDisposition). Issue: SPR-14408 --- .../http/ContentDisposition.java | 383 ++++++++++++++++++ .../org/springframework/http/HttpHeaders.java | 89 ++-- .../http/ContentDispositionTests.java | 135 ++++++ .../http/HttpHeadersTests.java | 31 +- 4 files changed, 558 insertions(+), 80 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/ContentDisposition.java create mode 100644 spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java diff --git a/spring-web/src/main/java/org/springframework/http/ContentDisposition.java b/spring-web/src/main/java/org/springframework/http/ContentDisposition.java new file mode 100644 index 00000000000..67db20e1e1a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/ContentDisposition.java @@ -0,0 +1,383 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed 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.springframework.http; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Represent the content disposition type and parameters as defined in RFC 2183. + * + * @author Sebastien Deleuze + * @since 5.0 + * @see RFC 2183 + */ +public class ContentDisposition { + + private final String type; + + private final String name; + + private final String filename; + + private final Charset charset; + + private final Long size; + + /** + * Create a {@code ContentDisposition} instance with the specified disposition type + * and {@litteral name}, {@litteral filename} (encoded with the specified {@link Charset} + * if any) and {@litteral size} parameter values. + */ + private ContentDisposition(String type, String name, String filename, Charset charset, Long size) { + this.type = type; + this.name = name; + this.filename = filename; + this.charset = charset; + this.size = size; + } + + /** + * Return a builder for a {@code ContentDisposition}. + * @param type the disposition type like for example {@literal inline}, {@literal attachment}, + * or {@literal form-data} + * @return a content disposition builder + */ + public static Builder builder(String type) { + return new BuilderImpl(type); + } + + /** + * @return an empty content disposition + */ + public static ContentDisposition empty() { + return new ContentDisposition(null, null, null, null, null); + } + + /** + * Return the disposition type, like for example {@literal inline}, {@literal attachment}, + * {@literal form-data}, or {@code null} if not defined. + */ + public String getType() { + return this.type; + } + + /** + * Return the value of the {@literal name} parameter, or {@code null} if not defined. + */ + public String getName() { + return this.name; + } + + /** + * Return the value of the {@literal filename} parameter (or the value of the + * {@literal filename*} one decoded as defined in the RFC 5987), or {@code null} if not defined. + */ + public String getFilename() { + return this.filename; + } + + /** + * Return the charset defined in {@literal filename*} parameter, or {@code null} if not defined. + */ + public Charset getCharset() { + return this.charset; + } + + /** + * Return the value of the {@literal size} parameter, or {@code null} if not defined. + */ + public Long getSize() { + return this.size; + } + + /** + * Parse a {@literal Content-Disposition} header value as defined in RFC 2183. + * @param contentDisposition the {@literal Content-Disposition} header value + * @return the parsed content disposition + * @see #toString() + */ + public static ContentDisposition parse(String contentDisposition) { + String[] parts = StringUtils.tokenizeToStringArray(contentDisposition, ";"); + Assert.isTrue(parts.length >= 1); + String type = parts[0]; + String name = null; + String filename = null; + Charset charset = null; + Long size = null; + for (int i = 1; i < parts.length; i++) { + String parameter = parts[i]; + int eqIndex = parameter.indexOf('='); + if (eqIndex != -1) { + String attribute = parameter.substring(0, eqIndex); + String value = (parameter.startsWith("\"", eqIndex + 1) && parameter.endsWith("\"") ? + parameter.substring(eqIndex + 2, parameter.length() - 1) : + parameter.substring(eqIndex + 1, parameter.length())); + if (attribute.equals("name") ) { + name = value; + } + else if (attribute.equals("filename*") ) { + filename = decodeHeaderFieldParam(value); + charset = Charset.forName(value.substring(0, value.indexOf("'"))); + Assert.isTrue(StandardCharsets.UTF_8.equals(charset) || StandardCharsets.ISO_8859_1.equals(charset), + "Charset should be UTF-8 or ISO-8859-1"); + } + else if (attribute.equals("filename") && (filename == null)) { + filename = value; + } + else if (attribute.equals("size") ) { + size = Long.parseLong(value); + } + } + else { + throw new IllegalArgumentException("Invalid content disposition format"); + } + } + return new ContentDisposition(type, name, filename, charset, size); + } + + /** + * Encode the given header field param as describe in RFC 5987. + * @param input the header field param + * @param charset the charset of the header field param string, + * only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported + * @return the encoded header field param + * @see RFC 5987 + */ + private static String encodeHeaderFieldParam(String input, Charset charset) { + Assert.notNull(input, "Input String should not be null"); + Assert.notNull(charset, "Charset should not be null"); + if (StandardCharsets.US_ASCII.equals(charset)) { + return input; + } + Assert.isTrue(StandardCharsets.UTF_8.equals(charset) || StandardCharsets.ISO_8859_1.equals(charset), + "Charset should be UTF-8 or ISO-8859-1"); + byte[] source = input.getBytes(charset); + int len = source.length; + StringBuilder sb = new StringBuilder(len << 1); + sb.append(charset.name()); + sb.append("''"); + for (byte b : source) { + if (isRFC5987AttrChar(b)) { + sb.append((char) b); + } + else { + sb.append('%'); + char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16)); + char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16)); + sb.append(hex1); + sb.append(hex2); + } + } + return sb.toString(); + } + + /** + * Decode the given header field param as describe in RFC 5987. + *

Only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported. + * @param input the header field param + * @return the encoded header field param + * @see RFC 5987 + */ + private static String decodeHeaderFieldParam(String input) { + Assert.notNull(input, "Input String should not be null"); + int firstQuoteIndex = input.indexOf("'"); + int secondQuoteIndex = input.indexOf("'", firstQuoteIndex + 1); + // US_ASCII + if (firstQuoteIndex == -1 || secondQuoteIndex == -1) { + return input; + } + Charset charset = Charset.forName(input.substring(0, firstQuoteIndex)); + Assert.isTrue(StandardCharsets.UTF_8.equals(charset) || StandardCharsets.ISO_8859_1.equals(charset), + "Charset should be UTF-8 or ISO-8859-1"); + byte[] value = input.substring(secondQuoteIndex + 1, input.length()).getBytes(charset); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + int index = 0; + while (index < value.length) { + byte b = value[index]; + if (isRFC5987AttrChar(b)) { + bos.write((char) b); + index++; + } + else if (b == '%') { + char[] array = { (char)value[index + 1], (char)value[index + 2]}; + bos.write(Integer.parseInt(String.valueOf(array), 16)); + index+=3; + } + else { + throw new IllegalArgumentException("Invalid header field parameter format (as defined in RFC 5987)"); + } + } + return new String(bos.toByteArray(), charset); + } + + private static boolean isRFC5987AttrChar(byte c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + c == '!' || c == '#' || c == '$' || c == '&' || c == '+' || c == '-' || + c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ContentDisposition that = (ContentDisposition) o; + if (type != null ? !type.equals(that.type) : that.type != null) { + return false; + } + if (name != null ? !name.equals(that.name) : that.name != null) { + return false; + } + if (filename != null ? !filename.equals(that.filename) : that.filename != null) { + return false; + } + if (charset != null ? !charset.equals(that.charset) : that.charset != null) { + return false; + } + return size != null ? size.equals(that.size) : that.size == null; + } + + @Override + public int hashCode() { + int result = type != null ? type.hashCode() : 0; + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (filename != null ? filename.hashCode() : 0); + result = 31 * result + (charset != null ? charset.hashCode() : 0); + result = 31 * result + (size != null ? size.hashCode() : 0); + return result; + } + + /** + * Return the header value for this content disposition as defined in RFC 2183. + * @see #parse(String) + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(this.type); + if (this.name != null) { + builder.append("; name=\""); + builder.append(this.name).append('\"'); + } + if (this.filename != null) { + if(this.charset == null || StandardCharsets.US_ASCII.equals(this.charset)) { + builder.append("; filename=\""); + builder.append(this.filename).append('\"'); + } + else { + builder.append("; filename*="); + builder.append(encodeHeaderFieldParam(this.filename, this.charset)); + } + } + if (this.size != null) { + builder.append("; size="); + builder.append(this.size); + } + return builder.toString(); + } + + + /** + * A mutable builder for {@code ContentDisposition}. + */ + public interface Builder { + + /** + * Set the value of the {@literal name} parameter + */ + Builder name(String name); + + /** + * Set the value of the {@literal filename} parameter + */ + Builder filename(String filename); + + /** + * Set the value of the {@literal filename*} that will be encoded as defined in + * the RFC 5987. Only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported. + */ + Builder filename(String filename, Charset charset); + + /** + * Set the value of the {@literal size} parameter + */ + Builder size(Long size); + + /** + * Build the content disposition + */ + ContentDisposition build(); + + } + + private static class BuilderImpl implements Builder { + + private String type; + + private String name; + + private String filename; + + private Charset charset; + + private Long size; + + public BuilderImpl(String type) { + Assert.hasText(type, "'type' must not be not empty"); + this.type = type; + } + + @Override + public Builder name(String name) { + this.name = name; + return this; + } + + @Override + public Builder filename(String filename) { + this.filename = filename; + return this; + } + + @Override + public Builder filename(String filename, Charset charset) { + this.filename = filename; + this.charset = charset; + return this; + } + + @Override + public Builder size(Long size) { + this.size = size; + return this; + } + + @Override + public ContentDisposition build() { + return new ContentDisposition(this.type, this.name, this.filename, this.charset, this.size); + } + + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 43b77d82694..1c26558e6d7 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -20,7 +20,6 @@ import java.io.Serializable; import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -676,6 +675,8 @@ public class HttpHeaders implements MultiValueMap, Serializable * for {@code form-data}. * @param name the control name * @param filename the filename (may be {@code null}) + * @see #setContentDisposition(ContentDisposition) + * @see #getContentDisposition() */ public void setContentDispositionFormData(String name, String filename) { setContentDispositionFormData(name, filename, null); @@ -689,24 +690,43 @@ public class HttpHeaders implements MultiValueMap, Serializable * @param filename the filename (may be {@code null}) * @param charset the charset used for the filename (may be {@code null}) * @since 4.3.3 - * @see #setContentDispositionFormData(String, String) + * @see #setContentDisposition(ContentDisposition) + * @see #getContentDisposition() * @see RFC 7230 Section 3.2.4 */ public void setContentDispositionFormData(String name, String filename, Charset charset) { Assert.notNull(name, "'name' must not be null"); - StringBuilder builder = new StringBuilder("form-data; name=\""); - builder.append(name).append('\"'); - if (filename != null) { - if(charset == null || StandardCharsets.US_ASCII.equals(charset)) { - builder.append("; filename=\""); - builder.append(filename).append('\"'); - } - else { - builder.append("; filename*="); - builder.append(encodeHeaderFieldParam(filename, charset)); - } + ContentDisposition disposition = ContentDisposition.builder("form-data") + .name(name).filename(filename, charset).build(); + setContentDisposition(disposition); + } + + /** + * Set the (new) value of the {@literal Content-Disposition} header. Supports the + * disposition type and {@literal filename}, {@literal filename*} (encoded according + * to RFC 5987, only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported), + * {@literal name}, {@literal size} parameters. + * @since 5.0 + * @see #getContentDisposition() + */ + public void setContentDisposition(ContentDisposition contentDisposition) { + set(CONTENT_DISPOSITION, contentDisposition.toString()); + } + + /** + * Return the {@literal Content-Disposition} header parsed as a {@link ContentDisposition} + * instance. Supports the disposition type and {@literal filename}, {@literal filename*} + * (encoded according to RFC 5987, only the US-ASCII, UTF-8 and ISO-8859-1 charsets are + * supported), {@literal name}, {@literal size} parameters. + * @since 5.0 + * @see #setContentDisposition(ContentDisposition) + */ + public ContentDisposition getContentDisposition() { + String contentDisposition = getFirst(CONTENT_DISPOSITION); + if (contentDisposition != null) { + return ContentDisposition.parse(contentDisposition); } - set(CONTENT_DISPOSITION, builder.toString()); + return ContentDisposition.empty(); } /** @@ -1352,45 +1372,4 @@ public class HttpHeaders implements MultiValueMap, Serializable return new HttpHeaders(headers, true); } - /** - * Encode the given header field param as describe in RFC 5987. - * @param input the header field param - * @param charset the charset of the header field param string - * @return the encoded header field param - * @see RFC 5987 - */ - static String encodeHeaderFieldParam(String input, Charset charset) { - Assert.notNull(input, "Input String should not be null"); - Assert.notNull(charset, "Charset should not be null"); - if (StandardCharsets.US_ASCII.equals(charset)) { - return input; - } - Assert.isTrue(StandardCharsets.UTF_8.equals(charset) || StandardCharsets.ISO_8859_1.equals(charset), - "Charset should be UTF-8 or ISO-8859-1"); - byte[] source = input.getBytes(charset); - int len = source.length; - StringBuilder sb = new StringBuilder(len << 1); - sb.append(charset.name()); - sb.append("''"); - for (byte b : source) { - if (isRFC5987AttrChar(b)) { - sb.append((char) b); - } - else { - sb.append('%'); - char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16)); - char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16)); - sb.append(hex1); - sb.append(hex2); - } - } - return sb.toString(); - } - - private static boolean isRFC5987AttrChar(byte c) { - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - c == '!' || c == '#' || c == '$' || c == '&' || c == '+' || c == '-' || - c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; - } - } diff --git a/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java b/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java new file mode 100644 index 00000000000..b704f3ce211 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed 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.springframework.http; + +import java.lang.reflect.Method; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +import org.springframework.util.ReflectionUtils; + +/** + * Unit tests for {@link ContentDisposition} + * + * @author Sebastien Deleuze + */ +public class ContentDispositionTests { + + @Test + public void parse() { + ContentDisposition disposition = ContentDisposition + .parse("form-data; name=\"foo\"; filename=\"foo.txt\"; size=123"); + assertEquals(ContentDisposition.builder("form-data").name("foo").filename("foo.txt").size(123L).build(), disposition); + } + + @Test + public void parseType() { + ContentDisposition disposition = ContentDisposition.parse("form-data"); + assertEquals(ContentDisposition.builder("form-data").build(), disposition); + } + + @Test + public void parseUnquotedFilename() { + ContentDisposition disposition = ContentDisposition + .parse("form-data; filename=unquoted"); + assertEquals(ContentDisposition.builder("form-data").filename("unquoted").build(), disposition); + } + + @Test + public void parseEncodedFilename() { + ContentDisposition disposition = ContentDisposition + .parse("form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt"); + assertEquals(ContentDisposition.builder("form-data").name("name") + .filename("中文.txt", StandardCharsets.UTF_8).build(), disposition); + } + + @Test(expected = IllegalArgumentException.class) + public void parseEmpty() { + ContentDisposition.parse(""); + } + + @Test(expected = IllegalArgumentException.class) + public void parseNoType() { + ContentDisposition.parse(";"); + } + + @Test(expected = IllegalArgumentException.class) + public void parseInvalidParameter() { + ContentDisposition.parse("foo;bar"); + } + + @Test + public void headerValue() { + ContentDisposition disposition = ContentDisposition.builder("form-data") + .name("foo").filename("foo.txt").size(123L).build(); + assertEquals("form-data; name=\"foo\"; filename=\"foo.txt\"; size=123", disposition.toString()); + } + + @Test + public void headerValueWithEncodedFilename() { + ContentDisposition disposition = ContentDisposition.builder("form-data") + .name("name").filename("中文.txt", StandardCharsets.UTF_8).build(); + assertEquals("form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt", disposition.toString()); + } + + @Test // SPR-14547 + public void encodeHeaderFieldParam() { + Method encode = ReflectionUtils.findMethod(ContentDisposition.class, + "encodeHeaderFieldParam", String.class, Charset.class); + ReflectionUtils.makeAccessible(encode); + + String result = (String)ReflectionUtils.invokeMethod(encode, null, "test.txt", + StandardCharsets.US_ASCII); + assertEquals("test.txt", result); + + result = (String)ReflectionUtils.invokeMethod(encode, null, "中文.txt", StandardCharsets.UTF_8); + assertEquals("UTF-8''%E4%B8%AD%E6%96%87.txt", result); + } + + @Test(expected = IllegalArgumentException.class) + public void encodeHeaderFieldParamInvalidCharset() { + Method encode = ReflectionUtils.findMethod(ContentDisposition.class, + "encodeHeaderFieldParam", String.class, Charset.class); + ReflectionUtils.makeAccessible(encode); + ReflectionUtils.invokeMethod(encode, null, "test", StandardCharsets.UTF_16); + } + + @Test // SPR-14408 + public void decodeHeaderFieldParam() { + Method decode = ReflectionUtils.findMethod(ContentDisposition.class, + "decodeHeaderFieldParam", String.class); + ReflectionUtils.makeAccessible(decode); + + String result = (String)ReflectionUtils.invokeMethod(decode, null, "test.txt"); + assertEquals("test.txt", result); + + result = (String)ReflectionUtils.invokeMethod(decode, null, "UTF-8''%E4%B8%AD%E6%96%87.txt"); + assertEquals("中文.txt", result); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeHeaderFieldParamInvalidCharset() { + Method decode = ReflectionUtils.findMethod(ContentDisposition.class, + "decodeHeaderFieldParam", String.class); + ReflectionUtils.makeAccessible(decode); + ReflectionUtils.invokeMethod(decode, null, "UTF-16''test"); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java index 8a40d86bd27..87bf12f8e70 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java @@ -331,18 +331,13 @@ public class HttpHeadersTests { @Test public void contentDisposition() { - headers.setContentDispositionFormData("name", null); - assertEquals("Invalid Content-Disposition header", "form-data; name=\"name\"", - headers.getFirst("Content-Disposition")); + ContentDisposition disposition = headers.getContentDisposition(); + assertNotNull(disposition); + assertEquals("Invalid Content-Disposition header", ContentDisposition.empty(), headers.getContentDisposition()); - headers.setContentDispositionFormData("name", "filename"); - assertEquals("Invalid Content-Disposition header", "form-data; name=\"name\"; filename=\"filename\"", - headers.getFirst("Content-Disposition")); - - headers.setContentDispositionFormData("name", "中文.txt", StandardCharsets.UTF_8); - assertEquals("Invalid Content-Disposition header", - "form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt", - headers.getFirst("Content-Disposition")); + disposition = ContentDisposition.builder("attachment").name("foo").filename("foo.txt").size(123L).build(); + headers.setContentDisposition(disposition); + assertEquals("Invalid Content-Disposition header", disposition, headers.getContentDisposition()); } @Test // SPR-11917 @@ -427,18 +422,4 @@ public class HttpHeadersTests { assertEquals(HttpMethod.POST, headers.getAccessControlRequestMethod()); } - @Test // SPR-14547 - public void encodeHeaderFieldParam() { - String result = HttpHeaders.encodeHeaderFieldParam("test.txt", StandardCharsets.US_ASCII); - assertEquals("test.txt", result); - - result = HttpHeaders.encodeHeaderFieldParam("中文.txt", StandardCharsets.UTF_8); - assertEquals("UTF-8''%E4%B8%AD%E6%96%87.txt", result); - } - - @Test(expected = IllegalArgumentException.class) - public void encodeHeaderFieldParamInvalidCharset() { - HttpHeaders.encodeHeaderFieldParam("test", StandardCharsets.UTF_16); - } - }