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
This commit is contained in:
Sebastien Deleuze 2016-11-08 00:52:07 +01:00
parent c44c607570
commit 99a8510ace
4 changed files with 558 additions and 80 deletions

View File

@ -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 <a href="https://tools.ietf.org/html/rfc2183">RFC 2183</a>
*/
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 <a href="https://tools.ietf.org/html/rfc5987">RFC 5987</a>
*/
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.
* <p>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 <a href="https://tools.ietf.org/html/rfc5987">RFC 5987</a>
*/
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);
}
}
}

View File

@ -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<String, String>, 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<String, String>, 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 <a href="https://tools.ietf.org/html/rfc7230#section-3.2.4">RFC 7230 Section 3.2.4</a>
*/
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<String, String>, 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 <a href="https://tools.ietf.org/html/rfc5987">RFC 5987</a>
*/
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 == '~';
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}