Add optional password to the download url
This commit is contained in:
parent
837747f8f7
commit
bc24a069da
28 changed files with 805 additions and 241 deletions
|
@ -153,6 +153,7 @@ export default function(state, emitter) {
|
|||
state.storage.totalUploads += 1;
|
||||
emitter.emit('pushState', `/share/${info.id}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
state.transfer = null;
|
||||
if (err.message === '0') {
|
||||
//cancelled. do nothing
|
||||
|
@ -161,23 +162,51 @@ export default function(state, emitter) {
|
|||
}
|
||||
state.raven.captureException(err);
|
||||
metrics.stoppedUpload({ size, type, err });
|
||||
emitter.emit('replaceState', '/error');
|
||||
emitter.emit('pushState', '/error');
|
||||
}
|
||||
});
|
||||
|
||||
emitter.on('download', async file => {
|
||||
const size = file.size;
|
||||
emitter.on('password', async ({ password, file }) => {
|
||||
try {
|
||||
await FileSender.setPassword(password, file);
|
||||
metrics.addedPassword({ size: file.size });
|
||||
file.password = password;
|
||||
state.storage.writeFiles();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('preview', async () => {
|
||||
const file = state.fileInfo;
|
||||
const url = `/api/download/${file.id}`;
|
||||
const receiver = new FileReceiver(url, file.key);
|
||||
const receiver = new FileReceiver(url, file);
|
||||
receiver.on('progress', updateProgress);
|
||||
receiver.on('decrypting', render);
|
||||
state.transfer = receiver;
|
||||
const links = openLinksInNewTab();
|
||||
try {
|
||||
await receiver.getMetadata(file.nonce);
|
||||
} catch (e) {
|
||||
if (e.message === '401') {
|
||||
file.password = null;
|
||||
if (!file.pwd) {
|
||||
return emitter.emit('pushState', '/404');
|
||||
}
|
||||
}
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('download', async file => {
|
||||
state.transfer.on('progress', render);
|
||||
state.transfer.on('decrypting', render);
|
||||
const links = openLinksInNewTab();
|
||||
const size = file.size;
|
||||
try {
|
||||
const start = Date.now();
|
||||
metrics.startedDownload({ size: file.size, ttl: file.ttl });
|
||||
const f = await receiver.download();
|
||||
const f = await state.transfer.download(file.nonce);
|
||||
const time = Date.now() - start;
|
||||
const speed = size / (time / 1000);
|
||||
await delay(1000);
|
||||
|
@ -187,13 +216,14 @@ export default function(state, emitter) {
|
|||
metrics.completedDownload({ size, time, speed });
|
||||
emitter.emit('pushState', '/completed');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// TODO cancelled download
|
||||
const location = err.message === 'notfound' ? '/404' : '/error';
|
||||
if (location === '/error') {
|
||||
state.raven.captureException(err);
|
||||
metrics.stoppedDownload({ size, err });
|
||||
}
|
||||
emitter.emit('replaceState', location);
|
||||
emitter.emit('pushState', location);
|
||||
} finally {
|
||||
state.transfer = null;
|
||||
openLinksInNewTab(links, false);
|
||||
|
|
|
@ -1,25 +1,104 @@
|
|||
import Nanobus from 'nanobus';
|
||||
import { hexToArray, bytes } from './utils';
|
||||
import { arrayToB64, b64ToArray, bytes } from './utils';
|
||||
|
||||
export default class FileReceiver extends Nanobus {
|
||||
constructor(url, k) {
|
||||
constructor(url, file) {
|
||||
super('FileReceiver');
|
||||
this.key = window.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
{
|
||||
k,
|
||||
kty: 'oct',
|
||||
alg: 'A128GCM',
|
||||
ext: true
|
||||
},
|
||||
{
|
||||
name: 'AES-GCM'
|
||||
},
|
||||
this.secretKeyPromise = window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
b64ToArray(file.key),
|
||||
'HKDF',
|
||||
false,
|
||||
['decrypt']
|
||||
['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();
|
||||
console.log(file.password + file.url);
|
||||
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 => {
|
||||
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.msg = 'fileSizeProgress';
|
||||
this.state = 'initialized';
|
||||
this.progress = [0, 1];
|
||||
}
|
||||
|
||||
|
@ -38,7 +117,65 @@ export default class FileReceiver extends Nanobus {
|
|||
// TODO
|
||||
}
|
||||
|
||||
downloadFile() {
|
||||
fetchMetadata(sig) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
|
||||
this.file.nonce = nonce;
|
||||
if (xhr.status === 200) {
|
||||
return resolve(xhr.response);
|
||||
}
|
||||
reject(new Error(xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error(0));
|
||||
xhr.ontimeout = () => reject(new Error(0));
|
||||
xhr.open('get', `/api/metadata/${this.file.id}`);
|
||||
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`);
|
||||
xhr.responseType = 'json';
|
||||
xhr.timeout = 2000;
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
async getMetadata(nonce) {
|
||||
try {
|
||||
const authKey = await this.authKeyPromise;
|
||||
const sig = await window.crypto.subtle.sign(
|
||||
{
|
||||
name: 'HMAC'
|
||||
},
|
||||
authKey,
|
||||
b64ToArray(nonce)
|
||||
);
|
||||
const data = await this.fetchMetadata(new Uint8Array(sig));
|
||||
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.state = 'ready';
|
||||
} catch (e) {
|
||||
this.state = 'invalid';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
downloadFile(sig) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
|
@ -49,52 +186,67 @@ export default class FileReceiver extends Nanobus {
|
|||
}
|
||||
};
|
||||
|
||||
xhr.onload = function(event) {
|
||||
xhr.onload = event => {
|
||||
if (xhr.status === 404) {
|
||||
reject(new Error('notfound'));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([this.response]);
|
||||
const meta = JSON.parse(xhr.getResponseHeader('X-File-Metadata'));
|
||||
if (xhr.status !== 200) {
|
||||
return reject(new Error(xhr.status));
|
||||
}
|
||||
|
||||
const blob = new Blob([xhr.response]);
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = function() {
|
||||
resolve({
|
||||
data: this.result,
|
||||
name: meta.filename,
|
||||
type: meta.mimeType,
|
||||
iv: meta.id
|
||||
});
|
||||
resolve(this.result);
|
||||
};
|
||||
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
};
|
||||
|
||||
xhr.open('get', this.url);
|
||||
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
async download() {
|
||||
const key = await this.key;
|
||||
const file = await this.downloadFile();
|
||||
this.msg = 'decryptingFile';
|
||||
this.emit('decrypting');
|
||||
const plaintext = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: hexToArray(file.iv),
|
||||
tagLength: 128
|
||||
},
|
||||
key,
|
||||
file.data
|
||||
);
|
||||
this.msg = 'downloadFinish';
|
||||
return {
|
||||
plaintext,
|
||||
name: decodeURIComponent(file.name),
|
||||
type: file.type
|
||||
};
|
||||
async download(nonce) {
|
||||
this.state = 'downloading';
|
||||
this.emit('progress', this.progress);
|
||||
try {
|
||||
const encryptKey = await this.encryptKeyPromise;
|
||||
const authKey = await this.authKeyPromise;
|
||||
const sig = await window.crypto.subtle.sign(
|
||||
{
|
||||
name: 'HMAC'
|
||||
},
|
||||
authKey,
|
||||
b64ToArray(nonce)
|
||||
);
|
||||
const ciphertext = await this.downloadFile(new Uint8Array(sig));
|
||||
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
|
||||
);
|
||||
this.msg = 'downloadFinish';
|
||||
this.state = 'complete';
|
||||
return {
|
||||
plaintext,
|
||||
name: decodeURIComponent(this.file.name),
|
||||
type: this.file.type
|
||||
};
|
||||
} catch (e) {
|
||||
this.state = 'invalid';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Nanobus from 'nanobus';
|
||||
import { arrayToHex, bytes } from './utils';
|
||||
import { arrayToB64, b64ToArray, bytes } from './utils';
|
||||
|
||||
export default class FileSender extends Nanobus {
|
||||
constructor(file) {
|
||||
|
@ -10,13 +10,13 @@ export default class FileSender extends Nanobus {
|
|||
this.cancelled = false;
|
||||
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
this.uploadXHR = new XMLHttpRequest();
|
||||
this.key = window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 128
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
this.secretKey = window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
this.rawSecret,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -71,14 +71,12 @@ export default class FileSender extends Nanobus {
|
|||
});
|
||||
}
|
||||
|
||||
uploadFile(encrypted, keydata) {
|
||||
uploadFile(encrypted, metadata, rawAuth) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = this.file;
|
||||
const id = arrayToHex(this.iv);
|
||||
const dataView = new DataView(encrypted);
|
||||
const blob = new Blob([dataView], { type: file.type });
|
||||
const blob = new Blob([dataView], { type: 'application/octet-stream' });
|
||||
const fd = new FormData();
|
||||
fd.append('data', blob, file.name);
|
||||
fd.append('data', blob);
|
||||
|
||||
const xhr = this.uploadXHR;
|
||||
|
||||
|
@ -92,14 +90,18 @@ export default class FileSender extends Nanobus {
|
|||
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: keydata.k,
|
||||
deleteToken: responseObj.delete
|
||||
secretKey: arrayToB64(this.rawSecret),
|
||||
deleteToken: responseObj.delete,
|
||||
nonce
|
||||
});
|
||||
}
|
||||
this.msg = 'errorPageHeader';
|
||||
|
@ -110,18 +112,62 @@ export default class FileSender extends Nanobus {
|
|||
xhr.open('post', '/api/upload', true);
|
||||
xhr.setRequestHeader(
|
||||
'X-File-Metadata',
|
||||
JSON.stringify({
|
||||
id: id,
|
||||
filename: encodeURIComponent(file.name)
|
||||
})
|
||||
arrayToB64(new Uint8Array(metadata))
|
||||
);
|
||||
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
|
||||
xhr.send(fd);
|
||||
this.msg = 'fileSizeProgress';
|
||||
});
|
||||
}
|
||||
|
||||
async upload() {
|
||||
const key = await this.key;
|
||||
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();
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
|
@ -134,13 +180,112 @@ export default class FileSender extends Nanobus {
|
|||
iv: this.iv,
|
||||
tagLength: 128
|
||||
},
|
||||
key,
|
||||
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
|
||||
})
|
||||
)
|
||||
);
|
||||
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
}
|
||||
const keydata = await window.crypto.subtle.exportKey('jwk', key);
|
||||
return this.uploadFile(encrypted, keydata);
|
||||
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
|
||||
}
|
||||
|
||||
static async setPassword(password, file) {
|
||||
const encoder = new TextEncoder();
|
||||
const secretKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
b64ToArray(file.secretKey),
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
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 sig = await window.crypto.subtle.sign(
|
||||
{
|
||||
name: 'HMAC'
|
||||
},
|
||||
authKey,
|
||||
b64ToArray(file.nonce)
|
||||
);
|
||||
const pwdKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
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);
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
if (xhr.status === 200) {
|
||||
return resolve(xhr.response);
|
||||
}
|
||||
if (xhr.status === 401) {
|
||||
const nonce = xhr
|
||||
.getResponseHeader('WWW-Authenticate')
|
||||
.split(' ')[1];
|
||||
file.nonce = nonce;
|
||||
}
|
||||
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',
|
||||
`send-v1 ${arrayToB64(new Uint8Array(sig))}`
|
||||
);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.responseType = 'json';
|
||||
xhr.timeout = 2000;
|
||||
xhr.send(JSON.stringify({ auth: arrayToB64(new Uint8Array(rawAuth)) }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,6 +147,15 @@ function completedUpload(params) {
|
|||
});
|
||||
}
|
||||
|
||||
function addedPassword(params) {
|
||||
return sendEvent('sender', 'password-added', {
|
||||
cm1: params.size,
|
||||
cm5: storage.totalUploads,
|
||||
cm6: storage.files.length,
|
||||
cm7: storage.totalDownloads
|
||||
});
|
||||
}
|
||||
|
||||
function startedDownload(params) {
|
||||
return sendEvent('recipient', 'download-started', {
|
||||
cm1: params.size,
|
||||
|
@ -262,6 +271,7 @@ export {
|
|||
cancelledDownload,
|
||||
stoppedDownload,
|
||||
completedDownload,
|
||||
addedPassword,
|
||||
restart,
|
||||
unsupported
|
||||
};
|
||||
|
|
|
@ -3,7 +3,10 @@ const download = require('../templates/download');
|
|||
|
||||
module.exports = function(state, emit) {
|
||||
if (state.transfer) {
|
||||
return download(state, emit);
|
||||
const s = state.transfer.state;
|
||||
if (s === 'downloading' || s === 'complete') {
|
||||
return download(state, emit);
|
||||
}
|
||||
}
|
||||
return preview(state, emit);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,8 @@ const welcome = require('../templates/welcome');
|
|||
const upload = require('../templates/upload');
|
||||
|
||||
module.exports = function(state, emit) {
|
||||
if (state.transfer) {
|
||||
if (state.transfer && state.transfer.iv) {
|
||||
//TODO relying on 'iv' is gross
|
||||
return upload(state, emit);
|
||||
}
|
||||
return welcome(state, emit);
|
||||
|
|
|
@ -92,11 +92,7 @@ class Storage {
|
|||
}
|
||||
|
||||
getFileById(id) {
|
||||
try {
|
||||
return JSON.parse(this.engine.getItem(id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return this._files.find(f => f.id === id);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
|
@ -114,6 +110,10 @@ class Storage {
|
|||
this._files.push(file);
|
||||
this.engine.setItem(file.id, JSON.stringify(file));
|
||||
}
|
||||
|
||||
writeFiles() {
|
||||
this._files.forEach(f => this.engine.setItem(f.id, JSON.stringify(f)));
|
||||
}
|
||||
}
|
||||
|
||||
export default new Storage();
|
||||
|
|
41
app/templates/downloadPassword.js
Normal file
41
app/templates/downloadPassword.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
const html = require('choo/html');
|
||||
|
||||
module.exports = function(state, emit) {
|
||||
const fileInfo = state.fileInfo;
|
||||
const label =
|
||||
fileInfo.password === null
|
||||
? html`
|
||||
<label class="red"
|
||||
for="unlock-input">${state.translate('incorrectPassword')}</label>`
|
||||
: html`
|
||||
<label for="unlock-input">
|
||||
${state.translate('unlockInputLabel')}
|
||||
</label>`;
|
||||
const div = html`
|
||||
<div class="enterPassword">
|
||||
${label}
|
||||
<form id="unlock" onsubmit=${checkPassword}>
|
||||
<input id="unlock-input"
|
||||
autocomplete="off"
|
||||
placeholder="${state.translate('unlockInputPlaceholder')}"
|
||||
type="password"/>
|
||||
<input type="submit"
|
||||
id="unlock-btn"
|
||||
class="btn"
|
||||
value="${state.translate('unlockButtonLabel')}"/>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
function checkPassword(event) {
|
||||
event.preventDefault();
|
||||
const password = document.getElementById('unlock-input').value;
|
||||
if (password.length > 0) {
|
||||
document.getElementById('unlock-btn').disabled = true;
|
||||
state.fileInfo.url = window.location.href;
|
||||
state.fileInfo.password = password;
|
||||
emit('preview');
|
||||
}
|
||||
}
|
||||
|
||||
return div;
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
const html = require('choo/html');
|
||||
const assets = require('../../common/assets');
|
||||
const notFound = require('./notFound');
|
||||
const downloadPassword = require('./downloadPassword');
|
||||
const { bytes } = require('../utils');
|
||||
|
||||
function getFileFromDOM() {
|
||||
|
@ -8,11 +9,9 @@ function getFileFromDOM() {
|
|||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
const data = el.dataset;
|
||||
return {
|
||||
name: data.name,
|
||||
size: parseInt(data.size, 10),
|
||||
ttl: parseInt(data.ttl, 10)
|
||||
nonce: el.getAttribute('data-nonce'),
|
||||
pwd: !!+el.getAttribute('data-requires-password')
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -24,40 +23,47 @@ module.exports = function(state, emit) {
|
|||
state.fileInfo.id = state.params.id;
|
||||
state.fileInfo.key = state.params.key;
|
||||
const fileInfo = state.fileInfo;
|
||||
const size = bytes(fileInfo.size);
|
||||
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');
|
||||
const div = html`
|
||||
<div id="page-one">
|
||||
<div id="download">
|
||||
<div id="download-page-one">
|
||||
<div class="title">
|
||||
<span id="dl-file"
|
||||
data-name="${fileInfo.name}"
|
||||
data-size="${fileInfo.size}"
|
||||
data-ttl="${fileInfo.ttl}">${state.translate('downloadFileName', {
|
||||
filename: fileInfo.name
|
||||
})}</span>
|
||||
<span id="dl-filesize">${' ' +
|
||||
state.translate('downloadFileSize', { size })}</span>
|
||||
data-nonce="${fileInfo.nonce}"
|
||||
data-requires-password="${fileInfo.pwd}">${title}</span>
|
||||
<span id="dl-filesize">${' ' + size}</span>
|
||||
</div>
|
||||
<div class="description">${state.translate('downloadMessage')}</div>
|
||||
<img
|
||||
src="${assets.get('illustration_download.svg')}"
|
||||
id="download-img"
|
||||
alt="${state.translate('downloadAltText')}"/>
|
||||
<div>
|
||||
<button
|
||||
id="download-btn"
|
||||
class="btn"
|
||||
title="${state.translate('downloadButtonLabel')}"
|
||||
onclick=${download}>${state.translate(
|
||||
'downloadButtonLabel'
|
||||
)}</button>
|
||||
</div>
|
||||
${action}
|
||||
</div>
|
||||
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function download(event) {
|
||||
event.preventDefault();
|
||||
emit('download', fileInfo);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const html = require('choo/html');
|
||||
const assets = require('../../common/assets');
|
||||
const notFound = require('./notFound');
|
||||
const uploadPassword = require('./uploadPassword');
|
||||
const { allowedCopy, delay, fadeOut } = require('../utils');
|
||||
|
||||
module.exports = function(state, emit) {
|
||||
|
@ -8,25 +9,37 @@ module.exports = function(state, emit) {
|
|||
if (!file) {
|
||||
return notFound(state, emit);
|
||||
}
|
||||
const passwordComplete = html`
|
||||
<div class="selectPassword">
|
||||
Password: ${file.password}
|
||||
</div>`;
|
||||
const passwordSection = file.password
|
||||
? passwordComplete
|
||||
: uploadPassword(state, emit);
|
||||
const div = html`
|
||||
<div id="share-link" class="fadeIn">
|
||||
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
|
||||
<div id="share-window">
|
||||
<div id="copy-text">${state.translate('copyUrlFormLabelWithName', {
|
||||
filename: file.name
|
||||
})}</div>
|
||||
<div id="copy-text">
|
||||
${state.translate('copyUrlFormLabelWithName', {
|
||||
filename: file.name
|
||||
})}</div>
|
||||
<div id="copy">
|
||||
<input id="link" type="url" value="${file.url}" readonly="true"/>
|
||||
<button id="copy-btn" class="btn" title="${state.translate(
|
||||
'copyUrlFormButton'
|
||||
)}" onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
|
||||
<button id="copy-btn"
|
||||
class="btn"
|
||||
title="${state.translate('copyUrlFormButton')}"
|
||||
onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
|
||||
</div>
|
||||
<button id="delete-file" class="btn" title="${state.translate(
|
||||
'deleteFileButton'
|
||||
)}" onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
|
||||
<a class="send-new" data-state="completed" href="/" onclick=${sendNew}>${state.translate(
|
||||
'sendAnotherFileLink'
|
||||
)}</a>
|
||||
${passwordSection}
|
||||
<button id="delete-file"
|
||||
class="btn"
|
||||
title="${state.translate('deleteFileButton')}"
|
||||
onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
|
||||
<a class="send-new"
|
||||
data-state="completed"
|
||||
href="/"
|
||||
onclick=${sendNew}>${state.translate('sendAnotherFileLink')}</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
37
app/templates/uploadPassword.js
Normal file
37
app/templates/uploadPassword.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
const html = require('choo/html');
|
||||
|
||||
module.exports = function(state, emit) {
|
||||
const file = state.storage.getFileById(state.params.id);
|
||||
const div = html`
|
||||
<div class="selectPassword">
|
||||
<div>
|
||||
<input id="addPassword" type="checkbox" onchange=${togglePasswordInput}/>
|
||||
<label for="addPassword">${state.translate(
|
||||
'requirePasswordCheckbox'
|
||||
)}</label>
|
||||
</div>
|
||||
<form class="setPassword hidden" onsubmit=${setPassword}>
|
||||
<input id="unlock-input"
|
||||
autocomplete="off"
|
||||
placeholder="${state.translate('unlockInputPlaceholder')}"/>
|
||||
<input type="submit"
|
||||
id="unlock-btn"
|
||||
class="btn"
|
||||
value="${state.translate('addPasswordButton')}"/>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
function togglePasswordInput() {
|
||||
document.querySelector('.setPassword').classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function setPassword(event) {
|
||||
event.preventDefault();
|
||||
const password = document.getElementById('unlock-input').value;
|
||||
if (password.length > 0) {
|
||||
emit('password', { password, file });
|
||||
}
|
||||
}
|
||||
|
||||
return div;
|
||||
};
|
|
@ -9,24 +9,31 @@ 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'
|
||||
)}" title="${state.translate('uploadSvgAlt')}"/></div>
|
||||
<div class="upload-window"
|
||||
ondragover=${dragover}
|
||||
ondragleave=${dragleave}>
|
||||
<div id="upload-img">
|
||||
<img src="${assets.get('upload.svg')}"
|
||||
title="${state.translate('uploadSvgAlt')}"/>
|
||||
</div>
|
||||
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
|
||||
<span id="file-size-msg"><em>${state.translate(
|
||||
'uploadPageSizeMessage'
|
||||
)}</em></span>
|
||||
<form method="post" action="upload" enctype="multipart/form-data">
|
||||
<input id="file-upload" type="file" name="fileUploaded" onchange=${upload} onfocus=${onfocus} onblur=${onblur} />
|
||||
<label for="file-upload" id="browse" class="btn browse" title="${state.translate(
|
||||
'uploadPageBrowseButton1'
|
||||
)}">${state.translate('uploadPageBrowseButton1')}</label>
|
||||
</form>
|
||||
<span id="file-size-msg">
|
||||
<em>${state.translate('uploadPageSizeMessage')}</em>
|
||||
</span>
|
||||
<label for="file-upload"
|
||||
id="browse"
|
||||
class="btn browse"
|
||||
title="${state.translate('uploadPageBrowseButton1')}">
|
||||
${state.translate('uploadPageBrowseButton1')}</label>
|
||||
<input id="file-upload"
|
||||
type="file"
|
||||
name="fileUploaded"
|
||||
onfocus=${onfocus}
|
||||
onblur=${onblur}
|
||||
onchange=${upload} />
|
||||
</div>
|
||||
${fileList(state, emit)}
|
||||
</div>
|
||||
|
|
38
app/utils.js
38
app/utils.js
|
@ -1,23 +1,18 @@
|
|||
function arrayToHex(iv) {
|
||||
let hexStr = '';
|
||||
// eslint-disable-next-line prefer-const
|
||||
for (let i in iv) {
|
||||
if (iv[i] < 16) {
|
||||
hexStr += '0' + iv[i].toString(16);
|
||||
} else {
|
||||
hexStr += iv[i].toString(16);
|
||||
}
|
||||
}
|
||||
return hexStr;
|
||||
const b64 = require('base64-js');
|
||||
|
||||
function arrayToB64(array) {
|
||||
return b64
|
||||
.fromByteArray(array)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
function hexToArray(str) {
|
||||
const iv = new Uint8Array(str.length / 2);
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
iv[i / 2] = parseInt(str.charAt(i) + str.charAt(i + 1), 16);
|
||||
}
|
||||
|
||||
return iv;
|
||||
function b64ToArray(str) {
|
||||
str = (str + '==='.slice((str.length + 3) % 4))
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
return b64.toByteArray(str);
|
||||
}
|
||||
|
||||
function notify(str) {
|
||||
|
@ -105,6 +100,9 @@ const LOCALIZE_NUMBERS = !!(
|
|||
|
||||
const UNITS = ['B', 'kB', 'MB', 'GB'];
|
||||
function bytes(num) {
|
||||
if (num < 1) {
|
||||
return '0B';
|
||||
}
|
||||
const exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1);
|
||||
const n = Number(num / Math.pow(1000, exponent));
|
||||
const nStr = LOCALIZE_NUMBERS
|
||||
|
@ -147,8 +145,8 @@ module.exports = {
|
|||
bytes,
|
||||
percent,
|
||||
copyToClipboard,
|
||||
arrayToHex,
|
||||
hexToArray,
|
||||
arrayToB64,
|
||||
b64ToArray,
|
||||
notify,
|
||||
canHasSend,
|
||||
isFile,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue