Implemented FxA

This commit is contained in:
Danny Coates 2018-08-07 15:40:17 -07:00
parent 70bc2b7656
commit 718d74fa50
No known key found for this signature in database
GPG Key ID: 4C442633C62E00CB
40 changed files with 1306 additions and 651 deletions

View File

@ -65,14 +65,6 @@ export async function fileInfo(id, owner_token) {
throw new Error(response.status); throw new Error(response.status);
} }
export async function hasPassword(id) {
const response = await fetch(`/api/exists/${id}`);
if (response.ok) {
return response.json();
}
throw new Error(response.status);
}
export async function metadata(id, keychain) { export async function metadata(id, keychain) {
const result = await fetchWithAuthAndRetry( const result = await fetchWithAuthAndRetry(
`/api/metadata/${id}`, `/api/metadata/${id}`,
@ -141,6 +133,7 @@ async function upload(
metadata, metadata,
verifierB64, verifierB64,
timeLimit, timeLimit,
bearerToken,
onprogress, onprogress,
canceller canceller
) { ) {
@ -159,6 +152,7 @@ async function upload(
const fileMeta = { const fileMeta = {
fileMetadata: metadataHeader, fileMetadata: metadataHeader,
authorization: `send-v1 ${verifierB64}`, authorization: `send-v1 ${verifierB64}`,
bearer: bearerToken,
timeLimit timeLimit
}; };
@ -200,8 +194,9 @@ export function uploadWs(
encrypted, encrypted,
metadata, metadata,
verifierB64, verifierB64,
onprogress, timeLimit,
timeLimit bearerToken,
onprogress
) { ) {
const canceller = { cancelled: false }; const canceller = { cancelled: false };
@ -216,6 +211,7 @@ export function uploadWs(
metadata, metadata,
verifierB64, verifierB64,
timeLimit, timeLimit,
bearerToken,
onprogress, onprogress,
canceller canceller
) )
@ -332,3 +328,19 @@ export function downloadFile(id, keychain, onprogress) {
result: tryDownload(id, keychain, onprogress, canceller, 2) result: tryDownload(id, keychain, onprogress, canceller, 2)
}; };
} }
export async function getFileList(bearerToken) {
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
const response = await fetch('/api/filelist', { headers });
return response.body; // stream
}
export async function setFileList(bearerToken, data) {
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
const response = await fetch('/api/filelist', {
headers,
method: 'POST',
body: data
});
return response.status === 200;
}

View File

@ -1,4 +1,4 @@
/* global MAXFILESIZE */ /* global LIMITS */
import { blobStream, concatStream } from './streams'; import { blobStream, concatStream } from './streams';
function isDupe(newFile, array) { function isDupe(newFile, array) {
@ -15,7 +15,7 @@ function isDupe(newFile, array) {
} }
export default class Archive { export default class Archive {
constructor(files) { constructor(files = []) {
this.files = Array.from(files); this.files = Array.from(files);
} }
@ -49,20 +49,19 @@ export default class Archive {
return concatStream(this.files.map(file => blobStream(file))); return concatStream(this.files.map(file => blobStream(file)));
} }
addFiles(files) { addFiles(files, maxSize) {
if (this.files.length + files.length > LIMITS.MAX_FILES_PER_ARCHIVE) {
throw new Error('tooManyFiles');
}
const newFiles = files.filter(file => !isDupe(file, this.files)); const newFiles = files.filter(file => !isDupe(file, this.files));
const newSize = newFiles.reduce((total, file) => total + file.size, 0); const newSize = newFiles.reduce((total, file) => total + file.size, 0);
if (this.size + newSize > MAXFILESIZE) { if (this.size + newSize > maxSize) {
return false; throw new Error('fileTooBig');
} }
this.files = this.files.concat(newFiles); this.files = this.files.concat(newFiles);
return true; return true;
} }
checkSize() {
return this.size <= MAXFILESIZE;
}
remove(index) { remove(index) {
this.files.splice(index, 1); this.files.splice(index, 1);
} }

View File

@ -1,12 +1,11 @@
/* global MAXFILESIZE */ /* global DEFAULTS LIMITS */
/* global DEFAULT_EXPIRE_SECONDS */
import FileSender from './fileSender'; import FileSender from './fileSender';
import FileReceiver from './fileReceiver'; import FileReceiver from './fileReceiver';
import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils'; import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
import * as metrics from './metrics'; import * as metrics from './metrics';
import { hasPassword } from './api';
import Archive from './archive'; import Archive from './archive';
import { bytes } from './utils'; import { bytes } from './utils';
import { prepareWrapKey } from './fxa';
export default function(state, emitter) { export default function(state, emitter) {
let lastRender = 0; let lastRender = 0;
@ -17,19 +16,8 @@ export default function(state, emitter) {
} }
async function checkFiles() { async function checkFiles() {
const files = state.storage.files.slice(); const changes = await state.user.syncFileList();
let rerender = false; const rerender = changes.incoming || changes.downloadCount;
for (const file of files) {
const oldLimit = file.dlimit;
const oldTotal = file.dtotal;
await file.updateDownloadCount();
if (file.dtotal === file.dlimit) {
state.storage.remove(file.id);
rerender = true;
} else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) {
rerender = true;
}
}
if (rerender) { if (rerender) {
render(); render();
} }
@ -57,6 +45,16 @@ export default function(state, emitter) {
lastRender = Date.now(); lastRender = Date.now();
}); });
emitter.on('login', async () => {
const k = await prepareWrapKey(state.storage);
location.assign(`/api/fxa/login?keys_jwk=${k}`);
});
emitter.on('logout', () => {
state.user.logout();
render();
});
emitter.on('changeLimit', async ({ file, value }) => { emitter.on('changeLimit', async ({ file, value }) => {
await file.changeLimit(value); await file.changeLimit(value);
state.storage.writeFile(file); state.storage.writeFile(file);
@ -90,29 +88,37 @@ export default function(state, emitter) {
}); });
emitter.on('addFiles', async ({ files }) => { emitter.on('addFiles', async ({ files }) => {
if (state.archive) { const maxSize = state.user.maxSize;
if (!state.archive.addFiles(files)) { state.archive = state.archive || new Archive();
// eslint-disable-next-line no-alert try {
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) })); state.archive.addFiles(files, maxSize);
return; } catch (e) {
} alert(
} else { state.translate(e.message, {
const archive = new Archive(files); size: bytes(maxSize),
if (!archive.checkSize()) { count: LIMITS.MAX_FILES_PER_ARCHIVE
// eslint-disable-next-line no-alert })
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) })); );
return;
}
state.archive = archive;
} }
render(); render();
}); });
emitter.on('upload', async ({ type, dlCount, password }) => { emitter.on('upload', async ({ type, dlCount, password }) => {
if (!state.archive) return; if (!state.archive) return;
if (state.storage.files.length >= LIMITS.MAX_ARCHIVES_PER_USER) {
return alert(
state.translate('tooManyArchives', {
count: LIMITS.MAX_ARCHIVES_PER_USER
})
);
}
const size = state.archive.size; const size = state.archive.size;
if (!state.timeLimit) state.timeLimit = DEFAULT_EXPIRE_SECONDS; if (!state.timeLimit) state.timeLimit = DEFAULTS.EXPIRE_SECONDS;
const sender = new FileSender(state.archive, state.timeLimit); const sender = new FileSender(
state.archive,
state.timeLimit,
state.user.bearerToken
);
sender.on('progress', updateProgress); sender.on('progress', updateProgress);
sender.on('encrypting', render); sender.on('encrypting', render);
@ -132,7 +138,6 @@ export default function(state, emitter) {
metrics.completedUpload(ownedFile); metrics.completedUpload(ownedFile);
state.storage.addFile(ownedFile); state.storage.addFile(ownedFile);
if (password) { if (password) {
emitter.emit('password', { password, file: ownedFile }); emitter.emit('password', { password, file: ownedFile });
} }
@ -185,17 +190,6 @@ export default function(state, emitter) {
render(); render();
}); });
emitter.on('getPasswordExist', async ({ id }) => {
try {
state.fileInfo = await hasPassword(id);
render();
} catch (e) {
if (e.message === '404') {
return emitter.emit('pushState', '/404');
}
}
});
emitter.on('getMetadata', async () => { emitter.on('getMetadata', async () => {
const file = state.fileInfo; const file = state.fileInfo;
@ -204,7 +198,7 @@ export default function(state, emitter) {
await receiver.getMetadata(); await receiver.getMetadata();
state.transfer = receiver; state.transfer = receiver;
} catch (e) { } catch (e) {
if (e.message === '401') { if (e.message === '401' || e.message === '404') {
file.password = null; file.password = null;
if (!file.requiresPassword) { if (!file.requiresPassword) {
return emitter.emit('pushState', '/404'); return emitter.emit('pushState', '/404');

View File

@ -1,6 +1,6 @@
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import Keychain from './keychain'; import Keychain from './keychain';
import { delay, bytes } from './utils'; import { delay, bytes, streamToArrayBuffer } from './utils';
import { downloadFile, metadata } from './api'; import { downloadFile, metadata } from './api';
import { blobStream } from './streams'; import { blobStream } from './streams';
import Zip from './zip'; import Zip from './zip';
@ -191,20 +191,6 @@ export default class FileReceiver extends Nanobus {
} }
} }
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) { async function saveFile(file) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
const dataView = new DataView(file.plaintext); const dataView = new DataView(file.plaintext);

View File

@ -1,4 +1,4 @@
/* global DEFAULT_EXPIRE_SECONDS */ /* global DEFAULTS */
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import OwnedFile from './ownedFile'; import OwnedFile from './ownedFile';
import Keychain from './keychain'; import Keychain from './keychain';
@ -7,9 +7,10 @@ import { uploadWs } from './api';
import { encryptedSize } from './ece'; import { encryptedSize } from './ece';
export default class FileSender extends Nanobus { export default class FileSender extends Nanobus {
constructor(file, timeLimit) { constructor(file, timeLimit, bearerToken) {
super('FileSender'); super('FileSender');
this.timeLimit = timeLimit || DEFAULT_EXPIRE_SECONDS; this.timeLimit = timeLimit || DEFAULTS.EXPIRE_SECONDS;
this.bearerToken = bearerToken;
this.file = file; this.file = file;
this.keychain = new Keychain(); this.keychain = new Keychain();
this.reset(); this.reset();
@ -75,11 +76,12 @@ export default class FileSender extends Nanobus {
encStream, encStream,
metadata, metadata,
authKeyB64, authKeyB64,
this.timeLimit,
this.bearerToken,
p => { p => {
this.progress = [p, totalSize]; this.progress = [p, totalSize];
this.emit('progress'); this.emit('progress');
}, }
this.timeLimit
); );
if (this.cancelled) { if (this.cancelled) {

44
app/fxa.js Normal file
View File

@ -0,0 +1,44 @@
import jose from 'node-jose';
import { arrayToB64, b64ToArray } from './utils';
const encoder = new TextEncoder();
export async function prepareWrapKey(storage) {
const keystore = jose.JWK.createKeyStore();
const keypair = await keystore.generate('EC', 'P-256');
storage.set('fxaWrapKey', JSON.stringify(keystore.toJSON(true)));
return jose.util.base64url.encode(JSON.stringify(keypair.toJSON()));
}
export async function getFileListKey(storage, bundle) {
const keystore = await jose.JWK.asKeyStore(
JSON.parse(storage.get('fxaWrapKey'))
);
const result = await jose.JWE.createDecrypt(keystore).decrypt(bundle);
const jwks = JSON.parse(jose.util.utf8.encode(result.plaintext));
const jwk = jwks['https://identity.mozilla.com/apps/send'];
const baseKey = await crypto.subtle.importKey(
'raw',
b64ToArray(jwk.k),
{ name: 'HKDF' },
false,
['deriveKey']
);
const fileListKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('fileList'),
hash: 'SHA-256'
},
baseKey,
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
);
const rawFileListKey = await crypto.subtle.exportKey('raw', fileListKey);
return arrayToB64(new Uint8Array(rawFileListKey));
}

View File

@ -1,3 +1,4 @@
/* global userInfo */
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';
@ -11,6 +12,8 @@ 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';
import User from './user';
import { getFileListKey } from './fxa';
(async function start() { (async function start() {
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
@ -20,6 +23,9 @@ import './main.css';
if (capa.streamDownload) { if (capa.streamDownload) {
navigator.serviceWorker.register('/serviceWorker.js'); navigator.serviceWorker.register('/serviceWorker.js');
} }
if (userInfo && userInfo.keys_jwe) {
userInfo.fileListKey = await getFileListKey(storage, userInfo.keys_jwe);
}
app.use((state, emitter) => { app.use((state, emitter) => {
state.capabilities = capa; state.capabilities = capa;
state.transfer = null; state.transfer = null;
@ -27,6 +33,7 @@ import './main.css';
state.translate = locale.getTranslator(); state.translate = locale.getTranslator();
state.storage = storage; state.storage = storage;
state.raven = Raven; state.raven = Raven;
state.user = new User(userInfo, storage);
window.appState = state; window.appState = state;
let unsupportedReason = null; let unsupportedReason = null;
if ( if (

View File

@ -22,6 +22,14 @@ export default class OwnedFile {
this.timeLimit = obj.timeLimit; this.timeLimit = obj.timeLimit;
} }
get hasPassword() {
return !!this._hasPassword;
}
get expired() {
return this.dlimit === this.dtotal || Date.now() > this.expiresAt;
}
async setPassword(password) { async setPassword(password) {
try { try {
this.password = password; this.password = password;
@ -48,11 +56,9 @@ export default class OwnedFile {
return Promise.resolve(true); return Promise.resolve(true);
} }
get hasPassword() {
return !!this._hasPassword;
}
async updateDownloadCount() { async updateDownloadCount() {
const oldTotal = this.dtotal;
const oldLimit = this.dlimit;
try { try {
const result = await fileInfo(this.id, this.ownerToken); const result = await fileInfo(this.id, this.ownerToken);
this.dtotal = result.dtotal; this.dtotal = result.dtotal;
@ -63,6 +69,7 @@ export default class OwnedFile {
} }
// ignore other errors // ignore other errors
} }
return oldTotal !== this.dtotal || oldLimit !== this.dlimit;
} }
toJSON() { toJSON() {

View File

@ -4,7 +4,7 @@ const downloadButton = require('../../templates/downloadButton');
const downloadedFiles = require('../../templates/uploadedFileList'); const downloadedFiles = require('../../templates/uploadedFileList');
module.exports = function(state, emit) { module.exports = function(state, emit) {
const ownedFile = state.storage.getFileById(state.params.id); const fileInfo = state.fileInfo;
const trySendLink = html` const trySendLink = html`
<a class="link link--action" href="/"> <a class="link link--action" href="/">
@ -25,7 +25,7 @@ module.exports = function(state, emit) {
<div class="page"> <div class="page">
${titleSection(state)} ${titleSection(state)}
${downloadedFiles(ownedFile, state, emit)} ${downloadedFiles(fileInfo, state, emit)}
<div class="description">${state.translate('downloadMessage2')}</div> <div class="description">${state.translate('downloadMessage2')}</div>
${downloadButton(state, emit)} ${downloadButton(state, emit)}

View File

@ -66,7 +66,7 @@ module.exports = function(state, emit) {
</label> </label>
<div class="uploadOptions ${optionClass}"> <div class="uploadOptions ${optionClass}">
${expireInfo(state)} ${expireInfo(state, emit)}
${setPasswordSection(state)} ${setPasswordSection(state)}
</div> </div>

View File

@ -1,14 +1,20 @@
/* global downloadMetadata */
const preview = require('../pages/preview'); const preview = require('../pages/preview');
const password = require('../pages/password'); const password = require('../pages/password');
module.exports = function(state, emit) { function createFileInfo(state) {
if (!state.fileInfo) { return {
emit('getPasswordExist', { id: state.params.id }); id: state.params.id,
return; secretKey: state.params.key,
nonce: downloadMetadata.nonce,
requiresPassword: downloadMetadata.pwd
};
} }
state.fileInfo.id = state.params.id; module.exports = function(state, emit) {
state.fileInfo.secretKey = state.params.key; if (!state.fileInfo) {
state.fileInfo = createFileInfo(state);
}
if (!state.transfer && !state.fileInfo.requiresPassword) { if (!state.transfer && !state.fileInfo.requiresPassword) {
emit('getMetadata'); emit('getMetadata');

View File

@ -38,7 +38,7 @@ function body(template) {
<div class="stripedBox"> <div class="stripedBox">
<div class="mainContent"> <div class="mainContent">
${profile(state)} ${profile(state, emit)}
${template(state, emit)} ${template(state, emit)}
</div> </div>
@ -67,7 +67,11 @@ app.route('/unsupported/:reason', body(require('../pages/unsupported')));
app.route('/legal', body(require('../pages/legal'))); app.route('/legal', body(require('../pages/legal')));
app.route('/error', body(require('../pages/error'))); app.route('/error', body(require('../pages/error')));
app.route('/blank', body(require('../pages/blank'))); app.route('/blank', body(require('../pages/blank')));
app.route('*', body(require('../pages/notFound')));
app.route('/signin', body(require('../pages/signin'))); app.route('/signin', body(require('../pages/signin')));
app.route('/api/fxa/oauth', function(state, emit) {
emit('replaceState', '/');
setTimeout(() => emit('render'));
});
app.route('*', body(require('../pages/notFound')));
module.exports = app; module.exports = app;

View File

@ -38,7 +38,7 @@ class Storage {
} }
loadFiles() { loadFiles() {
const fs = []; const fs = new Map();
for (let i = 0; i < this.engine.length; i++) { for (let i = 0; i < this.engine.length; i++) {
const k = this.engine.key(i); const k = this.engine.key(i);
if (isFile(k)) { if (isFile(k)) {
@ -48,14 +48,14 @@ class Storage {
f.id = f.fileId; f.id = f.fileId;
} }
fs.push(f); fs.set(f.id, f);
} catch (err) { } catch (err) {
// obviously you're not a golfer // obviously you're not a golfer
this.engine.removeItem(k); this.engine.removeItem(k);
} }
} }
} }
return fs.sort((a, b) => a.createdAt - b.createdAt); return fs;
} }
get totalDownloads() { get totalDownloads() {
@ -90,26 +90,44 @@ class Storage {
} }
get files() { get files() {
return this._files; return Array.from(this._files.values()).sort(
(a, b) => a.createdAt - b.createdAt
);
}
get user() {
try {
return JSON.parse(this.engine.getItem('user'));
} catch (e) {
return null;
}
}
set user(info) {
return this.engine.setItem('user', JSON.stringify(info));
} }
getFileById(id) { getFileById(id) {
return this._files.find(f => f.id === id); return this._files.get(id);
} }
get(id) { get(id) {
return this.engine.getItem(id); return this.engine.getItem(id);
} }
set(id, value) {
return this.engine.setItem(id, value);
}
remove(property) { remove(property) {
if (isFile(property)) { if (isFile(property)) {
this._files.splice(this._files.findIndex(f => f.id === property), 1); this._files.delete(property);
} }
this.engine.removeItem(property); this.engine.removeItem(property);
} }
addFile(file) { addFile(file) {
this._files.push(file); this._files.set(file.id, file);
this.writeFile(file); this.writeFile(file);
} }
@ -120,6 +138,39 @@ class Storage {
writeFiles() { writeFiles() {
this._files.forEach(f => this.writeFile(f)); this._files.forEach(f => this.writeFile(f));
} }
clearLocalFiles() {
this._files.forEach(f => this.engine.removeItem(f.id));
this._files = new Map();
}
async merge(files = []) {
let incoming = false;
let outgoing = false;
let downloadCount = false;
for (const f of files) {
if (!this.getFileById(f.id)) {
this.addFile(new OwnedFile(f));
incoming = true;
}
}
const workingFiles = this.files.slice();
for (const f of workingFiles) {
const cc = await f.updateDownloadCount();
downloadCount = downloadCount || cc;
outgoing = outgoing || f.expired;
if (f.expired) {
this.remove(f.id);
} else if (!files.find(x => x.id === f.id)) {
outgoing = true;
}
}
return {
incoming,
outgoing,
downloadCount
};
}
} }
export default new Storage(); export default new Storage();

View File

@ -3,7 +3,7 @@ const raw = require('choo/html/raw');
const selectbox = require('../selectbox'); const selectbox = require('../selectbox');
const timeLimitText = require('../timeLimitText'); const timeLimitText = require('../timeLimitText');
module.exports = function(state) { module.exports = function(state, emit) {
const el = html`<div> ${raw( const el = html`<div> ${raw(
state.translate('frontPageExpireInfo', { state.translate('frontPageExpireInfo', {
downloadCount: '<select id=dlCount></select>', downloadCount: '<select id=dlCount></select>',
@ -11,15 +11,25 @@ module.exports = function(state) {
}) })
)} )}
</div>`; </div>`;
if (el.__encoded) {
// we're rendering on the server
return el;
}
const dlCountSelect = el.querySelector('#dlCount'); const dlCountSelect = el.querySelector('#dlCount');
el.replaceChild( el.replaceChild(
selectbox( selectbox(
state.downloadCount || 1, state.downloadCount || 1,
[1, 2, 3, 4, 5, 20], [1, 2, 3, 4, 5, 20, 50, 100, 200],
num => state.translate('downloadCount', { num }), num => state.translate('downloadCount', { num }),
value => { value => {
const max = state.user.maxDownloads;
if (value > max) {
alert('todo: this setting requires an account');
value = max;
}
state.downloadCount = value; state.downloadCount = value;
emit('render');
} }
), ),
dlCountSelect dlCountSelect
@ -29,10 +39,16 @@ module.exports = function(state) {
el.replaceChild( el.replaceChild(
selectbox( selectbox(
state.timeLimit || 86400, state.timeLimit || 86400,
[300, 3600, 86400, 604800, 1209600], [300, 3600, 86400, 604800],
num => timeLimitText(state.translate, num), num => timeLimitText(state.translate, num),
value => { value => {
const max = state.user.maxExpireSeconds;
if (value > max) {
alert('todo: this setting requires an account');
value = max;
}
state.timeLimit = value; state.timeLimit = value;
emit('render');
} }
), ),
timeSelect timeSelect

View File

@ -1,20 +1,25 @@
const html = require('choo/html'); const html = require('choo/html');
const assets = require('../../../common/assets');
// eslint-disable-next-line no-unused-vars module.exports = function(state, emit) {
module.exports = function(state) { const user = state.user;
const notLoggedInMenu = html` const menu = user.loggedIn
? html`
<ul class="account_dropdown">
<li class="account_dropdown__text">
${user.email}
</li>
<li>
<a class="account_dropdown__link" onclick=${logout}>${state.translate(
'logOut'
)}</a>
</li>
</ul>`
: html`
<ul class="account_dropdown" <ul class="account_dropdown"
tabindex="-1" tabindex="-1"
> >
<li> <li>
<a class=account_dropdown__link>${state.translate( <a class="account_dropdown__link" onclick=${login}>${state.translate(
'accountMenuOption'
)}</a>
</li>
<li>
<a href="/signin"
class=account_dropdown__link>${state.translate(
'signInMenuOption' 'signInMenuOption'
)}</a> )}</a>
</li> </li>
@ -23,11 +28,14 @@ module.exports = function(state) {
return html` return html`
<div class="account"> <div class="account">
<div class="account__avatar">
<img <img
src="${assets.get('user.svg')}" class="account__avatar"
src="${user.avatar}"
onclick=${avatarClick} onclick=${avatarClick}
alt="account"/> />
${notLoggedInMenu} </div>
${menu}
</div>`; </div>`;
function avatarClick(event) { function avatarClick(event) {
@ -37,6 +45,16 @@ module.exports = function(state) {
dropdown.focus(); dropdown.focus();
} }
function login(event) {
event.preventDefault();
emit('login');
}
function logout(event) {
event.preventDefault();
emit('logout');
}
//the onblur trick makes links unclickable wtf //the onblur trick makes links unclickable wtf
/* /*
function hideMenu(event) { function hideMenu(event) {

View File

@ -5,12 +5,18 @@
padding: 0; padding: 0;
} }
.account__avatar {
height: 32px;
width: 32px;
border-radius: 50%;
}
.account_dropdown { .account_dropdown {
z-index: 2; z-index: 2;
position: absolute; position: absolute;
top: 30px; top: 30px;
left: -15px; left: -15px;
width: 150px; min-width: 150px;
list-style-type: none; list-style-type: none;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
@ -62,3 +68,11 @@
background-color: var(--primaryControlBGColor); background-color: var(--primaryControlBGColor);
color: var(--primaryControlFGColor); color: var(--primaryControlFGColor);
} }
.account_dropdown__text {
display: block;
padding: 0 14px;
font-size: 13px;
color: var(--lightTextColor);
line-height: 24px;
}

102
app/user.js Normal file
View File

@ -0,0 +1,102 @@
/* global LIMITS */
import assets from '../common/assets';
import { getFileList, setFileList } from './api';
import { encryptStream, decryptStream } from './ece';
import { b64ToArray, streamToArrayBuffer } from './utils';
import { blobStream } from './streams';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export default class User {
constructor(info, storage) {
if (info && storage) {
storage.user = info;
}
this.storage = storage;
this.data = info || storage.user || {};
}
get avatar() {
const defaultAvatar = assets.get('user.svg');
if (this.data.avatarDefault) {
return defaultAvatar;
}
return this.data.avatar || defaultAvatar;
}
get name() {
return this.data.displayName;
}
get email() {
return this.data.email;
}
get loggedIn() {
return !!this.data.access_token;
}
get bearerToken() {
return this.data.access_token;
}
get maxSize() {
return this.loggedIn ? LIMITS.MAX_FILE_SIZE : LIMITS.ANON.MAX_FILE_SIZE;
}
get maxExpireSeconds() {
return this.loggedIn
? LIMITS.MAX_EXPIRE_SECONDS
: LIMITS.ANON.MAX_EXPIRE_SECONDS;
}
get maxDownloads() {
return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS;
}
login() {}
logout() {
this.storage.user = null;
this.storage.clearLocalFiles();
this.data = {};
}
async syncFileList() {
let changes = { incoming: false, outgoing: false, downloadCount: false };
if (!this.loggedIn) {
return this.storage.merge();
}
let list = [];
try {
const encrypted = await getFileList(this.bearerToken);
const decrypted = await streamToArrayBuffer(
decryptStream(encrypted, b64ToArray(this.data.fileListKey))
);
list = JSON.parse(textDecoder.decode(decrypted));
} catch (e) {
//
}
changes = await this.storage.merge(list);
if (!changes.outgoing) {
return changes;
}
try {
const blob = new Blob([
textEncoder.encode(JSON.stringify(this.storage.files))
]);
const encrypted = await streamToArrayBuffer(
encryptStream(blobStream(blob), b64ToArray(this.data.fileListKey))
);
await setFileList(this.bearerToken, encrypted);
} catch (e) {
//
}
return changes;
}
toJSON() {
return this.data;
}
}

View File

@ -151,6 +151,37 @@ function browserName() {
} }
} }
async function streamToArrayBuffer(stream, size) {
const reader = stream.getReader();
let state = await reader.read();
if (size) {
const result = new Uint8Array(size);
let offset = 0;
while (!state.done) {
result.set(state.value, offset);
offset += state.value.length;
state = await reader.read();
}
return result.buffer;
}
const parts = [];
let len = 0;
while (!state.done) {
parts.push(state.value);
len += state.value.length;
state = await reader.read();
}
let offset = 0;
const result = new Uint8Array(len);
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result.buffer;
}
module.exports = { module.exports = {
fadeOut, fadeOut,
delay, delay,
@ -164,5 +195,6 @@ module.exports = {
loadShim, loadShim,
isFile, isFile,
openLinksInNewTab, openLinksInNewTab,
browserName browserName,
streamToArrayBuffer
}; };

946
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@
"test:frontend": "cross-env NODE_ENV=development node test/frontend/runner.js && nyc report --reporter=html", "test:frontend": "cross-env NODE_ENV=development node test/frontend/runner.js && nyc report --reporter=html",
"test-integration": "docker-compose up --abort-on-container-exit --exit-code-from integration-tests --build --remove-orphans --quiet-pull && docker-compose down", "test-integration": "docker-compose up --abort-on-container-exit --exit-code-from integration-tests --build --remove-orphans --quiet-pull && docker-compose down",
"test-integration-stage": "cross-env BASE_URL=https://send.stage.mozaws.net npm run test-integration", "test-integration-stage": "cross-env BASE_URL=https://send.stage.mozaws.net npm run test-integration",
"start": "npm run clean && cross-env NODE_ENV=development webpack-dev-server --mode=development", "start": "npm run clean && cross-env NODE_ENV=development BASE_URL=http://localhost:8080 webpack-dev-server --mode=development",
"prod": "node server/bin/prod.js" "prod": "node server/bin/prod.js"
}, },
"lint-staged": { "lint-staged": {
@ -89,6 +89,7 @@
"mocha": "^5.2.0", "mocha": "^5.2.0",
"nanobus": "^4.3.2", "nanobus": "^4.3.2",
"nanotiming": "^7.3.1", "nanotiming": "^7.3.1",
"node-jose": "^1.0.0",
"npm-run-all": "^4.1.3", "npm-run-all": "^4.1.3",
"nyc": "^12.0.2", "nyc": "^12.0.2",
"postcss-cssnext": "^3.1.0", "postcss-cssnext": "^3.1.0",
@ -129,7 +130,7 @@
"helmet": "^3.13.0", "helmet": "^3.13.0",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"mozlog": "^2.2.0", "mozlog": "^2.2.0",
"package-lock": "^1.0.0", "node-fetch": "^2.2.0",
"raven": "^2.6.3", "raven": "^2.6.3",
"redis": "^2.8.0", "redis": "^2.8.0",
"websocket-stream": "^5.1.2" "websocket-stream": "^5.1.2"

View File

@ -84,6 +84,8 @@ errorPageHeader = Something went wrong!
errorPageMessage = There has been an error uploading the file. errorPageMessage = There has been an error uploading the file.
errorPageLink = Send another file errorPageLink = Send another file
fileTooBig = That file is too big to upload. It should be less than { $size }. fileTooBig = That file is too big to upload. It should be less than { $size }.
tooManyFiles = Only { $count } files can be uploaded at a time.
tooManyArchives = Only { $count } archives are allowed.
linkExpiredAlt = Link expired linkExpiredAlt = Link expired
expiredPageHeader = This link has expired or never existed in the first place! expiredPageHeader = This link has expired or never existed in the first place!
notSupportedHeader = Your browser is not supported. notSupportedHeader = Your browser is not supported.
@ -162,4 +164,5 @@ accountBenefitExpiry = Have more expiry options
accountBenefitSync = Manage your uploads across devices accountBenefitSync = Manage your uploads across devices
accountBenefitNotify = Be notified when your files are downloaded accountBenefitNotify = Be notified when your files are downloaded
accountBenefitMore = Do a lot more! accountBenefitMore = Do a lot more!
manageAccount = Manage Account
logOut = Sign Out

View File

@ -21,7 +21,7 @@ const conf = convict({
}, },
expire_times_seconds: { expire_times_seconds: {
format: Array, format: Array,
default: [300, 3600, 86400, 604800, 1209600], default: [300, 3600, 86400, 604800],
env: 'EXPIRE_TIMES_SECONDS' env: 'EXPIRE_TIMES_SECONDS'
}, },
default_expire_seconds: { default_expire_seconds: {
@ -31,9 +31,34 @@ const conf = convict({
}, },
max_expire_seconds: { max_expire_seconds: {
format: Number, format: Number,
default: 1209600, default: 86400 * 7,
env: 'MAX_EXPIRE_SECONDS' env: 'MAX_EXPIRE_SECONDS'
}, },
anon_max_expire_seconds: {
format: Number,
default: 86400,
env: 'ANON_MAX_EXPIRE_SECONDS'
},
max_downloads: {
format: Number,
default: 200,
env: 'MAX_DOWNLOADS'
},
anon_max_downloads: {
format: Number,
default: 20,
env: 'ANON_MAX_DOWNLOADS'
},
max_files_per_archive: {
format: Number,
default: 64,
env: 'MAX_FILES_PER_ARCHIVE'
},
max_archives_per_user: {
format: Number,
default: 16,
env: 'MAX_ARCHIVES_PER_USER'
},
redis_host: { redis_host: {
format: String, format: String,
default: 'localhost', default: 'localhost',
@ -77,9 +102,14 @@ const conf = convict({
}, },
max_file_size: { max_file_size: {
format: Number, format: Number,
default: 1024 * 1024 * 1024 * 3, default: 1024 * 1024 * 1024 * 4,
env: 'MAX_FILE_SIZE' env: 'MAX_FILE_SIZE'
}, },
anon_max_file_size: {
format: Number,
default: 1024 * 1024 * 500,
env: 'ANON_MAX_FILE_SIZE'
},
l10n_dev: { l10n_dev: {
format: Boolean, format: Boolean,
default: false, default: false,
@ -94,6 +124,21 @@ const conf = convict({
format: 'String', format: 'String',
default: `${tmpdir()}${path.sep}send-${randomBytes(4).toString('hex')}`, default: `${tmpdir()}${path.sep}send-${randomBytes(4).toString('hex')}`,
env: 'FILE_DIR' env: 'FILE_DIR'
},
fxa_url: {
format: 'url',
default: 'https://stable.dev.lcip.org',
env: 'FXA_URL'
},
fxa_client_id: {
format: String,
default: 'b50ec33d3c9beb6d', // localhost
env: 'FXA_CLIENT_ID'
},
fxa_client_secret: {
format: String,
default: '05ac76fbe3e739c9effbaea439bc07d265c613c5e0da9070590a2378377c09d8', // localhost
env: 'FXA_CLIENT_SECRET'
} }
}); });

17
server/initScript.js Normal file
View File

@ -0,0 +1,17 @@
const html = require('choo/html');
const raw = require('choo/html/raw');
module.exports = function(state) {
// return '';
return state.cspNonce
? html`
<script nonce="${state.cspNonce}">
const userInfo = ${
state.user.loggedIn ? raw(JSON.stringify(state.user)) : 'null'
};
const downloadMetadata = ${
state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}'
};
</script>`
: '';
};

View File

@ -1,6 +1,7 @@
const html = require('choo/html'); const html = require('choo/html');
const assets = require('../common/assets'); const assets = require('../common/assets');
const locales = require('../common/locales'); const locales = require('../common/locales');
const initScript = require('./initScript');
module.exports = function(state, body = '') { module.exports = function(state, body = '') {
const firaTag = state.fira const firaTag = state.fira
@ -73,6 +74,7 @@ module.exports = function(state, body = '') {
<script defer src="${assets.get('app.js')}"></script> <script defer src="${assets.get('app.js')}"></script>
</head> </head>
${body} ${body}
${initScript(state)}
</html> </html>
`; `;
}; };

View File

@ -1,9 +1,12 @@
const crypto = require('crypto'); const crypto = require('crypto');
const storage = require('../storage'); const storage = require('../storage');
const fxa = require('../routes/fxa');
module.exports = async function(req, res, next) { module.exports = {
hmac: async function(req, res, next) {
const id = req.params.id; const id = req.params.id;
if (id && req.header('Authorization')) { const authHeader = req.header('Authorization');
if (id && authHeader) {
try { try {
const auth = req.header('Authorization').split(' ')[1]; const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id); const meta = await storage.metadata(id);
@ -35,4 +38,33 @@ module.exports = async function(req, res, next) {
} else { } else {
res.sendStatus(401); res.sendStatus(401);
} }
},
owner: async function(req, res, next) {
const id = req.params.id;
const ownerToken = req.body.owner_token;
if (id && ownerToken) {
try {
req.meta = await storage.metadata(id);
if (!req.meta) {
return res.sendStatus(404);
}
req.authorized = req.meta.owner === ownerToken;
} catch (e) {
req.authorized = false;
}
}
if (req.authorized) {
next();
} else {
res.sendStatus(401);
}
},
fxa: async function(req, res, next) {
const authHeader = req.header('Authorization');
if (authHeader && /^Bearer\s/i.test(authHeader)) {
const token = authHeader.split(' ')[1];
req.user = await fxa.verify(token);
}
return next();
}
}; };

View File

@ -1,22 +0,0 @@
const storage = require('../storage');
module.exports = async function(req, res, next) {
const id = req.params.id;
const ownerToken = req.body.owner_token;
if (id && ownerToken) {
try {
req.meta = await storage.metadata(id);
if (!req.meta) {
return res.sendStatus(404);
}
req.authorized = req.meta.owner === ownerToken;
} catch (e) {
req.authorized = false;
}
}
if (req.authorized) {
next();
} else {
res.sendStatus(401);
}
};

View File

@ -14,15 +14,15 @@ module.exports = async function(req, res) {
'WWW-Authenticate': `send-v1 ${req.nonce}` 'WWW-Authenticate': `send-v1 ${req.nonce}`
}); });
const file_stream = await storage.get(id); const fileStream = await storage.get(id);
let cancelled = false; let cancelled = false;
req.on('close', () => { req.on('close', () => {
cancelled = true; cancelled = true;
file_stream.destroy(); fileStream.destroy();
}); });
file_stream.on('end', async () => { fileStream.on('end', async () => {
if (cancelled) { if (cancelled) {
return; return;
} }
@ -40,7 +40,7 @@ module.exports = async function(req, res) {
} }
}); });
file_stream.pipe(res); fileStream.pipe(res);
} catch (e) { } catch (e) {
res.sendStatus(404); res.sendStatus(404);
} }

49
server/routes/filelist.js Normal file
View File

@ -0,0 +1,49 @@
const config = require('../config');
const storage = require('../storage');
const Limiter = require('../limiter');
function id(user) {
return `filelist-${user}`;
}
module.exports = {
async get(req, res) {
if (!req.user) {
return res.sendStatus(401);
}
try {
const fileId = id(req.user);
const contentLength = await storage.length(fileId);
const fileStream = await storage.get(fileId);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': contentLength
});
fileStream.pipe(res);
} catch (e) {
res.sendStatus(404);
}
},
async post(req, res) {
if (!req.user) {
return res.sendStatus(401);
}
try {
const limiter = new Limiter(1024 * 1024 * 10);
const fileStream = req.pipe(limiter);
await storage.set(
id(req.user),
fileStream,
{ n: 'a' }, //TODO
config.max_expire_seconds
);
res.sendStatus(200);
} catch (e) {
if (e.message === 'limit') {
return res.sendStatus(413);
}
res.sendStatus(500);
}
}
};

96
server/routes/fxa.js Normal file
View File

@ -0,0 +1,96 @@
const { URLSearchParams } = require('url');
const fetch = require('node-fetch');
const config = require('../config');
const pages = require('./pages');
const KEY_SCOPE = 'https://identity.mozilla.com/apps/send';
let fxaConfig = null;
let lastConfigRefresh = 0;
async function getFxaConfig() {
if (fxaConfig && Date.now() - lastConfigRefresh < 1000 * 60 * 5) {
return fxaConfig;
}
const res = await fetch(`${config.fxa_url}/.well-known/openid-configuration`);
fxaConfig = await res.json();
lastConfigRefresh = Date.now();
return fxaConfig;
}
module.exports = {
login: async function(req, res) {
const query = req.query;
if (!query || !query.keys_jwk) {
return res.sendStatus(400);
}
const c = await getFxaConfig();
const params = new URLSearchParams({
client_id: config.fxa_client_id,
redirect_uri: `${config.base_url}/api/fxa/oauth`,
state: 'todo',
scope: `profile ${KEY_SCOPE}`,
action: 'email',
keys_jwk: query.keys_jwk
});
res.redirect(`${c.authorization_endpoint}?${params.toString()}`);
},
oauth: async function(req, res) {
const query = req.query;
if (!query || !query.code || !query.state || !query.action) {
return res.sendStatus(400);
}
const c = await getFxaConfig();
const x = await fetch(c.token_endpoint, {
method: 'POST',
body: JSON.stringify({
code: query.code,
client_id: config.fxa_client_id,
client_secret: config.fxa_client_secret
}),
headers: {
'content-type': 'application/json'
}
});
const zzz = await x.json();
console.error(zzz);
const p = await fetch(c.userinfo_endpoint, {
method: 'GET',
headers: {
authorization: `Bearer ${zzz.access_token}`
}
});
const userInfo = await p.json();
userInfo.keys_jwe = zzz.keys_jwe;
userInfo.access_token = zzz.access_token;
req.userInfo = userInfo;
pages.index(req, res);
},
verify: async function(token) {
if (!token) {
return null;
}
const c = await getFxaConfig();
try {
const verifyUrl = c.jwks_uri.replace('jwks', 'verify');
const result = await fetch(verifyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const info = await result.json();
if (
info.scope &&
Array.isArray(info.scope) &&
info.scope.includes(KEY_SCOPE)
) {
return info.user;
}
} catch (e) {
// gulp
}
return null;
}
};

View File

@ -1,11 +1,13 @@
const crypto = require('crypto');
const express = require('express'); const express = require('express');
const helmet = require('helmet'); const helmet = require('helmet');
const storage = require('../storage'); const storage = require('../storage');
const config = require('../config'); const config = require('../config');
const auth = require('../middleware/auth'); const auth = require('../middleware/auth');
const owner = require('../middleware/owner');
const language = require('../middleware/language'); const language = require('../middleware/language');
const pages = require('./pages'); const pages = require('./pages');
const fxa = require('./fxa');
const filelist = require('./filelist');
const IS_DEV = config.env === 'development'; const IS_DEV = config.env === 'development';
const ID_REGEX = '([0-9a-fA-F]{10})'; const ID_REGEX = '([0-9a-fA-F]{10})';
@ -18,6 +20,10 @@ module.exports = function(app) {
force: !IS_DEV force: !IS_DEV
}) })
); );
app.use(function(req, res, next) {
req.cspNonce = crypto.randomBytes(16).toString('hex');
next();
});
if (!IS_DEV) { if (!IS_DEV) {
app.use( app.use(
helmet.contentSecurityPolicy({ helmet.contentSecurityPolicy({
@ -31,8 +37,18 @@ module.exports = function(app) {
'https://sentry.prod.mozaws.net', 'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com' 'https://www.google-analytics.com'
], ],
imgSrc: ["'self'", 'https://www.google-analytics.com'], imgSrc: [
scriptSrc: ["'self'"], "'self'",
'https://www.google-analytics.com',
'https://*.dev.lcip.org',
'https://firefoxusercontent.com'
],
scriptSrc: [
"'self'",
function(req) {
return `'nonce-${req.cspNonce}'`;
}
],
styleSrc: ["'self'", 'https://code.cdn.mozilla.net'], styleSrc: ["'self'", 'https://code.cdn.mozilla.net'],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'], fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"], formAction: ["'none'"],
@ -49,22 +65,30 @@ module.exports = function(app) {
next(); next();
}); });
app.use(express.json()); app.use(express.json());
app.get('/', language, pages.blank); app.get('/', language, pages.index);
app.get('/legal', language, pages.legal); app.get('/legal', language, pages.legal);
app.get('/jsconfig.js', require('./jsconfig')); app.get('/jsconfig.js', require('./jsconfig'));
app.get(`/share/:id${ID_REGEX}`, language, pages.blank); app.get(`/share/:id${ID_REGEX}`, language, pages.blank);
app.get(`/download/:id${ID_REGEX}`, language, pages.download); app.get(`/download/:id${ID_REGEX}`, language, pages.download);
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.hmac, require('./download'));
app.get(`/api/download/blob/:id${ID_REGEX}`, auth, require('./download')); app.get(
`/api/download/blob/:id${ID_REGEX}`,
auth.hmac,
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.hmac, require('./metadata'));
app.post('/api/upload', require('./upload')); app.get('/api/fxa/login', fxa.login);
app.post(`/api/delete/:id${ID_REGEX}`, owner, require('./delete')); app.get('/api/fxa/oauth', fxa.oauth);
app.post(`/api/password/:id${ID_REGEX}`, owner, require('./password')); app.get('/api/filelist', auth.fxa, filelist.get);
app.post(`/api/params/:id${ID_REGEX}`, owner, require('./params')); app.post('/api/filelist', auth.fxa, filelist.post);
app.post(`/api/info/:id${ID_REGEX}`, owner, require('./info')); app.post('/api/upload', auth.fxa, require('./upload'));
app.post(`/api/delete/:id${ID_REGEX}`, auth.owner, require('./delete'));
app.post(`/api/password/:id${ID_REGEX}`, auth.owner, require('./password'));
app.post(`/api/params/:id${ID_REGEX}`, auth.owner, require('./params'));
app.post(`/api/info/:id${ID_REGEX}`, auth.owner, require('./info'));
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

@ -34,8 +34,21 @@ var isUnsupportedPage = /\\\/unsupported/.test(location.pathname);
if (isIE && !isUnsupportedPage) { if (isIE && !isUnsupportedPage) {
window.location.replace('/unsupported/ie'); window.location.replace('/unsupported/ie');
} }
var MAXFILESIZE = ${config.max_file_size}; var LIMITS = {
var DEFAULT_EXPIRE_SECONDS = ${config.default_expire_seconds}; ANON: {
MAX_FILE_SIZE: ${config.anon_max_file_size},
MAX_DOWNLOADS: ${config.anon_max_downloads},
MAX_EXPIRE_SECONDS: ${config.anon_max_expire_seconds},
},
MAX_FILE_SIZE: ${config.max_file_size},
MAX_DOWNLOADS: ${config.max_downloads},
MAX_EXPIRE_SECONDS: ${config.max_expire_seconds},
MAX_FILES_PER_ARCHIVE: ${config.max_files_per_archive},
MAX_ARCHIVES_PER_USER: ${config.max_archives_per_user}
};
var DEFAULTS = {
EXPIRE_SECONDS: ${config.default_expire_seconds}
};
${ga} ${ga}
${sentry} ${sentry}
`; `;

View File

@ -27,7 +27,7 @@ module.exports = {
routes.toString( routes.toString(
`/download/${id}`, `/download/${id}`,
Object.assign(state(req), { Object.assign(state(req), {
fileInfo: { nonce, requiresPassword: pwd } downloadMetadata: { nonce, pwd }
}) })
) )
) )

View File

@ -1,8 +1,10 @@
const config = require('../config');
const storage = require('../storage'); const storage = require('../storage');
module.exports = function(req, res) { module.exports = function(req, res) {
const dlimit = req.body.dlimit; const dlimit = req.body.dlimit;
if (!dlimit || dlimit > 20) { // TODO: fxa auth
if (!dlimit || dlimit > config.max_downloads) {
return res.sendStatus(400); return res.sendStatus(400);
} }

View File

@ -5,10 +5,11 @@ const mozlog = require('../log');
const Limiter = require('../limiter'); const Limiter = require('../limiter');
const Parser = require('../streamparser'); const Parser = require('../streamparser');
const wsStream = require('websocket-stream/stream'); const wsStream = require('websocket-stream/stream');
// const fxa = require('./fxa');
const log = mozlog('send.upload'); const log = mozlog('send.upload');
module.exports = async function(ws, req) { module.exports = function(ws, req) {
let fileStream; let fileStream;
ws.on('close', e => { ws.on('close', e => {
@ -26,12 +27,19 @@ module.exports = async function(ws, req) {
const timeLimit = fileInfo.timeLimit; const timeLimit = fileInfo.timeLimit;
const metadata = fileInfo.fileMetadata; const metadata = fileInfo.fileMetadata;
const auth = fileInfo.authorization; const auth = fileInfo.authorization;
const user = '1'; //await fxa.verify(fileInfo.bearer); // TODO
const maxFileSize = user
? config.max_file_size
: config.anon_max_file_size;
const maxExpireSeconds = user
? config.max_expire_seconds
: config.anon_max_expire_seconds;
if ( if (
!metadata || !metadata ||
!auth || !auth ||
timeLimit <= 0 || timeLimit <= 0 ||
timeLimit > config.max_expire_seconds timeLimit > maxExpireSeconds
) { ) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@ -51,7 +59,7 @@ module.exports = async function(ws, req) {
const protocol = config.env === 'production' ? 'https' : req.protocol; const protocol = config.env === 'production' ? 'https' : req.protocol;
const url = `${protocol}://${req.get('host')}/download/${newId}/`; const url = `${protocol}://${req.get('host')}/download/${newId}/`;
const limiter = new Limiter(config.max_file_size); const limiter = new Limiter(maxFileSize);
const parser = new Parser(); const parser = new Parser();
fileStream = wsStream(ws, { binary: true }) fileStream = wsStream(ws, { binary: true })
.pipe(limiter) .pipe(limiter)

View File

@ -1,9 +1,12 @@
const config = require('./config'); const config = require('./config');
const layout = require('./layout'); const layout = require('./layout');
const locales = require('../common/locales'); const locales = require('../common/locales');
const assets = require('../common/assets');
module.exports = function(req) { module.exports = function(req) {
const locale = req.language || 'en-US'; const locale = req.language || 'en-US';
const userInfo = req.userInfo || { avatar: assets.get('user.svg') };
userInfo.loggedIn = !!userInfo.access_token;
return { return {
locale, locale,
translate: locales.getTranslator(locale), translate: locales.getTranslator(locale),
@ -17,6 +20,8 @@ module.exports = function(req) {
}, },
fira: false, fira: false,
fileInfo: {}, fileInfo: {},
cspNonce: req.cspNonce,
user: userInfo,
layout layout
}; };
}; };

View File

@ -31,7 +31,7 @@ const storedMeta = {
const authMiddleware = proxyquire('../../server/middleware/auth', { const authMiddleware = proxyquire('../../server/middleware/auth', {
'../storage': storage '../storage': storage
}); }).hmac;
describe('Owner Middleware', function() { describe('Owner Middleware', function() {
afterEach(function() { afterEach(function() {

View File

@ -19,9 +19,9 @@ function response() {
}; };
} }
const ownerMiddleware = proxyquire('../../server/middleware/owner', { const ownerMiddleware = proxyquire('../../server/middleware/auth', {
'../storage': storage '../storage': storage
}); }).owner;
describe('Owner Middleware', function() { describe('Owner Middleware', function() {
afterEach(function() { afterEach(function() {

View File

@ -40,7 +40,7 @@ describe('/api/params', function() {
it('sends a 400 if dlimit is too large', function() { it('sends a 400 if dlimit is too large', function() {
const req = request('x'); const req = request('x');
const res = response(); const res = response();
req.body.dlimit = 21; req.body.dlimit = 201;
paramsRoute(req, res); paramsRoute(req, res);
sinon.assert.calledWith(res.sendStatus, 400); sinon.assert.calledWith(res.sendStatus, 400);
}); });

View File

@ -1,4 +1,4 @@
/* global DEFAULT_EXPIRE_SECONDS */ /* global DEFAULTS */
import assert from 'assert'; import assert from 'assert';
import Archive from '../../../app/archive'; import Archive from '../../../app/archive';
import * as api from '../../../app/api'; import * as api from '../../../app/api';
@ -23,8 +23,9 @@ describe('API', function() {
enc, enc,
meta, meta,
verifierB64, verifierB64,
p, DEFAULTS.EXPIRE_SECONDS,
DEFAULT_EXPIRE_SECONDS null,
p
); );
const result = await up.result; const result = await up.result;
@ -43,8 +44,9 @@ describe('API', function() {
enc, enc,
meta, meta,
verifierB64, verifierB64,
p, DEFAULTS.EXPIRE_SECONDS,
DEFAULT_EXPIRE_SECONDS null,
p
); );
up.cancel(); up.cancel();

View File

@ -176,7 +176,7 @@ const web = {
from: '*.*' from: '*.*'
} }
]), ]),
new webpack.IgnorePlugin(/dist/), // used in common/*.js new webpack.IgnorePlugin(/\.\.\/dist/), // used in common/*.js
new webpack.IgnorePlugin(/require-from-string/), // used in common/locales.js new webpack.IgnorePlugin(/require-from-string/), // used in common/locales.js
new webpack.HashedModuleIdsPlugin(), new webpack.HashedModuleIdsPlugin(),
new ExtractTextPlugin({ new ExtractTextPlugin({