/* * 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.shared.ApplicationConstants; 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 <code>true</code>. * * 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 <code>UploadInterruptedException</code>. */ 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. * <p> * 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. * </p> * * @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)) * <p> * 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. * </p> * * @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 filename * @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( 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("<html><body>download handled</body></html>"); outWriter.flush(); } } private void cleanStreamVariable(VaadinSession session, final UI ui, final ClientConnector owner, final String variableName) { session.accessSynchronously(() -> { ui.getConnectorTracker().cleanStreamVariable(owner.getConnectorId(), variableName); }); } }