diff --git a/app/archive.js b/app/archive.js index 20766055..572bb071 100644 --- a/app/archive.js +++ b/app/archive.js @@ -1,4 +1,4 @@ -/* global LIMITS */ +/* global LIMITS DEFAULTS */ import { blobStream, concatStream } from './streams'; function isDupe(newFile, array) { @@ -17,6 +17,9 @@ function isDupe(newFile, array) { export default class Archive { constructor(files = []) { this.files = Array.from(files); + this.timeLimit = DEFAULTS.EXPIRE_SECONDS; + this.dlimit = 1; + this.password = null; } get name() { @@ -73,5 +76,8 @@ export default class Archive { clear() { this.files = []; + this.dlimit = 1; + this.timeLimit = DEFAULTS.EXPIRE_SECONDS; + this.password = null; } } diff --git a/app/controller.js b/app/controller.js index 47675d6b..95fa9ad3 100644 --- a/app/controller.js +++ b/app/controller.js @@ -1,4 +1,4 @@ -/* global DEFAULTS LIMITS */ +/* global LIMITS */ import FileSender from './fileSender'; import FileReceiver from './fileReceiver'; import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils'; @@ -50,37 +50,27 @@ export default function(state, emitter) { emitter.on('logout', () => { state.user.logout(); - state.timeLimit = DEFAULTS.EXPIRE_SECONDS; - state.downloadCount = 1; + metrics.loggedOut({ trigger: 'button' }); 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 => { state.archive.remove(file); render(); }); - emitter.on('delete', async ({ file, location }) => { + emitter.on('delete', async ownedFile => { try { metrics.deletedUpload({ - size: file.size, - time: file.time, - speed: file.speed, - type: file.type, - ttl: file.expiresAt - Date.now(), + size: ownedFile.size, + time: ownedFile.time, + speed: ownedFile.speed, + type: ownedFile.type, + ttl: ownedFile.expiresAt - Date.now(), location }); - state.storage.remove(file.id); - await file.del(); + state.storage.remove(ownedFile.id); + await ownedFile.del(); } catch (e) { state.raven.captureException(e); } @@ -100,20 +90,35 @@ export default function(state, emitter) { state.archive.addFiles(files, maxSize); } catch (e) { if (e.message === 'fileTooBig' && maxSize < LIMITS.MAX_FILE_SIZE) { - state.modal = signupDialog(); - } else { - state.modal = okDialog( - state.translate(e.message, { - size: bytes(maxSize), - count: LIMITS.MAX_FILES_PER_ARCHIVE - }) - ); + return emitter.emit('signup-cta', 'size'); } + state.modal = okDialog( + state.translate(e.message, { + size: bytes(maxSize), + count: LIMITS.MAX_FILES_PER_ARCHIVE + }) + ); } 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) { state.modal = okDialog( state.translate('tooManyArchives', { @@ -122,8 +127,7 @@ export default function(state, emitter) { ); return render(); } - const size = state.archive.size; - if (!state.timeLimit) state.timeLimit = DEFAULTS.EXPIRE_SECONDS; + const archive = state.archive; const sender = new FileSender(); sender.on('progress', updateProgress); @@ -135,41 +139,38 @@ export default function(state, emitter) { const links = openLinksInNewTab(); await delay(200); + const start = Date.now(); try { - metrics.startedUpload({ size, type }); - - const ownedFile = await sender.upload( - state.archive, - state.timeLimit, - dlimit, - state.user.bearerToken - ); - ownedFile.type = type; + const ownedFile = await sender.upload(archive, state.user.bearerToken); state.storage.totalUploads += 1; - metrics.completedUpload(ownedFile); + const duration = Date.now() - start; + metrics.completedUpload(archive, duration); state.storage.addFile(ownedFile); // TODO integrate password into /upload request - if (password) { - emitter.emit('password', { password, file: ownedFile }); + if (archive.password) { + emitter.emit('password', { + password: archive.password, + file: ownedFile + }); } state.modal = copyDialog(ownedFile.name, ownedFile.url); } catch (err) { if (err.message === '0') { //cancelled. do nothing - metrics.cancelledUpload({ size, type }); + const duration = Date.now() - start; + metrics.cancelledUpload(archive, duration); render(); } else { // eslint-disable-next-line no-console console.error(err); state.raven.captureException(err); - metrics.stoppedUpload({ size, type, err }); + metrics.stoppedUpload(archive); emitter.emit('pushState', '/error'); } } finally { openLinksInNewTab(links, false); - state.archive.clear(); - state.password = ''; + archive.clear(); state.uploading = false; state.transfer = null; await state.user.syncFileList(); @@ -183,7 +184,6 @@ export default function(state, emitter) { render(); await file.setPassword(password); state.storage.writeFile(file); - metrics.addedPassword({ size: file.size }); await delay(1000); } catch (err) { // eslint-disable-next-line no-console @@ -220,18 +220,20 @@ export default function(state, emitter) { state.transfer.on('complete', render); const links = openLinksInNewTab(); const size = file.size; + const start = Date.now(); try { - const start = Date.now(); - metrics.startedDownload({ size: file.size, ttl: file.ttl }); const dl = state.transfer.download({ stream: state.capabilities.streamDownload }); render(); await dl; - const time = Date.now() - start; - const speed = size / (time / 1000); state.storage.totalDownloads += 1; - metrics.completedDownload({ size, time, speed }); + const duration = Date.now() - start; + metrics.completedDownload({ + size, + duration, + password_protected: file.requiresPassword + }); } catch (err) { if (err.message === '0') { // download cancelled @@ -239,12 +241,16 @@ export default function(state, emitter) { render(); } else { // eslint-disable-next-line no-console - console.error(err); state.transfer = null; const location = err.message === '404' ? '/404' : '/error'; if (location === '/error') { 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); } @@ -253,9 +259,9 @@ export default function(state, emitter) { } }); - emitter.on('copy', ({ url, location }) => { + emitter.on('copy', ({ url }) => { copyToClipboard(url); - metrics.copiedLink({ location }); + // metrics.copiedLink({ location }); }); setInterval(() => { diff --git a/app/fileSender.js b/app/fileSender.js index cc937833..47955068 100644 --- a/app/fileSender.js +++ b/app/fileSender.js @@ -1,4 +1,3 @@ -/* global DEFAULTS */ import Nanobus from 'nanobus'; import OwnedFile from './ownedFile'; import Keychain from './keychain'; @@ -42,29 +41,24 @@ export default class FileSender extends Nanobus { } } - async upload( - file, - timeLimit = DEFAULTS.EXPIRE_SECONDS, - dlimit = 1, - bearerToken - ) { + async upload(archive, bearerToken) { const start = Date.now(); if (this.cancelled) { throw new Error(0); } this.msg = 'encryptingFile'; this.emit('encrypting'); - const totalSize = encryptedSize(file.size); - const encStream = await this.keychain.encryptStream(file.stream); - const metadata = await this.keychain.encryptMetadata(file); + const totalSize = encryptedSize(archive.size); + const encStream = await this.keychain.encryptStream(archive.stream); + const metadata = await this.keychain.encryptMetadata(archive); const authKeyB64 = await this.keychain.authKeyB64(); this.uploadRequest = uploadWs( encStream, metadata, authKeyB64, - timeLimit, - dlimit, + archive.timeLimit, + archive.dlimit, bearerToken, p => { this.progress = [p, totalSize]; @@ -88,18 +82,18 @@ export default class FileSender extends Nanobus { const ownedFile = new OwnedFile({ id: result.id, url: `${result.url}#${secretKey}`, - name: file.name, - size: file.size, - manifest: file.manifest, + name: archive.name, + size: archive.size, + manifest: archive.manifest, time: time, - speed: file.size / (time / 1000), + speed: archive.size / (time / 1000), createdAt: Date.now(), - expiresAt: Date.now() + timeLimit * 1000, + expiresAt: Date.now() + archive.timeLimit * 1000, secretKey: secretKey, nonce: this.keychain.nonce, ownerToken: result.ownerToken, - dlimit, - timeLimit: timeLimit + dlimit: archive.dlimit, + timeLimit: archive.timeLimit }); return ownedFile; diff --git a/app/metrics.js b/app/metrics.js index 1f3c25db..3c02e2a7 100644 --- a/app/metrics.js +++ b/app/metrics.js @@ -1,296 +1,172 @@ -import testPilotGA from 'testpilot-ga/src/TestPilotGA'; import storage from './storage'; - -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 -}); +import { platform } from './utils'; 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) { appState = state; + if (!appState.user.firstAction) { + appState.user.firstAction = appState.route === '/' ? 'upload' : 'download'; + } emitter.on('DOMContentLoaded', () => { - addExitHandlers(); - experiment = storage.enrolled[0]; - sendEvent(category(), 'visit', { - cm5: storage.totalUploads, - cm6: storage.files.length, - cm7: storage.totalDownloads + // experiment = storage.enrolled[0]; + addEvent('client_visit', { + entrypoint: appState.route === '/' ? 'upload' : 'download' }); }); - emitter.on('exit', exitEvent); emitter.on('experiment', experimentEvent); + window.addEventListener('unload', submitEvents); } -function category() { - switch (appState.route) { - case '/': - case '/share/:id': - return 'sender'; - case '/download/:id/:key': - case '/download/:id': - case '/completed': - return 'recipient'; - default: - return 'other'; - } +function sizeOrder(n) { + return Math.floor(Math.log10(n)); } -function sendEvent() { - const args = Array.from(arguments); - if (experiment && args[2]) { - args[2].xid = experiment[0]; - args[2].xvar = experiment[1]; +function submitEvents() { + if (navigator.doNotTrack === '1') { + return; } - return ( - hasLocalStorage && analytics.sendEvent.apply(analytics, args).catch(() => 0) + const data = new Blob( + [ + JSON.stringify({ + now: Date.now(), + session_id, + lang, + platform: platform(), + events + }) + ], + { type: 'application/json' } ); -} - -function urlToMetric(url) { - 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'; + events.splice(0); + if (!navigator.sendBeacon) { + return; } + navigator.sendBeacon('/api/metrics', data); } -function setReferrer(state) { - if (category() === 'sender') { - if (state) { - storage.referrer = `${state}-upload`; - } - } else if (category() === 'recipient') { - if (state) { - storage.referrer = `${state}-download`; +async function addEvent(event_type, event_properties) { + const user_id = await appState.user.metricId(); + const device_id = await appState.user.deviceId(); + events.push({ + device_id, + event_properties, + event_type, + 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() { - if (/^https:\/\/testpilot\.firefox\.com/.test(document.referrer)) { - return 'testpilot'; - } - return 'external'; -} - -function takeReferrer() { - const referrer = storage.referrer || externalReferrer(); - 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(archive, duration) { + return addEvent('client_upload', { + download_limit: archive.dlimit, + duration: sizeOrder(duration), + file_count: archive.numFiles, + password_protected: !!archive.password, + size: sizeOrder(archive.size), + status: 'cancel', + time_limit: archive.timeLimit }); } -function cancelledUpload(params) { - setReferrer('cancelled'); - return sendEvent('sender', 'upload-stopped', { - cm1: params.size, - cm5: storage.totalUploads, - cm6: storage.files.length, - cm7: storage.totalDownloads, - cd1: params.type, - cd2: 'cancelled' +function completedUpload(archive, duration) { + return addEvent('client_upload', { + download_limit: archive.dlimit, + duration: sizeOrder(duration), + file_count: archive.numFiles, + password_protected: !!archive.password, + size: sizeOrder(archive.size), + status: 'ok', + time_limit: archive.timeLimit }); } -function completedUpload(params) { - return sendEvent('sender', 'upload-stopped', { - cm1: params.size, - cm2: params.time, - cm3: params.speed, - cm5: storage.totalUploads, - cm6: storage.files.length, - cm7: storage.totalDownloads, - 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 stoppedUpload(archive) { + return addEvent('client_upload', { + download_limit: archive.dlimit, + file_count: archive.numFiles, + password_protected: !!archive.password, + size: sizeOrder(archive.size), + status: 'error', + time_limit: archive.timeLimit }); } function stoppedDownload(params) { - return sendEvent('recipient', 'download-stopped', { - cm1: params.size, - cm5: storage.totalUploads, - cm6: storage.files.length, - cm7: storage.totalDownloads, - 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 + return addEvent('client_download', { + duration: sizeOrder(params.duration), + password_protected: params.password_protected, + size: sizeOrder(params.size), + status: 'error' }); } function completedDownload(params) { - return sendEvent('recipient', 'download-stopped', { - cm1: params.size, - cm2: params.time, - cm3: params.speed, - cm5: storage.totalUploads, - cm6: storage.files.length, - cm7: storage.totalDownloads, - cd2: 'completed' + return addEvent('client_download', { + duration: sizeOrder(params.duration), + password_protected: params.password_protected, + size: sizeOrder(params.size), + status: 'ok' }); } -function deletedUpload(params) { - return sendEvent(category(), 'upload-deleted', { - cm1: params.size, - cm2: params.time, - cm3: params.speed, - 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 deletedUpload(ownedFile) { + return addEvent('client_delete', { + age: Math.floor((Date.now() - ownedFile.createdAt) / HOUR), + downloaded: ownedFile.dtotal > 0, + status: 'ok' }); } function experimentEvent(params) { - return sendEvent(category(), 'experiment', params); + return addEvent('client_experiment', params); } -// eslint-disable-next-line no-unused-vars -function addExitHandlers() { - const links = Array.from(document.querySelectorAll('a')); - links.forEach(l => { - if (/^http/.test(l.getAttribute('href'))) { - l.addEventListener('click', exitEvent); - } +function submittedSignup(params) { + return addEvent('client_login', { + status: 'ok', + trigger: params.trigger }); } -function restart(state) { - setReferrer(state); - return sendEvent(category(), 'restarted', { - cd2: state +function canceledSignup(params) { + return addEvent('client_login', { + status: 'cancel', + 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 { - copiedLink, - startedUpload, cancelledUpload, stoppedUpload, completedUpload, - changedDownloadLimit, deletedUpload, - startedDownload, - cancelledDownload, stoppedDownload, completedDownload, - addedPassword, - restart, - unsupported + submittedSignup, + canceledSignup, + loggedOut }; diff --git a/app/ownedFile.js b/app/ownedFile.js index 3fef67cb..d6d91a53 100644 --- a/app/ownedFile.js +++ b/app/ownedFile.js @@ -8,7 +8,6 @@ export default class OwnedFile { this.url = obj.url; this.name = obj.name; this.size = obj.size; - this.type = obj.type; this.manifest = obj.manifest; this.time = obj.time; this.speed = obj.speed; @@ -78,7 +77,6 @@ export default class OwnedFile { url: this.url, name: this.name, size: this.size, - type: this.type, manifest: this.manifest, time: this.time, speed: this.speed, diff --git a/app/routes.js b/app/routes.js index 6abd558e..8405db9b 100644 --- a/app/routes.js +++ b/app/routes.js @@ -11,14 +11,7 @@ module.exports = function(app = choo()) { app.route('/error', body(require('./ui/error'))); app.route('/blank', body(require('./ui/blank'))); app.route('/oauth', async function(state, emit) { - try { - await state.user.finishLogin(state.query.code, state.query.state); - await state.user.syncFileList(); - emit('replaceState', '/'); - } catch (e) { - emit('replaceState', '/error'); - setTimeout(() => emit('render')); - } + emit('authenticate', state.query.code, state.query.state); }); app.route('*', body(require('./ui/notFound'))); return app; diff --git a/app/storage.js b/app/storage.js index 34837318..d66391e0 100644 --- a/app/storage.js +++ b/app/storage.js @@ -1,4 +1,4 @@ -import { isFile } from './utils'; +import { arrayToB64, isFile } from './utils'; import OwnedFile from './ownedFile'; class Mem { @@ -58,6 +58,15 @@ class Storage { 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() { return Number(this.engine.getItem('totalDownloads')); } diff --git a/app/ui/account.js b/app/ui/account.js index 3ddbd83a..cb67b540 100644 --- a/app/ui/account.js +++ b/app/ui/account.js @@ -1,6 +1,5 @@ const html = require('choo/html'); const Component = require('choo/component'); -const signupDialog = require('./signupDialog'); class Account extends Component { constructor(name, state, emit) { @@ -27,8 +26,7 @@ class Account extends Component { login(event) { event.preventDefault(); - this.state.modal = signupDialog(); - this.emit('render'); + this.emit('signup-cta', 'button'); } logout(event) { diff --git a/app/ui/archiveTile.js b/app/ui/archiveTile.js index 7a70e6f2..e6f65f33 100644 --- a/app/ui/archiveTile.js +++ b/app/ui/archiveTile.js @@ -34,7 +34,7 @@ function password(state) { @@ -44,7 +44,7 @@ function password(state) {