Fully async Multipart Form handling (#9975)

A fully async ContentSourceCompletableFuture for use by MultiPartFormData and MultiPartByteRanges
Restructure MultiPartFormData to have a Parser class
---------

Signed-off-by: Simone Bordet <simone.bordet@gmail.com>
Co-authored-by: Simone Bordet <simone.bordet@gmail.com>
This commit is contained in:
Greg Wilkins 2023-06-30 17:01:16 +02:00 committed by GitHub
parent dd44b30c3e
commit ec2dbe73a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1515 additions and 1227 deletions

View File

@ -13,11 +13,18 @@
package org.eclipse.jetty.docs.programming; package org.eclipse.jetty.docs.programming;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.content.AsyncContent; import org.eclipse.jetty.io.content.AsyncContent;
import org.eclipse.jetty.io.content.ContentSourceCompletableFuture;
import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.CharsetStringBuilder;
import org.eclipse.jetty.util.FutureCallback; import org.eclipse.jetty.util.FutureCallback;
import org.eclipse.jetty.util.Utf8StringBuilder;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -146,8 +153,92 @@ public class ContentDocs
throw new IllegalStateException("EOF expected"); throw new IllegalStateException("EOF expected");
} }
public static class FutureString extends CompletableFuture<String>
{
private final CharsetStringBuilder text;
private final Content.Source source;
public FutureString(Content.Source source, Charset charset)
{
this.source = source;
this.text = CharsetStringBuilder.forCharset(charset);
source.demand(this::onContentAvailable);
}
private void onContentAvailable()
{
while (true)
{
Content.Chunk chunk = source.read();
if (chunk == null)
{
source.demand(this::onContentAvailable);
return;
}
try
{
if (Content.Chunk.isFailure(chunk))
throw chunk.getFailure();
if (chunk.hasRemaining())
text.append(chunk.getByteBuffer());
if (chunk.isLast() && complete(text.build()))
return;
}
catch (Throwable e)
{
completeExceptionally(e);
}
finally
{
chunk.release();
}
}
}
}
public static void testFutureString() throws Exception
{
AsyncContent source = new AsyncContent();
FutureString future = new FutureString(source, StandardCharsets.UTF_8);
if (future.isDone())
throw new IllegalStateException();
Callback.Completable writeCallback = new Callback.Completable();
Content.Sink.write(source, false, "One", writeCallback);
if (!writeCallback.isDone() || future.isDone())
throw new IllegalStateException("Should be consumed");
Content.Sink.write(source, false, "Two", writeCallback);
if (!writeCallback.isDone() || future.isDone())
throw new IllegalStateException("Should be consumed");
Content.Sink.write(source, true, "Three", writeCallback);
if (!writeCallback.isDone() || !future.isDone())
throw new IllegalStateException("Should be consumed");
}
public static class FutureUtf8String extends ContentSourceCompletableFuture<String>
{
private final Utf8StringBuilder builder = new Utf8StringBuilder();
public FutureUtf8String(Content.Source content)
{
super(content);
}
@Override
protected String parse(Content.Chunk chunk) throws Throwable
{
if (chunk.hasRemaining())
builder.append(chunk.getByteBuffer());
return chunk.isLast() ? builder.takeCompleteString(IllegalStateException::new) : null;
}
}
public static void main(String... args) throws Exception public static void main(String... args) throws Exception
{ {
testEcho(); testEcho();
testFutureString();
} }
} }

View File

@ -439,12 +439,12 @@ public class MultiPartRequestContentTest extends AbstractHttpClientServerTest
String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE);
assertEquals("multipart/form-data", HttpField.valueParameters(contentType, null)); assertEquals("multipart/form-data", HttpField.valueParameters(contentType, null));
String boundary = MultiPart.extractBoundary(contentType); String boundary = MultiPart.extractBoundary(contentType);
MultiPartFormData formData = new MultiPartFormData(boundary); MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary);
formData.setFilesDirectory(tmpDir); formData.setFilesDirectory(tmpDir);
formData.parse(request);
try try
{ {
process(formData.join()); // May block waiting for multipart form data. process(formData.parse(request).join()); // May block waiting for multipart form data.
response.write(true, BufferUtil.EMPTY_BUFFER, callback); response.write(true, BufferUtil.EMPTY_BUFFER, callback);
} }
catch (Exception x) catch (Exception x)

View File

@ -23,12 +23,12 @@ import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.content.ContentSourceCompletableFuture;
import org.eclipse.jetty.util.thread.AutoLock; import org.eclipse.jetty.util.thread.AutoLock;
/** /**
* <p>A {@link CompletableFuture} that is completed when a multipart/byteranges * <p>A {@link CompletableFuture} that is completed when a multipart/byteranges
* content has been parsed asynchronously from a {@link Content.Source} via * has been parsed asynchronously from a {@link Content.Source}.</p>
* {@link #parse(Content.Source)}.</p>
* <p>Once the parsing of the multipart/byteranges content completes successfully, * <p>Once the parsing of the multipart/byteranges content completes successfully,
* objects of this class are completed with a {@link MultiPartByteRanges.Parts} * objects of this class are completed with a {@link MultiPartByteRanges.Parts}
* object.</p> * object.</p>
@ -52,75 +52,10 @@ import org.eclipse.jetty.util.thread.AutoLock;
* *
* @see Parts * @see Parts
*/ */
public class MultiPartByteRanges extends CompletableFuture<MultiPartByteRanges.Parts> public class MultiPartByteRanges
{ {
private final PartsListener listener = new PartsListener(); private MultiPartByteRanges()
private final MultiPart.Parser parser;
public MultiPartByteRanges(String boundary)
{ {
this.parser = new MultiPart.Parser(boundary, listener);
}
/**
* @return the boundary string
*/
public String getBoundary()
{
return parser.getBoundary();
}
@Override
public boolean completeExceptionally(Throwable failure)
{
listener.fail(failure);
return super.completeExceptionally(failure);
}
/**
* <p>Parses the given multipart/byteranges content.</p>
* <p>Returns this {@code MultiPartByteRanges} object,
* so that it can be used in the typical "fluent" style
* of {@link CompletableFuture}.</p>
*
* @param content the multipart/byteranges content to parse
* @return this {@code MultiPartByteRanges} object
*/
public MultiPartByteRanges parse(Content.Source content)
{
new Runnable()
{
@Override
public void run()
{
while (true)
{
Content.Chunk chunk = content.read();
if (chunk == null)
{
content.demand(this);
return;
}
if (Content.Chunk.isFailure(chunk))
{
listener.onFailure(chunk.getFailure());
return;
}
parse(chunk);
chunk.release();
if (chunk.isLast() || isDone())
return;
}
}
}.run();
return this;
}
private void parse(Content.Chunk chunk)
{
if (listener.isFailed())
return;
parser.parse(chunk);
} }
/** /**
@ -267,76 +202,123 @@ public class MultiPartByteRanges extends CompletableFuture<MultiPartByteRanges.P
} }
} }
private class PartsListener extends MultiPart.AbstractPartsListener public static class Parser
{ {
private final AutoLock lock = new AutoLock(); private final PartsListener listener = new PartsListener();
private final List<Content.Chunk> partChunks = new ArrayList<>(); private final MultiPart.Parser parser;
private final List<MultiPart.Part> parts = new ArrayList<>(); private Parts parts;
private Throwable failure;
private boolean isFailed() public Parser(String boundary)
{ {
try (AutoLock ignored = lock.lock()) parser = new MultiPart.Parser(boundary, listener);
{
return failure != null;
}
} }
@Override public CompletableFuture<MultiPartByteRanges.Parts> parse(Content.Source content)
public void onPartContent(Content.Chunk chunk)
{ {
try (AutoLock ignored = lock.lock()) ContentSourceCompletableFuture<MultiPartByteRanges.Parts> futureParts = new ContentSourceCompletableFuture<>(content)
{ {
// Retain the chunk because it is stored for later use. @Override
chunk.retain(); protected MultiPartByteRanges.Parts parse(Content.Chunk chunk) throws Throwable
partChunks.add(chunk); {
} if (listener.isFailed())
throw listener.failure;
parser.parse(chunk);
if (listener.isFailed())
throw listener.failure;
return parts;
}
@Override
public boolean completeExceptionally(Throwable failure)
{
boolean failed = super.completeExceptionally(failure);
if (failed)
listener.fail(failure);
return failed;
}
};
futureParts.parse();
return futureParts;
} }
@Override /**
public void onPart(String name, String fileName, HttpFields headers) * @return the boundary string
*/
public String getBoundary()
{ {
try (AutoLock ignored = lock.lock()) return parser.getBoundary();
{
parts.add(new MultiPart.ChunksPart(name, fileName, headers, List.copyOf(partChunks)));
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
}
} }
@Override private class PartsListener extends MultiPart.AbstractPartsListener
public void onComplete()
{ {
super.onComplete(); private final AutoLock lock = new AutoLock();
List<MultiPart.Part> copy; private final List<Content.Chunk> partChunks = new ArrayList<>();
try (AutoLock ignored = lock.lock()) private final List<MultiPart.Part> parts = new ArrayList<>();
{ private Throwable failure;
copy = List.copyOf(parts);
}
complete(new Parts(getBoundary(), copy));
}
@Override private boolean isFailed()
public void onFailure(Throwable failure)
{
super.onFailure(failure);
completeExceptionally(failure);
}
private void fail(Throwable cause)
{
List<MultiPart.Part> partsToFail;
try (AutoLock ignored = lock.lock())
{ {
if (failure != null) try (AutoLock ignored = lock.lock())
return; {
failure = cause; return failure != null;
partsToFail = List.copyOf(parts); }
parts.clear(); }
partChunks.forEach(Content.Chunk::release);
partChunks.clear(); @Override
public void onPartContent(Content.Chunk chunk)
{
try (AutoLock ignored = lock.lock())
{
// Retain the chunk because it is stored for later use.
chunk.retain();
partChunks.add(chunk);
}
}
@Override
public void onPart(String name, String fileName, HttpFields headers)
{
try (AutoLock ignored = lock.lock())
{
parts.add(new MultiPart.ChunksPart(name, fileName, headers, List.copyOf(partChunks)));
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
}
}
@Override
public void onComplete()
{
super.onComplete();
List<MultiPart.Part> copy;
try (AutoLock ignored = lock.lock())
{
copy = List.copyOf(parts);
Parser.this.parts = new Parts(getBoundary(), copy);
}
}
@Override
public void onFailure(Throwable failure)
{
fail(failure);
}
private void fail(Throwable cause)
{
List<MultiPart.Part> partsToFail;
try (AutoLock ignored = lock.lock())
{
if (failure != null)
return;
failure = cause;
partsToFail = List.copyOf(parts);
parts.clear();
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
}
partsToFail.forEach(p -> p.fail(cause));
} }
partsToFail.forEach(p -> p.fail(cause));
} }
} }
} }

View File

@ -26,8 +26,11 @@ import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.content.ContentSourceCompletableFuture;
import org.eclipse.jetty.util.Attributes;
import org.eclipse.jetty.util.IO; import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.thread.AutoLock; import org.eclipse.jetty.util.thread.AutoLock;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -37,8 +40,7 @@ import static java.nio.charset.StandardCharsets.US_ASCII;
/** /**
* <p>A {@link CompletableFuture} that is completed when a multipart/form-data content * <p>A {@link CompletableFuture} that is completed when a multipart/form-data content
* has been parsed asynchronously from a {@link Content.Source} via {@link #parse(Content.Source)} * has been parsed asynchronously from a {@link Content.Source}.</p>
* or from one or more {@link Content.Chunk}s via {@link #parse(Content.Chunk)}.</p>
* <p>Once the parsing of the multipart/form-data content completes successfully, * <p>Once the parsing of the multipart/form-data content completes successfully,
* objects of this class are completed with a {@link Parts} object.</p> * objects of this class are completed with a {@link Parts} object.</p>
* <p>Objects of this class may be configured to save multipart files in a configurable * <p>Objects of this class may be configured to save multipart files in a configurable
@ -67,241 +69,31 @@ import static java.nio.charset.StandardCharsets.US_ASCII;
* *
* @see Parts * @see Parts
*/ */
public class MultiPartFormData extends CompletableFuture<MultiPartFormData.Parts> public class MultiPartFormData
{ {
private static final Logger LOG = LoggerFactory.getLogger(MultiPartFormData.class); private static final Logger LOG = LoggerFactory.getLogger(MultiPartFormData.class);
private final PartsListener listener = new PartsListener(); private MultiPartFormData()
private final MultiPart.Parser parser;
private boolean useFilesForPartsWithoutFileName;
private Path filesDirectory;
private long maxFileSize = -1;
private long maxMemoryFileSize;
private long maxLength = -1;
private long length;
public MultiPartFormData(String boundary)
{ {
parser = new MultiPart.Parser(Objects.requireNonNull(boundary), listener);
} }
/** public static CompletableFuture<Parts> from(Attributes attributes, String boundary, Function<Parser, CompletableFuture<Parts>> parse)
* @return the boundary string
*/
public String getBoundary()
{ {
return parser.getBoundary(); @SuppressWarnings("unchecked")
} CompletableFuture<Parts> futureParts = (CompletableFuture<Parts>)attributes.getAttribute(MultiPartFormData.class.getName());
if (futureParts == null)
/**
* <p>Parses the given multipart/form-data content.</p>
* <p>Returns this {@code MultiPartFormData} object,
* so that it can be used in the typical "fluent"
* style of {@link CompletableFuture}.</p>
*
* @param content the multipart/form-data content to parse
* @return this {@code MultiPartFormData} object
*/
public MultiPartFormData parse(Content.Source content)
{
new Runnable()
{ {
@Override futureParts = parse.apply(new Parser(boundary));
public void run() attributes.setAttribute(MultiPartFormData.class.getName(), futureParts);
{ }
while (true) return futureParts;
{
Content.Chunk chunk = content.read();
if (chunk == null)
{
content.demand(this);
return;
}
if (Content.Chunk.isFailure(chunk))
{
listener.onFailure(chunk.getFailure());
return;
}
parse(chunk);
chunk.release();
if (chunk.isLast() || isDone())
return;
}
}
}.run();
return this;
}
/**
* <p>Parses the given chunk containing multipart/form-data bytes.</p>
* <p>One or more chunks may be passed to this method, until the parsing
* of the multipart/form-data content completes.</p>
*
* @param chunk the {@link Content.Chunk} to parse.
*/
public void parse(Content.Chunk chunk)
{
if (listener.isFailed())
return;
length += chunk.getByteBuffer().remaining();
long max = getMaxLength();
if (max > 0 && length > max)
listener.onFailure(new IllegalStateException("max length exceeded: %d".formatted(max)));
else
parser.parse(chunk);
}
/**
* <p>Returns the default charset as specified by
* <a href="https://datatracker.ietf.org/doc/html/rfc7578#section-4.6">RFC 7578, section 4.6</a>,
* that is the charset specified by the part named {@code _charset_}.</p>
* <p>If that part is not present, returns {@code null}.</p>
*
* @return the default charset specified by the {@code _charset_} part,
* or null if that part is not present
*/
public Charset getDefaultCharset()
{
return listener.getDefaultCharset();
}
/**
* @return the max length of a {@link MultiPart.Part} headers, in bytes, or -1 for unlimited length
*/
public int getPartHeadersMaxLength()
{
return parser.getPartHeadersMaxLength();
}
/**
* @param partHeadersMaxLength the max length of a {@link MultiPart.Part} headers, in bytes, or -1 for unlimited length
*/
public void setPartHeadersMaxLength(int partHeadersMaxLength)
{
parser.setPartHeadersMaxLength(partHeadersMaxLength);
}
/**
* @return whether parts without fileName may be stored as files
*/
public boolean isUseFilesForPartsWithoutFileName()
{
return useFilesForPartsWithoutFileName;
}
/**
* @param useFilesForPartsWithoutFileName whether parts without fileName may be stored as files
*/
public void setUseFilesForPartsWithoutFileName(boolean useFilesForPartsWithoutFileName)
{
this.useFilesForPartsWithoutFileName = useFilesForPartsWithoutFileName;
}
/**
* @return the directory where files are saved
*/
public Path getFilesDirectory()
{
return filesDirectory;
}
/**
* <p>Sets the directory where the files uploaded in the parts will be saved.</p>
*
* @param filesDirectory the directory where files are saved
*/
public void setFilesDirectory(Path filesDirectory)
{
this.filesDirectory = filesDirectory;
}
/**
* @return the maximum file size in bytes, or -1 for unlimited file size
*/
public long getMaxFileSize()
{
return maxFileSize;
}
/**
* @param maxFileSize the maximum file size in bytes, or -1 for unlimited file size
*/
public void setMaxFileSize(long maxFileSize)
{
this.maxFileSize = maxFileSize;
}
/**
* @return the maximum memory file size in bytes, or -1 for unlimited memory file size
*/
public long getMaxMemoryFileSize()
{
return maxMemoryFileSize;
}
/**
* <p>Sets the maximum memory file size in bytes, after which files will be saved
* in the directory specified by {@link #setFilesDirectory(Path)}.</p>
* <p>Use value {@code 0} to always save the files in the directory.</p>
* <p>Use value {@code -1} to never save the files in the directory.</p>
*
* @param maxMemoryFileSize the maximum memory file size in bytes, or -1 for unlimited memory file size
*/
public void setMaxMemoryFileSize(long maxMemoryFileSize)
{
this.maxMemoryFileSize = maxMemoryFileSize;
}
/**
* @return the maximum length in bytes of the whole multipart content, or -1 for unlimited length
*/
public long getMaxLength()
{
return maxLength;
}
/**
* @param maxLength the maximum length in bytes of the whole multipart content, or -1 for unlimited length
*/
public void setMaxLength(long maxLength)
{
this.maxLength = maxLength;
}
/**
* @return the maximum number of parts that can be parsed from the multipart content.
*/
public long getMaxParts()
{
return parser.getMaxParts();
}
/**
* @param maxParts the maximum number of parts that can be parsed from the multipart content.
*/
public void setMaxParts(long maxParts)
{
parser.setMaxParts(maxParts);
}
@Override
public boolean completeExceptionally(Throwable failure)
{
listener.fail(failure);
return super.completeExceptionally(failure);
}
// Only used for testing.
int getPartsSize()
{
return listener.getPartsSize();
} }
/** /**
* <p>An ordered list of {@link MultiPart.Part}s that can * <p>An ordered list of {@link MultiPart.Part}s that can
* be accessed by index or by name, or iterated over.</p> * be accessed by index or by name, or iterated over.</p>
*/ */
public class Parts implements Iterable<MultiPart.Part>, Closeable public static class Parts implements Iterable<MultiPart.Part>, Closeable
{ {
private final List<MultiPart.Part> parts; private final List<MultiPart.Part> parts;
@ -310,11 +102,6 @@ public class MultiPartFormData extends CompletableFuture<MultiPartFormData.Parts
this.parts = parts; this.parts = parts;
} }
public MultiPartFormData getMultiPartFormData()
{
return MultiPartFormData.this;
}
/** /**
* <p>Returns the {@link MultiPart.Part} at the given index, a number * <p>Returns the {@link MultiPart.Part} at the given index, a number
* between {@code 0} included and the value returned by {@link #size()} * between {@code 0} included and the value returned by {@link #size()}
@ -409,251 +196,447 @@ public class MultiPartFormData extends CompletableFuture<MultiPartFormData.Parts
} }
} }
private class PartsListener extends MultiPart.AbstractPartsListener public static class Parser
{ {
private final AutoLock lock = new AutoLock(); private final PartsListener listener = new PartsListener();
private final List<MultiPart.Part> parts = new ArrayList<>(); private final MultiPart.Parser parser;
private final List<Content.Chunk> partChunks = new ArrayList<>(); private boolean useFilesForPartsWithoutFileName;
private long fileSize; private Path filesDirectory;
private long memoryFileSize; private long maxFileSize = -1;
private Path filePath; private long maxMemoryFileSize;
private SeekableByteChannel fileChannel; private long maxLength = -1;
private Throwable failure; private long length;
private Parts parts;
@Override public Parser(String boundary)
public void onPartContent(Content.Chunk chunk)
{ {
ByteBuffer buffer = chunk.getByteBuffer(); parser = new MultiPart.Parser(Objects.requireNonNull(boundary), listener);
String fileName = getFileName(); }
if (fileName != null || isUseFilesForPartsWithoutFileName())
public CompletableFuture<Parts> parse(Content.Source content)
{
ContentSourceCompletableFuture<Parts> futureParts = new ContentSourceCompletableFuture<>(content)
{ {
long maxFileSize = getMaxFileSize(); @Override
fileSize += buffer.remaining(); protected Parts parse(Content.Chunk chunk) throws Throwable
if (maxFileSize >= 0 && fileSize > maxFileSize)
{ {
onFailure(new IllegalStateException("max file size exceeded: %d".formatted(maxFileSize))); if (listener.isFailed())
return; throw listener.failure;
length += chunk.getByteBuffer().remaining();
long max = getMaxLength();
if (max >= 0 && length > max)
throw new IllegalStateException("max length exceeded: %d".formatted(max));
parser.parse(chunk);
if (listener.isFailed())
throw listener.failure;
return parts;
} }
long maxMemoryFileSize = getMaxMemoryFileSize(); @Override
if (maxMemoryFileSize >= 0) public boolean completeExceptionally(Throwable failure)
{ {
memoryFileSize += buffer.remaining(); boolean failed = super.completeExceptionally(failure);
if (memoryFileSize > maxMemoryFileSize) if (failed)
{ listener.fail(failure);
try return failed;
{
// Must save to disk.
if (ensureFileChannel())
{
// Write existing memory chunks.
List<Content.Chunk> partChunks;
try (AutoLock ignored = lock.lock())
{
partChunks = List.copyOf(this.partChunks);
}
for (Content.Chunk c : partChunks)
{
write(c.getByteBuffer());
}
}
write(buffer);
if (chunk.isLast())
close();
}
catch (Throwable x)
{
onFailure(x);
}
try (AutoLock ignored = lock.lock())
{
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
}
return;
}
} }
} };
// Retain the chunk because it is stored for later use. futureParts.parse();
chunk.retain(); return futureParts;
try (AutoLock ignored = lock.lock())
{
partChunks.add(chunk);
}
} }
private void write(ByteBuffer buffer) throws Exception /**
* @return the boundary string
*/
public String getBoundary()
{ {
int remaining = buffer.remaining(); return parser.getBoundary();
while (remaining > 0)
{
SeekableByteChannel channel = fileChannel();
if (channel == null)
throw new IllegalStateException();
int written = channel.write(buffer);
if (written == 0)
throw new NonWritableChannelException();
remaining -= written;
}
} }
private void close() /**
* <p>Returns the default charset as specified by
* <a href="https://datatracker.ietf.org/doc/html/rfc7578#section-4.6">RFC 7578, section 4.6</a>,
* that is the charset specified by the part named {@code _charset_}.</p>
* <p>If that part is not present, returns {@code null}.</p>
*
* @return the default charset specified by the {@code _charset_} part,
* or null if that part is not present
*/
public Charset getDefaultCharset()
{ {
try return listener.getDefaultCharset();
{
Closeable closeable = fileChannel();
if (closeable != null)
closeable.close();
}
catch (Throwable x)
{
onFailure(x);
}
} }
@Override /**
public void onPart(String name, String fileName, HttpFields headers) * @return the max length of a {@link MultiPart.Part} headers, in bytes, or -1 for unlimited length
*/
public int getPartHeadersMaxLength()
{ {
fileSize = 0; return parser.getPartHeadersMaxLength();
memoryFileSize = 0;
try (AutoLock ignored = lock.lock())
{
MultiPart.Part part;
if (fileChannel != null)
part = new MultiPart.PathPart(name, fileName, headers, filePath);
else
part = new MultiPart.ChunksPart(name, fileName, headers, List.copyOf(partChunks));
// Reset part-related state.
filePath = null;
fileChannel = null;
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
// Store the new part.
parts.add(part);
}
} }
@Override /**
public void onComplete() * @param partHeadersMaxLength the max length of a {@link MultiPart.Part} headers, in bytes, or -1 for unlimited length
*/
public void setPartHeadersMaxLength(int partHeadersMaxLength)
{ {
super.onComplete(); parser.setPartHeadersMaxLength(partHeadersMaxLength);
List<MultiPart.Part> result;
try (AutoLock ignored = lock.lock())
{
result = List.copyOf(parts);
}
complete(new Parts(result));
} }
Charset getDefaultCharset() /**
* @return whether parts without fileName may be stored as files
*/
public boolean isUseFilesForPartsWithoutFileName()
{ {
try (AutoLock ignored = lock.lock()) return useFilesForPartsWithoutFileName;
{
return parts.stream()
.filter(part -> "_charset_".equals(part.getName()))
.map(part -> part.getContentAsString(US_ASCII))
.map(Charset::forName)
.findFirst()
.orElse(null);
}
} }
/**
* @param useFilesForPartsWithoutFileName whether parts without fileName may be stored as files
*/
public void setUseFilesForPartsWithoutFileName(boolean useFilesForPartsWithoutFileName)
{
this.useFilesForPartsWithoutFileName = useFilesForPartsWithoutFileName;
}
/**
* @return the directory where files are saved
*/
public Path getFilesDirectory()
{
return filesDirectory;
}
/**
* <p>Sets the directory where the files uploaded in the parts will be saved.</p>
*
* @param filesDirectory the directory where files are saved
*/
public void setFilesDirectory(Path filesDirectory)
{
this.filesDirectory = filesDirectory;
}
/**
* @return the maximum file size in bytes, or -1 for unlimited file size
*/
public long getMaxFileSize()
{
return maxFileSize;
}
/**
* @param maxFileSize the maximum file size in bytes, or -1 for unlimited file size
*/
public void setMaxFileSize(long maxFileSize)
{
this.maxFileSize = maxFileSize;
}
/**
* @return the maximum memory file size in bytes, or -1 for unlimited memory file size
*/
public long getMaxMemoryFileSize()
{
return maxMemoryFileSize;
}
/**
* <p>Sets the maximum memory file size in bytes, after which files will be saved
* in the directory specified by {@link #setFilesDirectory(Path)}.</p>
* <p>Use value {@code 0} to always save the files in the directory.</p>
* <p>Use value {@code -1} to never save the files in the directory.</p>
*
* @param maxMemoryFileSize the maximum memory file size in bytes, or -1 for unlimited memory file size
*/
public void setMaxMemoryFileSize(long maxMemoryFileSize)
{
this.maxMemoryFileSize = maxMemoryFileSize;
}
/**
* @return the maximum length in bytes of the whole multipart content, or -1 for unlimited length
*/
public long getMaxLength()
{
return maxLength;
}
/**
* @param maxLength the maximum length in bytes of the whole multipart content, or -1 for unlimited length
*/
public void setMaxLength(long maxLength)
{
this.maxLength = maxLength;
}
/**
* @return the maximum number of parts that can be parsed from the multipart content.
*/
public long getMaxParts()
{
return parser.getMaxParts();
}
/**
* @param maxParts the maximum number of parts that can be parsed from the multipart content.
*/
public void setMaxParts(long maxParts)
{
parser.setMaxParts(maxParts);
}
// Only used for testing.
int getPartsSize() int getPartsSize()
{ {
try (AutoLock ignored = lock.lock()) return listener.getPartsSize();
{
return parts.size();
}
} }
@Override private class PartsListener extends MultiPart.AbstractPartsListener
public void onFailure(Throwable failure)
{ {
super.onFailure(failure); private final AutoLock lock = new AutoLock();
completeExceptionally(failure); private final List<MultiPart.Part> parts = new ArrayList<>();
} private final List<Content.Chunk> partChunks = new ArrayList<>();
private long fileSize;
private long memoryFileSize;
private Path filePath;
private SeekableByteChannel fileChannel;
private Throwable failure;
private void fail(Throwable cause) @Override
{ public void onPartContent(Content.Chunk chunk)
List<MultiPart.Part> partsToFail;
try (AutoLock ignored = lock.lock())
{ {
if (failure != null) ByteBuffer buffer = chunk.getByteBuffer();
return; String fileName = getFileName();
failure = cause; if (fileName != null || isUseFilesForPartsWithoutFileName())
partsToFail = List.copyOf(parts); {
parts.clear(); long maxFileSize = getMaxFileSize();
partChunks.forEach(Content.Chunk::release); fileSize += buffer.remaining();
partChunks.clear(); if (maxFileSize >= 0 && fileSize > maxFileSize)
} {
partsToFail.forEach(p -> p.fail(cause)); onFailure(new IllegalStateException("max file size exceeded: %d".formatted(maxFileSize)));
close(); return;
delete(); }
}
private SeekableByteChannel fileChannel() long maxMemoryFileSize = getMaxMemoryFileSize();
{ if (maxMemoryFileSize >= 0)
try (AutoLock ignored = lock.lock()) {
{ memoryFileSize += buffer.remaining();
return fileChannel; if (memoryFileSize > maxMemoryFileSize)
} {
} try
{
// Must save to disk.
if (ensureFileChannel())
{
// Write existing memory chunks.
List<Content.Chunk> partChunks;
try (AutoLock ignored = lock.lock())
{
partChunks = List.copyOf(this.partChunks);
}
for (Content.Chunk c : partChunks)
{
write(c.getByteBuffer());
}
}
write(buffer);
if (chunk.isLast())
close();
}
catch (Throwable x)
{
onFailure(x);
}
private void delete() try (AutoLock ignored = lock.lock())
{ {
try partChunks.forEach(Content.Chunk::release);
{ partChunks.clear();
Path path = null; }
return;
}
}
}
// Retain the chunk because it is stored for later use.
chunk.retain();
try (AutoLock ignored = lock.lock()) try (AutoLock ignored = lock.lock())
{ {
if (filePath != null) partChunks.add(chunk);
path = filePath; }
}
private void write(ByteBuffer buffer) throws Exception
{
int remaining = buffer.remaining();
while (remaining > 0)
{
SeekableByteChannel channel = fileChannel();
if (channel == null)
throw new IllegalStateException();
int written = channel.write(buffer);
if (written == 0)
throw new NonWritableChannelException();
remaining -= written;
}
}
private void close()
{
try
{
Closeable closeable = fileChannel();
if (closeable != null)
closeable.close();
}
catch (Throwable x)
{
onFailure(x);
}
}
@Override
public void onPart(String name, String fileName, HttpFields headers)
{
fileSize = 0;
memoryFileSize = 0;
try (AutoLock ignored = lock.lock())
{
MultiPart.Part part;
if (fileChannel != null)
part = new MultiPart.PathPart(name, fileName, headers, filePath);
else
part = new MultiPart.ChunksPart(name, fileName, headers, List.copyOf(partChunks));
// Reset part-related state.
filePath = null; filePath = null;
fileChannel = null; fileChannel = null;
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
// Store the new part.
parts.add(part);
} }
if (path != null)
Files.delete(path);
} }
catch (Throwable x)
{
if (LOG.isTraceEnabled())
LOG.trace("IGNORED", x);
}
}
private boolean isFailed() @Override
{ public void onComplete()
try (AutoLock ignored = lock.lock())
{ {
return failure != null; super.onComplete();
List<MultiPart.Part> result;
try (AutoLock ignored = lock.lock())
{
result = List.copyOf(parts);
Parser.this.parts = new Parts(result);
}
} }
}
private boolean ensureFileChannel() Charset getDefaultCharset()
{
try (AutoLock ignored = lock.lock())
{ {
if (fileChannel != null) try (AutoLock ignored = lock.lock())
return false; {
createFileChannel(); return parts.stream()
return true; .filter(part -> "_charset_".equals(part.getName()))
.map(part -> part.getContentAsString(US_ASCII))
.map(Charset::forName)
.findFirst()
.orElse(null);
}
} }
}
private void createFileChannel() int getPartsSize()
{
try (AutoLock ignored = lock.lock())
{ {
Path directory = getFilesDirectory(); try (AutoLock ignored = lock.lock())
Files.createDirectories(directory); {
String fileName = "MultiPart"; return parts.size();
filePath = Files.createTempFile(directory, fileName, ""); }
fileChannel = Files.newByteChannel(filePath, StandardOpenOption.WRITE, StandardOpenOption.APPEND);
} }
catch (Throwable x)
@Override
public void onFailure(Throwable failure)
{ {
onFailure(x); fail(failure);
}
private void fail(Throwable cause)
{
List<MultiPart.Part> partsToFail;
try (AutoLock ignored = lock.lock())
{
if (failure != null)
return;
failure = cause;
partsToFail = List.copyOf(parts);
parts.clear();
partChunks.forEach(Content.Chunk::release);
partChunks.clear();
}
partsToFail.forEach(p -> p.fail(cause));
close();
delete();
}
private SeekableByteChannel fileChannel()
{
try (AutoLock ignored = lock.lock())
{
return fileChannel;
}
}
private void delete()
{
try
{
Path path = null;
try (AutoLock ignored = lock.lock())
{
if (filePath != null)
path = filePath;
filePath = null;
fileChannel = null;
}
if (path != null)
Files.delete(path);
}
catch (Throwable x)
{
if (LOG.isTraceEnabled())
LOG.trace("IGNORED", x);
}
}
private boolean isFailed()
{
try (AutoLock ignored = lock.lock())
{
return failure != null;
}
}
private boolean ensureFileChannel()
{
try (AutoLock ignored = lock.lock())
{
if (fileChannel != null)
return false;
createFileChannel();
return true;
}
}
private void createFileChannel()
{
try (AutoLock ignored = lock.lock())
{
Path directory = getFilesDirectory();
Files.createDirectories(directory);
String fileName = "MultiPart";
filePath = Files.createTempFile(directory, fileName, "");
fileChannel = Files.newByteChannel(filePath, StandardOpenOption.WRITE, StandardOpenOption.APPEND);
}
catch (Throwable x)
{
onFailure(x);
}
} }
} }
} }

View File

@ -16,19 +16,20 @@ package org.eclipse.jetty.http;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.content.AsyncContent;
import org.eclipse.jetty.toolchain.test.FS; import org.eclipse.jetty.toolchain.test.FS;
import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
@ -71,32 +72,19 @@ public class MultiPartFormDataTest
int leaks = 0; int leaks = 0;
for (Content.Chunk chunk : _allocatedChunks) for (Content.Chunk chunk : _allocatedChunks)
{ {
// Any release that does not return true is a leak. // Any release that does not throw or return true is a leak.
if (!chunk.release()) try
leaks++; {
if (!chunk.release())
leaks++;
}
catch (IllegalStateException ignored)
{
}
} }
assertThat("Leaked " + leaks + "/" + _allocatedChunks.size() + " chunk(s)", leaks, is(0)); assertThat("Leaked " + leaks + "/" + _allocatedChunks.size() + " chunk(s)", leaks, is(0));
} }
Content.Chunk asChunk(String data, boolean last)
{
byte[] b = data.getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer = BufferUtil.allocate(b.length);
BufferUtil.append(buffer, b);
Content.Chunk chunk = Content.Chunk.from(buffer, last);
_allocatedChunks.add(chunk);
return chunk;
}
Content.Chunk asChunk(ByteBuffer data, boolean last)
{
ByteBuffer buffer = BufferUtil.allocate(data.remaining());
BufferUtil.append(buffer, data);
Content.Chunk chunk = Content.Chunk.from(buffer, last);
_allocatedChunks.add(chunk);
return chunk;
}
@Test @Test
public void testBadMultiPart() throws Exception public void testBadMultiPart() throws Exception
{ {
@ -109,14 +97,14 @@ public class MultiPartFormDataTest
"Content-Disposition: form-data; name=\"fileup\"; filename=\"test.upload\"\r\n" + "Content-Disposition: form-data; name=\"fileup\"; filename=\"test.upload\"\r\n" +
"\r\n"; "\r\n";
MultiPartFormData formData = new MultiPartFormData(boundary); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary);
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(1024); formData.setMaxFileSize(1024);
formData.setMaxLength(3072); formData.setMaxLength(3072);
formData.setMaxMemoryFileSize(50); formData.setMaxMemoryFileSize(50);
formData.parse(asChunk(str, true)); Content.Sink.write(source, true, str, Callback.NOOP);
formData.parse(source).handle((parts, failure) ->
formData.handle((parts, failure) ->
{ {
assertNull(parts); assertNull(parts);
assertInstanceOf(BadMessageException.class, failure); assertInstanceOf(BadMessageException.class, failure);
@ -139,14 +127,14 @@ public class MultiPartFormDataTest
eol + eol +
"--" + boundary + "--" + eol; "--" + boundary + "--" + eol;
MultiPartFormData formData = new MultiPartFormData(boundary); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary);
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(1024); formData.setMaxFileSize(1024);
formData.setMaxLength(3072); formData.setMaxLength(3072);
formData.setMaxMemoryFileSize(50); formData.setMaxMemoryFileSize(50);
formData.parse(asChunk(str, true)); Content.Sink.write(source, true, str, Callback.NOOP);
formData.parse(source).whenComplete((parts, failure) ->
formData.whenComplete((parts, failure) ->
{ {
// No errors and no parts. // No errors and no parts.
assertNull(failure); assertNull(failure);
@ -165,14 +153,14 @@ public class MultiPartFormDataTest
String str = eol + String str = eol +
"--" + boundary + "--" + eol; "--" + boundary + "--" + eol;
MultiPartFormData formData = new MultiPartFormData(boundary); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary);
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(1024); formData.setMaxFileSize(1024);
formData.setMaxLength(3072); formData.setMaxLength(3072);
formData.setMaxMemoryFileSize(50); formData.setMaxMemoryFileSize(50);
formData.parse(asChunk(str, true)); Content.Sink.write(source, true, str, Callback.NOOP);
formData.parse(source).whenComplete((parts, failure) ->
formData.whenComplete((parts, failure) ->
{ {
// No errors and no parts. // No errors and no parts.
assertNull(failure); assertNull(failure);
@ -213,14 +201,14 @@ public class MultiPartFormDataTest
----\r ----\r
"""; """;
MultiPartFormData formData = new MultiPartFormData(""); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(1024); formData.setMaxFileSize(1024);
formData.setMaxLength(3072); formData.setMaxLength(3072);
formData.setMaxMemoryFileSize(50); formData.setMaxMemoryFileSize(50);
formData.parse(asChunk(str, true)); Content.Sink.write(source, true, str, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(4)); assertThat(parts.size(), is(4));
@ -253,10 +241,10 @@ public class MultiPartFormDataTest
@Test @Test
public void testNoBody() throws Exception public void testNoBody() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("boundary"); AsyncContent source = new TestContent();
formData.parse(Content.Chunk.EOF); MultiPartFormData.Parser formData = new MultiPartFormData.Parser("boundary");
source.close();
formData.handle((parts, failure) -> formData.parse(source).handle((parts, failure) ->
{ {
assertNull(parts); assertNull(parts);
assertNotNull(failure); assertNotNull(failure);
@ -268,11 +256,11 @@ public class MultiPartFormDataTest
@Test @Test
public void testBodyWithOnlyCRLF() throws Exception public void testBodyWithOnlyCRLF() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("boundary"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("boundary");
String body = " \n\n\n\r\n\r\n\r\n\r\n"; String body = " \n\n\n\r\n\r\n\r\n\r\n";
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
formData.parse(source).handle((parts, failure) ->
formData.handle((parts, failure) ->
{ {
assertNull(parts); assertNull(parts);
assertNotNull(failure); assertNotNull(failure);
@ -285,7 +273,7 @@ public class MultiPartFormDataTest
public void testLeadingWhitespaceBodyWithCRLF() throws Exception public void testLeadingWhitespaceBodyWithCRLF() throws Exception
{ {
String body = """ String body = """
\r \r
\r \r
@ -303,14 +291,14 @@ public class MultiPartFormDataTest
--AaB03x--\r --AaB03x--\r
"""; """;
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(1024); formData.setMaxFileSize(1024);
formData.setMaxLength(3072); formData.setMaxLength(3072);
formData.setMaxMemoryFileSize(50); formData.setMaxMemoryFileSize(50);
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(2)); assertThat(parts.size(), is(2));
MultiPart.Part part1 = parts.getFirst("field1"); MultiPart.Part part1 = parts.getFirst("field1");
@ -340,14 +328,14 @@ public class MultiPartFormDataTest
--AaB03x--\r --AaB03x--\r
"""; """;
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(1024); formData.setMaxFileSize(1024);
formData.setMaxLength(3072); formData.setMaxLength(3072);
formData.setMaxMemoryFileSize(50); formData.setMaxMemoryFileSize(50);
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
// The first boundary must be on a new line, so the first "part" is not recognized as such. // The first boundary must be on a new line, so the first "part" is not recognized as such.
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
@ -361,7 +349,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testDefaultLimits() throws Exception public void testDefaultLimits() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
String body = """ String body = """
--AaB03x\r --AaB03x\r
@ -371,9 +360,8 @@ public class MultiPartFormDataTest
ABCDEFGHIJKLMNOPQRSTUVWXYZ\r ABCDEFGHIJKLMNOPQRSTUVWXYZ\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
MultiPart.Part part = parts.get(0); MultiPart.Part part = parts.get(0);
@ -390,7 +378,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testRequestContentTooBig() throws Exception public void testRequestContentTooBig() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxLength(16); formData.setMaxLength(16);
@ -402,9 +391,8 @@ public class MultiPartFormDataTest
ABCDEFGHIJKLMNOPQRSTUVWXYZ\r ABCDEFGHIJKLMNOPQRSTUVWXYZ\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
formData.parse(source).handle((parts, failure) ->
formData.handle((parts, failure) ->
{ {
assertNull(parts); assertNull(parts);
assertNotNull(failure); assertNotNull(failure);
@ -416,7 +404,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testFileTooBig() throws Exception public void testFileTooBig() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxFileSize(16); formData.setMaxFileSize(16);
@ -428,9 +417,8 @@ public class MultiPartFormDataTest
ABCDEFGHIJKLMNOPQRSTUVWXYZ\r ABCDEFGHIJKLMNOPQRSTUVWXYZ\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
formData.parse(source).handle((parts, failure) ->
formData.handle((parts, failure) ->
{ {
assertNull(parts); assertNull(parts);
assertNotNull(failure); assertNotNull(failure);
@ -442,7 +430,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testTwoFilesOneInMemoryOneOnDisk() throws Exception public void testTwoFilesOneInMemoryOneOnDisk() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
String chunk = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; String chunk = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
formData.setMaxMemoryFileSize(chunk.length() + 1); formData.setMaxMemoryFileSize(chunk.length() + 1);
@ -460,9 +449,8 @@ public class MultiPartFormDataTest
$C$C$C$C\r $C$C$C$C\r
--AaB03x--\r --AaB03x--\r
""".replace("$C", chunk); """.replace("$C", chunk);
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertNotNull(parts); assertNotNull(parts);
assertEquals(2, parts.size()); assertEquals(2, parts.size());
@ -482,7 +470,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testPartWrite() throws Exception public void testPartWrite() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
String chunk = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; String chunk = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
formData.setMaxMemoryFileSize(chunk.length() + 1); formData.setMaxMemoryFileSize(chunk.length() + 1);
@ -500,9 +489,8 @@ public class MultiPartFormDataTest
$C$C$C$C\r $C$C$C$C\r
--AaB03x--\r --AaB03x--\r
""".replace("$C", chunk); """.replace("$C", chunk);
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertNotNull(parts); assertNotNull(parts);
assertEquals(2, parts.size()); assertEquals(2, parts.size());
@ -528,7 +516,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testPathPartDelete() throws Exception public void testPathPartDelete() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
String body = """ String body = """
@ -539,9 +528,8 @@ public class MultiPartFormDataTest
ABCDEFGHIJKLMNOPQRSTUVWXYZ\r ABCDEFGHIJKLMNOPQRSTUVWXYZ\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertNotNull(parts); assertNotNull(parts);
assertEquals(1, parts.size()); assertEquals(1, parts.size());
@ -559,7 +547,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testAbort() public void testAbort()
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxMemoryFileSize(32); formData.setMaxMemoryFileSize(32);
@ -575,24 +564,27 @@ public class MultiPartFormDataTest
--AaB03x--\r --AaB03x--\r
"""; """;
// Parse only part of the content. // Parse only part of the content.
formData.parse(asChunk(body, false)); Content.Sink.write(source, false, body, Callback.NOOP);
CompletableFuture<MultiPartFormData.Parts> futureParts = formData.parse(source);
assertEquals(1, formData.getPartsSize()); assertEquals(1, formData.getPartsSize());
// Abort MultiPartFormData. // Abort MultiPartFormData.
formData.completeExceptionally(new IOException()); futureParts.completeExceptionally(new IOException());
// Parse the rest of the content. // Parse the rest of the content.
formData.parse(asChunk(terminator, true)); Content.Sink.write(source, true, terminator, Callback.NOOP);
// Try to get the parts, it should fail. // Try to get the parts, it should fail.
assertThrows(ExecutionException.class, () -> formData.get(5, TimeUnit.SECONDS)); assertThrows(ExecutionException.class, () -> futureParts.get(5, TimeUnit.SECONDS));
assertEquals(0, formData.getPartsSize()); assertEquals(0, formData.getPartsSize());
} }
@Test @Test
public void testMaxHeaderLength() throws Exception public void testMaxHeaderLength() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setPartHeadersMaxLength(32); formData.setPartHeadersMaxLength(32);
@ -604,9 +596,8 @@ public class MultiPartFormDataTest
ABCDEFGHIJKLMNOPQRSTUVWXYZ\r ABCDEFGHIJKLMNOPQRSTUVWXYZ\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
formData.parse(source).handle((parts, failure) ->
formData.handle((parts, failure) ->
{ {
assertNull(parts); assertNull(parts);
assertNotNull(failure); assertNotNull(failure);
@ -618,7 +609,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testDefaultCharset() throws Exception public void testDefaultCharset() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxMemoryFileSize(-1); formData.setMaxMemoryFileSize(-1);
@ -645,13 +637,14 @@ public class MultiPartFormDataTest
\r \r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body1, false)); CompletableFuture<MultiPartFormData.Parts> futureParts = formData.parse(source);
formData.parse(asChunk(isoCedilla, false)); Content.Sink.write(source, false, body1, Callback.NOOP);
formData.parse(asChunk(body2, false)); source.write(false, isoCedilla, Callback.NOOP);
formData.parse(asChunk(utfCedilla, false)); Content.Sink.write(source, false, body2, Callback.NOOP);
formData.parse(asChunk(terminator, true)); source.write(false, utfCedilla, Callback.NOOP);
Content.Sink.write(source, true, terminator, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS)) try (MultiPartFormData.Parts parts = futureParts.get(5, TimeUnit.SECONDS))
{ {
Charset defaultCharset = formData.getDefaultCharset(); Charset defaultCharset = formData.getDefaultCharset();
assertEquals(ISO_8859_1, defaultCharset); assertEquals(ISO_8859_1, defaultCharset);
@ -669,7 +662,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testPartWithBackSlashInFileName() throws Exception public void testPartWithBackSlashInFileName() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxMemoryFileSize(-1); formData.setMaxMemoryFileSize(-1);
@ -681,9 +675,9 @@ public class MultiPartFormDataTest
stuffaaa\r stuffaaa\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(contents, true)); Content.Sink.write(source, true, contents, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS)) try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
MultiPart.Part part = parts.get(0); MultiPart.Part part = parts.get(0);
@ -694,7 +688,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testPartWithWindowsFileName() throws Exception public void testPartWithWindowsFileName() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxMemoryFileSize(-1); formData.setMaxMemoryFileSize(-1);
@ -706,9 +701,8 @@ public class MultiPartFormDataTest
stuffaaa\r stuffaaa\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(contents, true)); Content.Sink.write(source, true, contents, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
MultiPart.Part part = parts.get(0); MultiPart.Part part = parts.get(0);
@ -722,7 +716,8 @@ public class MultiPartFormDataTest
@Disabled @Disabled
public void testCorrectlyEncodedMSFilename() throws Exception public void testCorrectlyEncodedMSFilename() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setMaxMemoryFileSize(-1); formData.setMaxMemoryFileSize(-1);
@ -734,9 +729,8 @@ public class MultiPartFormDataTest
stuffaaa\r stuffaaa\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(contents, true)); Content.Sink.write(source, true, contents, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
MultiPart.Part part = parts.get(0); MultiPart.Part part = parts.get(0);
@ -747,7 +741,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testWriteFilesForPartWithoutFileName() throws Exception public void testWriteFilesForPartWithoutFileName() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
formData.setUseFilesForPartsWithoutFileName(true); formData.setUseFilesForPartsWithoutFileName(true);
@ -759,9 +754,8 @@ public class MultiPartFormDataTest
sssaaa\r sssaaa\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(body, true)); Content.Sink.write(source, true, body, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
MultiPart.Part part = parts.get(0); MultiPart.Part part = parts.get(0);
@ -775,7 +769,8 @@ public class MultiPartFormDataTest
@Test @Test
public void testPartsWithSameName() throws Exception public void testPartsWithSameName() throws Exception
{ {
MultiPartFormData formData = new MultiPartFormData("AaB03x"); AsyncContent source = new TestContent();
MultiPartFormData.Parser formData = new MultiPartFormData.Parser("AaB03x");
formData.setFilesDirectory(_tmpDir); formData.setFilesDirectory(_tmpDir);
String sameNames = """ String sameNames = """
@ -791,9 +786,8 @@ public class MultiPartFormDataTest
AAAAA\r AAAAA\r
--AaB03x--\r --AaB03x--\r
"""; """;
formData.parse(asChunk(sameNames, true)); Content.Sink.write(source, true, sameNames, Callback.NOOP);
try (MultiPartFormData.Parts parts = formData.parse(source).get(5, TimeUnit.SECONDS))
try (MultiPartFormData.Parts parts = formData.get(5, TimeUnit.SECONDS))
{ {
assertEquals(2, parts.size()); assertEquals(2, parts.size());
@ -810,4 +804,16 @@ public class MultiPartFormDataTest
assertEquals("AAAAA", part2.getContentAsString(formData.getDefaultCharset())); assertEquals("AAAAA", part2.getContentAsString(formData.getDefaultCharset()));
} }
} }
private class TestContent extends AsyncContent
{
@Override
public Content.Chunk read()
{
Content.Chunk chunk = super.read();
if (chunk != null && chunk.canRetain())
_allocatedChunks.add(chunk);
return chunk;
}
}
} }

View File

@ -0,0 +1,144 @@
//
// ========================================================================
// 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.io.content;
import java.io.EOFException;
import java.util.concurrent.CompletableFuture;
import org.eclipse.jetty.io.Content;
/**
* <p>A utility class to convert content from a {@link Content.Source} to an instance
* available via a {@link CompletableFuture}.</p>
* <p>An example usage to asynchronously read UTF-8 content is:</p>
* <pre>{@code
* public static class CompletableUTF8String extends ContentSourceCompletableFuture<String>;
* {
* private final Utf8StringBuilder builder = new Utf8StringBuilder();
*
* public CompletableUTF8String(Content.Source content)
* {
* super(content);
* }
*
* @Override
* protected String parse(Content.Chunk chunk) throws Throwable
* {
* // Accumulate the chunk bytes.
* if (chunk.hasRemaining())
* builder.append(chunk.getByteBuffer());
*
* // Not the last chunk, the result is not ready yet.
* if (!chunk.isLast())
* return null;
*
* // The result is ready.
* return builder.takeCompleteString(IllegalStateException::new);
* }
* }
*
* new CompletableUTF8String(source).thenAccept(System.err::println);
* }</pre>
*/
public abstract class ContentSourceCompletableFuture<X> extends CompletableFuture<X>
{
private final Content.Source _content;
public ContentSourceCompletableFuture(Content.Source content)
{
_content = content;
}
/**
* <p>Initiates the parsing of the {@link Content.Source}.</p>
* <p>For every valid chunk that is read, {@link #parse(Content.Chunk)}
* is called, until a result is produced that is used to
* complete this {@link CompletableFuture}.</p>
* <p>Internally, this method is called multiple times to progress
* the parsing in response to {@link Content.Source#demand(Runnable)}
* calls.</p>
* <p>Exceptions thrown during parsing result in this
* {@link CompletableFuture} to be completed exceptionally.</p>
*/
public void parse()
{
while (true)
{
Content.Chunk chunk = _content.read();
if (chunk == null)
{
_content.demand(this::parse);
return;
}
if (Content.Chunk.isFailure(chunk))
{
if (!chunk.isLast() && onTransientFailure(chunk.getFailure()))
continue;
completeExceptionally(chunk.getFailure());
return;
}
try
{
X x = parse(chunk);
if (x != null)
{
complete(x);
return;
}
}
catch (Throwable failure)
{
completeExceptionally(failure);
return;
}
finally
{
chunk.release();
}
if (chunk.isLast())
{
completeExceptionally(new EOFException());
return;
}
}
}
/**
* <p>Called by {@link #parse()} to parse a {@link org.eclipse.jetty.io.Content.Chunk}.</p>
*
* @param chunk The chunk containing content to parse. The chunk will never be {@code null} nor a
* {@link org.eclipse.jetty.io.Content.Chunk#isFailure(Content.Chunk) failure chunk}.
* If the chunk is stored away to be used later beyond the scope of this call,
* then implementations must call {@link Content.Chunk#retain()} and
* {@link Content.Chunk#release()} as appropriate.
* @return The parsed {@code X} result instance or {@code null} if parsing is not yet complete
* @throws Throwable If there is an error parsing
*/
protected abstract X parse(Content.Chunk chunk) throws Throwable;
/**
* <p>Callback method that informs the parsing about how to handle transient failures.</p>
*
* @param cause A transient failure obtained by reading a {@link Content.Chunk#isLast() non-last}
* {@link org.eclipse.jetty.io.Content.Chunk#isFailure(Content.Chunk) failure chunk}
* @return {@code true} if the transient failure can be ignored, {@code false} otherwise
*/
protected boolean onTransientFailure(Throwable cause)
{
return false;
}
}

View File

@ -22,6 +22,8 @@ import java.util.concurrent.CompletableFuture;
import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.MimeTypes; import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.content.ContentSourceCompletableFuture;
import org.eclipse.jetty.util.Attributes;
import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.CharsetStringBuilder; import org.eclipse.jetty.util.CharsetStringBuilder;
import org.eclipse.jetty.util.Fields; import org.eclipse.jetty.util.Fields;
@ -33,7 +35,7 @@ import static org.eclipse.jetty.util.UrlEncoded.decodeHexByte;
* A {@link CompletableFuture} that is completed once a {@code application/x-www-form-urlencoded} * A {@link CompletableFuture} that is completed once a {@code application/x-www-form-urlencoded}
* content has been parsed asynchronously from the {@link Content.Source}. * content has been parsed asynchronously from the {@link Content.Source}.
*/ */
public class FormFields extends CompletableFuture<Fields> implements Runnable public class FormFields extends ContentSourceCompletableFuture<Fields>
{ {
public static final String MAX_FIELDS_ATTRIBUTE = "org.eclipse.jetty.server.Request.maxFormKeys"; public static final String MAX_FIELDS_ATTRIBUTE = "org.eclipse.jetty.server.Request.maxFormKeys";
public static final String MAX_LENGTH_ATTRIBUTE = "org.eclipse.jetty.server.Request.maxFormContentSize"; public static final String MAX_LENGTH_ATTRIBUTE = "org.eclipse.jetty.server.Request.maxFormContentSize";
@ -57,29 +59,22 @@ public class FormFields extends CompletableFuture<Fields> implements Runnable
return StringUtil.isEmpty(cs) ? StandardCharsets.UTF_8 : Charset.forName(cs); return StringUtil.isEmpty(cs) ? StandardCharsets.UTF_8 : Charset.forName(cs);
} }
public static CompletableFuture<Fields> from(Request request) /**
{ * Set a {@link Fields} or related failure for the request
// TODO make this attributes provided by the ContextRequest wrapper * @param request The request to which to associate the fields with
int maxFields = getRequestAttribute(request, FormFields.MAX_FIELDS_ATTRIBUTE); * @param fields A {@link CompletableFuture} that will provide either the fields or a failure.
int maxLength = getRequestAttribute(request, FormFields.MAX_LENGTH_ATTRIBUTE); */
return from(request, maxFields, maxLength);
}
public static CompletableFuture<Fields> from(Request request, Charset charset)
{
// TODO make this attributes provided by the ContextRequest wrapper
int maxFields = getRequestAttribute(request, FormFields.MAX_FIELDS_ATTRIBUTE);
int maxLength = getRequestAttribute(request, FormFields.MAX_LENGTH_ATTRIBUTE);
return from(request, charset, maxFields, maxLength);
}
public static void set(Request request, CompletableFuture<Fields> fields) public static void set(Request request, CompletableFuture<Fields> fields)
{ {
request.setAttribute(FormFields.class.getName(), fields); request.setAttribute(FormFields.class.getName(), fields);
} }
/**
* @param request The request to enquire from
* @return A {@link CompletableFuture} that will provide either the fields or a failure, or null if none set.
* @see #from(Request)
*
*/
public static CompletableFuture<Fields> get(Request request) public static CompletableFuture<Fields> get(Request request)
{ {
Object attr = request.getAttribute(FormFields.class.getName()); Object attr = request.getAttribute(FormFields.class.getName());
@ -88,26 +83,93 @@ public class FormFields extends CompletableFuture<Fields> implements Runnable
return EMPTY; return EMPTY;
} }
/**
* Find or create a {@link FormFields} from a {@link Content.Source}.
* @param request The {@link Request} in which to look for an existing {@link FormFields} attribute,
* using the classname as the attribute name, else the request is used
* as a {@link Content.Source} from which to read the fields and set the attribute.
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
* @see #from(Content.Source, Attributes, Charset, int, int)
*/
public static CompletableFuture<Fields> from(Request request)
{
int maxFields = getRequestAttribute(request, FormFields.MAX_FIELDS_ATTRIBUTE);
int maxLength = getRequestAttribute(request, FormFields.MAX_LENGTH_ATTRIBUTE);
return from(request, maxFields, maxLength);
}
/**
* Find or create a {@link FormFields} from a {@link Content.Source}.
* @param request The {@link Request} in which to look for an existing {@link FormFields} attribute,
* using the classname as the attribute name, else the request is used
* as a {@link Content.Source} from which to read the fields and set the attribute.
* @param charset the {@link Charset} to use for byte to string conversion.
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
* @see #from(Content.Source, Attributes, Charset, int, int)
*/
public static CompletableFuture<Fields> from(Request request, Charset charset)
{
int maxFields = getRequestAttribute(request, FormFields.MAX_FIELDS_ATTRIBUTE);
int maxLength = getRequestAttribute(request, FormFields.MAX_LENGTH_ATTRIBUTE);
return from(request, charset, maxFields, maxLength);
}
/**
* Find or create a {@link FormFields} from a {@link Content.Source}.
* @param request The {@link Request} in which to look for an existing {@link FormFields} attribute,
* using the classname as the attribute name, else the request is used
* as a {@link Content.Source} from which to read the fields and set the attribute.
* @param maxFields The maximum number of fields to be parsed
* @param maxLength The maximum total size of the fields
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
* @see #from(Content.Source, Attributes, Charset, int, int)
*/
public static CompletableFuture<Fields> from(Request request, int maxFields, int maxLength) public static CompletableFuture<Fields> from(Request request, int maxFields, int maxLength)
{ {
Object attr = request.getAttribute(FormFields.class.getName()); return from(request, getFormEncodedCharset(request), maxFields, maxLength);
}
/**
* Find or create a {@link FormFields} from a {@link Content.Source}.
* @param request The {@link Request} in which to look for an existing {@link FormFields} attribute,
* using the classname as the attribute name, else the request is used
* as a {@link Content.Source} from which to read the fields and set the attribute.
* @param charset the {@link Charset} to use for byte to string conversion.
* @param maxFields The maximum number of fields to be parsed
* @param maxLength The maximum total size of the fields
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
* @see #from(Content.Source, Attributes, Charset, int, int)
*/
public static CompletableFuture<Fields> from(Request request, Charset charset, int maxFields, int maxLength)
{
return from(request, request, charset, maxFields, maxLength);
}
/**
* Find or create a {@link FormFields} from a {@link Content.Source}.
* @param source The {@link Content.Source} from which to read the fields.
* @param attributes The {@link Attributes} in which to look for an existing {@link CompletableFuture} of
* {@link FormFields}, using the classname as the attribute name. If not found the attribute
* is set with the created {@link CompletableFuture} of {@link FormFields}.
* @param charset the {@link Charset} to use for byte to string conversion.
* @param maxFields The maximum number of fields to be parsed
* @param maxLength The maximum total size of the fields
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
*/
static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
{
Object attr = attributes.getAttribute(FormFields.class.getName());
if (attr instanceof FormFields futureFormFields) if (attr instanceof FormFields futureFormFields)
return futureFormFields; return futureFormFields;
else if (attr instanceof Fields fields) else if (attr instanceof Fields fields)
return CompletableFuture.completedFuture(fields); return CompletableFuture.completedFuture(fields);
Charset charset = getFormEncodedCharset(request);
if (charset == null) if (charset == null)
return EMPTY; return EMPTY;
return from(request, charset, maxFields, maxLength); FormFields futureFormFields = new FormFields(source, charset, maxFields, maxLength);
} attributes.setAttribute(FormFields.class.getName(), futureFormFields);
futureFormFields.parse();
public static CompletableFuture<Fields> from(Request request, Charset charset, int maxFields, int maxLength)
{
FormFields futureFormFields = new FormFields(request, charset, maxFields, maxLength);
request.setAttribute(FormFields.class.getName(), futureFormFields);
futureFormFields.run();
return futureFormFields; return futureFormFields;
} }
@ -126,7 +188,6 @@ public class FormFields extends CompletableFuture<Fields> implements Runnable
} }
} }
private final Content.Source _source;
private final Fields _fields; private final Fields _fields;
private final CharsetStringBuilder _builder; private final CharsetStringBuilder _builder;
private final int _maxFields; private final int _maxFields;
@ -136,9 +197,9 @@ public class FormFields extends CompletableFuture<Fields> implements Runnable
private int _percent = 0; private int _percent = 0;
private byte _percentCode; private byte _percentCode;
public FormFields(Content.Source source, Charset charset, int maxFields, int maxSize) private FormFields(Content.Source source, Charset charset, int maxFields, int maxSize)
{ {
_source = source; super(source);
_maxFields = maxFields; _maxFields = maxFields;
_maxLength = maxSize; _maxLength = maxSize;
_builder = CharsetStringBuilder.forCharset(charset); _builder = CharsetStringBuilder.forCharset(charset);
@ -146,137 +207,91 @@ public class FormFields extends CompletableFuture<Fields> implements Runnable
} }
@Override @Override
public void run() protected Fields parse(Content.Chunk chunk) throws CharacterCodingException
{
Content.Chunk chunk = null;
try
{
while (true)
{
chunk = _source.read();
if (chunk == null)
{
_source.demand(this);
return;
}
if (Content.Chunk.isFailure(chunk))
{
completeExceptionally(chunk.getFailure());
return;
}
while (true)
{
Fields.Field field = parse(chunk);
if (field == null)
break;
if (_maxFields >= 0 && _fields.getSize() >= _maxFields)
{
chunk.release();
// Do not double release if completeExceptionally() throws.
chunk = null;
completeExceptionally(new IllegalStateException("form with too many fields"));
return;
}
_fields.add(field);
}
chunk.release();
if (chunk.isLast())
{
// Do not double release if complete() throws.
chunk = null;
complete(_fields);
return;
}
}
}
catch (Throwable x)
{
if (chunk != null)
chunk.release();
completeExceptionally(x);
}
}
protected Fields.Field parse(Content.Chunk chunk) throws CharacterCodingException
{ {
String value = null; String value = null;
ByteBuffer buffer = chunk.getByteBuffer(); ByteBuffer buffer = chunk.getByteBuffer();
loop:
while (BufferUtil.hasContent(buffer)) do
{ {
byte b = buffer.get(); loop:
switch (_percent) while (BufferUtil.hasContent(buffer))
{ {
case 1 -> byte b = buffer.get();
switch (_percent)
{ {
_percentCode = b; case 1 ->
_percent++; {
continue; _percentCode = b;
_percent++;
continue;
}
case 2 ->
{
_builder.append(decodeHexByte((char)_percentCode, (char)b));
_percent = 0;
continue;
}
} }
case 2 ->
if (_name == null)
{ {
_builder.append(decodeHexByte((char)_percentCode, (char)b)); switch (b)
_percent = 0; {
continue; case '=' ->
{
_name = _builder.build();
checkLength(_name);
}
case '+' -> _builder.append((byte)' ');
case '%' -> _percent++;
default -> _builder.append(b);
}
}
else
{
switch (b)
{
case '&' ->
{
value = _builder.build();
checkLength(value);
break loop;
}
case '+' -> _builder.append((byte)' ');
case '%' -> _percent++;
default -> _builder.append(b);
}
} }
} }
if (_name == null) if (_name != null)
{ {
switch (b) if (value == null && chunk.isLast())
{ {
case '=' -> if (_percent > 0)
{ {
_name = _builder.build(); _builder.append((byte)'%');
checkLength(_name); _builder.append(_percentCode);
} }
case '+' -> _builder.append((byte)' '); value = _builder.build();
case '%' -> _percent++; checkLength(value);
default -> _builder.append(b);
} }
}
else if (value != null)
{
switch (b)
{ {
case '&' -> Fields.Field field = new Fields.Field(_name, value);
{ _name = null;
value = _builder.build(); value = null;
checkLength(value); if (_maxFields >= 0 && _fields.getSize() >= _maxFields)
break loop; throw new IllegalStateException("form with too many fields > " + _maxFields);
} _fields.add(field);
case '+' -> _builder.append((byte)' ');
case '%' -> _percent++;
default -> _builder.append(b);
} }
} }
} }
while (BufferUtil.hasContent(buffer));
if (_name != null) return chunk.isLast() ? _fields : null;
{
if (value == null && chunk.isLast())
{
if (_percent > 0)
{
_builder.append((byte)'%');
_builder.append(_percentCode);
}
value = _builder.build();
checkLength(value);
}
if (value != null)
{
Fields.Field field = new Fields.Field(_name, value);
_name = null;
return field;
}
}
return null;
} }
private void checkLength(String nameOrValue) private void checkLength(String nameOrValue)

View File

@ -13,7 +13,6 @@
package org.eclipse.jetty.server.handler; package org.eclipse.jetty.server.handler;
import java.io.IOException;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Objects; import java.util.Objects;
@ -25,8 +24,6 @@ import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpHeaderValue; import org.eclipse.jetty.http.HttpHeaderValue;
import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.MimeTypes; 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.io.Content;
import org.eclipse.jetty.server.FormFields; import org.eclipse.jetty.server.FormFields;
import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Handler;
@ -115,7 +112,6 @@ public class DelayedHandler extends Handler.Wrapper
return switch (mimeType) return switch (mimeType)
{ {
case FORM_ENCODED -> new UntilFormDelayedProcess(handler, request, response, callback, contentType); case FORM_ENCODED -> new UntilFormDelayedProcess(handler, request, response, callback, contentType);
case MULTIPART_FORM_DATA -> new UntilMultiPartDelayedProcess(handler, request, response, callback, contentType);
default -> new UntilContentDelayedProcess(handler, request, response, callback); default -> new UntilContentDelayedProcess(handler, request, response, callback);
}; };
} }
@ -270,102 +266,4 @@ public class DelayedHandler extends Handler.Wrapper
Response.writeError(getRequest(), getResponse(), getCallback(), x); Response.writeError(getRequest(), getResponse(), getCallback(), x);
} }
} }
protected static class UntilMultiPartDelayedProcess extends DelayedProcess
{
private final MultiPartFormData _formData;
public UntilMultiPartDelayedProcess(Handler handler, Request wrapped, Response response, Callback callback, String contentType)
{
super(handler, wrapped, response, callback);
String boundary = MultiPart.extractBoundary(contentType);
_formData = boundary == null ? null : new MultiPartFormData(boundary);
}
private void process(MultiPartFormData.Parts parts, Throwable x)
{
if (x == null)
{
getRequest().setAttribute(MultiPartFormData.Parts.class.getName(), parts);
super.process();
}
else
{
Response.writeError(getRequest(), getResponse(), getCallback(), x);
}
}
private void executeProcess(MultiPartFormData.Parts parts, Throwable x)
{
if (x == null)
{
// We must execute here as even though we have consumed all the input, we are probably
// invoked in a demand runnable that is serialized with any write callbacks that might be done in process
getRequest().getContext().execute(() -> process(parts, x));
}
else
{
Response.writeError(getRequest(), getResponse(), getCallback(), x);
}
}
@Override
public void delay()
{
if (_formData == null)
{
this.process();
}
else
{
_formData.setFilesDirectory(getRequest().getContext().getTempDirectory().toPath());
readAndParse();
// if we are done already, then we are still in the scope of the original process call and can
// process directly, otherwise we must execute a call to process as we are within a serialized
// demand callback.
if (_formData.isDone())
{
try
{
MultiPartFormData.Parts parts = _formData.join();
process(parts, null);
}
catch (Throwable t)
{
process(null, t);
}
}
else
{
_formData.whenComplete(this::executeProcess);
}
}
}
private void readAndParse()
{
while (!_formData.isDone())
{
Content.Chunk chunk = getRequest().read();
if (chunk == null)
{
getRequest().demand(this::readAndParse);
return;
}
if (Content.Chunk.isFailure(chunk))
{
_formData.completeExceptionally(chunk.getFailure());
return;
}
_formData.parse(chunk);
chunk.release();
if (chunk.isLast())
{
if (!_formData.isDone())
process(null, new IOException("Incomplete multipart"));
return;
}
}
}
}
} }

View File

@ -0,0 +1,92 @@
//
// ========================================================================
// 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.charset.Charset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.eclipse.jetty.io.content.AsyncContent;
import org.eclipse.jetty.util.Attributes;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.Fields;
import org.eclipse.jetty.util.FutureCallback;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class FormFieldsTest
{
public static Stream<Arguments> tests()
{
return Stream.of(
Arguments.of(List.of("name=value"), UTF_8, -1, -1, Map.of("name", "value")),
Arguments.of(List.of("name=value", ""), UTF_8, -1, -1, Map.of("name", "value")),
Arguments.of(List.of("name", "=value", ""), UTF_8, -1, -1, Map.of("name", "value")),
Arguments.of(List.of("n", "ame", "=", "value"), UTF_8, -1, -1, Map.of("name", "value")),
Arguments.of(List.of("n=v&X=Y"), UTF_8, 2, 4, Map.of("n", "v", "X", "Y")),
Arguments.of(List.of("name=f¤¤&X=Y"), UTF_8, -1, -1, Map.of("name", "f¤¤", "X", "Y")),
Arguments.of(List.of("n=v&X=Y"), UTF_8, 1, -1, null),
Arguments.of(List.of("n=v&X=Y"), UTF_8, -1, 3, null)
);
}
@ParameterizedTest
@MethodSource("tests")
public void testFormFields(List<String> chunks, Charset charset, int maxFields, int maxLength, Map<String, String> expected)
throws Exception
{
AsyncContent source = new AsyncContent();
Attributes attributes = new Attributes.Mapped();
CompletableFuture<Fields> futureFields = FormFields.from(source, attributes, charset, maxFields, maxLength);
assertFalse(futureFields.isDone());
int last = chunks.size() - 1;
FutureCallback eof = new FutureCallback();
for (int i = 0; i <= last; i++)
source.write(i == last, BufferUtil.toBuffer(chunks.get(i), charset), i == last ? eof : Callback.NOOP);
try
{
eof.get(10, TimeUnit.SECONDS);
assertTrue(futureFields.isDone());
Map<String, String> result = new HashMap<>();
for (Fields.Field f : futureFields.get())
result.put(f.getName(), f.getValue());
assertEquals(expected, result);
}
catch (AssertionError e)
{
throw e;
}
catch (Throwable e)
{
assertNull(expected);
}
}
}

View File

@ -119,9 +119,8 @@ public class MultiPartByteRangesTest
assertNotNull(contentType); assertNotNull(contentType);
String boundary = MultiPart.extractBoundary(contentType); String boundary = MultiPart.extractBoundary(contentType);
MultiPartByteRanges byteRanges = new MultiPartByteRanges(boundary); MultiPartByteRanges.Parser byteRanges = new MultiPartByteRanges.Parser(boundary);
byteRanges.parse(new ByteBufferContentSource(ByteBuffer.wrap(response.getContentBytes()))); MultiPartByteRanges.Parts parts = byteRanges.parse(new ByteBufferContentSource(ByteBuffer.wrap(response.getContentBytes()))).join();
MultiPartByteRanges.Parts parts = byteRanges.join();
assertEquals(3, parts.size()); assertEquals(3, parts.size());
MultiPart.Part part1 = parts.get(0); MultiPart.Part part1 = parts.get(0);

View File

@ -17,8 +17,6 @@ import java.net.InetSocketAddress;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel; import java.nio.channels.SocketChannel;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.http.HttpField;
import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpFields;
@ -44,7 +42,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@ -79,7 +76,8 @@ public class MultiPartFormDataHandlerTest
public boolean handle(Request request, Response response, Callback callback) public boolean handle(Request request, Response response, Callback callback)
{ {
String boundary = MultiPart.extractBoundary(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); String boundary = MultiPart.extractBoundary(request.getHeaders().get(HttpHeader.CONTENT_TYPE));
new MultiPartFormData(boundary).parse(request) new MultiPartFormData.Parser(boundary)
.parse(request)
.whenComplete((parts, failure) -> .whenComplete((parts, failure) ->
{ {
if (parts != null) if (parts != null)
@ -118,72 +116,6 @@ public class MultiPartFormDataHandlerTest
} }
} }
@Test
public void testDelayedUntilFormData() throws Exception
{
DelayedHandler delayedHandler = new DelayedHandler();
CountDownLatch processLatch = new CountDownLatch(1);
delayedHandler.setHandler(new Handler.Abstract.NonBlocking()
{
@Override
public boolean handle(Request request, Response response, Callback callback) throws Exception
{
processLatch.countDown();
MultiPartFormData.Parts parts = (MultiPartFormData.Parts)request.getAttribute(MultiPartFormData.Parts.class.getName());
assertNotNull(parts);
MultiPart.Part part = parts.get(0);
Content.copy(part.getContentSource(), response, callback);
return true;
}
});
start(delayedHandler);
try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort())))
{
String contentBegin = """
--A1B2C3
Content-Disposition: form-data; name="part"
""";
String contentMiddle = """
0123456789\
""";
String contentEnd = """
ABCDEF
--A1B2C3--
""";
String header = """
POST / HTTP/1.1
Host: localhost
Content-Type: multipart/form-data; boundary=A1B2C3
Content-Length: $L
""".replace("$L", String.valueOf(contentBegin.length() + contentMiddle.length() + contentEnd.length()));
client.write(UTF_8.encode(header));
client.write(UTF_8.encode(contentBegin));
// Verify that the handler has not been called yet.
assertFalse(processLatch.await(1, TimeUnit.SECONDS));
client.write(UTF_8.encode(contentMiddle));
// Verify that the handler has not been called yet.
assertFalse(processLatch.await(1, TimeUnit.SECONDS));
// Finish to send the content.
client.write(UTF_8.encode(contentEnd));
// Verify that the handler has been called.
assertTrue(processLatch.await(5, TimeUnit.SECONDS));
HttpTester.Response response = HttpTester.parseResponse(HttpTester.from(client));
assertNotNull(response);
assertEquals(HttpStatus.OK_200, response.getStatus());
assertEquals("0123456789ABCDEF", response.getContent());
}
}
@Test @Test
public void testEchoMultiPart() throws Exception public void testEchoMultiPart() throws Exception
{ {
@ -193,13 +125,15 @@ public class MultiPartFormDataHandlerTest
public boolean handle(Request request, Response response, Callback callback) public boolean handle(Request request, Response response, Callback callback)
{ {
String boundary = MultiPart.extractBoundary(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); String boundary = MultiPart.extractBoundary(request.getHeaders().get(HttpHeader.CONTENT_TYPE));
new MultiPartFormData(boundary).parse(request)
new MultiPartFormData.Parser(boundary)
.parse(request)
.whenComplete((parts, failure) -> .whenComplete((parts, failure) ->
{ {
if (parts != null) if (parts != null)
{ {
response.getHeaders().put(HttpHeader.CONTENT_TYPE, "multipart/form-data; boundary=\"%s\"".formatted(parts.getMultiPartFormData().getBoundary())); response.getHeaders().put(HttpHeader.CONTENT_TYPE, "multipart/form-data; boundary=\"%s\"".formatted(boundary));
MultiPartFormData.ContentSource source = new MultiPartFormData.ContentSource(parts.getMultiPartFormData().getBoundary()); MultiPartFormData.ContentSource source = new MultiPartFormData.ContentSource(boundary);
source.setPartHeadersMaxLength(1024); source.setPartHeadersMaxLength(1024);
parts.forEach(source::addPart); parts.forEach(source::addPart);
source.close(); source.close();
@ -310,22 +244,22 @@ public class MultiPartFormDataHandlerTest
String boundary = MultiPart.extractBoundary(value); String boundary = MultiPart.extractBoundary(value);
assertNotNull(boundary); assertNotNull(boundary);
MultiPartFormData formData = new MultiPartFormData(boundary); ByteBufferContentSource byteBufferContentSource = new ByteBufferContentSource(ByteBuffer.wrap(response.getContentBytes()));
MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary);
formData.setFilesDirectory(tempDir); formData.setFilesDirectory(tempDir);
formData.parse(new ByteBufferContentSource(ByteBuffer.wrap(response.getContentBytes()))); try (MultiPartFormData.Parts parts = formData.parse(byteBufferContentSource).join())
MultiPartFormData.Parts parts = formData.join(); {
assertEquals(2, parts.size());
assertEquals(2, parts.size()); MultiPart.Part part1 = parts.get(0);
MultiPart.Part part1 = parts.get(0); assertEquals("part1", part1.getName());
assertEquals("part1", part1.getName()); assertEquals("hello", part1.getContentAsString(UTF_8));
assertEquals("hello", part1.getContentAsString(UTF_8)); MultiPart.Part part2 = parts.get(1);
MultiPart.Part part2 = parts.get(1); assertEquals("part2", part2.getName());
assertEquals("part2", part2.getName()); assertEquals("file2.bin", part2.getFileName());
assertEquals("file2.bin", part2.getFileName()); HttpFields headers2 = part2.getHeaders();
HttpFields headers2 = part2.getHeaders(); assertEquals(2, headers2.size());
assertEquals(2, headers2.size()); assertEquals("application/octet-stream", headers2.get(HttpHeader.CONTENT_TYPE));
assertEquals("application/octet-stream", headers2.get(HttpHeader.CONTENT_TYPE)); }
assertEquals(32, part2.getContentSource().getLength());
} }
} }
} }

View File

@ -175,7 +175,7 @@ public class ResourceHandlerByteRangesTest
String contentType = response.get(HttpHeader.CONTENT_TYPE); String contentType = response.get(HttpHeader.CONTENT_TYPE);
assertThat(contentType, startsWith(responseContentType)); assertThat(contentType, startsWith(responseContentType));
String boundary = MultiPart.extractBoundary(contentType); String boundary = MultiPart.extractBoundary(contentType);
MultiPartByteRanges.Parts parts = new MultiPartByteRanges(boundary) MultiPartByteRanges.Parts parts = new MultiPartByteRanges.Parser(boundary)
.parse(new ByteBufferContentSource(response.getContentByteBuffer())) .parse(new ByteBufferContentSource(response.getContentByteBuffer()))
.join(); .join();
assertEquals(2, parts.size()); assertEquals(2, parts.size());

View File

@ -1984,11 +1984,8 @@ public class GzipHandlerTest
public boolean handle(Request request, Response response, Callback callback) throws Exception public boolean handle(Request request, Response response, Callback callback) throws Exception
{ {
response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain"); response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain");
Fields queryParameters = Request.extractQueryParameters(request); Fields queryParameters = Request.extractQueryParameters(request);
FormFields futureFormFields = new FormFields(request, StandardCharsets.UTF_8, -1, -1); Fields formParameters = FormFields.from(request, UTF_8, -1, -1).get();
futureFormFields.run();
Fields formParameters = futureFormFields.get();
Fields parameters = Fields.combine(queryParameters, formParameters); Fields parameters = Fields.combine(queryParameters, formParameters);
String dump = parameters.stream().map(f -> "%s: %s\n".formatted(f.getName(), f.getValue())).collect(Collectors.joining()); String dump = parameters.stream().map(f -> "%s: %s\n".formatted(f.getName(), f.getValue())).collect(Collectors.joining());

View File

@ -764,7 +764,7 @@ public class QuickStartGeneratorConfiguration extends AbstractConfiguration
} }
//multipart-config //multipart-config
MultipartConfigElement multipartConfig = ((ServletHolder.Registration)holder.getRegistration()).getMultipartConfig(); MultipartConfigElement multipartConfig = holder.getRegistration().getMultipartConfigElement();
if (multipartConfig != null) if (multipartConfig != null)
{ {
out.openTag("multipart-config", origin(md, holder.getName() + ".servlet.multipart-config")); out.openTag("multipart-config", origin(md, holder.getName() + ".servlet.multipart-config"));

View File

@ -309,6 +309,15 @@ public class Dispatcher implements RequestDispatcher
{ {
return null; return null;
} }
case ServletContextRequest.MULTIPART_CONFIG_ELEMENT ->
{
// If we already have future parts, return the configuration of the wrapped request.
if (super.getAttribute(ServletMultiPartFormData.class.getName()) != null)
return super.getAttribute(name);
// otherwise, return the configuration of this mapping
return _mappedServlet.getServletHolder().getMultipartConfigElement();
}
default -> default ->
{ {
return super.getAttribute(name); return super.getAttribute(name);

View File

@ -0,0 +1,83 @@
//
// ========================================================================
// 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.ee10.servlet;
import java.util.concurrent.CompletableFuture;
import jakarta.servlet.ServletRequest;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.server.FormFields;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.util.Callback;
/**
* Handler to eagerly and asynchronously read and parse {@link MimeTypes.Type#FORM_ENCODED} and
* {@link MimeTypes.Type#MULTIPART_FORM_DATA} content prior to invoking the {@link ServletHandler},
* which can then consume them with blocking APIs but without blocking.
* @see FormFields#from(Request)
* @see ServletMultiPartFormData#from(ServletRequest)
*/
public class EagerFormHandler extends Handler.Wrapper
{
public EagerFormHandler()
{
this(null);
}
public EagerFormHandler(Handler handler)
{
super(handler);
}
@Override
public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback) throws Exception
{
String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE);
if (contentType == null)
return super.handle(request, response, callback);
MimeTypes.Type mimeType = MimeTypes.getBaseType(contentType);
if (mimeType == null)
return super.handle(request, response, callback);
CompletableFuture<?> future = switch (mimeType)
{
case FORM_ENCODED -> FormFields.from(request);
case MULTIPART_FORM_DATA -> ServletMultiPartFormData.from(Request.as(request, ServletContextRequest.class).getServletApiRequest(), contentType);
default -> null;
};
if (future == null)
return super.handle(request, response, callback);
future.whenComplete((result, failure) ->
{
// The result and failure are not handled here. Rather we call the next handler
// to allow the normal processing to handle the result or failure, which will be
// provided via the attribute to ServletApiRequest#getParts()
try
{
if (!super.handle(request, response, callback))
callback.failed(new IllegalStateException("Not Handled"));
}
catch (Throwable x)
{
callback.failed(x);
}
});
return true;
}
}

View File

@ -34,11 +34,11 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import jakarta.servlet.AsyncContext; import jakarta.servlet.AsyncContext;
import jakarta.servlet.DispatcherType; import jakarta.servlet.DispatcherType;
import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.RequestDispatcher; import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletConnection; import jakarta.servlet.ServletConnection;
import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContext;
@ -489,33 +489,26 @@ public class ServletApiRequest implements HttpServletRequest
{ {
if (_parts == null) if (_parts == null)
{ {
String contentType = getContentType(); try
if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.valueParameters(contentType, null)))
throw new ServletException("Unsupported Content-Type [%s], expected [%s]".formatted(contentType, MimeTypes.Type.MULTIPART_FORM_DATA.asString()));
MultipartConfigElement config = (MultipartConfigElement)getAttribute(ServletContextRequest.MULTIPART_CONFIG_ELEMENT);
if (config == null)
throw new IllegalStateException("No multipart config for servlet");
ServletContextHandler contextHandler = getServletRequestInfo().getServletContext().getServletContextHandler();
int maxFormContentSize = contextHandler.getMaxFormContentSize();
int maxFormKeys = contextHandler.getMaxFormKeys();
_parts = ServletMultiPartFormData.from(this, maxFormKeys);
Collection<Part> parts = _parts.getParts();
String formCharset = null;
Part charsetPart = _parts.getPart("_charset_");
if (charsetPart != null)
{ {
try (InputStream is = charsetPart.getInputStream()) CompletableFuture<ServletMultiPartFormData.Parts> futureServletMultiPartFormData = ServletMultiPartFormData.from(this);
{
formCharset = IO.toString(is, StandardCharsets.UTF_8);
}
}
/* _parts = futureServletMultiPartFormData.get();
Select Charset to use for this part. (NOTE: charset behavior is for the part value only and not the part header/field names)
Collection<Part> parts = _parts.getParts();
String formCharset = null;
Part charsetPart = _parts.getPart("_charset_");
if (charsetPart != null)
{
try (InputStream is = charsetPart.getInputStream())
{
formCharset = IO.toString(is, StandardCharsets.UTF_8);
}
}
/*
Select Charset to use for this part. (NOTE: charset behavior is for the part value only and not the part header/field names)
1. Use the part specific charset as provided in that part's Content-Type header; else 1. Use the part specific charset as provided in that part's Content-Type header; else
2. Use the overall default charset. Determined by: 2. Use the overall default charset. Determined by:
a. if part name _charset_ exists, use that part's value. a. if part name _charset_ exists, use that part's value.
@ -523,38 +516,66 @@ public class ServletApiRequest implements HttpServletRequest
(note, this can be either from the charset field on the request Content-Type (note, this can be either from the charset field on the request Content-Type
header, or from a manual call to request.setCharacterEncoding()) header, or from a manual call to request.setCharacterEncoding())
c. use utf-8. c. use utf-8.
*/ */
Charset defaultCharset; Charset defaultCharset;
if (formCharset != null) if (formCharset != null)
defaultCharset = Charset.forName(formCharset); defaultCharset = Charset.forName(formCharset);
else if (getCharacterEncoding() != null) else if (getCharacterEncoding() != null)
defaultCharset = Charset.forName(getCharacterEncoding()); defaultCharset = Charset.forName(getCharacterEncoding());
else else
defaultCharset = StandardCharsets.UTF_8; defaultCharset = StandardCharsets.UTF_8;
long formContentSize = 0; // Recheck some constraints here, just in case the preloaded parts were not properly configured.
for (Part p : parts) ServletContextHandler servletContextHandler = getServletRequestInfo().getServletContext().getServletContextHandler();
{ long maxFormContentSize = servletContextHandler.getMaxFormContentSize();
if (p.getSubmittedFileName() == null) int maxFormKeys = servletContextHandler.getMaxFormKeys();
long formContentSize = 0;
int count = 0;
for (Part p : parts)
{ {
formContentSize = Math.addExact(formContentSize, p.getSize()); if (maxFormKeys > 0 && ++count > maxFormKeys)
if (maxFormContentSize >= 0 && formContentSize > maxFormContentSize) throw new IllegalStateException("Too many form keys > " + maxFormKeys);
throw new IllegalStateException("Form is larger than max length " + maxFormContentSize);
// Servlet Spec 3.0 pg 23, parts without filename must be put into params. if (p.getSubmittedFileName() == null)
String charset = null;
if (p.getContentType() != null)
charset = MimeTypes.getCharsetFromContentType(p.getContentType());
try (InputStream is = p.getInputStream())
{ {
String content = IO.toString(is, charset == null ? defaultCharset : Charset.forName(charset)); formContentSize = Math.addExact(formContentSize, p.getSize());
if (_contentParameters == null) if (maxFormContentSize >= 0 && formContentSize > maxFormContentSize)
_contentParameters = new Fields(); throw new IllegalStateException("Form is larger than max length " + maxFormContentSize);
_contentParameters.add(p.getName(), content);
// Servlet Spec 3.0 pg 23, parts without filename must be put into params.
String charset = null;
if (p.getContentType() != null)
charset = MimeTypes.getCharsetFromContentType(p.getContentType());
try (InputStream is = p.getInputStream())
{
String content = IO.toString(is, charset == null ? defaultCharset : Charset.forName(charset));
if (_contentParameters == null)
_contentParameters = new Fields();
_contentParameters.add(p.getName(), content);
}
} }
} }
} }
catch (Throwable t)
{
if (LOG.isDebugEnabled())
LOG.debug("getParts", t);
Throwable cause;
if (t instanceof ExecutionException ee)
cause = ee.getCause();
else if (t instanceof ServletException se)
cause = se.getCause();
else
cause = t;
if (cause instanceof IOException ioException)
throw ioException;
throw new ServletException(new BadMessageException("bad multipart", cause));
}
} }
return _parts.getParts(); return _parts.getParts();
@ -654,6 +675,7 @@ public class ServletApiRequest implements HttpServletRequest
if (_async != null) if (_async != null)
{ {
// This switch works by allowing the attribute to get underneath any dispatch wrapper. // This switch works by allowing the attribute to get underneath any dispatch wrapper.
// Note that there are further servlet specific attributes in ServletContextRequest
return switch (name) return switch (name)
{ {
case AsyncContext.ASYNC_REQUEST_URI -> getRequestURI(); case AsyncContext.ASYNC_REQUEST_URI -> getRequestURI();
@ -867,13 +889,24 @@ public class ServletApiRequest implements HttpServletRequest
{ {
getParts(); getParts();
} }
catch (IOException | ServletException e) catch (IOException e)
{ {
String msg = "Unable to extract content parameters"; String msg = "Unable to extract content parameters";
if (LOG.isDebugEnabled()) if (LOG.isDebugEnabled())
LOG.debug(msg, e); LOG.debug(msg, e);
throw new RuntimeIOException(msg, e); throw new RuntimeIOException(msg, e);
} }
catch (ServletException e)
{
Throwable cause = e.getCause();
if (cause instanceof BadMessageException badMessageException)
throw badMessageException;
String msg = "Unable to extract content parameters";
if (LOG.isDebugEnabled())
LOG.debug(msg, e);
throw new RuntimeIOException(msg, e);
}
} }
else else
{ {

View File

@ -32,6 +32,7 @@ import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.UriCompliance; import org.eclipse.jetty.http.UriCompliance;
import org.eclipse.jetty.http.pathmap.MatchedResource; import org.eclipse.jetty.http.pathmap.MatchedResource;
import org.eclipse.jetty.server.FormFields;
import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Response;
import org.eclipse.jetty.server.SecureRequestCustomizer; import org.eclipse.jetty.server.SecureRequestCustomizer;
@ -239,6 +240,9 @@ public class ServletContextRequest extends ContextRequest implements ServletCont
case "jakarta.servlet.request.key_size" -> super.getAttribute(SecureRequestCustomizer.KEY_SIZE_ATTRIBUTE); case "jakarta.servlet.request.key_size" -> super.getAttribute(SecureRequestCustomizer.KEY_SIZE_ATTRIBUTE);
case "jakarta.servlet.request.ssl_session_id" -> super.getAttribute(SecureRequestCustomizer.SSL_SESSION_ID_ATTRIBUTE); case "jakarta.servlet.request.ssl_session_id" -> super.getAttribute(SecureRequestCustomizer.SSL_SESSION_ID_ATTRIBUTE);
case "jakarta.servlet.request.X509Certificate" -> super.getAttribute(SecureRequestCustomizer.PEER_CERTIFICATES_ATTRIBUTE); case "jakarta.servlet.request.X509Certificate" -> super.getAttribute(SecureRequestCustomizer.PEER_CERTIFICATES_ATTRIBUTE);
case ServletContextRequest.MULTIPART_CONFIG_ELEMENT -> _matchedResource.getResource().getServletHolder().getMultipartConfigElement();
case FormFields.MAX_FIELDS_ATTRIBUTE -> getServletContext().getServletContextHandler().getMaxFormKeys();
case FormFields.MAX_LENGTH_ATTRIBUTE -> getServletContext().getServletContextHandler().getMaxFormContentSize();
default -> super.getAttribute(name); default -> super.getAttribute(name);
}; };
} }
@ -255,6 +259,12 @@ public class ServletContextRequest extends ContextRequest implements ServletCont
names.add("jakarta.servlet.request.ssl_session_id"); names.add("jakarta.servlet.request.ssl_session_id");
if (names.contains(SecureRequestCustomizer.PEER_CERTIFICATES_ATTRIBUTE)) if (names.contains(SecureRequestCustomizer.PEER_CERTIFICATES_ATTRIBUTE))
names.add("jakarta.servlet.request.X509Certificate"); names.add("jakarta.servlet.request.X509Certificate");
if (_matchedResource.getResource().getServletHolder().getMultipartConfigElement() != null)
names.add(ServletContextRequest.MULTIPART_CONFIG_ELEMENT);
if (getServletContext().getServletContextHandler().getMaxFormKeys() >= 0)
names.add(FormFields.MAX_FIELDS_ATTRIBUTE);
if (getServletContext().getServletContextHandler().getMaxFormContentSize() >= 0L)
names.add(FormFields.MAX_FIELDS_ATTRIBUTE);
return names; return names;
} }

View File

@ -74,7 +74,7 @@ public class ServletHolder extends Holder<Servlet> implements Comparable<Servlet
private Map<String, String> _roleMap; private Map<String, String> _roleMap;
private String _forcedPath; private String _forcedPath;
private String _runAsRole; private String _runAsRole;
private ServletRegistration.Dynamic _registration; private ServletHolder.Registration _registration;
private JspContainer _jspContainer; private JspContainer _jspContainer;
private volatile Servlet _servlet; private volatile Servlet _servlet;
@ -155,6 +155,11 @@ public class ServletHolder extends Holder<Servlet> implements Comparable<Servlet
setHeldClass(servlet); setHeldClass(servlet);
} }
public MultipartConfigElement getMultipartConfigElement()
{
return _registration == null ? null : _registration.getMultipartConfigElement();
}
/** /**
* @return The unavailable exception or null if not unavailable * @return The unavailable exception or null if not unavailable
*/ */
@ -710,14 +715,6 @@ public class ServletHolder extends Holder<Servlet> implements Comparable<Servlet
{ {
// Ensure the servlet is initialized prior to any filters being invoked // Ensure the servlet is initialized prior to any filters being invoked
getServlet(); getServlet();
// Check for multipart config
if (_registration != null)
{
MultipartConfigElement mpce = ((Registration)_registration).getMultipartConfig();
if (mpce != null)
request.setAttribute(ServletContextRequest.MULTIPART_CONFIG_ELEMENT, mpce);
}
} }
/** /**
@ -919,7 +916,7 @@ public class ServletHolder extends Holder<Servlet> implements Comparable<Servlet
public class Registration extends HolderRegistration implements ServletRegistration.Dynamic public class Registration extends HolderRegistration implements ServletRegistration.Dynamic
{ {
protected MultipartConfigElement _multipartConfig; protected MultipartConfigElement _multipartConfigElement;
@Override @Override
public Set<String> addMapping(String... urlPatterns) public Set<String> addMapping(String... urlPatterns)
@ -994,12 +991,12 @@ public class ServletHolder extends Holder<Servlet> implements Comparable<Servlet
@Override @Override
public void setMultipartConfig(MultipartConfigElement element) public void setMultipartConfig(MultipartConfigElement element)
{ {
_multipartConfig = element; _multipartConfigElement = element;
} }
public MultipartConfigElement getMultipartConfig() public MultipartConfigElement getMultipartConfigElement()
{ {
return _multipartConfig; return _multipartConfigElement;
} }
@Override @Override
@ -1015,7 +1012,7 @@ public class ServletHolder extends Holder<Servlet> implements Comparable<Servlet
} }
} }
public ServletRegistration.Dynamic getRegistration() public ServletHolder.Registration getRegistration()
{ {
if (_registration == null) if (_registration == null)
_registration = new Registration(); _registration = new Registration();

View File

@ -16,31 +16,32 @@ package org.eclipse.jetty.ee10.servlet;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import jakarta.servlet.MultipartConfigElement; import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.ServletContext; import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.Part; import jakarta.servlet.http.Part;
import org.eclipse.jetty.http.HttpField;
import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.http.MultiPart; import org.eclipse.jetty.http.MultiPart;
import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.http.MultiPartFormData;
import org.eclipse.jetty.io.AbstractConnection; import org.eclipse.jetty.io.AbstractConnection;
import org.eclipse.jetty.io.ByteBufferPool; import org.eclipse.jetty.io.ByteBufferPool;
import org.eclipse.jetty.io.Connection; import org.eclipse.jetty.io.Connection;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.RetainableByteBuffer; import org.eclipse.jetty.io.content.InputStreamContentSource;
import org.eclipse.jetty.server.ConnectionMetaData; import org.eclipse.jetty.server.ConnectionMetaData;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.StringUtil;
/** /**
* <p>Servlet specific class for multipart content support.</p> * <p>Servlet specific class for multipart content support.</p>
* <p>Use {@link #from(ServletApiRequest)} to * <p>Use {@link #from(ServletRequest)} to
* parse multipart request content into a {@link Parts} object that can * parse multipart request content into a {@link Parts} object that can
* be used to access Servlet {@link Part} objects.</p> * be used to access Servlet {@link Part} objects.</p>
* *
@ -49,106 +50,103 @@ import org.eclipse.jetty.util.StringUtil;
public class ServletMultiPartFormData public class ServletMultiPartFormData
{ {
/** /**
* <p>Parses the request content assuming it is a multipart content, * Get future {@link ServletMultiPartFormData.Parts} from a servlet request.
* and returns a {@link Parts} objects that can be used to access * @param servletRequest A servlet request
* individual {@link Part}s.</p> * @return A future {@link ServletMultiPartFormData.Parts}, which may have already been created and/or completed.
* * @see #from(ServletRequest, String)
* @param request the HTTP request with multipart content
* @return a {@link Parts} object to access the individual {@link Part}s
* @throws IOException if reading the request content fails
* @see org.eclipse.jetty.server.handler.DelayedHandler
*/ */
public static Parts from(ServletApiRequest request) throws IOException public static CompletableFuture<Parts> from(ServletRequest servletRequest)
{ {
return from(request, ServletContextHandler.DEFAULT_MAX_FORM_KEYS); return from(servletRequest, servletRequest.getContentType());
} }
/** /**
* <p>Parses the request content assuming it is a multipart content, * Get future {@link ServletMultiPartFormData.Parts} from a servlet request.
* and returns a {@link Parts} objects that can be used to access * @param servletRequest A servlet request
* individual {@link Part}s.</p> * @param contentType The contentType, passed as an optimization as it has likely already been retrieved.
* * @return A future {@link ServletMultiPartFormData.Parts}, which may have already been created and/or completed.
* @param request the HTTP request with multipart content
* @return a {@link Parts} object to access the individual {@link Part}s
* @throws IOException if reading the request content fails
* @see org.eclipse.jetty.server.handler.DelayedHandler
*/ */
public static Parts from(ServletApiRequest request, int maxParts) throws IOException public static CompletableFuture<Parts> from(ServletRequest servletRequest, String contentType)
{ {
try // Look for an existing future (we use the future here rather than the parts as it can remember any failure).
@SuppressWarnings("unchecked")
CompletableFuture<Parts> futureServletParts = (CompletableFuture<Parts>)servletRequest.getAttribute(ServletMultiPartFormData.class.getName());
if (futureServletParts == null)
{ {
// Look for a previously read and parsed MultiPartFormData from the DelayedHandler. // No existing parts, so we need to try to read them ourselves
MultiPartFormData.Parts parts = (MultiPartFormData.Parts)request.getAttribute(MultiPartFormData.Parts.class.getName());
if (parts != null)
return new Parts(parts);
// TODO set the files directory // Is this servlet a valid target for Multipart?
return new ServletMultiPartFormData().parse(request, maxParts); MultipartConfigElement config = (MultipartConfigElement)servletRequest.getAttribute(ServletContextRequest.MULTIPART_CONFIG_ELEMENT);
} if (config == null)
catch (Throwable x) return CompletableFuture.failedFuture(new IllegalStateException("No multipart configuration element"));
{
throw IO.rethrow(x);
}
}
private Parts parse(ServletApiRequest request, int maxParts) throws IOException // Are we the right content type to produce our own parts?
{ if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.valueParameters(contentType, null)))
MultipartConfigElement config = (MultipartConfigElement)request.getAttribute(ServletContextRequest.MULTIPART_CONFIG_ELEMENT); return CompletableFuture.failedFuture(new IllegalStateException("Not multipart Content-Type"));
if (config == null)
throw new IllegalStateException("No multipart configuration element");
String boundary = MultiPart.extractBoundary(request.getContentType()); // Do we have a boundary?
if (boundary == null) String boundary = MultiPart.extractBoundary(servletRequest.getContentType());
throw new IllegalStateException("No multipart boundary parameter in Content-Type"); if (boundary == null)
return CompletableFuture.failedFuture(new IllegalStateException("No multipart boundary parameter in Content-Type"));
// Store MultiPartFormData as attribute on request so it is released by the HttpChannel. // Can we access the core request, needed for components (eg buffer pools, temp directory, etc.) as well
MultiPartFormData formData = new MultiPartFormData(boundary); // as IO optimization
formData.setMaxParts(maxParts); ServletContextRequest servletContextRequest = ServletContextRequest.getServletContextRequest(servletRequest);
if (servletContextRequest == null)
return CompletableFuture.failedFuture(new IllegalStateException("No core request"));
File tmpDirFile = (File)request.getServletContext().getAttribute(ServletContext.TEMPDIR); // Get a temporary directory for larger parts.
if (tmpDirFile == null) File filesDirectory = StringUtil.isBlank(config.getLocation())
tmpDirFile = new File(System.getProperty("java.io.tmpdir")); ? servletContextRequest.getContext().getTempDirectory()
String fileLocation = config.getLocation(); : new File(config.getLocation());
if (!StringUtil.isBlank(fileLocation))
tmpDirFile = new File(fileLocation);
formData.setFilesDirectory(tmpDirFile.toPath()); // Look for an existing future MultiPartFormData.Parts
formData.setMaxMemoryFileSize(config.getFileSizeThreshold()); CompletableFuture<MultiPartFormData.Parts> futureFormData = MultiPartFormData.from(servletContextRequest, boundary, parser ->
formData.setMaxFileSize(config.getMaxFileSize());
formData.setMaxLength(config.getMaxRequestSize());
ConnectionMetaData connectionMetaData = request.getRequest().getConnectionMetaData();
formData.setPartHeadersMaxLength(connectionMetaData.getHttpConfiguration().getRequestHeaderSize());
ByteBufferPool byteBufferPool = request.getRequest().getComponents().getByteBufferPool();
Connection connection = connectionMetaData.getConnection();
int bufferSize = connection instanceof AbstractConnection c ? c.getInputBufferSize() : 2048;
InputStream input = request.getInputStream();
while (!formData.isDone())
{
RetainableByteBuffer retainable = byteBufferPool.acquire(bufferSize, false);
boolean readEof = false;
ByteBuffer buffer = retainable.getByteBuffer();
while (BufferUtil.space(buffer) > bufferSize / 2)
{ {
int read = BufferUtil.readFrom(input, buffer); try
if (read < 0)
{ {
readEof = true; // No existing core parts, so we need to configure the parser.
break; ServletContextHandler contextHandler = servletContextRequest.getServletContext().getServletContextHandler();
ByteBufferPool byteBufferPool = servletContextRequest.getComponents().getByteBufferPool();
ConnectionMetaData connectionMetaData = servletContextRequest.getConnectionMetaData();
Connection connection = connectionMetaData.getConnection();
Content.Source source;
if (servletRequest instanceof ServletApiRequest servletApiRequest)
{
source = servletApiRequest.getRequest();
}
else
{
int bufferSize = connection instanceof AbstractConnection c ? c.getInputBufferSize() : 2048;
InputStreamContentSource iscs = new InputStreamContentSource(servletRequest.getInputStream(), byteBufferPool);
iscs.setBufferSize(bufferSize);
source = iscs;
}
parser.setMaxParts(contextHandler.getMaxFormKeys());
parser.setFilesDirectory(filesDirectory.toPath());
parser.setMaxMemoryFileSize(config.getFileSizeThreshold());
parser.setMaxFileSize(config.getMaxFileSize());
parser.setMaxLength(config.getMaxRequestSize());
parser.setPartHeadersMaxLength(connectionMetaData.getHttpConfiguration().getRequestHeaderSize());
// parse the core parts.
return parser.parse(source);
} }
} catch (Throwable failure)
{
return CompletableFuture.failedFuture(failure);
}
});
formData.parse(Content.Chunk.from(buffer, false, retainable::release)); // When available, convert the core parts to servlet parts
if (readEof) futureServletParts = futureFormData.thenApply(formDataParts -> new Parts(filesDirectory.toPath(), formDataParts));
{
formData.parse(Content.Chunk.EOF); // cache the result in attributes.
break; servletRequest.setAttribute(ServletMultiPartFormData.class.getName(), futureServletParts);
}
} }
return futureServletParts;
Parts parts = new Parts(formData.join());
request.setAttribute(Parts.class.getName(), parts);
return parts;
} }
/** /**
@ -158,9 +156,9 @@ public class ServletMultiPartFormData
{ {
private final List<Part> parts = new ArrayList<>(); private final List<Part> parts = new ArrayList<>();
public Parts(MultiPartFormData.Parts parts) public Parts(Path directory, MultiPartFormData.Parts parts)
{ {
parts.forEach(part -> this.parts.add(new ServletPart(parts.getMultiPartFormData(), part))); parts.forEach(part -> this.parts.add(new ServletPart(directory, part)));
} }
public Part getPart(String name) public Part getPart(String name)
@ -179,12 +177,12 @@ public class ServletMultiPartFormData
private static class ServletPart implements Part private static class ServletPart implements Part
{ {
private final MultiPartFormData _formData; private final Path _directory;
private final MultiPart.Part _part; private final MultiPart.Part _part;
private ServletPart(MultiPartFormData formData, MultiPart.Part part) private ServletPart(Path directory, MultiPart.Part part)
{ {
_formData = formData; _directory = directory;
_part = part; _part = part;
} }
@ -222,8 +220,8 @@ public class ServletMultiPartFormData
public void write(String fileName) throws IOException public void write(String fileName) throws IOException
{ {
Path filePath = Path.of(fileName); Path filePath = Path.of(fileName);
if (!filePath.isAbsolute()) if (!filePath.isAbsolute() && Files.isDirectory(_directory))
filePath = _formData.getFilesDirectory().resolve(filePath).normalize(); filePath = _directory.resolve(filePath).normalize();
_part.writeTo(filePath); _part.writeTo(filePath);
} }

View File

@ -17,7 +17,6 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.Socket; import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Arrays; import java.util.Arrays;
@ -51,9 +50,9 @@ import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.http.HttpTester;
import org.eclipse.jetty.http.MultiPart; import org.eclipse.jetty.http.MultiPart;
import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.http.MultiPartFormData;
import org.eclipse.jetty.io.ByteBufferPool;
import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.EofException; import org.eclipse.jetty.io.EofException;
import org.eclipse.jetty.io.content.InputStreamContentSource;
import org.eclipse.jetty.logging.StacklessLogging; import org.eclipse.jetty.logging.StacklessLogging;
import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.ServerConnector;
@ -62,7 +61,8 @@ import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.component.LifeCycle; import org.eclipse.jetty.util.component.LifeCycle;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
@ -93,32 +93,27 @@ public class MultiPartServletTest
tmpDirString = tmpDir.toAbsolutePath().toString(); tmpDirString = tmpDir.toAbsolutePath().toString();
} }
private void start(HttpServlet servlet) throws Exception private void start(HttpServlet servlet, MultipartConfigElement config, boolean eager) throws Exception
{ {
start(servlet, new MultipartConfigElement(tmpDirString, MAX_FILE_SIZE, -1, 0)); config = config == null ? new MultipartConfigElement(tmpDirString, MAX_FILE_SIZE, -1, 0) : config;
} server = new Server(null, null, null);
private void start(HttpServlet servlet, MultipartConfigElement config) throws Exception
{
start(servlet, config, null);
}
private void start(HttpServlet servlet, MultipartConfigElement config, ByteBufferPool bufferPool) throws Exception
{
server = new Server(null, null, bufferPool);
connector = new ServerConnector(server); connector = new ServerConnector(server);
server.addConnector(connector); server.addConnector(connector);
ServletContextHandler contextHandler = new ServletContextHandler("/"); ServletContextHandler servletContextHandler = new ServletContextHandler("/");
ServletHolder servletHolder = new ServletHolder(servlet); ServletHolder servletHolder = new ServletHolder(servlet);
servletHolder.getRegistration().setMultipartConfig(config); servletHolder.getRegistration().setMultipartConfig(config);
contextHandler.addServlet(servletHolder, "/"); servletContextHandler.addServlet(servletHolder, "/");
server.setHandler(servletContextHandler);
GzipHandler gzipHandler = new GzipHandler(); GzipHandler gzipHandler = new GzipHandler();
gzipHandler.addIncludedMimeTypes("multipart/form-data"); gzipHandler.addIncludedMimeTypes("multipart/form-data");
gzipHandler.setMinGzipSize(32); gzipHandler.setMinGzipSize(32);
gzipHandler.setHandler(contextHandler);
server.setHandler(gzipHandler); if (eager)
gzipHandler.setHandler(new EagerFormHandler());
servletContextHandler.insertHandler(gzipHandler);
server.start(); server.start();
@ -134,17 +129,18 @@ public class MultiPartServletTest
IO.delete(tmpDir.toFile()); IO.delete(tmpDir.toFile());
} }
@Test @ParameterizedTest
public void testLargePart() throws Exception @ValueSource(booleans = {true, false})
public void testLargePart(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@Override @Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException protected void service(HttpServletRequest req, HttpServletResponse resp)
{ {
req.getParameterMap(); req.getParameterMap();
} }
}, new MultipartConfigElement(tmpDirString)); }, new MultipartConfigElement(tmpDirString), eager);
OutputStreamRequestContent content = new OutputStreamRequestContent(); OutputStreamRequestContent content = new OutputStreamRequestContent();
MultiPartRequestContent multiPart = new MultiPartRequestContent(); MultiPartRequestContent multiPart = new MultiPartRequestContent();
@ -170,22 +166,23 @@ public class MultiPartServletTest
assert400orEof(listener, responseContent -> assert400orEof(listener, responseContent ->
{ {
assertThat(responseContent, containsString("Unable to parse form content")); assertThat(responseContent, containsString("400: bad"));
assertThat(responseContent, containsString("Form is larger than max length")); assertThat(responseContent, containsString("Form is larger than max length"));
}); });
} }
@Test @ParameterizedTest
public void testManyParts() throws Exception @ValueSource(booleans = {true, false})
public void testManyParts(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@Override @Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException protected void service(HttpServletRequest req, HttpServletResponse resp)
{ {
req.getParameterMap(); req.getParameterMap();
} }
}, new MultipartConfigElement(tmpDirString)); }, new MultipartConfigElement(tmpDirString), eager);
byte[] byteArray = new byte[1024]; byte[] byteArray = new byte[1024];
Arrays.fill(byteArray, (byte)1); Arrays.fill(byteArray, (byte)1);
@ -208,13 +205,14 @@ public class MultiPartServletTest
assert400orEof(listener, responseContent -> assert400orEof(listener, responseContent ->
{ {
assertThat(responseContent, containsString("Unable to parse form content")); assertThat(responseContent, containsString("400: bad"));
assertThat(responseContent, containsString("Form with too many keys")); assertThat(responseContent, containsString("Form with too many keys"));
}); });
} }
@Test @ParameterizedTest
public void testMaxRequestSize() throws Exception @ValueSource(booleans = {true, false})
public void testMaxRequestSize(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@ -223,7 +221,7 @@ public class MultiPartServletTest
{ {
req.getParameterMap(); req.getParameterMap();
} }
}, new MultipartConfigElement(tmpDirString, -1, 1024, 1024 * 1024 * 8)); }, new MultipartConfigElement(tmpDirString, -1, 1024, 1024 * 1024 * 8), eager);
OutputStreamRequestContent content = new OutputStreamRequestContent(); OutputStreamRequestContent content = new OutputStreamRequestContent();
MultiPartRequestContent multiPart = new MultiPartRequestContent(); MultiPartRequestContent multiPart = new MultiPartRequestContent();
@ -282,13 +280,14 @@ public class MultiPartServletTest
checkbody.accept(responseContent); checkbody.accept(responseContent);
} }
@Test @ParameterizedTest
public void testSimpleMultiPart() throws Exception @ValueSource(booleans = {true, false})
public void testSimpleMultiPart(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@Override @Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException protected void service(HttpServletRequest request, HttpServletResponse response1) throws ServletException, IOException
{ {
Collection<Part> parts = request.getParts(); Collection<Part> parts = request.getParts();
assertNotNull(parts); assertNotNull(parts);
@ -298,10 +297,10 @@ public class MultiPartServletTest
Collection<String> headerNames = part.getHeaderNames(); Collection<String> headerNames = part.getHeaderNames();
assertNotNull(headerNames); assertNotNull(headerNames);
assertEquals(2, headerNames.size()); assertEquals(2, headerNames.size());
String content = IO.toString(part.getInputStream(), UTF_8); String content1 = IO.toString(part.getInputStream(), UTF_8);
assertEquals("content1", content); assertEquals("content1", content1);
} }
}); }, null, eager);
try (Socket socket = new Socket("localhost", connector.getLocalPort())) try (Socket socket = new Socket("localhost", connector.getLocalPort()))
{ {
@ -333,21 +332,23 @@ public class MultiPartServletTest
} }
} }
@Test @ParameterizedTest
public void testTempFilesDeletedOnError() throws Exception @ValueSource(booleans = {true, false})
public void testTempFilesDeletedOnError(boolean eager) throws Exception
{ {
byte[] bytes = new byte[2 * MAX_FILE_SIZE]; byte[] bytes = new byte[2 * MAX_FILE_SIZE];
Arrays.fill(bytes, (byte)1); Arrays.fill(bytes, (byte)1);
// Should throw as the max file size is exceeded.
start(new HttpServlet() start(new HttpServlet()
{ {
@Override @Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException protected void service(HttpServletRequest request, HttpServletResponse response1) throws ServletException, IOException
{ {
// Should throw as the max file size is exceeded. // Should throw as the max file size is exceeded.
request.getParts(); request.getParts();
} }
}); }, null, eager);
MultiPartRequestContent multiPart = new MultiPartRequestContent(); MultiPartRequestContent multiPart = new MultiPartRequestContent();
multiPart.addPart(new MultiPart.ContentSourcePart("largePart", "largeFile.bin", HttpFields.EMPTY, new BytesRequestContent(bytes))); multiPart.addPart(new MultiPart.ContentSourcePart("largePart", "largeFile.bin", HttpFields.EMPTY, new BytesRequestContent(bytes)));
@ -361,7 +362,7 @@ public class MultiPartServletTest
.body(multiPart) .body(multiPart)
.send(); .send();
assertEquals(500, response.getStatus()); assertEquals(400, response.getStatus());
assertThat(response.getContentAsString(), containsString("max file size exceeded")); assertThat(response.getContentAsString(), containsString("max file size exceeded"));
} }
@ -370,18 +371,34 @@ public class MultiPartServletTest
assertThat(fileList.length, is(0)); assertThat(fileList.length, is(0));
} }
@Test @ParameterizedTest
public void testMultiPartGzip() throws Exception @ValueSource(booleans = {true, false})
public void testMultiPartGzip(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@Override @Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException protected void service(HttpServletRequest request, HttpServletResponse response1) throws IOException, ServletException
{ {
response.setContentType(request.getContentType()); String contentType1 = request.getContentType();
IO.copy(request.getInputStream(), response.getOutputStream()); response1.setContentType(contentType1);
response1.flushBuffer();
MultiPartRequestContent echoParts = new MultiPartRequestContent(MultiPart.extractBoundary(contentType1));
Collection<Part> servletParts = request.getParts();
for (Part part : servletParts)
{
HttpFields.Mutable partHeaders = HttpFields.build();
for (String h1 : part.getHeaderNames())
partHeaders.add(h1, part.getHeader(h1));
echoParts.addPart(new MultiPart.ContentSourcePart(part.getName(), part.getSubmittedFileName(), partHeaders, new InputStreamContentSource(part.getInputStream())));
}
echoParts.close();
IO.copy(Content.Source.asInputStream(echoParts), response1.getOutputStream());
} }
}); }, null, eager);
// Do not automatically handle gzip. // Do not automatically handle gzip.
client.getContentDecoderFactories().clear(); client.getContentDecoderFactories().clear();
@ -409,19 +426,18 @@ public class MultiPartServletTest
String contentType = headers.get(HttpHeader.CONTENT_TYPE); String contentType = headers.get(HttpHeader.CONTENT_TYPE);
String boundary = MultiPart.extractBoundary(contentType); String boundary = MultiPart.extractBoundary(contentType);
MultiPartFormData formData = new MultiPartFormData(boundary);
formData.setMaxParts(1);
InputStream inputStream = new GZIPInputStream(responseStream.getInputStream()); InputStream inputStream = new GZIPInputStream(responseStream.getInputStream());
formData.parse(Content.Chunk.from(ByteBuffer.wrap(IO.readBytes(inputStream)), true)); MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary);
MultiPartFormData.Parts parts = formData.join(); formData.setMaxParts(1);
MultiPartFormData.Parts parts = formData.parse(new InputStreamContentSource(inputStream)).join();
assertThat(parts.size(), is(1)); assertThat(parts.size(), is(1));
assertThat(parts.get(0).getContentAsString(UTF_8), is(contentString)); assertThat(parts.get(0).getContentAsString(UTF_8), is(contentString));
} }
@Test @ParameterizedTest
public void testDoubleReadFromPart() throws Exception @ValueSource(booleans = {true, false})
public void testDoubleReadFromPart(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@ -435,7 +451,7 @@ public class MultiPartServletTest
resp.getWriter().println("Part: name=" + part.getName() + ", size=" + part.getSize() + ", content=" + IO.toString(part.getInputStream())); resp.getWriter().println("Part: name=" + part.getName() + ", size=" + part.getSize() + ", content=" + IO.toString(part.getInputStream()));
} }
} }
}); }, null, eager);
String contentString = "the quick brown fox jumps over the lazy dog, " + String contentString = "the quick brown fox jumps over the lazy dog, " +
"the quick brown fox jumps over the lazy dog"; "the quick brown fox jumps over the lazy dog";
@ -455,8 +471,9 @@ public class MultiPartServletTest
"Part: name=myPart, size=88, content=the quick brown fox jumps over the lazy dog, the quick brown fox jumps over the lazy dog")); "Part: name=myPart, size=88, content=the quick brown fox jumps over the lazy dog, the quick brown fox jumps over the lazy dog"));
} }
@Test @ParameterizedTest
public void testPartAsParameter() throws Exception @ValueSource(booleans = {true, false})
public void testPartAsParameter(boolean eager) throws Exception
{ {
start(new HttpServlet() start(new HttpServlet()
{ {
@ -471,7 +488,7 @@ public class MultiPartServletTest
resp.getWriter().println("Parameter: " + entry.getKey() + "=" + entry.getValue()[0]); resp.getWriter().println("Parameter: " + entry.getKey() + "=" + entry.getValue()[0]);
} }
} }
}); }, null, eager);
String contentString = "the quick brown fox jumps over the lazy dog, " + String contentString = "the quick brown fox jumps over the lazy dog, " +
"the quick brown fox jumps over the lazy dog"; "the quick brown fox jumps over the lazy dog";

View File

@ -588,7 +588,7 @@ public class StandardDescriptorProcessor extends IterativeDescriptorProcessor
case WebFragment: case WebFragment:
{ {
//another fragment set the value, this fragment's values must match exactly or it is an error //another fragment set the value, this fragment's values must match exactly or it is an error
MultipartConfigElement cfg = ((ServletHolder.Registration)holder.getRegistration()).getMultipartConfig(); MultipartConfigElement cfg = holder.getRegistration().getMultipartConfigElement();
if (cfg.getMaxFileSize() != element.getMaxFileSize()) if (cfg.getMaxFileSize() != element.getMaxFileSize())
throw new IllegalStateException("Conflicting multipart-config max-file-size for servlet " + name + " in " + descriptor.getURI()); throw new IllegalStateException("Conflicting multipart-config max-file-size for servlet " + name + " in " + descriptor.getURI());