From d7b5d8b8b88d2c4e313d4109a4482e39565b46db Mon Sep 17 00:00:00 2001 From: Niklas Edmundsson Date: Fri, 12 May 2023 15:55:56 +0200 Subject: [PATCH 1/2] Let browser handle downloads directly - part 1(2) Currently dCacheView handles downloads by first doing the download and only when the download has completed it passes the object along for the browser to handle. The result is no feedback at all for users when initiating download of large files since the save dialog is shown on completion, and huge files might not download at all if the temporary browser download location runs out of space. Work around this by letting the browser handle the download instead, the method chosen is to create a temporary Anchor element to drive the download action as it has an explicit download attribute and avoids issues with modern browser pop-up blockers. Since username/password (Basic) auth can't be reliably passed along a short-lived Macaroon is created to handle the download. Existing Macaroons are used as-is to handle download in the shared-files top-level view. Sessions with certificate authentication are assumed to work as-is, bypassing the Macaroon generation stage. The result is a decent end user experience when downloading files, including huge files that are common in a scientific data store. Missing in this patch is handling of subdirectories in the shared-files view. Credits go to various threads on https://stackoverflow.com/ for explaining numerous corner cases and nuances in this area. Fixes: https://github.com/dCache/dcache-view/issues/269 Signed-off-by: Niklas Edmundsson --- src/scripts/dv.js | 74 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/src/scripts/dv.js b/src/scripts/dv.js index f60924d..b4d930c 100644 --- a/src/scripts/dv.js +++ b/src/scripts/dv.js @@ -501,6 +501,14 @@ .set(`items.${itemIndex}.currentQos`, status); vf.shadowRoot.querySelector('#feList').notifyPath(`items.${itemIndex}.currentQos`); } + /* Initiate browser file download */ + function _downloadFile(url) + { + var dl = document.createElement("a"); + dl.setAttribute('href', url); + dl.setAttribute('download', ''); + dl.click(); + } window.addEventListener('qos-in-transition', function(event) { updateFeListAndMetaDataDrawer([`${event.detail.options.targetQos}`], event.detail.options.itemIndex); @@ -632,32 +640,52 @@ app.ls(e.detail.file.filePath, auth); Polymer.dom.flush(); } else { - //Download the file - const worker = new Worker('./scripts/tasks/download-task.js'); + // Download the file const fileURL = getFileWebDavUrl(e.detail.file.filePath, "read")[0]; - worker.addEventListener('message', (response) => { - worker.terminate(); - const windowUrl = window.URL || window.webkitURL; - const url = windowUrl.createObjectURL(response.data); - const link = app.$.download; - link.href = url; - link.download = e.detail.file.fileMetaData.fileName; - link.click(); - windowUrl.revokeObjectURL(url); - - }, false); - worker.addEventListener('error', (err)=> { - worker.terminate(); - openToast(`${err.message}`); - }, false); - worker.postMessage({ - 'url' : fileURL, - 'mime' : e.detail.file.fileMetaData.fileMimeType, - 'upauth' : app.getAuthValue(auth), - 'return': 'blob' - }); + let authval = app.getAuthValue(); + if (e.detail.file.macaroon) { + // Unconditionally use existing macaroon if available + let u = new URL(fileURL); + u.searchParams.append('authz', e.detail.file.macaroon); + _downloadFile(u); + } + else if(!authval) { + /* + * No explicit auth, so using cert auth, which means we can + * just access the file directly without having the user + * re-login. + */ + _downloadFile(fileURL); + } + else { + /* + * We don't seem to be able to pass our current auth + * via a standard method that triggers the browser standard + * file-download handling, so need to create a short-lived + * Macaroon for it. + */ + const macaroonWorker = new Worker('./scripts/tasks/macaroon-request-task.js'); + macaroonWorker.addEventListener('message', (e) => { + macaroonWorker.terminate(); + _downloadFile(e.data.uri.targetWithMacaroon); + }, false); + macaroonWorker.addEventListener('error', (e) => { + macaroonWorker.terminate(); + // FIXME: Display an error dialog somehow + console.error(e); + }, false); + macaroonWorker.postMessage({ + "url": fileURL, + "body": { + "caveats": ["activity:DOWNLOAD"], + "validity": "PT1M" + }, + 'upauth' : authval, + }); + } } }); + window.addEventListener('dv-namespace-open-subcontextmenu', e => app.subContextMenu(e)); window.addEventListener('dv-namespace-close-subcontextmenu', () => { app.$.centralSubContextMenu.close(); From 17161f8e130625ad70208fc0fa95bcd32f7defbc Mon Sep 17 00:00:00 2001 From: Niklas Edmundsson Date: Tue, 16 May 2023 13:40:53 +0200 Subject: [PATCH 2/2] Let browser handle downloads directly - part 2(2) dCacheView is limited to file-by-file downloads, which is frustrating users since there is support for multi-file selection. This patch builds on the previous one to enable downloads for multi-file selections. The simplest solution is implemented that triggers multiple file downloads in the browser, this is also found to work best with tablets and smartphones, where the traditional method by handling multi-file download by providing a .zip archive of the files is really cumbersome to handle. There is also a completion of the implementation to handle sub-directories in the shared-files view. The end result is that it is now possible to select multiple files in a directory and then download them in a smooth manner. Fixes: https://github.com/dCache/dcache-view/issues/268 Signed-off-by: Niklas Edmundsson --- .../namespace-contextual-content.html | 9 +- .../shared-files-page/shared-files-page.js | 30 +--- .../files-viewer/files-viewer.html | 2 +- src/scripts/dv.js | 149 ++++++++++++------ 4 files changed, 110 insertions(+), 80 deletions(-) diff --git a/src/elements/dv-elements/contextual-content/namespace-contextual-content.html b/src/elements/dv-elements/contextual-content/namespace-contextual-content.html index d51d6f1..8b81423 100644 --- a/src/elements/dv-elements/contextual-content/namespace-contextual-content.html +++ b/src/elements/dv-elements/contextual-content/namespace-contextual-content.html @@ -246,6 +246,10 @@ } else if (this.multipleSelection) { this.$.delete.addEventListener('tap', this._delete.bind(this)); this.$.move.addEventListener('tap', this._move.bind(this)); + // FIXME: Is this the place to figure out if any + // directories are selected and disable download if + // that's the case? + this.$.download.addEventListener('tap', this._openOrDownload.bind(this)); } else if (this.currentDir) { this.$.create.addEventListener('tap', this._create.bind(this)); this.$.metadata.addEventListener('tap', this._metadata.bind(this)); @@ -289,6 +293,7 @@ } else if (this.multipleSelection) { this.$.delete.removeEventListener('tap', this._delete.bind(this)); this.$.move.removeEventListener('tap', this._move.bind(this)); + this.$.download.removeEventListener('tap', this._openOrDownload.bind(this)); } else if (this.currentDir) { this.$.create.removeEventListener('tap', this._create.bind(this)); this.$.metadata.removeEventListener('tap', this._metadata.bind(this)); @@ -642,9 +647,11 @@ _setDisabledAttribute(id, singleSelection, multipleSelection, t) { + // FIXME: Is this the place to figure out if any directories + // are selected and disable download if that's the case? if (multipleSelection && (id === 'open' || id === 'share' || id === 'metadata' || id === 'webdavUrl' || id === 'rename' || id === 'setLabel' || - id === 'download' || id === 'changeQos' || id === 'qosInfo')) { + id === 'changeQos' || id === 'qosInfo')) { this.$[id].setAttribute('disabled', ""); } diff --git a/src/elements/dv-elements/file-sharing/shared-files-page/shared-files-page.js b/src/elements/dv-elements/file-sharing/shared-files-page/shared-files-page.js index 0cc898d..1010376 100644 --- a/src/elements/dv-elements/file-sharing/shared-files-page/shared-files-page.js +++ b/src/elements/dv-elements/file-sharing/shared-files-page/shared-files-page.js @@ -45,33 +45,7 @@ class SharedFilesPage extends DcacheViewMixins.Commons(Polymer.Element) }; this.$['shared-directory-view'].path = e.detail.file.filePath; } else if (e.detail.file.fileMetaData.fileType === "REGULAR") { - //download - const worker = new Worker('./scripts/tasks/download-task.js'); - const fileURL = this.getFileWebDavUrl(e.detail.file.filePath, "read")[0]; - worker.addEventListener('message', (file) => { - worker.terminate(); - const windowUrl = window.URL || window.webkitURL; - const url = windowUrl.createObjectURL(file.data); - const link = app.$.download; - link.href = url; - link.download = e.detail.file.fileMetaData.fileName; - link.click(); - windowUrl.revokeObjectURL(url); - }, false); - worker.addEventListener('error', (e)=> { - console.info(e); - worker.terminate(); - this.dispatchEvent(new CustomEvent('dv-namespace-show-message-toast', { - detail: {message: e.message}, bubbles: true, composed: true - })); - }, false); - this.authenticationParameters = {"scheme": "Bearer", "value": e.detail.file.macaroon}; - worker.postMessage({ - 'url' : fileURL, - 'mime' : e.detail.file.fileMetaData.fileMimeType, - 'upauth' : this.getAuthValue(), - 'return': 'blob' - }); + app._initiateDownload(e.detail.file); } } _showSharedFileList() @@ -81,4 +55,4 @@ class SharedFilesPage extends DcacheViewMixins.Commons(Polymer.Element) this.$['shared-directory-view'].classList.replace('normal', 'none'); } } -window.customElements.define(SharedFilesPage.is, SharedFilesPage); \ No newline at end of file +window.customElements.define(SharedFilesPage.is, SharedFilesPage); diff --git a/src/elements/dv-elements/files-viewer/files-viewer.html b/src/elements/dv-elements/files-viewer/files-viewer.html index 71f350f..f1f3a3d 100644 --- a/src/elements/dv-elements/files-viewer/files-viewer.html +++ b/src/elements/dv-elements/files-viewer/files-viewer.html @@ -236,4 +236,4 @@ } window.customElements.define(FilesViewer.is, FilesViewer); - \ No newline at end of file + diff --git a/src/scripts/dv.js b/src/scripts/dv.js index b4d930c..456a353 100644 --- a/src/scripts/dv.js +++ b/src/scripts/dv.js @@ -501,7 +501,7 @@ .set(`items.${itemIndex}.currentQos`, status); vf.shadowRoot.querySelector('#feList').notifyPath(`items.${itemIndex}.currentQos`); } - /* Initiate browser file download */ + /* Start browser file download of a URL */ function _downloadFile(url) { var dl = document.createElement("a"); @@ -509,6 +509,92 @@ dl.setAttribute('download', ''); dl.click(); } + /* Initiate download of a single file object */ + app._initiateDownload = function(file) + { + const fileURL = getFileWebDavUrl(file.filePath, "read")[0]; + let authval = app.getAuthValue(); + + // Unconditionally use existing macaroon if available + let macaroon = undefined; + if (file.macaroon) { + macaroon = file.macaroon; + } + else if(file.authenticationParameters !== undefined && file.authenticationParameters.scheme === "Bearer") { + macaroon = file.authenticationParameters.value; + } + + if(macaroon !== undefined) { + let u = new URL(fileURL); + u.searchParams.append('authz', macaroon); + _downloadFile(u); + } + else if(!authval) { + /* + * No explicit auth, so using cert auth, which means we can + * just access the file directly without having the user + * re-login. + */ + _downloadFile(fileURL); + } + else { + /* + * We don't seem to be able to pass our current auth + * via a standard method that triggers the browser standard + * file-download handling, so need to create a short-lived + * Macaroon for it. + */ + const macaroonWorker = new Worker('./scripts/tasks/macaroon-request-task.js'); + macaroonWorker.addEventListener('message', (e) => { + macaroonWorker.terminate(); + _downloadFile(e.data.uri.targetWithMacaroon); + }, false); + macaroonWorker.addEventListener('error', (e) => { + macaroonWorker.terminate(); + // FIXME: Display an error dialog somehow + console.error(e); + }, false); + macaroonWorker.postMessage({ + "url": fileURL, + "body": { + "caveats": ["activity:DOWNLOAD"], + "validity": "PT10M" + }, + 'upauth' : authval, + }); + } + } + /* Initiate downloads of a multi-file selection */ + function _initiateMultiDownload(e) + { + /* For some reason we only have the fileMetaData, so need to + * fabricate the expected structure... + */ + const toDL = []; + for(const f of e.detail.file.files) { + if(f.fileType === "REGULAR") { + let n = {}; + n.fileMetaData = f; + n.filePath = e.target.currentPath.endsWith('/') ? `${e.target.currentPath}${f.fileName}`: `${e.target.currentPath}/${f.fileName}`; + if(e.detail.file.authenticationParameters !== undefined) { + n.authenticationParameters = e.detail.file.authenticationParameters; + } + toDL.push(n); + } + else { + /* FIXME: Either disable download choice if non-file + * selected, or abort and display an error to the user. + */ + console.error(`Skipping ${f.fileType} ${f.fileName}`); + } + } + for (let i = 0; i < toDL.length; i++) { + /* Need to stagger starts to allow browser to start + * this download before initiating the next one. + */ + setTimeout(app._initiateDownload, i*1000, toDL[i]); + } + } window.addEventListener('qos-in-transition', function(event) { updateFeListAndMetaDataDrawer([`${event.detail.options.targetQos}`], event.detail.options.itemIndex); @@ -632,57 +718,20 @@ app.drop(e); }); window.addEventListener('dv-namespace-open-file', function (e) { - let auth; - if (e.detail.file.authenticationParameters !== undefined) { - auth = e.detail.file.authenticationParameters; - } - if (e.detail.file.fileMetaData.fileType === "DIR") { + if (e.detail.file.fileMetaData !== undefined && e.detail.file.fileMetaData.fileType === "DIR") { + let auth; + if (e.detail.file.authenticationParameters !== undefined) { + auth = e.detail.file.authenticationParameters; + } app.ls(e.detail.file.filePath, auth); Polymer.dom.flush(); - } else { - // Download the file - const fileURL = getFileWebDavUrl(e.detail.file.filePath, "read")[0]; - let authval = app.getAuthValue(); - if (e.detail.file.macaroon) { - // Unconditionally use existing macaroon if available - let u = new URL(fileURL); - u.searchParams.append('authz', e.detail.file.macaroon); - _downloadFile(u); - } - else if(!authval) { - /* - * No explicit auth, so using cert auth, which means we can - * just access the file directly without having the user - * re-login. - */ - _downloadFile(fileURL); - } - else { - /* - * We don't seem to be able to pass our current auth - * via a standard method that triggers the browser standard - * file-download handling, so need to create a short-lived - * Macaroon for it. - */ - const macaroonWorker = new Worker('./scripts/tasks/macaroon-request-task.js'); - macaroonWorker.addEventListener('message', (e) => { - macaroonWorker.terminate(); - _downloadFile(e.data.uri.targetWithMacaroon); - }, false); - macaroonWorker.addEventListener('error', (e) => { - macaroonWorker.terminate(); - // FIXME: Display an error dialog somehow - console.error(e); - }, false); - macaroonWorker.postMessage({ - "url": fileURL, - "body": { - "caveats": ["activity:DOWNLOAD"], - "validity": "PT1M" - }, - 'upauth' : authval, - }); - } + } else if (e.target.__data.singleSelection === true) { + app._initiateDownload(e.detail.file); + } else if (e.target.__data.multipleSelection === true) { + _initiateMultiDownload(e); + } + else { + console.error("Internal error, no matching state"); } });