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

View File

@ -1,104 +1,21 @@
import Nanobus from 'nanobus'; 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 { export default class FileReceiver extends Nanobus {
constructor(url, file) { constructor(fileInfo) {
super('FileReceiver'); super('FileReceiver');
this.secretKeyPromise = window.crypto.subtle.importKey( this.keychain = new Keychain(fileInfo.secretKey, fileInfo.nonce);
'raw', if (fileInfo.requiresPassword) {
b64ToArray(file.secretKey), this.keychain.setPassword(fileInfo.password, fileInfo.url);
'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.metaKeyPromise = this.secretKeyPromise.then(sk => { this.fileInfo = fileInfo;
const encoder = new TextEncoder(); this.fileDownload = null;
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.msg = 'fileSizeProgress'; this.msg = 'fileSizeProgress';
this.state = 'initialized'; this.state = 'initialized';
this.progress = [0, 1]; this.progress = [0, 1];
this.cancelled = false;
} }
get progressRatio() { get progressRatio() {
@ -113,160 +30,51 @@ export default class FileReceiver extends Nanobus {
} }
cancel() { 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;
this.state = 'ready';
} catch (e) {
this.state = 'invalid';
throw e;
} }
} }
async downloadFile(nonce) { async getMetadata() {
const authHeader = await this.getAuthHeader(nonce); const meta = await metadata(this.fileInfo.id, this.keychain);
return new Promise((resolve, reject) => { if (meta) {
const xhr = new XMLHttpRequest(); this.keychain.setIV(meta.iv);
this.fileInfo.name = meta.name;
xhr.onprogress = event => { this.fileInfo.type = meta.type;
if (event.lengthComputable && event.target.status !== 404) { this.fileInfo.iv = meta.iv;
this.progress = [event.loaded, event.total]; this.fileInfo.size = meta.size;
this.emit('progress', this.progress); this.state = 'ready';
} return;
}; }
this.state = 'invalid';
xhr.onload = event => { return;
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);
}
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) { async download() {
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) {
this.state = 'downloading'; this.state = 'downloading';
this.emit('progress', this.progress); this.emit('progress', this.progress);
try { try {
const encryptKey = await this.encryptKeyPromise; const download = await downloadFile(this.fileInfo.id, this.keychain);
let ciphertext = null; download.onprogress = p => {
try { this.progress = p;
ciphertext = await this.downloadFile(nonce); this.emit('progress', p);
} catch (e) { };
if (e.message === '401' && nonce !== e.nonce) { this.fileDownload = download;
ciphertext = await this.downloadFile(e.nonce); const ciphertext = await download.result;
} else { this.fileDownload = null;
throw e;
}
}
this.msg = 'decryptingFile'; this.msg = 'decryptingFile';
this.emit('decrypting'); this.emit('decrypting');
const plaintext = await window.crypto.subtle.decrypt( const plaintext = await this.keychain.decryptFile(ciphertext);
{ if (this.cancelled) {
name: 'AES-GCM', throw new Error(0);
iv: b64ToArray(this.file.iv), }
tagLength: 128
},
encryptKey,
ciphertext
);
this.msg = 'downloadFinish'; this.msg = 'downloadFinish';
this.state = 'complete'; this.state = 'complete';
return { return {
plaintext, plaintext,
name: decodeURIComponent(this.file.name), name: decodeURIComponent(this.fileInfo.name),
type: this.file.type type: this.fileInfo.type
}; };
} catch (e) { } catch (e) {
this.state = 'invalid'; this.state = 'invalid';

View File

@ -1,97 +1,19 @@
/* global EXPIRE_SECONDS */
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import { arrayToB64, b64ToArray, bytes } from './utils'; import OwnedFile from './ownedFile';
import Keychain from './keychain';
async function getAuthHeader(authKey, nonce) { import { arrayToB64, bytes } from './utils';
const sig = await window.crypto.subtle.sign( import { uploadFile } from './api';
{
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)) }));
});
}
export default class FileSender extends Nanobus { export default class FileSender extends Nanobus {
constructor(file) { constructor(file) {
super('FileSender'); super('FileSender');
this.file = file; this.file = file;
this.uploadRequest = null;
this.msg = 'importingFile'; this.msg = 'importingFile';
this.progress = [0, 1]; this.progress = [0, 1];
this.cancelled = false; this.cancelled = false;
this.iv = window.crypto.getRandomValues(new Uint8Array(12)); this.keychain = new Keychain();
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 }));
});
} }
get progressRatio() { get progressRatio() {
@ -107,8 +29,8 @@ export default class FileSender extends Nanobus {
cancel() { cancel() {
this.cancelled = true; this.cancelled = true;
if (this.msg === 'fileSizeProgress') { if (this.uploadRequest) {
this.uploadXHR.abort(); this.uploadRequest.cancel();
} }
} }
@ -116,6 +38,7 @@ export default class FileSender extends Nanobus {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsArrayBuffer(this.file); reader.readAsArrayBuffer(this.file);
// TODO: progress?
reader.onload = function(event) { reader.onload = function(event) {
const plaintext = new Uint8Array(this.result); const plaintext = new Uint8Array(this.result);
resolve(plaintext); resolve(plaintext);
@ -126,218 +49,60 @@ export default class FileSender extends Nanobus {
}); });
} }
uploadFile(encrypted, metadata, rawAuth) { async upload(storage) {
return new Promise((resolve, reject) => { const start = Date.now();
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']
);
const plaintext = await this.readFile(); const plaintext = await this.readFile();
if (this.cancelled) { if (this.cancelled) {
throw new Error(0); throw new Error(0);
} }
this.msg = 'encryptingFile'; this.msg = 'encryptingFile';
this.emit('encrypting'); this.emit('encrypting');
const encrypted = await window.crypto.subtle.encrypt( const encrypted = await this.keychain.encryptFile(plaintext);
{ const metadata = await this.keychain.encryptMetadata(this.file);
name: 'AES-GCM', const authKeyB64 = await this.keychain.authKeyB64();
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);
if (this.cancelled) { if (this.cancelled) {
throw new Error(0); throw new Error(0);
} }
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth)); this.uploadRequest = uploadFile(
} encrypted,
metadata,
static async setPassword(existingPassword, password, file) { authKeyB64,
const encoder = new TextEncoder(); this.keychain
const secretKey = await window.crypto.subtle.importKey(
'raw',
b64ToArray(file.secretKey),
'HKDF',
false,
['deriveKey']
); );
const authKey = await window.crypto.subtle.deriveKey( this.msg = 'fileSizeProgress';
{ this.uploadRequest.onprogress = p => {
name: 'HKDF', this.progress = p;
salt: new Uint8Array(), this.emit('progress', p);
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;
try { 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) { } catch (e) {
if (e.message === '401' && file.nonce !== e.nonce) { this.msg = 'errorPageHeader';
await sendPassword(file, aKey, rawAuth); this.uploadRequest = null;
} else { throw e;
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) => { app.use((state, emitter) => {
// init state
state.transfer = null; state.transfer = null;
state.fileInfo = null; state.fileInfo = null;
state.translate = locale.getTranslator(); state.translate = locale.getTranslator();
state.storage = storage; state.storage = storage;
state.raven = Raven; state.raven = Raven;
emitter.on('DOMContentLoaded', async () => { window.appState = state;
let reason = null; emitter.on('DOMContentLoaded', async function checkSupport() {
let unsupportedReason = null;
if ( if (
// Firefox < 50
/firefox/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent) &&
parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) <= parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) < 50
49
) { ) {
reason = 'outdated'; unsupportedReason = 'outdated';
} }
if (/edge\/\d+/i.test(navigator.userAgent)) { if (/edge\/\d+/i.test(navigator.userAgent)) {
reason = 'edge'; unsupportedReason = 'edge';
} }
const ok = await canHasSend(assets.get('cryptofill.js')); const ok = await canHasSend(assets.get('cryptofill.js'));
if (!ok) { if (!ok) {
reason = /firefox/i.test(navigator.userAgent) ? 'outdated' : 'gcm'; unsupportedReason = /firefox/i.test(navigator.userAgent)
? 'outdated'
: 'gcm';
} }
if (reason) { if (unsupportedReason) {
setTimeout(() => emitter.emit('replaceState', `/unsupported/${reason}`)); 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 preview = require('../templates/preview');
const download = require('../templates/download'); 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) { 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) { if (state.transfer) {
const s = state.transfer.state; const s = state.transfer.state;
if (s === 'downloading' || s === 'complete') { if (s === 'downloading' || s === 'complete') {
// Downloading is in progress
return download(state, emit); 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'); const upload = require('../templates/upload');
module.exports = function(state, emit) { module.exports = function(state, emit) {
if (state.transfer && state.transfer.iv) { if (state.transfer) {
//TODO relying on 'iv' is gross
return upload(state, emit); return upload(state, emit);
} }
return welcome(state, emit); return welcome(state, emit);

View File

@ -7,26 +7,33 @@ const fxPromo = require('../templates/fxPromo');
const app = choo(); const app = choo();
function showBanner(state) { function banner(state, emit) {
return state.promo && !state.route.startsWith('/unsupported/'); if (state.promo && !state.route.startsWith('/unsupported/')) {
return fxPromo(state, emit);
}
} }
function body(template) { function body(template) {
return function(state, emit) { return function(state, emit) {
const b = html`<body> const b = html`<body>
${showBanner(state) ? fxPromo(state, emit) : ''} ${banner(state, emit)}
${header(state)} ${header(state)}
<div class="all"> <div class="all">
<noscript> <noscript>
<h2>Firefox Send requires JavaScript</h2> <h2>${state.translate('javascriptRequired')}</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>
<p>Please enable JavaScript and try again.</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> </noscript>
${template(state, emit)} ${template(state, emit)}
</div> </div>
${footer(state)} ${footer(state)}
</body>`; </body>`;
if (state.layout) { if (state.layout) {
// server side only
return state.layout(state, b); return state.layout(state, b);
} }
return b; return b;

View File

@ -1,4 +1,5 @@
import { isFile } from './utils'; import { isFile } from './utils';
import OwnedFile from './ownedFile';
class Mem { class Mem {
constructor() { constructor() {
@ -42,7 +43,7 @@ class Storage {
const k = this.engine.key(i); const k = this.engine.key(i);
if (isFile(k)) { if (isFile(k)) {
try { try {
const f = JSON.parse(this.engine.getItem(k)); const f = new OwnedFile(JSON.parse(this.engine.getItem(k)), this);
if (!f.id) { if (!f.id) {
f.id = f.fileId; f.id = f.fileId;
} }
@ -108,11 +109,15 @@ class Storage {
addFile(file) { addFile(file) {
this._files.push(file); this._files.push(file);
this.writeFile(file);
}
writeFile(file) {
this.engine.setItem(file.id, JSON.stringify(file)); this.engine.setItem(file.id, JSON.stringify(file));
} }
writeFiles() { 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'); const html = require('choo/html');
module.exports = function() { module.exports = function() {
const div = html`<div id="page-one"></div>`; const div = html`<div></div>`;
return div; return div;
}; };

View File

@ -5,21 +5,22 @@ const { fadeOut } = require('../utils');
module.exports = function(state, emit) { module.exports = function(state, emit) {
const div = html` const div = html`
<div id="page-one"> <div id="page-one">
<div id="download" class="fadeIn"> <div id="download" class="fadeIn">
<div id="download-progress"> <div id="download-progress">
<div id="dl-title" class="title">${state.translate( <div id="dl-title" class="title">
'downloadFinish' ${state.translate('downloadFinish')}
)}</div> </div>
<div class="description"></div> <div class="description"></div>
${progress(1)} ${progress(1)}
<div class="upload"> <div class="upload">
<div class="progress-text"></div> <div class="progress-text"></div>
</div>
</div> </div>
<a class="send-new"
data-state="completed"
href="/"
onclick=${sendNew}>${state.translate('sendYourFilesLink')}</a>
</div> </div>
<a class="send-new" data-state="completed" href="/" onclick=${
sendNew
}>${state.translate('sendYourFilesLink')}</a>
</div>
</div> </div>
`; `;

View File

@ -2,7 +2,7 @@ const html = require('choo/html');
const progress = require('./progress'); const progress = require('./progress');
const { bytes } = require('../utils'); const { bytes } = require('../utils');
module.exports = function(state) { module.exports = function(state, emit) {
const transfer = state.transfer; const transfer = state.transfer;
const div = html` const div = html`
<div id="page-one"> <div id="page-one">
@ -24,11 +24,20 @@ module.exports = function(state) {
transfer.msg, transfer.msg,
transfer.sizes transfer.sizes
)}</div> )}</div>
<button
id="cancel-upload"
title="${state.translate('deletePopupCancel')}"
onclick=${cancel}>${state.translate('deletePopupCancel')}</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`; `;
function cancel() {
const btn = document.getElementById('cancel-upload');
btn.remove();
emit('cancel');
}
return div; 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 = const label =
fileInfo.password === null fileInfo.password === null
? html` ? html`
<label class="red" <label class="red" for="unlock-input">
for="unlock-input">${state.translate('passwordTryAgain')}</label>` ${state.translate('passwordTryAgain')}
</label>`
: html` : html`
<label for="unlock-input"> <label for="unlock-input">
${state.translate('unlockInputLabel')} ${state.translate('unlockInputLabel')}
@ -48,7 +49,7 @@ module.exports = function(state, emit) {
document.getElementById('unlock-btn').disabled = true; document.getElementById('unlock-btn').disabled = true;
state.fileInfo.url = window.location.href; state.fileInfo.url = window.location.href;
state.fileInfo.password = password; state.fileInfo.password = password;
emit('preview'); emit('getMetadata');
} }
} }

View File

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

View File

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

View File

@ -4,31 +4,40 @@ const assets = require('../../common/assets');
module.exports = function(state) { module.exports = function(state) {
return html`<div class="footer"> return html`<div class="footer">
<div class="legal-links"> <div class="legal-links">
<a href="https://www.mozilla.org" role="presentation"><img class="mozilla-logo" src="${assets.get( <a href="https://www.mozilla.org" role="presentation">
'mozilla-logo.svg' <img
)}" alt="mozilla"/></a> class="mozilla-logo"
<a href="https://www.mozilla.org/about/legal">${state.translate( src="${assets.get('mozilla-logo.svg')}"
'footerLinkLegal' alt="mozilla"/>
)}</a> </a>
<a href="https://testpilot.firefox.com/about">${state.translate( <a href="https://www.mozilla.org/about/legal">
'footerLinkAbout' ${state.translate('footerLinkLegal')}
)}</a> </a>
<a href="https://testpilot.firefox.com/about">
${state.translate('footerLinkAbout')}
</a>
<a href="/legal">${state.translate('footerLinkPrivacy')}</a> <a href="/legal">${state.translate('footerLinkPrivacy')}</a>
<a href="/legal">${state.translate('footerLinkTerms')}</a> <a href="/legal">${state.translate('footerLinkTerms')}</a>
<a href="https://www.mozilla.org/privacy/websites/#cookies">${state.translate( <a href="https://www.mozilla.org/privacy/websites/#cookies">
'footerLinkCookies' ${state.translate('footerLinkCookies')}
)}</a> </a>
<a href="https://www.mozilla.org/about/legal/report-infringement/">${state.translate( <a href="https://www.mozilla.org/about/legal/report-infringement/">
'reportIPInfringement' ${state.translate('reportIPInfringement')}
)}</a> </a>
</div> </div>
<div class="social-links"> <div class="social-links">
<a href="https://github.com/mozilla/send" role="presentation"><img class="github" src="${assets.get( <a href="https://github.com/mozilla/send" role="presentation">
'github-icon.svg' <img
)}" alt="github"/></a> class="github"
<a href="https://twitter.com/FxTestPilot" role="presentation"><img class="twitter" src="${assets.get( src="${assets.get('github-icon.svg')}"
'twitter-icon.svg' alt="github"/>
)}" alt="twitter"/></a> </a>
<a href="https://twitter.com/FxTestPilot" role="presentation">
<img
class="twitter"
src="${assets.get('twitter-icon.svg')}"
alt="twitter"/>
</a>
</div> </div>
</div>`; </div>`;
}; };

View File

@ -41,9 +41,10 @@ module.exports = function(state) {
return html`<header class="header"> return html`<header class="header">
<div class="send-logo"> <div class="send-logo">
<a href="/"> <a href="/">
<img src="${assets.get( <img
'send_logo.svg' src="${assets.get('send_logo.svg')}"
)}" alt="Send"/><h1 class="site-title">Send</h1> alt="Send"/>
<h1 class="site-title">Send</h1>
</a> </a>
<div class="site-subtitle"> <div class="site-subtitle">
<a href="https://testpilot.firefox.com">Firefox Test Pilot</a> <a href="https://testpilot.firefox.com">Firefox Test Pilot</a>

View File

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

View File

@ -1,48 +1,13 @@
const html = require('choo/html'); const html = require('choo/html');
const assets = require('../../common/assets'); const assets = require('../../common/assets');
const notFound = require('./notFound');
const downloadPassword = require('./downloadPassword');
const { bytes } = require('../utils'); const { bytes } = require('../utils');
function getFileFromDOM() { module.exports = function(state, pageAction) {
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;
const fileInfo = state.fileInfo; const fileInfo = state.fileInfo;
const size = fileInfo.size const size = fileInfo.size
? state.translate('downloadFileSize', { size: bytes(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 const title = fileInfo.name
? state.translate('downloadFileName', { filename: fileInfo.name }) ? state.translate('downloadFileName', { filename: fileInfo.name })
: state.translate('downloadFileTitle'); : state.translate('downloadFileTitle');
@ -53,20 +18,20 @@ module.exports = function(state, emit) {
<div class="title"> <div class="title">
<span id="dl-file" <span id="dl-file"
data-nonce="${fileInfo.nonce}" data-nonce="${fileInfo.nonce}"
data-requires-password="${fileInfo.pwd}">${title}</span> data-requires-password="${fileInfo.requiresPassword}"
>${title}</span>
<span id="dl-filesize">${' ' + size}</span> <span id="dl-filesize">${' ' + size}</span>
</div> </div>
<div class="description">${state.translate('downloadMessage')}</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> </div>
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a> <a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
</div> </div>
</div> </div>
`; `;
function download(event) {
event.preventDefault();
emit('download', fileInfo);
}
return div; return div;
}; };

View File

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

View File

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

View File

@ -2,34 +2,11 @@
const html = require('choo/html'); const html = require('choo/html');
const assets = require('../../common/assets'); const assets = require('../../common/assets');
const notFound = require('./notFound'); const notFound = require('./notFound');
const uploadPassword = require('./uploadPassword'); const uploadPasswordSet = require('./uploadPasswordSet');
const uploadPasswordUnset = require('./uploadPasswordUnset');
const selectbox = require('./selectbox'); const selectbox = require('./selectbox');
const { allowedCopy, delay, fadeOut } = require('../utils'); 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) { function expireInfo(file, translate, emit) {
const hours = Math.floor(EXPIRE_SECONDS / 60 / 60); const hours = Math.floor(EXPIRE_SECONDS / 60 / 60);
const el = html([ const el = html([
@ -55,19 +32,16 @@ module.exports = function(state, emit) {
return notFound(state, emit); return notFound(state, emit);
} }
file.password = file.password || ''; const passwordSection = file.hasPassword()
? uploadPasswordSet(state, emit)
const passwordSection = file.password : uploadPasswordUnset(state, emit);
? passwordComplete(file.password)
: uploadPassword(state, emit);
const div = html` const div = html`
<div id="share-link" class="fadeIn"> <div id="share-link" class="fadeIn">
<div class="title">${expireInfo(file, state.translate, emit)}</div> <div class="title">${expireInfo(file, state.translate, emit)}</div>
<div id="share-window"> <div id="share-window">
<div id="copy-text"> <div id="copy-text">
${state.translate('copyUrlFormLabelWithName', { ${state.translate('copyUrlFormLabelWithName', { filename: file.name })}
filename: file.name </div>
})}</div>
<div id="copy"> <div id="copy">
<input id="link" type="url" value="${file.url}" readonly="true"/> <input id="link" type="url" value="${file.url}" readonly="true"/>
<button id="copy-btn" <button id="copy-btn"
@ -86,13 +60,11 @@ module.exports = function(state, emit) {
<div class="popup-message">${state.translate('deletePopupText')} <div class="popup-message">${state.translate('deletePopupText')}
</div> </div>
<div class="popup-action"> <div class="popup-action">
<span class="popup-no" onclick=${cancel}>${state.translate( <span class="popup-no" onclick=${cancel}>
'deletePopupCancel' ${state.translate('deletePopupCancel')}
)}
</span> </span>
<span class="popup-yes" onclick=${deleteFile}>${state.translate( <span class="popup-yes" onclick=${deleteFile}>
'deletePopupYes' ${state.translate('deletePopupYes')}
)}
</span> </span>
</div> </div>
</div> </div>
@ -105,54 +77,6 @@ module.exports = function(state, emit) {
</div> </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() { function showPopup() {
const popupText = document.querySelector('.popuptext'); const popupText = document.querySelector('.popuptext');
popupText.classList.add('show'); popupText.classList.add('show');

View File

@ -7,39 +7,45 @@ module.exports = function(state) {
? html` ? html`
<div id="unsupported-browser"> <div id="unsupported-browser">
<div class="title">${state.translate('notSupportedHeader')}</div> <div class="title">${state.translate('notSupportedHeader')}</div>
<div class="description">${state.translate( <div class="description">
'notSupportedOutdatedDetail' ${state.translate('notSupportedOutdatedDetail')}
)}</div> </div>
<a id="update-firefox" href="https://support.mozilla.org/kb/update-firefox-latest-version"> <a
<img src="${assets.get( id="update-firefox"
'firefox_logo-only.svg' href="https://support.mozilla.org/kb/update-firefox-latest-version">
)}" class="firefox-logo" alt="Firefox"/> <img
<div class="unsupported-button-text">${state.translate( src="${assets.get('firefox_logo-only.svg')}"
'updateFirefox' class="firefox-logo"
)}</div> alt="Firefox"/>
<div class="unsupported-button-text">
${state.translate('updateFirefox')}
</div>
</a> </a>
<div class="unsupported-description">${state.translate( <div class="unsupported-description">
'uploadPageExplainer' ${state.translate('uploadPageExplainer')}
)}</div> </div>
</div>` </div>`
: html` : html`
<div id="unsupported-browser"> <div id="unsupported-browser">
<div class="title">${state.translate('notSupportedHeader')}</div> <div class="title">${state.translate('notSupportedHeader')}</div>
<div class="description">${state.translate('notSupportedDetail')}</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( <div class="description">
'notSupportedLink' <a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">
)}</a></div> ${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"> <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( <img
'firefox_logo-only.svg' src="${assets.get('firefox_logo-only.svg')}"
)}" class="firefox-logo" alt="Firefox"/> class="firefox-logo"
alt="Firefox"/>
<div class="unsupported-button-text">Firefox<br> <div class="unsupported-button-text">Firefox<br>
<span>${state.translate('downloadFirefoxButtonSub')}</span> <span>${state.translate('downloadFirefoxButtonSub')}</span>
</div> </div>
</a> </a>
<div class="unsupported-description">${state.translate( <div class="unsupported-description">
'uploadPageExplainer' ${state.translate('uploadPageExplainer')}
)}</div> </div>
</div>`; </div>`;
const div = html`<div id="page-one">${msg}</div>`; const div = html`<div id="page-one">${msg}</div>`;
return div; return div;

View File

@ -8,23 +8,24 @@ module.exports = function(state, emit) {
const div = html` const div = html`
<div id="download"> <div id="download">
<div id="upload-progress" class="fadeIn"> <div id="upload-progress" class="fadeIn">
<div class="title" id="upload-filename">${state.translate( <div class="title" id="upload-filename">
'uploadingPageProgress', ${state.translate('uploadingPageProgress', {
{
filename: transfer.file.name, filename: transfer.file.name,
size: bytes(transfer.file.size) size: bytes(transfer.file.size)
} })}
)}</div> </div>
<div class="description"></div> <div class="description"></div>
${progress(transfer.progressRatio)} ${progress(transfer.progressRatio)}
<div class="upload"> <div class="upload">
<div class="progress-text">${state.translate( <div class="progress-text">
transfer.msg, ${state.translate(transfer.msg, transfer.sizes)}
transfer.sizes </div>
)}</div> <button
<button id="cancel-upload" title="${state.translate( id="cancel-upload"
'uploadingPageCancel' title="${state.translate('uploadingPageCancel')}"
)}" onclick=${cancel}>${state.translate('uploadingPageCancel')}</button> onclick=${cancel}>
${state.translate('uploadingPageCancel')}
</button>
</div> </div>
</div> </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` const div = html`
<div class="selectPassword"> <div class="selectPassword">
<div id="addPasswordWrapper"> <div id="addPasswordWrapper">
<input id="addPassword" type="checkbox" autocomplete="off" onchange=${togglePasswordInput}/> <input
id="addPassword"
type="checkbox"
autocomplete="off"
onchange=${togglePasswordInput}/>
<label for="addPassword"> <label for="addPassword">
${state.translate('requirePasswordCheckbox')}</label> ${state.translate('requirePasswordCheckbox')}
</label>
</div> </div>
<form class="setPassword hidden" onsubmit=${setPassword} data-no-csrf> <form class="setPassword hidden" onsubmit=${setPassword} data-no-csrf>
<input id="unlock-input" <input id="unlock-input"
@ -52,12 +57,11 @@ module.exports = function(state, emit) {
function setPassword(event) { function setPassword(event) {
event.preventDefault(); event.preventDefault();
const existingPassword = null;
const password = document.getElementById('unlock-input').value; const password = document.getElementById('unlock-input').value;
if (password.length > 0) { if (password.length > 0) {
document.getElementById('copy').classList.remove('wait-password'); document.getElementById('copy').classList.remove('wait-password');
document.getElementById('copy-btn').disabled = false; 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="title">${state.translate('uploadPageHeader')}</div>
<div class="description"> <div class="description">
<div>${state.translate('uploadPageExplainer')}</div> <div>${state.translate('uploadPageExplainer')}</div>
<a href="https://testpilot.firefox.com/experiments/send" <a
class="link">${state.translate('uploadPageLearnMore')}</a> href="https://testpilot.firefox.com/experiments/send"
class="link">
${state.translate('uploadPageLearnMore')}
</a>
</div> </div>
<div class="upload-window" <div class="upload-window"
ondragover=${dragover} ondragover=${dragover}
ondragleave=${dragleave}> ondragleave=${dragleave}>
<div id="upload-img"> <div id="upload-img">
<img src="${assets.get('upload.svg')}" <img
src="${assets.get('upload.svg')}"
title="${state.translate('uploadSvgAlt')}"/> title="${state.translate('uploadSvgAlt')}"/>
</div> </div>
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div> <div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
@ -34,7 +38,8 @@ module.exports = function(state, emit) {
id="browse" id="browse"
class="btn browse" class="btn browse"
title="${state.translate('uploadPageBrowseButton1')}"> title="${state.translate('uploadPageBrowseButton1')}">
${state.translate('uploadPageBrowseButton1')}</label> ${state.translate('uploadPageBrowseButton1')}
</label>
</div> </div>
${fileList(state, emit)} ${fileList(state, emit)}
</div> </div>

View File

@ -15,21 +15,6 @@ function b64ToArray(str) {
return b64.toByteArray(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) { function loadShim(polyfill) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const shim = document.createElement('script'); const shim = document.createElement('script');
@ -148,7 +133,37 @@ function fadeOut(id) {
return delay(300); 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 = { module.exports = {
fadeOut, fadeOut,
@ -159,8 +174,8 @@ module.exports = {
copyToClipboard, copyToClipboard,
arrayToB64, arrayToB64,
b64ToArray, b64ToArray,
notify,
canHasSend, canHasSend,
isFile, 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 // This label is followed by the password needed to download a file
passwordResult = Password: { $password } passwordResult = Password: { $password }
reportIPInfringement = Report IP Infringement 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

@ -15,9 +15,9 @@ const conf = convict({
env: 'REDIS_HOST' env: 'REDIS_HOST'
}, },
listen_address: { listen_address: {
format: 'ipaddress', format: 'ipaddress',
default: '0.0.0.0', default: '0.0.0.0',
env: 'IP_ADDRESS' env: 'IP_ADDRESS'
}, },
listen_port: { listen_port: {
format: 'port', format: 'port',

View File

@ -24,4 +24,4 @@ app.use(
app.use(pages.notfound); 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( routes.toString(
`/download/${req.params.id}`, `/download/${req.params.id}`,
Object.assign(state(req), { Object.assign(state(req), {
fileInfo: { nonce, pwd: +pwd } fileInfo: { nonce, requiresPassword: +pwd }
}) })
) )
) )

View File

@ -1,5 +1,4 @@
const storage = require('../storage'); const storage = require('../storage');
const crypto = require('crypto');
function validateID(route_id) { function validateID(route_id) {
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null; return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
@ -10,27 +9,24 @@ module.exports = async function(req, res) {
if (!validateID(id)) { if (!validateID(id)) {
return res.sendStatus(404); 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); return res.sendStatus(400);
} }
try { try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id); const meta = await storage.metadata(id);
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64')); if (meta.owner !== ownerToken) {
hmac.update(Buffer.from(meta.nonce, 'base64')); return res.sendStatus(404);
const verifyHash = hmac.digest();
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
return res.sendStatus(401);
} }
storage.setField(id, 'auth', auth);
storage.setField(id, 'pwd', 1);
res.sendStatus(200);
} catch (e) { } catch (e) {
return res.sendStatus(404); 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); .expect(404);
}); });
it('Successfully deletes if the id is valid and the delete token matches', function( it('Successfully deletes if the id is valid and the delete token matches', function(done) {
done
) {
request(server) request(server)
.post('/delete/' + fileId) .post('/delete/' + fileId)
.send({ delete_token: uuid }) .send({ delete_token: uuid })