import Nanobus from 'nanobus'; import Keychain from './keychain'; import { delay, bytes, streamToArrayBuffer } from './utils'; import { downloadFile, downloadDone, metadata, getApiUrl, reportLink, getDownloadToken } from './api'; import { blobStream } from './streams'; import Zip from './zip'; export default class FileReceiver extends Nanobus { constructor(fileInfo) { super('FileReceiver'); this.keychain = new Keychain(fileInfo.secretKey, fileInfo.nonce); if (fileInfo.requiresPassword) { this.keychain.setPassword(fileInfo.password, fileInfo.url); } this.fileInfo = fileInfo; this.dlToken = null; this.reset(); } get id() { return this.fileInfo.id; } get progressRatio() { return this.progress[0] / this.progress[1]; } get progressIndefinite() { return this.state !== 'downloading'; } get sizes() { return { partialSize: bytes(this.progress[0]), totalSize: bytes(this.progress[1]) }; } cancel() { if (this.downloadRequest) { this.downloadRequest.cancel(); } } reset() { this.msg = 'fileSizeProgress'; this.state = 'initialized'; this.progress = [0, 1]; } async getMetadata() { const meta = await metadata(this.fileInfo.id, this.keychain); this.fileInfo.name = meta.name; this.fileInfo.type = meta.type; this.fileInfo.size = +meta.size; this.fileInfo.manifest = meta.manifest; this.fileInfo.flagged = meta.flagged; this.state = 'ready'; } async reportLink(reason) { await reportLink(this.fileInfo.id, this.keychain, reason); } sendMessageToSw(msg) { return new Promise((resolve, reject) => { const channel = new MessageChannel(); channel.port1.onmessage = function(event) { if (event.data === undefined) { reject('bad response from serviceWorker'); } else if (event.data.error !== undefined) { reject(event.data.error); } else { resolve(event.data); } }; navigator.serviceWorker.controller.postMessage(msg, [channel.port2]); }); } async downloadBlob(noSave = false) { this.state = 'downloading'; this.downloadRequest = await downloadFile( this.fileInfo.id, this.dlToken, p => { this.progress = [p, this.fileInfo.size]; this.emit('progress'); } ); try { const ciphertext = await this.downloadRequest.result; this.downloadRequest = null; this.msg = 'decryptingFile'; this.state = 'decrypting'; this.emit('decrypting'); let size = this.fileInfo.size; let plainStream = this.keychain.decryptStream(blobStream(ciphertext)); if (this.fileInfo.type === 'send-archive') { const zip = new Zip(this.fileInfo.manifest, plainStream); plainStream = zip.stream; size = zip.size; } const plaintext = await streamToArrayBuffer(plainStream, size); if (!noSave) { await saveFile({ plaintext, name: decodeURIComponent(this.fileInfo.name), type: this.fileInfo.type }); } this.msg = 'downloadFinish'; this.emit('complete'); this.state = 'complete'; } catch (e) { this.downloadRequest = null; throw e; } } async downloadStream(noSave = false) { const start = Date.now(); const onprogress = p => { this.progress = [p, this.fileInfo.size]; this.emit('progress'); }; this.downloadRequest = { cancel: () => { this.sendMessageToSw({ request: 'cancel', id: this.fileInfo.id }); } }; try { this.state = 'downloading'; const info = { request: 'init', id: this.fileInfo.id, filename: this.fileInfo.name, type: this.fileInfo.type, manifest: this.fileInfo.manifest, key: this.fileInfo.secretKey, requiresPassword: this.fileInfo.requiresPassword, password: this.fileInfo.password, url: this.fileInfo.url, size: this.fileInfo.size, nonce: this.keychain.nonce, dlToken: this.dlToken, noSave }; await this.sendMessageToSw(info); onprogress(0); if (noSave) { const res = await fetch(getApiUrl(`/api/download/${this.fileInfo.id}`)); if (res.status !== 200) { throw new Error(res.status); } } else { const downloadPath = `/api/download/${this.fileInfo.id}`; let downloadUrl = getApiUrl(downloadPath); if (downloadUrl === downloadPath) { downloadUrl = `${location.protocol}//${location.host}${downloadPath}`; } const a = document.createElement('a'); a.href = downloadUrl; document.body.appendChild(a); a.click(); } let prog = 0; let hangs = 0; while (prog < this.fileInfo.size) { const msg = await this.sendMessageToSw({ request: 'progress', id: this.fileInfo.id }); if (msg.progress === prog) { hangs++; } else { hangs = 0; } if (hangs > 30) { // TODO: On Chrome we don't get a cancel // signal so one is indistinguishable from // a hang. We may be able to detect // which end is hung in the service worker // to improve on this. const e = new Error('hung download'); e.duration = Date.now() - start; e.size = this.fileInfo.size; e.progress = prog; throw e; } prog = msg.progress; onprogress(prog); await delay(1000); } this.downloadRequest = null; this.msg = 'downloadFinish'; this.emit('complete'); this.state = 'complete'; } catch (e) { this.downloadRequest = null; if (e === 'cancelled' || e.message === '400') { throw new Error(0); } throw e; } } async download({ stream, storage, noSave }) { this.dlToken = storage.getDownloadToken(this.id); if (!this.dlToken) { this.dlToken = await getDownloadToken(this.id, this.keychain); storage.setDownloadToken(this.id, this.dlToken); } if (stream) { await this.downloadStream(noSave); } else { await this.downloadBlob(noSave); } await downloadDone(this.id, this.dlToken); storage.setDownloadToken(this.id); } } async function saveFile(file) { return new Promise(function(resolve, reject) { const dataView = new DataView(file.plaintext); const blob = new Blob([dataView], { type: file.type }); if (navigator.msSaveBlob) { navigator.msSaveBlob(blob, file.name); return resolve(); } else if (/iPhone|fxios/i.test(navigator.userAgent)) { // This method is much slower but createObjectURL // is buggy on iOS const reader = new FileReader(); reader.addEventListener('loadend', function() { if (reader.error) { return reject(reader.error); } if (reader.result) { const a = document.createElement('a'); a.href = reader.result; a.download = file.name; document.body.appendChild(a); a.click(); } resolve(); }); reader.readAsDataURL(blob); } else { const downloadUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; a.download = file.name; document.body.appendChild(a); a.click(); URL.revokeObjectURL(downloadUrl); setTimeout(resolve, 100); } }); }