Merge branch 'vnext' of https://github.com/mozilla/send into ios-prototype-3

This commit is contained in:
Donovan Preston 2018-08-02 14:01:50 -04:00
commit c13b63ec9c
No known key found for this signature in database
GPG Key ID: B43EF44E428C806E
13 changed files with 239 additions and 102 deletions

View File

@ -135,7 +135,7 @@ async function upload(stream, metadata, verifierB64, onprogress, canceller) {
const endpoint = const endpoint =
window.location.protocol === 'file:' window.location.protocol === 'file:'
? 'wss://send2.dev.lcip.org/api/ws' ? 'wss://send2.dev.lcip.org/api/ws'
: `${protocol}//${host}:${port}/api/ws`; : `${protocol}//${host}${port ? ':' : ''}${port}/api/ws`;
const ws = await asyncInitWebSocket(endpoint); const ws = await asyncInitWebSocket(endpoint);
@ -270,7 +270,7 @@ function download(id, keychain, onprogress, canceller) {
} }
}); });
const auth = await keychain.authHeader(); const auth = await keychain.authHeader();
xhr.open('get', `/api/download/${id}`); xhr.open('get', `/api/download/blob/${id}`);
xhr.setRequestHeader('Authorization', auth); xhr.setRequestHeader('Authorization', auth);
xhr.responseType = 'blob'; xhr.responseType = 'blob';
xhr.send(); xhr.send();

75
app/capabilities.js Normal file
View File

@ -0,0 +1,75 @@
async function checkCrypto() {
try {
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
);
await crypto.subtle.exportKey('raw', key);
await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: crypto.getRandomValues(new Uint8Array(12)),
tagLength: 128
},
key,
new ArrayBuffer(8)
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'PBKDF2',
false,
['deriveKey']
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'HKDF',
false,
['deriveKey']
);
return true;
} catch (err) {
return false;
}
}
function checkStreams() {
try {
new ReadableStream({
pull() {}
});
return true;
} catch (e) {
return false;
}
}
function polyfillStreams() {
try {
require('@mattiasbuelens/web-streams-polyfill');
return true;
} catch (e) {
return false;
}
}
export default async function capabilities() {
const crypto = await checkCrypto();
const nativeStreams = checkStreams();
const polyStreams = nativeStreams ? false : polyfillStreams();
return {
crypto,
streamUpload: nativeStreams || polyStreams,
streamDownload:
nativeStreams &&
'serviceWorker' in navigator &&
!/safari/i.test(navigator.userAgent),
multifile: nativeStreams || polyStreams
};
}

View File

@ -48,7 +48,7 @@ class ECETransformer {
name: 'AES-GCM', name: 'AES-GCM',
length: 128 length: 128
}, },
false, true, // Edge polyfill requires key to be extractable to encrypt :/
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
); );
} }

View File

@ -177,7 +177,9 @@ export default function(state, emitter) {
try { try {
const start = Date.now(); const start = Date.now();
metrics.startedDownload({ size: file.size, ttl: file.ttl }); metrics.startedDownload({ size: file.size, ttl: file.ttl });
const dl = state.transfer.download(); const dl = state.transfer.download({
stream: state.capabilities.streamDownload
});
render(); render();
await dl; await dl;
const time = Date.now() - start; const time = Date.now() - start;

View File

@ -1,7 +1,9 @@
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import Keychain from './keychain'; import Keychain from './keychain';
import { delay, bytes } from './utils'; import { delay, bytes } from './utils';
import { metadata } from './api'; import { downloadFile, metadata } from './api';
import { blobStream } from './streams';
import Zip from './zip';
export default class FileReceiver extends Nanobus { export default class FileReceiver extends Nanobus {
constructor(fileInfo) { constructor(fileInfo) {
@ -52,22 +54,6 @@ export default class FileReceiver extends Nanobus {
this.state = 'ready'; this.state = 'ready';
} }
async streamToArrayBuffer(stream, streamSize, onprogress) {
const result = new Uint8Array(streamSize);
let offset = 0;
const reader = stream.getReader();
let state = await reader.read();
while (!state.done) {
result.set(state.value, offset);
offset += state.value.length;
state = await reader.read();
onprogress([offset, streamSize]);
}
onprogress([streamSize, streamSize]);
return result.slice(0, offset).buffer;
}
sendMessageToSw(msg) { sendMessageToSw(msg) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const channel = new MessageChannel(); const channel = new MessageChannel();
@ -86,7 +72,46 @@ export default class FileReceiver extends Nanobus {
}); });
} }
async download(noSave = false) { async downloadBlob(noSave = false) {
this.state = 'downloading';
this.downloadRequest = await downloadFile(
this.fileInfo.id,
this.keychain,
p => {
this.progress = p;
this.emit('progress');
}
);
try {
const ciphertext = await this.downloadRequest.result;
this.downloadRequest = null;
this.msg = 'decryptingFile';
this.state = 'decrypting';
this.emit('decrypting');
let size = this.fileInfo.size;
let plainStream = this.keychain.decryptStream(blobStream(ciphertext));
if (this.fileInfo.type === 'send-archive') {
const zip = new Zip(this.fileInfo.manifest, plainStream);
plainStream = zip.stream;
size = zip.size;
}
const plaintext = await streamToArrayBuffer(plainStream, size);
if (!noSave) {
await saveFile({
plaintext,
name: decodeURIComponent(this.fileInfo.name),
type: this.fileInfo.type
});
}
this.msg = 'downloadFinish';
this.state = 'complete';
} catch (e) {
this.downloadRequest = null;
throw e;
}
}
async downloadStream(noSave = false) {
const onprogress = p => { const onprogress = p => {
this.progress = p; this.progress = p;
this.emit('progress'); this.emit('progress');
@ -156,4 +181,64 @@ export default class FileReceiver extends Nanobus {
throw e; throw e;
} }
} }
download(options) {
if (options.stream) {
return this.downloadStream(options.noSave);
}
return this.downloadBlob(options.noSave);
}
}
async function streamToArrayBuffer(stream, size) {
const result = new Uint8Array(size);
let offset = 0;
const reader = stream.getReader();
let state = await reader.read();
while (!state.done) {
result.set(state.value, offset);
offset += state.value.length;
state = await reader.read();
}
return result.buffer;
}
async function saveFile(file) {
return new Promise(function(resolve, reject) {
const dataView = new DataView(file.plaintext);
const blob = new Blob([dataView], { type: file.type });
if (navigator.msSaveBlob) {
navigator.msSaveBlob(blob, file.name);
return resolve();
} else if (/iPhone|fxios/i.test(navigator.userAgent)) {
// This method is much slower but createObjectURL
// is buggy on iOS
const reader = new FileReader();
reader.addEventListener('loadend', function() {
if (reader.error) {
return reject(reader.error);
}
if (reader.result) {
const a = document.createElement('a');
a.href = reader.result;
a.download = file.name;
document.body.appendChild(a);
a.click();
}
resolve();
});
reader.readAsDataURL(blob);
} else {
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(downloadUrl);
setTimeout(resolve, 100);
}
});
} }

View File

@ -1,29 +1,33 @@
import 'fast-text-encoding'; // MS Edge support import 'fast-text-encoding'; // MS Edge support
import 'fluent-intl-polyfill'; import 'fluent-intl-polyfill';
import app from './routes'; import app from './routes';
import capabilities from './capabilities';
import locale from '../common/locales'; import locale from '../common/locales';
import fileManager from './fileManager'; import fileManager from './fileManager';
import dragManager from './dragManager'; import dragManager from './dragManager';
import pasteManager from './pasteManager'; import pasteManager from './pasteManager';
import { canHasSend } from './utils';
import storage from './storage'; import storage from './storage';
import metrics from './metrics'; import metrics from './metrics';
import experiments from './experiments'; import experiments from './experiments';
import Raven from 'raven-js'; import Raven from 'raven-js';
import './main.css'; import './main.css';
(async function start() {
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install(); Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install();
} }
const capa = await capabilities();
if (capa.streamDownload) {
navigator.serviceWorker.register('/serviceWorker.js');
}
app.use((state, emitter) => { app.use((state, emitter) => {
state.capabilities = capa;
state.transfer = null; state.transfer = null;
state.fileInfo = null; state.fileInfo = null;
state.translate = locale.getTranslator(); state.translate = locale.getTranslator();
state.storage = storage; state.storage = storage;
state.raven = Raven; state.raven = Raven;
window.appState = state; window.appState = state;
emitter.on('DOMContentLoaded', async function checkSupport() {
let unsupportedReason = null; let unsupportedReason = null;
if ( if (
// Firefox < 50 // Firefox < 50
@ -32,8 +36,7 @@ app.use((state, emitter) => {
) { ) {
unsupportedReason = 'outdated'; unsupportedReason = 'outdated';
} }
const ok = await canHasSend(); if (!state.capabilities.crypto) {
if (!ok) {
unsupportedReason = /firefox/i.test(navigator.userAgent) unsupportedReason = /firefox/i.test(navigator.userAgent)
? 'outdated' ? 'outdated'
: 'gcm'; : 'gcm';
@ -44,15 +47,10 @@ app.use((state, emitter) => {
); );
} }
}); });
});
app.use(() => {
navigator.serviceWorker.register('/serviceWorker.js');
});
app.use(metrics); app.use(metrics);
app.use(fileManager); app.use(fileManager);
app.use(dragManager); app.use(dragManager);
app.use(experiments); app.use(experiments);
app.use(pasteManager); app.use(pasteManager);
app.mount('body'); app.mount('body');
})();

View File

@ -75,7 +75,7 @@ async function decryptStream(id) {
self.onfetch = event => { self.onfetch = event => {
const req = event.request; const req = event.request;
if (req.url.includes('/api/download')) { if (/\/api\/download\/[A-Fa-f0-9]{4,}/.test(req.url)) {
const id = req.url.split('/')[5]; const id = req.url.split('/')[5];
event.respondWith(decryptStream(id)); event.respondWith(decryptStream(id));
} }

View File

@ -22,46 +22,6 @@ function loadShim(polyfill) {
}); });
} }
async function canHasSend() {
try {
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
);
await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: crypto.getRandomValues(new Uint8Array(12)),
tagLength: 128
},
key,
new ArrayBuffer(8)
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'PBKDF2',
false,
['deriveKey']
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'HKDF',
false,
['deriveKey']
);
return true;
} catch (err) {
console.error(err);
return false;
}
}
function isFile(id) { function isFile(id) {
return /^[0-9a-fA-F]{10}$/.test(id); return /^[0-9a-fA-F]{10}$/.test(id);
} }
@ -179,7 +139,6 @@ module.exports = {
arrayToB64, arrayToB64,
b64ToArray, b64ToArray,
loadShim, loadShim,
canHasSend,
isFile, isFile,
openLinksInNewTab openLinksInNewTab
}; };

15
package-lock.json generated
View File

@ -88,6 +88,15 @@
"integrity": "sha1-9vGlzl05caSt6RoR0i1MRZrNN18=", "integrity": "sha1-9vGlzl05caSt6RoR0i1MRZrNN18=",
"dev": true "dev": true
}, },
"@mattiasbuelens/web-streams-polyfill": {
"version": "0.1.0-alpha.5",
"resolved": "https://registry.npmjs.org/@mattiasbuelens/web-streams-polyfill/-/web-streams-polyfill-0.1.0-alpha.5.tgz",
"integrity": "sha512-KduiboN8xXQT+XAe76935VriUy62Yv/1EyTE43xmP+GEtPKpyUklKvYaXPq1fZpibMgXIIi/B2Qx0dReam5RKQ==",
"dev": true,
"requires": {
"@types/whatwg-streams": "0.0.6"
}
},
"@mrmlnc/readdir-enhanced": { "@mrmlnc/readdir-enhanced": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@ -122,6 +131,12 @@
"samsam": "1.3.0" "samsam": "1.3.0"
} }
}, },
"@types/whatwg-streams": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/whatwg-streams/-/whatwg-streams-0.0.6.tgz",
"integrity": "sha512-O4Hat94N1RUCObqAbVUtd6EcucseqBcpfbFXzy12CYF6BQVHWR+ztDA3YPjewCmdKHYZ5VA7TZ5hq2bMyqxiBw==",
"dev": true
},
"@webassemblyjs/ast": { "@webassemblyjs/ast": {
"version": "1.5.13", "version": "1.5.13",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.5.13.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.5.13.tgz",

View File

@ -56,6 +56,7 @@
}, },
"devDependencies": { "devDependencies": {
"@dannycoates/webpack-dev-server": "^3.1.4", "@dannycoates/webpack-dev-server": "^3.1.4",
"@mattiasbuelens/web-streams-polyfill": "0.1.0-alpha.5",
"asmcrypto.js": "^0.22.0", "asmcrypto.js": "^0.22.0",
"babel-core": "^6.26.3", "babel-core": "^6.26.3",
"babel-loader": "^7.1.4", "babel-loader": "^7.1.4",

View File

@ -57,6 +57,7 @@ module.exports = function(app) {
app.get('/completed', language, pages.blank); app.get('/completed', language, pages.blank);
app.get('/unsupported/:reason', language, pages.unsupported); app.get('/unsupported/:reason', language, pages.unsupported);
app.get(`/api/download/:id${ID_REGEX}`, auth, require('./download')); app.get(`/api/download/:id${ID_REGEX}`, auth, require('./download'));
app.get(`/api/download/blob/:id${ID_REGEX}`, auth, require('./download'));
app.get(`/api/exists/:id${ID_REGEX}`, require('./exists')); app.get(`/api/exists/:id${ID_REGEX}`, require('./exists'));
app.get(`/api/metadata/:id${ID_REGEX}`, auth, require('./metadata')); app.get(`/api/metadata/:id${ID_REGEX}`, auth, require('./metadata'));
app.post('/api/upload', require('./upload')); app.post('/api/upload', require('./upload'));

View File

@ -7,7 +7,7 @@ const headless = /Headless/.test(navigator.userAgent);
// TODO: save on headless doesn't work as it used to since it now // TODO: save on headless doesn't work as it used to since it now
// follows a link instead of fetch. Maybe there's a way to make it // follows a link instead of fetch. Maybe there's a way to make it
// work? For now always set noSave. // work? For now always set noSave.
const noSave = true || !headless; // only run the saveFile code if headless const options = { noSave: true || !headless, stream: true }; // only run the saveFile code if headless
// FileSender uses a File in real life but a Blob works for testing // FileSender uses a File in real life but a Blob works for testing
const blob = new Blob([new ArrayBuffer(1024 * 128)], { type: 'text/plain' }); const blob = new Blob([new ArrayBuffer(1024 * 128)], { type: 'text/plain' });
@ -27,10 +27,10 @@ describe('Upload / Download flow', function() {
requiresPassword: false requiresPassword: false
}); });
await fr.getMetadata(); await fr.getMetadata();
await fr.download(noSave); await fr.download(options);
try { try {
await fr.download(noSave); await fr.download(options);
assert.fail('downloaded again'); assert.fail('downloaded again');
} catch (e) { } catch (e) {
assert.equal(e.message, '404'); assert.equal(e.message, '404');
@ -50,7 +50,7 @@ describe('Upload / Download flow', function() {
password: 'magic' password: 'magic'
}); });
await fr.getMetadata(); await fr.getMetadata();
await fr.download(noSave); await fr.download(options);
assert.equal(fr.state, 'complete'); assert.equal(fr.state, 'complete');
}); });
@ -75,7 +75,7 @@ describe('Upload / Download flow', function() {
try { try {
// We can't decrypt without IV from metadata // We can't decrypt without IV from metadata
// but let's try to download anyway // but let's try to download anyway
await fr.download(noSave); await fr.download(options);
assert.fail('downloaded file with bad password'); assert.fail('downloaded file with bad password');
} catch (e) { } catch (e) {
assert.equal(e.message, '401'); assert.equal(e.message, '401');
@ -135,7 +135,7 @@ describe('Upload / Download flow', function() {
await fr.getMetadata(); await fr.getMetadata();
fr.once('progress', () => fr.cancel()); fr.once('progress', () => fr.cancel());
try { try {
await fr.download(noSave); await fr.download(options);
assert.fail('not cancelled'); assert.fail('not cancelled');
} catch (e) { } catch (e) {
assert.equal(e.message, '0'); assert.equal(e.message, '0');
@ -153,7 +153,7 @@ describe('Upload / Download flow', function() {
requiresPassword: false requiresPassword: false
}); });
await fr.getMetadata(); await fr.getMetadata();
await fr.download(noSave); await fr.download(options);
await file.updateDownloadCount(); await file.updateDownloadCount();
assert.equal(file.dtotal, 1); assert.equal(file.dtotal, 1);
}); });
@ -171,7 +171,7 @@ describe('Upload / Download flow', function() {
fr.once('progress', () => fr.cancel()); fr.once('progress', () => fr.cancel());
try { try {
await fr.download(noSave); await fr.download(options);
assert.fail('not cancelled'); assert.fail('not cancelled');
} catch (e) { } catch (e) {
await file.updateDownloadCount(); await file.updateDownloadCount();
@ -190,15 +190,15 @@ describe('Upload / Download flow', function() {
}); });
await file.changeLimit(2); await file.changeLimit(2);
await fr.getMetadata(); await fr.getMetadata();
await fr.download(noSave); await fr.download(options);
await file.updateDownloadCount(); await file.updateDownloadCount();
assert.equal(file.dtotal, 1); assert.equal(file.dtotal, 1);
await fr.download(noSave); await fr.download(options);
await file.updateDownloadCount(); await file.updateDownloadCount();
assert.equal(file.dtotal, 2); assert.equal(file.dtotal, 2);
try { try {
await fr.download(noSave); await fr.download(options);
assert.fail('downloaded too many times'); assert.fail('downloaded too many times');
} catch (e) { } catch (e) {
assert.equal(e.message, '404'); assert.equal(e.message, '404');

View File

@ -89,6 +89,7 @@ const web = {
{ {
// Strip asserts from our deps, mainly choojs family // Strip asserts from our deps, mainly choojs family
include: [path.resolve(__dirname, 'node_modules')], include: [path.resolve(__dirname, 'node_modules')],
exclude: [path.resolve(__dirname, 'node_modules/crc')],
loader: 'webpack-unassert-loader' loader: 'webpack-unassert-loader'
} }
] ]