diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultMultipartMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultMultipartMessageReader.java new file mode 100644 index 0000000000..bb2631987f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultMultipartMessageReader.java @@ -0,0 +1,369 @@ +/* + * Copyright 2002-2019 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 + * + * https://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.codec.multipart; + +import java.io.IOException; +import java.nio.channels.AsynchronousFileChannel; +import java.nio.channels.Channel; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMessage; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.LoggingCodecSupport; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * {@code HttpMessageReader} for parsing {@code "multipart/form-data"} requests + * to a stream of {@link Part}'s. + * + *

This reader can be provided to {@link MultipartHttpMessageReader} in order + * to aggregate all parts into a Map. + * + * @author Arjen Poutsma + * @since 5.2 + * @see MultipartHttpMessageReader + */ +public class DefaultMultipartMessageReader extends LoggingCodecSupport + implements HttpMessageReader { + + private static final byte CR = '\r'; + + private static final byte LF = '\n'; + + private static final byte HYPHEN = '-'; + + private static final byte[] FIRST_BOUNDARY_PREFIX = {HYPHEN, HYPHEN}; + + private static final byte[] BOUNDARY_PREFIX = {CR, LF, HYPHEN, HYPHEN}; + + private static final byte[] HEADER_BODY_SEPARATOR = {CR, LF, CR, LF}; + + private static final String HEADER_SEPARATOR = "\\r\\n"; + + private static final DataBufferUtils.Matcher HEADER_MATCHER = + DataBufferUtils.matcher(HEADER_BODY_SEPARATOR); + + + @Override + public List getReadableMediaTypes() { + return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); + } + + @Override + public boolean canRead(ResolvableType elementType, @Nullable MediaType mediaType) { + return Part.class.equals(elementType.toClass()) && + (mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)); + } + + @Override + public Flux read(ResolvableType elementType, ReactiveHttpInputMessage message, + Map hints) { + + byte[] boundary = boundary(message); + if (boundary == null) { + return Flux.error(new CodecException("No multipart boundary found in Content-Type: \"" + + message.getHeaders().getContentType() + "\"")); + } + + byte[] boundaryNeedle = concat(BOUNDARY_PREFIX, boundary); + Flux body = skipUntilFirstBoundary(message.getBody(), boundary); + + return DataBufferUtils.split(body, boundaryNeedle) + .takeWhile(DefaultMultipartMessageReader::notLastBoundary) + .map(DefaultMultipartMessageReader::toPart) + .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release) + .doOnDiscard(DefaultPart.class, part -> DataBufferUtils.release(part.body)); + } + + @Nullable + private static byte[] boundary(HttpMessage message) { + MediaType contentType = message.getHeaders().getContentType(); + if (contentType != null) { + String boundary = contentType.getParameter("boundary"); + if (boundary != null) { + return boundary.getBytes(StandardCharsets.ISO_8859_1); + } + } + return null; + } + + /** + * Finds the fist occurrence of the boundary in the given stream of data buffers, and skips + * all data until then. Note that the first boundary of a multipart message does not contain + * the initial \r\n, hence the need for a special boundary matcher. + */ + private static Flux skipUntilFirstBoundary(Flux dataBuffers, + byte[] boundary) { + + byte[] needle = concat(FIRST_BOUNDARY_PREFIX, boundary); + DataBufferUtils.Matcher matcher = DataBufferUtils.matcher(needle); + + AtomicBoolean found = new AtomicBoolean(); + + return dataBuffers.concatMap(dataBuffer -> { + if (found.get()) { + return Mono.just(dataBuffer); + } + else { + int endIdx = matcher.match(dataBuffer); + if (endIdx != -1) { + found.set(true); + int length = dataBuffer.writePosition() - 1 - endIdx; + DataBuffer slice = dataBuffer.retainedSlice(endIdx + 1, length); + DataBufferUtils.release(dataBuffer); + return Mono.just(slice); + } + else { + DataBufferUtils.release(dataBuffer); + return Mono.empty(); + } + } + + }); + } + + /** + * Indicates whether the given data buffer is not the last boundary, i.e. it does not start + * with two hyphens. + */ + private static boolean notLastBoundary(DataBuffer dataBuffer) { + if (dataBuffer.readableByteCount() >= 2) { + int readPosition = dataBuffer.readPosition(); + if ((dataBuffer.getByte(readPosition) == HYPHEN) && + (dataBuffer.getByte(readPosition + 1) == HYPHEN)) { + DataBufferUtils.release(dataBuffer); + return false; + } + } + return true; + } + + /** + * Convert the given data buffer into a Part. All data up until the header separator (\r\n\r\n) + * is passed to {@link #toHeaders(DataBuffer)}, the remaining data is considered to be the + * body. + */ + private static Part toPart(DataBuffer dataBuffer) { + int readPosition = dataBuffer.readPosition(); + if (dataBuffer.readableByteCount() >= 2) { + if ( (dataBuffer.getByte(readPosition) == CR) && + (dataBuffer.getByte(readPosition + 1) == LF)) { + dataBuffer.readPosition(readPosition + 2); + } + } + int endIdx = HEADER_MATCHER.match(dataBuffer); + + HttpHeaders headers; + DataBuffer body; + if (endIdx > 0) { + readPosition = dataBuffer.readPosition(); + int headersLength = + endIdx + 1 - (readPosition + HEADER_BODY_SEPARATOR.length); + DataBuffer headersBuffer = dataBuffer.retainedSlice(readPosition, headersLength); + int bodyLength = dataBuffer.writePosition() - (1 + endIdx); + body = dataBuffer.retainedSlice(endIdx + 1, bodyLength); + headers = toHeaders(headersBuffer); + } + else { + headers = new HttpHeaders(); + body = DataBufferUtils.retain(dataBuffer); + } + DataBufferUtils.release(dataBuffer); + + ContentDisposition cd = headers.getContentDisposition(); + MediaType contentType = headers.getContentType(); + if (StringUtils.hasLength(cd.getFilename())) { + return new DefaultFilePart(headers, body); + } + else if (StringUtils.hasLength(cd.getName()) && + (contentType == null || MediaType.TEXT_PLAIN.isCompatibleWith(contentType))) { + return new DefaultFormPart(headers, body); + } + else { + return new DefaultPart(headers, body); + } + } + + /** + * Convert the given data buffer into a {@link HttpHeaders} instance. The given string is read + * as US-ASCII, then split along \r\n line boundaries, each line containing a header name and + * value(s). + */ + private static HttpHeaders toHeaders(DataBuffer dataBuffer) { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + String string = new String(bytes, StandardCharsets.US_ASCII); + String[] lines = string.split(HEADER_SEPARATOR); + HttpHeaders result = new HttpHeaders(); + for (String line : lines) { + int idx = line.indexOf(':'); + if (idx != -1) { + String name = line.substring(0, idx); + String value = line.substring(idx + 1); + while (value.startsWith(" ")) { + value = value.substring(1); + } + String[] tokens = StringUtils.tokenizeToStringArray(value, ","); + for (String token : tokens) { + result.add(name, token); + } + } + } + return result; + } + + @Override + public Mono readMono(ResolvableType elementType, ReactiveHttpInputMessage message, + Map hints) { + return Mono.error(new UnsupportedOperationException( + "Cannot read multipart request body into single Part")); + } + + private static byte[] concat(byte[]... byteArrays) { + int length = 0; + for (byte[] byteArray : byteArrays) { + length += byteArray.length; + } + byte[] result = new byte[length]; + length = 0; + for (byte[] byteArray : byteArrays) { + System.arraycopy(byteArray, 0, result, length, byteArray.length); + length += byteArray.length; + } + return result; + } + + + private static class DefaultPart implements Part { + + private final HttpHeaders headers; + + protected final DataBuffer body; + + + public DefaultPart(HttpHeaders headers, DataBuffer body) { + this.headers = headers; + this.body = body; + } + + @Override + public String name() { + return headers().getContentDisposition().getName(); + } + + @Override + public HttpHeaders headers() { + return this.headers; + } + + @Override + public Flux content() { + return Flux.just(this.body); + } + + } + + + private static class DefaultFormPart extends DefaultPart implements FormFieldPart { + + private String value; + + public DefaultFormPart(HttpHeaders headers, DataBuffer body) { + super(headers, body); + this.value = toString(body, contentTypeCharset(headers)); + } + + private static String toString(DataBuffer dataBuffer, Charset charset) { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + return new String(bytes, charset).trim(); + } + + private static Charset contentTypeCharset(HttpHeaders headers) { + MediaType contentType = headers.getContentType(); + if (contentType != null) { + Charset charset = contentType.getCharset(); + if (charset != null) { + return charset; + } + } + return StandardCharsets.ISO_8859_1; + } + + @Override + public String value() { + return this.value; + } + + } + + + private static class DefaultFilePart extends DefaultPart implements FilePart { + + public DefaultFilePart(HttpHeaders headers, DataBuffer body) { + super(headers, body); + } + + @Override + public String filename() { + return headers().getContentDisposition().getFilename(); + } + + @Override + public Mono transferTo(Path dest) { + return Mono.using(() -> AsynchronousFileChannel.open(dest, StandardOpenOption.WRITE), + this::writeBody, + this::close); + } + + private Mono writeBody(AsynchronousFileChannel channel) { + return DataBufferUtils.write(content(), channel) + .map(DataBufferUtils::release) + .then(); + } + + private void close(Channel channel) { + try { + channel.close(); + } + catch (IOException ignore) { + } + } + } +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultMultipartMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultMultipartMessageReaderTests.java new file mode 100644 index 0000000000..3eb4aa3a91 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultMultipartMessageReaderTests.java @@ -0,0 +1,177 @@ +/* + * Copyright 2002-2019 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 + * + * https://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.codec.multipart; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static org.junit.Assert.*; +import static org.springframework.core.ResolvableType.forClass; + +/** + * @author Arjen Poutsma + */ +public class DefaultMultipartMessageReaderTests extends AbstractDataBufferAllocatingTestCase { + + private static final String LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam."; + + private static final String MUSPI_MEROL = new StringBuilder(LOREM_IPSUM).reverse().toString(); + + private static final int BUFFER_SIZE = 16; + + private final DefaultMultipartMessageReader reader = new DefaultMultipartMessageReader(); + + @Test + public void canRead() { + assertTrue(this.reader.canRead(forClass(Part.class), MediaType.MULTIPART_FORM_DATA)); + } + + @Test + public void partNoHeader() { + MockServerHttpRequest request = createRequest( + new ClassPathResource("part-no-header.multipart", getClass()), "boundary"); + + Flux result = this.reader.read(forClass(Part.class), request, emptyMap()); + + StepVerifier.create(result) + .consumeNextWith(part -> { + assertTrue(part.headers().isEmpty()); + part.content().subscribe(DataBufferUtils::release); + }) + .verifyComplete(); + } + + @Test + public void partNoEndBoundary() { + MockServerHttpRequest request = createRequest( + new ClassPathResource("part-no-end-boundary.multipart", getClass()), "boundary"); + + Flux result = this.reader.read(forClass(Part.class), request, emptyMap()); + + StepVerifier.create(result) + .consumeNextWith(part -> { + part.content().subscribe(DataBufferUtils::release); + }) + .verifyComplete(); + } + + @Test + public void firefox() { + testBrowser(new ClassPathResource("firefox.multipart", getClass()), + "---------------------------18399284482060392383840973206"); + } + + @Test + public void chrome() { + testBrowser(new ClassPathResource("chrome.multipart", getClass()), + "----WebKitFormBoundaryEveBLvRT65n21fwU"); + } + + @Test + public void safari() { + testBrowser(new ClassPathResource("safari.multipart", getClass()), + "----WebKitFormBoundaryG8fJ50opQOML0oGD"); + } + + private void testBrowser(Resource resource, String boundary) { + MockServerHttpRequest request = createRequest(resource, boundary); + + Flux result = this.reader.read(forClass(Part.class), request, emptyMap()); + + StepVerifier.create(result) + .consumeNextWith(part -> testBrowserFormField(part, "text1", "a")) + .consumeNextWith(part -> testBrowserFormField(part, "text2", "b")) + .consumeNextWith(part -> testBrowserFile(part, "file1", "a.txt", LOREM_IPSUM)) + .consumeNextWith(part -> testBrowserFile(part, "file2", "a.txt", LOREM_IPSUM)) + .consumeNextWith(part -> testBrowserFile(part, "file2", "b.txt", MUSPI_MEROL)) + .verifyComplete(); + } + + private MockServerHttpRequest createRequest(Resource resource, String boundary) { + Flux body = DataBufferUtils + .readByteChannel(resource::readableChannel, this.bufferFactory, BUFFER_SIZE); + + MediaType contentType = new MediaType("multipart", "form-data", singletonMap("boundary", boundary)); + return MockServerHttpRequest.post("/") + .contentType(contentType) + .body(body); + } + + private static void testBrowserFormField(Part part, String name, String value) { + assertTrue(part instanceof FormFieldPart); + assertEquals(name, part.name()); + FormFieldPart formField = (FormFieldPart) part; + assertEquals(value, formField.value()); + } + + private static void testBrowserFile(Part part, String name, String filename, String contents) { + try { + assertTrue(part instanceof FilePart); + assertEquals(name, part.name()); + FilePart file = (FilePart) part; + assertEquals(filename, file.filename()); + + Path tempFile = Files.createTempFile("DefaultMultipartMessageReaderTests", null); + + CountDownLatch latch = new CountDownLatch(1); + file.transferTo(tempFile) + .subscribe(null, + throwable -> fail(throwable.getMessage()), + () -> { + try { + verifyContents(tempFile, contents); + } + finally { + latch.countDown(); + } + + }); + + latch.await(); + } + catch (Exception ex) { + throw new AssertionError(ex); + } + } + + private static void verifyContents(Path tempFile, String contents) { + try { + String result = String.join("", Files.readAllLines(tempFile)); + assertEquals(contents, result); + } + catch (IOException ex) { + throw new AssertionError(ex); + } + } + +} \ No newline at end of file diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/.gitattributes b/spring-web/src/test/resources/org/springframework/http/codec/multipart/.gitattributes new file mode 100644 index 0000000000..3a5e514d9b --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/.gitattributes @@ -0,0 +1 @@ +*.multipart -text diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/chrome.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/chrome.multipart new file mode 100644 index 0000000000..1cdc9ed946 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/chrome.multipart @@ -0,0 +1,27 @@ +------WebKitFormBoundaryEveBLvRT65n21fwU +Content-Disposition: form-data; name="text1" + +a +------WebKitFormBoundaryEveBLvRT65n21fwU +Content-Disposition: form-data; name="text2" + +b +------WebKitFormBoundaryEveBLvRT65n21fwU +Content-Disposition: form-data; name="file1"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +------WebKitFormBoundaryEveBLvRT65n21fwU +Content-Disposition: form-data; name="file2"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +------WebKitFormBoundaryEveBLvRT65n21fwU +Content-Disposition: form-data; name="file2"; filename="b.txt" +Content-Type: text/plain + +.mallun mulubitsev di sutem silucai regetnI .tile gnicsipida rutetcesnoc ,tema tis rolod muspi meroL + +------WebKitFormBoundaryEveBLvRT65n21fwU-- diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/firefox.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/firefox.multipart new file mode 100644 index 0000000000..e85062e533 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/firefox.multipart @@ -0,0 +1,27 @@ +-----------------------------18399284482060392383840973206 +Content-Disposition: form-data; name="text1" + +a +-----------------------------18399284482060392383840973206 +Content-Disposition: form-data; name="text2" + +b +-----------------------------18399284482060392383840973206 +Content-Disposition: form-data; name="file1"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +-----------------------------18399284482060392383840973206 +Content-Disposition: form-data; name="file2"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +-----------------------------18399284482060392383840973206 +Content-Disposition: form-data; name="file2"; filename="b.txt" +Content-Type: text/plain + +.mallun mulubitsev di sutem silucai regetnI .tile gnicsipida rutetcesnoc ,tema tis rolod muspi meroL + +-----------------------------18399284482060392383840973206-- diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/part-no-end-boundary.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/part-no-end-boundary.multipart new file mode 100644 index 0000000000..84485cc257 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/part-no-end-boundary.multipart @@ -0,0 +1,5 @@ +--boundary +Header: Value + +a +--boundary \ No newline at end of file diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/part-no-header.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/part-no-header.multipart new file mode 100644 index 0000000000..74fbb28797 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/part-no-header.multipart @@ -0,0 +1,4 @@ +--boundary + +a +--boundary-- \ No newline at end of file diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/safari.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/safari.multipart new file mode 100644 index 0000000000..2a078be751 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/safari.multipart @@ -0,0 +1,27 @@ +------WebKitFormBoundaryG8fJ50opQOML0oGD +Content-Disposition: form-data; name="text1" + +a +------WebKitFormBoundaryG8fJ50opQOML0oGD +Content-Disposition: form-data; name="text2" + +b +------WebKitFormBoundaryG8fJ50opQOML0oGD +Content-Disposition: form-data; name="file1"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +------WebKitFormBoundaryG8fJ50opQOML0oGD +Content-Disposition: form-data; name="file2"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +------WebKitFormBoundaryG8fJ50opQOML0oGD +Content-Disposition: form-data; name="file2"; filename="b.txt" +Content-Type: text/plain + +.mallun mulubitsev di sutem silucai regetnI .tile gnicsipida rutetcesnoc ,tema tis rolod muspi meroL + +------WebKitFormBoundaryG8fJ50opQOML0oGD--