/*
* Copyright 2000-2016 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package 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 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.ui.UI;
import com.vaadin.ui.Upload.FailedEvent;
/**
* Handles a file upload request submitted via an Upload component.
*
* @author Vaadin Ltd
* @since 7.1
*/
public class FileUploadHandler implements RequestHandler {
/**
* 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 UTF8 = "UTF-8";
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);
String[] parts = uppUri.split("/", 4); // 0= UIid, 1 = cid, 2= name, 3
// = sec key
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));
UI.setCurrent(uI);
streamVariable = uI.getConnectorTracker()
.getStreamVariable(connectorId, variableName);
String secKey = uI.getConnectorTracker().getSeckey(streamVariable);
if (secKey == null || !secKey.equals(parts[3])) {
// TODO Should rethink error handling
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);
readByte = stream.read();
}
byte[] bytes = bout.toByteArray();
return new String(bytes, 0, bytes.length - 1, UTF8);
}
/**
* 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(UTF8).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 -1l; } } 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 = connector.getUI(); 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(); } // 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 name * @return */ private static String removePath(String filename) { if (filename != null) { filename = filename.replaceAll("^.*[/\\\\]", ""); } return filename; } /** * TODO document * * @param request * @param response * @throws IOException */ protected void sendUploadResponse(VaadinRequest request, VaadinResponse response) throws IOException { response.setContentType("text/html"); 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); }); } }