com.vaadin.server.communication.FileUploadHandler Maven / Gradle / Ivy
/*
* Copyright (C) 2000-2024 Vaadin Ltd
*
* This program is available under Vaadin Commercial License and Service Terms.
*
* See for the full
* license.
*/
package com.vaadin.server.communication;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import com.vaadin.server.ClientConnector;
import com.vaadin.server.NoInputStreamException;
import com.vaadin.server.NoOutputStreamException;
import com.vaadin.server.RequestHandler;
import com.vaadin.server.ServletPortletHelper;
import com.vaadin.server.StreamVariable;
import com.vaadin.server.StreamVariable.StreamingEndEvent;
import com.vaadin.server.StreamVariable.StreamingErrorEvent;
import com.vaadin.server.UploadException;
import com.vaadin.server.VaadinRequest;
import com.vaadin.server.VaadinResponse;
import com.vaadin.server.VaadinSession;
import com.vaadin.shared.ApplicationConstants;
import com.vaadin.ui.UI;
import com.vaadin.ui.Upload.FailedEvent;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* Handles a file upload request submitted via an Upload component.
*
* @author Vaadin Ltd
* @since 7.1
*/
public class FileUploadHandler implements RequestHandler {
public static final int MULTIPART_BOUNDARY_LINE_LIMIT = 20000;
/**
* Stream that extracts content from another stream until the boundary
* string is encountered.
*
* Public only for unit tests, should be considered private for all other
* purposes.
*/
public static class SimpleMultiPartInputStream extends InputStream {
/**
* Counter of how many characters have been matched to boundary string
* from the stream
*/
int matchedCount = -1;
/**
* Used as pointer when returning bytes after partly matched boundary
* string.
*/
int curBoundaryIndex = 0;
/**
* The byte found after a "promising start for boundary"
*/
private int bufferedByte = -1;
private boolean atTheEnd = false;
private final char[] boundary;
private final InputStream realInputStream;
public SimpleMultiPartInputStream(InputStream realInputStream,
String boundaryString) {
boundary = (CRLF + DASHDASH + boundaryString).toCharArray();
this.realInputStream = realInputStream;
}
@Override
public int read() throws IOException {
if (atTheEnd) {
// End boundary reached, nothing more to read
return -1;
} else if (bufferedByte >= 0) {
/* Purge partially matched boundary if there was such */
return getBuffered();
} else if (matchedCount != -1) {
/*
* Special case where last "failed" matching ended with first
* character from boundary string
*/
return matchForBoundary();
} else {
int fromActualStream = realInputStream.read();
if (fromActualStream == -1) {
// unexpected end of stream
throw new IOException(
"The multipart stream ended unexpectedly");
}
if (boundary[0] == fromActualStream) {
/*
* If matches the first character in boundary string, start
* checking if the boundary is fetched.
*/
return matchForBoundary();
}
return fromActualStream;
}
}
/**
* Reads the input to expect a boundary string. Expects that the first
* character has already been matched.
*
* @return -1 if the boundary was matched, else returns the first byte
* from boundary
* @throws IOException
*/
private int matchForBoundary() throws IOException {
matchedCount = 0;
/*
* Going to "buffered mode". Read until full boundary match or a
* different character.
*/
while (true) {
matchedCount++;
if (matchedCount == boundary.length) {
/*
* The whole boundary matched so we have reached the end of
* file
*/
atTheEnd = true;
return -1;
}
int fromActualStream = realInputStream.read();
if (fromActualStream != boundary[matchedCount]) {
/*
* Did not find full boundary, cache the mismatching byte
* and start returning the partially matched boundary.
*/
bufferedByte = fromActualStream;
return getBuffered();
}
}
}
/**
* Returns the partly matched boundary string and the byte following
* that.
*
* @return
* @throws IOException
*/
private int getBuffered() throws IOException {
int b;
if (matchedCount == 0) {
// The boundary has been returned, return the buffered byte.
b = bufferedByte;
bufferedByte = -1;
matchedCount = -1;
} else {
b = boundary[curBoundaryIndex++];
if (curBoundaryIndex == matchedCount) {
// The full boundary has been returned, remaining is the
// char that did not match the boundary.
curBoundaryIndex = 0;
if (bufferedByte != boundary[0]) {
/*
* next call for getBuffered will return the
* bufferedByte that came after the partial boundary
* match
*/
matchedCount = 0;
} else {
/*
* Special case where buffered byte again matches the
* boundaryString. This could be the start of the real
* end boundary.
*/
matchedCount = 0;
bufferedByte = -1;
}
}
}
if (b == -1) {
throw new IOException(
"The multipart stream ended unexpectedly");
}
return b;
}
}
/**
* An UploadInterruptedException will be thrown by an ongoing upload if
* {@link StreamVariable#isInterrupted()} returns true
.
*
* By checking the exception of an {@link StreamingErrorEvent} or
* {@link FailedEvent} against this class, it is possible to determine if an
* upload was interrupted by code or aborted due to any other exception.
*/
public static class UploadInterruptedException extends Exception {
/**
* Constructs an instance of UploadInterruptedException
.
*/
public UploadInterruptedException() {
super("Upload interrupted by other thread");
}
}
/**
* as per RFC 2045, line delimiters in headers are always CRLF, i.e. 13 10
*/
private static final int LF = 10;
private static final String CRLF = "\r\n";
private static final String DASHDASH = "--";
/*
* Same as in apache commons file upload library that was previously used.
*/
private static final int MAX_UPLOAD_BUFFER_SIZE = 4 * 1024;
/* Minimum interval which will be used for streaming progress events. */
public static final int DEFAULT_STREAMING_PROGRESS_EVENT_INTERVAL_MS = 500;
@Override
public boolean handleRequest(VaadinSession session, VaadinRequest request,
VaadinResponse response) throws IOException {
if (!ServletPortletHelper.isFileUploadRequest(request)) {
return false;
}
/*
* URI pattern: APP/UPLOAD/[UIID]/[PID]/[NAME]/[SECKEY] See
* #createReceiverUrl
*/
String pathInfo = request.getPathInfo();
// strip away part until the data we are interested starts
int startOfData = pathInfo
.indexOf(ServletPortletHelper.UPLOAD_URL_PREFIX)
+ ServletPortletHelper.UPLOAD_URL_PREFIX.length();
String uppUri = pathInfo.substring(startOfData);
// 0= UIid, 1= cid, 2= name, 3= sec key
String[] parts = uppUri.split("/", 4);
String uiId = parts[0];
String connectorId = parts[1];
String variableName = parts[2];
// These are retrieved while session is locked
ClientConnector source;
StreamVariable streamVariable;
session.lock();
try {
UI uI = session.getUIById(Integer.parseInt(uiId));
if (uI == null) {
throw new IOException(
"File upload ignored because the UI was not found and stream variable cannot be determined");
}
// Set UI so that it can be used in stream variable clean up
UI.setCurrent(uI);
streamVariable = uI.getConnectorTracker()
.getStreamVariable(connectorId, variableName);
String secKey = uI.getConnectorTracker().getSeckey(streamVariable);
String securityKey = parts[3];
if (secKey == null || !MessageDigest.isEqual(
secKey.getBytes(StandardCharsets.UTF_8),
securityKey.getBytes(StandardCharsets.UTF_8))) {
return true;
}
source = uI.getConnectorTracker().getConnector(connectorId);
} finally {
session.unlock();
}
String contentType = request.getContentType();
if (contentType.contains("boundary")) {
// Multipart requests contain boundary string
doHandleSimpleMultipartFileUpload(session, request, response,
streamVariable, variableName, source,
contentType.split("boundary=")[1]);
} else {
// if boundary string does not exist, the posted file is from
// XHR2.post(File)
doHandleXhrFilePost(session, request, response, streamVariable,
variableName, source, getContentLength(request));
}
return true;
}
private static String readLine(InputStream stream) throws IOException {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
int readByte = stream.read();
while (readByte != LF) {
if (readByte == -1) {
throw new IOException(
"The multipart stream ended unexpectedly");
}
bout.write(readByte);
if (bout.size() > MULTIPART_BOUNDARY_LINE_LIMIT) {
throw new IOException(
"The multipart stream does not contain boundary");
}
readByte = stream.read();
}
byte[] bytes = bout.toByteArray();
return new String(bytes, 0, bytes.length - 1, UTF_8);
}
/**
* Method used to stream content from a multipart request (either from
* servlet or portlet request) to given StreamVariable.
*
* This method takes care of locking the session as needed and does not
* assume the caller has locked the session. This allows the session to be
* locked only when needed and not when handling the upload data.
*
*
* @param session
* The session containing the stream variable
* @param request
* The upload request
* @param response
* The upload response
* @param streamVariable
* The destination stream variable
* @param variableName
* The name of the destination stream variable
* @param owner
* The owner of the stream variable
* @param boundary
* The mime boundary used in the upload request
* @throws IOException
* If there is a problem reading the request or writing the
* response
*/
protected void doHandleSimpleMultipartFileUpload(VaadinSession session,
VaadinRequest request, VaadinResponse response,
StreamVariable streamVariable, String variableName,
ClientConnector owner, String boundary) throws IOException {
// multipart parsing, supports only one file for request, but that is
// fine for our current terminal
final InputStream inputStream = request.getInputStream();
long contentLength = getContentLength(request);
boolean atStart = false;
boolean firstFileFieldFound = false;
String rawfilename = "unknown";
String rawMimeType = "application/octet-stream";
/*
* Read the stream until the actual file starts (empty line). Read
* filename and content type from multipart headers.
*/
while (!atStart) {
String readLine = readLine(inputStream);
contentLength -= (readLine.getBytes(UTF_8).length + CRLF.length());
if (readLine.startsWith("Content-Disposition:")
&& readLine.indexOf("filename=") > 0) {
rawfilename = readLine.replaceAll(".*filename=", "");
char quote = rawfilename.charAt(0);
rawfilename = rawfilename.substring(1);
rawfilename = rawfilename.substring(0,
rawfilename.indexOf(quote));
firstFileFieldFound = true;
} else if (firstFileFieldFound && readLine.isEmpty()) {
atStart = true;
} else if (readLine.startsWith("Content-Type")) {
rawMimeType = readLine.split(": ")[1];
}
}
contentLength -= (boundary.length() + CRLF.length()
+ 2 * DASHDASH.length() + CRLF.length());
/*
* Reads bytes from the underlying stream. Compares the read bytes to
* the boundary string and returns -1 if met.
*
* The matching happens so that if the read byte equals to the first
* char of boundary string, the stream goes to "buffering mode". In
* buffering mode bytes are read until the character does not match the
* corresponding from boundary string or the full boundary string is
* found.
*
* Note, if this is someday needed elsewhere, don't shoot yourself to
* foot and split to a top level helper class.
*/
InputStream simpleMultiPartReader = new SimpleMultiPartInputStream(
inputStream, boundary);
/*
* Should report only the filename even if the browser sends the path
*/
final String filename = removePath(rawfilename);
final String mimeType = rawMimeType;
try {
handleFileUploadValidationAndData(session, simpleMultiPartReader,
streamVariable, filename, mimeType, contentLength, owner,
variableName);
} catch (UploadException e) {
session.getCommunicationManager()
.handleConnectorRelatedException(owner, e);
}
sendUploadResponse(request, response);
}
/*
* request.getContentLength() is limited to "int" by the Servlet
* specification. To support larger file uploads manually evaluate the
* Content-Length header which can contain long values.
*/
private long getContentLength(VaadinRequest request) {
try {
return Long.parseLong(request.getHeader("Content-Length"));
} catch (NumberFormatException e) {
return -1;
}
}
private void handleFileUploadValidationAndData(VaadinSession session,
InputStream inputStream, StreamVariable streamVariable,
String filename, String mimeType, long contentLength,
ClientConnector connector, String variableName)
throws UploadException {
session.lock();
try {
if (connector == null) {
throw new UploadException(
"File upload ignored because the connector for the stream variable was not found");
}
if (!connector.isConnectorEnabled()) {
throw new UploadException("Warning: file upload ignored for "
+ connector.getConnectorId()
+ " because the component was disabled");
}
} finally {
session.unlock();
}
try {
// Store ui reference so we can do cleanup even if connector is
// detached in some event handler
UI ui = UI.getCurrent();
boolean forgetVariable = streamToReceiver(session, inputStream,
streamVariable, filename, mimeType, contentLength);
if (forgetVariable) {
cleanStreamVariable(session, ui, connector, variableName);
}
} catch (Exception e) {
session.lock();
try {
session.getCommunicationManager()
.handleConnectorRelatedException(connector, e);
} finally {
session.unlock();
}
}
}
/**
* Used to stream plain file post (aka XHR2.post(File))
*
* This method takes care of locking the session as needed and does not
* assume the caller has locked the session. This allows the session to be
* locked only when needed and not when handling the upload data.
*
*
* @param session
* The session containing the stream variable
* @param request
* The upload request
* @param response
* The upload response
* @param streamVariable
* The destination stream variable
* @param variableName
* The name of the destination stream variable
* @param owner
* The owner of the stream variable
* @param contentLength
* The length of the request content
* @throws IOException
* If there is a problem reading the request or writing the
* response
*/
protected void doHandleXhrFilePost(VaadinSession session,
VaadinRequest request, VaadinResponse response,
StreamVariable streamVariable, String variableName,
ClientConnector owner, long contentLength) throws IOException {
// These are unknown in filexhr ATM, maybe add to Accept header that
// is accessible in portlets
final String filename = "unknown";
final String mimeType = filename;
final InputStream stream = request.getInputStream();
try {
handleFileUploadValidationAndData(session, stream, streamVariable,
filename, mimeType, contentLength, owner, variableName);
} catch (UploadException e) {
session.getCommunicationManager()
.handleConnectorRelatedException(owner, e);
}
sendUploadResponse(request, response);
}
/**
* @param in
* @param streamVariable
* @param filename
* @param type
* @param contentLength
* @return true if the streamvariable has informed that the terminal can
* forget this variable
* @throws UploadException
*/
protected final boolean streamToReceiver(VaadinSession session,
final InputStream in, StreamVariable streamVariable,
String filename, String type, long contentLength)
throws UploadException {
if (streamVariable == null) {
throw new IllegalStateException(
"StreamVariable for the post not found");
}
OutputStream out = null;
long totalBytes = 0;
StreamingStartEventImpl startedEvent = new StreamingStartEventImpl(
filename, type, contentLength);
try {
boolean listenProgress;
session.lock();
try {
streamVariable.streamingStarted(startedEvent);
out = streamVariable.getOutputStream();
listenProgress = streamVariable.listenProgress();
} finally {
session.unlock();
}
// Gets the output target stream
if (out == null) {
throw new NoOutputStreamException();
}
if (null == in) {
// No file, for instance non-existent filename in html upload
throw new NoInputStreamException();
}
final byte[] buffer = new byte[MAX_UPLOAD_BUFFER_SIZE];
long lastStreamingEvent = 0;
int bytesReadToBuffer = 0;
do {
bytesReadToBuffer = in.read(buffer);
if (bytesReadToBuffer > 0) {
out.write(buffer, 0, bytesReadToBuffer);
totalBytes += bytesReadToBuffer;
}
if (listenProgress) {
long now = System.currentTimeMillis();
// to avoid excessive session locking and event storms,
// events are sent in intervals, or at the end of the file.
if (lastStreamingEvent + getProgressEventInterval() <= now
|| bytesReadToBuffer <= 0) {
lastStreamingEvent = now;
session.lock();
try {
StreamingProgressEventImpl progressEvent = new StreamingProgressEventImpl(
filename, type, contentLength, totalBytes);
streamVariable.onProgress(progressEvent);
} finally {
session.unlock();
}
}
}
if (streamVariable.isInterrupted()) {
throw new UploadInterruptedException();
}
} while (bytesReadToBuffer > 0);
// upload successful
out.close();
StreamingEndEvent event = new StreamingEndEventImpl(filename, type,
totalBytes);
session.lock();
try {
streamVariable.streamingFinished(event);
} finally {
session.unlock();
}
} catch (UploadInterruptedException e) {
// Download interrupted by application code
tryToCloseStream(out);
StreamingErrorEvent event = new StreamingErrorEventImpl(filename,
type, contentLength, totalBytes, e);
session.lock();
try {
streamVariable.streamingFailed(event);
} finally {
session.unlock();
}
return true;
// Note, we are not throwing interrupted exception forward as it is
// not a terminal level error like all other exception.
} catch (final Exception e) {
tryToCloseStream(out);
session.lock();
try {
StreamingErrorEvent event = new StreamingErrorEventImpl(
filename, type, contentLength, totalBytes, e);
streamVariable.streamingFailed(event);
// throw exception for terminal to be handled (to be passed to
// terminalErrorHandler)
throw new UploadException(e);
} finally {
session.unlock();
}
}
return startedEvent.isDisposed();
}
/**
* To prevent event storming, streaming progress events are sent in this
* interval rather than every time the buffer is filled. This fixes #13155.
* To adjust this value override the method, and register your own handler
* in VaadinService.createRequestHandlers(). The default is 500ms, and
* setting it to 0 effectively restores the old behavior.
*/
protected int getProgressEventInterval() {
return DEFAULT_STREAMING_PROGRESS_EVENT_INTERVAL_MS;
}
static void tryToCloseStream(OutputStream out) {
try {
// try to close output stream (e.g. file handle)
if (out != null) {
out.close();
}
} catch (IOException e1) {
// NOP
}
}
/**
* Removes any possible path information from the filename and returns the
* filename. Separators / and \\ are used.
*
* @param filename
* @return
*/
private static String removePath(String filename) {
if (filename != null) {
filename = filename.replaceAll("^.*[/\\\\]", "");
}
return filename;
}
/**
* Sends the upload response.
*
* @param request
* @param response
* @throws IOException
*/
protected void sendUploadResponse(VaadinRequest request,
VaadinResponse response) throws IOException {
response.setContentType(
ApplicationConstants.CONTENT_TYPE_TEXT_HTML_UTF_8);
try (OutputStream out = response.getOutputStream()) {
final PrintWriter outWriter = new PrintWriter(
new BufferedWriter(new OutputStreamWriter(out, UTF_8)));
outWriter.print("download handled");
outWriter.flush();
}
}
private void cleanStreamVariable(VaadinSession session, final UI ui,
final ClientConnector owner, final String variableName) {
session.accessSynchronously(() -> {
ui.getConnectorTracker().cleanStreamVariable(owner.getConnectorId(),
variableName);
// in case of automatic push mode, the client connector
// could already have refreshed its StreamVariable
// in the ConnectorTracker. For instance, the Upload component
// adds its stream variable in its paintContent method, which is
// called (indirectly) on each session unlock in case of automatic
// pushes.
// To cover this case, mark the client connector as dirty, so that
// the unlock after this runnable refreshes the StreamVariable
// again.
owner.markAsDirty();
});
}
}