You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FileUploadHandler.java 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. /*
  2. * Copyright 2000-2016 Vaadin Ltd.
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License"); you may not
  5. * use this file except in compliance with the License. You may obtain a copy of
  6. * the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  12. * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  13. * License for the specific language governing permissions and limitations under
  14. * the License.
  15. */
  16. package com.vaadin.server.communication;
  17. import java.io.BufferedWriter;
  18. import java.io.ByteArrayOutputStream;
  19. import java.io.IOException;
  20. import java.io.InputStream;
  21. import java.io.OutputStream;
  22. import java.io.OutputStreamWriter;
  23. import java.io.PrintWriter;
  24. import com.vaadin.server.ClientConnector;
  25. import com.vaadin.server.NoInputStreamException;
  26. import com.vaadin.server.NoOutputStreamException;
  27. import com.vaadin.server.RequestHandler;
  28. import com.vaadin.server.ServletPortletHelper;
  29. import com.vaadin.server.StreamVariable;
  30. import com.vaadin.server.StreamVariable.StreamingEndEvent;
  31. import com.vaadin.server.StreamVariable.StreamingErrorEvent;
  32. import com.vaadin.server.UploadException;
  33. import com.vaadin.server.VaadinRequest;
  34. import com.vaadin.server.VaadinResponse;
  35. import com.vaadin.server.VaadinSession;
  36. import com.vaadin.ui.UI;
  37. import com.vaadin.ui.Upload.FailedEvent;
  38. /**
  39. * Handles a file upload request submitted via an Upload component.
  40. *
  41. * @author Vaadin Ltd
  42. * @since 7.1
  43. */
  44. public class FileUploadHandler implements RequestHandler {
  45. /**
  46. * Stream that extracts content from another stream until the boundary
  47. * string is encountered.
  48. *
  49. * Public only for unit tests, should be considered private for all other
  50. * purposes.
  51. */
  52. public static class SimpleMultiPartInputStream extends InputStream {
  53. /**
  54. * Counter of how many characters have been matched to boundary string
  55. * from the stream
  56. */
  57. int matchedCount = -1;
  58. /**
  59. * Used as pointer when returning bytes after partly matched boundary
  60. * string.
  61. */
  62. int curBoundaryIndex = 0;
  63. /**
  64. * The byte found after a "promising start for boundary"
  65. */
  66. private int bufferedByte = -1;
  67. private boolean atTheEnd = false;
  68. private final char[] boundary;
  69. private final InputStream realInputStream;
  70. public SimpleMultiPartInputStream(InputStream realInputStream,
  71. String boundaryString) {
  72. boundary = (CRLF + DASHDASH + boundaryString).toCharArray();
  73. this.realInputStream = realInputStream;
  74. }
  75. @Override
  76. public int read() throws IOException {
  77. if (atTheEnd) {
  78. // End boundary reached, nothing more to read
  79. return -1;
  80. } else if (bufferedByte >= 0) {
  81. /* Purge partially matched boundary if there was such */
  82. return getBuffered();
  83. } else if (matchedCount != -1) {
  84. /*
  85. * Special case where last "failed" matching ended with first
  86. * character from boundary string
  87. */
  88. return matchForBoundary();
  89. } else {
  90. int fromActualStream = realInputStream.read();
  91. if (fromActualStream == -1) {
  92. // unexpected end of stream
  93. throw new IOException(
  94. "The multipart stream ended unexpectedly");
  95. }
  96. if (boundary[0] == fromActualStream) {
  97. /*
  98. * If matches the first character in boundary string, start
  99. * checking if the boundary is fetched.
  100. */
  101. return matchForBoundary();
  102. }
  103. return fromActualStream;
  104. }
  105. }
  106. /**
  107. * Reads the input to expect a boundary string. Expects that the first
  108. * character has already been matched.
  109. *
  110. * @return -1 if the boundary was matched, else returns the first byte
  111. * from boundary
  112. * @throws IOException
  113. */
  114. private int matchForBoundary() throws IOException {
  115. matchedCount = 0;
  116. /*
  117. * Going to "buffered mode". Read until full boundary match or a
  118. * different character.
  119. */
  120. while (true) {
  121. matchedCount++;
  122. if (matchedCount == boundary.length) {
  123. /*
  124. * The whole boundary matched so we have reached the end of
  125. * file
  126. */
  127. atTheEnd = true;
  128. return -1;
  129. }
  130. int fromActualStream = realInputStream.read();
  131. if (fromActualStream != boundary[matchedCount]) {
  132. /*
  133. * Did not find full boundary, cache the mismatching byte
  134. * and start returning the partially matched boundary.
  135. */
  136. bufferedByte = fromActualStream;
  137. return getBuffered();
  138. }
  139. }
  140. }
  141. /**
  142. * Returns the partly matched boundary string and the byte following
  143. * that.
  144. *
  145. * @return
  146. * @throws IOException
  147. */
  148. private int getBuffered() throws IOException {
  149. int b;
  150. if (matchedCount == 0) {
  151. // The boundary has been returned, return the buffered byte.
  152. b = bufferedByte;
  153. bufferedByte = -1;
  154. matchedCount = -1;
  155. } else {
  156. b = boundary[curBoundaryIndex++];
  157. if (curBoundaryIndex == matchedCount) {
  158. // The full boundary has been returned, remaining is the
  159. // char that did not match the boundary.
  160. curBoundaryIndex = 0;
  161. if (bufferedByte != boundary[0]) {
  162. /*
  163. * next call for getBuffered will return the
  164. * bufferedByte that came after the partial boundary
  165. * match
  166. */
  167. matchedCount = 0;
  168. } else {
  169. /*
  170. * Special case where buffered byte again matches the
  171. * boundaryString. This could be the start of the real
  172. * end boundary.
  173. */
  174. matchedCount = 0;
  175. bufferedByte = -1;
  176. }
  177. }
  178. }
  179. if (b == -1) {
  180. throw new IOException(
  181. "The multipart stream ended unexpectedly");
  182. }
  183. return b;
  184. }
  185. }
  186. /**
  187. * An UploadInterruptedException will be thrown by an ongoing upload if
  188. * {@link StreamVariable#isInterrupted()} returns <code>true</code>.
  189. *
  190. * By checking the exception of an {@link StreamingErrorEvent} or
  191. * {@link FailedEvent} against this class, it is possible to determine if an
  192. * upload was interrupted by code or aborted due to any other exception.
  193. */
  194. public static class UploadInterruptedException extends Exception {
  195. /**
  196. * Constructs an instance of <code>UploadInterruptedException</code>.
  197. */
  198. public UploadInterruptedException() {
  199. super("Upload interrupted by other thread");
  200. }
  201. }
  202. /**
  203. * as per RFC 2045, line delimiters in headers are always CRLF, i.e. 13 10
  204. */
  205. private static final int LF = 10;
  206. private static final String CRLF = "\r\n";
  207. private static final String UTF8 = "UTF-8";
  208. private static final String DASHDASH = "--";
  209. /*
  210. * Same as in apache commons file upload library that was previously used.
  211. */
  212. private static final int MAX_UPLOAD_BUFFER_SIZE = 4 * 1024;
  213. /* Minimum interval which will be used for streaming progress events. */
  214. public static final int DEFAULT_STREAMING_PROGRESS_EVENT_INTERVAL_MS = 500;
  215. @Override
  216. public boolean handleRequest(VaadinSession session, VaadinRequest request,
  217. VaadinResponse response) throws IOException {
  218. if (!ServletPortletHelper.isFileUploadRequest(request)) {
  219. return false;
  220. }
  221. /*
  222. * URI pattern: APP/UPLOAD/[UIID]/[PID]/[NAME]/[SECKEY] See
  223. * #createReceiverUrl
  224. */
  225. String pathInfo = request.getPathInfo();
  226. // strip away part until the data we are interested starts
  227. int startOfData = pathInfo
  228. .indexOf(ServletPortletHelper.UPLOAD_URL_PREFIX)
  229. + ServletPortletHelper.UPLOAD_URL_PREFIX.length();
  230. String uppUri = pathInfo.substring(startOfData);
  231. String[] parts = uppUri.split("/", 4); // 0= UIid, 1 = cid, 2= name, 3
  232. // = sec key
  233. String uiId = parts[0];
  234. String connectorId = parts[1];
  235. String variableName = parts[2];
  236. // These are retrieved while session is locked
  237. ClientConnector source;
  238. StreamVariable streamVariable;
  239. session.lock();
  240. try {
  241. UI uI = session.getUIById(Integer.parseInt(uiId));
  242. UI.setCurrent(uI);
  243. streamVariable = uI.getConnectorTracker()
  244. .getStreamVariable(connectorId, variableName);
  245. String secKey = uI.getConnectorTracker().getSeckey(streamVariable);
  246. if (secKey == null || !secKey.equals(parts[3])) {
  247. // TODO Should rethink error handling
  248. return true;
  249. }
  250. source = uI.getConnectorTracker().getConnector(connectorId);
  251. } finally {
  252. session.unlock();
  253. }
  254. String contentType = request.getContentType();
  255. if (contentType.contains("boundary")) {
  256. // Multipart requests contain boundary string
  257. doHandleSimpleMultipartFileUpload(session, request, response,
  258. streamVariable, variableName, source,
  259. contentType.split("boundary=")[1]);
  260. } else {
  261. // if boundary string does not exist, the posted file is from
  262. // XHR2.post(File)
  263. doHandleXhrFilePost(session, request, response, streamVariable,
  264. variableName, source, getContentLength(request));
  265. }
  266. return true;
  267. }
  268. private static String readLine(InputStream stream) throws IOException {
  269. ByteArrayOutputStream bout = new ByteArrayOutputStream();
  270. int readByte = stream.read();
  271. while (readByte != LF) {
  272. if (readByte == -1) {
  273. throw new IOException(
  274. "The multipart stream ended unexpectedly");
  275. }
  276. bout.write(readByte);
  277. readByte = stream.read();
  278. }
  279. byte[] bytes = bout.toByteArray();
  280. return new String(bytes, 0, bytes.length - 1, UTF8);
  281. }
  282. /**
  283. * Method used to stream content from a multipart request (either from
  284. * servlet or portlet request) to given StreamVariable.
  285. * <p>
  286. * This method takes care of locking the session as needed and does not
  287. * assume the caller has locked the session. This allows the session to be
  288. * locked only when needed and not when handling the upload data.
  289. * </p>
  290. *
  291. * @param session
  292. * The session containing the stream variable
  293. * @param request
  294. * The upload request
  295. * @param response
  296. * The upload response
  297. * @param streamVariable
  298. * The destination stream variable
  299. * @param variableName
  300. * The name of the destination stream variable
  301. * @param owner
  302. * The owner of the stream variable
  303. * @param boundary
  304. * The mime boundary used in the upload request
  305. * @throws IOException
  306. * If there is a problem reading the request or writing the
  307. * response
  308. */
  309. protected void doHandleSimpleMultipartFileUpload(VaadinSession session,
  310. VaadinRequest request, VaadinResponse response,
  311. StreamVariable streamVariable, String variableName,
  312. ClientConnector owner, String boundary) throws IOException {
  313. // multipart parsing, supports only one file for request, but that is
  314. // fine for our current terminal
  315. final InputStream inputStream = request.getInputStream();
  316. long contentLength = getContentLength(request);
  317. boolean atStart = false;
  318. boolean firstFileFieldFound = false;
  319. String rawfilename = "unknown";
  320. String rawMimeType = "application/octet-stream";
  321. /*
  322. * Read the stream until the actual file starts (empty line). Read
  323. * filename and content type from multipart headers.
  324. */
  325. while (!atStart) {
  326. String readLine = readLine(inputStream);
  327. contentLength -= (readLine.getBytes(UTF8).length + CRLF.length());
  328. if (readLine.startsWith("Content-Disposition:")
  329. && readLine.indexOf("filename=") > 0) {
  330. rawfilename = readLine.replaceAll(".*filename=", "");
  331. char quote = rawfilename.charAt(0);
  332. rawfilename = rawfilename.substring(1);
  333. rawfilename = rawfilename.substring(0,
  334. rawfilename.indexOf(quote));
  335. firstFileFieldFound = true;
  336. } else if (firstFileFieldFound && readLine.isEmpty()) {
  337. atStart = true;
  338. } else if (readLine.startsWith("Content-Type")) {
  339. rawMimeType = readLine.split(": ")[1];
  340. }
  341. }
  342. contentLength -= (boundary.length() + CRLF.length()
  343. + 2 * DASHDASH.length() + CRLF.length());
  344. /*
  345. * Reads bytes from the underlying stream. Compares the read bytes to
  346. * the boundary string and returns -1 if met.
  347. *
  348. * The matching happens so that if the read byte equals to the first
  349. * char of boundary string, the stream goes to "buffering mode". In
  350. * buffering mode bytes are read until the character does not match the
  351. * corresponding from boundary string or the full boundary string is
  352. * found.
  353. *
  354. * Note, if this is someday needed elsewhere, don't shoot yourself to
  355. * foot and split to a top level helper class.
  356. */
  357. InputStream simpleMultiPartReader = new SimpleMultiPartInputStream(
  358. inputStream, boundary);
  359. /*
  360. * Should report only the filename even if the browser sends the path
  361. */
  362. final String filename = removePath(rawfilename);
  363. final String mimeType = rawMimeType;
  364. try {
  365. handleFileUploadValidationAndData(session, simpleMultiPartReader,
  366. streamVariable, filename, mimeType, contentLength, owner,
  367. variableName);
  368. } catch (UploadException e) {
  369. session.getCommunicationManager()
  370. .handleConnectorRelatedException(owner, e);
  371. }
  372. sendUploadResponse(request, response);
  373. }
  374. /*
  375. * request.getContentLength() is limited to "int" by the Servlet
  376. * specification. To support larger file uploads manually evaluate the
  377. * Content-Length header which can contain long values.
  378. */
  379. private long getContentLength(VaadinRequest request) {
  380. try {
  381. return Long.parseLong(request.getHeader("Content-Length"));
  382. } catch (NumberFormatException e) {
  383. return -1l;
  384. }
  385. }
  386. private void handleFileUploadValidationAndData(VaadinSession session,
  387. InputStream inputStream, StreamVariable streamVariable,
  388. String filename, String mimeType, long contentLength,
  389. ClientConnector connector, String variableName)
  390. throws UploadException {
  391. session.lock();
  392. try {
  393. if (connector == null) {
  394. throw new UploadException(
  395. "File upload ignored because the connector for the stream variable was not found");
  396. }
  397. if (!connector.isConnectorEnabled()) {
  398. throw new UploadException("Warning: file upload ignored for "
  399. + connector.getConnectorId()
  400. + " because the component was disabled");
  401. }
  402. } finally {
  403. session.unlock();
  404. }
  405. try {
  406. // Store ui reference so we can do cleanup even if connector is
  407. // detached in some event handler
  408. UI ui = connector.getUI();
  409. boolean forgetVariable = streamToReceiver(session, inputStream,
  410. streamVariable, filename, mimeType, contentLength);
  411. if (forgetVariable) {
  412. cleanStreamVariable(session, ui, connector, variableName);
  413. }
  414. } catch (Exception e) {
  415. session.lock();
  416. try {
  417. session.getCommunicationManager()
  418. .handleConnectorRelatedException(connector, e);
  419. } finally {
  420. session.unlock();
  421. }
  422. }
  423. }
  424. /**
  425. * Used to stream plain file post (aka XHR2.post(File))
  426. * <p>
  427. * This method takes care of locking the session as needed and does not
  428. * assume the caller has locked the session. This allows the session to be
  429. * locked only when needed and not when handling the upload data.
  430. * </p>
  431. *
  432. * @param session
  433. * The session containing the stream variable
  434. * @param request
  435. * The upload request
  436. * @param response
  437. * The upload response
  438. * @param streamVariable
  439. * The destination stream variable
  440. * @param variableName
  441. * The name of the destination stream variable
  442. * @param owner
  443. * The owner of the stream variable
  444. * @param contentLength
  445. * The length of the request content
  446. * @throws IOException
  447. * If there is a problem reading the request or writing the
  448. * response
  449. */
  450. protected void doHandleXhrFilePost(VaadinSession session,
  451. VaadinRequest request, VaadinResponse response,
  452. StreamVariable streamVariable, String variableName,
  453. ClientConnector owner, long contentLength) throws IOException {
  454. // These are unknown in filexhr ATM, maybe add to Accept header that
  455. // is accessible in portlets
  456. final String filename = "unknown";
  457. final String mimeType = filename;
  458. final InputStream stream = request.getInputStream();
  459. try {
  460. handleFileUploadValidationAndData(session, stream, streamVariable,
  461. filename, mimeType, contentLength, owner, variableName);
  462. } catch (UploadException e) {
  463. session.getCommunicationManager()
  464. .handleConnectorRelatedException(owner, e);
  465. }
  466. sendUploadResponse(request, response);
  467. }
  468. /**
  469. * @param in
  470. * @param streamVariable
  471. * @param filename
  472. * @param type
  473. * @param contentLength
  474. * @return true if the streamvariable has informed that the terminal can
  475. * forget this variable
  476. * @throws UploadException
  477. */
  478. protected final boolean streamToReceiver(VaadinSession session,
  479. final InputStream in, StreamVariable streamVariable,
  480. String filename, String type, long contentLength)
  481. throws UploadException {
  482. if (streamVariable == null) {
  483. throw new IllegalStateException(
  484. "StreamVariable for the post not found");
  485. }
  486. OutputStream out = null;
  487. long totalBytes = 0;
  488. StreamingStartEventImpl startedEvent = new StreamingStartEventImpl(
  489. filename, type, contentLength);
  490. try {
  491. boolean listenProgress;
  492. session.lock();
  493. try {
  494. streamVariable.streamingStarted(startedEvent);
  495. out = streamVariable.getOutputStream();
  496. listenProgress = streamVariable.listenProgress();
  497. } finally {
  498. session.unlock();
  499. }
  500. // Gets the output target stream
  501. if (out == null) {
  502. throw new NoOutputStreamException();
  503. }
  504. if (null == in) {
  505. // No file, for instance non-existent filename in html upload
  506. throw new NoInputStreamException();
  507. }
  508. final byte buffer[] = new byte[MAX_UPLOAD_BUFFER_SIZE];
  509. long lastStreamingEvent = 0;
  510. int bytesReadToBuffer = 0;
  511. do {
  512. bytesReadToBuffer = in.read(buffer);
  513. if (bytesReadToBuffer > 0) {
  514. out.write(buffer, 0, bytesReadToBuffer);
  515. totalBytes += bytesReadToBuffer;
  516. }
  517. if (listenProgress) {
  518. long now = System.currentTimeMillis();
  519. // to avoid excessive session locking and event storms,
  520. // events are sent in intervals, or at the end of the file.
  521. if (lastStreamingEvent + getProgressEventInterval() <= now
  522. || bytesReadToBuffer <= 0) {
  523. lastStreamingEvent = now;
  524. session.lock();
  525. try {
  526. StreamingProgressEventImpl progressEvent = new StreamingProgressEventImpl(
  527. filename, type, contentLength, totalBytes);
  528. streamVariable.onProgress(progressEvent);
  529. } finally {
  530. session.unlock();
  531. }
  532. }
  533. }
  534. if (streamVariable.isInterrupted()) {
  535. throw new UploadInterruptedException();
  536. }
  537. } while (bytesReadToBuffer > 0);
  538. // upload successful
  539. out.close();
  540. StreamingEndEvent event = new StreamingEndEventImpl(filename, type,
  541. totalBytes);
  542. session.lock();
  543. try {
  544. streamVariable.streamingFinished(event);
  545. } finally {
  546. session.unlock();
  547. }
  548. } catch (UploadInterruptedException e) {
  549. // Download interrupted by application code
  550. tryToCloseStream(out);
  551. StreamingErrorEvent event = new StreamingErrorEventImpl(filename,
  552. type, contentLength, totalBytes, e);
  553. session.lock();
  554. try {
  555. streamVariable.streamingFailed(event);
  556. } finally {
  557. session.unlock();
  558. }
  559. // Note, we are not throwing interrupted exception forward as it is
  560. // not a terminal level error like all other exception.
  561. } catch (final Exception e) {
  562. tryToCloseStream(out);
  563. session.lock();
  564. try {
  565. StreamingErrorEvent event = new StreamingErrorEventImpl(
  566. filename, type, contentLength, totalBytes, e);
  567. streamVariable.streamingFailed(event);
  568. // throw exception for terminal to be handled (to be passed to
  569. // terminalErrorHandler)
  570. throw new UploadException(e);
  571. } finally {
  572. session.unlock();
  573. }
  574. }
  575. return startedEvent.isDisposed();
  576. }
  577. /**
  578. * To prevent event storming, streaming progress events are sent in this
  579. * interval rather than every time the buffer is filled. This fixes #13155.
  580. * To adjust this value override the method, and register your own handler
  581. * in VaadinService.createRequestHandlers(). The default is 500ms, and
  582. * setting it to 0 effectively restores the old behavior.
  583. */
  584. protected int getProgressEventInterval() {
  585. return DEFAULT_STREAMING_PROGRESS_EVENT_INTERVAL_MS;
  586. }
  587. static void tryToCloseStream(OutputStream out) {
  588. try {
  589. // try to close output stream (e.g. file handle)
  590. if (out != null) {
  591. out.close();
  592. }
  593. } catch (IOException e1) {
  594. // NOP
  595. }
  596. }
  597. /**
  598. * Removes any possible path information from the filename and returns the
  599. * filename. Separators / and \\ are used.
  600. *
  601. * @param name
  602. * @return
  603. */
  604. private static String removePath(String filename) {
  605. if (filename != null) {
  606. filename = filename.replaceAll("^.*[/\\\\]", "");
  607. }
  608. return filename;
  609. }
  610. /**
  611. * TODO document
  612. *
  613. * @param request
  614. * @param response
  615. * @throws IOException
  616. */
  617. protected void sendUploadResponse(VaadinRequest request,
  618. VaadinResponse response) throws IOException {
  619. response.setContentType("text/html");
  620. try (OutputStream out = response.getOutputStream()) {
  621. final PrintWriter outWriter = new PrintWriter(
  622. new BufferedWriter(new OutputStreamWriter(out, "UTF-8")));
  623. outWriter.print("<html><body>download handled</body></html>");
  624. outWriter.flush();
  625. }
  626. }
  627. private void cleanStreamVariable(VaadinSession session, final UI ui,
  628. final ClientConnector owner, final String variableName) {
  629. session.accessSynchronously(() -> {
  630. ui.getConnectorTracker().cleanStreamVariable(owner.getConnectorId(),
  631. variableName);
  632. });
  633. }
  634. }