From 7b4060f9e1d5638ebf159a895d5121092c06214b Mon Sep 17 00:00:00 2001 From: Danny Coates Date: Thu, 30 Nov 2017 13:41:09 -0800 Subject: [PATCH] Added multiple download option --- app/fileManager.js | 9 +- app/fileReceiver.js | 62 +- app/fileSender.js | 47 +- app/main.js | 1 + app/metrics.js | 11 + app/templates/file.js | 4 +- app/templates/selectbox.js | 56 ++ app/templates/share.js | 21 +- assets/main.css | 46 +- docs/metrics.md | 12 + package-lock.json | 1170 +++++++++++++++++++++++---------- package.json | 25 +- public/locales/en-US/send.ftl | 9 + server/routes/delete.js | 6 +- server/routes/download.js | 16 +- server/routes/index.js | 47 +- server/routes/metadata.js | 8 +- server/routes/params.js | 32 + server/routes/password.js | 7 +- server/routes/upload.js | 9 +- server/storage.js | 10 +- webpack.config.js | 4 +- 22 files changed, 1159 insertions(+), 453 deletions(-) create mode 100644 app/templates/selectbox.js create mode 100644 server/routes/params.js diff --git a/app/fileManager.js b/app/fileManager.js index 588fd93a..8e9b3f40 100644 --- a/app/fileManager.js +++ b/app/fileManager.js @@ -97,6 +97,13 @@ export default function(state, emitter) { lastRender = Date.now(); }); + emitter.on('changeLimit', async ({ file, value }) => { + await FileSender.changeLimit(file.id, file.ownerToken, value); + file.dlimit = value; + state.storage.writeFiles(); + metrics.changedDownloadLimit(file); + }); + emitter.on('delete', async ({ file, location }) => { try { metrics.deletedUpload({ @@ -108,7 +115,7 @@ export default function(state, emitter) { location }); state.storage.remove(file.id); - await FileSender.delete(file.id, file.deleteToken); + await FileSender.delete(file.id, file.ownerToken); } catch (e) { state.raven.captureException(e); } diff --git a/app/fileReceiver.js b/app/fileReceiver.js index ee0cf5ca..71a2cebb 100644 --- a/app/fileReceiver.js +++ b/app/fileReceiver.js @@ -116,7 +116,8 @@ export default class FileReceiver extends Nanobus { // TODO } - fetchMetadata(sig) { + async fetchMetadata(nonce) { + const authHeader = await this.getAuthHeader(nonce); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.onreadystatechange = () => { @@ -132,7 +133,7 @@ export default class FileReceiver extends Nanobus { xhr.onerror = () => reject(new Error(0)); xhr.ontimeout = () => reject(new Error(0)); xhr.open('get', `/api/metadata/${this.file.id}`); - xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`); + xhr.setRequestHeader('Authorization', authHeader); xhr.responseType = 'json'; xhr.timeout = 2000; xhr.send(); @@ -140,16 +141,16 @@ export default class FileReceiver extends Nanobus { } async getMetadata(nonce) { + let data = null; try { - const authKey = await this.authKeyPromise; - const sig = await window.crypto.subtle.sign( - { - name: 'HMAC' - }, - authKey, - b64ToArray(nonce) - ); - const data = await this.fetchMetadata(new Uint8Array(sig)); + try { + data = await this.fetchMetadata(nonce); + } catch (e) { + if (e.message === '401') { + // allow one retry for changed nonce + data = await this.fetchMetadata(e.nonce); + } + } const metaKey = await this.metaKeyPromise; const json = await window.crypto.subtle.decrypt( { @@ -174,7 +175,8 @@ export default class FileReceiver extends Nanobus { } } - downloadFile(sig) { + async downloadFile(nonce) { + const authHeader = await this.getAuthHeader(nonce); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -190,9 +192,10 @@ export default class FileReceiver extends Nanobus { reject(new Error('notfound')); return; } - if (xhr.status !== 200) { - return reject(new Error(xhr.status)); + const err = new Error(xhr.status); + err.nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1]; + return reject(err); } const blob = new Blob([xhr.response]); @@ -205,26 +208,37 @@ export default class FileReceiver extends Nanobus { }; xhr.open('get', this.url); - xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`); + xhr.setRequestHeader('Authorization', authHeader); xhr.responseType = 'blob'; xhr.send(); }); } + async getAuthHeader(nonce) { + const authKey = await this.authKeyPromise; + const sig = await window.crypto.subtle.sign( + { + name: 'HMAC' + }, + authKey, + b64ToArray(nonce) + ); + return `send-v1 ${arrayToB64(new Uint8Array(sig))}`; + } + async download(nonce) { this.state = 'downloading'; this.emit('progress', this.progress); try { const encryptKey = await this.encryptKeyPromise; - const authKey = await this.authKeyPromise; - const sig = await window.crypto.subtle.sign( - { - name: 'HMAC' - }, - authKey, - b64ToArray(nonce) - ); - const ciphertext = await this.downloadFile(new Uint8Array(sig)); + let ciphertext = null; + try { + ciphertext = await this.downloadFile(nonce); + } catch (e) { + if (e.message === '401') { + ciphertext = await this.downloadFile(e.nonce); + } + } this.msg = 'decryptingFile'; this.emit('decrypting'); const plaintext = await window.crypto.subtle.decrypt( diff --git a/app/fileSender.js b/app/fileSender.js index d5a00eec..1212429e 100644 --- a/app/fileSender.js +++ b/app/fileSender.js @@ -35,7 +35,26 @@ export default class FileSender extends Nanobus { } }; - xhr.send(JSON.stringify({ delete_token: token })); + xhr.send(JSON.stringify({ owner_token: token })); + }); + } + + static changeLimit(id, owner_token, dlimit) { + return new Promise((resolve, reject) => { + if (!id || !owner_token) { + return reject(); + } + const xhr = new XMLHttpRequest(); + xhr.open('POST', `/api/params/${id}`); + xhr.setRequestHeader('Content-Type', 'application/json'); + + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.DONE) { + resolve(); + } + }; + + xhr.send(JSON.stringify({ owner_token, dlimit })); }); } @@ -100,7 +119,7 @@ export default class FileSender extends Nanobus { url: responseObj.url, id: responseObj.id, secretKey: arrayToB64(this.rawSecret), - deleteToken: responseObj.delete, + ownerToken: responseObj.owner, nonce }); } @@ -205,6 +224,17 @@ export default class FileSender extends Nanobus { return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth)); } + async getAuthHeader(authKey, nonce) { + const sig = await window.crypto.subtle.sign( + { + name: 'HMAC' + }, + authKey, + b64ToArray(nonce) + ); + return `send-v1 ${arrayToB64(new Uint8Array(sig))}`; + } + static async setPassword(password, file) { const encoder = new TextEncoder(); const secretKey = await window.crypto.subtle.importKey( @@ -229,13 +259,7 @@ export default class FileSender extends Nanobus { true, ['sign'] ); - const sig = await window.crypto.subtle.sign( - { - name: 'HMAC' - }, - authKey, - b64ToArray(file.nonce) - ); + const authHeader = await this.getAuthHeader(authKey, file.nonce); const pwdKey = await window.crypto.subtle.importKey( 'raw', encoder.encode(password), @@ -278,10 +302,7 @@ export default class FileSender extends Nanobus { xhr.onerror = () => reject(new Error(0)); xhr.ontimeout = () => reject(new Error(0)); xhr.open('post', `/api/password/${file.id}`); - xhr.setRequestHeader( - 'Authorization', - `send-v1 ${arrayToB64(new Uint8Array(sig))}` - ); + xhr.setRequestHeader('Authorization', authHeader); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.responseType = 'json'; xhr.timeout = 2000; diff --git a/app/main.js b/app/main.js index 2c55e0ed..7f7e0402 100644 --- a/app/main.js +++ b/app/main.js @@ -1,3 +1,4 @@ +import 'fluent-intl-polyfill'; import app from './routes'; import locale from '../common/locales'; import fileManager from './fileManager'; diff --git a/app/metrics.js b/app/metrics.js index 3f3adf4e..e469d12a 100644 --- a/app/metrics.js +++ b/app/metrics.js @@ -205,6 +205,16 @@ function stoppedUpload(params) { }); } +function changedDownloadLimit(params) { + return sendEvent('sender', 'download-limit-changed', { + cm1: params.size, + cm5: storage.totalUploads, + cm6: storage.files.length, + cm7: storage.totalDownloads, + cm8: params.dlimit + }); +} + function completedDownload(params) { return sendEvent('recipient', 'download-stopped', { cm1: params.size, @@ -272,6 +282,7 @@ export { cancelledUpload, stoppedUpload, completedUpload, + changedDownloadLimit, deletedUpload, startedDownload, cancelledDownload, diff --git a/app/templates/file.js b/app/templates/file.js index 7fc665e9..881ef098 100644 --- a/app/templates/file.js +++ b/app/templates/file.js @@ -18,7 +18,9 @@ module.exports = function(file, state, emit) { const remaining = timeLeft(ttl) || state.translate('linkExpiredAlt'); const row = html` - ${file.name} + ${file.name} +
+ ${translate(selected)} + + + +
+ + `; +}; diff --git a/app/templates/share.js b/app/templates/share.js index 3efeb6bd..4869cb50 100644 --- a/app/templates/share.js +++ b/app/templates/share.js @@ -2,6 +2,7 @@ const html = require('choo/html'); const assets = require('../../common/assets'); const notFound = require('./notFound'); const uploadPassword = require('./uploadPassword'); +const selectbox = require('./selectbox'); const { allowedCopy, delay, fadeOut } = require('../utils'); function passwordComplete(state, password) { @@ -14,6 +15,24 @@ function passwordComplete(state, password) { return el; } +function expireInfo(file, translate, emit) { + const el = html([ + `
${translate('expireInfo', { + downloadCount: '', + timespan: translate('timespanHours', { number: 24 }) + })}
` + ]); + const select = el.querySelector('select'); + const options = [1, 2, 3, 4, 5, 20]; + const t = number => translate('downloadCount', { number }); + const changed = value => emit('changeLimit', { file, value }); + select.parentNode.replaceChild( + selectbox(file.dlimit || 1, options, t, changed), + select + ); + return el; +} + module.exports = function(state, emit) { const file = state.storage.getFileById(state.params.id); if (!file) { @@ -27,7 +46,7 @@ module.exports = function(state, emit) { : uploadPassword(state, emit); const div = html`