Merge pull request #737 from mozilla/refactor

big refactor
This commit is contained in:
Danny Coates 2018-01-30 09:52:22 -08:00 committed by GitHub
commit 6b7b142961
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1095 additions and 943 deletions

206
app/api.js Normal file
View File

@ -0,0 +1,206 @@
import { arrayToB64, b64ToArray } from './utils';
function post(obj) {
return {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify(obj)
};
}
function parseNonce(header) {
header = header || '';
return header.split(' ')[1];
}
async function fetchWithAuth(url, params, keychain) {
const result = {};
params = params || {};
const h = await keychain.authHeader();
params.headers = new Headers({ Authorization: h });
const response = await fetch(url, params);
result.response = response;
result.ok = response.ok;
const nonce = parseNonce(response.headers.get('WWW-Authenticate'));
result.shouldRetry = response.status === 401 && nonce !== keychain.nonce;
keychain.nonce = nonce;
return result;
}
async function fetchWithAuthAndRetry(url, params, keychain) {
const result = await fetchWithAuth(url, params, keychain);
if (result.shouldRetry) {
return fetchWithAuth(url, params, keychain);
}
return result;
}
export async function del(id, owner_token) {
const response = await fetch(`/api/delete/${id}`, post({ owner_token }));
return response.ok;
}
export async function setParams(id, owner_token, params) {
const response = await fetch(
`/api/params/${id}`,
post({
owner_token,
dlimit: params.dlimit
})
);
return response.ok;
}
export async function metadata(id, keychain) {
const result = await fetchWithAuthAndRetry(
`/api/metadata/${id}`,
{ method: 'GET' },
keychain
);
if (result.ok) {
const data = await result.response.json();
const meta = await keychain.decryptMetadata(b64ToArray(data.metadata));
return {
dtotal: data.dtotal,
dlimit: data.dlimit,
size: data.size,
ttl: data.ttl,
iv: meta.iv,
name: meta.name,
type: meta.type
};
}
throw new Error(result.response.status);
}
export async function setPassword(id, owner_token, keychain) {
const auth = await keychain.authKeyB64();
const response = await fetch(
`/api/password/${id}`,
post({ owner_token, auth })
);
return response.ok;
}
export function uploadFile(encrypted, metadata, verifierB64, keychain) {
const xhr = new XMLHttpRequest();
const upload = {
onprogress: function() {},
cancel: function() {
xhr.abort();
},
result: new Promise(function(resolve, reject) {
xhr.addEventListener('loadend', function() {
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
if (authHeader) {
keychain.nonce = parseNonce(authHeader);
}
if (xhr.status === 200) {
const responseObj = JSON.parse(xhr.responseText);
return resolve({
url: responseObj.url,
id: responseObj.id,
ownerToken: responseObj.owner
});
}
reject(new Error(xhr.status));
});
})
};
const dataView = new DataView(encrypted);
const blob = new Blob([dataView], { type: 'application/octet-stream' });
const fd = new FormData();
fd.append('data', blob);
xhr.upload.addEventListener('progress', function(event) {
if (event.lengthComputable) {
upload.onprogress([event.loaded, event.total]);
}
});
xhr.open('post', '/api/upload', true);
xhr.setRequestHeader('X-File-Metadata', arrayToB64(new Uint8Array(metadata)));
xhr.setRequestHeader('Authorization', `send-v1 ${verifierB64}`);
xhr.send(fd);
return upload;
}
function download(id, keychain) {
const xhr = new XMLHttpRequest();
const download = {
onprogress: function() {},
cancel: function() {
xhr.abort();
},
result: new Promise(async function(resolve, reject) {
xhr.addEventListener('loadend', function() {
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
if (authHeader) {
keychain.nonce = parseNonce(authHeader);
}
if (xhr.status === 404) {
return reject(new Error('notfound'));
}
if (xhr.status !== 200) {
return reject(new Error(xhr.status));
}
const blob = new Blob([xhr.response]);
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(blob);
fileReader.onload = function() {
resolve(this.result);
};
});
xhr.addEventListener('progress', function(event) {
if (event.lengthComputable && event.target.status === 200) {
download.onprogress([event.loaded, event.total]);
}
});
const auth = await keychain.authHeader();
xhr.open('get', `/api/download/${id}`);
xhr.setRequestHeader('Authorization', auth);
xhr.responseType = 'blob';
xhr.send();
})
};
return download;
}
async function tryDownload(id, keychain, onprogress, tries = 1) {
const dl = download(id, keychain);
dl.onprogress = onprogress;
try {
const result = await dl.result;
return result;
} catch (e) {
if (e.message === '401' && --tries > 0) {
return tryDownload(id, keychain, onprogress, tries);
}
throw e;
}
}
export function downloadFile(id, keychain) {
let cancelled = false;
function updateProgress(p) {
if (cancelled) {
// This is a bit of a hack
// We piggyback off of the progress event as a chance to cancel.
// Otherwise wiring the xhr abort up while allowing retries
// gets pretty nasty.
// 'this' here is the object returned by download(id, keychain)
return this.cancel();
}
dl.onprogress(p);
}
const dl = {
onprogress: function() {},
cancel: function() {
cancelled = true;
},
result: tryDownload(id, keychain, updateProgress, 2)
};
return dl;
}

View File

@ -1,51 +1,15 @@
/* global EXPIRE_SECONDS */
import FileSender from './fileSender';
import FileReceiver from './fileReceiver';
import { copyToClipboard, delay, fadeOut, percent } from './utils';
import {
copyToClipboard,
delay,
fadeOut,
openLinksInNewTab,
percent,
saveFile
} 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;
}
async function getDLCounts(file) {
const url = `/api/metadata/${file.id}`;
const receiver = new FileReceiver(url, file);
try {
await receiver.getMetadata(file.nonce);
return receiver.file;
} catch (e) {
if (e.message === '404') return false;
}
}
export default function(state, emitter) {
let lastRender = 0;
let updateTitle = false;
@ -60,14 +24,11 @@ export default function(state, emitter) {
for (const file of files) {
const oldLimit = file.dlimit;
const oldTotal = file.dtotal;
const receivedFile = await getDLCounts(file);
if (!receivedFile) {
await file.updateDownloadCount();
if (file.dtotal === file.dlimit) {
state.storage.remove(file.id);
rerender = true;
} else if (
oldLimit !== receivedFile.dlimit ||
oldTotal !== receivedFile.dtotal
) {
} else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) {
rerender = true;
}
}
@ -92,16 +53,15 @@ export default function(state, emitter) {
checkFiles();
});
emitter.on('navigate', checkFiles);
// emitter.on('navigate', checkFiles);
emitter.on('render', () => {
lastRender = Date.now();
});
emitter.on('changeLimit', async ({ file, value }) => {
await FileSender.changeLimit(file.id, file.ownerToken, value);
file.dlimit = value;
state.storage.writeFiles();
await file.changeLimit(value);
state.storage.writeFile(file);
metrics.changedDownloadLimit(file);
});
@ -116,11 +76,10 @@ export default function(state, emitter) {
location
});
state.storage.remove(file.id);
await FileSender.delete(file.id, file.ownerToken);
await file.del();
} catch (e) {
state.raven.captureException(e);
}
state.fileInfo = null;
});
emitter.on('cancel', () => {
@ -134,32 +93,24 @@ export default function(state, emitter) {
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 });
const ownedFile = await sender.upload(state.storage);
state.storage.totalUploads += 1;
metrics.completedUpload(ownedFile);
state.storage.addFile(ownedFile);
document.getElementById('cancel-upload').hidden = 'hidden';
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}`);
emitter.emit('pushState', `/share/${ownedFile.id}`);
} catch (err) {
console.error(err);
state.transfer = null;
@ -174,31 +125,29 @@ export default function(state, emitter) {
}
});
emitter.on('password', async ({ existingPassword, password, file }) => {
emitter.on('password', async ({ password, file }) => {
try {
await FileSender.setPassword(existingPassword, password, file);
await file.setPassword(password);
state.storage.writeFile(file);
metrics.addedPassword({ size: file.size });
file.password = password;
state.storage.writeFiles();
} catch (e) {
console.error(e);
} catch (err) {
console.error(err);
}
render();
});
emitter.on('preview', async () => {
emitter.on('getMetadata', async () => {
const file = state.fileInfo;
const url = `/api/download/${file.id}`;
const receiver = new FileReceiver(url, file);
const receiver = new FileReceiver(file);
try {
await receiver.getMetadata();
receiver.on('progress', updateProgress);
receiver.on('decrypting', render);
state.transfer = receiver;
try {
await receiver.getMetadata(file.nonce);
} catch (e) {
if (e.message === '401') {
file.password = null;
if (!file.pwd) {
if (!file.requiresPassword) {
return emitter.emit('pushState', '/404');
}
}
@ -214,7 +163,7 @@ export default function(state, emitter) {
try {
const start = Date.now();
metrics.startedDownload({ size: file.size, ttl: file.ttl });
const f = await state.transfer.download(file.nonce);
const f = await state.transfer.download();
const time = Date.now() - start;
const speed = size / (time / 1000);
await delay(1000);
@ -225,8 +174,11 @@ export default function(state, emitter) {
metrics.completedDownload({ size, time, speed });
emitter.emit('pushState', '/completed');
} catch (err) {
if (err.message === '0') {
// download cancelled
return render();
}
console.error(err);
// TODO cancelled download
const location = err.message === 'notfound' ? '/404' : '/error';
if (location === '/error') {
state.raven.captureException(err);
@ -244,6 +196,14 @@ export default function(state, emitter) {
metrics.copiedLink({ location });
});
setInterval(() => {
// poll for updates of the download counts
// TODO something for the share page: || state.route === '/share/:id'
if (state.route === '/') {
checkFiles();
}
}, 2 * 60 * 1000);
setInterval(() => {
// poll for rerendering the file list countdown timers
if (

View File

@ -1,104 +1,21 @@
import Nanobus from 'nanobus';
import { arrayToB64, b64ToArray, bytes } from './utils';
import Keychain from './keychain';
import { bytes } from './utils';
import { metadata, downloadFile } from './api';
export default class FileReceiver extends Nanobus {
constructor(url, file) {
constructor(fileInfo) {
super('FileReceiver');
this.secretKeyPromise = window.crypto.subtle.importKey(
'raw',
b64ToArray(file.secretKey),
'HKDF',
false,
['deriveKey']
);
this.encryptKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('encryption'),
hash: 'SHA-256'
},
sk,
{
name: 'AES-GCM',
length: 128
},
false,
['decrypt']
);
});
if (file.pwd) {
const encoder = new TextEncoder();
this.authKeyPromise = window.crypto.subtle
.importKey(
'raw',
encoder.encode(file.password),
{ name: 'PBKDF2' },
false,
['deriveKey']
)
.then(pwdKey =>
window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(file.url),
iterations: 100,
hash: 'SHA-256'
},
pwdKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
)
);
} else {
this.authKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
sk,
{
name: 'HMAC',
hash: { name: 'SHA-256' }
},
false,
['sign']
);
});
this.keychain = new Keychain(fileInfo.secretKey, fileInfo.nonce);
if (fileInfo.requiresPassword) {
this.keychain.setPassword(fileInfo.password, fileInfo.url);
}
this.metaKeyPromise = this.secretKeyPromise.then(sk => {
const encoder = new TextEncoder();
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('metadata'),
hash: 'SHA-256'
},
sk,
{
name: 'AES-GCM',
length: 128
},
false,
['decrypt']
);
});
this.file = file;
this.url = url;
this.fileInfo = fileInfo;
this.fileDownload = null;
this.msg = 'fileSizeProgress';
this.state = 'initialized';
this.progress = [0, 1];
this.cancelled = false;
}
get progressRatio() {
@ -113,160 +30,51 @@ export default class FileReceiver extends Nanobus {
}
cancel() {
// TODO
this.cancelled = true;
if (this.fileDownload) {
this.fileDownload.cancel();
}
}
async fetchMetadata(nonce) {
const authHeader = await this.getAuthHeader(nonce);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 404) {
return reject(new Error(xhr.status));
}
const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
this.file.nonce = nonce;
if (xhr.status === 200) {
return resolve(xhr.response);
}
const err = new Error(xhr.status);
err.nonce = nonce;
reject(err);
}
};
xhr.onerror = () => reject(new Error(0));
xhr.ontimeout = () => reject(new Error(0));
xhr.open('get', `/api/metadata/${this.file.id}`);
xhr.setRequestHeader('Authorization', authHeader);
xhr.responseType = 'json';
xhr.timeout = 2000;
xhr.send();
});
}
async getMetadata(nonce) {
let data = null;
try {
try {
data = await this.fetchMetadata(nonce);
} catch (e) {
if (e.message === '401' && nonce !== e.nonce) {
// allow one retry for changed nonce
data = await this.fetchMetadata(e.nonce);
} else {
throw e;
}
}
const metaKey = await this.metaKeyPromise;
const json = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(12),
tagLength: 128
},
metaKey,
b64ToArray(data.metadata)
);
const decoder = new TextDecoder();
const meta = JSON.parse(decoder.decode(json));
this.file.name = meta.name;
this.file.type = meta.type;
this.file.iv = meta.iv;
this.file.size = data.size;
this.file.ttl = data.ttl;
this.file.dlimit = data.dlimit;
this.file.dtotal = data.dtotal;
async getMetadata() {
const meta = await metadata(this.fileInfo.id, this.keychain);
if (meta) {
this.keychain.setIV(meta.iv);
this.fileInfo.name = meta.name;
this.fileInfo.type = meta.type;
this.fileInfo.iv = meta.iv;
this.fileInfo.size = meta.size;
this.state = 'ready';
} catch (e) {
this.state = 'invalid';
throw e;
}
}
async downloadFile(nonce) {
const authHeader = await this.getAuthHeader(nonce);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onprogress = event => {
if (event.lengthComputable && event.target.status !== 404) {
this.progress = [event.loaded, event.total];
this.emit('progress', this.progress);
}
};
xhr.onload = event => {
if (xhr.status === 404) {
reject(new Error('notfound'));
return;
}
if (xhr.status !== 200) {
const err = new Error(xhr.status);
err.nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
return reject(err);
this.state = 'invalid';
return;
}
const blob = new Blob([xhr.response]);
const fileReader = new FileReader();
fileReader.onload = function() {
resolve(this.result);
};
fileReader.readAsArrayBuffer(blob);
};
xhr.open('get', this.url);
xhr.setRequestHeader('Authorization', authHeader);
xhr.responseType = 'blob';
xhr.send();
});
}
async getAuthHeader(nonce) {
const authKey = await this.authKeyPromise;
const sig = await window.crypto.subtle.sign(
{
name: 'HMAC'
},
authKey,
b64ToArray(nonce)
);
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
}
async download(nonce) {
async download() {
this.state = 'downloading';
this.emit('progress', this.progress);
try {
const encryptKey = await this.encryptKeyPromise;
let ciphertext = null;
try {
ciphertext = await this.downloadFile(nonce);
} catch (e) {
if (e.message === '401' && nonce !== e.nonce) {
ciphertext = await this.downloadFile(e.nonce);
} else {
throw e;
}
}
const download = await downloadFile(this.fileInfo.id, this.keychain);
download.onprogress = p => {
this.progress = p;
this.emit('progress', p);
};
this.fileDownload = download;
const ciphertext = await download.result;
this.fileDownload = null;
this.msg = 'decryptingFile';
this.emit('decrypting');
const plaintext = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: b64ToArray(this.file.iv),
tagLength: 128
},
encryptKey,
ciphertext
);
const plaintext = await this.keychain.decryptFile(ciphertext);
if (this.cancelled) {
throw new Error(0);
}
this.msg = 'downloadFinish';
this.state = 'complete';
return {
plaintext,
name: decodeURIComponent(this.file.name),
type: this.file.type
name: decodeURIComponent(this.fileInfo.name),
type: this.fileInfo.type
};
} catch (e) {
this.state = 'invalid';

View File

@ -1,97 +1,19 @@
/* global EXPIRE_SECONDS */
import Nanobus from 'nanobus';
import { arrayToB64, b64ToArray, bytes } from './utils';
async function getAuthHeader(authKey, nonce) {
const sig = await window.crypto.subtle.sign(
{
name: 'HMAC'
},
authKey,
b64ToArray(nonce)
);
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
}
async function sendPassword(file, authKey, rawAuth) {
const authHeader = await getAuthHeader(authKey, file.nonce);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
file.nonce = nonce;
return resolve(xhr.response);
}
reject(new Error(xhr.status));
}
};
xhr.onerror = () => reject(new Error(0));
xhr.ontimeout = () => reject(new Error(0));
xhr.open('post', `/api/password/${file.id}`);
xhr.setRequestHeader('Authorization', authHeader);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'json';
xhr.timeout = 2000;
xhr.send(JSON.stringify({ auth: arrayToB64(new Uint8Array(rawAuth)) }));
});
}
import OwnedFile from './ownedFile';
import Keychain from './keychain';
import { arrayToB64, bytes } from './utils';
import { uploadFile } from './api';
export default class FileSender extends Nanobus {
constructor(file) {
super('FileSender');
this.file = file;
this.uploadRequest = null;
this.msg = 'importingFile';
this.progress = [0, 1];
this.cancelled = false;
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
this.uploadXHR = new XMLHttpRequest();
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
this.secretKey = window.crypto.subtle.importKey(
'raw',
this.rawSecret,
'HKDF',
false,
['deriveKey']
);
}
static delete(id, token) {
return new Promise((resolve, reject) => {
if (!id || !token) {
return reject();
}
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/delete/${id}`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
resolve();
}
};
xhr.send(JSON.stringify({ owner_token: token }));
});
}
static changeLimit(id, owner_token, dlimit) {
return new Promise((resolve, reject) => {
if (!id || !owner_token) {
return reject();
}
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/params/${id}`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
resolve();
}
};
xhr.send(JSON.stringify({ owner_token, dlimit }));
});
this.keychain = new Keychain();
}
get progressRatio() {
@ -107,8 +29,8 @@ export default class FileSender extends Nanobus {
cancel() {
this.cancelled = true;
if (this.msg === 'fileSizeProgress') {
this.uploadXHR.abort();
if (this.uploadRequest) {
this.uploadRequest.cancel();
}
}
@ -116,6 +38,7 @@ export default class FileSender extends Nanobus {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsArrayBuffer(this.file);
// TODO: progress?
reader.onload = function(event) {
const plaintext = new Uint8Array(this.result);
resolve(plaintext);
@ -126,218 +49,60 @@ export default class FileSender extends Nanobus {
});
}
uploadFile(encrypted, metadata, rawAuth) {
return new Promise((resolve, reject) => {
const dataView = new DataView(encrypted);
const blob = new Blob([dataView], { type: 'application/octet-stream' });
const fd = new FormData();
fd.append('data', blob);
const xhr = this.uploadXHR;
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
this.progress = [e.loaded, e.total];
this.emit('progress', this.progress);
}
});
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
const nonce = xhr
.getResponseHeader('WWW-Authenticate')
.split(' ')[1];
this.progress = [1, 1];
this.msg = 'notifyUploadDone';
const responseObj = JSON.parse(xhr.responseText);
return resolve({
url: responseObj.url,
id: responseObj.id,
secretKey: arrayToB64(this.rawSecret),
ownerToken: responseObj.owner,
nonce
});
}
this.msg = 'errorPageHeader';
reject(new Error(xhr.status));
}
};
xhr.open('post', '/api/upload', true);
xhr.setRequestHeader(
'X-File-Metadata',
arrayToB64(new Uint8Array(metadata))
);
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
xhr.send(fd);
this.msg = 'fileSizeProgress';
});
}
async upload() {
const encoder = new TextEncoder();
const secretKey = await this.secretKey;
const encryptKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('encryption'),
hash: 'SHA-256'
},
secretKey,
{
name: 'AES-GCM',
length: 128
},
false,
['encrypt']
);
const authKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
secretKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const metaKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('metadata'),
hash: 'SHA-256'
},
secretKey,
{
name: 'AES-GCM',
length: 128
},
false,
['encrypt']
);
async upload(storage) {
const start = Date.now();
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(
{
name: 'AES-GCM',
iv: this.iv,
tagLength: 128
},
encryptKey,
plaintext
);
const metadata = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(12),
tagLength: 128
},
metaKey,
encoder.encode(
JSON.stringify({
iv: arrayToB64(this.iv),
name: this.file.name,
type: this.file.type || 'application/octet-stream'
})
)
);
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
const encrypted = await this.keychain.encryptFile(plaintext);
const metadata = await this.keychain.encryptMetadata(this.file);
const authKeyB64 = await this.keychain.authKeyB64();
if (this.cancelled) {
throw new Error(0);
}
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
}
static async setPassword(existingPassword, password, file) {
const encoder = new TextEncoder();
const secretKey = await window.crypto.subtle.importKey(
'raw',
b64ToArray(file.secretKey),
'HKDF',
false,
['deriveKey']
this.uploadRequest = uploadFile(
encrypted,
metadata,
authKeyB64,
this.keychain
);
const authKey = await window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
secretKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const pwdKey = await window.crypto.subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const oldPwdkey = await window.crypto.subtle.importKey(
'raw',
encoder.encode(existingPassword),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const oldAuthKey = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(file.url),
iterations: 100,
hash: 'SHA-256'
},
oldPwdkey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const newAuthKey = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(file.url),
iterations: 100,
hash: 'SHA-256'
},
pwdKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
const rawAuth = await window.crypto.subtle.exportKey('raw', newAuthKey);
const aKey = existingPassword ? oldAuthKey : authKey;
this.msg = 'fileSizeProgress';
this.uploadRequest.onprogress = p => {
this.progress = p;
this.emit('progress', p);
};
try {
await sendPassword(file, aKey, rawAuth);
const result = await this.uploadRequest.result;
const time = Date.now() - start;
this.msg = 'notifyUploadDone';
this.uploadRequest = null;
this.progress = [1, 1];
const secretKey = arrayToB64(this.keychain.rawSecret);
const ownedFile = new OwnedFile(
{
id: result.id,
url: `${result.url}#${secretKey}`,
name: this.file.name,
size: this.file.size,
type: this.file.type, //TODO 'click' ?
time: time,
speed: this.file.size / (time / 1000),
createdAt: Date.now(),
expiresAt: Date.now() + EXPIRE_SECONDS * 1000,
secretKey: secretKey,
nonce: this.keychain.nonce,
ownerToken: result.ownerToken
},
storage
);
return ownedFile;
} catch (e) {
if (e.message === '401' && file.nonce !== e.nonce) {
await sendPassword(file, aKey, rawAuth);
} else {
this.msg = 'errorPageHeader';
this.uploadRequest = null;
throw e;
}
}
}
}

212
app/keychain.js Normal file
View File

@ -0,0 +1,212 @@
import Nanobus from 'nanobus';
import { arrayToB64, b64ToArray } from './utils';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export default class Keychain extends Nanobus {
constructor(secretKeyB64, nonce, ivB64) {
super('Keychain');
this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ==';
if (ivB64) {
this.iv = b64ToArray(ivB64);
} else {
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
}
if (secretKeyB64) {
this.rawSecret = b64ToArray(secretKeyB64);
} else {
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
}
this.secretKeyPromise = window.crypto.subtle.importKey(
'raw',
this.rawSecret,
'HKDF',
false,
['deriveKey']
);
this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) {
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('encryption'),
hash: 'SHA-256'
},
secretKey,
{
name: 'AES-GCM',
length: 128
},
false,
['encrypt', 'decrypt']
);
});
this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) {
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('metadata'),
hash: 'SHA-256'
},
secretKey,
{
name: 'AES-GCM',
length: 128
},
false,
['encrypt', 'decrypt']
);
});
this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) {
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('authentication'),
hash: 'SHA-256'
},
secretKey,
{
name: 'HMAC',
hash: { name: 'SHA-256' }
},
true,
['sign']
);
});
}
get nonce() {
return this._nonce;
}
set nonce(n) {
if (n !== this.nonce) {
this.emit('nonceChanged', n);
}
this._nonce = n;
}
setIV(ivB64) {
this.iv = b64ToArray(ivB64);
}
setPassword(password, shareUrl) {
this.authKeyPromise = window.crypto.subtle
.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [
'deriveKey'
])
.then(passwordKey =>
window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(shareUrl),
iterations: 100,
hash: 'SHA-256'
},
passwordKey,
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
)
);
}
setAuthKey(authKeyB64) {
this.authKeyPromise = window.crypto.subtle.importKey(
'raw',
b64ToArray(authKeyB64),
{
name: 'HMAC',
hash: 'SHA-256'
},
true,
['sign']
);
}
async authKeyB64() {
const authKey = await this.authKeyPromise;
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
return arrayToB64(new Uint8Array(rawAuth));
}
async authHeader() {
const authKey = await this.authKeyPromise;
const sig = await window.crypto.subtle.sign(
{
name: 'HMAC'
},
authKey,
b64ToArray(this.nonce)
);
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
}
async encryptFile(plaintext) {
const encryptKey = await this.encryptKeyPromise;
const ciphertext = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: this.iv,
tagLength: 128
},
encryptKey,
plaintext
);
return ciphertext;
}
async encryptMetadata(metadata) {
const metaKey = await this.metaKeyPromise;
const ciphertext = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(12),
tagLength: 128
},
metaKey,
encoder.encode(
JSON.stringify({
iv: arrayToB64(this.iv),
name: metadata.name,
type: metadata.type || 'application/octet-stream'
})
)
);
return ciphertext;
}
async decryptFile(ciphertext) {
const encryptKey = await this.encryptKeyPromise;
const plaintext = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: this.iv,
tagLength: 128
},
encryptKey,
ciphertext
);
return plaintext;
}
async decryptMetadata(ciphertext) {
const metaKey = await this.metaKeyPromise;
const plaintext = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(12),
tagLength: 128
},
metaKey,
ciphertext
);
return JSON.parse(decoder.decode(plaintext));
}
}

View File

@ -15,30 +15,34 @@ if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
}
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 () => {
let reason = null;
window.appState = state;
emitter.on('DOMContentLoaded', async function checkSupport() {
let unsupportedReason = null;
if (
// Firefox < 50
/firefox/i.test(navigator.userAgent) &&
parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) <=
49
parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) < 50
) {
reason = 'outdated';
unsupportedReason = 'outdated';
}
if (/edge\/\d+/i.test(navigator.userAgent)) {
reason = 'edge';
unsupportedReason = 'edge';
}
const ok = await canHasSend(assets.get('cryptofill.js'));
if (!ok) {
reason = /firefox/i.test(navigator.userAgent) ? 'outdated' : 'gcm';
unsupportedReason = /firefox/i.test(navigator.userAgent)
? 'outdated'
: 'gcm';
}
if (reason) {
setTimeout(() => emitter.emit('replaceState', `/unsupported/${reason}`));
if (unsupportedReason) {
setTimeout(() =>
emitter.emit('replaceState', `/unsupported/${unsupportedReason}`)
);
}
});
});

81
app/ownedFile.js Normal file
View File

@ -0,0 +1,81 @@
import Keychain from './keychain';
import { arrayToB64 } from './utils';
import { del, metadata, setParams, setPassword } from './api';
export default class OwnedFile {
constructor(obj, storage) {
this.id = obj.id;
this.url = obj.url;
this.name = obj.name;
this.size = obj.size;
this.type = obj.type;
this.time = obj.time;
this.speed = obj.speed;
this.createdAt = obj.createdAt;
this.expiresAt = obj.expiresAt;
this.ownerToken = obj.ownerToken;
this.dlimit = obj.dlimit || 1;
this.dtotal = obj.dtotal || 0;
this.keychain = new Keychain(obj.secretKey, obj.nonce);
this.keychain.on('nonceChanged', () => storage.writeFile(this));
if (obj.authKeyB64) {
this.authKeyB64 = obj.authKeyB64;
this.keychain.setAuthKey(obj.authKeyB64);
}
}
async setPassword(password) {
this.password = password;
this.keychain.setPassword(password, this.url);
const result = await setPassword(this.id, this.ownerToken, this.keychain);
this.authKeyB64 = await this.keychain.authKeyB64();
return result;
}
del() {
return del(this.id, this.ownerToken);
}
changeLimit(dlimit) {
if (this.dlimit !== dlimit) {
this.dlimit = dlimit;
return setParams(this.id, this.ownerToken, { dlimit });
}
return Promise.resolve(true);
}
hasPassword() {
return !!this.authKeyB64;
}
async updateDownloadCount() {
try {
const result = await metadata(this.id, this.keychain);
this.dtotal = result.dtotal;
} catch (e) {
if (e.message === '404') {
this.dtotal = this.dlimit;
}
}
}
toJSON() {
return {
id: this.id,
url: this.url,
name: this.name,
size: this.size,
type: this.type,
time: this.time,
speed: this.speed,
createdAt: this.createdAt,
expiresAt: this.expiresAt,
secretKey: arrayToB64(this.keychain.rawSecret),
nonce: this.keychain.nonce,
ownerToken: this.ownerToken,
dlimit: this.dlimit,
dtotal: this.dtotal,
authKeyB64: this.authKeyB64
};
}
}

View File

@ -1,12 +1,60 @@
const preview = require('../templates/preview');
const download = require('../templates/download');
const notFound = require('../templates/notFound');
const downloadPassword = require('../templates/downloadPassword');
const downloadButton = require('../templates/downloadButton');
function hasFileInfo() {
return !!document.getElementById('dl-file');
}
function getFileInfoFromDOM() {
const el = document.getElementById('dl-file');
if (!el) {
return null;
}
return {
nonce: el.getAttribute('data-nonce'),
requiresPassword: !!+el.getAttribute('data-requires-password')
};
}
function createFileInfo(state) {
const metadata = getFileInfoFromDOM();
return {
id: state.params.id,
secretKey: state.params.key,
nonce: metadata.nonce,
requiresPassword: metadata.requiresPassword
};
}
module.exports = function(state, emit) {
if (!state.fileInfo) {
// This is a fresh page load
// We need to parse the file info from the server's html
if (!hasFileInfo()) {
return notFound(state, emit);
}
state.fileInfo = createFileInfo(state);
if (!state.fileInfo.requiresPassword) {
emit('getMetadata');
}
}
let pageAction = ''; //default state: we don't have file metadata
if (state.transfer) {
const s = state.transfer.state;
if (s === 'downloading' || s === 'complete') {
// Downloading is in progress
return download(state, emit);
}
// we have file metadata
pageAction = downloadButton(state, emit);
} else if (state.fileInfo.requiresPassword && !state.fileInfo.password) {
// we're waiting on the user for a valid password
pageAction = downloadPassword(state, emit);
}
return preview(state, emit);
return preview(state, pageAction);
};

View File

@ -2,8 +2,7 @@ const welcome = require('../templates/welcome');
const upload = require('../templates/upload');
module.exports = function(state, emit) {
if (state.transfer && state.transfer.iv) {
//TODO relying on 'iv' is gross
if (state.transfer) {
return upload(state, emit);
}
return welcome(state, emit);

View File

@ -7,26 +7,33 @@ const fxPromo = require('../templates/fxPromo');
const app = choo();
function showBanner(state) {
return state.promo && !state.route.startsWith('/unsupported/');
function banner(state, emit) {
if (state.promo && !state.route.startsWith('/unsupported/')) {
return fxPromo(state, emit);
}
}
function body(template) {
return function(state, emit) {
const b = html`<body>
${showBanner(state) ? fxPromo(state, emit) : ''}
${banner(state, emit)}
${header(state)}
<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>
<h2>${state.translate('javascriptRequired')}</h2>
<p>
<a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">
${state.translate('whyJavascript')}
</a>
</p>
<p>${state.translate('enableJavascript')}</p>
</noscript>
${template(state, emit)}
</div>
${footer(state)}
</body>`;
if (state.layout) {
// server side only
return state.layout(state, b);
}
return b;

View File

@ -1,4 +1,5 @@
import { isFile } from './utils';
import OwnedFile from './ownedFile';
class Mem {
constructor() {
@ -42,7 +43,7 @@ class Storage {
const k = this.engine.key(i);
if (isFile(k)) {
try {
const f = JSON.parse(this.engine.getItem(k));
const f = new OwnedFile(JSON.parse(this.engine.getItem(k)), this);
if (!f.id) {
f.id = f.fileId;
}
@ -108,11 +109,15 @@ class Storage {
addFile(file) {
this._files.push(file);
this.writeFile(file);
}
writeFile(file) {
this.engine.setItem(file.id, JSON.stringify(file));
}
writeFiles() {
this._files.forEach(f => this.engine.setItem(f.id, JSON.stringify(f)));
this._files.forEach(f => this.writeFile(f));
}
}

View File

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

View File

@ -7,18 +7,19 @@ module.exports = function(state, emit) {
<div id="page-one">
<div id="download" class="fadeIn">
<div id="download-progress">
<div id="dl-title" class="title">${state.translate(
'downloadFinish'
)}</div>
<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>
<a class="send-new"
data-state="completed"
href="/"
onclick=${sendNew}>${state.translate('sendYourFilesLink')}</a>
</div>
</div>
`;

View File

@ -2,7 +2,7 @@ const html = require('choo/html');
const progress = require('./progress');
const { bytes } = require('../utils');
module.exports = function(state) {
module.exports = function(state, emit) {
const transfer = state.transfer;
const div = html`
<div id="page-one">
@ -24,11 +24,20 @@ module.exports = function(state) {
transfer.msg,
transfer.sizes
)}</div>
<button
id="cancel-upload"
title="${state.translate('deletePopupCancel')}"
onclick=${cancel}>${state.translate('deletePopupCancel')}</button>
</div>
</div>
</div>
</div>
`;
function cancel() {
const btn = document.getElementById('cancel-upload');
btn.remove();
emit('cancel');
}
return div;
};

View File

@ -0,0 +1,16 @@
const html = require('choo/html');
module.exports = function(state, emit) {
function download(event) {
event.preventDefault();
emit('download', state.fileInfo);
}
return html`
<div>
<button id="download-btn"
class="btn"
onclick=${download}>${state.translate('downloadButtonLabel')}
</button>
</div>`;
};

View File

@ -5,8 +5,9 @@ module.exports = function(state, emit) {
const label =
fileInfo.password === null
? html`
<label class="red"
for="unlock-input">${state.translate('passwordTryAgain')}</label>`
<label class="red" for="unlock-input">
${state.translate('passwordTryAgain')}
</label>`
: html`
<label for="unlock-input">
${state.translate('unlockInputLabel')}
@ -48,7 +49,7 @@ module.exports = function(state, emit) {
document.getElementById('unlock-btn').disabled = true;
state.fileInfo.url = window.location.href;
state.fileInfo.password = password;
emit('preview');
emit('getMetadata');
}
}

View File

@ -1,52 +1,62 @@
const html = require('choo/html');
const assets = require('../../common/assets');
function timeLeft(milliseconds) {
function timeLeft(milliseconds, state) {
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`;
return state.translate('expiresHoursMinutes', {
hours,
minutes: minutes % 60
});
} else if (hours === 0) {
return `${minutes}m ${seconds}s`;
if (minutes === 0) {
return state.translate('expiresMinutes', { minutes: '< 1' });
}
return state.translate('expiresMinutes', { minutes });
}
return null;
}
module.exports = function(file, state, emit) {
const ttl = file.expiresAt - Date.now();
const remainingTime = timeLeft(ttl) || state.translate('linkExpiredAlt');
const remainingTime =
timeLeft(ttl, state) || state.translate('linkExpiredAlt');
const downloadLimit = file.dlimit || 1;
const totalDownloads = file.dtotal || 0;
const row = html`
<tr id="${file.id}">
<td class="overflow-col" title="${
file.name
}"><a class="link" href="/share/${file.id}">${file.name}</a></td>
<td class="overflow-col" title="${file.name}">
<a class="link" href="/share/${file.id}">${file.name}</a>
</td>
<td class="center-col">
<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>
<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>${remainingTime}</td>
<td class="center-col">${totalDownloads}/${downloadLimit}</td>
<td class="center-col">${totalDownloads} / ${downloadLimit}</td>
<td class="center-col">
<img onclick=${showPopup} src="${assets.get(
'close-16.svg'
)}" class="icon-delete" title="${state.translate('deleteButtonHover')}">
<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>
<span class="popup-no" onclick=${cancel}>
${state.translate('deletePopupCancel')}
</span>
<span class="popup-yes" onclick=${deleteFile}>
${state.translate('deletePopupYes')}
</span>
</div>
</div>
</div>

View File

@ -9,18 +9,18 @@ module.exports = function(state, emit) {
<thead>
<tr>
<th id="uploaded-file">${state.translate('uploadedFile')}</th>
<th id="copy-file-list" class="center-col">${state.translate(
'copyFileList'
)}</th>
<th id="expiry-time-file-list" >${state.translate(
'timeFileList'
)}</th>
<th id="expiry-downloads-file-list" >${state.translate(
'downloadsFileList'
)}</th>
<th id="delete-file-list" class="center-col">${state.translate(
'deleteFileList'
)}</th>
<th id="copy-file-list" class="center-col">
${state.translate('copyFileList')}
</th>
<th id="expiry-time-file-list" >
${state.translate('timeFileList')}
</th>
<th id="expiry-downloads-file-list" >
${state.translate('downloadsFileList')}
</th>
<th id="delete-file-list" class="center-col">
${state.translate('deleteFileList')}
</th>
</tr>
</thead>
<tbody>

View File

@ -4,31 +4,40 @@ const assets = require('../../common/assets');
module.exports = function(state) {
return html`<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="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>
<a href="https://www.mozilla.org/about/legal/report-infringement/">${state.translate(
'reportIPInfringement'
)}</a>
<a href="https://www.mozilla.org/privacy/websites/#cookies">
${state.translate('footerLinkCookies')}
</a>
<a href="https://www.mozilla.org/about/legal/report-infringement/">
${state.translate('reportIPInfringement')}
</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>
<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>`;
};

View File

@ -41,9 +41,10 @@ module.exports = function(state) {
return html`<header class="header">
<div class="send-logo">
<a href="/">
<img src="${assets.get(
'send_logo.svg'
)}" alt="Send"/><h1 class="site-title">Send</h1>
<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>

View File

@ -9,12 +9,12 @@ module.exports = function(state) {
<div class="share-window">
<img src="${assets.get('illustration_expired.svg')}" id="expired-img">
</div>
<div class="expired-description">${state.translate(
'uploadPageExplainer'
)}</div>
<a class="send-new" href="/" data-state="notfound">${state.translate(
'sendYourFilesLink'
)}</a>
<div class="expired-description">
${state.translate('uploadPageExplainer')}
</div>
<a class="send-new" href="/" data-state="notfound">
${state.translate('sendYourFilesLink')}
</a>
</div>
</div>`;
return div;

View File

@ -1,48 +1,13 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const notFound = require('./notFound');
const downloadPassword = require('./downloadPassword');
const { bytes } = require('../utils');
function getFileFromDOM() {
const el = document.getElementById('dl-file');
if (!el) {
return null;
}
return {
nonce: el.getAttribute('data-nonce'),
pwd: !!+el.getAttribute('data-requires-password')
};
}
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.secretKey = state.params.key;
module.exports = function(state, pageAction) {
const fileInfo = state.fileInfo;
const size = fileInfo.size
? state.translate('downloadFileSize', { size: bytes(fileInfo.size) })
: '';
let action = html`
<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>`;
if (fileInfo.pwd && !fileInfo.password) {
action = downloadPassword(state, emit);
} else if (!state.transfer) {
emit('preview');
}
const title = fileInfo.name
? state.translate('downloadFileName', { filename: fileInfo.name })
: state.translate('downloadFileTitle');
@ -53,20 +18,20 @@ module.exports = function(state, emit) {
<div class="title">
<span id="dl-file"
data-nonce="${fileInfo.nonce}"
data-requires-password="${fileInfo.pwd}">${title}</span>
data-requires-password="${fileInfo.requiresPassword}"
>${title}</span>
<span id="dl-filesize">${' ' + size}</span>
</div>
<div class="description">${state.translate('downloadMessage')}</div>
${action}
<img
src="${assets.get('illustration_download.svg')}"
id="download-img"
alt="${state.translate('downloadAltText')}"/>
${pageAction}
</div>
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
</div>
</div>
`;
function download(event) {
event.preventDefault();
emit('download', fileInfo);
}
return div;
};

View File

@ -10,18 +10,30 @@ module.exports = function(progressRatio) {
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
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>
`;

View File

@ -47,9 +47,7 @@ module.exports = function(selected, options, translate, changed) {
<ul id="${id}" class="selectOptions">
${options.map(
i =>
html`<li class="selectOption" onclick=${choose} data-value="${i}">${
i
}</li>`
html`<li class="selectOption" onclick=${choose} data-value="${i}">${i}</li>`
)}
</ul>
</div>`;

View File

@ -2,34 +2,11 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const notFound = require('./notFound');
const uploadPassword = require('./uploadPassword');
const uploadPasswordSet = require('./uploadPasswordSet');
const uploadPasswordUnset = require('./uploadPasswordUnset');
const selectbox = require('./selectbox');
const { allowedCopy, delay, fadeOut } = require('../utils');
function inputChanged() {
const resetInput = document.getElementById('unlock-reset-input');
const resetBtn = document.getElementById('unlock-reset-btn');
if (resetInput.value.length > 0) {
resetBtn.classList.remove('btn-hidden');
resetInput.classList.remove('input-no-btn');
} else {
resetBtn.classList.add('btn-hidden');
resetInput.classList.add('input-no-btn');
}
}
function toggleResetInput(event) {
const form = event.target.parentElement.querySelector('form');
const input = document.getElementById('unlock-reset-input');
if (form.style.visibility === 'hidden' || form.style.visibility === '') {
form.style.visibility = 'visible';
input.focus();
} else {
form.style.visibility = 'hidden';
}
inputChanged();
}
function expireInfo(file, translate, emit) {
const hours = Math.floor(EXPIRE_SECONDS / 60 / 60);
const el = html([
@ -55,19 +32,16 @@ module.exports = function(state, emit) {
return notFound(state, emit);
}
file.password = file.password || '';
const passwordSection = file.password
? passwordComplete(file.password)
: uploadPassword(state, emit);
const passwordSection = file.hasPassword()
? uploadPasswordSet(state, emit)
: uploadPasswordUnset(state, emit);
const div = html`
<div id="share-link" class="fadeIn">
<div class="title">${expireInfo(file, state.translate, emit)}</div>
<div id="share-window">
<div id="copy-text">
${state.translate('copyUrlFormLabelWithName', {
filename: file.name
})}</div>
${state.translate('copyUrlFormLabelWithName', { filename: file.name })}
</div>
<div id="copy">
<input id="link" type="url" value="${file.url}" readonly="true"/>
<button id="copy-btn"
@ -86,13 +60,11 @@ module.exports = function(state, emit) {
<div class="popup-message">${state.translate('deletePopupText')}
</div>
<div class="popup-action">
<span class="popup-no" onclick=${cancel}>${state.translate(
'deletePopupCancel'
)}
<span class="popup-no" onclick=${cancel}>
${state.translate('deletePopupCancel')}
</span>
<span class="popup-yes" onclick=${deleteFile}>${state.translate(
'deletePopupYes'
)}
<span class="popup-yes" onclick=${deleteFile}>
${state.translate('deletePopupYes')}
</span>
</div>
</div>
@ -105,54 +77,6 @@ module.exports = function(state, emit) {
</div>
`;
function passwordComplete(password) {
const passwordSpan = html([
`<span>${state.translate('passwordResult', {
password:
'<pre class="passwordOriginal"></pre><pre class="passwordMask"></pre>'
})}</span>`
]);
const og = passwordSpan.querySelector('.passwordOriginal');
const masked = passwordSpan.querySelector('.passwordMask');
og.textContent = password;
masked.textContent = password.replace(/./g, '●');
return html`<div class="selectPassword">
${passwordSpan}
<button
id="resetButton"
onclick=${toggleResetInput}
>${state.translate('changePasswordButton')}</button>
<form
id='reset-form'
class="setPassword hidden"
onsubmit=${resetPassword}
data-no-csrf>
<input id="unlock-reset-input"
class="unlock-input input-no-btn"
maxlength="32"
autocomplete="off"
type="password"
oninput=${inputChanged}
placeholder="${state.translate('unlockInputPlaceholder')}">
<input type="submit"
id="unlock-reset-btn"
class="btn btn-hidden"
value="${state.translate('changePasswordButton')}"/>
</form>
</div>`;
}
function resetPassword(event) {
event.preventDefault();
const existingPassword = file.password;
const password = document.querySelector('#unlock-reset-input').value;
if (password.length > 0) {
document.getElementById('copy').classList.remove('wait-password');
document.getElementById('copy-btn').disabled = false;
emit('password', { existingPassword, password, file });
}
}
function showPopup() {
const popupText = document.querySelector('.popuptext');
popupText.classList.add('show');

View File

@ -7,39 +7,45 @@ module.exports = function(state) {
? 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>
<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 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>
<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/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com">
<img src="${assets.get(
'firefox_logo-only.svg'
)}" class="firefox-logo" alt="Firefox"/>
<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 class="unsupported-description">
${state.translate('uploadPageExplainer')}
</div>
</div>`;
const div = html`<div id="page-one">${msg}</div>`;
return div;

View File

@ -8,23 +8,24 @@ module.exports = function(state, emit) {
const div = html`
<div id="download">
<div id="upload-progress" class="fadeIn">
<div class="title" id="upload-filename">${state.translate(
'uploadingPageProgress',
{
<div class="title" id="upload-filename">
${state.translate('uploadingPageProgress', {
filename: transfer.file.name,
size: bytes(transfer.file.size)
}
)}</div>
})}
</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" title="${state.translate(
'uploadingPageCancel'
)}" onclick=${cancel}>${state.translate('uploadingPageCancel')}</button>
<div class="progress-text">
${state.translate(transfer.msg, transfer.sizes)}
</div>
<button
id="cancel-upload"
title="${state.translate('uploadingPageCancel')}"
onclick=${cancel}>
${state.translate('uploadingPageCancel')}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,79 @@
const html = require('choo/html');
module.exports = function(state, emit) {
const file = state.storage.getFileById(state.params.id);
return html`<div class="selectPassword">
${passwordSpan(file.password)}
<button
id="resetButton"
onclick=${toggleResetInput}
>${state.translate('changePasswordButton')}</button>
<form
id='reset-form'
class="setPassword hidden"
onsubmit=${resetPassword}
data-no-csrf>
<input id="unlock-reset-input"
class="unlock-input input-no-btn"
maxlength="32"
autocomplete="off"
type="password"
oninput=${inputChanged}
placeholder="${state.translate('unlockInputPlaceholder')}">
<input type="submit"
id="unlock-reset-btn"
class="btn btn-hidden"
value="${state.translate('changePasswordButton')}"/>
</form>
</div>`;
function passwordSpan(password) {
password = password || '●●●●●';
const span = html([
`<span>${state.translate('passwordResult', {
password:
'<pre class="passwordOriginal"></pre><pre class="passwordMask"></pre>'
})}</span>`
]);
const og = span.querySelector('.passwordOriginal');
const masked = span.querySelector('.passwordMask');
og.textContent = password;
masked.textContent = password.replace(/./g, '●');
return span;
}
function inputChanged() {
const resetInput = document.getElementById('unlock-reset-input');
const resetBtn = document.getElementById('unlock-reset-btn');
if (resetInput.value.length > 0) {
resetBtn.classList.remove('btn-hidden');
resetInput.classList.remove('input-no-btn');
} else {
resetBtn.classList.add('btn-hidden');
resetInput.classList.add('input-no-btn');
}
}
function resetPassword(event) {
event.preventDefault();
const password = document.querySelector('#unlock-reset-input').value;
if (password.length > 0) {
document.getElementById('copy').classList.remove('wait-password');
document.getElementById('copy-btn').disabled = false;
emit('password', { password, file });
}
}
function toggleResetInput(event) {
const form = event.target.parentElement.querySelector('form');
const input = document.getElementById('unlock-reset-input');
if (form.style.visibility === 'hidden' || form.style.visibility === '') {
form.style.visibility = 'visible';
input.focus();
} else {
form.style.visibility = 'hidden';
}
inputChanged();
}
};

View File

@ -5,9 +5,14 @@ module.exports = function(state, emit) {
const div = html`
<div class="selectPassword">
<div id="addPasswordWrapper">
<input id="addPassword" type="checkbox" autocomplete="off" onchange=${togglePasswordInput}/>
<input
id="addPassword"
type="checkbox"
autocomplete="off"
onchange=${togglePasswordInput}/>
<label for="addPassword">
${state.translate('requirePasswordCheckbox')}</label>
${state.translate('requirePasswordCheckbox')}
</label>
</div>
<form class="setPassword hidden" onsubmit=${setPassword} data-no-csrf>
<input id="unlock-input"
@ -52,12 +57,11 @@ module.exports = function(state, emit) {
function setPassword(event) {
event.preventDefault();
const existingPassword = null;
const password = document.getElementById('unlock-input').value;
if (password.length > 0) {
document.getElementById('copy').classList.remove('wait-password');
document.getElementById('copy-btn').disabled = false;
emit('password', { existingPassword, password, file });
emit('password', { password, file });
}
}

View File

@ -10,14 +10,18 @@ module.exports = function(state, emit) {
<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>
<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')}"
<img
src="${assets.get('upload.svg')}"
title="${state.translate('uploadSvgAlt')}"/>
</div>
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
@ -34,7 +38,8 @@ module.exports = function(state, emit) {
id="browse"
class="btn browse"
title="${state.translate('uploadPageBrowseButton1')}">
${state.translate('uploadPageBrowseButton1')}</label>
${state.translate('uploadPageBrowseButton1')}
</label>
</div>
${fileList(state, emit)}
</div>

View File

@ -15,21 +15,6 @@ function b64ToArray(str) {
return b64.toByteArray(str);
}
function notify(str) {
return str;
/* TODO: enable once we have an opt-in ui element
if (!('Notification' in window)) {
return;
} else if (Notification.permission === 'granted') {
new Notification(str);
} else if (Notification.permission !== 'denied') {
Notification.requestPermission(function(permission) {
if (permission === 'granted') new Notification(str);
});
}
*/
}
function loadShim(polyfill) {
return new Promise((resolve, reject) => {
const shim = document.createElement('script');
@ -148,7 +133,37 @@ function fadeOut(id) {
return delay(300);
}
const ONE_DAY_IN_MS = 86400000;
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;
}
module.exports = {
fadeOut,
@ -159,8 +174,8 @@ module.exports = {
copyToClipboard,
arrayToB64,
b64ToArray,
notify,
canHasSend,
isFile,
ONE_DAY_IN_MS
saveFile,
openLinksInNewTab
};

View File

@ -106,3 +106,10 @@ passwordTryAgain = Incorrect password. Try again.
// This label is followed by the password needed to download a file
passwordResult = Password: { $password }
reportIPInfringement = Report IP Infringement
javascriptRequired = Firefox Send requires JavaScript
whyJavascript = Why does Firefox Send require JavaScript?
enableJavascript = Please enable JavaScript and try again.
// A short representation of a countdown timer containing the number of hours and minutes remaining as digits, example "13h 47m"
expiresHoursMinutes = { $hours }h { $minutes }m
// A short representation of a countdown timer containing the number of minutes remaining as digits, example "56m"
expiresMinutes = { $minutes }m

View File

@ -24,4 +24,4 @@ app.use(
app.use(pages.notfound);
app.listen(config.listen_port,config.listen_address);
app.listen(config.listen_port, config.listen_address);

View File

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

View File

@ -1,5 +1,4 @@
const storage = require('../storage');
const crypto = require('crypto');
function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
@ -10,27 +9,24 @@ module.exports = async function(req, res) {
if (!validateID(id)) {
return res.sendStatus(404);
}
if (!req.body.auth) {
const ownerToken = req.body.owner_token;
if (!ownerToken) {
return res.sendStatus(404);
}
const auth = req.body.auth;
if (!auth) {
return res.sendStatus(400);
}
try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id);
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
return res.sendStatus(401);
if (meta.owner !== ownerToken) {
return res.sendStatus(404);
}
storage.setField(id, 'auth', auth);
storage.setField(id, 'pwd', 1);
res.sendStatus(200);
} catch (e) {
return res.sendStatus(404);
}
const nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', nonce);
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
storage.setField(id, 'auth', req.body.auth);
storage.setField(id, 'pwd', 1);
res.sendStatus(200);
};

View File

@ -116,9 +116,7 @@ describe('Server integration tests', function() {
.expect(404);
});
it('Successfully deletes if the id is valid and the delete token matches', function(
done
) {
it('Successfully deletes if the id is valid and the delete token matches', function(done) {
request(server)
.post('/delete/' + fileId)
.send({ delete_token: uuid })