24
1
Fork 0

a few changes to make A/B testing easier

This commit is contained in:
Danny Coates 2017-08-24 14:54:02 -07:00
parent b2f76d2df9
commit 53e822964e
No known key found for this signature in database
GPG Key ID: 4C442633C62E00CB
94 changed files with 4566 additions and 3958 deletions

View File

@ -1,12 +1,8 @@
node_modules
.git
.DS_Store
static
test
scripts
docs
firefox
assets
docs
public
views
webpack
frontend
test

View File

@ -1,3 +1,3 @@
public
test/frontend/bundle.js
dist
assets
firefox

6
.gitignore vendored
View File

@ -1,6 +1,2 @@
.DS_Store
dist
node_modules
static/*
!static/info.txt
test/frontend/bundle.js
dist

View File

@ -12,4 +12,4 @@ RUN npm install --production && npm cache clean --force
ENV PORT=1443
EXPOSE $PORT
CMD ["npm", "start"]
CMD ["npm", "run", "prod"]

View File

@ -30,11 +30,12 @@ $ redis-server /usr/local/etc/redis.conf
| Command | Description |
|------------------|-------------|
| `npm run dev` | Builds and starts the web server locally for development.
| `npm run format` | Formats the frontend and server code using **prettier**.
| `npm run lint` | Lints the CSS and JavaScript code.
| `npm start` | Starts the Express web server.
| `npm test` | Runs the suite of mocha tests.
| `npm start` | Runs the server in development configuration.
| `npm run build` | Builds the production assets.
| `npm run prod` | Runs the server in production configuration.
---

View File

@ -1,6 +1,6 @@
env:
browser: true
node: false
node: true
parserOptions:
sourceType: module

24
app/dragManager.js Normal file
View File

@ -0,0 +1,24 @@
export default function(state, emitter) {
emitter.on('DOMContentLoaded', () => {
document.body.addEventListener('dragover', event => {
if (state.route === '/') {
event.preventDefault();
}
});
document.body.addEventListener('drop', event => {
if (state.route === '/' && !state.transfer) {
event.preventDefault();
document.querySelector('.upload-window').classList.remove('ondrag');
const target = event.dataTransfer;
if (target.files.length === 0) {
return;
}
if (target.files.length > 1 || target.files[0].size === 0) {
return alert(state.translate('uploadPageMultipleFilesAlert'));
}
const file = target.files[0];
emitter.emit('upload', { file, type: 'drop' });
}
});
});
}

202
app/fileManager.js Normal file
View File

@ -0,0 +1,202 @@
/* global EXPIRE_SECONDS */
import FileSender from './fileSender';
import FileReceiver from './fileReceiver';
import { copyToClipboard, delay, fadeOut } from './utils';
import * as metrics from './metrics';
function saveFile(file) {
const dataView = new DataView(file.plaintext);
const blob = new Blob([dataView], { type: file.type });
const downloadUrl = URL.createObjectURL(blob);
if (window.navigator.msSaveBlob) {
return window.navigator.msSaveBlob(blob, file.name);
}
const a = document.createElement('a');
a.href = downloadUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(downloadUrl);
}
function openLinksInNewTab(links, should = true) {
links = links || Array.from(document.querySelectorAll('a:not([target])'));
if (should) {
links.forEach(l => {
l.setAttribute('target', '_blank');
l.setAttribute('rel', 'noopener noreferrer');
});
} else {
links.forEach(l => {
l.removeAttribute('target');
l.removeAttribute('rel');
});
}
return links;
}
function exists(id) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
resolve(xhr.status === 200);
}
};
xhr.onerror = () => resolve(false);
xhr.ontimeout = () => resolve(false);
xhr.open('get', '/api/exists/' + id);
xhr.timeout = 2000;
xhr.send();
});
}
export default function(state, emitter) {
let lastRender = 0;
function render() {
emitter.emit('render');
}
async function checkFiles() {
const files = state.storage.files;
let rerender = false;
for (const file of files) {
const ok = await exists(file.id);
if (!ok) {
state.storage.remove(file.id);
rerender = true;
}
}
if (rerender) {
render();
}
}
emitter.on('DOMContentLoaded', checkFiles);
emitter.on('navigate', checkFiles);
emitter.on('render', () => {
lastRender = Date.now();
});
emitter.on('delete', async ({ file, location }) => {
try {
metrics.deletedUpload({
size: file.size,
time: file.time,
speed: file.speed,
type: file.type,
ttl: file.expiresAt - Date.now(),
location
});
state.storage.remove(file.id);
await FileSender.delete(file.id, file.deleteToken);
} catch (e) {
state.raven.captureException(e);
}
state.fileInfo = null;
});
emitter.on('cancel', () => {
state.transfer.cancel();
});
emitter.on('upload', async ({ file, type }) => {
const size = file.size;
const sender = new FileSender(file);
sender.on('progress', render);
sender.on('encrypting', render);
state.transfer = sender;
render();
const links = openLinksInNewTab();
await delay(200);
try {
const start = Date.now();
metrics.startedUpload({ size, type });
const info = await sender.upload();
const time = Date.now() - start;
const speed = size / (time / 1000);
metrics.completedUpload({ size, time, speed, type });
await delay(1000);
await fadeOut('upload-progress');
info.name = file.name;
info.size = size;
info.type = type;
info.time = time;
info.speed = speed;
info.createdAt = Date.now();
info.url = `${info.url}#${info.secretKey}`;
info.expiresAt = Date.now() + EXPIRE_SECONDS * 1000;
state.fileInfo = info;
state.storage.addFile(state.fileInfo);
openLinksInNewTab(links, false);
state.transfer = null;
state.storage.totalUploads += 1;
emitter.emit('pushState', `/share/${info.id}`);
} catch (err) {
state.transfer = null;
if (err.message === '0') {
//cancelled. do nothing
metrics.cancelledUpload({ size, type });
return render();
}
state.raven.captureException(err);
metrics.stoppedUpload({ size, type, err });
emitter.emit('replaceState', '/error');
}
});
emitter.on('download', async file => {
const size = file.size;
const url = `/api/download/${file.id}`;
const receiver = new FileReceiver(url, file.key);
receiver.on('progress', render);
receiver.on('decrypting', render);
state.transfer = receiver;
const links = openLinksInNewTab();
render();
try {
const start = Date.now();
metrics.startedDownload({ size: file.size, ttl: file.ttl });
const f = await receiver.download();
const time = Date.now() - start;
const speed = size / (time / 1000);
await delay(1000);
await fadeOut('download-progress');
saveFile(f);
state.storage.totalDownloads += 1;
metrics.completedDownload({ size, time, speed });
emitter.emit('pushState', '/completed');
} catch (err) {
// TODO cancelled download
const location = err.message === 'notfound' ? '/404' : '/error';
if (location === '/error') {
state.raven.captureException(err);
metrics.stoppedDownload({ size, err });
}
emitter.emit('replaceState', location);
} finally {
state.transfer = null;
openLinksInNewTab(links, false);
}
});
emitter.on('copy', ({ url, location }) => {
copyToClipboard(url);
metrics.copiedLink({ location });
});
setInterval(() => {
// poll for rerendering the file list countdown timers
if (
state.route === '/' &&
state.storage.files.length > 0 &&
Date.now() - lastRender > 30000
) {
render();
}
}, 60000);
}

View File

@ -1,9 +1,9 @@
import EventEmitter from 'events';
import { hexToArray } from './utils';
import Nanobus from 'nanobus';
import { hexToArray, bytes } from './utils';
export default class FileReceiver extends EventEmitter {
export default class FileReceiver extends Nanobus {
constructor(url, k) {
super();
super('FileReceiver');
this.key = window.crypto.subtle.importKey(
'jwk',
{
@ -19,6 +19,23 @@ export default class FileReceiver extends EventEmitter {
['decrypt']
);
this.url = url;
this.msg = 'fileSizeProgress';
this.progress = [0, 1];
}
get progressRatio() {
return this.progress[0] / this.progress[1];
}
get sizes() {
return {
partialSize: bytes(this.progress[0]),
totalSize: bytes(this.progress[1])
};
}
cancel() {
// TODO
}
downloadFile() {
@ -27,7 +44,8 @@ export default class FileReceiver extends EventEmitter {
xhr.onprogress = event => {
if (event.lengthComputable && event.target.status !== 404) {
this.emit('progress', [event.loaded, event.total]);
this.progress = [event.loaded, event.total];
this.emit('progress', this.progress);
}
};
@ -61,6 +79,7 @@ export default class FileReceiver extends EventEmitter {
async download() {
const key = await this.key;
const file = await this.downloadFile();
this.msg = 'decryptingFile';
this.emit('decrypting');
const plaintext = await window.crypto.subtle.decrypt(
{
@ -71,6 +90,7 @@ export default class FileReceiver extends EventEmitter {
key,
file.data
);
this.msg = 'downloadFinish';
return {
plaintext,
name: decodeURIComponent(file.name),

View File

@ -1,10 +1,13 @@
import EventEmitter from 'events';
import { arrayToHex } from './utils';
import Nanobus from 'nanobus';
import { arrayToHex, bytes } from './utils';
export default class FileSender extends EventEmitter {
export default class FileSender extends Nanobus {
constructor(file) {
super();
super('FileSender');
this.file = file;
this.msg = 'importingFile';
this.progress = [0, 1];
this.cancelled = false;
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
this.uploadXHR = new XMLHttpRequest();
this.key = window.crypto.subtle.generateKey(
@ -17,13 +20,13 @@ export default class FileSender extends EventEmitter {
);
}
static delete(fileId, token) {
static delete(id, token) {
return new Promise((resolve, reject) => {
if (!fileId || !token) {
if (!id || !token) {
return reject();
}
const xhr = new XMLHttpRequest();
xhr.open('post', '/delete/' + fileId, true);
xhr.open('POST', `/api/delete/${id}`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = () => {
@ -36,8 +39,22 @@ export default class FileSender extends EventEmitter {
});
}
get progressRatio() {
return this.progress[0] / this.progress[1];
}
get sizes() {
return {
partialSize: bytes(this.progress[0]),
totalSize: bytes(this.progress[1])
};
}
cancel() {
this.uploadXHR.abort();
this.cancelled = true;
if (this.msg === 'fileSizeProgress') {
this.uploadXHR.abort();
}
}
readFile() {
@ -57,7 +74,7 @@ export default class FileSender extends EventEmitter {
uploadFile(encrypted, keydata) {
return new Promise((resolve, reject) => {
const file = this.file;
const fileId = arrayToHex(this.iv);
const id = arrayToHex(this.iv);
const dataView = new DataView(encrypted);
const blob = new Blob([dataView], { type: file.type });
const fd = new FormData();
@ -67,41 +84,49 @@ export default class FileSender extends EventEmitter {
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
this.emit('progress', [e.loaded, e.total]);
this.progress = [e.loaded, e.total];
this.emit('progress', this.progress);
}
});
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
this.progress = [1, 1];
this.msg = 'notifyUploadDone';
const responseObj = JSON.parse(xhr.responseText);
return resolve({
url: responseObj.url,
fileId: responseObj.id,
id: responseObj.id,
secretKey: keydata.k,
deleteToken: responseObj.delete
});
}
reject(xhr.status);
this.msg = 'errorPageHeader';
reject(new Error(xhr.status));
}
};
xhr.open('post', '/upload', true);
xhr.open('post', '/api/upload', true);
xhr.setRequestHeader(
'X-File-Metadata',
JSON.stringify({
id: fileId,
id: id,
filename: encodeURIComponent(file.name)
})
);
xhr.send(fd);
this.msg = 'fileSizeProgress';
});
}
async upload() {
this.emit('loading');
const key = await this.key;
const plaintext = await this.readFile();
if (this.cancelled) {
throw new Error(0);
}
this.msg = 'encryptingFile';
this.emit('encrypting');
const encrypted = await window.crypto.subtle.encrypt(
{
@ -112,6 +137,9 @@ export default class FileSender extends EventEmitter {
key,
plaintext
);
if (this.cancelled) {
throw new Error(0);
}
const keydata = await window.crypto.subtle.exportKey('jwk', key);
return this.uploadFile(encrypted, keydata);
}

38
app/main.js Normal file
View File

@ -0,0 +1,38 @@
import app from './routes';
import log from 'choo-log';
import locale from '../common/locales';
import fileManager from './fileManager';
import dragManager from './dragManager';
import { canHasSend } from './utils';
import assets from '../common/assets';
import storage from './storage';
import metrics from './metrics';
import Raven from 'raven-js';
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install();
}
app.use(log());
app.use((state, emitter) => {
// init state
state.transfer = null;
state.fileInfo = null;
state.translate = locale.getTranslator();
state.storage = storage;
state.raven = Raven;
emitter.on('DOMContentLoaded', async () => {
const ok = await canHasSend(assets.get('cryptofill.js'));
if (!ok) {
const reason = /firefox/i.test(navigator.userAgent) ? 'outdated' : 'gcm';
emitter.emit('replaceState', `/unsupported/${reason}`);
}
});
});
app.use(metrics);
app.use(fileManager);
app.use(dragManager);
app.mount('#page-one');

View File

@ -1,12 +1,11 @@
import testPilotGA from 'testpilot-ga/src/TestPilotGA';
import Storage from './storage';
const storage = new Storage();
import storage from './storage';
let hasLocalStorage = false;
try {
hasLocalStorage = !!localStorage;
hasLocalStorage = typeof localStorage !== 'undefined';
} catch (e) {
// don't care
// when disabled, any mention of localStorage throws an error
}
const analytics = new testPilotGA({
@ -15,14 +14,19 @@ const analytics = new testPilotGA({
tid: window.GOOGLE_ANALYTICS_ID
});
const category = location.pathname.includes('/download')
? 'recipient'
: 'sender';
let appState = null;
document.addEventListener('DOMContentLoaded', function() {
addExitHandlers();
addRestartHandlers();
});
export default function initialize(state, emitter) {
appState = state;
emitter.on('DOMContentLoaded', () => {
addExitHandlers();
//TODO restart handlers... somewhere
});
}
function category() {
return appState.route === '/' ? 'sender' : 'recipient';
}
function sendEvent() {
return (
@ -62,11 +66,11 @@ function urlToMetric(url) {
}
function setReferrer(state) {
if (category === 'sender') {
if (category() === 'sender') {
if (state) {
storage.referrer = `${state}-upload`;
}
} else if (category === 'recipient') {
} else if (category() === 'recipient') {
if (state) {
storage.referrer = `${state}-download`;
}
@ -87,10 +91,10 @@ function takeReferrer() {
}
function startedUpload(params) {
return sendEvent(category, 'upload-started', {
return sendEvent('sender', 'upload-started', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.numFiles + 1,
cm6: storage.files.length + 1,
cm7: storage.totalDownloads,
cd1: params.type,
cd5: takeReferrer()
@ -99,10 +103,10 @@ function startedUpload(params) {
function cancelledUpload(params) {
setReferrer('cancelled');
return sendEvent(category, 'upload-stopped', {
return sendEvent('sender', 'upload-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd2: 'cancelled'
@ -110,12 +114,12 @@ function cancelledUpload(params) {
}
function completedUpload(params) {
return sendEvent(category, 'upload-stopped', {
return sendEvent('sender', 'upload-stopped', {
cm1: params.size,
cm2: params.time,
cm3: params.speed,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd2: 'completed'
@ -123,20 +127,20 @@ function completedUpload(params) {
}
function startedDownload(params) {
return sendEvent(category, 'download-started', {
return sendEvent('recipient', 'download-started', {
cm1: params.size,
cm4: params.ttl,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads
});
}
function stoppedDownload(params) {
return sendEvent(category, 'download-stopped', {
return sendEvent('recipient', 'download-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'errored',
cd6: params.err
@ -145,20 +149,20 @@ function stoppedDownload(params) {
function cancelledDownload(params) {
setReferrer('cancelled');
return sendEvent(category, 'download-stopped', {
return sendEvent('recipient', 'download-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'cancelled'
});
}
function stoppedUpload(params) {
return sendEvent(category, 'upload-stopped', {
return sendEvent('sender', 'upload-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd2: 'errored',
@ -167,25 +171,25 @@ function stoppedUpload(params) {
}
function completedDownload(params) {
return sendEvent(category, 'download-stopped', {
return sendEvent('recipient', 'download-stopped', {
cm1: params.size,
cm2: params.time,
cm3: params.speed,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'completed'
});
}
function deletedUpload(params) {
return sendEvent(category, 'upload-deleted', {
return sendEvent(category(), 'upload-deleted', {
cm1: params.size,
cm2: params.time,
cm3: params.speed,
cm4: params.ttl,
cm5: storage.totalUploads,
cm6: storage.numFiles,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd4: params.location
@ -193,19 +197,19 @@ function deletedUpload(params) {
}
function unsupported(params) {
return sendEvent(category, 'unsupported', {
return sendEvent(category(), 'unsupported', {
cd6: params.err
});
}
function copiedLink(params) {
return sendEvent(category, 'copied', {
return sendEvent('sender', 'copied', {
cd4: params.location
});
}
function exitEvent(target) {
return sendEvent(category, 'exited', {
return sendEvent(category(), 'exited', {
cd3: urlToMetric(target.currentTarget.href)
});
}
@ -219,21 +223,13 @@ function addExitHandlers() {
});
}
function restartEvent(state) {
function restart(state) {
setReferrer(state);
return sendEvent(category, 'restarted', {
return sendEvent(category(), 'restarted', {
cd2: state
});
}
function addRestartHandlers() {
const elements = Array.from(document.querySelectorAll('.send-new'));
elements.forEach(el => {
const state = el.getAttribute('data-state');
el.addEventListener('click', restartEvent.bind(null, state));
});
}
export {
copiedLink,
startedUpload,
@ -245,5 +241,6 @@ export {
cancelledDownload,
stoppedDownload,
completedDownload,
restart,
unsupported
};

9
app/routes/download.js Normal file
View File

@ -0,0 +1,9 @@
const preview = require('../templates/preview');
const download = require('../templates/download');
module.exports = function(state, emit) {
if (state.transfer) {
return download(state, emit);
}
return preview(state, emit);
};

9
app/routes/home.js Normal file
View File

@ -0,0 +1,9 @@
const welcome = require('../templates/welcome');
const upload = require('../templates/upload');
module.exports = function(state, emit) {
if (state.transfer) {
return upload(state, emit);
}
return welcome(state, emit);
};

17
app/routes/index.js Normal file
View File

@ -0,0 +1,17 @@
const choo = require('choo');
const download = require('./download');
const app = choo();
app.route('/', require('./home'));
app.route('/share/:id', require('../templates/share'));
app.route('/download/:id', download);
app.route('/download/:id/:key', download);
app.route('/completed', require('../templates/completed'));
app.route('/unsupported/:reason', require('../templates/unsupported'));
app.route('/legal', require('../templates/legal'));
app.route('/error', require('../templates/error'));
app.route('/blank', require('../templates/blank'));
app.route('*', require('../templates/notFound'));
module.exports = app;

View File

@ -26,13 +26,30 @@ class Mem {
}
}
export default class Storage {
class Storage {
constructor() {
try {
this.engine = localStorage || new Mem();
} catch (e) {
this.engine = new Mem();
}
this._files = this.loadFiles();
}
loadFiles() {
const fs = [];
for (let i = 0; i < this.engine.length; i++) {
const k = this.engine.key(i);
if (isFile(k)) {
try {
fs.push(JSON.parse(this.engine.getItem(k)));
} catch (err) {
// obviously you're not a golfer
this.engine.removeItem(k);
}
}
}
return fs.sort((a, b) => a.createdAt - b.createdAt);
}
get totalDownloads() {
@ -55,34 +72,7 @@ export default class Storage {
}
get files() {
const fs = [];
for (let i = 0; i < this.engine.length; i++) {
const k = this.engine.key(i);
if (isFile(k)) {
try {
fs.push(JSON.parse(this.engine.getItem(k)));
} catch (err) {
// obviously you're not a golfer
this.engine.removeItem(k);
}
}
}
return fs.sort((file1, file2) => {
const creationDate1 = new Date(file1.creationDate);
const creationDate2 = new Date(file2.creationDate);
return creationDate1 - creationDate2;
});
}
get numFiles() {
let length = 0;
for (let i = 0; i < this.engine.length; i++) {
const k = this.engine.key(i);
if (isFile(k)) {
length += 1;
}
}
return length;
return this._files;
}
getFileById(id) {
@ -94,10 +84,16 @@ export default class Storage {
}
remove(property) {
if (isFile(property)) {
this._files.splice(this._files.findIndex(f => f.id === property), 1);
}
this.engine.removeItem(property);
}
addFile(id, file) {
this.engine.setItem(id, JSON.stringify(file));
addFile(file) {
this._files.push(file);
this.engine.setItem(file.id, JSON.stringify(file));
}
}
export default new Storage();

9
app/templates/blank.js Normal file
View File

@ -0,0 +1,9 @@
const html = require('choo/html');
module.exports = function(state) {
const div = html`<div id="page-one"></div>`;
if (state.layout) {
return state.layout(state, div);
}
return div;
};

View File

@ -0,0 +1,31 @@
const html = require('choo/html');
const progress = require('./progress');
const { fadeOut } = require('../utils');
module.exports = function(state, emit) {
const div = html`
<div id="download" class="fadeIn">
<div id="download-progress">
<div id="dl-title" class="title">${state.translate(
'downloadFinish'
)}</div>
<div class="description"></div>
${progress(1)}
<div class="upload">
<div class="progress-text"></div>
</div>
</div>
<a class="send-new" data-state="completed" href="/" onclick=${sendNew}>${state.translate(
'sendYourFilesLink'
)}</a>
</div>
`;
async function sendNew(e) {
e.preventDefault();
await fadeOut('download');
emit('pushState', '/');
}
return div;
};

28
app/templates/download.js Normal file
View File

@ -0,0 +1,28 @@
const html = require('choo/html');
const progress = require('./progress');
const { bytes } = require('../utils');
module.exports = function(state) {
const transfer = state.transfer;
const div = html`
<div id="download-progress" class="fadeIn">
<div id="dl-title" class="title">${state.translate(
'downloadingPageProgress',
{
filename: state.fileInfo.name,
size: bytes(state.fileInfo.size)
}
)}</div>
<div class="description">${state.translate('downloadingPageMessage')}</div>
${progress(transfer.progressRatio)}
<div class="upload">
<div class="progress-text">${state.translate(
transfer.msg,
transfer.sizes
)}</div>
</div>
</div>
`;
return div;
};

12
app/templates/error.js Normal file
View File

@ -0,0 +1,12 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function(state) {
return html`
<div id="upload-error">
<div class="title">${state.translate('errorPageHeader')}</div>
<img id="upload-error-img" data-l10n-id="errorAltText" src="${assets.get(
'illustration_error.svg'
)}"/>
</div>`;
};

84
app/templates/file.js Normal file
View File

@ -0,0 +1,84 @@
const html = require('choo/html');
const assets = require('../../common/assets');
function timeLeft(milliseconds) {
const minutes = Math.floor(milliseconds / 1000 / 60);
const hours = Math.floor(minutes / 60);
const seconds = Math.floor(milliseconds / 1000 % 60);
if (hours >= 1) {
return `${hours}h ${minutes % 60}m`;
} else if (hours === 0) {
return `${minutes}m ${seconds}s`;
}
return null;
}
module.exports = function(file, state, emit) {
const ttl = file.expiresAt - Date.now();
const remaining = timeLeft(ttl) || state.translate('linkExpiredAlt');
const row = html`
<tr id="${file.id}">
<td>${file.name}</td>
<td>
<img onclick=${copyClick} src="${assets.get(
'copy-16.svg'
)}" class="icon-copy" title="${state.translate('copyUrlHover')}">
<span class="text-copied" hidden="true">${state.translate(
'copiedUrl'
)}</span>
</td>
<td>${remaining}</td>
<td>
<img onclick=${showPopup} src="${assets.get(
'close-16.svg'
)}" class="icon-delete" title="${state.translate('deleteButtonHover')}">
<div class="popup">
<div class="popuptext" onblur=${cancel} tabindex="-1">
<div class="popup-message">${state.translate('deletePopupText')}</div>
<div class="popup-action">
<span class="popup-no" onclick=${cancel}>${state.translate(
'deletePopupCancel'
)}</span>
<span class="popup-yes" onclick=${deleteFile}>${state.translate(
'deletePopupYes'
)}</span>
</div>
</div>
</div>
</td>
</tr>
`;
function copyClick(e) {
emit('copy', { url: file.url, location: 'upload-list' });
const icon = e.target;
const text = e.target.nextSibling;
icon.hidden = true;
text.hidden = false;
setTimeout(() => {
icon.hidden = false;
text.hidden = true;
}, 500);
}
function showPopup() {
const tr = document.getElementById(file.id);
const popup = tr.querySelector('.popuptext');
popup.classList.add('show');
popup.focus();
}
function cancel(e) {
e.stopPropagation();
const tr = document.getElementById(file.id);
const popup = tr.querySelector('.popuptext');
popup.classList.remove('show');
}
function deleteFile() {
emit('delete', { file, location: 'upload-list' });
emit('render');
}
return row;
};

28
app/templates/fileList.js Normal file
View File

@ -0,0 +1,28 @@
const html = require('choo/html');
const file = require('./file');
module.exports = function(state, emit) {
let table = '';
if (state.storage.files.length) {
table = html`
<table id="uploaded-files">
<thead>
<tr>
<th id="uploaded-file">${state.translate('uploadedFile')}</th>
<th id="copy-file-list">${state.translate('copyFileList')}</th>
<th id="expiry-file-list">${state.translate('expiryFileList')}</th>
<th id="delete-file-list">${state.translate('deleteFileList')}</th>
</tr>
</thead>
<tbody>
${state.storage.files.map(f => file(f, state, emit))}
</tbody>
</table>
`;
}
return html`
<div id="file-list">
${table}
</div>
`;
};

38
app/templates/legal.js Normal file
View File

@ -0,0 +1,38 @@
const html = require('choo/html');
function replaceLinks(str, urls) {
let i = -1;
const s = str.replace(/<a>([^<]+)<\/a>/g, (m, v) => {
i++;
return `<a href="${urls[i]}">${v}</a>`;
});
return [`<div class="description">${s}</div>`];
}
module.exports = function(state) {
const div = html`
<div id="page-one">
<div id="legal">
<div class="title">${state.translate('legalHeader')}</div>
${html(
replaceLinks(state.translate('legalNoticeTestPilot'), [
'https://testpilot.firefox.com/terms',
'https://testpilot.firefox.com/privacy',
'https://testpilot.firefox.com/experiments/send'
])
)}
${html(
replaceLinks(state.translate('legalNoticeMozilla'), [
'https://www.mozilla.org/privacy/websites/',
'https://www.mozilla.org/about/legal/terms/mozilla/'
])
)}
</div>
</div>
`;
if (state.layout) {
return state.layout(state, div);
}
return div;
};

27
app/templates/notFound.js Normal file
View File

@ -0,0 +1,27 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function(state) {
const div = html`
<div id="page-one">
<div id="download">
<div class="title">${state.translate('expiredPageHeader')}</div>
<div class="share-window">
<img src="${assets.get(
'illustration_expired.svg'
)}" id="expired-img" data-l10n-id="linkExpiredAlt"/>
</div>
<div class="expired-description">${state.translate(
'uploadPageExplainer'
)}</div>
<a class="send-new" href="/" data-state="notfound">${state.translate(
'sendYourFilesLink'
)}</a>
</div>
</div>`;
if (state.layout) {
return state.layout(state, div);
}
return div;
};

65
app/templates/preview.js Normal file
View File

@ -0,0 +1,65 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const notFound = require('./notFound');
const { bytes } = require('../utils');
function getFileFromDOM() {
const el = document.getElementById('dl-file');
if (!el) {
return null;
}
const data = el.dataset;
return {
name: data.name,
size: parseInt(data.size, 10),
ttl: parseInt(data.ttl, 10)
};
}
module.exports = function(state, emit) {
state.fileInfo = state.fileInfo || getFileFromDOM();
if (!state.fileInfo) {
return notFound(state, emit);
}
state.fileInfo.id = state.params.id;
state.fileInfo.key = state.params.key;
const fileInfo = state.fileInfo;
const size = bytes(fileInfo.size);
const div = html`
<div id="page-one">
<div id="download">
<div id="download-page-one">
<div class="title">
<span id="dl-file"
data-name="${fileInfo.name}"
data-size="${fileInfo.size}"
data-ttl="${fileInfo.ttl}">${state.translate('downloadFileName', {
filename: fileInfo.name
})}</span>
<span id="dl-filesize">${' ' +
state.translate('downloadFileSize', { size })}</span>
</div>
<div class="description">${state.translate('downloadMessage')}</div>
<img src="${assets.get(
'illustration_download.svg'
)}" id="download-img" alt="${state.translate('downloadAltText')}"/>
<div>
<button id="download-btn" class="btn" onclick=${download}>${state.translate(
'downloadButtonLabel'
)}</button>
</div>
</div>
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
</div>
</div>
`;
function download(event) {
event.preventDefault();
emit('download', fileInfo);
}
if (state.layout) {
return state.layout(state, div);
}
return div;
};

21
app/templates/progress.js Normal file
View File

@ -0,0 +1,21 @@
const html = require('choo/html');
const radius = 73;
const oRadius = radius + 10;
const oDiameter = oRadius * 2;
const circumference = 2 * Math.PI * radius;
module.exports = function(progressRatio) {
const dashOffset = (1 - progressRatio) * circumference;
const percent = Math.floor(progressRatio * 100);
const div = html`
<div class="progress-bar">
<svg id="progress" width="${oDiameter}" height="${oDiameter}" viewPort="0 0 ${oDiameter} ${oDiameter}" version="1.1">
<circle r="${radius}" cx="${oRadius}" cy="${oRadius}" fill="transparent"/>
<circle id="bar" r="${radius}" cx="${oRadius}" cy="${oRadius}" fill="transparent" transform="rotate(-90 ${oRadius} ${oRadius})" stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}"/>
<text class="percentage" text-anchor="middle" x="50%" y="98"><tspan class="percent-number">${percent}</tspan><tspan class="percent-sign">%</tspan></text>
</svg>
</div>
`;
return div;
};

61
app/templates/share.js Normal file
View File

@ -0,0 +1,61 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const notFound = require('./notFound');
const { allowedCopy, delay, fadeOut } = require('../utils');
module.exports = function(state, emit) {
const file = state.storage.getFileById(state.params.id);
if (!file) {
return notFound(state, emit);
}
const div = html`
<div id="share-link" class="fadeIn">
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
<div id="share-window">
<div id="copy-text">${state.translate('copyUrlFormLabelWithName', {
filename: file.name
})}</div>
<div id="copy">
<input id="link" type="url" value="${file.url}" readonly="true"/>
<button id="copy-btn" class="btn" onclick=${copyLink}>${state.translate(
'copyUrlFormButton'
)}</button>
</div>
<button id="delete-file" class="btn" onclick=${deleteFile}>${state.translate(
'deleteFileButton'
)}</button>
<a class="send-new" data-state="completed" href="/" onclick=${sendNew}>${state.translate(
'sendAnotherFileLink'
)}</a>
</div>
</div>
`;
async function sendNew(e) {
e.preventDefault();
await fadeOut('share-link');
emit('pushState', '/');
}
async function copyLink() {
if (allowedCopy()) {
emit('copy', { url: file.url, location: 'success-screen' });
const copyBtn = document.getElementById('copy-btn');
copyBtn.disabled = true;
copyBtn.replaceChild(
html`<img src="${assets.get('check-16.svg')}" class="icon-check">`,
copyBtn.firstChild
);
await delay(2000);
copyBtn.disabled = false;
copyBtn.textContent = state.translate('copyUrlFormButton');
}
}
async function deleteFile() {
emit('delete', { file, location: 'success-screen' });
await fadeOut('share-link');
emit('pushState', '/');
}
return div;
};

View File

@ -0,0 +1,50 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function(state) {
const msg =
state.params.reason === 'outdated'
? html`
<div id="unsupported-browser">
<div class="title">${state.translate('notSupportedHeader')}</div>
<div class="description">${state.translate(
'notSupportedOutdatedDetail'
)}</div>
<a id="update-firefox" href="https://support.mozilla.org/kb/update-firefox-latest-version">
<img src="${assets.get(
'firefox_logo-only.svg'
)}" class="firefox-logo" alt="Firefox"/>
<div class="unsupported-button-text">${state.translate(
'updateFirefox'
)}</div>
</a>
<div class="unsupported-description">${state.translate(
'uploadPageExplainer'
)}</div>
</div>`
: html`
<div id="unsupported-browser">
<div class="title">${state.translate('notSupportedHeader')}</div>
<div class="description">${state.translate('notSupportedDetail')}</div>
<div class="description"><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">${state.translate(
'notSupportedLink'
)}</a></div>
<a id="dl-firefox" href="https://www.mozilla.org/firefox/new/?scene=2">
<img src="${assets.get(
'firefox_logo-only.svg'
)}" class="firefox-logo" alt="Firefox"/>
<div class="unsupported-button-text">Firefox<br>
<span>${state.translate('downloadFirefoxButtonSub')}</span>
</div>
</a>
<div class="unsupported-description">${state.translate(
'uploadPageExplainer'
)}</div>
</div>`;
const div = html`<div id="page-one">${msg}</div>`;
if (state.layout) {
return state.layout(state, div);
}
return div;
};

38
app/templates/upload.js Normal file
View File

@ -0,0 +1,38 @@
const html = require('choo/html');
const progress = require('./progress');
const { bytes } = require('../utils');
module.exports = function(state, emit) {
const transfer = state.transfer;
const div = html`
<div id="upload-progress" class="fadeIn">
<div class="title" id="upload-filename">${state.translate(
'uploadingPageProgress',
{
filename: transfer.file.name,
size: bytes(transfer.file.size)
}
)}</div>
<div class="description"></div>
${progress(transfer.progressRatio)}
<div class="upload">
<div class="progress-text">${state.translate(
transfer.msg,
transfer.sizes
)}</div>
<button id="cancel-upload" onclick=${cancel}>${state.translate(
'uploadingPageCancel'
)}</button>
</div>
</div>
`;
function cancel() {
const btn = document.getElementById('cancel-upload');
btn.disabled = true;
btn.textContent = state.translate('uploadCancelNotification');
emit('cancel');
}
return div;
};

55
app/templates/welcome.js Normal file
View File

@ -0,0 +1,55 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const fileList = require('./fileList');
const { fadeOut } = require('../utils');
module.exports = function(state, emit) {
const div = html`
<div id="page-one" class="fadeIn">
<div class="title">${state.translate('uploadPageHeader')}</div>
<div class="description">
<div>${state.translate('uploadPageExplainer')}</div>
<a href="https://testpilot.firefox.com/experiments/send" class="link">${state.translate(
'uploadPageLearnMore'
)}</a>
</div>
<div class="upload-window" ondragover=${dragover} ondragleave=${dragleave}>
<div id="upload-img"><img src="${assets.get(
'upload.svg'
)}" title="${state.translate('uploadSvgAlt')}"/></div>
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
<span id="file-size-msg"><em>${state.translate(
'uploadPageSizeMessage'
)}</em></span>
<form method="post" action="upload" enctype="multipart/form-data">
<label for="file-upload" id="browse" class="btn">${state.translate(
'uploadPageBrowseButton1'
)}</label>
<input id="file-upload" type="file" name="fileUploaded" onchange=${upload} />
</form>
</div>
${fileList(state, emit)}
</div>
`;
function dragover(event) {
event.target.classList.add('ondrag');
}
function dragleave(event) {
event.target.classList.remove('ondrag');
}
async function upload(event) {
event.preventDefault();
const target = event.target;
const file = target.files[0];
await fadeOut('page-one');
emit('upload', { file, type: 'click' });
}
if (state.layout) {
return state.layout(state, div);
}
return div;
};

View File

@ -35,47 +35,39 @@ function notify(str) {
*/
}
function gcmCompliant() {
function loadShim(polyfill) {
return new Promise((resolve, reject) => {
const shim = document.createElement('script');
shim.src = polyfill;
shim.addEventListener('load', () => resolve(true));
shim.addEventListener('error', () => resolve(false));
document.head.appendChild(shim);
});
}
async function canHasSend(polyfill) {
try {
return window.crypto.subtle
.generateKey(
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
)
.then(key => {
return window.crypto.subtle
.encrypt(
{
name: 'AES-GCM',
iv: window.crypto.getRandomValues(new Uint8Array(12)),
additionalData: window.crypto.getRandomValues(new Uint8Array(6)),
tagLength: 128
},
key,
new ArrayBuffer(8)
)
.then(() => {
return Promise.resolve();
});
})
.catch(err => {
return loadShim();
});
const key = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
);
await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: window.crypto.getRandomValues(new Uint8Array(12)),
tagLength: 128
},
key,
new ArrayBuffer(8)
);
return true;
} catch (err) {
return loadShim();
}
function loadShim() {
return new Promise((resolve, reject) => {
const shim = document.createElement('script');
shim.src = '/cryptofill.js';
shim.addEventListener('load', resolve);
shim.addEventListener('error', reject);
document.head.appendChild(shim);
});
return loadShim(polyfill);
}
}
@ -107,7 +99,8 @@ function copyToClipboard(str) {
const LOCALIZE_NUMBERS = !!(
typeof Intl === 'object' &&
Intl &&
typeof Intl.NumberFormat === 'function'
typeof Intl.NumberFormat === 'function' &&
typeof navigator === 'object'
);
const UNITS = ['B', 'kB', 'MB', 'GB'];
@ -134,9 +127,22 @@ function allowedCopy() {
return support ? document.queryCommandSupported('copy') : false;
}
function delay(delay = 100) {
return new Promise(resolve => setTimeout(resolve, delay));
}
function fadeOut(id) {
const classes = document.getElementById(id).classList;
classes.remove('fadeIn');
classes.add('fadeOut');
return delay(300);
}
const ONE_DAY_IN_MS = 86400000;
export {
module.exports = {
fadeOut,
delay,
allowedCopy,
bytes,
percent,
@ -144,7 +150,7 @@ export {
arrayToHex,
hexToArray,
notify,
gcmCompliant,
canHasSend,
isFile,
ONE_DAY_IN_MS
};

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#4A4A4A" d="M9.414 8l5.293-5.293a1 1 0 0 0-1.414-1.414L8 6.586 2.707 1.293a1 1 0 0 0-1.414 1.414L6.586 8l-5.293 5.293a1 1 0 1 0 1.414 1.414L8 9.414l5.293 5.293a1 1 0 0 0 1.414-1.414z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16"><path fill="#4A4A4A" d="M9.414 8l5.293-5.293a1 1 0 0 0-1.414-1.414L8 6.586 2.707 1.293a1 1 0 0 0-1.414 1.414L6.586 8l-5.293 5.293a1 1 0 1 0 1.414 1.414L8 9.414l5.293 5.293a1 1 0 0 0 1.414-1.414z"/></svg>

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 416 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 649 B

View File

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1,6 +1,6 @@
/*** index.html ***/
html {
background: url('../../public/resources/send_bg.svg');
background: url('./send_bg.svg');
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'segoe ui',
'helvetica neue', helvetica, ubuntu, roboto, noto, arial, sans-serif;
font-weight: 200;
@ -89,7 +89,7 @@ body {
.feedback {
background-color: #0297f8;
background-image: url('../../public/resources/feedback.svg');
background-image: url('./feedback.svg');
background-position: 2px 4px;
background-repeat: no-repeat;
background-size: 18px;
@ -154,6 +154,36 @@ a {
/** page-one **/
.fadeOut {
opacity: 0;
animation: fadeout 200ms linear;
}
@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.fadeIn {
opacity: 1;
animation: fadein 200ms linear;
}
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.title {
font-size: 33px;
line-height: 40px;
@ -431,12 +461,8 @@ tbody {
}
.percentage {
position: absolute;
letter-spacing: -0.78px;
font-family: 'Segoe UI', 'SF Pro Text', sans-serif;
top: 50px;
left: 50%;
transform: translateX(-45%);
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@ -449,7 +475,8 @@ tbody {
.percent-sign {
font-size: 28.8px;
color: rgb(104, 104, 104);
stroke: none;
fill: #686868;
}
.upload {
@ -471,10 +498,18 @@ tbody {
#cancel-upload {
color: #d70022;
background: #fff;
font-size: 15px;
border: 0;
cursor: pointer;
text-decoration: underline;
}
#cancel-upload:disabled {
text-decoration: none;
cursor: auto;
}
/** share-link **/
#share-window {
margin: 0 auto;
@ -624,7 +659,7 @@ tbody {
#update-firefox {
margin-bottom: 181px;
height: 80px;
background: #12bc00;
background: #98e02b;
border-radius: 3px;
cursor: pointer;
border: 0;

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 312 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 873 B

After

Width:  |  Height:  |  Size: 873 B

View File

Before

Width:  |  Height:  |  Size: 336 B

After

Width:  |  Height:  |  Size: 336 B

38
build/fluent_loader.js Normal file
View File

@ -0,0 +1,38 @@
const { MessageContext } = require('fluent');
function toJSON(map) {
return JSON.stringify(Array.from(map));
}
module.exports = function(source) {
const localeExp = this.options.locale || /([^/]+)\/[^/]+\.ftl$/;
const result = localeExp.exec(this.resourcePath);
const locale = result && result[1];
// pre-parse the ftl
const context = new MessageContext(locale);
context.addMessages(source);
if (!locale) {
throw new Error(`couldn't find locale in: ${this.resourcePath}`);
}
return `
module.exports = \`
if (typeof window === 'undefined') {
var fluent = require('fluent');
}
var ctx = new fluent.MessageContext('${locale}', {useIsolating: false});
ctx._messages = new Map(${toJSON(context._messages)});
function translate(id, data) {
var msg = ctx.getMessage(id);
if (typeof(msg) !== 'string' && !msg.val && msg.attrs) {
msg = msg.attrs.title || msg.attrs.alt
}
return ctx.format(msg, data);
}
if (typeof window === 'undefined') {
module.exports = translate;
}
else {
window.translate = translate;
}
\``;
};

View File

@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');
function kv(f) {
return `"${f}": require('../assets/${f}')`;
}
module.exports = function() {
const files = fs.readdirSync(path.join(__dirname, '..', 'assets'));
const code = `module.exports = {
"package.json": require('../package.json'),
${files.map(kv).join(',\n')}
};`;
return {
code,
dependencies: files.map(f => require.resolve('../assets/' + f)),
cacheable: false
};
};

View File

@ -0,0 +1,22 @@
const fs = require('fs');
const path = require('path');
function kv(d) {
return `"${d}": require('../public/locales/${d}/send.ftl')`;
}
module.exports = function() {
const dirs = fs.readdirSync(path.join(__dirname, '..', 'public', 'locales'));
const code = `
module.exports = {
translate: function (id, data) { return window.translate(id, data) },
${dirs.map(kv).join(',\n')}
};`;
return {
code,
dependencies: dirs.map(d =>
require.resolve(`../public/locales/${d}/send.ftl`)
),
cacheable: false
};
};

View File

@ -0,0 +1,11 @@
const commit = require('git-rev-sync').short();
module.exports = function(source) {
const pkg = JSON.parse(source);
const version = {
commit,
source: pkg.homepage,
version: process.env.CIRCLE_TAG || `v${pkg.version}`
};
return `module.exports = '${JSON.stringify(version)}'`;
};

32
common/assets.js Normal file
View File

@ -0,0 +1,32 @@
const genmap = require('../build/generate_asset_map');
const isServer = typeof genmap === 'function';
const prefix = isServer ? '/' : '';
let manifest = {};
try {
//eslint-disable-next-line node/no-missing-require
manifest = require('../dist/manifest.json');
} catch (e) {
// use middleware
}
const assets = isServer ? manifest : genmap;
function getAsset(name) {
return prefix + assets[name];
}
const instance = {
get: getAsset,
setMiddleware: function(middleware) {
if (middleware) {
instance.get = function getAssetWithMiddleware(name) {
const f = middleware.fileSystem.readFileSync(
middleware.getFilenameFromUrl('/manifest.json')
);
return prefix + JSON.parse(f)[name];
};
}
}
};
module.exports = instance;

51
common/locales.js Normal file
View File

@ -0,0 +1,51 @@
const gen = require('../build/generate_l10n_map');
const isServer = typeof gen === 'function';
const prefix = isServer ? '/' : '';
let manifest = {};
try {
//eslint-disable-next-line node/no-missing-require
manifest = require('../dist/manifest.json');
} catch (e) {
// use middleware
}
const locales = isServer ? manifest : gen;
function getLocale(name) {
return prefix + locales[`public/locales/${name}/send.ftl`];
}
function serverTranslator(name) {
return require(`../dist/${locales[`public/locales/${name}/send.ftl`]}`);
}
function browserTranslator() {
return locales.translate;
}
const translator = isServer ? serverTranslator : browserTranslator;
const instance = {
get: getLocale,
getTranslator: translator,
setMiddleware: function(middleware) {
if (middleware) {
const _eval = require('require-from-string');
instance.get = function getLocaleWithMiddleware(name) {
const f = middleware.fileSystem.readFileSync(
middleware.getFilenameFromUrl('/manifest.json')
);
return prefix + JSON.parse(f)[`public/locales/${name}/send.ftl`];
};
instance.getTranslator = function(name) {
const f = middleware.fileSystem.readFileSync(
middleware.getFilenameFromUrl(instance.get(name))
);
return _eval(f.toString());
};
}
}
};
module.exports = instance;

View File

@ -1,20 +0,0 @@
import Raven from 'raven-js';
import { unsupported } from './metrics';
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install();
}
const ua = navigator.userAgent.toLowerCase();
if (
ua.indexOf('firefox') > -1 &&
parseInt(ua.match(/firefox\/*([^\n\r]*)\./)[1], 10) <= 49
) {
unsupported({
err: new Error('Firefox is outdated.')
}).then(() => {
location.replace('/unsupported/outdated');
});
}
export { Raven };

View File

@ -1,119 +0,0 @@
import { Raven } from './common';
import FileReceiver from './fileReceiver';
import { bytes, notify, gcmCompliant } from './utils';
import Storage from './storage';
import * as links from './links';
import * as metrics from './metrics';
import * as progress from './progress';
const storage = new Storage();
function onUnload(size) {
metrics.cancelledDownload({ size });
}
async function download() {
const downloadBtn = document.getElementById('download-btn');
const downloadPanel = document.getElementById('download-page-one');
const progressPanel = document.getElementById('download-progress');
const file = document.getElementById('dl-file');
const size = Number(file.getAttribute('data-size'));
const ttl = Number(file.getAttribute('data-ttl'));
const unloadHandler = onUnload.bind(null, size);
const startTime = Date.now();
const fileReceiver = new FileReceiver(
'/assets' + location.pathname.slice(0, -1),
location.hash.slice(1)
);
downloadBtn.disabled = true;
downloadPanel.hidden = true;
progressPanel.hidden = false;
metrics.startedDownload({ size, ttl });
links.setOpenInNewTab(true);
window.addEventListener('unload', unloadHandler);
fileReceiver.on('progress', data => {
progress.setProgress({ complete: data[0], total: data[1] });
});
let downloadEnd;
fileReceiver.on('decrypting', () => {
downloadEnd = Date.now();
window.removeEventListener('unload', unloadHandler);
fileReceiver.removeAllListeners('progress');
document.l10n.formatValue('decryptingFile').then(progress.setText);
});
try {
const file = await fileReceiver.download();
const endTime = Date.now();
const time = endTime - startTime;
const downloadTime = endTime - downloadEnd;
const speed = size / (downloadTime / 1000);
links.setOpenInNewTab(false);
storage.totalDownloads += 1;
metrics.completedDownload({ size, time, speed });
progress.setText(' ');
document.l10n
.formatValues('downloadNotification', 'downloadFinish')
.then(translated => {
notify(translated[0]);
document.getElementById('dl-title').textContent = translated[1];
document.querySelector('#download-progress .description').textContent =
' ';
});
const dataView = new DataView(file.plaintext);
const blob = new Blob([dataView], { type: file.type });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
if (window.navigator.msSaveBlob) {
window.navigator.msSaveBlob(blob, file.name);
return;
}
a.download = file.name;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(downloadUrl);
} catch (err) {
metrics.stoppedDownload({ size, err });
if (err.message === 'notfound') {
location.reload();
} else {
progressPanel.hidden = true;
downloadPanel.hidden = true;
document.getElementById('upload-error').hidden = false;
}
Raven.captureException(err);
}
}
document.addEventListener('DOMContentLoaded', function() {
const file = document.getElementById('dl-file');
const filename = file.getAttribute('data-filename');
const b = Number(file.getAttribute('data-size'));
const size = bytes(b);
document.l10n.formatValue('downloadFileSize', { size }).then(str => {
document.getElementById('dl-filesize').textContent = str;
});
document.l10n
.formatValue('downloadingPageProgress', { filename, size })
.then(str => {
document.getElementById('dl-title').textContent = str;
});
gcmCompliant()
.then(() => {
document
.getElementById('download-btn')
.addEventListener('click', download);
})
.catch(err => {
metrics.unsupported({ err }).then(() => {
location.replace('/unsupported/gcm');
});
});
});

View File

@ -1,163 +0,0 @@
import FileSender from './fileSender';
import Storage from './storage';
import * as metrics from './metrics';
import { allowedCopy, copyToClipboard, ONE_DAY_IN_MS } from './utils';
import bel from 'bel';
import copyImg from '../../public/resources/copy-16.svg';
import closeImg from '../../public/resources/close-16.svg';
const HOUR = 1000 * 60 * 60;
const storage = new Storage();
let fileList = null;
document.addEventListener('DOMContentLoaded', function() {
fileList = document.getElementById('file-list');
toggleHeader();
Promise.all(
storage.files.map(file => {
const id = file.fileId;
return checkExistence(id).then(exists => {
if (exists) {
addFile(storage.getFileById(id));
} else {
storage.remove(id);
}
});
})
)
.catch(err => console.error(err))
.then(toggleHeader);
});
function toggleHeader() {
fileList.hidden = storage.files.length === 0;
}
function timeLeft(milliseconds) {
const minutes = Math.floor(milliseconds / 1000 / 60);
const hours = Math.floor(minutes / 60);
const seconds = Math.floor(milliseconds / 1000 % 60);
if (hours >= 1) {
return `${hours}h ${minutes % 60}m`;
} else if (hours === 0) {
return `${minutes}m ${seconds}s`;
}
return 'Expired';
}
function addFile(file) {
if (!file) {
return;
}
file.creationDate = new Date(file.creationDate);
const url = `${file.url}#${file.secretKey}`;
const future = new Date();
future.setTime(file.creationDate.getTime() + file.expiry);
const countdown = future.getTime() - Date.now();
const row = bel`
<tr>
<td>${file.name}</td>
<td>
<span class="icon-docs" data-l10n-id="copyUrlHover"></span>
<img onclick=${copyClick} src="${copyImg}" class="icon-copy" data-l10n-id="copyUrlHover">
<span data-l10n-id="copiedUrl" class="text-copied" hidden="true"></span>
</td>
<td>${timeLeft(countdown)}</td>
<td>
<span class="icon-cancel-1" data-l10n-id="deleteButtonHover" title="Delete"></span>
<img onclick=${showPopup} src="${closeImg}" class="icon-delete" data-l10n-id="deleteButtonHover" title="Delete">
<div class="popup">
<div class="popuptext" onclick=${stopProp} onblur=${cancel} tabindex="-1">
<div class="popup-message" data-l10n-id="deletePopupText"></div>
<div class="popup-action">
<span class="popup-no" onclick=${cancel} data-l10n-id="deletePopupCancel"></span>
<span class="popup-yes" onclick=${deleteFile} data-l10n-id="deletePopupYes"></span>
</div>
</div>
</div>
</td>
</tr>
`;
const popup = row.querySelector('.popuptext');
const timeCol = row.querySelectorAll('td')[2];
if (!allowedCopy()) {
row.querySelector('.icon-copy').disabled = true;
}
fileList.querySelector('tbody').appendChild(row);
toggleHeader();
poll();
function copyClick(e) {
metrics.copiedLink({ location: 'upload-list' });
copyToClipboard(url);
const icon = e.target;
const text = e.target.nextSibling;
icon.hidden = true;
text.hidden = false;
setTimeout(() => {
icon.hidden = false;
text.hidden = true;
}, 500);
}
function poll() {
const countdown = future.getTime() - Date.now();
if (countdown <= 0) {
storage.remove(file.fileId);
row.parentNode.removeChild(row);
toggleHeader();
}
timeCol.textContent = timeLeft(countdown);
setTimeout(poll, countdown >= HOUR ? 60000 : 1000);
}
function deleteFile() {
FileSender.delete(file.fileId, file.deleteToken);
const ttl = ONE_DAY_IN_MS - (Date.now() - file.creationDate.getTime());
metrics.deletedUpload({
size: file.size,
time: file.totalTime,
speed: file.uploadSpeed,
type: file.typeOfUpload,
location: 'upload-list',
ttl
});
row.parentNode.removeChild(row);
storage.remove(file.fileId);
toggleHeader();
}
function showPopup() {
popup.classList.add('show');
popup.focus();
}
function cancel(e) {
e.stopPropagation();
popup.classList.remove('show');
}
function stopProp(e) {
e.stopPropagation();
}
}
async function checkExistence(id) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
resolve(xhr.status === 200);
}
};
xhr.onerror = reject;
xhr.ontimeout = reject;
xhr.open('get', '/exists/' + id);
xhr.timeout = 2000;
xhr.send();
});
}
export { addFile };

View File

@ -1,21 +0,0 @@
let links = [];
document.addEventListener('DOMContentLoaded', function() {
links = Array.from(document.querySelectorAll('a:not([target])'));
});
function setOpenInNewTab(bool) {
if (bool === false) {
links.forEach(l => {
l.removeAttribute('target');
l.removeAttribute('rel');
});
} else {
links.forEach(l => {
l.setAttribute('target', '_blank');
l.setAttribute('rel', 'noopener noreferrer');
});
}
}
export { setOpenInNewTab };

View File

@ -1,47 +0,0 @@
import { bytes, percent } from './utils';
let percentText = null;
let text = null;
let title = null;
let bar = null;
let updateTitle = false;
const radius = 73;
const circumference = 2 * Math.PI * radius;
document.addEventListener('DOMContentLoaded', function() {
percentText = document.querySelector('.percent-number');
text = document.querySelector('.progress-text');
bar = document.getElementById('bar');
title = document.querySelector('title');
});
document.addEventListener('blur', function() {
updateTitle = true;
});
document.addEventListener('focus', function() {
updateTitle = false;
return title && (title.textContent = 'Firefox Send');
});
function setProgress(params) {
const ratio = params.complete / params.total;
bar.setAttribute('stroke-dashoffset', (1 - ratio) * circumference);
percentText.textContent = Math.floor(ratio * 100);
if (updateTitle) {
title.textContent = percent(ratio);
}
document.l10n
.formatValue('fileSizeProgress', {
partialSize: bytes(params.complete),
totalSize: bytes(params.total)
})
.then(setText);
}
function setText(str) {
text.textContent = str;
}
export { setProgress, setText };

View File

@ -1,257 +0,0 @@
/* global MAXFILESIZE EXPIRE_SECONDS */
import { Raven } from './common';
import FileSender from './fileSender';
import {
allowedCopy,
bytes,
copyToClipboard,
notify,
gcmCompliant,
ONE_DAY_IN_MS
} from './utils';
import Storage from './storage';
import * as metrics from './metrics';
import * as progress from './progress';
import * as fileList from './fileList';
import checkImg from '../../public/resources/check-16.svg';
const storage = new Storage();
async function upload(event) {
event.preventDefault();
const pageOne = document.getElementById('page-one');
const link = document.getElementById('link');
const uploadWindow = document.querySelector('.upload-window');
const uploadError = document.getElementById('upload-error');
const uploadProgress = document.getElementById('upload-progress');
const clickOrDrop = event.type === 'drop' ? 'drop' : 'click';
// don't allow upload if not on upload page
if (pageOne.hidden) {
return;
}
storage.totalUploads += 1;
let file = '';
if (clickOrDrop === 'drop') {
if (!event.dataTransfer.files[0]) {
uploadWindow.classList.remove('ondrag');
return;
}
if (
event.dataTransfer.files.length > 1 ||
event.dataTransfer.files[0].size === 0
) {
uploadWindow.classList.remove('ondrag');
document.l10n.formatValue('uploadPageMultipleFilesAlert').then(str => {
alert(str);
});
return;
}
file = event.dataTransfer.files[0];
} else {
file = event.target.files[0];
}
if (file.size > MAXFILESIZE) {
return document.l10n
.formatValue('fileTooBig', { size: bytes(MAXFILESIZE) })
.then(alert);
}
pageOne.hidden = true;
uploadError.hidden = true;
uploadProgress.hidden = false;
document.l10n
.formatValue('uploadingPageProgress', {
size: bytes(file.size),
filename: file.name
})
.then(str => {
document.getElementById('upload-filename').textContent = str;
});
document.l10n.formatValue('importingFile').then(progress.setText);
//don't allow drag and drop when not on page-one
document.body.removeEventListener('drop', upload);
const fileSender = new FileSender(file);
document.getElementById('cancel-upload').addEventListener('click', () => {
fileSender.cancel();
metrics.cancelledUpload({
size: file.size,
type: clickOrDrop
});
location.reload();
});
let uploadStart;
fileSender.on('progress', data => {
uploadStart = uploadStart || Date.now();
progress.setProgress({
complete: data[0],
total: data[1]
});
});
fileSender.on('encrypting', () => {
document.l10n.formatValue('encryptingFile').then(progress.setText);
});
let t;
const startTime = Date.now();
metrics.startedUpload({
size: file.size,
type: clickOrDrop
});
// For large files we need to give the ui a tick to breathe and update
// before we kick off the FileSender
setTimeout(() => {
fileSender
.upload()
.then(info => {
const endTime = Date.now();
const time = endTime - startTime;
const uploadTime = endTime - uploadStart;
const speed = file.size / (uploadTime / 1000);
const expiration = EXPIRE_SECONDS * 1000;
link.setAttribute('value', `${info.url}#${info.secretKey}`);
const copyText = document.getElementById('copy-text');
copyText.setAttribute(
'data-l10n-args',
JSON.stringify({ filename: file.name })
);
copyText.setAttribute('data-l10n-id', 'copyUrlFormLabelWithName');
metrics.completedUpload({
size: file.size,
time,
speed,
type: clickOrDrop
});
const fileData = {
name: file.name,
size: file.size,
fileId: info.fileId,
url: info.url,
secretKey: info.secretKey,
deleteToken: info.deleteToken,
creationDate: new Date(),
expiry: expiration,
totalTime: time,
typeOfUpload: clickOrDrop,
uploadSpeed: speed
};
document.getElementById('delete-file').addEventListener('click', () => {
FileSender.delete(fileData.fileId, fileData.deleteToken).then(() => {
const ttl =
ONE_DAY_IN_MS - (Date.now() - fileData.creationDate.getTime());
metrics
.deletedUpload({
size: fileData.size,
time: fileData.totalTime,
speed: fileData.uploadSpeed,
type: fileData.typeOfUpload,
location: 'success-screen',
ttl
})
.then(() => {
storage.remove(fileData.fileId);
location.reload();
});
});
});
storage.addFile(info.fileId, fileData);
pageOne.hidden = true;
uploadProgress.hidden = true;
uploadError.hidden = true;
document.getElementById('share-link').hidden = false;
fileList.addFile(fileData);
document.l10n.formatValue('notifyUploadDone').then(str => {
notify(str);
});
})
.catch(err => {
// err is 0 when coming from a cancel upload event
if (err === 0) {
return;
}
// only show error page when the error is anything other than user cancelling the upload
Raven.captureException(err);
pageOne.hidden = true;
uploadProgress.hidden = true;
uploadError.hidden = false;
window.clearTimeout(t);
metrics.stoppedUpload({
size: file.size,
type: clickOrDrop,
err
});
});
}, 10);
}
document.addEventListener('DOMContentLoaded', function() {
gcmCompliant()
.then(function() {
const pageOne = document.getElementById('page-one');
const copyBtn = document.getElementById('copy-btn');
const link = document.getElementById('link');
const uploadWindow = document.querySelector('.upload-window');
pageOne.hidden = false;
document.getElementById('file-upload').addEventListener('change', upload);
document.body.addEventListener('dragover', allowDrop);
document.body.addEventListener('drop', upload);
// reset copy button
copyBtn.disabled = !allowedCopy();
copyBtn.setAttribute('data-l10n-id', 'copyUrlFormButton');
link.disabled = false;
// copy link to clipboard
copyBtn.addEventListener('click', () => {
if (allowedCopy() && copyToClipboard(link.getAttribute('value'))) {
metrics.copiedLink({ location: 'success-screen' });
//disable button for 3s
copyBtn.disabled = true;
link.disabled = true;
copyBtn.innerHTML = `<img src="${checkImg}" class="icon-check"></img>`;
setTimeout(() => {
copyBtn.disabled = !allowedCopy();
copyBtn.setAttribute('data-l10n-id', 'copyUrlFormButton');
link.disabled = false;
}, 3000);
}
});
uploadWindow.addEventListener('dragover', () =>
uploadWindow.classList.add('ondrag')
);
uploadWindow.addEventListener('dragleave', () =>
uploadWindow.classList.remove('ondrag')
);
// on file upload by browse or drag & drop
function allowDrop(ev) {
ev.preventDefault();
}
})
.catch(err => {
metrics.unsupported({ err }).then(() => {
location.replace('/unsupported/gcm');
});
});
});

4957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,73 +3,109 @@
"description": "File Sharing Experiment",
"version": "1.1.2",
"author": "Mozilla (https://mozilla.org)",
"dependencies": {
"aws-sdk": "^2.98.0",
"body-parser": "^1.17.2",
"connect-busboy": "0.0.2",
"convict": "^3.0.0",
"express": "^4.15.3",
"express-handlebars": "^3.0.0",
"helmet": "^3.8.0",
"mozlog": "^2.1.1",
"raven": "^2.1.0",
"redis": "^2.8.0"
"repository": "mozilla/send",
"homepage": "https://github.com/mozilla/send/",
"license": "MPL-2.0",
"private": true,
"scripts": {
"precommit": "lint-staged",
"clean": "rimraf dist",
"build": "npm run clean && webpack -p",
"lint": "npm-run-all lint:*",
"lint:css": "stylelint 'assets/*.css'",
"lint:js": "eslint .",
"lint-locales": "node scripts/lint-locales",
"lint-locales:dev": "npm run lint-locales",
"lint-locales:prod": "npm run lint-locales -- --production",
"format": "prettier '!(dist|node_modules|assets)/**/*.js' 'assets/*.css' --single-quote --write",
"get-prod-locales": "node scripts/get-prod-locales",
"get-prod-locales:write": "npm run get-prod-locales -- --write",
"changelog": "github-changes -o mozilla -r send --only-pulls --use-commit-body --no-merges",
"contributors": "git shortlog -s | awk -F\\t '{print $2}' > CONTRIBUTORS",
"release": "npm-run-all contributors changelog",
"test": "mocha test/unit",
"start": "cross-env NODE_ENV=development webpack-dev-server",
"prod": "node server/prod.js"
},
"devDependencies": {
"asmcrypto.js": "0.0.11",
"autoprefixer": "^7.1.2",
"babel-core": "^6.25.0",
"babel-loader": "^7.1.1",
"babel-polyfill": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",
"bel": "^5.0.3",
"browserify": "^14.4.0",
"copy-webpack-plugin": "^4.0.1",
"cross-env": "^5.0.5",
"css-loader": "^0.28.4",
"css-mqpacker": "^6.0.1",
"cssnano": "^3.10.0",
"eslint": "^4.3.0",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-node": "^5.1.1",
"eslint-plugin-security": "^1.4.0",
"extract-loader": "^1.0.0",
"file-loader": "^0.11.2",
"git-rev-sync": "^1.9.1",
"github-changes": "^1.1.0",
"html-loader": "^0.5.1",
"html-webpack-plugin": "^2.30.1",
"husky": "^0.14.3",
"l20n": "^5.0.0",
"lint-staged": "^4.0.3",
"mkdirp": "^0.5.1",
"mocha": "^3.4.2",
"npm-run-all": "^4.0.2",
"postcss-cli": "^4.1.0",
"postcss-loader": "^2.0.6",
"prettier": "^1.5.3",
"proxyquire": "^1.8.0",
"raven-js": "^3.17.0",
"rimraf": "^2.6.1",
"selenium-webdriver": "^3.5.0",
"sinon": "^2.3.8",
"stylelint": "^8.0.0",
"stylelint-config-standard": "^17.0.0",
"stylelint-no-unsupported-browser-features": "^1.0.0",
"supertest": "^3.0.0",
"testpilot-ga": "^0.3.0",
"webcrypto-liner": "^0.1.25",
"webpack": "^3.5.4",
"webpack-dev-middleware": "^1.12.0"
"lint-staged": {
"*.js": [
"prettier --single-quote --write",
"eslint",
"git add"
],
"*.css": [
"prettier --single-quote --write",
"stylelint",
"git add"
]
},
"engines": {
"node": ">=8.2.0"
},
"homepage": "https://github.com/mozilla/send/",
"license": "MPL-2.0",
"repository": "mozilla/send",
"devDependencies": {
"autoprefixer": "^7.1.2",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-yo-yoify": "^0.7.0",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",
"choo-log": "^7.2.1",
"copy-webpack-plugin": "^4.0.1",
"cross-env": "^5.0.5",
"css-loader": "^0.28.5",
"css-mqpacker": "^6.0.1",
"cssnano": "^3.10.0",
"eslint": "^4.5.0",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-node": "^5.1.1",
"eslint-plugin-security": "^1.4.0",
"expose-loader": "^0.7.3",
"extract-loader": "^1.0.1",
"file-loader": "^0.11.2",
"git-rev-sync": "^1.9.1",
"github-changes": "^1.1.0",
"html-loader": "^0.5.1",
"husky": "^0.14.3",
"lint-staged": "^4.0.4",
"mocha": "^3.5.0",
"nanobus": "^4.2.0",
"npm-run-all": "^4.0.2",
"postcss-loader": "^2.0.6",
"prettier": "^1.5.3",
"proxyquire": "^1.8.0",
"raven-js": "^3.17.0",
"require-from-string": "^1.2.1",
"rimraf": "^2.6.1",
"selenium-webdriver": "^3.5.0",
"sinon": "^3.2.1",
"stylelint-config-standard": "^17.0.0",
"stylelint-no-unsupported-browser-features": "^1.0.0",
"supertest": "^3.0.0",
"testpilot-ga": "^0.3.0",
"val-loader": "^1.0.2",
"webpack": "^3.5.5",
"webpack-dev-server": "^2.7.1",
"webpack-manifest-plugin": "^1.3.1"
},
"dependencies": {
"aws-sdk": "^2.103.0",
"body-parser": "^1.17.2",
"choo": "^6.0.0",
"connect-busboy": "0.0.2",
"convict": "^4.0.0",
"express": "^4.15.4",
"express-request-language": "^1.1.12",
"fluent": "^0.4.1",
"fluent-langneg": "^0.1.0",
"helmet": "^3.8.1",
"mozlog": "^2.1.1",
"raven": "^2.1.2",
"redis": "^2.8.0"
},
"availableLanguages": [
"en-US",
"ast",
"az",
"ca",
@ -78,7 +114,6 @@
"de",
"dsb",
"el",
"en-US",
"es-AR",
"es-CL",
"es-ES",
@ -108,42 +143,5 @@
"vi",
"zh-CN",
"zh-TW"
],
"scripts": {
"precommit": "lint-staged",
"clean": "rimraf dist",
"build": "npm-run-all build:*",
"build:js": "webpack -p",
"build:version": "node scripts/version",
"changelog": "github-changes -o mozilla -r send --only-pulls --use-commit-body --no-merges",
"contributors": "git shortlog -s | awk -F\\t '{print $2}' > CONTRIBUTORS",
"dev": "npm run clean && npm run build && npm start",
"format": "prettier '{,frontend/src/,scripts/,server/,test/**/!(bundle)}*.{js,css}' --single-quote --write",
"get-prod-locales": "node scripts/get-prod-locales",
"get-prod-locales:write": "npm run get-prod-locales -- --write",
"lint": "npm-run-all lint:*",
"lint:css": "stylelint 'frontend/src/*.css'",
"lint:js": "eslint .",
"lint-locales": "node scripts/lint-locales",
"lint-locales:dev": "npm run lint-locales",
"lint-locales:prod": "npm run lint-locales -- --production",
"release": "npm-run-all contributors changelog",
"start": "node server/server",
"test": "cross-env NODE_ENV=test npm-run-all test:*",
"test:unit": "mocha test/unit",
"test:server": "mocha test/server",
"test--browser": "browserify test/frontend/frontend.bundle.js -o test/frontend/bundle.js -d && node test/frontend/driver.js"
},
"lint-staged": {
"*.js": [
"prettier --single-quote --write",
"eslint",
"git add"
],
"*.css": [
"prettier --single-quote --write",
"stylelint",
"git add"
]
}
]
}

View File

@ -2,13 +2,13 @@ const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');
const mqpacker = require('css-mqpacker');
const conf = require('./server/config');
const config = require('./server/config');
const options = {
plugins: [autoprefixer, mqpacker, cssnano]
};
if (conf.env === 'development') {
if (config.env === 'development') {
options.map = { inline: true };
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,21 +0,0 @@
const fs = require('fs');
const path = require('path');
const pkg = require('../package.json');
const mkdirp = require('mkdirp');
let commit;
try {
commit = require('git-rev-sync').short();
} catch (err) {
// Whatever...
}
const filename = path.join(__dirname, '..', 'dist', 'public', 'version.json');
const filedata = {
commit,
source: pkg.homepage,
version: process.env.CIRCLE_TAG || `v${pkg.version}`
};
mkdirp.sync(path.dirname(filename));
fs.writeFileSync(filename, JSON.stringify(filedata, null, 2) + '\n');

13
server/dev.js Normal file
View File

@ -0,0 +1,13 @@
const assets = require('../common/assets');
const locales = require('../common/locales');
const routes = require('./routes');
const pages = require('./routes/pages');
module.exports = function(app, devServer) {
assets.setMiddleware(devServer.middleware);
locales.setMiddleware(devServer.middleware);
routes(app);
// webpack-dev-server routes haven't been added yet
// so wait for next tick to add 404 handler
process.nextTick(() => app.use(pages.notfound));
};

16
server/languages.js Normal file
View File

@ -0,0 +1,16 @@
const { availableLanguages } = require('../package.json');
const config = require('./config');
const fs = require('fs');
const path = require('path');
function allLangs() {
const langs = fs.readdirSync(path.join(__dirname, '..', 'public', 'locales'));
langs.unshift('en-US'); // default first, TODO change for fluent-langneg
return langs;
}
if (config.l10n_dev) {
module.exports = allLangs();
} else {
module.exports = availableLanguages;
}

98
server/layout.js Normal file
View File

@ -0,0 +1,98 @@
const html = require('choo/html');
const assets = require('../common/assets');
const locales = require('../common/locales');
module.exports = function(state, body = '') {
const firaTag = state.fira
? html`<link rel="stylesheet" type="text/css" href="https://code.cdn.mozilla.net/fonts/fira.css" />`
: '';
return html`
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:title" content="${state.title}"/>
<meta name="twitter:title" content="${state.title}"/>
<meta name="description" content="${state.description}"/>
<meta property="og:description" content="${state.description}"/>
<meta name="twitter:description" content="${state.description}"/>
<meta name="twitter:card" content="summary"/>
<meta property="og:image" content="${state.baseUrl}${assets.get(
'send-fb.jpg'
)}"/>
<meta name="twitter:image" content="${state.baseUrl}${assets.get(
'send-twitter.jpg'
)}"/>
<meta property="og:url" content="${state.baseUrl}"/>
<title>${state.title}</title>
<link rel="stylesheet" type="text/css" href="${assets.get('main.css')}" />
<link rel="icon" type="image/png" href="${assets.get(
'favicon-32x32.png'
)}" sizes="32x32" />
${firaTag}
<script defer src="/jsconfig.js"></script>
<script defer src="${assets.get('runtime.js')}"></script>
<script defer src="${assets.get('vendor.js')}"></script>
<script defer src="${locales.get(state.locale)}"></script>
<script defer src="${assets.get('app.js')}"></script>
</head>
<body>
<header class="header">
<div class="send-logo">
<a href="/">
<img src="${assets.get(
'send_logo.svg'
)}" alt="Send"/><h1 class="site-title">Send</h1>
</a>
<div class="site-subtitle">
<a href="https://testpilot.firefox.com">Firefox Test Pilot</a>
<div>${state.translate('siteSubtitle')}</div>
</div>
</div>
<a href="https://qsurvey.mozilla.com/s3/txp-firefox-send" rel="noreferrer noopener" class="feedback" target="_blank">${state.translate(
'siteFeedback'
)}</a>
</header>
<div class="all">
<noscript>
<h2>Firefox Send requires JavaScript</h2>
<p><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">Why does Firefox Send require JavaScript?</a></p>
<p>Please enable JavaScript and try again.</p>
</noscript>
${body}
</div>
<div class="footer">
<div class="legal-links">
<a href="https://www.mozilla.org" role="presentation"><img class="mozilla-logo" src="${assets.get(
'mozilla-logo.svg'
)}" alt="mozilla"/></a>
<a href="https://www.mozilla.org/about/legal">${state.translate(
'footerLinkLegal'
)}</a>
<a href="https://testpilot.firefox.com/about">${state.translate(
'footerLinkAbout'
)}</a>
<a href="/legal">${state.translate('footerLinkPrivacy')}</a>
<a href="/legal">${state.translate('footerLinkTerms')}</a>
<a href="https://www.mozilla.org/privacy/websites/#cookies">${state.translate(
'footerLinkCookies'
)}</a>
</div>
<div class="social-links">
<a href="https://github.com/mozilla/send" role="presentation"><img class="github" src="${assets.get(
'github-icon.svg'
)}" alt="github"/></a>
<a href="https://twitter.com/FxTestPilot" role="presentation"><img class="twitter" src="${assets.get(
'twitter-icon.svg'
)}" alt="twitter"/></a>
</div>
</div>
</body>
</html>
`;
};

View File

@ -1,4 +1,4 @@
const conf = require('./config.js');
const conf = require('./config');
const isProduction = conf.env === 'production';

26
server/prod.js Normal file
View File

@ -0,0 +1,26 @@
const express = require('express');
const path = require('path');
const Raven = require('raven');
const config = require('./config');
const routes = require('./routes');
const pages = require('./routes/pages');
if (config.sentry_dsn) {
Raven.config(config.sentry_dsn).install();
}
const app = express();
app.use(
express.static(path.resolve(__dirname, '../dist/'), {
setHeaders: function(res) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
})
);
routes(app);
app.use(pages.notfound);
app.listen(1443);

30
server/routes/delete.js Normal file
View File

@ -0,0 +1,30 @@
const storage = require('../storage');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
module.exports = async function(req, res) {
const id = req.params.id;
if (!validateID(id)) {
res.sendStatus(404);
return;
}
const delete_token = req.body.delete_token;
if (!delete_token) {
res.sendStatus(404);
return;
}
try {
const err = await storage.delete(id, delete_token);
if (!err) {
res.sendStatus(200);
}
} catch (e) {
res.sendStatus(404);
}
};

38
server/routes/download.js Normal file
View File

@ -0,0 +1,38 @@
const storage = require('../storage');
const mozlog = require('../log');
const log = mozlog('send.download');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
module.exports = async function(req, res) {
const id = req.params.id;
if (!validateID(id)) {
return res.sendStatus(404);
}
try {
const meta = await storage.metadata(id);
const contentLength = await storage.length(id);
res.writeHead(200, {
'Content-Disposition': `attachment; filename=${meta.filename}`,
'Content-Type': 'application/octet-stream',
'Content-Length': contentLength,
'X-File-Metadata': JSON.stringify(meta)
});
const file_stream = storage.get(id);
file_stream.on('end', async () => {
try {
await storage.forceDelete(id);
} catch (e) {
log.info('DeleteError:', id);
}
});
file_stream.pipe(res);
} catch (e) {
res.sendStatus(404);
}
};

19
server/routes/exists.js Normal file
View File

@ -0,0 +1,19 @@
const storage = require('../storage');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
module.exports = async (req, res) => {
const id = req.params.id;
if (!validateID(id)) {
return res.sendStatus(404);
}
try {
await storage.exists(id);
res.sendStatus(200);
} catch (e) {
res.sendStatus(404);
}
};

80
server/routes/index.js Normal file
View File

@ -0,0 +1,80 @@
const busboy = require('connect-busboy');
const helmet = require('helmet');
const bodyParser = require('body-parser');
const requestLanguage = require('express-request-language');
const languages = require('../languages');
const storage = require('../storage');
const config = require('../config');
const pages = require('./pages');
// const lang = require('fluent-langneg')
module.exports = function(app) {
app.use(
requestLanguage({
languages
})
);
app.use(helmet());
app.use(
helmet.hsts({
maxAge: 31536000,
force: config.env === 'production'
})
);
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
connectSrc: [
"'self'",
'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com'
],
imgSrc: ["'self'", 'https://www.google-analytics.com'],
scriptSrc: ["'self'"],
styleSrc: ["'self'", 'https://code.cdn.mozilla.net'],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
reportUri: '/__cspreport__'
}
})
);
app.use(
busboy({
limits: {
fileSize: config.max_file_size
}
})
);
app.use(bodyParser.json());
app.get('/', pages.index);
app.get('/legal', pages.legal);
app.get('/jsconfig.js', require('./jsconfig'));
app.get('/share/:id', pages.blank);
app.get('/download/:id', pages.download);
app.get('/completed', pages.blank);
app.get('/unsupported/:reason', pages.unsupported);
app.post('/api/upload', require('./upload'));
app.get('/api/download/:id', require('./download'));
app.get('/api/exists/:id', require('./exists'));
app.post('/api/delete/:id', require('./delete'));
app.get('/__version__', function(req, res) {
res.sendFile(require.resolve('../../dist/version.json'));
});
app.get('/__lbheartbeat__', function(req, res) {
res.sendStatus(200);
});
app.get('__heartbeat__', async (req, res) => {
try {
await storage.ping();
res.sendStatus(200);
} catch (e) {
res.sendStatus(500);
}
});
};

46
server/routes/jsconfig.js Normal file
View File

@ -0,0 +1,46 @@
const config = require('../config');
let sentry = '';
if (config.sentry_id) {
//eslint-disable-next-line node/no-missing-require
const version = require('../../dist/version.json');
sentry = `
var RAVEN_CONFIG = {
release: '${version.version}',
tags: {
commit: '${version.commit}'
},
dataCallback: function (data) {
var hash = window.location.hash;
if (hash) {
return JSON.parse(JSON.stringify(data).replace(new RegExp(hash.slice(1), 'g'), ''));
}
return data;
}
}
var SENTRY_ID = '${config.sentry_id}';
`;
}
let ga = '';
if (config.analytics_id) {
ga = `var GOOGLE_ANALYTICS_ID = ${config.analytics_id};`;
}
/* eslint-disable no-useless-escape */
const jsconfig = `
var isIE = /trident\\\/7\.|msie/i.test(navigator.userAgent);
var isUnsupportedPage = /\\\/unsupported/.test(location.pathname);
if (isIE && !isUnsupportedPage) {
window.location.replace('/unsupported/ie');
}
var MAXFILESIZE = ${config.max_file_size};
var EXPIRE_SECONDS = ${config.expire_seconds};
${ga}
${sentry}
`;
module.exports = function(req, res) {
res.set('Content-Type', 'application/javascript');
res.send(jsconfig);
};

68
server/routes/pages.js Normal file
View File

@ -0,0 +1,68 @@
const routes = require('../../app/routes');
const storage = require('../storage');
const state = require('../state');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
}
function stripEvents(str) {
// For CSP we need to remove all the event handler placeholders.
// It's ok, app.js will add them when it attaches to the DOM.
return str.replace(/\son\w+=""/g, '');
}
module.exports = {
index: function(req, res) {
res.send(stripEvents(routes.toString('/', state(req))));
},
blank: function(req, res) {
res.send(stripEvents(routes.toString('/blank', state(req))));
},
download: async function(req, res, next) {
const id = req.params.id;
if (!validateID(id)) {
return next();
}
try {
const efilename = await storage.filename(id);
const name = decodeURIComponent(efilename);
const size = await storage.length(id);
const ttl = await storage.ttl(id);
res.send(
stripEvents(
routes.toString(
`/download/${req.params.id}`,
Object.assign(state(req), {
fileInfo: { name, size, ttl }
})
)
)
);
} catch (e) {
next();
}
},
unsupported: function(req, res) {
res.send(
stripEvents(
routes.toString(
`/unsupported/${req.params.reason}`,
Object.assign(state(req), { fira: true })
)
)
);
},
legal: function(req, res) {
res.send(stripEvents(routes.toString('/legal', state(req))));
},
notfound: function(req, res) {
res.status(404).send(stripEvents(routes.toString('/404', state(req))));
}
};

65
server/routes/upload.js Normal file
View File

@ -0,0 +1,65 @@
const crypto = require('crypto');
const storage = require('../storage');
const config = require('../config');
const mozlog = require('../log');
const log = mozlog('send.upload');
const validateIV = route_id => {
return route_id.match(/^[0-9a-fA-F]{24}$/) !== null;
};
module.exports = function(req, res) {
const newId = crypto.randomBytes(5).toString('hex');
let meta;
try {
meta = JSON.parse(req.header('X-File-Metadata'));
} catch (e) {
res.sendStatus(400);
return;
}
if (
!meta.hasOwnProperty('id') ||
!meta.hasOwnProperty('filename') ||
!validateIV(meta.id)
) {
res.sendStatus(404);
return;
}
meta.delete = crypto.randomBytes(10).toString('hex');
req.pipe(req.busboy);
req.busboy.on(
'file',
async (fieldname, file, filename, encoding, mimeType) => {
try {
meta.mimeType = mimeType || 'application/octet-stream';
await storage.set(newId, file, filename, meta);
const protocol = config.env === 'production' ? 'https' : req.protocol;
const url = `${protocol}://${req.get('host')}/download/${newId}/`;
res.json({
url,
delete: meta.delete,
id: newId
});
} catch (e) {
log.error('upload', e);
if (e.message === 'limit') {
return res.sendStatus(413);
}
res.sendStatus(500);
}
}
);
req.on('close', async err => {
try {
await storage.forceDelete(newId);
} catch (e) {
log.info('DeleteError:', newId);
}
});
};

View File

@ -1,328 +0,0 @@
const express = require('express');
const exphbs = require('express-handlebars');
const busboy = require('connect-busboy');
const path = require('path');
const bodyParser = require('body-parser');
const helmet = require('helmet');
const conf = require('./config.js');
const storage = require('./storage.js');
const Raven = require('raven');
const crypto = require('crypto');
const fs = require('fs');
const version = require('../dist/public/version.json');
if (conf.sentry_dsn) {
Raven.config(conf.sentry_dsn).install();
}
const mozlog = require('./log.js');
const log = mozlog('send.server');
const STATIC_PATH = path.join(__dirname, '../dist/public');
const app = express();
function allLangs() {
return fs
.readdirSync(path.join(STATIC_PATH, 'locales'))
.map(function(f) {
return f.split('.')[0];
})
.join(',');
}
function prodLangs() {
return require('../package.json').availableLanguages.join(',');
}
const availableLanguages = conf.l10n_dev ? allLangs() : prodLangs();
// dev middleware is broken at the moment because of how webpack builds the
// handlebars templates. Leaving the commented code here as a mark of shame.
// if (conf.env === 'development') {
// const webpack = require('webpack');
// const webpackDevMiddleware = require('webpack-dev-middleware');
// const config = require('../webpack.config.js');
// config.devtool = 'inline-source-map';
// const compiler = webpack(config);
// const wdm = webpackDevMiddleware(compiler, {
// publicPath: config.output.publicPath
// });
// app.use(wdm);
// }
app.set('views', 'dist/views/');
app.engine(
'handlebars',
exphbs({
defaultLayout: 'main',
layoutsDir: 'dist/views/layouts',
helpers: {
availableLanguages,
baseUrl: conf.base_url,
title: 'Firefox Send',
description:
'Encrypt and send files with a link that automatically expires to ensure your important documents dont stay online forever.'
}
})
);
app.set('view engine', 'handlebars');
app.use(helmet());
app.use(
helmet.hsts({
maxAge: 31536000,
force: conf.env === 'production'
})
);
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
connectSrc: [
"'self'",
'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com'
],
imgSrc: ["'self'", 'https://www.google-analytics.com'],
scriptSrc: ["'self'"],
styleSrc: ["'self'", 'https://code.cdn.mozilla.net'],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
reportUri: '/__cspreport__'
}
})
);
app.use(
busboy({
limits: {
fileSize: conf.max_file_size
}
})
);
app.use(bodyParser.json());
app.use(
'/resources',
express.static(path.join(STATIC_PATH, 'resources'), {
setHeaders: function(res) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
})
);
app.use(express.static(STATIC_PATH));
app.get('/', (req, res) => {
res.render('index');
});
app.get('/unsupported/:reason', (req, res) => {
const outdated = req.params.reason === 'outdated';
res.render('unsupported', {
outdated,
fira: true
});
});
app.get('/legal', (req, res) => {
res.render('legal');
});
app.get('/jsconfig.js', (req, res) => {
res.set('Content-Type', 'application/javascript');
res.render('jsconfig', {
googleAnalyticsId: conf.analytics_id,
sentryId: conf.sentry_id,
version: version.version,
commit: version.commit,
maxFileSize: conf.max_file_size,
expireSeconds: conf.expire_seconds,
layout: false
});
});
app.get('/exists/:id', async (req, res) => {
const id = req.params.id;
if (!validateID(id)) {
res.sendStatus(404);
return;
}
try {
await storage.exists(id);
res.sendStatus(200);
} catch (e) {
res.sendStatus(404);
}
});
app.get('/download/:id', async (req, res) => {
const id = req.params.id;
if (!validateID(id)) {
res.status(404).render('notfound');
return;
}
try {
const efilename = await storage.filename(id);
const filename = decodeURIComponent(efilename);
const filenameJson = JSON.stringify({ filename });
const sizeInBytes = await storage.length(id);
const ttl = await storage.ttl(id);
res.render('download', {
filename,
filenameJson,
sizeInBytes,
ttl
});
} catch (e) {
res.status(404).render('notfound');
}
});
app.get('/assets/download/:id', async (req, res) => {
const id = req.params.id;
if (!validateID(id)) {
res.sendStatus(404);
return;
}
try {
const meta = await storage.metadata(id);
const contentLength = await storage.length(id);
res.writeHead(200, {
'Content-Disposition': `attachment; filename=${meta.filename}`,
'Content-Type': 'application/octet-stream',
'Content-Length': contentLength,
'X-File-Metadata': JSON.stringify(meta)
});
const file_stream = storage.get(id);
file_stream.on('end', async () => {
try {
await storage.forceDelete(id);
} catch (e) {
log.info('DeleteError:', id);
}
});
file_stream.pipe(res);
} catch (e) {
res.sendStatus(404);
}
});
app.post('/delete/:id', async (req, res) => {
const id = req.params.id;
if (!validateID(id)) {
res.sendStatus(404);
return;
}
const delete_token = req.body.delete_token;
if (!delete_token) {
res.sendStatus(404);
return;
}
try {
const err = await storage.delete(id, delete_token);
if (!err) {
res.sendStatus(200);
}
} catch (e) {
res.sendStatus(404);
}
});
app.post('/upload', (req, res, next) => {
const newId = crypto.randomBytes(5).toString('hex');
let meta;
try {
meta = JSON.parse(req.header('X-File-Metadata'));
} catch (e) {
res.sendStatus(400);
return;
}
if (
!meta.hasOwnProperty('id') ||
!meta.hasOwnProperty('filename') ||
!validateIV(meta.id)
) {
res.sendStatus(404);
return;
}
meta.delete = crypto.randomBytes(10).toString('hex');
req.pipe(req.busboy);
req.busboy.on(
'file',
async (fieldname, file, filename, encoding, mimeType) => {
try {
meta.mimeType = mimeType || 'application/octet-stream';
await storage.set(newId, file, filename, meta);
const protocol = conf.env === 'production' ? 'https' : req.protocol;
const url = `${protocol}://${req.get('host')}/download/${newId}/`;
res.json({
url,
delete: meta.delete,
id: newId
});
} catch (e) {
if (e.message === 'limit') {
return res.sendStatus(413);
}
res.sendStatus(500);
}
}
);
req.on('close', async err => {
try {
await storage.forceDelete(newId);
} catch (e) {
log.info('DeleteError:', newId);
}
});
});
app.get('/__lbheartbeat__', (req, res) => {
res.sendStatus(200);
});
app.get('/__heartbeat__', async (req, res) => {
try {
await storage.ping();
res.sendStatus(200);
} catch (e) {
res.sendStatus(500);
}
});
app.get('/__version__', (req, res) => {
res.sendFile(path.join(STATIC_PATH, 'version.json'));
});
const server = app.listen(conf.listen_port, () => {
log.info('startServer:', `Send app listening on port ${conf.listen_port}!`);
});
const validateID = route_id => {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
};
const validateIV = route_id => {
return route_id.match(/^[0-9a-fA-F]{24}$/) !== null;
};
module.exports = {
server: server,
storage: storage
};

20
server/state.js Normal file
View File

@ -0,0 +1,20 @@
const config = require('./config');
const layout = require('./layout');
const locales = require('../common/locales');
module.exports = function(req) {
const locale = req.language || 'en-US';
return {
locale,
translate: locales.getTranslator(locale),
title: 'Firefox Send',
description:
'Encrypt and send files with a link that automatically expires to ensure your important documents dont stay online forever.',
baseUrl: config.base_url,
ui: {},
storage: {
files: []
},
layout
};
};

View File

@ -1,17 +1,18 @@
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const conf = require('./config.js');
const config = require('./config');
const { tmpdir } = require('os');
const fs = require('fs');
const path = require('path');
const mozlog = require('./log.js');
const mozlog = require('./log');
const log = mozlog('send.storage');
const redis = require('redis');
const redis_client = redis.createClient({
host: conf.redis_host,
host: config.redis_host,
connect_timeout: 10000
});
@ -19,7 +20,9 @@ redis_client.on('error', err => {
log.error('Redis:', err);
});
if (conf.s3_bucket) {
let tempDir = null;
if (config.s3_bucket) {
module.exports = {
filename: filename,
exists: exists,
@ -36,6 +39,8 @@ if (conf.s3_bucket) {
metadata
};
} else {
tempDir = fs.mkdtempSync(`${tmpdir()}${path.sep}send-`);
log.info('tempDir', tempDir);
module.exports = {
filename: filename,
exists: exists,
@ -113,7 +118,7 @@ function setField(id, key, value) {
function localLength(id) {
return new Promise((resolve, reject) => {
try {
resolve(fs.statSync(path.join(__dirname, '../static', id)).size);
resolve(fs.statSync(path.join(tempDir, id)).size);
} catch (err) {
reject();
}
@ -121,12 +126,12 @@ function localLength(id) {
}
function localGet(id) {
return fs.createReadStream(path.join(__dirname, '../static', id));
return fs.createReadStream(path.join(tempDir, id));
}
function localSet(newId, file, filename, meta) {
return new Promise((resolve, reject) => {
const filepath = path.join(__dirname, '../static', newId);
const filepath = path.join(tempDir, newId);
const fstream = fs.createWriteStream(filepath);
file.pipe(fstream);
file.on('limit', () => {
@ -135,7 +140,7 @@ function localSet(newId, file, filename, meta) {
});
fstream.on('finish', () => {
redis_client.hmset(newId, meta);
redis_client.expire(newId, conf.expire_seconds);
redis_client.expire(newId, config.expire_seconds);
log.info('localSet:', 'Upload Finished of ' + newId);
resolve(meta.delete);
});
@ -156,7 +161,7 @@ function localDelete(id, delete_token) {
} else {
redis_client.del(id);
log.info('Deleted:', id);
resolve(fs.unlinkSync(path.join(__dirname, '../static', id)));
resolve(fs.unlinkSync(path.join(tempDir, id)));
}
});
});
@ -165,7 +170,7 @@ function localDelete(id, delete_token) {
function localForceDelete(id) {
return new Promise((resolve, reject) => {
redis_client.del(id);
resolve(fs.unlinkSync(path.join(__dirname, '../static', id)));
resolve(fs.unlinkSync(path.join(tempDir, id)));
});
}
@ -179,7 +184,7 @@ function localPing() {
function awsLength(id) {
const params = {
Bucket: conf.s3_bucket,
Bucket: config.s3_bucket,
Key: id
};
return new Promise((resolve, reject) => {
@ -195,7 +200,7 @@ function awsLength(id) {
function awsGet(id) {
const params = {
Bucket: conf.s3_bucket,
Bucket: config.s3_bucket,
Key: id
};
@ -208,7 +213,7 @@ function awsGet(id) {
function awsSet(newId, file, filename, meta) {
const params = {
Bucket: conf.s3_bucket,
Bucket: config.s3_bucket,
Key: newId,
Body: file
};
@ -221,7 +226,7 @@ function awsSet(newId, file, filename, meta) {
return upload.promise().then(
() => {
redis_client.hmset(newId, meta);
redis_client.expire(newId, conf.expire_seconds);
redis_client.expire(newId, config.expire_seconds);
},
err => {
if (hitLimit) {
@ -240,7 +245,7 @@ function awsDelete(id, delete_token) {
reject();
} else {
const params = {
Bucket: conf.s3_bucket,
Bucket: config.s3_bucket,
Key: id
};
@ -256,7 +261,7 @@ function awsDelete(id, delete_token) {
function awsForceDelete(id) {
return new Promise((resolve, reject) => {
const params = {
Bucket: conf.s3_bucket,
Bucket: config.s3_bucket,
Key: id
};
@ -269,6 +274,6 @@ function awsForceDelete(id) {
function awsPing() {
return localPing().then(() =>
s3.headBucket({ Bucket: conf.s3_bucket }).promise()
s3.headBucket({ Bucket: config.s3_bucket }).promise()
);
}

View File

@ -1 +0,0 @@
This is where downloaded files are stored.

View File

@ -12,11 +12,11 @@ window.Raven = {
};
window.FakeFile = FakeFile;
window.FileSender = require('../../frontend/src/fileSender');
window.FileReceiver = require('../../frontend/src/fileReceiver');
window.FileSender = require('../../app/fileSender');
window.FileReceiver = require('../../app/fileReceiver');
window.sinon = require('sinon');
window.server = window.sinon.fakeServer.create();
window.assert = require('assert');
const utils = require('../../frontend/src/utils');
const utils = require('../../app/utils');
window.hexToArray = utils.hexToArray;
window.arrayToHex = utils.arrayToHex;

View File

@ -46,11 +46,11 @@ const awsStub = {
const storage = proxyquire('../../server/storage', {
redis: redisStub,
fs: fsStub,
'./log.js': function() {
'./log': function() {
return logStub;
},
'aws-sdk': awsStub,
'./config.js': {
'./config': {
s3_bucket: 'test'
}
});

View File

@ -2,8 +2,6 @@ const assert = require('assert');
const sinon = require('sinon');
const proxyquire = require('proxyquire');
// const conf = require('../server/config.js');
const redisStub = {};
const exists = sinon.stub();
const hget = sinon.stub();
@ -35,7 +33,7 @@ logStub.error = sinon.stub();
const storage = proxyquire('../../server/storage', {
redis: redisStub,
fs: fsStub,
'./log.js': function() {
'./log': function() {
return logStub;
}
});

View File

@ -1,28 +0,0 @@
{{!-- This file should be es5 only --}}
var isIE = /trident\/7\.|msie/i.test(navigator.userAgent);
var isUnsupportedPage = /\/unsupported/.test(location.pathname);
if (isIE && !isUnsupportedPage) {
window.location.replace('/unsupported/ie');
}
{{#if sentryId}}
var RAVEN_CONFIG = {
release: '{{{version}}}',
tags: {
commit: '{{{commit}}}'
},
dataCallback: function (data) {
var hash = window.location.hash;
if (hash) {
return JSON.parse(JSON.stringify(data).replace(new RegExp(hash.slice(1), 'g'), ''));
}
return data;
}
}
var SENTRY_ID = '{{{sentryId}}}';
{{/if}}
{{#if googleAnalyticsId}}
var GOOGLE_ANALYTICS_ID = '{{{googleAnalyticsId}}}';
{{/if}}
var MAXFILESIZE = {{{maxFileSize}}};
var EXPIRE_SECONDS = {{{expireSeconds}}};

View File

@ -1,17 +1,16 @@
const path = require('path');
const webpack = require('webpack');
const HtmlPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
module.exports = {
entry: {
vendor: ['babel-polyfill', 'raven-js'],
upload: ['./frontend/src/upload.js'],
download: ['./frontend/src/download.js']
vendor: ['babel-polyfill', 'raven-js', 'fluent', 'choo'],
app: ['./app/main.js']
},
output: {
filename: 'resources/[name].[chunkhash].js',
path: path.resolve(__dirname, 'dist/public'),
filename: '[name].[chunkhash:8].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/'
},
module: {
@ -20,19 +19,48 @@ module.exports = {
test: /\.js$/,
loader: 'babel-loader',
include: [
path.resolve(__dirname, 'frontend'),
path.resolve(__dirname, 'app'),
path.resolve(__dirname, 'common'),
path.resolve(__dirname, 'node_modules/testpilot-ga/src')
],
options: {
babelrc: false,
presets: [['es2015', { modules: false }], 'stage-2']
presets: [['env', { modules: false }], 'stage-2'],
plugins: ['yo-yoify']
}
},
{
test: /\.js$/,
include: [path.dirname(require.resolve('fluent'))],
use: [
{
loader: 'expose-loader',
options: 'fluent'
},
{
loader: 'babel-loader',
options: {
presets: [['env', { modules: false }]]
}
}
]
},
{
test: require.resolve('./assets/cryptofill'),
use: [
{
loader: 'file-loader',
options: {
name: '[name].[hash:8].[ext]'
}
}
]
},
{
test: /\.(svg|png|jpg)$/,
loader: 'file-loader',
options: {
name: 'resources/[name].[hash].[ext]'
name: '[name].[hash:8].[ext]'
}
},
{
@ -41,7 +69,7 @@ module.exports = {
{
loader: 'file-loader',
options: {
name: 'resources/[name].[hash].[ext]'
name: '[name].[hash:8].[ext]'
}
},
'extract-loader',
@ -50,75 +78,64 @@ module.exports = {
]
},
{
test: /\.hbs$/,
test: require.resolve('./package.json'),
use: [
{
loader: 'html-loader',
loader: 'file-loader',
options: {
interpolate: 'require',
minimize: false
name: 'version.json'
}
}
},
'extract-loader',
'./build/package_json_loader'
]
},
{
test: /\.ftl$/,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[hash:8].js'
}
},
'extract-loader',
'./build/fluent_loader'
]
},
{
test: require.resolve('./build/generate_asset_map.js'),
use: ['babel-loader', 'val-loader']
},
{
test: require.resolve('./build/generate_l10n_map.js'),
use: ['babel-loader', 'val-loader']
}
]
},
plugins: [
new CopyPlugin([
{
context: 'public',
from: 'locales/**/*.ftl'
},
{
context: 'public',
from: '*.*'
},
{
from: 'views/**',
to: '../'
},
{
context: 'node_modules/l20n/dist/web',
from: 'l20n.min.js'
}
]),
new HtmlPlugin({
filename: '../views/index.handlebars',
template: 'webpack/upload.hbs',
chunks: ['upload']
}),
new HtmlPlugin({
filename: '../views/download.handlebars',
template: 'webpack/download.hbs',
chunks: ['download']
}),
new HtmlPlugin({
filename: '../views/legal.handlebars',
template: 'webpack/legal.hbs',
inject: false
}),
new HtmlPlugin({
filename: '../views/notfound.handlebars',
template: 'webpack/notfound.hbs',
inject: false
}),
new HtmlPlugin({
filename: '../views/layouts/main.handlebars',
template: 'webpack/layout.hbs',
inject: 'head',
excludeChunks: ['upload', 'download']
}),
new HtmlPlugin({
filename: '../views/unsupported.handlebars',
template: 'webpack/unsupported.hbs',
inject: false
}),
new webpack.IgnorePlugin(/dist/),
new webpack.IgnorePlugin(/require-from-string/),
new webpack.HashedModuleIdsPlugin(),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'runtime'
})
]
}),
new ManifestPlugin()
],
devServer: {
compress: true,
setup:
process.env.NODE_ENV === 'development'
? require('./server/dev')
: undefined
}
};

View File

@ -1,43 +0,0 @@
<div id="download">
<div id="download-page-one">
<div class="title">
<span id="dl-file"
data-filename="{{filename}}"
data-size="{{sizeInBytes}}"
data-ttl="{{ttl}}"
data-l10n-id="downloadFileName"
data-l10n-args='{{filenameJson}}'></span>
<span id="dl-filesize"></span>
</div>
<div class="description" data-l10n-id="downloadMessage"></div>
<img src="../public/resources/illustration_download.svg" id="download-img" data-l10n-id="downloadAltText"/>
<div>
<button id="download-btn" class="btn" data-l10n-id="downloadButtonLabel"></button>
</div>
</div>
<div id="download-progress" hidden="true">
<div id="dl-title" class="title"></div>
<div class="description" data-l10n-id="downloadingPageMessage"></div>
<div class="progress-bar">
<svg id="progress" width="166" height="166" viewPort="0 0 166 166" version="1.1">
<circle r="73" cx="83" cy="83" fill="transparent"/>
<circle id="bar" r="73" cx="83" cy="83" fill="transparent" transform="rotate(-90 83 83)" stroke-dasharray="458.67" stroke-dashoffset="458.67"/>
</svg>
<div class="percentage">
<span class="percent-number"></span>
<span class="percent-sign">%</span>
</div>
</div>
<div class="upload">
<div class="progress-text">{{filename}}</div>
</div>
</div>
<div id="upload-error" hidden="true">
<div class="title" data-l10n-id="errorPageHeader"></div>
<img id="upload-error-img" data-l10n-id="errorAltText" src="../public/resources/illustration_error.svg"/>
</div>
<a class="send-new" data-state="completed" data-l10n-id="sendYourFilesLink" href="/"></a>
</div>

View File

@ -1,69 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="{{availableLanguages}}">
<meta property="og:title" content="{{title}}"/>
<meta name="twitter:title" content="{{title}}"/>
<meta name="description" content="{{description}}"/>
<meta property="og:description" content="{{description}}"/>
<meta name="twitter:description" content="{{description}}"/>
<meta name="twitter:card" content="summary"/>
<meta property="og:image" content="{{baseUrl}}${require('../public/resources/send-fb.jpg')}"/>
<meta name="twitter:image" content="{{baseUrl}}${require('../public/resources/send-twitter.jpg')}"/>
<meta property="og:url" content="{{baseUrl}}"/>
<title>{{title}}</title>
<link rel="stylesheet" type="text/css" href="${require('../frontend/src/main.css')}" />
{{#if fira}}
<link rel="stylesheet" type="text/css" href="https://code.cdn.mozilla.net/fonts/fira.css" />
{{/if}}
<link rel="icon" type="image/png" href="${require('../public/resources/favicon-32x32.png')}" sizes="32x32" />
<link rel="localization" href="/locales/{locale}/send.ftl">
<script src="/jsconfig.js"></script>
<script defer src="/l20n.min.js"></script>
</head>
<body>
<header class="header">
<div class="send-logo">
<a href="/">
<img src="../public/resources/send_logo.svg" alt="Send"/><h1 class="site-title">Send</h1>
</a>
<div class="site-subtitle">
<a href="https://testpilot.firefox.com">Firefox Test Pilot</a>
<div data-l10n-id="siteSubtitle">web experiment</div>
</div>
</div>
<a href="https://qsurvey.mozilla.com/s3/txp-firefox-send" rel="noreferrer noopener" class="feedback" target="_blank" data-l10n-id="siteFeedback">Feedback</a>
</header>
<div class="all">
<noscript>
<h2>Firefox Send requires JavaScript</h2>
<p><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">Why does Firefox Send require JavaScript?</a></p>
<p>Please enable JavaScript and try again.</p>
</noscript>
{{{body}}}
</div>
<div class="footer">
<div class="legal-links">
<a href="https://www.mozilla.org" role="presentation"><img class="mozilla-logo" src="../public/resources/mozilla-logo.svg" alt="mozilla"/></a>
<a href="https://www.mozilla.org/about/legal" data-l10n-id="footerLinkLegal">Legal</a>
<a href="https://testpilot.firefox.com/about" data-l10n-id="footerLinkAbout">About Test Pilot</a>
<a href="/legal" data-l10n-id="footerLinkPrivacy">Privacy</a>
<a href="/legal" data-l10n-id="footerLinkTerms">Terms</a>
<a href="https://www.mozilla.org/privacy/websites/#cookies" data-l10n-id="footerLinkCookies">Cookies</a>
</div>
<div class="social-links">
<a href="https://github.com/mozilla/send" role="presentation"><img class="github" src="../public/resources/github-icon.svg" alt="github"/></a>
<a href="https://twitter.com/FxTestPilot" role="presentation"><img class="twitter" src="../public/resources/twitter-icon.svg" alt="twitter"/></a>
</div>
</div>
</body>
</html>

View File

@ -1,12 +0,0 @@
<div id="legal">
<div class="title" data-l10n-id="legalHeader"></div>
<div class="description" data-l10n-id="legalNoticeTestPilot">
<a href="https://testpilot.firefox.com/terms"></a>
<a href="https://testpilot.firefox.com/privacy"></a>
<a href="https://testpilot.firefox.com/experiments/send"></a>
</div>
<div class="description" data-l10n-id="legalNoticeMozilla">
<a href="https://www.mozilla.org/privacy/websites/"></a>
<a href="https://www.mozilla.org/about/legal/terms/mozilla/"></a>
</div>
</div>

View File

@ -1,8 +0,0 @@
<div id="download">
<div class="title" data-l10n-id="expiredPageHeader"></div>
<div class="share-window">
<img src="../public/resources/illustration_expired.svg" id="expired-img" data-l10n-id="linkExpiredAlt"/>
</div>
<div class="expired-description" data-l10n-id="uploadPageExplainer"></div>
<a class="send-new" href="/" data-state="notfound" data-l10n-id="sendYourFilesLink"></a>
</div>

View File

@ -1,20 +0,0 @@
<div id="unsupported-browser">
<div class="title" data-l10n-id="notSupportedHeader">Your browser is not supported.</div>
{{#if outdated}}
<div class="description" data-l10n-id="notSupportedOutdatedDetail">Unfortunately this version of Firefox does not support the web technology that powers Firefox Send. Youll need to update your browser.</div>
<a id="update-firefox" href="https://support.mozilla.org/kb/update-firefox-latest-version">
<img src="../public/resources/firefox_logo-only.svg" class="firefox-logo" alt="Firefox"/>
<div class="unsupported-button-text" data-l10n-id="updateFirefox">Update Firefox</div>
</a>
{{else}}
<div class="description" data-l10n-id="notSupportedDetail">Unfortunately this browser does not support the web technology that powers Firefox Send. Youll need to try another browser. We recommend Firefox!</div>
<div class="description"><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported" data-l10n-id="notSupportedLink">Why is my browser not supported?</a></div>
<a id="dl-firefox" href="https://www.mozilla.org/firefox/new/?scene=2">
<img src="../public/resources/firefox_logo-only.svg" class="firefox-logo" alt="Firefox"/>
<div class="unsupported-button-text">Firefox<br>
<span data-l10n-id="downloadFirefoxButtonSub">Free Download</span>
</div>
</a>
{{/if}}
<div class="unsupported-description" data-l10n-id="uploadPageExplainer">Send files through a safe, private, and encrypted link that automatically expires to ensure your stuff does not remain online forever.</div>
</div>

View File

@ -1,75 +0,0 @@
<div id="page-one" hidden>
<div class="title" data-l10n-id="uploadPageHeader"></div>
<div class="description">
<div data-l10n-id="uploadPageExplainer"></div>
<a href="https://testpilot.firefox.com/experiments/send" class="link" data-l10n-id="uploadPageLearnMore"></a>
</div>
<div class="upload-window" >
<div id="upload-img"><img data-l10n-id="uploadSvgAlt" src="../public/resources/upload.svg"/></div>
<div id="upload-text" data-l10n-id="uploadPageDropMessage"></div>
<span id="file-size-msg"><em data-l10n-id="uploadPageSizeMessage"></em></span>
<form method="post" action="upload" enctype="multipart/form-data">
<label for="file-upload" id="browse"
data-l10n-id="uploadPageBrowseButton1" class="btn"></label>
<input id="file-upload" type="file" name="fileUploaded" />
</form>
</div>
<div id="file-list">
<table id="uploaded-files">
<thead>
<tr>
<!-- htmllint attr-bans="false" -->
<th id="uploaded-file" data-l10n-id="uploadedFile"></th>
<th id="copy-file-list" data-l10n-id="copyFileList"></th>
<th id="expiry-file-list" data-l10n-id="expiryFileList"></th>
<th id="delete-file-list" data-l10n-id="deleteFileList"></th>
<!-- htmllint tag-bans="$previous" -->
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<div id="upload-progress" hidden="true">
<div class="title" id="upload-filename"></div>
<div class="description"></div>
<div class="progress-bar">
<svg id="progress" width="166" height="166" viewPort="0 0 166 166" version="1.1">
<circle r="73" cx="83" cy="83" fill="transparent"/>
<circle id="bar" r="73" cx="83" cy="83" fill="transparent" transform="rotate(-90 83 83)" stroke-dasharray="458.67" stroke-dashoffset="458.67"/>
</svg>
<div class="percentage">
<span class="percent-number">0</span>
<span class="percent-sign">%</span>
</div>
</div>
<div class="upload">
<div class="progress-text"></div>
<div id="cancel-upload"
data-l10n-id="uploadingPageCancel"></div>
</div>
</div>
<div id="share-link" hidden="true">
<div class="title" data-l10n-id="uploadSuccessTimingHeader"></div>
<div id="share-window">
<div id="copy-text"></div>
<div id="copy">
<input id="link" type="url" value="" readonly/>
<button id="copy-btn" class="btn" data-l10n-id="copyUrlFormButton"></button>
</div>
<button id="delete-file" class="btn" data-l10n-id="deleteFileButton"></button>
<a class="send-new" data-state="completed" data-l10n-id="sendAnotherFileLink" href="/"></a>
</div>
</div>
<div id="upload-error" hidden="true">
<div class="title" data-l10n-id="errorPageHeader"></div>
<div class="expired-description" data-l10n-id="errorPageMessage"></div>
<img id="upload-error-img" data-l10n-id="errorAltText" src="../public/resources/illustration_error.svg"/>
<a class="send-new" href="/" data-state="errored" data-l10n-id="sendAnotherFileLink"></a>
</div>