Merge pull request #654 from mozilla/multi

Multiple download UI
This commit is contained in:
Danny Coates 2017-12-04 19:29:03 -08:00 committed by GitHub
commit 46a3933dbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1159 additions and 453 deletions

View File

@ -97,6 +97,13 @@ export default function(state, emitter) {
lastRender = Date.now(); 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 }) => { emitter.on('delete', async ({ file, location }) => {
try { try {
metrics.deletedUpload({ metrics.deletedUpload({
@ -108,7 +115,7 @@ export default function(state, emitter) {
location location
}); });
state.storage.remove(file.id); state.storage.remove(file.id);
await FileSender.delete(file.id, file.deleteToken); await FileSender.delete(file.id, file.ownerToken);
} catch (e) { } catch (e) {
state.raven.captureException(e); state.raven.captureException(e);
} }

View File

@ -116,7 +116,8 @@ export default class FileReceiver extends Nanobus {
// TODO // TODO
} }
fetchMetadata(sig) { async fetchMetadata(nonce) {
const authHeader = await this.getAuthHeader(nonce);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => { xhr.onreadystatechange = () => {
@ -132,7 +133,7 @@ export default class FileReceiver extends Nanobus {
xhr.onerror = () => reject(new Error(0)); xhr.onerror = () => reject(new Error(0));
xhr.ontimeout = () => reject(new Error(0)); xhr.ontimeout = () => reject(new Error(0));
xhr.open('get', `/api/metadata/${this.file.id}`); xhr.open('get', `/api/metadata/${this.file.id}`);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`); xhr.setRequestHeader('Authorization', authHeader);
xhr.responseType = 'json'; xhr.responseType = 'json';
xhr.timeout = 2000; xhr.timeout = 2000;
xhr.send(); xhr.send();
@ -140,16 +141,16 @@ export default class FileReceiver extends Nanobus {
} }
async getMetadata(nonce) { async getMetadata(nonce) {
let data = null;
try { try {
const authKey = await this.authKeyPromise; try {
const sig = await window.crypto.subtle.sign( data = await this.fetchMetadata(nonce);
{ } catch (e) {
name: 'HMAC' if (e.message === '401') {
}, // allow one retry for changed nonce
authKey, data = await this.fetchMetadata(e.nonce);
b64ToArray(nonce) }
); }
const data = await this.fetchMetadata(new Uint8Array(sig));
const metaKey = await this.metaKeyPromise; const metaKey = await this.metaKeyPromise;
const json = await window.crypto.subtle.decrypt( 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) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@ -190,9 +192,10 @@ export default class FileReceiver extends Nanobus {
reject(new Error('notfound')); reject(new Error('notfound'));
return; return;
} }
if (xhr.status !== 200) { 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]); const blob = new Blob([xhr.response]);
@ -205,17 +208,13 @@ export default class FileReceiver extends Nanobus {
}; };
xhr.open('get', this.url); xhr.open('get', this.url);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`); xhr.setRequestHeader('Authorization', authHeader);
xhr.responseType = 'blob'; xhr.responseType = 'blob';
xhr.send(); xhr.send();
}); });
} }
async download(nonce) { async getAuthHeader(nonce) {
this.state = 'downloading';
this.emit('progress', this.progress);
try {
const encryptKey = await this.encryptKeyPromise;
const authKey = await this.authKeyPromise; const authKey = await this.authKeyPromise;
const sig = await window.crypto.subtle.sign( const sig = await window.crypto.subtle.sign(
{ {
@ -224,7 +223,22 @@ export default class FileReceiver extends Nanobus {
authKey, authKey,
b64ToArray(nonce) b64ToArray(nonce)
); );
const ciphertext = await this.downloadFile(new Uint8Array(sig)); return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
}
async download(nonce) {
this.state = 'downloading';
this.emit('progress', this.progress);
try {
const encryptKey = await this.encryptKeyPromise;
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.msg = 'decryptingFile';
this.emit('decrypting'); this.emit('decrypting');
const plaintext = await window.crypto.subtle.decrypt( 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, url: responseObj.url,
id: responseObj.id, id: responseObj.id,
secretKey: arrayToB64(this.rawSecret), secretKey: arrayToB64(this.rawSecret),
deleteToken: responseObj.delete, ownerToken: responseObj.owner,
nonce nonce
}); });
} }
@ -205,6 +224,17 @@ export default class FileSender extends Nanobus {
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth)); 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) { static async setPassword(password, file) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const secretKey = await window.crypto.subtle.importKey( const secretKey = await window.crypto.subtle.importKey(
@ -229,13 +259,7 @@ export default class FileSender extends Nanobus {
true, true,
['sign'] ['sign']
); );
const sig = await window.crypto.subtle.sign( const authHeader = await this.getAuthHeader(authKey, file.nonce);
{
name: 'HMAC'
},
authKey,
b64ToArray(file.nonce)
);
const pwdKey = await window.crypto.subtle.importKey( const pwdKey = await window.crypto.subtle.importKey(
'raw', 'raw',
encoder.encode(password), encoder.encode(password),
@ -278,10 +302,7 @@ export default class FileSender extends Nanobus {
xhr.onerror = () => reject(new Error(0)); xhr.onerror = () => reject(new Error(0));
xhr.ontimeout = () => reject(new Error(0)); xhr.ontimeout = () => reject(new Error(0));
xhr.open('post', `/api/password/${file.id}`); xhr.open('post', `/api/password/${file.id}`);
xhr.setRequestHeader( xhr.setRequestHeader('Authorization', authHeader);
'Authorization',
`send-v1 ${arrayToB64(new Uint8Array(sig))}`
);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'json'; xhr.responseType = 'json';
xhr.timeout = 2000; xhr.timeout = 2000;

View File

@ -1,3 +1,4 @@
import 'fluent-intl-polyfill';
import app from './routes'; import app from './routes';
import locale from '../common/locales'; import locale from '../common/locales';
import fileManager from './fileManager'; 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) { function completedDownload(params) {
return sendEvent('recipient', 'download-stopped', { return sendEvent('recipient', 'download-stopped', {
cm1: params.size, cm1: params.size,
@ -272,6 +282,7 @@ export {
cancelledUpload, cancelledUpload,
stoppedUpload, stoppedUpload,
completedUpload, completedUpload,
changedDownloadLimit,
deletedUpload, deletedUpload,
startedDownload, startedDownload,
cancelledDownload, cancelledDownload,

View File

@ -18,7 +18,9 @@ module.exports = function(file, state, emit) {
const remaining = timeLeft(ttl) || state.translate('linkExpiredAlt'); const remaining = timeLeft(ttl) || state.translate('linkExpiredAlt');
const row = html` const row = html`
<tr id="${file.id}"> <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"> <td class="center-col">
<img onclick=${copyClick} src="${assets.get( <img onclick=${copyClick} src="${assets.get(
'copy-16.svg' '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 assets = require('../../common/assets');
const notFound = require('./notFound'); const notFound = require('./notFound');
const uploadPassword = require('./uploadPassword'); const uploadPassword = require('./uploadPassword');
const selectbox = require('./selectbox');
const { allowedCopy, delay, fadeOut } = require('../utils'); const { allowedCopy, delay, fadeOut } = require('../utils');
function passwordComplete(state, password) { function passwordComplete(state, password) {
@ -14,6 +15,24 @@ function passwordComplete(state, password) {
return el; 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) { module.exports = function(state, emit) {
const file = state.storage.getFileById(state.params.id); const file = state.storage.getFileById(state.params.id);
if (!file) { if (!file) {
@ -27,7 +46,7 @@ module.exports = function(state, emit) {
: uploadPassword(state, emit); : uploadPassword(state, emit);
const div = html` const div = html`
<div id="share-link" class="fadeIn"> <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="share-window">
<div id="copy-text"> <div id="copy-text">
${state.translate('copyUrlFormLabelWithName', { ${state.translate('copyUrlFormLabelWithName', {

View File

@ -938,12 +938,11 @@ tbody {
#addPasswordWrapper label { #addPasswordWrapper label {
line-height: 20px; line-height: 20px;
cursor: pointer; cursor: pointer;
position: relative; color: #737373;
opacity: 0.6;
} }
#addPassword:checked + label { #addPassword:checked + label {
opacity: 1; color: #000;
} }
#addPasswordWrapper label::before { #addPasswordWrapper label::before {
@ -985,6 +984,47 @@ tbody {
margin-left: 10px; 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) { @media (max-device-width: 992px), (max-width: 992px) {
.popup .popuptext { .popup .popuptext {
left: auto; 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. - `cm5` - the number of files the user has ever uploaded.
- `cm6` - the number of unexpired files the user has uploaded. - `cm6` - the number of unexpired files the user has uploaded.
- `cm7` - the number of files the user has ever downloaded. - `cm7` - the number of files the user has ever downloaded.
- `cm8` - the number of downloads permitted by the uploader.
### Custom Dimensions ### Custom Dimensions
- `cd1` - the method by which the user initiated an upload. One of `drag`, `click`. - `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` - `cd2`
- `cd6` - `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` #### `password-added`
Triggered whenever a password is added to a file. Includes: Triggered whenever a password is added to a file. Includes:

1154
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -25,6 +25,15 @@ uploadingFileNotification = Notify me when the upload is complete.
uploadSuccessConfirmHeader = Ready to Send uploadSuccessConfirmHeader = Ready to Send
uploadSvgAlt = Upload uploadSvgAlt = Upload
uploadSuccessTimingHeader = The link to your file will expire after 1 download or in 24 hours. 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 } copyUrlFormLabelWithName = Copy and share the link to send your file: { $filename }
copyUrlFormButton = Copy to clipboard copyUrlFormButton = Copy to clipboard
copiedUrl = Copied! copiedUrl = Copied!

View File

@ -12,15 +12,15 @@ module.exports = async function(req, res) {
return; 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); res.sendStatus(404);
return; return;
} }
try { try {
const err = await storage.delete(id, delete_token); const err = await storage.delete(id, ownerToken);
if (!err) { if (!err) {
res.sendStatus(200); 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')); const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64')); hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest(); const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) { 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); return res.sendStatus(401);
} }
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
const contentLength = await storage.length(id); const contentLength = await storage.length(id);
res.writeHead(200, { res.writeHead(200, {
'Content-Disposition': 'attachment', 'Content-Disposition': 'attachment',
@ -36,10 +36,16 @@ module.exports = async function(req, res) {
const file_stream = storage.get(id); const file_stream = storage.get(id);
file_stream.on('end', async () => { file_stream.on('end', async () => {
const dl = (+meta.dl || 0) + 1;
const dlimit = +meta.dlimit || 1;
try { try {
if (dl >= dlimit) {
await storage.forceDelete(id); await storage.forceDelete(id);
} else {
await storage.setField(id, 'dl', dl);
}
} catch (e) { } catch (e) {
log.info('DeleteError:', id); log.info('StorageError:', id);
} }
}); });

View File

@ -42,6 +42,7 @@ module.exports = function(app) {
force: !IS_DEV force: !IS_DEV
}) })
); );
if (!IS_DEV) {
app.use( app.use(
helmet.contentSecurityPolicy({ helmet.contentSecurityPolicy({
directives: { directives: {
@ -53,7 +54,11 @@ module.exports = function(app) {
], ],
imgSrc: ["'self'", 'https://www.google-analytics.com'], imgSrc: ["'self'", 'https://www.google-analytics.com'],
scriptSrc: ["'self'"], scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://code.cdn.mozilla.net'], styleSrc: [
"'self'",
"'unsafe-inline'",
'https://code.cdn.mozilla.net'
],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'], fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"], formAction: ["'none'"],
frameAncestors: ["'none'"], frameAncestors: ["'none'"],
@ -62,6 +67,7 @@ module.exports = function(app) {
} }
}) })
); );
}
app.use( app.use(
busboy({ busboy({
limits: { limits: {
@ -88,6 +94,7 @@ module.exports = function(app) {
app.post('/api/upload', require('./upload')); app.post('/api/upload', require('./upload'));
app.post('/api/delete/:id', require('./delete')); app.post('/api/delete/:id', require('./delete'));
app.post('/api/password/:id', require('./password')); app.post('/api/password/:id', require('./password'));
app.post('/api/params/:id', require('./params'));
app.get('/__version__', function(req, res) { app.get('/__version__', function(req, res) {
res.sendFile(require.resolve('../../dist/version.json')); 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')); const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64')); hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest(); 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'); const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce); storage.setField(id, 'nonce', nonce);
res.set('WWW-Authenticate', `send-v1 ${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 size = await storage.length(id);
const ttl = await storage.ttl(id); const ttl = await storage.ttl(id);
res.send({ 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')); const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64')); hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest(); const verifyHash = hmac.digest();
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) { 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); 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) { } catch (e) {
res.sendStatus(404); res.sendStatus(404);
} }

View File

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

View File

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

View File

@ -51,7 +51,9 @@ module.exports = {
include: [ include: [
path.resolve(__dirname, 'app'), path.resolve(__dirname, 'app'),
path.resolve(__dirname, 'common'), 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: { options: {
babelrc: false, babelrc: false,