implemented amplitude metrics (#1141)

This commit is contained in:
Danny Coates 2019-02-12 11:50:06 -08:00 committed by GitHub
parent 1a483cad55
commit 9b37e92a81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 774 additions and 528 deletions

View File

@ -1,4 +1,4 @@
/* global LIMITS */ /* global LIMITS DEFAULTS */
import { blobStream, concatStream } from './streams'; import { blobStream, concatStream } from './streams';
function isDupe(newFile, array) { function isDupe(newFile, array) {
@ -17,6 +17,9 @@ 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);
this.timeLimit = DEFAULTS.EXPIRE_SECONDS;
this.dlimit = 1;
this.password = null;
} }
get name() { get name() {
@ -73,5 +76,8 @@ export default class Archive {
clear() { clear() {
this.files = []; this.files = [];
this.dlimit = 1;
this.timeLimit = DEFAULTS.EXPIRE_SECONDS;
this.password = null;
} }
} }

View File

@ -1,4 +1,4 @@
/* global DEFAULTS LIMITS */ /* global LIMITS */
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';
@ -50,37 +50,27 @@ export default function(state, emitter) {
emitter.on('logout', () => { emitter.on('logout', () => {
state.user.logout(); state.user.logout();
state.timeLimit = DEFAULTS.EXPIRE_SECONDS; metrics.loggedOut({ trigger: 'button' });
state.downloadCount = 1;
emitter.emit('pushState', '/'); emitter.emit('pushState', '/');
}); });
emitter.on('changeLimit', async ({ file, value }) => {
const ok = await file.changeLimit(value, state.user);
if (!ok) {
return;
}
state.storage.writeFile(file);
metrics.changedDownloadLimit(file);
});
emitter.on('removeUpload', file => { emitter.on('removeUpload', file => {
state.archive.remove(file); state.archive.remove(file);
render(); render();
}); });
emitter.on('delete', async ({ file, location }) => { emitter.on('delete', async ownedFile => {
try { try {
metrics.deletedUpload({ metrics.deletedUpload({
size: file.size, size: ownedFile.size,
time: file.time, time: ownedFile.time,
speed: file.speed, speed: ownedFile.speed,
type: file.type, type: ownedFile.type,
ttl: file.expiresAt - Date.now(), ttl: ownedFile.expiresAt - Date.now(),
location location
}); });
state.storage.remove(file.id); state.storage.remove(ownedFile.id);
await file.del(); await ownedFile.del();
} catch (e) { } catch (e) {
state.raven.captureException(e); state.raven.captureException(e);
} }
@ -100,20 +90,35 @@ export default function(state, emitter) {
state.archive.addFiles(files, maxSize); state.archive.addFiles(files, maxSize);
} catch (e) { } catch (e) {
if (e.message === 'fileTooBig' && maxSize < LIMITS.MAX_FILE_SIZE) { if (e.message === 'fileTooBig' && maxSize < LIMITS.MAX_FILE_SIZE) {
state.modal = signupDialog(); return emitter.emit('signup-cta', 'size');
} else {
state.modal = okDialog(
state.translate(e.message, {
size: bytes(maxSize),
count: LIMITS.MAX_FILES_PER_ARCHIVE
})
);
} }
state.modal = okDialog(
state.translate(e.message, {
size: bytes(maxSize),
count: LIMITS.MAX_FILES_PER_ARCHIVE
})
);
} }
render(); render();
}); });
emitter.on('upload', async ({ type, dlimit, password }) => { emitter.on('signup-cta', source => {
state.modal = signupDialog(source);
render();
});
emitter.on('authenticate', async (code, oauthState) => {
try {
await state.user.finishLogin(code, oauthState);
await state.user.syncFileList();
emitter.emit('replaceState', '/');
} catch (e) {
emitter.emit('replaceState', '/error');
setTimeout(render);
}
});
emitter.on('upload', async () => {
if (state.storage.files.length >= LIMITS.MAX_ARCHIVES_PER_USER) { if (state.storage.files.length >= LIMITS.MAX_ARCHIVES_PER_USER) {
state.modal = okDialog( state.modal = okDialog(
state.translate('tooManyArchives', { state.translate('tooManyArchives', {
@ -122,8 +127,7 @@ export default function(state, emitter) {
); );
return render(); return render();
} }
const size = state.archive.size; const archive = state.archive;
if (!state.timeLimit) state.timeLimit = DEFAULTS.EXPIRE_SECONDS;
const sender = new FileSender(); const sender = new FileSender();
sender.on('progress', updateProgress); sender.on('progress', updateProgress);
@ -135,41 +139,38 @@ export default function(state, emitter) {
const links = openLinksInNewTab(); const links = openLinksInNewTab();
await delay(200); await delay(200);
const start = Date.now();
try { try {
metrics.startedUpload({ size, type }); const ownedFile = await sender.upload(archive, state.user.bearerToken);
const ownedFile = await sender.upload(
state.archive,
state.timeLimit,
dlimit,
state.user.bearerToken
);
ownedFile.type = type;
state.storage.totalUploads += 1; state.storage.totalUploads += 1;
metrics.completedUpload(ownedFile); const duration = Date.now() - start;
metrics.completedUpload(archive, duration);
state.storage.addFile(ownedFile); state.storage.addFile(ownedFile);
// TODO integrate password into /upload request // TODO integrate password into /upload request
if (password) { if (archive.password) {
emitter.emit('password', { password, file: ownedFile }); emitter.emit('password', {
password: archive.password,
file: ownedFile
});
} }
state.modal = copyDialog(ownedFile.name, ownedFile.url); state.modal = copyDialog(ownedFile.name, ownedFile.url);
} catch (err) { } catch (err) {
if (err.message === '0') { if (err.message === '0') {
//cancelled. do nothing //cancelled. do nothing
metrics.cancelledUpload({ size, type }); const duration = Date.now() - start;
metrics.cancelledUpload(archive, duration);
render(); render();
} else { } else {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err);
state.raven.captureException(err); state.raven.captureException(err);
metrics.stoppedUpload({ size, type, err }); metrics.stoppedUpload(archive);
emitter.emit('pushState', '/error'); emitter.emit('pushState', '/error');
} }
} finally { } finally {
openLinksInNewTab(links, false); openLinksInNewTab(links, false);
state.archive.clear(); archive.clear();
state.password = '';
state.uploading = false; state.uploading = false;
state.transfer = null; state.transfer = null;
await state.user.syncFileList(); await state.user.syncFileList();
@ -183,7 +184,6 @@ export default function(state, emitter) {
render(); render();
await file.setPassword(password); await file.setPassword(password);
state.storage.writeFile(file); state.storage.writeFile(file);
metrics.addedPassword({ size: file.size });
await delay(1000); await delay(1000);
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -220,18 +220,20 @@ export default function(state, emitter) {
state.transfer.on('complete', render); state.transfer.on('complete', render);
const links = openLinksInNewTab(); const links = openLinksInNewTab();
const size = file.size; const size = file.size;
const start = Date.now();
try { try {
const start = Date.now();
metrics.startedDownload({ size: file.size, ttl: file.ttl });
const dl = state.transfer.download({ const dl = state.transfer.download({
stream: state.capabilities.streamDownload stream: state.capabilities.streamDownload
}); });
render(); render();
await dl; await dl;
const time = Date.now() - start;
const speed = size / (time / 1000);
state.storage.totalDownloads += 1; state.storage.totalDownloads += 1;
metrics.completedDownload({ size, time, speed }); const duration = Date.now() - start;
metrics.completedDownload({
size,
duration,
password_protected: file.requiresPassword
});
} catch (err) { } catch (err) {
if (err.message === '0') { if (err.message === '0') {
// download cancelled // download cancelled
@ -239,12 +241,16 @@ export default function(state, emitter) {
render(); render();
} else { } else {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err);
state.transfer = null; state.transfer = null;
const location = err.message === '404' ? '/404' : '/error'; const location = err.message === '404' ? '/404' : '/error';
if (location === '/error') { if (location === '/error') {
state.raven.captureException(err); state.raven.captureException(err);
metrics.stoppedDownload({ size, err }); const duration = Date.now() - start;
metrics.stoppedDownload({
size,
duration,
password_protected: file.requiresPassword
});
} }
emitter.emit('pushState', location); emitter.emit('pushState', location);
} }
@ -253,9 +259,9 @@ export default function(state, emitter) {
} }
}); });
emitter.on('copy', ({ url, location }) => { emitter.on('copy', ({ url }) => {
copyToClipboard(url); copyToClipboard(url);
metrics.copiedLink({ location }); // metrics.copiedLink({ location });
}); });
setInterval(() => { setInterval(() => {

View File

@ -1,4 +1,3 @@
/* 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';
@ -42,29 +41,24 @@ export default class FileSender extends Nanobus {
} }
} }
async upload( async upload(archive, bearerToken) {
file,
timeLimit = DEFAULTS.EXPIRE_SECONDS,
dlimit = 1,
bearerToken
) {
const start = Date.now(); const start = Date.now();
if (this.cancelled) { if (this.cancelled) {
throw new Error(0); throw new Error(0);
} }
this.msg = 'encryptingFile'; this.msg = 'encryptingFile';
this.emit('encrypting'); this.emit('encrypting');
const totalSize = encryptedSize(file.size); const totalSize = encryptedSize(archive.size);
const encStream = await this.keychain.encryptStream(file.stream); const encStream = await this.keychain.encryptStream(archive.stream);
const metadata = await this.keychain.encryptMetadata(file); const metadata = await this.keychain.encryptMetadata(archive);
const authKeyB64 = await this.keychain.authKeyB64(); const authKeyB64 = await this.keychain.authKeyB64();
this.uploadRequest = uploadWs( this.uploadRequest = uploadWs(
encStream, encStream,
metadata, metadata,
authKeyB64, authKeyB64,
timeLimit, archive.timeLimit,
dlimit, archive.dlimit,
bearerToken, bearerToken,
p => { p => {
this.progress = [p, totalSize]; this.progress = [p, totalSize];
@ -88,18 +82,18 @@ export default class FileSender extends Nanobus {
const ownedFile = new OwnedFile({ const ownedFile = new OwnedFile({
id: result.id, id: result.id,
url: `${result.url}#${secretKey}`, url: `${result.url}#${secretKey}`,
name: file.name, name: archive.name,
size: file.size, size: archive.size,
manifest: file.manifest, manifest: archive.manifest,
time: time, time: time,
speed: file.size / (time / 1000), speed: archive.size / (time / 1000),
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + timeLimit * 1000, expiresAt: Date.now() + archive.timeLimit * 1000,
secretKey: secretKey, secretKey: secretKey,
nonce: this.keychain.nonce, nonce: this.keychain.nonce,
ownerToken: result.ownerToken, ownerToken: result.ownerToken,
dlimit, dlimit: archive.dlimit,
timeLimit: timeLimit timeLimit: archive.timeLimit
}); });
return ownedFile; return ownedFile;

View File

@ -1,296 +1,172 @@
import testPilotGA from 'testpilot-ga/src/TestPilotGA';
import storage from './storage'; import storage from './storage';
import { platform } from './utils';
let hasLocalStorage = false;
try {
hasLocalStorage = typeof localStorage !== 'undefined';
} catch (e) {
// when disabled, any mention of localStorage throws an error
}
const analytics = new testPilotGA({
an: 'Firefox Send',
ds: 'web',
tid: window.GOOGLE_ANALYTICS_ID
});
let appState = null; let appState = null;
let experiment = null; // let experiment = null;
const HOUR = 1000 * 60 * 60;
const events = [];
let session_id = Date.now();
const lang = document.querySelector('html').lang;
export default function initialize(state, emitter) { export default function initialize(state, emitter) {
appState = state; appState = state;
if (!appState.user.firstAction) {
appState.user.firstAction = appState.route === '/' ? 'upload' : 'download';
}
emitter.on('DOMContentLoaded', () => { emitter.on('DOMContentLoaded', () => {
addExitHandlers(); // experiment = storage.enrolled[0];
experiment = storage.enrolled[0]; addEvent('client_visit', {
sendEvent(category(), 'visit', { entrypoint: appState.route === '/' ? 'upload' : 'download'
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads
}); });
}); });
emitter.on('exit', exitEvent);
emitter.on('experiment', experimentEvent); emitter.on('experiment', experimentEvent);
window.addEventListener('unload', submitEvents);
} }
function category() { function sizeOrder(n) {
switch (appState.route) { return Math.floor(Math.log10(n));
case '/':
case '/share/:id':
return 'sender';
case '/download/:id/:key':
case '/download/:id':
case '/completed':
return 'recipient';
default:
return 'other';
}
} }
function sendEvent() { function submitEvents() {
const args = Array.from(arguments); if (navigator.doNotTrack === '1') {
if (experiment && args[2]) { return;
args[2].xid = experiment[0];
args[2].xvar = experiment[1];
} }
return ( const data = new Blob(
hasLocalStorage && analytics.sendEvent.apply(analytics, args).catch(() => 0) [
JSON.stringify({
now: Date.now(),
session_id,
lang,
platform: platform(),
events
})
],
{ type: 'application/json' }
); );
} events.splice(0);
if (!navigator.sendBeacon) {
function urlToMetric(url) { return;
switch (url) {
case 'https://www.mozilla.org/':
return 'mozilla';
case 'https://www.mozilla.org/about/legal':
return 'legal';
case 'https://testpilot.firefox.com/about':
return 'about';
case 'https://testpilot.firefox.com/privacy':
return 'privacy';
case 'https://testpilot.firefox.com/terms':
return 'terms';
case 'https://www.mozilla.org/privacy/websites/#cookies':
return 'cookies';
case 'https://github.com/mozilla/send':
return 'github';
case 'https://twitter.com/FxTestPilot':
return 'twitter';
case 'https://www.mozilla.org/firefox/new/?scene=2':
return 'download-firefox';
case 'https://qsurvey.mozilla.com/s3/txp-firefox-send':
return 'survey';
case 'https://testpilot.firefox.com/':
case 'https://testpilot.firefox.com/experiments/send':
return 'testpilot';
case 'https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com':
return 'promo';
default:
return 'other';
} }
navigator.sendBeacon('/api/metrics', data);
} }
function setReferrer(state) { async function addEvent(event_type, event_properties) {
if (category() === 'sender') { const user_id = await appState.user.metricId();
if (state) { const device_id = await appState.user.deviceId();
storage.referrer = `${state}-upload`; events.push({
} device_id,
} else if (category() === 'recipient') { event_properties,
if (state) { event_type,
storage.referrer = `${state}-download`; time: Date.now(),
user_id,
user_properties: {
anonymous: !appState.user.loggedIn,
first_action: appState.user.firstAction,
active_count: storage.files.length
} }
});
if (events.length === 25) {
submitEvents();
} }
} }
function externalReferrer() { function cancelledUpload(archive, duration) {
if (/^https:\/\/testpilot\.firefox\.com/.test(document.referrer)) { return addEvent('client_upload', {
return 'testpilot'; download_limit: archive.dlimit,
} duration: sizeOrder(duration),
return 'external'; file_count: archive.numFiles,
} password_protected: !!archive.password,
size: sizeOrder(archive.size),
function takeReferrer() { status: 'cancel',
const referrer = storage.referrer || externalReferrer(); time_limit: archive.timeLimit
storage.referrer = null;
return referrer;
}
function startedUpload(params) {
return sendEvent('sender', 'upload-started', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length + 1,
cm7: storage.totalDownloads,
cd1: params.type,
cd5: takeReferrer()
}); });
} }
function cancelledUpload(params) { function completedUpload(archive, duration) {
setReferrer('cancelled'); return addEvent('client_upload', {
return sendEvent('sender', 'upload-stopped', { download_limit: archive.dlimit,
cm1: params.size, duration: sizeOrder(duration),
cm5: storage.totalUploads, file_count: archive.numFiles,
cm6: storage.files.length, password_protected: !!archive.password,
cm7: storage.totalDownloads, size: sizeOrder(archive.size),
cd1: params.type, status: 'ok',
cd2: 'cancelled' time_limit: archive.timeLimit
}); });
} }
function completedUpload(params) { function stoppedUpload(archive) {
return sendEvent('sender', 'upload-stopped', { return addEvent('client_upload', {
cm1: params.size, download_limit: archive.dlimit,
cm2: params.time, file_count: archive.numFiles,
cm3: params.speed, password_protected: !!archive.password,
cm5: storage.totalUploads, size: sizeOrder(archive.size),
cm6: storage.files.length, status: 'error',
cm7: storage.totalDownloads, time_limit: archive.timeLimit
cd1: params.type,
cd2: 'completed'
});
}
function addedPassword(params) {
return sendEvent('sender', 'password-added', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads
});
}
function startedDownload(params) {
return sendEvent('recipient', 'download-started', {
cm1: params.size,
cm4: params.ttl,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads
}); });
} }
function stoppedDownload(params) { function stoppedDownload(params) {
return sendEvent('recipient', 'download-stopped', { return addEvent('client_download', {
cm1: params.size, duration: sizeOrder(params.duration),
cm5: storage.totalUploads, password_protected: params.password_protected,
cm6: storage.files.length, size: sizeOrder(params.size),
cm7: storage.totalDownloads, status: 'error'
cd2: 'errored',
cd6: params.err
});
}
function cancelledDownload(params) {
setReferrer('cancelled');
return sendEvent('recipient', 'download-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'cancelled'
});
}
function stoppedUpload(params) {
return sendEvent('sender', 'upload-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd2: 'errored',
cd6: params.err
});
}
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 addEvent('client_download', {
cm1: params.size, duration: sizeOrder(params.duration),
cm2: params.time, password_protected: params.password_protected,
cm3: params.speed, size: sizeOrder(params.size),
cm5: storage.totalUploads, status: 'ok'
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'completed'
}); });
} }
function deletedUpload(params) { function deletedUpload(ownedFile) {
return sendEvent(category(), 'upload-deleted', { return addEvent('client_delete', {
cm1: params.size, age: Math.floor((Date.now() - ownedFile.createdAt) / HOUR),
cm2: params.time, downloaded: ownedFile.dtotal > 0,
cm3: params.speed, status: 'ok'
cm4: params.ttl,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd4: params.location
});
}
function unsupported(params) {
return sendEvent(category(), 'unsupported', {
cd6: params.err
});
}
function copiedLink(params) {
return sendEvent('sender', 'copied', {
cd4: params.location
});
}
function exitEvent(target) {
return sendEvent(category(), 'exited', {
cd3: urlToMetric(target.currentTarget.href)
}); });
} }
function experimentEvent(params) { function experimentEvent(params) {
return sendEvent(category(), 'experiment', params); return addEvent('client_experiment', params);
} }
// eslint-disable-next-line no-unused-vars function submittedSignup(params) {
function addExitHandlers() { return addEvent('client_login', {
const links = Array.from(document.querySelectorAll('a')); status: 'ok',
links.forEach(l => { trigger: params.trigger
if (/^http/.test(l.getAttribute('href'))) {
l.addEventListener('click', exitEvent);
}
}); });
} }
function restart(state) { function canceledSignup(params) {
setReferrer(state); return addEvent('client_login', {
return sendEvent(category(), 'restarted', { status: 'cancel',
cd2: state trigger: params.trigger
}); });
} }
function loggedOut(params) {
addEvent('client_logout', {
status: 'ok',
trigger: params.trigger
});
// flush events and start new anon session
submitEvents();
session_id = Date.now();
}
export { export {
copiedLink,
startedUpload,
cancelledUpload, cancelledUpload,
stoppedUpload, stoppedUpload,
completedUpload, completedUpload,
changedDownloadLimit,
deletedUpload, deletedUpload,
startedDownload,
cancelledDownload,
stoppedDownload, stoppedDownload,
completedDownload, completedDownload,
addedPassword, submittedSignup,
restart, canceledSignup,
unsupported loggedOut
}; };

View File

@ -8,7 +8,6 @@ export default class OwnedFile {
this.url = obj.url; this.url = obj.url;
this.name = obj.name; this.name = obj.name;
this.size = obj.size; this.size = obj.size;
this.type = obj.type;
this.manifest = obj.manifest; this.manifest = obj.manifest;
this.time = obj.time; this.time = obj.time;
this.speed = obj.speed; this.speed = obj.speed;
@ -78,7 +77,6 @@ export default class OwnedFile {
url: this.url, url: this.url,
name: this.name, name: this.name,
size: this.size, size: this.size,
type: this.type,
manifest: this.manifest, manifest: this.manifest,
time: this.time, time: this.time,
speed: this.speed, speed: this.speed,

View File

@ -11,14 +11,7 @@ module.exports = function(app = choo()) {
app.route('/error', body(require('./ui/error'))); app.route('/error', body(require('./ui/error')));
app.route('/blank', body(require('./ui/blank'))); app.route('/blank', body(require('./ui/blank')));
app.route('/oauth', async function(state, emit) { app.route('/oauth', async function(state, emit) {
try { emit('authenticate', state.query.code, state.query.state);
await state.user.finishLogin(state.query.code, state.query.state);
await state.user.syncFileList();
emit('replaceState', '/');
} catch (e) {
emit('replaceState', '/error');
setTimeout(() => emit('render'));
}
}); });
app.route('*', body(require('./ui/notFound'))); app.route('*', body(require('./ui/notFound')));
return app; return app;

View File

@ -1,4 +1,4 @@
import { isFile } from './utils'; import { arrayToB64, isFile } from './utils';
import OwnedFile from './ownedFile'; import OwnedFile from './ownedFile';
class Mem { class Mem {
@ -58,6 +58,15 @@ class Storage {
return fs; return fs;
} }
get id() {
let id = this.engine.getItem('device_id');
if (!id) {
id = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
this.engine.setItem('device_id', id);
}
return id;
}
get totalDownloads() { get totalDownloads() {
return Number(this.engine.getItem('totalDownloads')); return Number(this.engine.getItem('totalDownloads'));
} }

View File

@ -1,6 +1,5 @@
const html = require('choo/html'); const html = require('choo/html');
const Component = require('choo/component'); const Component = require('choo/component');
const signupDialog = require('./signupDialog');
class Account extends Component { class Account extends Component {
constructor(name, state, emit) { constructor(name, state, emit) {
@ -27,8 +26,7 @@ class Account extends Component {
login(event) { login(event) {
event.preventDefault(); event.preventDefault();
this.state.modal = signupDialog(); this.emit('signup-cta', 'button');
this.emit('render');
} }
logout(event) { logout(event) {

View File

@ -34,7 +34,7 @@ function password(state) {
<input <input
id="add-password" id="add-password"
type="checkbox" type="checkbox"
${state.password ? 'checked' : ''} ${state.archive.password ? 'checked' : ''}
autocomplete="off" autocomplete="off"
onchange="${togglePasswordInput}" onchange="${togglePasswordInput}"
/> />
@ -44,7 +44,7 @@ function password(state) {
</div> </div>
<input <input
id="password-input" id="password-input"
class="${state.password class="${state.archive.password
? '' ? ''
: 'invisible'} border rounded-sm focus:border-blue leading-normal my-2 py-1 px-2 h-8" : 'invisible'} border rounded-sm focus:border-blue leading-normal my-2 py-1 px-2 h-8"
autocomplete="off" autocomplete="off"
@ -53,7 +53,7 @@ function password(state) {
oninput="${inputChanged}" oninput="${inputChanged}"
onfocus="${focused}" onfocus="${focused}"
placeholder="${state.translate('unlockInputPlaceholder')}" placeholder="${state.translate('unlockInputPlaceholder')}"
value="${state.password || ''}" value="${state.archive.password || ''}"
/> />
<label <label
id="password-msg" id="password-msg"
@ -74,7 +74,7 @@ function password(state) {
input.classList.add('invisible'); input.classList.add('invisible');
input.value = ''; input.value = '';
document.getElementById('password-msg').textContent = ''; document.getElementById('password-msg').textContent = '';
state.password = null; state.archive.password = null;
} }
} }
@ -91,7 +91,7 @@ function password(state) {
} else { } else {
pwdmsg.textContent = ''; pwdmsg.textContent = '';
} }
state.password = password; state.archive.password = password;
} }
function focused(event) { function focused(event) {
@ -219,7 +219,7 @@ module.exports = function(state, emit, archive) {
function del(event) { function del(event) {
event.stopPropagation(); event.stopPropagation();
emit('delete', { file: archive, location: 'success-screen' }); emit('delete', archive);
} }
function share(event) { function share(event) {
@ -279,11 +279,7 @@ module.exports.wip = function(state, emit) {
event.preventDefault(); event.preventDefault();
event.target.disabled = true; event.target.disabled = true;
if (!state.uploading) { if (!state.uploading) {
emit('upload', { emit('upload');
type: 'click',
dlimit: state.downloadCount || 1,
password: state.password
});
} }
} }
@ -333,9 +329,9 @@ module.exports.uploading = function(state, emit) {
</p> </p>
<div class="text-xs text-grey-dark w-full mt-2 mb-2"> <div class="text-xs text-grey-dark w-full mt-2 mb-2">
${expiryInfo(state.translate, { ${expiryInfo(state.translate, {
dlimit: state.downloadCount || 1, dlimit: state.archive.dlimit,
dtotal: 0, dtotal: 0,
expiresAt: Date.now() + 500 + state.timeLimit * 1000 expiresAt: Date.now() + 500 + state.archive.timeLimit * 1000
})} })}
</div> </div>
<div class="text-blue text-sm font-medium mt-2">${progressPercent}</div> <div class="text-blue text-sm font-medium mt-2">${progressPercent}</div>

View File

@ -3,7 +3,6 @@ const html = require('choo/html');
const raw = require('choo/html/raw'); const raw = require('choo/html/raw');
const { secondsToL10nId } = require('../utils'); const { secondsToL10nId } = require('../utils');
const selectbox = require('./selectbox'); const selectbox = require('./selectbox');
const signupDialog = require('./signupDialog');
module.exports = function(state, emit) { module.exports = function(state, emit) {
const el = html` const el = html`
@ -29,17 +28,17 @@ module.exports = function(state, emit) {
const dlCountSelect = el.querySelector('#dlCount'); const dlCountSelect = el.querySelector('#dlCount');
el.replaceChild( el.replaceChild(
selectbox( selectbox(
state.downloadCount || 1, state.archive.dlimit,
counts, counts,
num => state.translate('downloadCount', { num }), num => state.translate('downloadCount', { num }),
value => { value => {
const max = state.user.maxDownloads; const max = state.user.maxDownloads;
state.archive.dlimit = Math.min(value, max);
if (value > max) { if (value > max) {
state.modal = signupDialog(); emit('signup-cta', 'count');
value = max; } else {
emit('render');
} }
state.downloadCount = value;
emit('render');
}, },
'expire-after-dl-count-select' 'expire-after-dl-count-select'
), ),
@ -53,7 +52,7 @@ module.exports = function(state, emit) {
const timeSelect = el.querySelector('#timespan'); const timeSelect = el.querySelector('#timespan');
el.replaceChild( el.replaceChild(
selectbox( selectbox(
state.timeLimit || 86400, state.archive.timeLimit,
expires, expires,
num => { num => {
const l10n = secondsToL10nId(num); const l10n = secondsToL10nId(num);
@ -61,12 +60,12 @@ module.exports = function(state, emit) {
}, },
value => { value => {
const max = state.user.maxExpireSeconds; const max = state.user.maxExpireSeconds;
state.archive.timeLimit = Math.min(value, max);
if (value > max) { if (value > max) {
state.modal = signupDialog(); emit('signup-cta', 'time');
value = max; } else {
emit('render');
} }
state.timeLimit = value;
emit('render');
}, },
'expire-after-time-select' 'expire-after-time-select'
), ),

View File

@ -1,8 +1,9 @@
/* global LIMITS */ /* global LIMITS */
const html = require('choo/html'); const html = require('choo/html');
const { bytes, platform } = require('../utils'); const { bytes, platform } = require('../utils');
const { canceledSignup, submittedSignup } = require('../metrics');
module.exports = function() { module.exports = function(trigger) {
return function(state, emit, close) { return function(state, emit, close) {
const hidden = platform() === 'android' ? 'hidden' : ''; const hidden = platform() === 'android' ? 'hidden' : '';
let submitting = false; let submitting = false;
@ -37,7 +38,7 @@ module.exports = function() {
<button <button
class="my-4 text-blue hover:text-blue-dark focus:text-blue-darker font-medium" class="my-4 text-blue hover:text-blue-dark focus:text-blue-darker font-medium"
title="${state.translate('deletePopupCancel')}" title="${state.translate('deletePopupCancel')}"
onclick=${close}>${state.translate('deletePopupCancel')} onclick=${cancel}>${state.translate('deletePopupCancel')}
</button> </button>
</send-signup-dialog>`; </send-signup-dialog>`;
@ -50,6 +51,11 @@ module.exports = function() {
return a.length === 2 && a.every(s => s.length > 0); return a.length === 2 && a.every(s => s.length > 0);
} }
function cancel(event) {
canceledSignup({ trigger });
close(event);
}
function submitEmail(event) { function submitEmail(event) {
event.preventDefault(); event.preventDefault();
if (submitting) { if (submitting) {
@ -59,6 +65,7 @@ module.exports = function() {
const el = document.getElementById('email-input'); const el = document.getElementById('email-input');
const email = el.value; const email = el.value;
submittedSignup({ trigger });
emit('login', emailish(email) ? email : null); emit('login', emailish(email) ? email : null);
} }
}; };

View File

@ -9,6 +9,16 @@ import storage from './storage';
const textEncoder = new TextEncoder(); const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder(); const textDecoder = new TextDecoder();
const anonId = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
async function hashId(id) {
const d = new Date();
const month = d.getUTCMonth();
const year = d.getUTCFullYear();
const encoded = textEncoder.encode(`${id}:${year}:${month}`);
const hash = await crypto.subtle.digest('SHA-256', encoded);
return arrayToB64(new Uint8Array(hash.slice(16)));
}
export default class User { export default class User {
constructor(storage) { constructor(storage) {
@ -25,6 +35,14 @@ export default class User {
this.storage.user = data; this.storage.user = data;
} }
get firstAction() {
return this.storage.get('firstAction');
}
set firstAction(action) {
this.storage.set('firstAction', action);
}
get avatar() { get avatar() {
const defaultAvatar = assets.get('user.svg'); const defaultAvatar = assets.get('user.svg');
if (this.info.avatarDefault) { if (this.info.avatarDefault) {
@ -63,6 +81,14 @@ export default class User {
return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS; return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS;
} }
async metricId() {
return this.loggedIn ? hashId(this.info.uid) : undefined;
}
async deviceId() {
return this.loggedIn ? hashId(this.storage.id) : hashId(anonId);
}
async login(email) { async login(email) {
const state = arrayToB64(crypto.getRandomValues(new Uint8Array(16))); const state = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
storage.set('oauthState', state); storage.set('oauthState', state);

View File

@ -16,7 +16,6 @@ Then you can run either `docker build` or `docker-compose up`.
| `PORT` | Port the server will listen on (defaults to 1443). | `PORT` | Port the server will listen on (defaults to 1443).
| `S3_BUCKET` | The S3 bucket name. | `S3_BUCKET` | The S3 bucket name.
| `REDIS_HOST` | Host name of the Redis server. | `REDIS_HOST` | Host name of the Redis server.
| `GOOGLE_ANALYTICS_ID` | Google Analytics ID
| `SENTRY_CLIENT` | Sentry Client ID | `SENTRY_CLIENT` | Sentry Client ID
| `SENTRY_DSN` | Sentry DSN | `SENTRY_DSN` | Sentry DSN
| `MAX_FILE_SIZE` | in bytes (defaults to 2147483648) | `MAX_FILE_SIZE` | in bytes (defaults to 2147483648)
@ -28,7 +27,6 @@ Then you can run either `docker build` or `docker-compose up`.
$ docker run --net=host -e 'NODE_ENV=production' \ $ docker run --net=host -e 'NODE_ENV=production' \
-e 'S3_BUCKET=testpilot-p2p-dev' \ -e 'S3_BUCKET=testpilot-p2p-dev' \
-e 'REDIS_HOST=dyf9s2r4vo3.bolxr4.0001.usw2.cache.amazonaws.com' \ -e 'REDIS_HOST=dyf9s2r4vo3.bolxr4.0001.usw2.cache.amazonaws.com' \
-e 'GOOGLE_ANALYTICS_ID=UA-35433268-78' \
-e 'SENTRY_CLIENT=https://51e23d7263e348a7a3b90a5357c61cb2@sentry.prod.mozaws.net/168' \ -e 'SENTRY_CLIENT=https://51e23d7263e348a7a3b90a5357c61cb2@sentry.prod.mozaws.net/168' \
-e 'SENTRY_DSN=https://51e23d7263e348a7a3b90a5357c61cb2:65e23d7263e348a7a3b90a5357c61c44@sentry.prod.mozaws.net/168' \ -e 'SENTRY_DSN=https://51e23d7263e348a7a3b90a5357c61cb2:65e23d7263e348a7a3b90a5357c61c44@sentry.prod.mozaws.net/168' \
mozilla/send:latest mozilla/send:latest

View File

@ -39,87 +39,89 @@ We will know this to be true when we can conduct six research tasks (surveys, A/
* FxA UI Engagement `--- DESIRED OUTCOME --->` Authenticate * FxA UI Engagement `--- DESIRED OUTCOME --->` Authenticate
* **STRETCH** App Open or Visit `--- DESIRED OUTCOME --->` Successful Download * **STRETCH** App Open or Visit `--- DESIRED OUTCOME --->` Successful Download
## Complete Schema ## Amplitude Schema
Please see, **See Amplitude HTTP API**(https://amplitude.zendesk.com/hc/en-us/articles/204771828) for HTTP API reference. Please see, **See Amplitude HTTP API**(https://amplitude.zendesk.com/hc/en-us/articles/204771828) for HTTP API reference.
### Event Structure ## Metric Events
* `app_version` **string** ∙ app version `Android 1.5` or `Web 1.2.5` In support of our KPIs we collect events from two separate contexts, server and client. The events are designed to have minimal correlation between contexts.
* `country` **string** ∙ Can be captured using [FxA Geo Library](https://github.com/mozilla/fxa-geodb)
* `device_id` **string** ∙ required, should be a unique hash
* `event_properties` **dictionary** ∙ [see list below](#event-properties)
* `event_type` **string** ∙ [see list below](#events)
* `insert_id` **string** ∙ unique event id used by amplitude to dedupe events
* `language` **string** ∙ App language
* `os_name` **string**`Mac OS X`, `iOS`, `Windows`, etc.
* `os_version` **string**`10.01`, `400`, etc
* `region` **string** ∙ Can be captured using [FxA Geo Library](https://github.com/mozilla/fxa-geodb)
* `session_id` **long** ∙ start time in ms since epoch (this should only be changed at the start of a session, but sent with each ping), set to -1 if event is out of session, such as expiration
* `time` **long** ∙ The timestamp of the event in milliseconds since epoch
* `user_id` **string** ∙ required unless device ID is present, should be a double hash of FxA email
* `user_properties` **dictionary** ∙ [see list below](#user-properties). All user properties can be passed with all events. Amplitude will automatically drop user properties that do not change
### User Properties Server events collect lifecycle information about individual uploads but no user information; also time precision is truncated to hour increments. Client events collect information about how users interact with the UI but no upload identifiers.
* `Has account` **boolean** ∙ whether the user is account active ### Server Events
* `First action` **string** ∙ did this user `upload` or `download` first
* `Total uploads` **num** ∙ running sum of bundles uploaded
* `Total upload size` **float** ∙ running sum of total MB uploaded
* `Total downloads` **num** ∙ running count of bundles downloaded
* `Total download size` **float** ∙ running sum of total MB downloaded
* `Total clients` **num** ∙ running tally of total clients sharing a UID
* `Current uploads` **int** ∙ count of current unexpired files
* `User agent Browser` **string** ∙ browser or if app `App` derived from UA string
* `User Agent version` **string** ∙ browser version or if app `App Version` derived from UA string
* `UTM campaign` **string** ∙ referrer
* `UTM content` **string** ∙ referrer
* `UTM medium` **string** ∙ referrer
* `UTM source` **string** ∙ referrer
* `UTM term` **string** ∙ referrer
* `Experiments` **array of strings** ∙ set of experiments the user is in
### Event Properties Server events allow us to aggregate data about file lifecycle without collecting data about individual users. In this context `user_id` and `user_properties` describe the uploaded archive.
1. `Bundle id` **string** ∙ Guid for bundle * `session_id` -1 (not part of a session)
2. `Bundle creation timestamp` **long** ∙ The timestamp of bundle creation in milliseconds since epoch * `user_id` hash of (archive_id + owner_id)
3. `Number of files` **int** ∙ Number of files in bundle * `app_version` package.json version
4. `Size of files` **float** ∙ Size of files in MB * `time` timestamp truncated to hour precision
5. `Transfer rate` **float** ∙ rate of transfter in bytes per second * `country`
6. `Total downloads` **int** ∙ number of downloads set * `region`
7. `Total duration` **string** ∙ Time for bundle expiry, one of `5 minutes` `one hour` etc * `event_type` [server_upload | server_download | server_delete]
8. `Password added` **boolean** ∙ Did the user add a password to the bundle * `user_properties`
9. `Remaining downloads` **int** ∙ number of remaining downloads for a file * `download_limit` set number of downloads
10. `Remaining time` **long** ∙ time until a bundle expires * `time_limit` set expiry duration
11. `Reason transfer stopped` **string** ∙ One of `completed`, `errored` or `canceled` * `size` approximate size (log10)
12. `FxA prompt trigger` **string** ∙ One of `time options`, `count options`, `bundle size`, `shoulder button` * `anonymous` true if anonymous, false if fxa
13. `Location of URL copy` **string** ∙ Where did the user copy the share url `success-screen` or `upload-list` * `event_properties`
14. `Site exit path` **string** ∙ Name of external link followed ... `download-firefox`, `twitter`, `github`, `cookies`, `terms`, `privacy`, `about`, `legal`, `mozilla` * `download_count` downloads completed
15. `Expiry reason` **string** ∙ one of `time limit hit`, `download limit hit`, `user deleted` * `ttl` time remaining before expiry truncated to hour
16. `Error code` **String** ∙ Error code if added
### Event Types ### Client Events
The following list is of required `event_type` definitions. If adding new event types please use the syntax `Group - verb subject modifier` Client events allow us to aggregate data about how the user interface is being used without tracking the lifecycle of individual files. In this context `user_id` and `user_properties` describe the user. The `user_id` and `device_id` change for all users at the beginning of each month.
| Event | Event Properties | Description | * `session_id` timestamp
|-------|------------------|-------------| * `user_id` hash of (fxa_id + Date.year + Date.month)
| `{ Uploader, Downloader, Unsupported } - visit` | `none` | When a user visits the site, or opens the app, grouped by interface at open. Note, a number of API properties and User Properties should be set with this event | * `device_id` hash of (localStorage random id + Date.year + Date.month)
|`{ Uploader, Downloader, Unsupported } - exit` | `none` | When a user exits the site via click event on a link that directs to another domain | * `platform` [web | android]
| `Uploader - start bundle upload` | `1, 2, 3, 4, 6, 7, 8, 16 (if applicable)` | When a user begins to upload a bundle for the site | * `country`
| `Uploader - stop bundle upload` | `1, 2, 3, 4, 5, 6, 7, 8, 11, 16 (if applicable)` | When a user stops an upload or an upload stops for any reason | * `region`
| `Uploader - delete bundle` | `1, 2, 3, 4, 6, 7, 8, 9, 10` | When a user deletes their bundle | * `language`
| `Uploader - copy bundle url` | `1, 13` | When a user copies the url of a bundle they create | * `time` timestamp
| `Uploader - dismiss copy bundle dialog` | `1` | When a user dismisses the bundle copy dialog | * `os_name`
| `{ Uploader, Downloader } - start bundle download` | `1, 2, 3, 4, 6, 7, 8, 9, 10, 16 (if applicable)` | When a user begins to download a bundle. Remaining downloads should be decremented after event. | * `event_type` [client_visit | client_upload | client_download | client_delete | client_login | client_logout]
| `{ Uploader, Downloader } - stop bundle download` | `1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16 (if applicable)` | When a a download ends for any reason | * `event_properties`
| `Downloader - click try send cta` | `1` | When a downloader clicks the prompt to try Firefox Send | * `browser`
| `Downloader - unlock bundle success` | `1` | When a downloader successfully unlocks a file | * `browser_version`
| `Downloader - unlock bundle failure` | `1` | When a downloader fails to unlock a file (only send once per session) | * `status` [ ok | error | cancel ]
| `Uploader - trigger signup cta` | `12` | When an uploader triggers the CTA via change to expiry options | * Event specific properties (see below)
| `Signup - interact with email` | `12` | when a user inputs anything into the email submission form | * `user_properties`
| `Signup - cancel signup` | `12` | When a user opts out of signing up | * `active_count` number of active uploads
| `Signup - submit signup` | `12` | When a user submits a sign up to fxa and we begin OAuth dance | * `anonymous` true if anonymous, false if fxa
| `Server - expire bundle` | `1, 2, 3, 4, 6, 7, 8, 9, 10, 15` | when the server expires a bundle for any reason | * `experiments` list of experiment ids the user is participating in
| `Error` | `16` | Fallback event for any errors that occur. Use the error code event property to specify an error type | * `first_action` how this use came to Send the first time [ upload | download ]
#### Visit Event
* `entrypoint` [ upload | download ]
#### Upload Event
* `download_limit` download limit
* `file_count` number of files
* `password_protected` boolean
* `size` approximate size (log10)
* `time_limit` time limit
* `duration` approximate transfer duration (log10)
#### Download Event
* `password_protected` boolean
* `size` approximate size (log10)
* `duration` approximate transfer duration (log10)
#### Delete Event
* `age` hours since uploaded
* `downloaded` downloaded at least once
#### Login Event
* `trigger` [button | time | count | size]
#### Logout Event
* `trigger` [button | timeout]

292
package-lock.json generated
View File

@ -1825,9 +1825,9 @@
} }
}, },
"aws-sdk": { "aws-sdk": {
"version": "2.400.0", "version": "2.401.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.400.0.tgz", "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.401.0.tgz",
"integrity": "sha512-FJjRXajLnI52F0C1E4nFARk/907x4ZuffwFd6jiBQfaepT03wsY1PcJNEQ5CNHYA2qxAx4HezL2pKySEX+g87g==", "integrity": "sha512-mOI4gzKoP/g8Q0ToAaqTh7TijGG9PvGVVUkKmurXqBKy7GTPmy4JizfVkTrM+iBg7RAsx5H2lBxBFpdEFBa5fg==",
"requires": { "requires": {
"buffer": "4.9.1", "buffer": "4.9.1",
"events": "1.1.1", "events": "1.1.1",
@ -2054,6 +2054,11 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"big-integer": {
"version": "1.6.41",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.41.tgz",
"integrity": "sha512-d5AT9lMTYJ/ZE/4gzxb+5ttPcRWljVsvv7lF1w9KzkPhVUhBtHrjDo1J8swfZKepfLsliDhYa31zRYwcD0Yg9w=="
},
"big.js": { "big.js": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -3275,6 +3280,14 @@
"sha.js": "^2.4.8" "sha.js": "^2.4.8"
} }
}, },
"cron": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.5.0.tgz",
"integrity": "sha512-j7zMFLrcSta53xqOvETUt8ge+PM14GtF47gEGJJeVlM6qP24/eWHSgtiWiEiKBR2sHS8xZaBQZq4D7vFXg8dcQ==",
"requires": {
"moment-timezone": "^0.5.x"
}
},
"cross-env": { "cross-env": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz",
@ -4851,9 +4864,9 @@
"dev": true "dev": true
}, },
"esm": { "esm": {
"version": "3.2.4", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.4.tgz", "resolved": "https://registry.npmjs.org/esm/-/esm-3.1.1.tgz",
"integrity": "sha512-wOuWtQCkkwD1WKQN/k3RsyGSSN+AmiUzdKftn8vaC+uV9JesYmQlODJxgXaaRz0LaaFIlUxZaUu5NPiUAjKAAA==" "integrity": "sha512-Md2pR4IbR37UqubbgbA4+wiBorOEFB05Oo+g4WJW7W2ajiOhUfjZt77NzzCoQdrCb40GdKcflitm+XHDF053OQ=="
}, },
"espree": { "espree": {
"version": "5.0.0", "version": "5.0.0",
@ -5772,24 +5785,28 @@
"dependencies": { "dependencies": {
"abbrev": { "abbrev": {
"version": "1.1.1", "version": "1.1.1",
"bundled": true, "resolved": false,
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "resolved": false,
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"dev": true "dev": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
"bundled": true, "resolved": false,
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"are-we-there-yet": { "are-we-there-yet": {
"version": "1.1.5", "version": "1.1.5",
"bundled": true, "resolved": false,
"integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5799,12 +5816,14 @@
}, },
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true "dev": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "resolved": false,
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true, "dev": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@ -5813,34 +5832,40 @@
}, },
"chownr": { "chownr": {
"version": "1.1.1", "version": "1.1.1",
"bundled": true, "resolved": false,
"integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "resolved": false,
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"dev": true "dev": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true "dev": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "resolved": false,
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"dev": true "dev": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"debug": { "debug": {
"version": "2.6.9", "version": "2.6.9",
"bundled": true, "resolved": false,
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5849,25 +5874,29 @@
}, },
"deep-extend": { "deep-extend": {
"version": "0.6.0", "version": "0.6.0",
"bundled": true, "resolved": false,
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"delegates": { "delegates": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"detect-libc": { "detect-libc": {
"version": "1.0.3", "version": "1.0.3",
"bundled": true, "resolved": false,
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"fs-minipass": { "fs-minipass": {
"version": "1.2.5", "version": "1.2.5",
"bundled": true, "resolved": false,
"integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5876,13 +5905,15 @@
}, },
"fs.realpath": { "fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"gauge": { "gauge": {
"version": "2.7.4", "version": "2.7.4",
"bundled": true, "resolved": false,
"integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5898,7 +5929,8 @@
}, },
"glob": { "glob": {
"version": "7.1.3", "version": "7.1.3",
"bundled": true, "resolved": false,
"integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5912,13 +5944,15 @@
}, },
"has-unicode": { "has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"iconv-lite": { "iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"bundled": true, "resolved": false,
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5927,7 +5961,8 @@
}, },
"ignore-walk": { "ignore-walk": {
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "resolved": false,
"integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5936,7 +5971,8 @@
}, },
"inflight": { "inflight": {
"version": "1.0.6", "version": "1.0.6",
"bundled": true, "resolved": false,
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -5946,18 +5982,21 @@
}, },
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true, "resolved": false,
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"dev": true "dev": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
"bundled": true, "resolved": false,
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"dev": true, "dev": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "number-is-nan": "^1.0.0"
@ -5965,13 +6004,15 @@
}, },
"isarray": { "isarray": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "resolved": false,
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true, "dev": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@ -5979,12 +6020,14 @@
}, },
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true, "resolved": false,
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true "dev": true
}, },
"minipass": { "minipass": {
"version": "2.3.5", "version": "2.3.5",
"bundled": true, "resolved": false,
"integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==",
"dev": true, "dev": true,
"requires": { "requires": {
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
@ -5993,7 +6036,8 @@
}, },
"minizlib": { "minizlib": {
"version": "1.2.1", "version": "1.2.1",
"bundled": true, "resolved": false,
"integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6002,7 +6046,8 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "resolved": false,
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true, "dev": true,
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
@ -6010,13 +6055,15 @@
}, },
"ms": { "ms": {
"version": "2.0.0", "version": "2.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"needle": { "needle": {
"version": "2.2.4", "version": "2.2.4",
"bundled": true, "resolved": false,
"integrity": "sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6027,7 +6074,8 @@
}, },
"node-pre-gyp": { "node-pre-gyp": {
"version": "0.10.3", "version": "0.10.3",
"bundled": true, "resolved": false,
"integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6045,7 +6093,8 @@
}, },
"nopt": { "nopt": {
"version": "4.0.1", "version": "4.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6055,13 +6104,15 @@
}, },
"npm-bundled": { "npm-bundled": {
"version": "1.0.5", "version": "1.0.5",
"bundled": true, "resolved": false,
"integrity": "sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"npm-packlist": { "npm-packlist": {
"version": "1.2.0", "version": "1.2.0",
"bundled": true, "resolved": false,
"integrity": "sha512-7Mni4Z8Xkx0/oegoqlcao/JpPCPEMtUvsmB0q7mgvlMinykJLSRTYuFqoQLYgGY8biuxIeiHO+QNJKbCfljewQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6071,7 +6122,8 @@
}, },
"npmlog": { "npmlog": {
"version": "4.1.2", "version": "4.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6083,18 +6135,21 @@
}, },
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true "dev": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
"bundled": true, "resolved": false,
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"once": { "once": {
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "resolved": false,
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true, "dev": true,
"requires": { "requires": {
"wrappy": "1" "wrappy": "1"
@ -6102,19 +6157,22 @@
}, },
"os-homedir": { "os-homedir": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"os-tmpdir": { "os-tmpdir": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"osenv": { "osenv": {
"version": "0.1.5", "version": "0.1.5",
"bundled": true, "resolved": false,
"integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6124,19 +6182,22 @@
}, },
"path-is-absolute": { "path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.0", "version": "2.0.0",
"bundled": true, "resolved": false,
"integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"rc": { "rc": {
"version": "1.2.8", "version": "1.2.8",
"bundled": true, "resolved": false,
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6148,7 +6209,8 @@
"dependencies": { "dependencies": {
"minimist": { "minimist": {
"version": "1.2.0", "version": "1.2.0",
"bundled": true, "resolved": false,
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"dev": true, "dev": true,
"optional": true "optional": true
} }
@ -6156,7 +6218,8 @@
}, },
"readable-stream": { "readable-stream": {
"version": "2.3.6", "version": "2.3.6",
"bundled": true, "resolved": false,
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6171,7 +6234,8 @@
}, },
"rimraf": { "rimraf": {
"version": "2.6.3", "version": "2.6.3",
"bundled": true, "resolved": false,
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6180,42 +6244,49 @@
}, },
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true "dev": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"sax": { "sax": {
"version": "1.2.4", "version": "1.2.4",
"bundled": true, "resolved": false,
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"semver": { "semver": {
"version": "5.6.0", "version": "5.6.0",
"bundled": true, "resolved": false,
"integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"set-blocking": { "set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.2", "version": "3.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"string-width": { "string-width": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"dev": true, "dev": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "code-point-at": "^1.0.0",
@ -6225,7 +6296,8 @@
}, },
"string_decoder": { "string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"bundled": true, "resolved": false,
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6234,7 +6306,8 @@
}, },
"strip-ansi": { "strip-ansi": {
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"dev": true, "dev": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
@ -6242,13 +6315,15 @@
}, },
"strip-json-comments": { "strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"tar": { "tar": {
"version": "4.4.8", "version": "4.4.8",
"bundled": true, "resolved": false,
"integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6263,13 +6338,15 @@
}, },
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"wide-align": { "wide-align": {
"version": "1.1.3", "version": "1.1.3",
"bundled": true, "resolved": false,
"integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -6278,12 +6355,14 @@
}, },
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true "dev": true
}, },
"yallist": { "yallist": {
"version": "3.0.3", "version": "3.0.3",
"bundled": true, "resolved": false,
"integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==",
"dev": true "dev": true
} }
} }
@ -6300,6 +6379,26 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true "dev": true
}, },
"fxa-geodb": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fxa-geodb/-/fxa-geodb-1.0.4.tgz",
"integrity": "sha512-f+uNgA+6OxmLAHhZvMztwPrByhkaVmSrKcb5Q1TI7Zz/onSQPYCJs388are7nWQdXI94pncqmSPxmT9kOUllEA==",
"requires": {
"bluebird": "3.5.2",
"cron": "1.5.0",
"maxmind": "2.8.0",
"mkdirp": "0.5.1",
"mozlog": "2.2.0",
"request": "2.88.0"
},
"dependencies": {
"bluebird": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.2.tgz",
"integrity": "sha512-dhHTWMI7kMx5whMQntl7Vr9C6BvV10lFXDAasnqnrMYhXVCzzk6IO9Fo2L75jXHT07WrOngL1WDXOp+yYS91Yg=="
}
}
},
"g-status": { "g-status": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/g-status/-/g-status-2.0.2.tgz", "resolved": "https://registry.npmjs.org/g-status/-/g-status-2.0.2.tgz",
@ -8732,6 +8831,15 @@
"integrity": "sha512-3Zs9P/0zzwTob2pdgT0CHZuMbnSUSp8MB1bddfm+HDmnFWHGT4jvEZRf+2RuPoa+cjdn/z25SEt5gFTqdhvJAg==", "integrity": "sha512-3Zs9P/0zzwTob2pdgT0CHZuMbnSUSp8MB1bddfm+HDmnFWHGT4jvEZRf+2RuPoa+cjdn/z25SEt5gFTqdhvJAg==",
"dev": true "dev": true
}, },
"maxmind": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-2.8.0.tgz",
"integrity": "sha512-U3/jQRUoMf4pQ/Tm7JNtGRaM9z82fATB2TiGgs0kEKMPZn/UbOnlyGMRItJ2+KWrwjz9a7PqRzy3/haq9XfUOQ==",
"requires": {
"big-integer": "^1.6.31",
"tiny-lru": "^1.6.1"
}
},
"md5": { "md5": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz",
@ -9077,6 +9185,14 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=" "integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
}, },
"moment-timezone": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.23.tgz",
"integrity": "sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==",
"requires": {
"moment": ">= 2.9.0"
}
},
"morgan": { "morgan": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz",
@ -15047,14 +15163,14 @@
} }
}, },
"terser": { "terser": {
"version": "3.16.1", "version": "3.14.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-3.16.1.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-3.14.1.tgz",
"integrity": "sha512-JDJjgleBROeek2iBcSNzOHLKsB/MdDf+E/BOAJ0Tk9r7p9/fVobfv7LMJ/g/k3v9SXdmjZnIlFd5nfn/Rt0Xow==", "integrity": "sha512-NSo3E99QDbYSMeJaEk9YW2lTg3qS9V0aKGlb+PlOrei1X02r1wSBHCNX/O+yeTRFSWPKPIGj6MqvvdqV4rnVGw==",
"dev": true, "dev": true,
"requires": { "requires": {
"commander": "~2.17.1", "commander": "~2.17.1",
"source-map": "~0.6.1", "source-map": "~0.6.1",
"source-map-support": "~0.5.9" "source-map-support": "~0.5.6"
}, },
"dependencies": { "dependencies": {
"source-map": { "source-map": {
@ -15066,9 +15182,9 @@
} }
}, },
"terser-webpack-plugin": { "terser-webpack-plugin": {
"version": "1.2.2", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.2.2.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.2.1.tgz",
"integrity": "sha512-1DMkTk286BzmfylAvLXwpJrI7dWa5BnFmscV/2dCr8+c56egFcbaeFAl7+sujAjdmpLam21XRdhA4oifLyiWWg==", "integrity": "sha512-GGSt+gbT0oKcMDmPx4SRSfJPE1XaN3kQRWG4ghxKQw9cn5G9x6aCKSsgYdvyM0na9NJ4Drv0RG6jbBByZ5CMjw==",
"dev": true, "dev": true,
"requires": { "requires": {
"cacache": "^11.0.2", "cacache": "^11.0.2",
@ -15076,7 +15192,7 @@
"schema-utils": "^1.0.0", "schema-utils": "^1.0.0",
"serialize-javascript": "^1.4.0", "serialize-javascript": "^1.4.0",
"source-map": "^0.6.1", "source-map": "^0.6.1",
"terser": "^3.16.1", "terser": "^3.8.1",
"webpack-sources": "^1.1.0", "webpack-sources": "^1.1.0",
"worker-farm": "^1.5.2" "worker-farm": "^1.5.2"
}, },
@ -15285,12 +15401,6 @@
} }
} }
}, },
"testpilot-ga": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/testpilot-ga/-/testpilot-ga-0.3.0.tgz",
"integrity": "sha512-z4PJbw3KK0R0iflA+u/3BhWZrtsLHLu+0rMviMd8H1wp8qPV0rggNBjsKckBJCcXq4uEjXETGZzApHH7Tovpzw==",
"dev": true
},
"text-encoding": { "text-encoding": {
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz",
@ -15344,6 +15454,11 @@
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=",
"dev": true "dev": true
}, },
"tiny-lru": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-1.6.4.tgz",
"integrity": "sha512-Et+J3Css66XPSLWjLF9wmgbECsGiExlEL+jxsFerTQF6N6dpxswDTPAfIrAbQKO5c1uhgq2xvo5zMk1W+kBDNA=="
},
"tmp": { "tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -15540,6 +15655,11 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
}, },
"ua-parser-js": {
"version": "0.7.19",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz",
"integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ=="
},
"uglify-js": { "uglify-js": {
"version": "3.4.9", "version": "3.4.9",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz",

View File

@ -119,7 +119,6 @@
"svgo": "^1.1.1", "svgo": "^1.1.1",
"svgo-loader": "^2.2.0", "svgo-loader": "^2.2.0",
"tailwindcss": "^0.7.4", "tailwindcss": "^0.7.4",
"testpilot-ga": "^0.3.0",
"val-loader": "^1.1.1", "val-loader": "^1.1.1",
"wdio-docker-service": "^1.4.2", "wdio-docker-service": "^1.4.2",
"wdio-dot-reporter": "0.0.10", "wdio-dot-reporter": "0.0.10",
@ -144,12 +143,14 @@
"express-ws": "github:dannycoates/express-ws", "express-ws": "github:dannycoates/express-ws",
"fluent": "^0.10.0", "fluent": "^0.10.0",
"fluent-langneg": "^0.1.1", "fluent-langneg": "^0.1.1",
"fxa-geodb": "^1.0.4",
"helmet": "^3.15.0", "helmet": "^3.15.0",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"mozlog": "^2.2.0", "mozlog": "^2.2.0",
"node-fetch": "^2.3.0", "node-fetch": "^2.3.0",
"raven": "^2.6.4", "raven": "^2.6.4",
"redis": "^2.8.0", "redis": "^2.8.0",
"ua-parser-js": "^0.7.19",
"websocket-stream": "^5.1.2" "websocket-stream": "^5.1.2"
}, },
"availableLanguages": [ "availableLanguages": [

161
server/amplitude.js Normal file
View File

@ -0,0 +1,161 @@
const crypto = require('crypto');
const geoip = require('fxa-geodb')();
const fetch = require('node-fetch');
const config = require('./config');
const pkg = require('../package.json');
const HOUR = 1000 * 60 * 60;
function truncateToHour(timestamp) {
return Math.floor(timestamp / HOUR) * HOUR;
}
function orderOfMagnitude(n) {
return Math.floor(Math.log10(n));
}
function userId(fileId, ownerId) {
const hash = crypto.createHash('sha256');
hash.update(fileId);
hash.update(ownerId);
return hash.digest('hex').substring(32);
}
function location(ip) {
try {
return geoip(ip);
} catch (e) {
return {};
}
}
function statUploadEvent(data) {
const loc = location(data.ip);
const event = {
session_id: -1,
country: loc.country,
region: loc.state,
user_id: userId(data.id, data.owner),
app_version: pkg.version,
time: truncateToHour(Date.now()),
event_type: 'server_upload',
user_properties: {
download_limit: data.dlimit,
time_limit: data.timeLimit,
size: orderOfMagnitude(data.size),
anonymous: data.anonymous
},
event_id: 0
};
return sendBatch([event]);
}
function statDownloadEvent(data) {
const loc = location(data.ip);
const event = {
session_id: -1,
country: loc.country,
region: loc.state,
user_id: userId(data.id, data.owner),
app_version: pkg.version,
time: truncateToHour(Date.now()),
event_type: 'server_download',
event_properties: {
download_count: data.download_count,
ttl: data.ttl
},
event_id: data.download_count
};
return sendBatch([event]);
}
function statDeleteEvent(data) {
const loc = location(data.ip);
const event = {
session_id: -1,
country: loc.country,
region: loc.state,
user_id: userId(data.id, data.owner),
app_version: pkg.version,
time: truncateToHour(Date.now()),
event_type: 'server_delete',
event_properties: {
download_count: data.download_count,
ttl: data.ttl
},
event_id: data.download_count + 1
};
return sendBatch([event]);
}
function clientEvent(event, ua, language, session_id, deltaT, platform, ip) {
const loc = location(ip);
const ep = event.event_properties || {};
const up = event.user_properties || {};
const event_properties = {
browser: ua.browser.name,
browser_version: ua.browser.version,
status: ep.status,
age: ep.age,
downloaded: ep.downloaded,
download_limit: ep.download_limit,
duration: ep.duration,
file_count: ep.file_count,
password_protected: ep.password_protected,
size: ep.size,
time_limit: ep.time_limit,
trigger: ep.trigger,
ttl: ep.ttl
};
const user_properties = {
active_count: up.active_count,
anonymous: up.anonymous,
experiments: up.experiments,
first_action: up.first_action
};
return {
app_version: pkg.version,
country: loc.country,
device_id: event.device_id,
event_properties,
event_type: event.event_type,
language,
os_name: ua.os.name,
os_version: ua.os.version,
platform,
region: loc.state,
session_id,
time: event.time + deltaT,
user_id: event.user_id,
user_properties
};
}
async function sendBatch(events, timeout = 1000) {
if (!config.amplitude_id) {
return 200;
}
try {
const result = await fetch('https://api.amplitude.com/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: config.amplitude_id,
events
}),
timeout
});
return result.status;
} catch (e) {
return 500;
}
}
module.exports = {
statUploadEvent,
statDownloadEvent,
statDeleteEvent,
clientEvent,
sendBatch
};

View File

@ -80,6 +80,11 @@ const conf = convict({
arg: 'port', arg: 'port',
env: 'PORT' env: 'PORT'
}, },
amplitude_id: {
format: String,
default: '',
env: 'AMPLITUDE_ID'
},
analytics_id: { analytics_id: {
format: String, format: String,
default: '', default: '',

View File

@ -24,11 +24,6 @@ var SENTRY_ID = '${config.sentry_id}';
`; `;
} }
let ga = '';
if (config.analytics_id) {
ga = `var GOOGLE_ANALYTICS_ID = '${config.analytics_id}';`;
}
module.exports = function(state) { module.exports = function(state) {
const authConfig = state.authConfig const authConfig = state.authConfig
? `var AUTH_CONFIG = ${JSON.stringify(state.authConfig)};` ? `var AUTH_CONFIG = ${JSON.stringify(state.authConfig)};`
@ -71,7 +66,6 @@ module.exports = function(state) {
state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}' state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}'
}; };
${authConfig}; ${authConfig};
${ga}
${sentry} ${sentry}
`; `;
return state.cspNonce return state.cspNonce

View File

@ -1,9 +1,20 @@
const storage = require('../storage'); const storage = require('../storage');
const { statDeleteEvent } = require('../amplitude');
module.exports = async function(req, res) { module.exports = async function(req, res) {
try { try {
await storage.del(req.params.id); const id = req.params.id;
const meta = req.meta;
const ttl = await storage.ttl(id);
await storage.del(id);
res.sendStatus(200); res.sendStatus(200);
statDeleteEvent({
id,
ip: req.ip,
owner: meta.owner,
download_count: meta.dl,
ttl
});
} catch (e) { } catch (e) {
res.sendStatus(404); res.sendStatus(404);
} }

View File

@ -1,6 +1,7 @@
const storage = require('../storage'); const storage = require('../storage');
const mozlog = require('../log'); const mozlog = require('../log');
const log = mozlog('send.download'); const log = mozlog('send.download');
const { statDownloadEvent } = require('../amplitude');
module.exports = async function(req, res) { module.exports = async function(req, res) {
const id = req.params.id; const id = req.params.id;
@ -21,6 +22,14 @@ module.exports = async function(req, res) {
const dl = meta.dl + 1; const dl = meta.dl + 1;
const dlimit = meta.dlimit; const dlimit = meta.dlimit;
const ttl = await storage.ttl(id);
statDownloadEvent({
id,
ip: req.ip,
owner: meta.owner,
download_count: dl,
ttl
});
try { try {
if (dl >= dlimit) { if (dl >= dlimit) {
await storage.del(id); await storage.del(id);

View File

@ -1,6 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const express = require('express'); const express = require('express');
const helmet = require('helmet'); const helmet = require('helmet');
const uaparser = require('ua-parser-js');
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');
@ -12,6 +13,7 @@ const IS_DEV = config.env === 'development';
const ID_REGEX = '([0-9a-fA-F]{10})'; const ID_REGEX = '([0-9a-fA-F]{10})';
module.exports = function(app) { module.exports = function(app) {
app.set('trust proxy', true);
app.use(helmet()); app.use(helmet());
app.use( app.use(
helmet.hsts({ helmet.hsts({
@ -19,6 +21,10 @@ module.exports = function(app) {
force: !IS_DEV force: !IS_DEV
}) })
); );
app.use(function(req, res, next) {
req.ua = uaparser(req.header('user-agent'));
next();
});
app.use(function(req, res, next) { app.use(function(req, res, next) {
req.cspNonce = crypto.randomBytes(16).toString('hex'); req.cspNonce = crypto.randomBytes(16).toString('hex');
next(); next();
@ -35,12 +41,10 @@ module.exports = function(app) {
'wss://send.firefox.com', 'wss://send.firefox.com',
'https://*.dev.lcip.org', 'https://*.dev.lcip.org',
'https://*.accounts.firefox.com', 'https://*.accounts.firefox.com',
'https://sentry.prod.mozaws.net', 'https://sentry.prod.mozaws.net'
'https://www.google-analytics.com'
], ],
imgSrc: [ imgSrc: [
"'self'", "'self'",
'https://www.google-analytics.com',
'https://*.dev.lcip.org', 'https://*.dev.lcip.org',
'https://firefoxusercontent.com' 'https://firefoxusercontent.com'
], ],
@ -92,7 +96,7 @@ module.exports = function(app) {
require('./params') require('./params')
); );
app.post(`/api/info/:id${ID_REGEX}`, auth.owner, require('./info')); app.post(`/api/info/:id${ID_REGEX}`, auth.owner, require('./info'));
app.post('/api/metrics', require('./metrics'));
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'));
}); });

23
server/routes/metrics.js Normal file
View File

@ -0,0 +1,23 @@
const { sendBatch, clientEvent } = require('../amplitude');
module.exports = async function(req, res) {
try {
const data = req.body;
const deltaT = Date.now() - data.now;
const events = data.events.map(e =>
clientEvent(
e,
req.ua,
data.lang,
data.session_id + deltaT,
deltaT,
data.platform,
req.ip
)
);
const status = await sendBatch(events);
res.sendStatus(status);
} catch (e) {
res.sendStatus(500);
}
};

View File

@ -5,6 +5,7 @@ const mozlog = require('../log');
const Limiter = require('../limiter'); const Limiter = require('../limiter');
const wsStream = require('websocket-stream/stream'); const wsStream = require('websocket-stream/stream');
const fxa = require('../fxa'); const fxa = require('../fxa');
const { statUploadEvent } = require('../amplitude');
const { Duplex } = require('stream'); const { Duplex } = require('stream');
@ -105,6 +106,15 @@ module.exports = function(ws, req) {
// in order to avoid having to check socket state and clean // in order to avoid having to check socket state and clean
// up storage, possibly with an exception that we can catch. // up storage, possibly with an exception that we can catch.
ws.send(JSON.stringify({ ok: true })); ws.send(JSON.stringify({ ok: true }));
statUploadEvent({
id: newId,
ip: req.ip,
owner,
dlimit,
timeLimit,
anonymous: !user,
size: limiter.length
});
} }
} catch (e) { } catch (e) {
log.error('upload', e); log.error('upload', e);

View File

@ -2,7 +2,8 @@ const sinon = require('sinon');
const proxyquire = require('proxyquire').noCallThru(); const proxyquire = require('proxyquire').noCallThru();
const storage = { const storage = {
del: sinon.stub() del: sinon.stub(),
ttl: sinon.stub()
}; };
function request(id) { function request(id) {

View File

@ -101,7 +101,6 @@ const web = {
path.resolve(__dirname, 'common'), path.resolve(__dirname, 'common'),
// some dependencies need to get re-babeled because we // some dependencies need to get re-babeled because we
// have different targets than their default configs // have different targets than their default configs
path.resolve(__dirname, 'node_modules/testpilot-ga/src'),
path.resolve(__dirname, 'node_modules/fluent'), path.resolve(__dirname, 'node_modules/fluent'),
path.resolve(__dirname, 'node_modules/fluent-intl-polyfill'), path.resolve(__dirname, 'node_modules/fluent-intl-polyfill'),
path.resolve(__dirname, 'node_modules/intl-pluralrules') path.resolve(__dirname, 'node_modules/intl-pluralrules')