diff --git a/app/api.js b/app/api.js new file mode 100644 index 00000000..69c7216c --- /dev/null +++ b/app/api.js @@ -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; +} diff --git a/app/fileManager.js b/app/fileManager.js index acbe9161..9be93215 100644 --- a/app/fileManager.js +++ b/app/fileManager.js @@ -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); - receiver.on('progress', updateProgress); - receiver.on('decrypting', render); - state.transfer = receiver; + const receiver = new FileReceiver(file); try { - await receiver.getMetadata(file.nonce); + await receiver.getMetadata(); + receiver.on('progress', updateProgress); + receiver.on('decrypting', render); + state.transfer = receiver; } 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 ( diff --git a/app/fileReceiver.js b/app/fileReceiver.js index 0766f71e..03347dcd 100644 --- a/app/fileReceiver.js +++ b/app/fileReceiver.js @@ -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 - } - - 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; + this.cancelled = true; + if (this.fileDownload) { + this.fileDownload.cancel(); } } - 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); - } - - 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 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'; + return; + } + this.state = 'invalid'; + return; } - 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'; diff --git a/app/fileSender.js b/app/fileSender.js index 3965edf5..fd6465c1 100644 --- a/app/fileSender.js +++ b/app/fileSender.js @@ -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 { - throw e; - } + this.msg = 'errorPageHeader'; + this.uploadRequest = null; + throw e; } } } diff --git a/app/keychain.js b/app/keychain.js new file mode 100644 index 00000000..bb19864b --- /dev/null +++ b/app/keychain.js @@ -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)); + } +} diff --git a/app/main.js b/app/main.js index 7f7e0402..0b41b75d 100644 --- a/app/main.js +++ b/app/main.js @@ -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}`) + ); } }); }); diff --git a/app/ownedFile.js b/app/ownedFile.js new file mode 100644 index 00000000..b482caf8 --- /dev/null +++ b/app/ownedFile.js @@ -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 + }; + } +} diff --git a/app/routes/download.js b/app/routes/download.js index c0a47a4c..ca0f2b71 100644 --- a/app/routes/download.js +++ b/app/routes/download.js @@ -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); }; diff --git a/app/routes/home.js b/app/routes/home.js index 0059ceb0..2be53047 100644 --- a/app/routes/home.js +++ b/app/routes/home.js @@ -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); diff --git a/app/routes/index.js b/app/routes/index.js index 4bf6a55a..dfe6c34c 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -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; diff --git a/app/storage.js b/app/storage.js index 27cba1cb..64d591fc 100644 --- a/app/storage.js +++ b/app/storage.js @@ -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)); } } diff --git a/app/templates/blank.js b/app/templates/blank.js index 080a3232..ec104ac3 100644 --- a/app/templates/blank.js +++ b/app/templates/blank.js @@ -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; }; diff --git a/app/templates/completed.js b/app/templates/completed.js index 6415a0c0..88bb9958 100644 --- a/app/templates/completed.js +++ b/app/templates/completed.js @@ -5,21 +5,22 @@ const { fadeOut } = require('../utils'); module.exports = function(state, emit) { const div = html` <div id="page-one"> - <div id="download" class="fadeIn"> - <div id="download-progress"> - <div id="dl-title" class="title">${state.translate( - 'downloadFinish' - )}</div> - <div class="description"></div> - ${progress(1)} - <div class="upload"> - <div class="progress-text"></div> + <div id="download" class="fadeIn"> + <div id="download-progress"> + <div id="dl-title" class="title"> + ${state.translate('downloadFinish')} + </div> + <div class="description"></div> + ${progress(1)} + <div class="upload"> + <div class="progress-text"></div> + </div> </div> + <a class="send-new" + data-state="completed" + href="/" + onclick=${sendNew}>${state.translate('sendYourFilesLink')}</a> </div> - <a class="send-new" data-state="completed" href="/" onclick=${ - sendNew - }>${state.translate('sendYourFilesLink')}</a> - </div> </div> `; diff --git a/app/templates/download.js b/app/templates/download.js index eca55893..8e0c81c8 100644 --- a/app/templates/download.js +++ b/app/templates/download.js @@ -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; }; diff --git a/app/templates/downloadButton.js b/app/templates/downloadButton.js new file mode 100644 index 00000000..be01333f --- /dev/null +++ b/app/templates/downloadButton.js @@ -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>`; +}; diff --git a/app/templates/downloadPassword.js b/app/templates/downloadPassword.js index 4ce13608..dea30a03 100644 --- a/app/templates/downloadPassword.js +++ b/app/templates/downloadPassword.js @@ -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'); } } diff --git a/app/templates/file.js b/app/templates/file.js index 8d658ad7..8a56d090 100644 --- a/app/templates/file.js +++ b/app/templates/file.js @@ -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> diff --git a/app/templates/fileList.js b/app/templates/fileList.js index 6e63e31a..ece4ebcf 100644 --- a/app/templates/fileList.js +++ b/app/templates/fileList.js @@ -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> diff --git a/app/templates/footer.js b/app/templates/footer.js index 96925b04..a2dfe276 100644 --- a/app/templates/footer.js +++ b/app/templates/footer.js @@ -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>`; }; diff --git a/app/templates/header.js b/app/templates/header.js index 3c0e1bb0..761b8188 100644 --- a/app/templates/header.js +++ b/app/templates/header.js @@ -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> diff --git a/app/templates/notFound.js b/app/templates/notFound.js index c29bf194..495b8b08 100644 --- a/app/templates/notFound.js +++ b/app/templates/notFound.js @@ -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; diff --git a/app/templates/preview.js b/app/templates/preview.js index 63b1ae2a..5916780d 100644 --- a/app/templates/preview.js +++ b/app/templates/preview.js @@ -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; }; diff --git a/app/templates/progress.js b/app/templates/progress.js index 997e05ba..fe0cf6b7 100644 --- a/app/templates/progress.js +++ b/app/templates/progress.js @@ -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> `; diff --git a/app/templates/selectbox.js b/app/templates/selectbox.js index dc1290e2..be42da33 100644 --- a/app/templates/selectbox.js +++ b/app/templates/selectbox.js @@ -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>`; diff --git a/app/templates/share.js b/app/templates/share.js index 2aa31230..1ee8b4ba 100644 --- a/app/templates/share.js +++ b/app/templates/share.js @@ -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'); diff --git a/app/templates/unsupported.js b/app/templates/unsupported.js index b3aa362e..03a55883 100644 --- a/app/templates/unsupported.js +++ b/app/templates/unsupported.js @@ -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; diff --git a/app/templates/upload.js b/app/templates/upload.js index 98dc28fe..c8bb2caf 100644 --- a/app/templates/upload.js +++ b/app/templates/upload.js @@ -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> diff --git a/app/templates/uploadPasswordSet.js b/app/templates/uploadPasswordSet.js new file mode 100644 index 00000000..a8cb4fb5 --- /dev/null +++ b/app/templates/uploadPasswordSet.js @@ -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(); + } +}; diff --git a/app/templates/uploadPassword.js b/app/templates/uploadPasswordUnset.js similarity index 87% rename from app/templates/uploadPassword.js rename to app/templates/uploadPasswordUnset.js index d04776bb..9ddbaaba 100644 --- a/app/templates/uploadPassword.js +++ b/app/templates/uploadPasswordUnset.js @@ -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 }); } } diff --git a/app/templates/welcome.js b/app/templates/welcome.js index d62038c7..172f06a5 100644 --- a/app/templates/welcome.js +++ b/app/templates/welcome.js @@ -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> diff --git a/app/utils.js b/app/utils.js index 06f480f9..9fea560c 100644 --- a/app/utils.js +++ b/app/utils.js @@ -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 }; diff --git a/public/locales/en-US/send.ftl b/public/locales/en-US/send.ftl index 7cd55c53..049c498e 100644 --- a/public/locales/en-US/send.ftl +++ b/public/locales/en-US/send.ftl @@ -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 \ No newline at end of file diff --git a/server/config.js b/server/config.js index 4bad58d8..29d25644 100644 --- a/server/config.js +++ b/server/config.js @@ -15,9 +15,9 @@ const conf = convict({ env: 'REDIS_HOST' }, listen_address: { - format: 'ipaddress', - default: '0.0.0.0', - env: 'IP_ADDRESS' + format: 'ipaddress', + default: '0.0.0.0', + env: 'IP_ADDRESS' }, listen_port: { format: 'port', diff --git a/server/prod.js b/server/prod.js index 0515faf7..4177b160 100644 --- a/server/prod.js +++ b/server/prod.js @@ -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); diff --git a/server/routes/pages.js b/server/routes/pages.js index d0385fbf..f7fe35f0 100644 --- a/server/routes/pages.js +++ b/server/routes/pages.js @@ -35,7 +35,7 @@ module.exports = { routes.toString( `/download/${req.params.id}`, Object.assign(state(req), { - fileInfo: { nonce, pwd: +pwd } + fileInfo: { nonce, requiresPassword: +pwd } }) ) ) diff --git a/server/routes/password.js b/server/routes/password.js index fbb01dfa..ec861585 100644 --- a/server/routes/password.js +++ b/server/routes/password.js @@ -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); }; diff --git a/test/server/server.test.js b/test/server/server.test.js index 89a6d1ed..64402679 100644 --- a/test/server/server.test.js +++ b/test/server/server.test.js @@ -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 })