GUACAMOLE-1320: Provide chunked file upload mechanism
diff --git a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
index 5f9a21a..c5a10d6 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
@@ -55,6 +55,15 @@
     var DOWNLOAD_CLEANUP_WAIT = 5000;
 
     /**
+     * The maximum size a chunk may be during uploadToStream() in bytes.
+     * 
+     * @private
+     * @constant
+     * @type Number
+     */
+    const CHUNK_SIZE = 1024 * 1024 * 4;
+
+    /**
      * Makes a request to the REST API to get the list of all tunnels
      * associated with in-progress connections, returning a promise that
      * provides an array of their UUIDs (strings) if successful.
@@ -301,51 +310,99 @@
                 + '/' + encodeURIComponent(sanitizeFilename(file.name))
                 + '?token=' + encodeURIComponent(authenticationService.getCurrentToken());
 
-        var xhr = new XMLHttpRequest();
+        /**
+         * Creates a chunk of the inputted file to be uploaded.
+         * 
+         * @param {Number} offset
+         *      The byte at which to begin the chunk. 
+         * 
+         * @return {File}
+         *      The file chunk created by this function.
+         */
+        const createChunk = (offset) => {
+            var chunkEnd = Math.min(offset + CHUNK_SIZE, file.size);
+            const chunk = file.slice(offset, chunkEnd);
+            return chunk;
+        };
 
-        // Invoke provided callback if upload tracking is supported
-        if (progressCallback && xhr.upload) {
-            xhr.upload.addEventListener('progress', function updateProgress(e) {
-                progressCallback(e.loaded);
-            });
-        }
+        /**
+         * POSTs the inputted chunks and recursively calls uploadHandler()
+         * until the upload is complete.
+         * 
+         * @param {File} chunk
+         *      The chunk to be uploaded to the stream.
+         * 
+         * @param {Number} offset
+         *      The byte at which the inputted chunk begins.
+         */ 
+        const uploadChunk = (chunk, offset) => {
+            var xhr = new XMLHttpRequest();
+            xhr.open('POST', url, true);
 
-        // Resolve/reject promise once upload has stopped
-        xhr.onreadystatechange = function uploadStatusChanged() {
+            // Invoke provided callback if upload tracking is supported.
+            if (progressCallback && xhr.upload) {
+                xhr.upload.addEventListener('progress', function updateProgress(e) {
+                    progressCallback(e.loaded + offset);
+                });
+            };
 
-            // Ignore state changes prior to completion
-            if (xhr.readyState !== 4)
-                return;
+            // Continue to next chunk, resolve, or reject promise as appropriate
+            // once upload has stopped
+            xhr.onreadystatechange = function uploadStatusChanged() {
 
-            // Resolve if HTTP status code indicates success
-            if (xhr.status >= 200 && xhr.status < 300)
-                deferred.resolve();
+                // Ignore state changes prior to completion.
+                if (xhr.readyState !== 4)
+                    return;
 
-            // Parse and reject with resulting JSON error
-            else if (xhr.getResponseHeader('Content-Type') === 'application/json')
-                deferred.reject(new Error(angular.fromJson(xhr.responseText)));
+                // Resolve if last chunk or begin next chunk if HTTP status
+                // code indicates success.
+                if (xhr.status >= 200 && xhr.status < 300) {
+                    offset += CHUNK_SIZE;
 
-            // Warn of lack of permission of a proxy rejects the upload
-            else if (xhr.status >= 400 && xhr.status < 500)
-                deferred.reject(new Error({
-                    'type'       : Error.Type.STREAM_ERROR,
-                    'statusCode' : Guacamole.Status.Code.CLIENT_FORBIDDEN,
-                    'message'    : 'HTTP ' + xhr.status
-                }));
+                    if (offset < file.size)
+                        uploadHandler(offset);
+                    else
+                        deferred.resolve();
+                }
 
-            // Assume internal error for all other cases
-            else
-                deferred.reject(new Error({
-                    'type'       : Error.Type.STREAM_ERROR,
-                    'statusCode' : Guacamole.Status.Code.INTERNAL_ERROR,
-                    'message'    : 'HTTP ' + xhr.status
-                }));
+                // Parse and reject with resulting JSON error
+                else if (xhr.getResponseHeader('Content-Type') === 'application/json')
+                    deferred.reject(new Error(angular.fromJson(xhr.responseText)));
+
+                // Warn of lack of permission of a proxy rejects the upload
+                else if (xhr.status >= 400 && xhr.status < 500)
+                    deferred.reject(new Error({
+                        'type': Error.Type.STREAM_ERROR,
+                        'statusCode': Guacamole.Status.Code.CLIENT_FORBIDDEN,
+                        'message': 'HTTP ' + xhr.status
+                    }));
+
+                // Assume internal error for all other cases
+                else
+                    deferred.reject(new Error({
+                        'type': Error.Type.STREAM_ERROR,
+                        'statusCode': Guacamole.Status.Code.INTERNAL_ERROR,
+                        'message': 'HTTP ' + xhr.status
+                    }));
+
+            };
+
+            // Perform upload
+            xhr.send(chunk);
 
         };
 
-        // Perform upload
-        xhr.open('POST', url, true);
-        xhr.send(file);
+        /**
+         * Handler for the upload process. 
+         * 
+         * @param {Number} offset
+         *      The byte at which to begin the chunk.
+         */
+        const uploadHandler = (offset) => {
+            uploadChunk(createChunk(offset), offset);
+        };
+
+        uploadHandler(0);
 
         return deferred.promise;
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java
index f8e0334..6f725bf 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java
@@ -94,8 +94,7 @@
     /**
      * Reads the next chunk of data from the InputStream associated with an
      * intercepted stream, sending that data as a "blob" instruction over the
-     * GuacamoleTunnel associated with this filter. If the end of the
-     * InputStream is reached, an "end" instruction will automatically be sent.
+     * GuacamoleTunnel associated with this filter.
      *
      * @param stream
      *     The stream from which the next chunk of data should be read.
@@ -112,9 +111,8 @@
             // End stream if no more data
             if (length == -1) {
 
-                // Close stream, send end if the stream is still valid
-                if (closeInterceptedStream(stream))
-                    sendEnd(stream.getIndex());
+                // Close stream
+                closeInterceptedStream(stream);
 
                 return;