Add optional password to the download url
This commit is contained in:
parent
837747f8f7
commit
bc24a069da
|
@ -153,6 +153,7 @@ export default function(state, emitter) {
|
||||||
state.storage.totalUploads += 1;
|
state.storage.totalUploads += 1;
|
||||||
emitter.emit('pushState', `/share/${info.id}`);
|
emitter.emit('pushState', `/share/${info.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
state.transfer = null;
|
state.transfer = null;
|
||||||
if (err.message === '0') {
|
if (err.message === '0') {
|
||||||
//cancelled. do nothing
|
//cancelled. do nothing
|
||||||
|
@ -161,23 +162,51 @@ export default function(state, emitter) {
|
||||||
}
|
}
|
||||||
state.raven.captureException(err);
|
state.raven.captureException(err);
|
||||||
metrics.stoppedUpload({ size, type, err });
|
metrics.stoppedUpload({ size, type, err });
|
||||||
emitter.emit('replaceState', '/error');
|
emitter.emit('pushState', '/error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.on('download', async file => {
|
emitter.on('password', async ({ password, file }) => {
|
||||||
const size = file.size;
|
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 url = `/api/download/${file.id}`;
|
||||||
const receiver = new FileReceiver(url, file.key);
|
const receiver = new FileReceiver(url, file);
|
||||||
receiver.on('progress', updateProgress);
|
receiver.on('progress', updateProgress);
|
||||||
receiver.on('decrypting', render);
|
receiver.on('decrypting', render);
|
||||||
state.transfer = receiver;
|
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();
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.on('download', async file => {
|
||||||
|
state.transfer.on('progress', render);
|
||||||
|
state.transfer.on('decrypting', render);
|
||||||
|
const links = openLinksInNewTab();
|
||||||
|
const size = file.size;
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
metrics.startedDownload({ size: file.size, ttl: file.ttl });
|
metrics.startedDownload({ size: file.size, ttl: file.ttl });
|
||||||
const f = await receiver.download();
|
const f = await state.transfer.download(file.nonce);
|
||||||
const time = Date.now() - start;
|
const time = Date.now() - start;
|
||||||
const speed = size / (time / 1000);
|
const speed = size / (time / 1000);
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
|
@ -187,13 +216,14 @@ export default function(state, emitter) {
|
||||||
metrics.completedDownload({ size, time, speed });
|
metrics.completedDownload({ size, time, speed });
|
||||||
emitter.emit('pushState', '/completed');
|
emitter.emit('pushState', '/completed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
// TODO cancelled download
|
// TODO cancelled download
|
||||||
const location = err.message === 'notfound' ? '/404' : '/error';
|
const location = err.message === 'notfound' ? '/404' : '/error';
|
||||||
if (location === '/error') {
|
if (location === '/error') {
|
||||||
state.raven.captureException(err);
|
state.raven.captureException(err);
|
||||||
metrics.stoppedDownload({ size, err });
|
metrics.stoppedDownload({ size, err });
|
||||||
}
|
}
|
||||||
emitter.emit('replaceState', location);
|
emitter.emit('pushState', location);
|
||||||
} finally {
|
} finally {
|
||||||
state.transfer = null;
|
state.transfer = null;
|
||||||
openLinksInNewTab(links, false);
|
openLinksInNewTab(links, false);
|
||||||
|
|
|
@ -1,25 +1,104 @@
|
||||||
import Nanobus from 'nanobus';
|
import Nanobus from 'nanobus';
|
||||||
import { hexToArray, bytes } from './utils';
|
import { arrayToB64, b64ToArray, bytes } from './utils';
|
||||||
|
|
||||||
export default class FileReceiver extends Nanobus {
|
export default class FileReceiver extends Nanobus {
|
||||||
constructor(url, k) {
|
constructor(url, file) {
|
||||||
super('FileReceiver');
|
super('FileReceiver');
|
||||||
this.key = window.crypto.subtle.importKey(
|
this.secretKeyPromise = window.crypto.subtle.importKey(
|
||||||
'jwk',
|
'raw',
|
||||||
{
|
b64ToArray(file.key),
|
||||||
k,
|
'HKDF',
|
||||||
kty: 'oct',
|
|
||||||
alg: 'A128GCM',
|
|
||||||
ext: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AES-GCM'
|
|
||||||
},
|
|
||||||
false,
|
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.url = url;
|
||||||
this.msg = 'fileSizeProgress';
|
this.msg = 'fileSizeProgress';
|
||||||
|
this.state = 'initialized';
|
||||||
this.progress = [0, 1];
|
this.progress = [0, 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,7 +117,65 @@ export default class FileReceiver extends Nanobus {
|
||||||
// TODO
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
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) {
|
if (xhr.status === 404) {
|
||||||
reject(new Error('notfound'));
|
reject(new Error('notfound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([this.response]);
|
if (xhr.status !== 200) {
|
||||||
const meta = JSON.parse(xhr.getResponseHeader('X-File-Metadata'));
|
return reject(new Error(xhr.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([xhr.response]);
|
||||||
const fileReader = new FileReader();
|
const fileReader = new FileReader();
|
||||||
fileReader.onload = function() {
|
fileReader.onload = function() {
|
||||||
resolve({
|
resolve(this.result);
|
||||||
data: this.result,
|
|
||||||
name: meta.filename,
|
|
||||||
type: meta.mimeType,
|
|
||||||
iv: meta.id
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fileReader.readAsArrayBuffer(blob);
|
fileReader.readAsArrayBuffer(blob);
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.open('get', this.url);
|
xhr.open('get', this.url);
|
||||||
|
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(sig)}`);
|
||||||
xhr.responseType = 'blob';
|
xhr.responseType = 'blob';
|
||||||
xhr.send();
|
xhr.send();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async download() {
|
async download(nonce) {
|
||||||
const key = await this.key;
|
this.state = 'downloading';
|
||||||
const file = await this.downloadFile();
|
this.emit('progress', this.progress);
|
||||||
this.msg = 'decryptingFile';
|
try {
|
||||||
this.emit('decrypting');
|
const encryptKey = await this.encryptKeyPromise;
|
||||||
const plaintext = await window.crypto.subtle.decrypt(
|
const authKey = await this.authKeyPromise;
|
||||||
{
|
const sig = await window.crypto.subtle.sign(
|
||||||
name: 'AES-GCM',
|
{
|
||||||
iv: hexToArray(file.iv),
|
name: 'HMAC'
|
||||||
tagLength: 128
|
},
|
||||||
},
|
authKey,
|
||||||
key,
|
b64ToArray(nonce)
|
||||||
file.data
|
);
|
||||||
);
|
const ciphertext = await this.downloadFile(new Uint8Array(sig));
|
||||||
this.msg = 'downloadFinish';
|
this.msg = 'decryptingFile';
|
||||||
return {
|
this.emit('decrypting');
|
||||||
plaintext,
|
const plaintext = await window.crypto.subtle.decrypt(
|
||||||
name: decodeURIComponent(file.name),
|
{
|
||||||
type: file.type
|
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 Nanobus from 'nanobus';
|
||||||
import { arrayToHex, bytes } from './utils';
|
import { arrayToB64, b64ToArray, bytes } from './utils';
|
||||||
|
|
||||||
export default class FileSender extends Nanobus {
|
export default class FileSender extends Nanobus {
|
||||||
constructor(file) {
|
constructor(file) {
|
||||||
|
@ -10,13 +10,13 @@ export default class FileSender extends Nanobus {
|
||||||
this.cancelled = false;
|
this.cancelled = false;
|
||||||
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
|
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
this.uploadXHR = new XMLHttpRequest();
|
this.uploadXHR = new XMLHttpRequest();
|
||||||
this.key = window.crypto.subtle.generateKey(
|
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
{
|
this.secretKey = window.crypto.subtle.importKey(
|
||||||
name: 'AES-GCM',
|
'raw',
|
||||||
length: 128
|
this.rawSecret,
|
||||||
},
|
'HKDF',
|
||||||
true,
|
false,
|
||||||
['encrypt']
|
['deriveKey']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,14 +71,12 @@ export default class FileSender extends Nanobus {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFile(encrypted, keydata) {
|
uploadFile(encrypted, metadata, rawAuth) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const file = this.file;
|
|
||||||
const id = arrayToHex(this.iv);
|
|
||||||
const dataView = new DataView(encrypted);
|
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();
|
const fd = new FormData();
|
||||||
fd.append('data', blob, file.name);
|
fd.append('data', blob);
|
||||||
|
|
||||||
const xhr = this.uploadXHR;
|
const xhr = this.uploadXHR;
|
||||||
|
|
||||||
|
@ -92,14 +90,18 @@ export default class FileSender extends Nanobus {
|
||||||
xhr.onreadystatechange = () => {
|
xhr.onreadystatechange = () => {
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
|
const nonce = xhr
|
||||||
|
.getResponseHeader('WWW-Authenticate')
|
||||||
|
.split(' ')[1];
|
||||||
this.progress = [1, 1];
|
this.progress = [1, 1];
|
||||||
this.msg = 'notifyUploadDone';
|
this.msg = 'notifyUploadDone';
|
||||||
const responseObj = JSON.parse(xhr.responseText);
|
const responseObj = JSON.parse(xhr.responseText);
|
||||||
return resolve({
|
return resolve({
|
||||||
url: responseObj.url,
|
url: responseObj.url,
|
||||||
id: responseObj.id,
|
id: responseObj.id,
|
||||||
secretKey: keydata.k,
|
secretKey: arrayToB64(this.rawSecret),
|
||||||
deleteToken: responseObj.delete
|
deleteToken: responseObj.delete,
|
||||||
|
nonce
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.msg = 'errorPageHeader';
|
this.msg = 'errorPageHeader';
|
||||||
|
@ -110,18 +112,62 @@ export default class FileSender extends Nanobus {
|
||||||
xhr.open('post', '/api/upload', true);
|
xhr.open('post', '/api/upload', true);
|
||||||
xhr.setRequestHeader(
|
xhr.setRequestHeader(
|
||||||
'X-File-Metadata',
|
'X-File-Metadata',
|
||||||
JSON.stringify({
|
arrayToB64(new Uint8Array(metadata))
|
||||||
id: id,
|
|
||||||
filename: encodeURIComponent(file.name)
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
|
||||||
xhr.send(fd);
|
xhr.send(fd);
|
||||||
this.msg = 'fileSizeProgress';
|
this.msg = 'fileSizeProgress';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload() {
|
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();
|
const plaintext = await this.readFile();
|
||||||
if (this.cancelled) {
|
if (this.cancelled) {
|
||||||
throw new Error(0);
|
throw new Error(0);
|
||||||
|
@ -134,13 +180,112 @@ export default class FileSender extends Nanobus {
|
||||||
iv: this.iv,
|
iv: this.iv,
|
||||||
tagLength: 128
|
tagLength: 128
|
||||||
},
|
},
|
||||||
key,
|
encryptKey,
|
||||||
plaintext
|
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) {
|
if (this.cancelled) {
|
||||||
throw new Error(0);
|
throw new Error(0);
|
||||||
}
|
}
|
||||||
const keydata = await window.crypto.subtle.exportKey('jwk', key);
|
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
|
||||||
return this.uploadFile(encrypted, keydata);
|
}
|
||||||
|
|
||||||
|
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) {
|
function startedDownload(params) {
|
||||||
return sendEvent('recipient', 'download-started', {
|
return sendEvent('recipient', 'download-started', {
|
||||||
cm1: params.size,
|
cm1: params.size,
|
||||||
|
@ -262,6 +271,7 @@ export {
|
||||||
cancelledDownload,
|
cancelledDownload,
|
||||||
stoppedDownload,
|
stoppedDownload,
|
||||||
completedDownload,
|
completedDownload,
|
||||||
|
addedPassword,
|
||||||
restart,
|
restart,
|
||||||
unsupported
|
unsupported
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,7 +3,10 @@ const download = require('../templates/download');
|
||||||
|
|
||||||
module.exports = function(state, emit) {
|
module.exports = function(state, emit) {
|
||||||
if (state.transfer) {
|
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);
|
return preview(state, emit);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,8 @@ const welcome = require('../templates/welcome');
|
||||||
const upload = require('../templates/upload');
|
const upload = require('../templates/upload');
|
||||||
|
|
||||||
module.exports = function(state, emit) {
|
module.exports = function(state, emit) {
|
||||||
if (state.transfer) {
|
if (state.transfer && state.transfer.iv) {
|
||||||
|
//TODO relying on 'iv' is gross
|
||||||
return upload(state, emit);
|
return upload(state, emit);
|
||||||
}
|
}
|
||||||
return welcome(state, emit);
|
return welcome(state, emit);
|
||||||
|
|
|
@ -92,11 +92,7 @@ class Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileById(id) {
|
getFileById(id) {
|
||||||
try {
|
return this._files.find(f => f.id === id);
|
||||||
return JSON.parse(this.engine.getItem(id));
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id) {
|
get(id) {
|
||||||
|
@ -114,6 +110,10 @@ class Storage {
|
||||||
this._files.push(file);
|
this._files.push(file);
|
||||||
this.engine.setItem(file.id, JSON.stringify(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();
|
export default new Storage();
|
||||||
|
|
|
@ -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 html = require('choo/html');
|
||||||
const assets = require('../../common/assets');
|
const assets = require('../../common/assets');
|
||||||
const notFound = require('./notFound');
|
const notFound = require('./notFound');
|
||||||
|
const downloadPassword = require('./downloadPassword');
|
||||||
const { bytes } = require('../utils');
|
const { bytes } = require('../utils');
|
||||||
|
|
||||||
function getFileFromDOM() {
|
function getFileFromDOM() {
|
||||||
|
@ -8,11 +9,9 @@ function getFileFromDOM() {
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = el.dataset;
|
|
||||||
return {
|
return {
|
||||||
name: data.name,
|
nonce: el.getAttribute('data-nonce'),
|
||||||
size: parseInt(data.size, 10),
|
pwd: !!+el.getAttribute('data-requires-password')
|
||||||
ttl: parseInt(data.ttl, 10)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,40 +23,47 @@ module.exports = function(state, emit) {
|
||||||
state.fileInfo.id = state.params.id;
|
state.fileInfo.id = state.params.id;
|
||||||
state.fileInfo.key = state.params.key;
|
state.fileInfo.key = state.params.key;
|
||||||
const fileInfo = state.fileInfo;
|
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`
|
const div = html`
|
||||||
<div id="page-one">
|
<div id="page-one">
|
||||||
<div id="download">
|
<div id="download">
|
||||||
<div id="download-page-one">
|
<div id="download-page-one">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<span id="dl-file"
|
<span id="dl-file"
|
||||||
data-name="${fileInfo.name}"
|
data-nonce="${fileInfo.nonce}"
|
||||||
data-size="${fileInfo.size}"
|
data-requires-password="${fileInfo.pwd}">${title}</span>
|
||||||
data-ttl="${fileInfo.ttl}">${state.translate('downloadFileName', {
|
<span id="dl-filesize">${' ' + size}</span>
|
||||||
filename: fileInfo.name
|
|
||||||
})}</span>
|
|
||||||
<span id="dl-filesize">${' ' +
|
|
||||||
state.translate('downloadFileSize', { size })}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="description">${state.translate('downloadMessage')}</div>
|
<div class="description">${state.translate('downloadMessage')}</div>
|
||||||
<img
|
${action}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
|
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function download(event) {
|
function download(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
emit('download', fileInfo);
|
emit('download', fileInfo);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const html = require('choo/html');
|
const html = require('choo/html');
|
||||||
const assets = require('../../common/assets');
|
const assets = require('../../common/assets');
|
||||||
const notFound = require('./notFound');
|
const notFound = require('./notFound');
|
||||||
|
const uploadPassword = require('./uploadPassword');
|
||||||
const { allowedCopy, delay, fadeOut } = require('../utils');
|
const { allowedCopy, delay, fadeOut } = require('../utils');
|
||||||
|
|
||||||
module.exports = function(state, emit) {
|
module.exports = function(state, emit) {
|
||||||
|
@ -8,25 +9,37 @@ module.exports = function(state, emit) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return notFound(state, emit);
|
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`
|
const div = html`
|
||||||
<div id="share-link" class="fadeIn">
|
<div id="share-link" class="fadeIn">
|
||||||
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
|
<div class="title">${state.translate('uploadSuccessTimingHeader')}</div>
|
||||||
<div id="share-window">
|
<div id="share-window">
|
||||||
<div id="copy-text">${state.translate('copyUrlFormLabelWithName', {
|
<div id="copy-text">
|
||||||
filename: file.name
|
${state.translate('copyUrlFormLabelWithName', {
|
||||||
})}</div>
|
filename: file.name
|
||||||
|
})}</div>
|
||||||
<div id="copy">
|
<div id="copy">
|
||||||
<input id="link" type="url" value="${file.url}" readonly="true"/>
|
<input id="link" type="url" value="${file.url}" readonly="true"/>
|
||||||
<button id="copy-btn" class="btn" title="${state.translate(
|
<button id="copy-btn"
|
||||||
'copyUrlFormButton'
|
class="btn"
|
||||||
)}" onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
|
title="${state.translate('copyUrlFormButton')}"
|
||||||
|
onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="delete-file" class="btn" title="${state.translate(
|
${passwordSection}
|
||||||
'deleteFileButton'
|
<button id="delete-file"
|
||||||
)}" onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
|
class="btn"
|
||||||
<a class="send-new" data-state="completed" href="/" onclick=${sendNew}>${state.translate(
|
title="${state.translate('deleteFileButton')}"
|
||||||
'sendAnotherFileLink'
|
onclick=${deleteFile}>${state.translate('deleteFileButton')}</button>
|
||||||
)}</a>
|
<a class="send-new"
|
||||||
|
data-state="completed"
|
||||||
|
href="/"
|
||||||
|
onclick=${sendNew}>${state.translate('sendAnotherFileLink')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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="title">${state.translate('uploadPageHeader')}</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<div>${state.translate('uploadPageExplainer')}</div>
|
<div>${state.translate('uploadPageExplainer')}</div>
|
||||||
<a href="https://testpilot.firefox.com/experiments/send" class="link">${state.translate(
|
<a href="https://testpilot.firefox.com/experiments/send"
|
||||||
'uploadPageLearnMore'
|
class="link">${state.translate('uploadPageLearnMore')}</a>
|
||||||
)}</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-window" ondragover=${dragover} ondragleave=${dragleave}>
|
<div class="upload-window"
|
||||||
<div id="upload-img"><img src="${assets.get(
|
ondragover=${dragover}
|
||||||
'upload.svg'
|
ondragleave=${dragleave}>
|
||||||
)}" title="${state.translate('uploadSvgAlt')}"/></div>
|
<div id="upload-img">
|
||||||
|
<img src="${assets.get('upload.svg')}"
|
||||||
|
title="${state.translate('uploadSvgAlt')}"/>
|
||||||
|
</div>
|
||||||
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
|
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
|
||||||
<span id="file-size-msg"><em>${state.translate(
|
<span id="file-size-msg">
|
||||||
'uploadPageSizeMessage'
|
<em>${state.translate('uploadPageSizeMessage')}</em>
|
||||||
)}</em></span>
|
</span>
|
||||||
<form method="post" action="upload" enctype="multipart/form-data">
|
<label for="file-upload"
|
||||||
<input id="file-upload" type="file" name="fileUploaded" onchange=${upload} onfocus=${onfocus} onblur=${onblur} />
|
id="browse"
|
||||||
<label for="file-upload" id="browse" class="btn browse" title="${state.translate(
|
class="btn browse"
|
||||||
'uploadPageBrowseButton1'
|
title="${state.translate('uploadPageBrowseButton1')}">
|
||||||
)}">${state.translate('uploadPageBrowseButton1')}</label>
|
${state.translate('uploadPageBrowseButton1')}</label>
|
||||||
</form>
|
<input id="file-upload"
|
||||||
|
type="file"
|
||||||
|
name="fileUploaded"
|
||||||
|
onfocus=${onfocus}
|
||||||
|
onblur=${onblur}
|
||||||
|
onchange=${upload} />
|
||||||
</div>
|
</div>
|
||||||
${fileList(state, emit)}
|
${fileList(state, emit)}
|
||||||
</div>
|
</div>
|
||||||
|
|
38
app/utils.js
38
app/utils.js
|
@ -1,23 +1,18 @@
|
||||||
function arrayToHex(iv) {
|
const b64 = require('base64-js');
|
||||||
let hexStr = '';
|
|
||||||
// eslint-disable-next-line prefer-const
|
function arrayToB64(array) {
|
||||||
for (let i in iv) {
|
return b64
|
||||||
if (iv[i] < 16) {
|
.fromByteArray(array)
|
||||||
hexStr += '0' + iv[i].toString(16);
|
.replace(/\+/g, '-')
|
||||||
} else {
|
.replace(/\//g, '_')
|
||||||
hexStr += iv[i].toString(16);
|
.replace(/=/g, '');
|
||||||
}
|
|
||||||
}
|
|
||||||
return hexStr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToArray(str) {
|
function b64ToArray(str) {
|
||||||
const iv = new Uint8Array(str.length / 2);
|
str = (str + '==='.slice((str.length + 3) % 4))
|
||||||
for (let i = 0; i < str.length; i += 2) {
|
.replace(/-/g, '+')
|
||||||
iv[i / 2] = parseInt(str.charAt(i) + str.charAt(i + 1), 16);
|
.replace(/_/g, '/');
|
||||||
}
|
return b64.toByteArray(str);
|
||||||
|
|
||||||
return iv;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify(str) {
|
function notify(str) {
|
||||||
|
@ -105,6 +100,9 @@ const LOCALIZE_NUMBERS = !!(
|
||||||
|
|
||||||
const UNITS = ['B', 'kB', 'MB', 'GB'];
|
const UNITS = ['B', 'kB', 'MB', 'GB'];
|
||||||
function bytes(num) {
|
function bytes(num) {
|
||||||
|
if (num < 1) {
|
||||||
|
return '0B';
|
||||||
|
}
|
||||||
const exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1);
|
const exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1);
|
||||||
const n = Number(num / Math.pow(1000, exponent));
|
const n = Number(num / Math.pow(1000, exponent));
|
||||||
const nStr = LOCALIZE_NUMBERS
|
const nStr = LOCALIZE_NUMBERS
|
||||||
|
@ -147,8 +145,8 @@ module.exports = {
|
||||||
bytes,
|
bytes,
|
||||||
percent,
|
percent,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
arrayToHex,
|
arrayToB64,
|
||||||
hexToArray,
|
b64ToArray,
|
||||||
notify,
|
notify,
|
||||||
canHasSend,
|
canHasSend,
|
||||||
isFile,
|
isFile,
|
||||||
|
|
|
@ -638,6 +638,23 @@ tbody {
|
||||||
color: #0287e8;
|
color: #0287e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectPassword {
|
||||||
|
padding: 10px 0;
|
||||||
|
align-self: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setPassword {
|
||||||
|
align-self: left;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 80%;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* upload-error */
|
/* upload-error */
|
||||||
#upload-error {
|
#upload-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -766,6 +783,55 @@ tbody {
|
||||||
height: 196px;
|
height: 196px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.enterPassword {
|
||||||
|
text-align: left;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unlock {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unlock-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 46px;
|
||||||
|
border: 1px solid #0297f8;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #737373;
|
||||||
|
font-family: 'SF Pro Text', sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 23px;
|
||||||
|
font-weight: 300;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unlock-btn {
|
||||||
|
flex: 0 1 165px;
|
||||||
|
background: #0297f8;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
border: 1px solid #0297f8;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
height: 50px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unlock-btn:hover {
|
||||||
|
background-color: #0287e8;
|
||||||
|
}
|
||||||
|
|
||||||
/* footer */
|
/* footer */
|
||||||
.footer {
|
.footer {
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
|
@ -67,6 +67,14 @@ Triggered whenever a user stops uploading a file. Includes:
|
||||||
- `cd2`
|
- `cd2`
|
||||||
- `cd6`
|
- `cd6`
|
||||||
|
|
||||||
|
#### `password-added`
|
||||||
|
Triggered whenever a password is added to a file. Includes:
|
||||||
|
|
||||||
|
- `cm1`
|
||||||
|
- `cm5`
|
||||||
|
- `cm6`
|
||||||
|
- `cm7`
|
||||||
|
|
||||||
#### `download-started`
|
#### `download-started`
|
||||||
Triggered whenever a user begins downloading a file. Includes:
|
Triggered whenever a user begins downloading a file. Includes:
|
||||||
|
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
"babel-preset-env": "^1.6.0",
|
"babel-preset-env": "^1.6.0",
|
||||||
"babel-preset-es2015": "^6.24.1",
|
"babel-preset-es2015": "^6.24.1",
|
||||||
"babel-preset-stage-2": "^6.24.1",
|
"babel-preset-stage-2": "^6.24.1",
|
||||||
|
"base64-js": "^1.2.1",
|
||||||
"copy-webpack-plugin": "^4.1.1",
|
"copy-webpack-plugin": "^4.1.1",
|
||||||
"cross-env": "^5.0.5",
|
"cross-env": "^5.0.5",
|
||||||
"css-loader": "^0.28.7",
|
"css-loader": "^0.28.7",
|
||||||
|
|
|
@ -34,6 +34,10 @@ sendAnotherFileLink = Send another file
|
||||||
downloadAltText = Download
|
downloadAltText = Download
|
||||||
downloadFileName = Download { $filename }
|
downloadFileName = Download { $filename }
|
||||||
downloadFileSize = ({ $size })
|
downloadFileSize = ({ $size })
|
||||||
|
unlockInputLabel = Enter Password
|
||||||
|
unlockInputPlaceholder = Password
|
||||||
|
unlockButtonLabel = Unlock
|
||||||
|
downloadFileTitle = Download Encrypted File
|
||||||
// Firefox Send is a brand name and should not be localized.
|
// Firefox Send is a brand name and should not be localized.
|
||||||
downloadMessage = Your friend is sending you a file with Firefox Send, a service that allows you to share files with a safe, private, and encrypted link that automatically expires to ensure your stuff does not remain online forever.
|
downloadMessage = Your friend is sending you a file with Firefox Send, a service that allows you to share files with a safe, private, and encrypted link that automatically expires to ensure your stuff does not remain online forever.
|
||||||
// Text and title used on the download link/button (indicates an action).
|
// Text and title used on the download link/button (indicates an action).
|
||||||
|
@ -80,3 +84,6 @@ footerLinkAbout = About Test Pilot
|
||||||
footerLinkPrivacy = Privacy
|
footerLinkPrivacy = Privacy
|
||||||
footerLinkTerms = Terms
|
footerLinkTerms = Terms
|
||||||
footerLinkCookies = Cookies
|
footerLinkCookies = Cookies
|
||||||
|
requirePasswordCheckbox = Require a password to download this file
|
||||||
|
addPasswordButton = Add Password
|
||||||
|
incorrectPassword = Incorrect password. Try again?
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const storage = require('../storage');
|
const storage = require('../storage');
|
||||||
const mozlog = require('../log');
|
const mozlog = require('../log');
|
||||||
const log = mozlog('send.download');
|
const log = mozlog('send.download');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
function validateID(route_id) {
|
function validateID(route_id) {
|
||||||
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
|
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
|
||||||
|
@ -13,13 +14,24 @@ module.exports = async function(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const auth = req.header('Authorization').split(' ')[1];
|
||||||
const meta = await storage.metadata(id);
|
const meta = await storage.metadata(id);
|
||||||
|
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
|
||||||
|
hmac.update(Buffer.from(meta.nonce, 'base64'));
|
||||||
|
const verifyHash = hmac.digest();
|
||||||
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
storage.setField(id, 'nonce', nonce);
|
||||||
|
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
|
||||||
|
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
|
||||||
|
return res.sendStatus(401);
|
||||||
|
}
|
||||||
const contentLength = await storage.length(id);
|
const contentLength = await storage.length(id);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Disposition': `attachment; filename=${meta.filename}`,
|
'Content-Disposition': 'attachment',
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Length': contentLength,
|
'Content-Length': contentLength,
|
||||||
'X-File-Metadata': JSON.stringify(meta)
|
'X-File-Metadata': meta.metadata,
|
||||||
|
'WWW-Authenticate': `send-v1 ${nonce}`
|
||||||
});
|
});
|
||||||
const file_stream = storage.get(id);
|
const file_stream = storage.get(id);
|
||||||
|
|
||||||
|
|
|
@ -59,10 +59,12 @@ module.exports = function(app) {
|
||||||
app.get('/download/:id', pages.download);
|
app.get('/download/:id', pages.download);
|
||||||
app.get('/completed', pages.blank);
|
app.get('/completed', pages.blank);
|
||||||
app.get('/unsupported/:reason', pages.unsupported);
|
app.get('/unsupported/:reason', pages.unsupported);
|
||||||
app.post('/api/upload', require('./upload'));
|
|
||||||
app.get('/api/download/:id', require('./download'));
|
app.get('/api/download/:id', require('./download'));
|
||||||
app.get('/api/exists/:id', require('./exists'));
|
app.get('/api/exists/:id', require('./exists'));
|
||||||
|
app.get('/api/metadata/:id', require('./metadata'));
|
||||||
|
app.post('/api/upload', require('./upload'));
|
||||||
app.post('/api/delete/:id', require('./delete'));
|
app.post('/api/delete/:id', require('./delete'));
|
||||||
|
app.post('/api/password/:id', require('./password'));
|
||||||
|
|
||||||
app.get('/__version__', function(req, res) {
|
app.get('/__version__', function(req, res) {
|
||||||
res.sendFile(require.resolve('../../dist/version.json'));
|
res.sendFile(require.resolve('../../dist/version.json'));
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
const storage = require('../storage');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function validateID(route_id) {
|
||||||
|
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async function(req, res) {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (!validateID(id)) {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
storage.setField(id, 'nonce', nonce);
|
||||||
|
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
|
||||||
|
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
|
||||||
|
return res.sendStatus(401);
|
||||||
|
}
|
||||||
|
const size = await storage.length(id);
|
||||||
|
const ttl = await storage.ttl(id);
|
||||||
|
res.send({
|
||||||
|
metadata: meta.metadata,
|
||||||
|
size,
|
||||||
|
ttl
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.sendStatus(404);
|
||||||
|
}
|
||||||
|
};
|
|
@ -28,16 +28,14 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const efilename = await storage.filename(id);
|
const { nonce, pwd } = await storage.metadata(id);
|
||||||
const name = decodeURIComponent(efilename);
|
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
|
||||||
const size = await storage.length(id);
|
|
||||||
const ttl = await storage.ttl(id);
|
|
||||||
res.send(
|
res.send(
|
||||||
stripEvents(
|
stripEvents(
|
||||||
routes.toString(
|
routes.toString(
|
||||||
`/download/${req.params.id}`,
|
`/download/${req.params.id}`,
|
||||||
Object.assign(state(req), {
|
Object.assign(state(req), {
|
||||||
fileInfo: { name, size, ttl }
|
fileInfo: { nonce, pwd: +pwd }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
const storage = require('../storage');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function validateID(route_id) {
|
||||||
|
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async function(req, res) {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (!validateID(id)) {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
}
|
||||||
|
if (!req.body.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();
|
||||||
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
storage.setField(id, 'nonce', nonce);
|
||||||
|
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
|
||||||
|
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
|
||||||
|
return res.sendStatus(401);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.sendStatus(404);
|
||||||
|
}
|
||||||
|
storage.setField(id, 'auth', req.body.auth);
|
||||||
|
storage.setField(id, 'pwd', 1);
|
||||||
|
res.sendStatus(200);
|
||||||
|
};
|
|
@ -5,55 +5,42 @@ const mozlog = require('../log');
|
||||||
|
|
||||||
const log = mozlog('send.upload');
|
const log = mozlog('send.upload');
|
||||||
|
|
||||||
const validateIV = route_id => {
|
|
||||||
return route_id.match(/^[0-9a-fA-F]{24}$/) !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = function(req, res) {
|
module.exports = function(req, res) {
|
||||||
const newId = crypto.randomBytes(5).toString('hex');
|
const newId = crypto.randomBytes(5).toString('hex');
|
||||||
let meta;
|
const metadata = req.header('X-File-Metadata');
|
||||||
|
const auth = req.header('Authorization');
|
||||||
try {
|
if (!metadata || !auth) {
|
||||||
meta = JSON.parse(req.header('X-File-Metadata'));
|
return res.sendStatus(400);
|
||||||
} catch (e) {
|
|
||||||
res.sendStatus(400);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const meta = {
|
||||||
!meta.hasOwnProperty('id') ||
|
delete: crypto.randomBytes(10).toString('hex'),
|
||||||
!meta.hasOwnProperty('filename') ||
|
metadata,
|
||||||
!validateIV(meta.id)
|
pwd: 0,
|
||||||
) {
|
auth: auth.split(' ')[1],
|
||||||
res.sendStatus(404);
|
nonce: crypto.randomBytes(16).toString('base64')
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
meta.delete = crypto.randomBytes(10).toString('hex');
|
|
||||||
req.pipe(req.busboy);
|
req.pipe(req.busboy);
|
||||||
|
|
||||||
req.busboy.on(
|
req.busboy.on('file', async (fieldname, file) => {
|
||||||
'file',
|
try {
|
||||||
async (fieldname, file, filename, encoding, mimeType) => {
|
await storage.set(newId, file, meta);
|
||||||
try {
|
const protocol = config.env === 'production' ? 'https' : req.protocol;
|
||||||
meta.mimeType = mimeType || 'application/octet-stream';
|
const url = `${protocol}://${req.get('host')}/download/${newId}/`;
|
||||||
await storage.set(newId, file, filename, meta);
|
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
|
||||||
const protocol = config.env === 'production' ? 'https' : req.protocol;
|
res.json({
|
||||||
const url = `${protocol}://${req.get('host')}/download/${newId}/`;
|
url,
|
||||||
res.json({
|
delete: meta.delete,
|
||||||
url,
|
id: newId
|
||||||
delete: meta.delete,
|
});
|
||||||
id: newId
|
} catch (e) {
|
||||||
});
|
log.error('upload', e);
|
||||||
} catch (e) {
|
if (e.message === 'limit') {
|
||||||
log.error('upload', e);
|
return res.sendStatus(413);
|
||||||
if (e.message === 'limit') {
|
|
||||||
return res.sendStatus(413);
|
|
||||||
}
|
|
||||||
res.sendStatus(500);
|
|
||||||
}
|
}
|
||||||
|
res.sendStatus(500);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
req.on('close', async err => {
|
req.on('close', async err => {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -29,7 +29,6 @@ const fileDir = config.file_dir;
|
||||||
|
|
||||||
if (config.s3_bucket) {
|
if (config.s3_bucket) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
filename: filename,
|
|
||||||
exists: exists,
|
exists: exists,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
length: awsLength,
|
length: awsLength,
|
||||||
|
@ -47,7 +46,6 @@ if (config.s3_bucket) {
|
||||||
mkdirp.sync(config.file_dir);
|
mkdirp.sync(config.file_dir);
|
||||||
log.info('fileDir', fileDir);
|
log.info('fileDir', fileDir);
|
||||||
module.exports = {
|
module.exports = {
|
||||||
filename: filename,
|
|
||||||
exists: exists,
|
exists: exists,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
length: localLength,
|
length: localLength,
|
||||||
|
@ -93,17 +91,6 @@ function ttl(id) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function filename(id) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
redis_client.hget(id, 'filename', (err, reply) => {
|
|
||||||
if (err || !reply) {
|
|
||||||
return reject();
|
|
||||||
}
|
|
||||||
resolve(reply);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function exists(id) {
|
function exists(id) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
redis_client.exists(id, (rediserr, reply) => {
|
redis_client.exists(id, (rediserr, reply) => {
|
||||||
|
@ -134,7 +121,7 @@ function localGet(id) {
|
||||||
return fs.createReadStream(path.join(fileDir, id));
|
return fs.createReadStream(path.join(fileDir, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function localSet(newId, file, filename, meta) {
|
function localSet(newId, file, meta) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const filepath = path.join(fileDir, newId);
|
const filepath = path.join(fileDir, newId);
|
||||||
const fstream = fs.createWriteStream(filepath);
|
const fstream = fs.createWriteStream(filepath);
|
||||||
|
@ -216,7 +203,7 @@ function awsGet(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function awsSet(newId, file, filename, meta) {
|
function awsSet(newId, file, meta) {
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: config.s3_bucket,
|
Bucket: config.s3_bucket,
|
||||||
Key: newId,
|
Key: newId,
|
||||||
|
|
|
@ -18,5 +18,5 @@ window.sinon = require('sinon');
|
||||||
window.server = window.sinon.fakeServer.create();
|
window.server = window.sinon.fakeServer.create();
|
||||||
window.assert = require('assert');
|
window.assert = require('assert');
|
||||||
const utils = require('../../app/utils');
|
const utils = require('../../app/utils');
|
||||||
window.hexToArray = utils.hexToArray;
|
window.b64ToArray = utils.b64ToArray;
|
||||||
window.arrayToHex = utils.arrayToHex;
|
window.arrayToB64 = utils.arrayToB64;
|
||||||
|
|
|
@ -3,7 +3,7 @@ const FileReceiver = window.FileReceiver;
|
||||||
const FakeFile = window.FakeFile;
|
const FakeFile = window.FakeFile;
|
||||||
const assert = window.assert;
|
const assert = window.assert;
|
||||||
const server = window.server;
|
const server = window.server;
|
||||||
const hexToArray = window.hexToArray;
|
const b64ToArray = window.b64ToArray;
|
||||||
const sinon = window.sinon;
|
const sinon = window.sinon;
|
||||||
|
|
||||||
let file;
|
let file;
|
||||||
|
@ -112,7 +112,7 @@ describe('File Sender', function() {
|
||||||
.encrypt(
|
.encrypt(
|
||||||
{
|
{
|
||||||
name: 'AES-GCM',
|
name: 'AES-GCM',
|
||||||
iv: hexToArray(IV),
|
iv: b64ToArray(IV),
|
||||||
tagLength: 128
|
tagLength: 128
|
||||||
},
|
},
|
||||||
cryptoKey,
|
cryptoKey,
|
||||||
|
|
|
@ -56,24 +56,6 @@ describe('Testing Exists from local filesystem', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Testing Filename from local filesystem', function() {
|
|
||||||
it('Filename returns properly if id exists', function() {
|
|
||||||
hget.callsArgWith(2, null, 'Filename.moz');
|
|
||||||
return storage
|
|
||||||
.filename('test')
|
|
||||||
.then(_reply => assert(1))
|
|
||||||
.catch(err => assert.fail());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Filename fails if id does not exist', function() {
|
|
||||||
hget.callsArgWith(2, null, 'Filename.moz');
|
|
||||||
return storage
|
|
||||||
.filename('test')
|
|
||||||
.then(_reply => assert.fail())
|
|
||||||
.catch(err => assert(1));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Testing Length from local filesystem', function() {
|
describe('Testing Length from local filesystem', function() {
|
||||||
it('Filesize returns properly if id exists', function() {
|
it('Filesize returns properly if id exists', function() {
|
||||||
fsStub.statSync.returns({ size: 10 });
|
fsStub.statSync.returns({ size: 10 });
|
||||||
|
|
|
@ -143,6 +143,6 @@ module.exports = {
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
compress: true,
|
compress: true,
|
||||||
setup: IS_DEV ? require('./server/dev') : undefined
|
before: IS_DEV ? require('./server/dev') : undefined
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue