diff --git a/frontend/src/download.js b/frontend/src/download.js index 98ac2ce9..42be0ec4 100644 --- a/frontend/src/download.js +++ b/frontend/src/download.js @@ -1,144 +1,118 @@ const { Raven } = require('./common'); const FileReceiver = require('./fileReceiver'); -const { notify, gcmCompliant } = require('./utils'); -const bytes = require('bytes'); +const { bytes, notify, gcmCompliant } = require('./utils'); const Storage = require('./storage'); const storage = new Storage(localStorage); const links = require('./links'); const metrics = require('./metrics'); - +const progress = require('./progress'); const $ = require('jquery'); -require('jquery-circle-progress'); + +function onUnload(size) { + metrics.cancelledDownload({ size }); +} + +function download() { + const $downloadBtn = $('#download-btn'); + const $title = $('.title'); + const $file = $('#dl-file'); + const size = Number($file.attr('data-size')); + const ttl = Number($file.attr('data-ttl')); + const unloadHandler = onUnload.bind(null, size); + const startTime = Date.now(); + const fileReceiver = new FileReceiver(); + + $downloadBtn.attr('disabled', 'disabled'); + $('#download-page-one').attr('hidden', true); + $('#download-progress').removeAttr('hidden'); + metrics.startedDownload({ size, ttl }); + links.setOpenInNewTab(true); + window.addEventListener('unload', unloadHandler); + + fileReceiver.on('progress', data => { + progress.setProgress({ complete: data[0], total: data[1] }); + }); + + let downloadEnd; + fileReceiver.on('decrypting', () => { + downloadEnd = Date.now(); + window.removeEventListener('unload', unloadHandler); + fileReceiver.removeAllListeners('progress'); + document.l10n.formatValue('decryptingFile').then(progress.setText); + }); + + fileReceiver.on('hashing', () => { + document.l10n.formatValue('verifyingFile').then(progress.setText); + }); + + fileReceiver + .download() + .catch(err => { + metrics.stoppedDownload({ size, err }); + + if (err.message === 'notfound') { + location.reload(); + } else { + document.l10n.formatValue('errorPageHeader').then(translated => { + $title.text(translated); + }); + $downloadBtn.attr('hidden', true); + $('#expired-img').removeAttr('hidden'); + } + throw err; + }) + .then(([decrypted, fname]) => { + const endTime = Date.now(); + const time = endTime - startTime; + const downloadTime = endTime - downloadEnd; + const speed = size / (downloadTime / 1000); + storage.totalDownloads += 1; + metrics.completedDownload({ size, time, speed }); + progress.setText(' '); + document.l10n + .formatValues('downloadNotification', 'downloadFinish') + .then(translated => { + notify(translated[0]); + $title.text(translated[1]); + }); + + const dataView = new DataView(decrypted); + const blob = new Blob([dataView]); + const downloadUrl = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = downloadUrl; + if (window.navigator.msSaveBlob) { + // if we are in microsoft edge or IE + window.navigator.msSaveBlob(blob, fname); + return; + } + a.download = fname; + document.body.appendChild(a); + a.click(); + }) + .catch(err => { + Raven.captureException(err); + return Promise.reject(err); + }) + .then(() => links.setOpenInNewTab(false)); +} $(() => { + const $file = $('#dl-file'); + const filename = $file.attr('data-filename'); + const b = Number($file.attr('data-size')); + const size = bytes(b); + document.l10n + .formatValue('downloadFileSize', { size }) + .then(str => $('#dl-filesize').text(str)); + document.l10n + .formatValue('downloadingPageProgress', { filename, size }) + .then(str => $('#dl-title').text(str)); + gcmCompliant() .then(() => { - const $downloadBtn = $('#download-btn'); - const $dlProgress = $('#dl-progress'); - const $progressText = $('.progress-text'); - const $title = $('.title'); - - const filename = $('#dl-filename').text(); - const size = Number($('#dl-size').text()); - const ttl = Number($('#dl-ttl').text()); - - //initiate progress bar - $dlProgress.circleProgress({ - value: 0.0, - startAngle: -Math.PI / 2, - fill: '#3B9DFF', - size: 158, - animation: { duration: 300 } - }); - - const download = () => { - // Disable the download button to avoid accidental double clicks. - $downloadBtn.attr('disabled', 'disabled'); - links.setOpenInNewTab(true); - - const fileReceiver = new FileReceiver(); - - fileReceiver.on('progress', progress => { - window.onunload = function() { - metrics.cancelledDownload({ size }); - }; - - $('#download-page-one').attr('hidden', true); - $('#download-progress').removeAttr('hidden'); - const percent = progress[0] / progress[1]; - // update progress bar - $dlProgress.circleProgress('value', percent); - $('.percent-number').text(`${Math.floor(percent * 100)}`); - $progressText.text( - `${filename} (${bytes(progress[0], { - decimalPlaces: 1, - fixedDecimals: true - })} of ${bytes(progress[1], { decimalPlaces: 1 })})` - ); - }); - - let downloadEnd; - fileReceiver.on('decrypting', isStillDecrypting => { - // The file is being decrypted - if (isStillDecrypting) { - fileReceiver.removeAllListeners('progress'); - window.onunload = null; - document.l10n.formatValue('decryptingFile').then(decryptingFile => { - $progressText.text(decryptingFile); - }); - } else { - downloadEnd = Date.now(); - } - }); - - fileReceiver.on('hashing', isStillHashing => { - // The file is being hashed to make sure a malicious user hasn't tampered with it - if (isStillHashing) { - document.l10n.formatValue('verifyingFile').then(verifyingFile => { - $progressText.text(verifyingFile); - }); - } else { - $progressText.text(' '); - document.l10n - .formatValues('downloadNotification', 'downloadFinish') - .then(translated => { - notify(translated[0]); - $title.text(translated[1]); - }); - } - }); - - const startTime = Date.now(); - - metrics.startedDownload({ size, ttl }); - - fileReceiver - .download() - .catch(err => { - metrics.stoppedDownload({ size, err }); - - if (err.message === 'notfound') { - location.reload(); - } else { - document.l10n.formatValue('errorPageHeader').then(translated => { - $title.text(translated); - }); - $downloadBtn.attr('hidden', true); - $('#expired-img').removeAttr('hidden'); - } - throw err; - }) - .then(([decrypted, fname]) => { - const endTime = Date.now(); - const time = endTime - startTime; - const downloadTime = endTime - downloadEnd; - const speed = size / (downloadTime / 1000); - storage.totalDownloads += 1; - metrics.completedDownload({ size, time, speed }); - - const dataView = new DataView(decrypted); - const blob = new Blob([dataView]); - const downloadUrl = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = downloadUrl; - if (window.navigator.msSaveBlob) { - // if we are in microsoft edge or IE - window.navigator.msSaveBlob(blob, fname); - return; - } - a.download = fname; - document.body.appendChild(a); - a.click(); - }) - .catch(err => { - Raven.captureException(err); - return Promise.reject(err); - }) - .then(() => links.setOpenInNewTab(false)); - }; - - $downloadBtn.on('click', download); + $('#download-btn').on('click', download); }) .catch(err => { metrics.unsupported({ err }).then(() => { diff --git a/frontend/src/fileReceiver.js b/frontend/src/fileReceiver.js index ebbe5cc8..5b1c21fb 100644 --- a/frontend/src/fileReceiver.js +++ b/frontend/src/fileReceiver.js @@ -62,7 +62,7 @@ class FileReceiver extends EventEmitter { }); }) .then(([fdata, key]) => { - this.emit('decrypting', true); + this.emit('decrypting'); return Promise.all([ window.crypto.subtle .decrypt( @@ -76,7 +76,6 @@ class FileReceiver extends EventEmitter { fdata.data ) .then(decrypted => { - this.emit('decrypting', false); return Promise.resolve(decrypted); }), fdata.filename, @@ -84,11 +83,10 @@ class FileReceiver extends EventEmitter { ]); }) .then(([decrypted, fname, proposedHash]) => { - this.emit('hashing', true); + this.emit('hashing'); return window.crypto.subtle .digest('SHA-256', decrypted) .then(calculatedHash => { - this.emit('hashing', false); const integrity = new Uint8Array(calculatedHash).toString() === proposedHash.toString(); diff --git a/frontend/src/fileSender.js b/frontend/src/fileSender.js index deee1b09..989be7fa 100644 --- a/frontend/src/fileSender.js +++ b/frontend/src/fileSender.js @@ -34,7 +34,7 @@ class FileSender extends EventEmitter { upload() { const self = this; - self.emit('loading', true); + self.emit('loading'); return Promise.all([ window.crypto.subtle.generateKey( { @@ -48,12 +48,10 @@ class FileSender extends EventEmitter { const reader = new FileReader(); reader.readAsArrayBuffer(this.file); reader.onload = function(event) { - self.emit('loading', false); - self.emit('hashing', true); + self.emit('hashing'); const plaintext = new Uint8Array(this.result); window.crypto.subtle.digest('SHA-256', plaintext).then(hash => { - self.emit('hashing', false); - self.emit('encrypting', true); + self.emit('encrypting'); resolve({ plaintext: plaintext, hash: new Uint8Array(hash) }); }); }; @@ -64,23 +62,16 @@ class FileSender extends EventEmitter { ]) .then(([secretKey, file]) => { return Promise.all([ - window.crypto.subtle - .encrypt( - { - name: 'AES-GCM', - iv: this.iv, - additionalData: file.hash, - tagLength: 128 - }, - secretKey, - file.plaintext - ) - .then(encrypted => { - self.emit('encrypting', false); - return new Promise((resolve, reject) => { - resolve(encrypted); - }); - }), + window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: this.iv, + additionalData: file.hash, + tagLength: 128 + }, + secretKey, + file.plaintext + ), window.crypto.subtle.exportKey('jwk', secretKey), new Promise((resolve, reject) => { resolve(file.hash); diff --git a/frontend/src/progress.js b/frontend/src/progress.js new file mode 100644 index 00000000..b054a86d --- /dev/null +++ b/frontend/src/progress.js @@ -0,0 +1,41 @@ +const { bytes } = require('./utils'); +const $ = require('jquery'); +require('jquery-circle-progress'); + +let $progress = null; +let $percent = null; +let $text = null; + +document.addEventListener('DOMContentLoaded', function() { + $percent = $('.percent-number'); + $text = $('.progress-text'); + $progress = $('.progress-bar'); + $progress.circleProgress({ + value: 0.0, + startAngle: -Math.PI / 2, + fill: '#3B9DFF', + size: 158, + animation: { duration: 300 } + }); +}); + +function setProgress(params) { + const percent = params.complete / params.total; + $progress.circleProgress('value', percent); + $percent.text(`${Math.floor(percent * 100)}`); + document.l10n + .formatValue('fileSizeProgress', { + partialSize: bytes(params.complete), + totalSize: bytes(params.total) + }) + .then(setText); +} + +function setText(str) { + $text.text(str); +} + +module.exports = { + setProgress, + setText +}; diff --git a/frontend/src/upload.js b/frontend/src/upload.js index 8dd315b5..7b87fdc3 100644 --- a/frontend/src/upload.js +++ b/frontend/src/upload.js @@ -2,18 +2,18 @@ const { Raven } = require('./common'); const FileSender = require('./fileSender'); const { + bytes, copyToClipboard, notify, gcmCompliant, ONE_DAY_IN_MS } = require('./utils'); -const bytes = require('bytes'); const Storage = require('./storage'); const storage = new Storage(localStorage); const metrics = require('./metrics'); +const progress = require('./progress'); const $ = require('jquery'); -require('jquery-circle-progress'); const allowedCopy = () => { const support = !!document.queryCommandSupported; @@ -27,10 +27,8 @@ $(() => { const $copyBtn = $('#copy-btn'); const $link = $('#link'); const $uploadWindow = $('.upload-window'); - const $ulProgress = $('#ul-progress'); const $uploadError = $('#upload-error'); const $uploadProgress = $('#upload-progress'); - const $progressText = $('.progress-text'); const $fileList = $('#file-list'); $pageOne.removeAttr('hidden'); @@ -96,15 +94,6 @@ $(() => { $uploadWindow.removeClass('ondrag'); }); - //initiate progress bar - $ulProgress.circleProgress({ - value: 0.0, - startAngle: -Math.PI / 2, - fill: '#3B9DFF', - size: 158, - animation: { duration: 300 } - }); - //link back to homepage $('.send-new').attr('href', window.location); @@ -152,9 +141,15 @@ $(() => { $pageOne.attr('hidden', true); $uploadError.attr('hidden', true); $uploadProgress.removeAttr('hidden'); - document.l10n.formatValue('importingFile').then(importingFile => { - $progressText.text(importingFile); - }); + document.l10n + .formatValue('uploadingPageProgress', { + size: bytes(file.size), + filename: file.name + }) + .then(str => { + $('#upload-filename').text(str); + }); + document.l10n.formatValue('importingFile').then(progress.setText); //don't allow drag and drop when not on page-one $(document.body).off('drop', onUpload); @@ -168,40 +163,21 @@ $(() => { location.reload(); }); - fileSender.on('progress', progress => { - const percent = progress[0] / progress[1]; - // update progress bar - $ulProgress.circleProgress('value', percent); - $ulProgress.circleProgress().on('circle-animation-end', function() { - $('.percent-number').text(`${Math.floor(percent * 100)}`); - }); - $progressText.text( - `${file.name} (${bytes(progress[0], { - decimalPlaces: 1, - fixedDecimals: true - })} of ${bytes(progress[1], { decimalPlaces: 1 })})` - ); - }); - - fileSender.on('hashing', isStillHashing => { - // The file is being hashed - if (isStillHashing) { - document.l10n.formatValue('verifyingFile').then(verifyingFile => { - $progressText.text(verifyingFile); - }); - } - }); - let uploadStart; - fileSender.on('encrypting', isStillEncrypting => { - // The file is being encrypted - if (isStillEncrypting) { - document.l10n.formatValue('encryptingFile').then(encryptingFile => { - $progressText.text(encryptingFile); - }); - } else { - uploadStart = Date.now(); - } + fileSender.on('progress', data => { + uploadStart = uploadStart || Date.now(); + progress.setProgress({ + complete: data[0], + total: data[1] + }); + }); + + fileSender.on('hashing', () => { + document.l10n.formatValue('verifyingFile').then(progress.setText); + }); + + fileSender.on('encrypting', () => { + document.l10n.formatValue('encryptingFile').then(progress.setText); }); let t; @@ -244,16 +220,11 @@ $(() => { }; storage.addFile(info.fileId, fileData); - $('#upload-filename').attr( - 'data-l10n-id', - 'uploadSuccessConfirmHeader' - ); - t = window.setTimeout(() => { - $pageOne.attr('hidden', true); - $uploadProgress.attr('hidden', true); - $uploadError.attr('hidden', true); - $('#share-link').removeAttr('hidden'); - }, 1000); + + $pageOne.attr('hidden', true); + $uploadProgress.attr('hidden', true); + $uploadError.attr('hidden', true); + $('#share-link').removeAttr('hidden'); populateFileList(fileData); document.l10n.formatValue('notifyUploadDone').then(str => { @@ -331,7 +302,7 @@ $(() => { $link.attr('value', url); $('#copy-text') - .attr('data-l10n-args', `{"filename": "${file.name}"}`) + .attr('data-l10n-args', JSON.stringify({ filename: file.name })) .attr('data-l10n-id', 'copyUrlFormLabelWithName'); $popupText.attr('tabindex', '-1'); diff --git a/frontend/src/utils.js b/frontend/src/utils.js index b6d0280c..02a77e30 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -104,9 +104,29 @@ function copyToClipboard(str) { return result; } +const LOCALIZE_NUMBERS = !!( + typeof Intl === 'object' && + Intl && + typeof Intl.NumberFormat === 'function' +); + +const UNITS = ['B', 'kB', 'MB', 'GB']; +function bytes(num) { + const exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1); + const n = Number(num / Math.pow(1000, exponent)); + const nStr = LOCALIZE_NUMBERS + ? n.toLocaleString(navigator.languages, { + minimumFractionDigits: 1, + maximumFractionDigits: 1 + }) + : n.toFixed(1); + return `${nStr}${UNITS[exponent]}`; +} + const ONE_DAY_IN_MS = 86400000; module.exports = { + bytes, copyToClipboard, arrayToHex, hexToArray, diff --git a/package-lock.json b/package-lock.json index e478f713..f9331efd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -663,11 +663,6 @@ "readable-stream": "1.1.14" } }, - "bytes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.5.0.tgz", - "integrity": "sha1-TJQj6i0lLCcMQbK97+/5u2tiwGo=" - }, "cached-path-relative": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.1.tgz", diff --git a/package.json b/package.json index d1ac6f23..89c56b67 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "dependencies": { "aws-sdk": "^2.89.0", "body-parser": "^1.17.2", - "bytes": "^2.5.0", "connect-busboy": "0.0.2", "convict": "^3.0.0", "express": "^4.15.3", diff --git a/public/locales/en-US/send.ftl b/public/locales/en-US/send.ftl index b1c61a75..c40d455e 100644 --- a/public/locales/en-US/send.ftl +++ b/public/locales/en-US/send.ftl @@ -11,7 +11,7 @@ uploadPageBrowseButton = Select a file on your computer .title = Select a file on your computer uploadPageMultipleFilesAlert = Uploading multiple files or a folder is currently not supported. uploadPageBrowseButtonTitle = Upload file -uploadingPageHeader = Uploading Your File +uploadingPageProgress = Uploading { $filename } ({ $size }) importingFile = Importing… verifyingFile = Verifying… encryptingFile = Encrypting… @@ -50,6 +50,8 @@ downloadButtonLabel = Download .title = Download downloadNotification = Your download has completed. downloadFinish = Download Complete +// This message is displayed when uploading or downloading a file, e.g. "(1,3 MB of 10 MB)". +fileSizeProgress = ({ $partialSize } of { $totalSize }) // Firefox Send is a brand name and should not be localized. Title text for button should be the same. sendYourFilesLink = Try Firefox Send .title = Try Firefox Send diff --git a/server/server.js b/server/server.js index 674d541d..d8259083 100644 --- a/server/server.js +++ b/server/server.js @@ -4,7 +4,6 @@ const busboy = require('connect-busboy'); const path = require('path'); const bodyParser = require('body-parser'); const helmet = require('helmet'); -const bytes = require('bytes'); const conf = require('./config.js'); const storage = require('./storage.js'); const Raven = require('raven'); @@ -141,13 +140,15 @@ app.get('/download/:id', async (req, res) => { } try { - const filename = await storage.filename(id); - const contentLength = await storage.length(id); + const efilename = await storage.filename(id); + const filename = decodeURIComponent(efilename); + const filenameJson = JSON.stringify({ filename }); + const sizeInBytes = await storage.length(id); const ttl = await storage.ttl(id); res.render('download', { - filename: decodeURIComponent(filename), - filesize: bytes(contentLength), - sizeInBytes: contentLength, + filename, + filenameJson, + sizeInBytes, ttl }); } catch (e) { diff --git a/views/download.handlebars b/views/download.handlebars index ede45fdd..25d458b9 100644 --- a/views/download.handlebars +++ b/views/download.handlebars @@ -2,13 +2,13 @@