From 76cf685763a0bc41df73922f86f6f9b649034dc6 Mon Sep 17 00:00:00 2001 From: Lachlan Roberts Date: Tue, 11 Jun 2024 19:01:55 +1000 Subject: [PATCH] Improve support for MultiPart in jetty-core Signed-off-by: Lachlan Roberts --- .../eclipse/jetty/http/MultiPartFormData.java | 33 +- .../eclipse/jetty/server/MultiPartConfig.java | 107 ++++ .../jetty/server/MultiPartFormFields.java | 82 +++ .../ee10/servlet/MultiPartServletTest.java | 1 + .../eclipse/jetty/test/CoreMultiPartTest.java | 543 ++++++++++++++++++ 5 files changed, 755 insertions(+), 11 deletions(-) create mode 100644 jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartConfig.java create mode 100644 jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java create mode 100644 tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java index e1cae2531fa..4c6db85c760 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java @@ -98,8 +98,7 @@ public class MultiPartFormData */ public static CompletableFuture from(Attributes attributes, MultiPartCompliance compliance, ComplianceViolation.Listener listener, String boundary, Function> parse) { - @SuppressWarnings("unchecked") - CompletableFuture futureParts = (CompletableFuture)attributes.getAttribute(MultiPartFormData.class.getName()); + CompletableFuture futureParts = get(attributes); if (futureParts == null) { futureParts = parse.apply(new Parser(boundary, compliance, listener)); @@ -108,6 +107,18 @@ public class MultiPartFormData return futureParts; } + /** + * Returns {@code multipart/form-data} parts if they have already been created. + * + * @param attributes the attributes where the futureParts are tracked + * @return the future parts + */ + @SuppressWarnings("unchecked") + public static CompletableFuture get(Attributes attributes) + { + return (CompletableFuture)attributes.getAttribute(MultiPartFormData.class.getName()); + } + /** *

An ordered list of {@link MultiPart.Part}s that can * be accessed by index or by name, or iterated over.

@@ -221,7 +232,7 @@ public class MultiPartFormData private final MultiPart.Parser parser; private final MultiPartCompliance compliance; private final ComplianceViolation.Listener complianceListener; - private boolean useFilesForPartsWithoutFileName; + private boolean useFilesForPartsWithoutFileName = true; private Path filesDirectory; private long maxFileSize = -1; private long maxMemoryFileSize; @@ -450,17 +461,17 @@ public class MultiPartFormData public void onPartContent(Content.Chunk chunk) { ByteBuffer buffer = chunk.getByteBuffer(); + long maxFileSize = getMaxFileSize(); + fileSize += buffer.remaining(); + if (maxFileSize >= 0 && fileSize > maxFileSize) + { + onFailure(new IllegalStateException("max file size exceeded: %d".formatted(maxFileSize))); + return; + } + String fileName = getFileName(); if (fileName != null || isUseFilesForPartsWithoutFileName()) { - long maxFileSize = getMaxFileSize(); - fileSize += buffer.remaining(); - if (maxFileSize >= 0 && fileSize > maxFileSize) - { - onFailure(new IllegalStateException("max file size exceeded: %d".formatted(maxFileSize))); - return; - } - long maxMemoryFileSize = getMaxMemoryFileSize(); if (maxMemoryFileSize >= 0) { diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartConfig.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartConfig.java new file mode 100644 index 00000000000..9b0ef92d4c1 --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartConfig.java @@ -0,0 +1,107 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server; + +import java.nio.file.Path; + +import org.eclipse.jetty.http.ComplianceViolation; +import org.eclipse.jetty.http.MultiPartCompliance; + +import static org.eclipse.jetty.http.ComplianceViolation.Listener.NOOP; + +public class MultiPartConfig +{ + public static MultiPartConfig from(Request request, Path location, int maxFormKeys, long maxRequestSize, long maxFileSize, long fileSizeThreshold) + { + HttpChannel httpChannel = HttpChannel.from(request); + HttpConfiguration httpConfiguration = request.getConnectionMetaData().getHttpConfiguration(); + MultiPartCompliance multiPartCompliance = httpConfiguration.getMultiPartCompliance(); + ComplianceViolation.Listener complianceViolationListener = httpChannel.getComplianceViolationListener(); + int maxHeaderSize = httpConfiguration.getRequestHeaderSize(); + + if (location == null) + location = request.getContext().getTempDirectory().toPath(); + + return new MultiPartConfig(location, maxFormKeys, maxRequestSize, + maxFileSize, fileSizeThreshold, maxHeaderSize, multiPartCompliance, complianceViolationListener); + } + + private final Path location; + private final long fileSizeThreshold; + private final long maxFileSize; + private final long maxRequestSize; + private final int maxFormKeys; + private final int maxHeadersSize; + private final MultiPartCompliance compliance; + private final ComplianceViolation.Listener listener; + + + public MultiPartConfig(Path location, int maxFormKeys, long maxRequestSize, long maxFileSize, long fileSizeThreshold) + { + this(location, maxFormKeys, maxRequestSize, maxFileSize, fileSizeThreshold, 8 * 1024, MultiPartCompliance.RFC7578, null); + } + + public MultiPartConfig(Path location, int maxFormKeys, long maxRequestSize, long maxFileSize, long fileSizeThreshold, + int maxHeadersSize, MultiPartCompliance compliance, ComplianceViolation.Listener listener) + { + this.location = location; + this.maxFormKeys = maxFormKeys; + this.maxRequestSize = maxRequestSize; + this.maxFileSize = maxFileSize; + this.fileSizeThreshold = fileSizeThreshold; + this.maxHeadersSize = maxHeadersSize; + this.compliance = compliance; + this.listener = listener == null ? NOOP : listener; + } + + public Path getLocation() + { + return location; + } + + public long getFileSizeThreshold() + { + return fileSizeThreshold; + } + + public long getMaxFileSize() + { + return maxFileSize; + } + + public long getMaxRequestSize() + { + return maxRequestSize; + } + + public int getMaxFormKeys() + { + return maxFormKeys; + } + + public int getMaxHeadersSize() + { + return maxHeadersSize; + } + + public MultiPartCompliance getMultiPartCompliance() + { + return compliance; + } + + public ComplianceViolation.Listener getViolationListener() + { + return listener; + } +} diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java new file mode 100644 index 00000000000..8f57203e06c --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java @@ -0,0 +1,82 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server; + +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.util.Attributes; + +public class MultiPartFormFields +{ + public static CompletableFuture from(Request request, MultiPartConfig config) + { + return from(request, request, config); + } + + public static CompletableFuture from(Request request, Content.Source source, MultiPartConfig config) + { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + + return from(source, request, contentType, config); + } + + public static CompletableFuture from(Content.Source content, Attributes attributes, String contentType, MultiPartConfig config) + { + // Look for an existing future (we use the future here rather than the parts as it can remember any failure). + CompletableFuture futureParts = MultiPartFormData.get(attributes); + if (futureParts == null) + { + // No existing parts, so we need to try to read them ourselves + + // Are we the right content type to produce our own parts? + if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.getValueParameters(contentType, null))) + return CompletableFuture.failedFuture(new IllegalStateException("Not multipart Content-Type")); + + // Do we have a boundary? + String boundary = MultiPart.extractBoundary(contentType); + if (boundary == null) + return CompletableFuture.failedFuture(new IllegalStateException("No multipart boundary parameter in Content-Type")); + + // Look for an existing future MultiPartFormData.Parts + futureParts = MultiPartFormData.from(attributes, config.getMultiPartCompliance(), config.getViolationListener(), boundary, parser -> + { + try + { + // No existing core parts, so we need to configure the parser. + parser.setMaxParts(config.getMaxFormKeys()); + parser.setMaxMemoryFileSize(config.getFileSizeThreshold()); + parser.setMaxFileSize(config.getMaxFileSize()); + parser.setMaxLength(config.getMaxRequestSize()); + parser.setPartHeadersMaxLength(config.getMaxHeadersSize()); + if (config.getLocation() != null) + parser.setFilesDirectory(config.getLocation()); + + // parse the core parts. + return parser.parse(content); + } + catch (Throwable failure) + { + return CompletableFuture.failedFuture(failure); + } + }); + } + return futureParts; + } +} diff --git a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java index c47a171e89a..0e712e87ed2 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java @@ -472,6 +472,7 @@ public class MultiPartServletTest InputStream inputStream = new GZIPInputStream(responseStream.getInputStream()); MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary); formData.setMaxParts(1); + formData.setMaxMemoryFileSize(-1); MultiPartFormData.Parts parts = formData.parse(new InputStreamContentSource(inputStream)).join(); assertThat(parts.size(), is(1)); diff --git a/tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java b/tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java new file mode 100644 index 00000000000..40ad8b763ce --- /dev/null +++ b/tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java @@ -0,0 +1,543 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.zip.GZIPInputStream; + +import org.eclipse.jetty.client.BytesRequestContent; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.InputStreamResponseListener; +import org.eclipse.jetty.client.MultiPartRequestContent; +import org.eclipse.jetty.client.OutputStreamRequestContent; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.EofException; +import org.eclipse.jetty.io.content.InputStreamContentSource; +import org.eclipse.jetty.logging.StacklessLogging; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpChannel; +import org.eclipse.jetty.server.MultiPartConfig; +import org.eclipse.jetty.server.MultiPartFormFields; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.gzip.GzipHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +public class CoreMultiPartTest +{ + private static final int MAX_FILE_SIZE = 512 * 1024; + + private Server server; + private ServerConnector connector; + private HttpClient client; + private Path tmpDir; + private MultiPartConfig config; + + @BeforeEach + public void before() throws Exception + { + tmpDir = Files.createTempDirectory(CoreMultiPartTest.class.getSimpleName()); + } + + private void start(Handler handler, MultiPartConfig config) throws Exception + { + this.config = config == null ? new MultiPartConfig(tmpDir, -1, -1, MAX_FILE_SIZE, 0) : config; + server = new Server(null, null, null); + connector = new ServerConnector(server); + server.addConnector(connector); + server.setHandler(handler); + + GzipHandler gzipHandler = new GzipHandler(); + gzipHandler.addIncludedMimeTypes("multipart/form-data"); + gzipHandler.setMinGzipSize(32); + server.insertHandler(gzipHandler); + + server.start(); + client = new HttpClient(); + client.start(); + } + + @AfterEach + public void stop() throws Exception + { + LifeCycle.stop(client); + LifeCycle.stop(server); + IO.delete(tmpDir.toFile()); + } + + private MultiPartFormData.Parts getParts(Request request, MultiPartConfig config) + { + try + { + return MultiPartFormFields.from(request, config).get(); + } + catch (Throwable t) + { + throw new BadMessageException("bad multipart", t.getCause()); + } + } + + @Test + public void testLargePart() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, new MultiPartConfig(null, -1, -1, 1024 * 1024, -1)); + + OutputStreamRequestContent content = new OutputStreamRequestContent(); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("param", null, null, content)); + multiPart.close(); + + InputStreamResponseListener listener = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/defaultConfig") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(listener); + + // The write side will eventually throw because connection is closed. + assertThrows(Throwable.class, () -> + { + // Write large amount of content to the part. + byte[] byteArray = new byte[1024 * 1024]; + Arrays.fill(byteArray, (byte)1); + for (int i = 0; i < 1024 * 2; i++) + { + content.getOutputStream().write(byteArray); + } + content.close(); + }); + + assert400orEof(listener, responseContent -> assertThat(responseContent, containsString("400"))); + } + + @Test + public void testManyParts() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, new MultiPartConfig(null, 1024, -1, -1, -1)); + + byte[] byteArray = new byte[1024]; + Arrays.fill(byteArray, (byte)1); + + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + for (int i = 0; i < 1024 * 1024; i++) + { + BytesRequestContent content = new BytesRequestContent(byteArray); + multiPart.addPart(new MultiPart.ContentSourcePart("part" + i, null, null, content)); + } + multiPart.close(); + + InputStreamResponseListener listener = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/defaultConfig") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(listener); + + assert400orEof(listener, responseContent -> assertThat(responseContent, containsString("400"))); + } + + @Test + public void testMaxRequestSize() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, new MultiPartConfig(tmpDir, -1, 1024, -1, 1024 * 1024 * 8)); + + OutputStreamRequestContent content = new OutputStreamRequestContent(); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("param", null, null, content)); + multiPart.close(); + + InputStreamResponseListener listener = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/requestSizeLimit") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(listener); + + Throwable writeError = null; + try + { + // Write large amount of content to the part. + byte[] byteArray = new byte[1024 * 1024]; + Arrays.fill(byteArray, (byte)1); + for (int i = 0; i < 1024 * 1024; i++) + { + content.getOutputStream().write(byteArray); + } + fail("We should never be able to write all the content."); + } + catch (Exception e) + { + writeError = e; + } + + assertThat(writeError, instanceOf(EofException.class)); + + assert400orEof(listener, null); + } + + private static void assert400orEof(InputStreamResponseListener listener, Consumer checkbody) throws InterruptedException, TimeoutException + { + // There is a race here, either we fail trying to write some more content OR + // we get 400 response, for some reason reading the content throws EofException. + String responseContent = null; + try + { + org.eclipse.jetty.client.Response response = listener.get(60, TimeUnit.SECONDS); + assertThat(response.getStatus(), equalTo(HttpStatus.BAD_REQUEST_400)); + responseContent = IO.toString(listener.getInputStream()); + } + catch (ExecutionException | IOException e) + { + Throwable cause = e.getCause(); + assertThat(cause, instanceOf(EofException.class)); + return; + } + + if (checkbody != null) + checkbody.accept(responseContent); + } + + @Test + public void testSimpleMultiPart() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + MultiPartFormData.Parts parts = getParts(request, config); + assertNotNull(parts); + assertEquals(1, parts.size()); + MultiPart.Part part = parts.iterator().next(); + assertEquals("part1", part.getName()); + HttpFields fields = part.getHeaders(); + assertNotNull(fields); + assertEquals(2, fields.size()); + InputStream inputStream = Content.Source.asInputStream(part.getContentSource()); + String content1 = IO.toString(inputStream, UTF_8); + assertEquals("content1", content1); + + callback.succeeded(); + return true; + } + }, null); + + try (Socket socket = new Socket("localhost", connector.getLocalPort())) + { + OutputStream output = socket.getOutputStream(); + + String content = """ + --A1B2C3 + Content-Disposition: form-data; name="part1" + Content-Type: text/plain; charset="UTF-8" + + content1 + --A1B2C3-- + """; + String header = """ + POST / HTTP/1.1 + Host: localhost + Content-Type: multipart/form-data; boundary="A1B2C3" + Content-Length: $L + + """.replace("$L", String.valueOf(content.length())); + + output.write(header.getBytes(UTF_8)); + output.write(content.getBytes(UTF_8)); + output.flush(); + + HttpTester.Response response = HttpTester.parseResponse(socket.getInputStream()); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } + + @Test + public void testTempFilesDeletedOnError() throws Exception + { + byte[] bytes = new byte[2 * MAX_FILE_SIZE]; + Arrays.fill(bytes, (byte)1); + + // Should throw as the max file size is exceeded. + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, null); + + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("largePart", "largeFile.bin", HttpFields.EMPTY, new BytesRequestContent(bytes))); + multiPart.close(); + + try (StacklessLogging ignored = new StacklessLogging(HttpChannel.class)) + { + ContentResponse response = client.newRequest("localhost", connector.getLocalPort()) + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(); + + assertEquals(400, response.getStatus()); + } + + String[] fileList = tmpDir.toFile().list(); + assertNotNull(fileList); + assertThat(fileList.length, is(0)); + } + + @Test + public void testDefaultTempDirectory() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + MultiPartConfig conf = MultiPartConfig.from(request, config.getLocation(), + config.getMaxFormKeys(), config.getMaxRequestSize(), + config.getMaxFileSize(), config.getFileSizeThreshold()); + MultiPartFormData.Parts parts = getParts(request, conf); + assertNotNull(parts); + assertEquals(1, parts.size()); + MultiPart.Part part = parts.iterator().next(); + assertEquals("part1", part.getName()); + HttpFields headers = part.getHeaders(); + assertNotNull(headers); + assertEquals(2, headers.size()); + InputStream inputStream = Content.Source.asInputStream(part.getContentSource()); + String content1 = IO.toString(inputStream, UTF_8); + assertEquals("content1", content1); + + callback.succeeded(); + return true; + } + }, new MultiPartConfig(null, -1, MAX_FILE_SIZE, -1, 0)); + + try (Socket socket = new Socket("localhost", connector.getLocalPort())) + { + OutputStream output = socket.getOutputStream(); + + String content = """ + --A1B2C3 + Content-Disposition: form-data; name="part1" + Content-Type: text/plain; charset="UTF-8" + + content1 + --A1B2C3-- + """; + String header = """ + POST / HTTP/1.1 + Host: localhost + Content-Type: multipart/form-data; boundary="A1B2C3" + Content-Length: $L + + """.replace("$L", String.valueOf(content.length())); + + output.write(header.getBytes(UTF_8)); + output.write(content.getBytes(UTF_8)); + output.flush(); + + HttpTester.Response response = HttpTester.parseResponse(socket.getInputStream()); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } + + @Test + public void testMultiPartGzip() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, contentType); + + MultiPartRequestContent echoParts = new MultiPartRequestContent(MultiPart.extractBoundary(contentType)); + MultiPartFormData.Parts servletParts = getParts(request, config); + for (MultiPart.Part part : servletParts) + { + HttpFields.Mutable partHeaders = HttpFields.build(); + for (HttpField field : part.getHeaders()) + partHeaders.add(field); + + echoParts.addPart(new MultiPart.ContentSourcePart(part.getName(), part.getFileName(), partHeaders, part.getContentSource())); + } + echoParts.close(); + IO.copy(Content.Source.asInputStream(echoParts), Content.Sink.asOutputStream(response)); + + callback.succeeded(); + return true; + } + }, null); + + // Do not automatically handle gzip. + client.getContentDecoderFactories().clear(); + + String contentString = "the quick brown fox jumps over the lazy dog, " + + "the quick brown fox jumps over the lazy dog"; + StringRequestContent content = new StringRequestContent(contentString); + + MultiPartRequestContent multiPartContent = new MultiPartRequestContent(); + multiPartContent.addPart(new MultiPart.ContentSourcePart("stringPart", null, HttpFields.EMPTY, content)); + multiPartContent.close(); + + InputStreamResponseListener responseStream = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/echo") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .headers(h -> h.add(HttpHeader.ACCEPT_ENCODING, "gzip")) + .body(multiPartContent) + .send(responseStream); + + org.eclipse.jetty.client.Response response = responseStream.get(5, TimeUnit.SECONDS); + HttpFields headers = response.getHeaders(); + assertThat(headers.get(HttpHeader.CONTENT_TYPE), startsWith("multipart/form-data")); + assertThat(headers.get(HttpHeader.CONTENT_ENCODING), is("gzip")); + + String contentType = headers.get(HttpHeader.CONTENT_TYPE); + String boundary = MultiPart.extractBoundary(contentType); + InputStream inputStream = new GZIPInputStream(responseStream.getInputStream()); + MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary); + formData.setMaxParts(1); + formData.setMaxMemoryFileSize(-1); + MultiPartFormData.Parts parts = formData.parse(new InputStreamContentSource(inputStream)).join(); + + assertThat(parts.size(), is(1)); + assertThat(parts.get(0).getContentAsString(UTF_8), is(contentString)); + } + + @Test + public void testDoubleReadFromPart() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain"); + PrintWriter writer = new PrintWriter(Content.Sink.asOutputStream(response)); + for (MultiPart.Part part : getParts(request, config)) + { + String partContent = IO.toString(Content.Source.asInputStream(part.getContentSource())); + writer.println("Part: name=" + part.getName() + ", size=" + part.getLength() + ", content=" + partContent); + + // We can only consume the getContentSource() once so we must use newContentSource(). + partContent = IO.toString(Content.Source.asInputStream(part.newContentSource())); + writer.println("Part: name=" + part.getName() + ", size=" + part.getLength() + ", content=" + partContent); + } + + writer.close(); + callback.succeeded(); + return true; + } + }, null); + + String contentString = "the quick brown fox jumps over the lazy dog, " + + "the quick brown fox jumps over the lazy dog"; + StringRequestContent content = new StringRequestContent(contentString); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("myPart", null, HttpFields.EMPTY, content)); + multiPart.close(); + + ContentResponse response = client.newRequest("localhost", connector.getLocalPort()) + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(); + + assertEquals(200, response.getStatus()); + assertThat(response.getContentAsString(), containsString("Part: name=myPart, size=88, content=the quick brown fox jumps over the lazy dog, the quick brown fox jumps over the lazy dog\n" + + "Part: name=myPart, size=88, content=the quick brown fox jumps over the lazy dog, the quick brown fox jumps over the lazy dog")); + } +}