From c9a2fc249e7ef4993d2cab3831fe07a9dc6a3c19 Mon Sep 17 00:00:00 2001 From: elijahnicpon Date: Mon, 25 Jul 2022 19:03:27 -0400 Subject: [PATCH 1/3] GUACAMOLE-1320: Provide chunked file upload mechanism --- .../src/app/rest/services/tunnelService.js | 127 +++++++++++++----- .../tunnel/InputStreamInterceptingFilter.java | 8 +- 2 files changed, 95 insertions(+), 40 deletions(-) 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 5f9a21a7c..c5a10d6e4 100644 --- a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js +++ b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js @@ -54,6 +54,15 @@ angular.module('rest').factory('tunnelService', ['$injector', */ 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 @@ -301,51 +310,99 @@ angular.module('rest').factory('tunnelService', ['$injector', + '/' + 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 f8e033416..6f725bfc1 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 @@ public class InputStreamInterceptingFilter /** * 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 @@ public class InputStreamInterceptingFilter // 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; From 003f7e945a8f14982e088aeb3c2b4df506ebd1a8 Mon Sep 17 00:00:00 2001 From: elijahnicpon Date: Wed, 27 Jul 2022 20:26:17 -0400 Subject: [PATCH 2/3] GUACAMOLE-1320: Provide chunked file upload mechanism - add sendEnd() --- .../frontend/src/app/client/types/ManagedFileUpload.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js b/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js index 56587fcd0..95eef0c61 100644 --- a/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js +++ b/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js @@ -163,17 +163,19 @@ angular.module('client').factory('ManagedFileUpload', ['$rootScope', '$injector' // Upload complete managedFileUpload.progress = file.size; + + // Close the stream + stream.sendEnd(); ManagedFileTransferState.setStreamState(managedFileUpload.transferState, ManagedFileTransferState.StreamState.CLOSED); // Notify of upload completion $rootScope.$broadcast('guacUploadComplete', file.name); - }, // Notify if upload fails requestService.createErrorCallback(function uploadFailed(error) { - + // Use provide status code if the error is coming from the stream if (error.type === Error.Type.STREAM_ERROR) ManagedFileTransferState.setStreamState(managedFileUpload.transferState, @@ -185,11 +187,15 @@ angular.module('client').factory('ManagedFileUpload', ['$rootScope', '$injector' ManagedFileTransferState.setStreamState(managedFileUpload.transferState, ManagedFileTransferState.StreamState.ERROR, Guacamole.Status.Code.INTERNAL_ERROR); + + // Close the stream + stream.sendEnd(); })); // Ignore all further acks stream.onack = null; + }; From a116208a6da0a3d3a6aa92352f5c33f3ac336e73 Mon Sep 17 00:00:00 2001 From: elijahnicpon Date: Thu, 28 Jul 2022 19:17:05 -0400 Subject: [PATCH 3/3] GUACAMOLE-1320: Provide chunked file upload mechanism - update uploadHandler documentation --- .../main/frontend/src/app/rest/services/tunnelService.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 c5a10d6e4..5cc547b40 100644 --- a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js +++ b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js @@ -393,7 +393,11 @@ angular.module('rest').factory('tunnelService', ['$injector', }; /** - * Handler for the upload process. + * Handles the recursive upload process. Each time it is called, a + * chunk is made with createChunk(), starting at the offset parameter. + * The chunk is then sent by uploadChunk(), which recursively calls + * this handler until the upload process is either completed and the + * promise is resolved, or fails and the promise is rejected. * * @param {Number} offset * The byte at which to begin the chunk.