Added multiple download option

This commit is contained in:
Danny Coates 2017-11-30 13:41:09 -08:00
parent beb3a6e67b
commit 7b4060f9e1
No known key found for this signature in database
GPG Key ID: 4C442633C62E00CB
22 changed files with 1159 additions and 453 deletions

View File

@ -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);
}

View File

@ -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(

View File

@ -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;

View File

@ -1,3 +1,4 @@
import 'fluent-intl-polyfill';
import app from './routes';
import locale from '../common/locales';
import fileManager from './fileManager';

View File

@ -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,

View File

@ -18,7 +18,9 @@ module.exports = function(file, state, emit) {
const remaining = timeLeft(ttl) || state.translate('linkExpiredAlt');
const row = html`
<tr id="${file.id}">
<td class="overflow-col" title="${file.name}">${file.name}</td>
<td class="overflow-col" title="${
file.name
}"><a class="link" href="/share/${file.id}">${file.name}</a></td>
<td class="center-col">
<img onclick=${copyClick} src="${assets.get(
'copy-16.svg'

View File

@ -0,0 +1,56 @@
const html = require('choo/html');
module.exports = function(selected, options, translate, changed) {
const id = `select-${Math.random()}`;
let x = selected;
function close() {
const ul = document.getElementById(id);
const body = document.querySelector('body');
ul.classList.remove('active');
body.removeEventListener('click', close);
}
function toggle(event) {
event.stopPropagation();
const ul = document.getElementById(id);
if (ul.classList.contains('active')) {
close();
} else {
ul.classList.add('active');
const body = document.querySelector('body');
body.addEventListener('click', close);
}
}
function choose(event) {
event.stopPropagation();
const target = event.target;
const value = +target.dataset.value;
target.parentNode.previousSibling.firstElementChild.textContent = translate(
value
);
if (x !== value) {
x = value;
changed(value);
}
close();
}
return html`
<div class="selectbox">
<div onclick=${toggle}>
<span class="link">${translate(selected)}</span>
<svg width="32" height="32">
<polygon points="8 18 17 28 26 18" fill="#0094fb"/>
</svg>
</div>
<ul id="${id}" class="selectOptions">
${options.map(
i =>
html`<li class="selectOption" onclick=${choose} data-value="${i}">${
i
}</li>`
)}
</ul>
</div>`;
};

View File

@ -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([
`<div>${translate('expireInfo', {
downloadCount: '<select></select>',
timespan: translate('timespanHours', { number: 24 })
})}</div>`
]);
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`
<div id="share-link" class="fadeIn">
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
<div class="title">${expireInfo(file, state.translate, emit)}</div>
<div id="share-window">
<div id="copy-text">
${state.translate('copyUrlFormLabelWithName', {

View File

@ -938,12 +938,11 @@ tbody {
#addPasswordWrapper label {
line-height: 20px;
cursor: pointer;
position: relative;
opacity: 0.6;
color: #737373;
}
#addPassword:checked + label {
opacity: 1;
color: #000;
}
#addPasswordWrapper label::before {
@ -985,6 +984,47 @@ tbody {
margin-left: 10px;
}
.selectbox {
display: inline-block;
position: relative;
cursor: pointer;
}
.selectSelected {
cursor: pointer;
}
.selectOptions {
display: none;
}
.selectOptions.active {
display: block;
position: absolute;
top: 0;
left: 0;
padding: 0;
margin: 40px 0;
background-color: white;
border: 1px solid rgba(12, 12, 13, 0.3);
border-radius: 4px;
box-shadow: 1px 2px 4px rgba(12, 12, 13, 0.3);
}
.selectOption {
color: #737373;
font-size: 12pt;
list-style: none;
user-select: none;
white-space: nowrap;
padding: 0 60px;
border-bottom: 1px solid rgba(12, 12, 13, 0.3);
}
.selectOption:hover {
background-color: #f4f4f4;
}
@media (max-device-width: 992px), (max-width: 992px) {
.popup .popuptext {
left: auto;

View File

@ -27,6 +27,7 @@ Data will be collected with Google Analytics and follow [Test Pilot standards](h
- `cm5` - the number of files the user has ever uploaded.
- `cm6` - the number of unexpired files the user has uploaded.
- `cm7` - the number of files the user has ever downloaded.
- `cm8` - the number of downloads permitted by the uploader.
### Custom Dimensions
- `cd1` - the method by which the user initiated an upload. One of `drag`, `click`.
@ -67,6 +68,17 @@ Triggered whenever a user stops uploading a file. Includes:
- `cd2`
- `cd6`
#### `download-limit-changed`
Triggered whenever the sender changes the download limit. Includes:
- `ec` - `sender`
- `ea` - `download-limit-changed`
- `cm1`
- `cm5`
- `cm6`
- `cm7`
- `cm8`
#### `password-added`
Triggered whenever a password is added to a file. Includes:

1170
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,39 +43,40 @@
"node": ">=8.2.0"
},
"devDependencies": {
"autoprefixer": "^7.1.6",
"autoprefixer": "^7.2.1",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-yo-yoify": "^1.0.1",
"babel-plugin-yo-yoify": "^1.0.2",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",
"base64-js": "^1.2.1",
"copy-webpack-plugin": "^4.2.0",
"copy-webpack-plugin": "^4.2.3",
"cross-env": "^5.1.1",
"css-loader": "^0.28.7",
"css-mqpacker": "^6.0.1",
"cssnano": "^3.10.0",
"eslint": "^4.10.0",
"eslint": "^4.12.0",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-node": "^5.2.1",
"eslint-plugin-security": "^1.4.0",
"expose-loader": "^0.7.3",
"expose-loader": "^0.7.4",
"extract-loader": "^1.0.1",
"file-loader": "^1.1.5",
"fluent-intl-polyfill": "^0.1.0",
"git-rev-sync": "^1.9.1",
"github-changes": "^1.1.1",
"html-loader": "^0.5.1",
"husky": "^0.14.3",
"lint-staged": "^4.3.0",
"mocha": "^3.5.3",
"nanobus": "^4.3.0",
"nanobus": "^4.3.1",
"npm-run-all": "^4.1.2",
"postcss-loader": "^2.0.8",
"postcss-loader": "^2.0.9",
"prettier": "^1.8.2",
"proxyquire": "^1.8.0",
"raven-js": "^3.19.1",
"raven-js": "^3.20.1",
"redis-mock": "^0.20.0",
"require-from-string": "^2.0.1",
"rimraf": "^2.6.2",
@ -86,16 +87,16 @@
"stylelint-no-unsupported-browser-features": "^1.0.1",
"supertest": "^3.0.0",
"testpilot-ga": "^0.3.0",
"val-loader": "^1.0.2",
"val-loader": "^1.1.0",
"webpack": "^3.8.1",
"webpack-dev-server": "2.9.1",
"webpack-manifest-plugin": "^1.3.2",
"webpack-unassert-loader": "^1.2.0"
},
"dependencies": {
"aws-sdk": "^2.149.0",
"aws-sdk": "^2.162.0",
"body-parser": "^1.18.2",
"choo": "^6.5.1",
"choo": "^6.6.0",
"cldr-core": "^32.0.0",
"connect-busboy": "0.0.2",
"convict": "^4.0.1",
@ -104,7 +105,7 @@
"fluent-langneg": "^0.1.0",
"helmet": "^3.9.0",
"mkdirp": "^0.5.1",
"mozlog": "^2.1.1",
"mozlog": "^2.2.0",
"raven": "^2.2.1",
"redis": "^2.8.0"
},

View File

@ -25,6 +25,15 @@ uploadingFileNotification = Notify me when the upload is complete.
uploadSuccessConfirmHeader = Ready to Send
uploadSvgAlt = Upload
uploadSuccessTimingHeader = The link to your file will expire after 1 download or in 24 hours.
expireInfo = The link to your file will expire after { $downloadCount } or { $timespan }.
downloadCount = { $number ->
[one] 1 download
*[other] { $number } downloads
}
timespanHours = { $number ->
[one] 1 hour
*[other] { $number } hours
}
copyUrlFormLabelWithName = Copy and share the link to send your file: { $filename }
copyUrlFormButton = Copy to clipboard
copiedUrl = Copied!

View File

@ -12,15 +12,15 @@ module.exports = async function(req, res) {
return;
}
const delete_token = req.body.delete_token;
const ownerToken = req.body.owner_token || req.body.delete_token;
if (!delete_token) {
if (!ownerToken) {
res.sendStatus(404);
return;
}
try {
const err = await storage.delete(id, delete_token);
const err = await storage.delete(id, ownerToken);
if (!err) {
res.sendStatus(200);
}

View File

@ -19,12 +19,12 @@ module.exports = async function(req, res) {
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
return res.sendStatus(401);
}
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
const contentLength = await storage.length(id);
res.writeHead(200, {
'Content-Disposition': 'attachment',
@ -36,10 +36,16 @@ module.exports = async function(req, res) {
const file_stream = storage.get(id);
file_stream.on('end', async () => {
const dl = (+meta.dl || 0) + 1;
const dlimit = +meta.dlimit || 1;
try {
await storage.forceDelete(id);
if (dl >= dlimit) {
await storage.forceDelete(id);
} else {
await storage.setField(id, 'dl', dl);
}
} catch (e) {
log.info('DeleteError:', id);
log.info('StorageError:', id);
}
});

View File

@ -42,26 +42,32 @@ module.exports = function(app) {
force: !IS_DEV
})
);
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
connectSrc: [
"'self'",
'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com'
],
imgSrc: ["'self'", 'https://www.google-analytics.com'],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://code.cdn.mozilla.net'],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
reportUri: '/__cspreport__'
}
})
);
if (!IS_DEV) {
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
connectSrc: [
"'self'",
'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com'
],
imgSrc: ["'self'", 'https://www.google-analytics.com'],
scriptSrc: ["'self'"],
styleSrc: [
"'self'",
"'unsafe-inline'",
'https://code.cdn.mozilla.net'
],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
reportUri: '/__cspreport__'
}
})
);
}
app.use(
busboy({
limits: {
@ -88,6 +94,7 @@ module.exports = function(app) {
app.post('/api/upload', require('./upload'));
app.post('/api/delete/:id', require('./delete'));
app.post('/api/password/:id', require('./password'));
app.post('/api/params/:id', require('./params'));
app.get('/__version__', function(req, res) {
res.sendFile(require.resolve('../../dist/version.json'));

View File

@ -17,12 +17,14 @@ module.exports = async function(req, res) {
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
return res.sendStatus(401);
}
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
return res.sendStatus(401);
}
const size = await storage.length(id);
const ttl = await storage.ttl(id);
res.send({

32
server/routes/params.js Normal file
View File

@ -0,0 +1,32 @@
const storage = require('../storage');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
module.exports = async function(req, res) {
const id = req.params.id;
if (!validateID(id)) {
return res.sendStatus(404);
}
const ownerToken = req.body.owner_token;
if (!ownerToken) {
return res.sendStatus(400);
}
const dlimit = req.body.dlimit;
if (!dlimit || dlimit > 20) {
return res.sendStatus(400);
}
try {
const meta = await storage.metadata(id);
if (meta.owner !== ownerToken) {
return res.sendStatus(400);
}
storage.setField(id, 'dlimit', dlimit);
res.sendStatus(200);
} catch (e) {
res.sendStatus(404);
}
};

View File

@ -20,12 +20,13 @@ module.exports = async function(req, res) {
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
return res.sendStatus(401);
}
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
} catch (e) {
res.sendStatus(404);
}

View File

@ -12,9 +12,12 @@ module.exports = function(req, res) {
if (!metadata || !auth) {
return res.sendStatus(400);
}
const owner = crypto.randomBytes(10).toString('hex');
const meta = {
delete: crypto.randomBytes(10).toString('hex'),
dlimit: 1,
dl: 0,
owner,
delete: owner, // delete is deprecated
metadata,
pwd: 0,
auth: auth.split(' ')[1],
@ -30,7 +33,7 @@ module.exports = function(req, res) {
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
res.json({
url,
delete: meta.delete,
owner: meta.owner,
id: newId
});
} catch (e) {

View File

@ -134,7 +134,7 @@ function localSet(newId, file, meta) {
redis_client.hmset(newId, meta);
redis_client.expire(newId, config.expire_seconds);
log.info('localSet:', 'Upload Finished of ' + newId);
resolve(meta.delete);
resolve(meta.owner);
});
fstream.on('error', err => {
@ -145,10 +145,10 @@ function localSet(newId, file, meta) {
});
}
function localDelete(id, delete_token) {
function localDelete(id, ownerToken) {
return new Promise((resolve, reject) => {
redis_client.hget(id, 'delete', (err, reply) => {
if (!reply || delete_token !== reply) {
if (!reply || ownerToken !== reply) {
reject();
} else {
redis_client.del(id);
@ -230,10 +230,10 @@ function awsSet(newId, file, meta) {
);
}
function awsDelete(id, delete_token) {
function awsDelete(id, ownerToken) {
return new Promise((resolve, reject) => {
redis_client.hget(id, 'delete', (err, reply) => {
if (!reply || delete_token !== reply) {
if (!reply || ownerToken !== reply) {
reject();
} else {
const params = {

View File

@ -51,7 +51,9 @@ module.exports = {
include: [
path.resolve(__dirname, 'app'),
path.resolve(__dirname, 'common'),
path.resolve(__dirname, 'node_modules/testpilot-ga/src')
path.resolve(__dirname, 'node_modules/testpilot-ga/src'),
path.resolve(__dirname, 'node_modules/fluent-intl-polyfill'),
path.resolve(__dirname, 'node_modules/intl-pluralrules')
],
options: {
babelrc: false,