commit
6b7b142961
206
app/api.js
Normal file
206
app/api.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { arrayToB64, b64ToArray } from './utils';
|
||||||
|
|
||||||
|
function post(obj) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: new Headers({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(obj)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonce(header) {
|
||||||
|
header = header || '';
|
||||||
|
return header.split(' ')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithAuth(url, params, keychain) {
|
||||||
|
const result = {};
|
||||||
|
params = params || {};
|
||||||
|
const h = await keychain.authHeader();
|
||||||
|
params.headers = new Headers({ Authorization: h });
|
||||||
|
const response = await fetch(url, params);
|
||||||
|
result.response = response;
|
||||||
|
result.ok = response.ok;
|
||||||
|
const nonce = parseNonce(response.headers.get('WWW-Authenticate'));
|
||||||
|
result.shouldRetry = response.status === 401 && nonce !== keychain.nonce;
|
||||||
|
keychain.nonce = nonce;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithAuthAndRetry(url, params, keychain) {
|
||||||
|
const result = await fetchWithAuth(url, params, keychain);
|
||||||
|
if (result.shouldRetry) {
|
||||||
|
return fetchWithAuth(url, params, keychain);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function del(id, owner_token) {
|
||||||
|
const response = await fetch(`/api/delete/${id}`, post({ owner_token }));
|
||||||
|
return response.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setParams(id, owner_token, params) {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/params/${id}`,
|
||||||
|
post({
|
||||||
|
owner_token,
|
||||||
|
dlimit: params.dlimit
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return response.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function metadata(id, keychain) {
|
||||||
|
const result = await fetchWithAuthAndRetry(
|
||||||
|
`/api/metadata/${id}`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
keychain
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
const data = await result.response.json();
|
||||||
|
const meta = await keychain.decryptMetadata(b64ToArray(data.metadata));
|
||||||
|
return {
|
||||||
|
dtotal: data.dtotal,
|
||||||
|
dlimit: data.dlimit,
|
||||||
|
size: data.size,
|
||||||
|
ttl: data.ttl,
|
||||||
|
iv: meta.iv,
|
||||||
|
name: meta.name,
|
||||||
|
type: meta.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(result.response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPassword(id, owner_token, keychain) {
|
||||||
|
const auth = await keychain.authKeyB64();
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/password/${id}`,
|
||||||
|
post({ owner_token, auth })
|
||||||
|
);
|
||||||
|
return response.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadFile(encrypted, metadata, verifierB64, keychain) {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const upload = {
|
||||||
|
onprogress: function() {},
|
||||||
|
cancel: function() {
|
||||||
|
xhr.abort();
|
||||||
|
},
|
||||||
|
result: new Promise(function(resolve, reject) {
|
||||||
|
xhr.addEventListener('loadend', function() {
|
||||||
|
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
|
||||||
|
if (authHeader) {
|
||||||
|
keychain.nonce = parseNonce(authHeader);
|
||||||
|
}
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const responseObj = JSON.parse(xhr.responseText);
|
||||||
|
return resolve({
|
||||||
|
url: responseObj.url,
|
||||||
|
id: responseObj.id,
|
||||||
|
ownerToken: responseObj.owner
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reject(new Error(xhr.status));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const dataView = new DataView(encrypted);
|
||||||
|
const blob = new Blob([dataView], { type: 'application/octet-stream' });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('data', blob);
|
||||||
|
xhr.upload.addEventListener('progress', function(event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
upload.onprogress([event.loaded, event.total]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.open('post', '/api/upload', true);
|
||||||
|
xhr.setRequestHeader('X-File-Metadata', arrayToB64(new Uint8Array(metadata)));
|
||||||
|
xhr.setRequestHeader('Authorization', `send-v1 ${verifierB64}`);
|
||||||
|
xhr.send(fd);
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(id, keychain) {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const download = {
|
||||||
|
onprogress: function() {},
|
||||||
|
cancel: function() {
|
||||||
|
xhr.abort();
|
||||||
|
},
|
||||||
|
result: new Promise(async function(resolve, reject) {
|
||||||
|
xhr.addEventListener('loadend', function() {
|
||||||
|
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
|
||||||
|
if (authHeader) {
|
||||||
|
keychain.nonce = parseNonce(authHeader);
|
||||||
|
}
|
||||||
|
if (xhr.status === 404) {
|
||||||
|
return reject(new Error('notfound'));
|
||||||
|
}
|
||||||
|
if (xhr.status !== 200) {
|
||||||
|
return reject(new Error(xhr.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([xhr.response]);
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.readAsArrayBuffer(blob);
|
||||||
|
fileReader.onload = function() {
|
||||||
|
resolve(this.result);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
xhr.addEventListener('progress', function(event) {
|
||||||
|
if (event.lengthComputable && event.target.status === 200) {
|
||||||
|
download.onprogress([event.loaded, event.total]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const auth = await keychain.authHeader();
|
||||||
|
xhr.open('get', `/api/download/${id}`);
|
||||||
|
xhr.setRequestHeader('Authorization', auth);
|
||||||
|
xhr.responseType = 'blob';
|
||||||
|
xhr.send();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return download;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryDownload(id, keychain, onprogress, tries = 1) {
|
||||||
|
const dl = download(id, keychain);
|
||||||
|
dl.onprogress = onprogress;
|
||||||
|
try {
|
||||||
|
const result = await dl.result;
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === '401' && --tries > 0) {
|
||||||
|
return tryDownload(id, keychain, onprogress, tries);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFile(id, keychain) {
|
||||||
|
let cancelled = false;
|
||||||
|
function updateProgress(p) {
|
||||||
|
if (cancelled) {
|
||||||
|
// This is a bit of a hack
|
||||||
|
// We piggyback off of the progress event as a chance to cancel.
|
||||||
|
// Otherwise wiring the xhr abort up while allowing retries
|
||||||
|
// gets pretty nasty.
|
||||||
|
// 'this' here is the object returned by download(id, keychain)
|
||||||
|
return this.cancel();
|
||||||
|
}
|
||||||
|
dl.onprogress(p);
|
||||||
|
}
|
||||||
|
const dl = {
|
||||||
|
onprogress: function() {},
|
||||||
|
cancel: function() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
result: tryDownload(id, keychain, updateProgress, 2)
|
||||||
|
};
|
||||||
|
return dl;
|
||||||
|
}
|
@ -1,51 +1,15 @@
|
|||||||
/* global EXPIRE_SECONDS */
|
|
||||||
import FileSender from './fileSender';
|
import FileSender from './fileSender';
|
||||||
import FileReceiver from './fileReceiver';
|
import FileReceiver from './fileReceiver';
|
||||||
import { copyToClipboard, delay, fadeOut, percent } from './utils';
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
delay,
|
||||||
|
fadeOut,
|
||||||
|
openLinksInNewTab,
|
||||||
|
percent,
|
||||||
|
saveFile
|
||||||
|
} from './utils';
|
||||||
import * as metrics from './metrics';
|
import * as metrics from './metrics';
|
||||||
|
|
||||||
function saveFile(file) {
|
|
||||||
const dataView = new DataView(file.plaintext);
|
|
||||||
const blob = new Blob([dataView], { type: file.type });
|
|
||||||
const downloadUrl = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
if (window.navigator.msSaveBlob) {
|
|
||||||
return window.navigator.msSaveBlob(blob, file.name);
|
|
||||||
}
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = downloadUrl;
|
|
||||||
a.download = file.name;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLinksInNewTab(links, should = true) {
|
|
||||||
links = links || Array.from(document.querySelectorAll('a:not([target])'));
|
|
||||||
if (should) {
|
|
||||||
links.forEach(l => {
|
|
||||||
l.setAttribute('target', '_blank');
|
|
||||||
l.setAttribute('rel', 'noopener noreferrer');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
links.forEach(l => {
|
|
||||||
l.removeAttribute('target');
|
|
||||||
l.removeAttribute('rel');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return links;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDLCounts(file) {
|
|
||||||
const url = `/api/metadata/${file.id}`;
|
|
||||||
const receiver = new FileReceiver(url, file);
|
|
||||||
try {
|
|
||||||
await receiver.getMetadata(file.nonce);
|
|
||||||
return receiver.file;
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message === '404') return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default function(state, emitter) {
|
export default function(state, emitter) {
|
||||||
let lastRender = 0;
|
let lastRender = 0;
|
||||||
let updateTitle = false;
|
let updateTitle = false;
|
||||||
@ -60,14 +24,11 @@ export default function(state, emitter) {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const oldLimit = file.dlimit;
|
const oldLimit = file.dlimit;
|
||||||
const oldTotal = file.dtotal;
|
const oldTotal = file.dtotal;
|
||||||
const receivedFile = await getDLCounts(file);
|
await file.updateDownloadCount();
|
||||||
if (!receivedFile) {
|
if (file.dtotal === file.dlimit) {
|
||||||
state.storage.remove(file.id);
|
state.storage.remove(file.id);
|
||||||
rerender = true;
|
rerender = true;
|
||||||
} else if (
|
} else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) {
|
||||||
oldLimit !== receivedFile.dlimit ||
|
|
||||||
oldTotal !== receivedFile.dtotal
|
|
||||||
) {
|
|
||||||
rerender = true;
|
rerender = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -92,16 +53,15 @@ export default function(state, emitter) {
|
|||||||
checkFiles();
|
checkFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.on('navigate', checkFiles);
|
// emitter.on('navigate', checkFiles);
|
||||||
|
|
||||||
emitter.on('render', () => {
|
emitter.on('render', () => {
|
||||||
lastRender = Date.now();
|
lastRender = Date.now();
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.on('changeLimit', async ({ file, value }) => {
|
emitter.on('changeLimit', async ({ file, value }) => {
|
||||||
await FileSender.changeLimit(file.id, file.ownerToken, value);
|
await file.changeLimit(value);
|
||||||
file.dlimit = value;
|
state.storage.writeFile(file);
|
||||||
state.storage.writeFiles();
|
|
||||||
metrics.changedDownloadLimit(file);
|
metrics.changedDownloadLimit(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -116,11 +76,10 @@ export default function(state, emitter) {
|
|||||||
location
|
location
|
||||||
});
|
});
|
||||||
state.storage.remove(file.id);
|
state.storage.remove(file.id);
|
||||||
await FileSender.delete(file.id, file.ownerToken);
|
await file.del();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.raven.captureException(e);
|
state.raven.captureException(e);
|
||||||
}
|
}
|
||||||
state.fileInfo = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.on('cancel', () => {
|
emitter.on('cancel', () => {
|
||||||
@ -134,32 +93,24 @@ export default function(state, emitter) {
|
|||||||
sender.on('encrypting', render);
|
sender.on('encrypting', render);
|
||||||
state.transfer = sender;
|
state.transfer = sender;
|
||||||
render();
|
render();
|
||||||
|
|
||||||
const links = openLinksInNewTab();
|
const links = openLinksInNewTab();
|
||||||
await delay(200);
|
await delay(200);
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
|
||||||
metrics.startedUpload({ size, type });
|
metrics.startedUpload({ size, type });
|
||||||
const info = await sender.upload();
|
const ownedFile = await sender.upload(state.storage);
|
||||||
const time = Date.now() - start;
|
state.storage.totalUploads += 1;
|
||||||
const speed = size / (time / 1000);
|
metrics.completedUpload(ownedFile);
|
||||||
metrics.completedUpload({ size, time, speed, type });
|
|
||||||
|
state.storage.addFile(ownedFile);
|
||||||
|
|
||||||
document.getElementById('cancel-upload').hidden = 'hidden';
|
document.getElementById('cancel-upload').hidden = 'hidden';
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
await fadeOut('upload-progress');
|
await fadeOut('upload-progress');
|
||||||
info.name = file.name;
|
|
||||||
info.size = size;
|
|
||||||
info.type = type;
|
|
||||||
info.time = time;
|
|
||||||
info.speed = speed;
|
|
||||||
info.createdAt = Date.now();
|
|
||||||
info.url = `${info.url}#${info.secretKey}`;
|
|
||||||
info.expiresAt = Date.now() + EXPIRE_SECONDS * 1000;
|
|
||||||
state.fileInfo = info;
|
|
||||||
state.storage.addFile(state.fileInfo);
|
|
||||||
openLinksInNewTab(links, false);
|
openLinksInNewTab(links, false);
|
||||||
state.transfer = null;
|
state.transfer = null;
|
||||||
state.storage.totalUploads += 1;
|
|
||||||
emitter.emit('pushState', `/share/${info.id}`);
|
emitter.emit('pushState', `/share/${ownedFile.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
state.transfer = null;
|
state.transfer = null;
|
||||||
@ -174,31 +125,29 @@ export default function(state, emitter) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.on('password', async ({ existingPassword, password, file }) => {
|
emitter.on('password', async ({ password, file }) => {
|
||||||
try {
|
try {
|
||||||
await FileSender.setPassword(existingPassword, password, file);
|
await file.setPassword(password);
|
||||||
|
state.storage.writeFile(file);
|
||||||
metrics.addedPassword({ size: file.size });
|
metrics.addedPassword({ size: file.size });
|
||||||
file.password = password;
|
} catch (err) {
|
||||||
state.storage.writeFiles();
|
console.error(err);
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.on('preview', async () => {
|
emitter.on('getMetadata', async () => {
|
||||||
const file = state.fileInfo;
|
const file = state.fileInfo;
|
||||||
const url = `/api/download/${file.id}`;
|
const receiver = new FileReceiver(file);
|
||||||
const receiver = new FileReceiver(url, file);
|
|
||||||
receiver.on('progress', updateProgress);
|
|
||||||
receiver.on('decrypting', render);
|
|
||||||
state.transfer = receiver;
|
|
||||||
try {
|
try {
|
||||||
await receiver.getMetadata(file.nonce);
|
await receiver.getMetadata();
|
||||||
|
receiver.on('progress', updateProgress);
|
||||||
|
receiver.on('decrypting', render);
|
||||||
|
state.transfer = receiver;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === '401') {
|
if (e.message === '401') {
|
||||||
file.password = null;
|
file.password = null;
|
||||||
if (!file.pwd) {
|
if (!file.requiresPassword) {
|
||||||
return emitter.emit('pushState', '/404');
|
return emitter.emit('pushState', '/404');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,7 +163,7 @@ export default function(state, emitter) {
|
|||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
metrics.startedDownload({ size: file.size, ttl: file.ttl });
|
metrics.startedDownload({ size: file.size, ttl: file.ttl });
|
||||||
const f = await state.transfer.download(file.nonce);
|
const f = await state.transfer.download();
|
||||||
const time = Date.now() - start;
|
const time = Date.now() - start;
|
||||||
const speed = size / (time / 1000);
|
const speed = size / (time / 1000);
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
@ -225,8 +174,11 @@ export default function(state, emitter) {
|
|||||||
metrics.completedDownload({ size, time, speed });
|
metrics.completedDownload({ size, time, speed });
|
||||||
emitter.emit('pushState', '/completed');
|
emitter.emit('pushState', '/completed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.message === '0') {
|
||||||
|
// download cancelled
|
||||||
|
return render();
|
||||||
|
}
|
||||||
console.error(err);
|
console.error(err);
|
||||||
// TODO cancelled download
|
|
||||||
const location = err.message === 'notfound' ? '/404' : '/error';
|
const location = err.message === 'notfound' ? '/404' : '/error';
|
||||||
if (location === '/error') {
|
if (location === '/error') {
|
||||||
state.raven.captureException(err);
|
state.raven.captureException(err);
|
||||||
@ -244,6 +196,14 @@ export default function(state, emitter) {
|
|||||||
metrics.copiedLink({ location });
|
metrics.copiedLink({ location });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
// poll for updates of the download counts
|
||||||
|
// TODO something for the share page: || state.route === '/share/:id'
|
||||||
|
if (state.route === '/') {
|
||||||
|
checkFiles();
|
||||||
|
}
|
||||||
|
}, 2 * 60 * 1000);
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
// poll for rerendering the file list countdown timers
|
// poll for rerendering the file list countdown timers
|
||||||
if (
|
if (
|
||||||
|
@ -1,104 +1,21 @@
|
|||||||
import Nanobus from 'nanobus';
|
import Nanobus from 'nanobus';
|
||||||
import { arrayToB64, b64ToArray, bytes } from './utils';
|
import Keychain from './keychain';
|
||||||
|
import { bytes } from './utils';
|
||||||
|
import { metadata, downloadFile } from './api';
|
||||||
|
|
||||||
export default class FileReceiver extends Nanobus {
|
export default class FileReceiver extends Nanobus {
|
||||||
constructor(url, file) {
|
constructor(fileInfo) {
|
||||||
super('FileReceiver');
|
super('FileReceiver');
|
||||||
this.secretKeyPromise = window.crypto.subtle.importKey(
|
this.keychain = new Keychain(fileInfo.secretKey, fileInfo.nonce);
|
||||||
'raw',
|
if (fileInfo.requiresPassword) {
|
||||||
b64ToArray(file.secretKey),
|
this.keychain.setPassword(fileInfo.password, fileInfo.url);
|
||||||
'HKDF',
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
this.encryptKeyPromise = this.secretKeyPromise.then(sk => {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
salt: new Uint8Array(),
|
|
||||||
info: encoder.encode('encryption'),
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
sk,
|
|
||||||
{
|
|
||||||
name: 'AES-GCM',
|
|
||||||
length: 128
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
['decrypt']
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (file.pwd) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
this.authKeyPromise = window.crypto.subtle
|
|
||||||
.importKey(
|
|
||||||
'raw',
|
|
||||||
encoder.encode(file.password),
|
|
||||||
{ name: 'PBKDF2' },
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
)
|
|
||||||
.then(pwdKey =>
|
|
||||||
window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: encoder.encode(file.url),
|
|
||||||
iterations: 100,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
pwdKey,
|
|
||||||
{
|
|
||||||
name: 'HMAC',
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['sign']
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.authKeyPromise = this.secretKeyPromise.then(sk => {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
salt: new Uint8Array(),
|
|
||||||
info: encoder.encode('authentication'),
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
sk,
|
|
||||||
{
|
|
||||||
name: 'HMAC',
|
|
||||||
hash: { name: 'SHA-256' }
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.metaKeyPromise = this.secretKeyPromise.then(sk => {
|
this.fileInfo = fileInfo;
|
||||||
const encoder = new TextEncoder();
|
this.fileDownload = null;
|
||||||
return window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
salt: new Uint8Array(),
|
|
||||||
info: encoder.encode('metadata'),
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
sk,
|
|
||||||
{
|
|
||||||
name: 'AES-GCM',
|
|
||||||
length: 128
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
['decrypt']
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.file = file;
|
|
||||||
this.url = url;
|
|
||||||
this.msg = 'fileSizeProgress';
|
this.msg = 'fileSizeProgress';
|
||||||
this.state = 'initialized';
|
this.state = 'initialized';
|
||||||
this.progress = [0, 1];
|
this.progress = [0, 1];
|
||||||
|
this.cancelled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get progressRatio() {
|
get progressRatio() {
|
||||||
@ -113,160 +30,51 @@ export default class FileReceiver extends Nanobus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
// TODO
|
this.cancelled = true;
|
||||||
}
|
if (this.fileDownload) {
|
||||||
|
this.fileDownload.cancel();
|
||||||
async fetchMetadata(nonce) {
|
|
||||||
const authHeader = await this.getAuthHeader(nonce);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.onreadystatechange = () => {
|
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
||||||
if (xhr.status === 404) {
|
|
||||||
return reject(new Error(xhr.status));
|
|
||||||
}
|
|
||||||
const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
|
|
||||||
this.file.nonce = nonce;
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
return resolve(xhr.response);
|
|
||||||
}
|
|
||||||
const err = new Error(xhr.status);
|
|
||||||
err.nonce = nonce;
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = () => reject(new Error(0));
|
|
||||||
xhr.ontimeout = () => reject(new Error(0));
|
|
||||||
xhr.open('get', `/api/metadata/${this.file.id}`);
|
|
||||||
xhr.setRequestHeader('Authorization', authHeader);
|
|
||||||
xhr.responseType = 'json';
|
|
||||||
xhr.timeout = 2000;
|
|
||||||
xhr.send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMetadata(nonce) {
|
|
||||||
let data = null;
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
data = await this.fetchMetadata(nonce);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message === '401' && nonce !== e.nonce) {
|
|
||||||
// allow one retry for changed nonce
|
|
||||||
data = await this.fetchMetadata(e.nonce);
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const metaKey = await this.metaKeyPromise;
|
|
||||||
const json = await window.crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: 'AES-GCM',
|
|
||||||
iv: new Uint8Array(12),
|
|
||||||
tagLength: 128
|
|
||||||
},
|
|
||||||
metaKey,
|
|
||||||
b64ToArray(data.metadata)
|
|
||||||
);
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const meta = JSON.parse(decoder.decode(json));
|
|
||||||
this.file.name = meta.name;
|
|
||||||
this.file.type = meta.type;
|
|
||||||
this.file.iv = meta.iv;
|
|
||||||
this.file.size = data.size;
|
|
||||||
this.file.ttl = data.ttl;
|
|
||||||
this.file.dlimit = data.dlimit;
|
|
||||||
this.file.dtotal = data.dtotal;
|
|
||||||
this.state = 'ready';
|
|
||||||
} catch (e) {
|
|
||||||
this.state = 'invalid';
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile(nonce) {
|
async getMetadata() {
|
||||||
const authHeader = await this.getAuthHeader(nonce);
|
const meta = await metadata(this.fileInfo.id, this.keychain);
|
||||||
return new Promise((resolve, reject) => {
|
if (meta) {
|
||||||
const xhr = new XMLHttpRequest();
|
this.keychain.setIV(meta.iv);
|
||||||
|
this.fileInfo.name = meta.name;
|
||||||
xhr.onprogress = event => {
|
this.fileInfo.type = meta.type;
|
||||||
if (event.lengthComputable && event.target.status !== 404) {
|
this.fileInfo.iv = meta.iv;
|
||||||
this.progress = [event.loaded, event.total];
|
this.fileInfo.size = meta.size;
|
||||||
this.emit('progress', this.progress);
|
this.state = 'ready';
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
this.state = 'invalid';
|
||||||
xhr.onload = event => {
|
return;
|
||||||
if (xhr.status === 404) {
|
|
||||||
reject(new Error('notfound'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (xhr.status !== 200) {
|
|
||||||
const err = new Error(xhr.status);
|
|
||||||
err.nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([xhr.response]);
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
fileReader.onload = function() {
|
|
||||||
resolve(this.result);
|
|
||||||
};
|
|
||||||
|
|
||||||
fileReader.readAsArrayBuffer(blob);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.open('get', this.url);
|
|
||||||
xhr.setRequestHeader('Authorization', authHeader);
|
|
||||||
xhr.responseType = 'blob';
|
|
||||||
xhr.send();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthHeader(nonce) {
|
async download() {
|
||||||
const authKey = await this.authKeyPromise;
|
|
||||||
const sig = await window.crypto.subtle.sign(
|
|
||||||
{
|
|
||||||
name: 'HMAC'
|
|
||||||
},
|
|
||||||
authKey,
|
|
||||||
b64ToArray(nonce)
|
|
||||||
);
|
|
||||||
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async download(nonce) {
|
|
||||||
this.state = 'downloading';
|
this.state = 'downloading';
|
||||||
this.emit('progress', this.progress);
|
this.emit('progress', this.progress);
|
||||||
try {
|
try {
|
||||||
const encryptKey = await this.encryptKeyPromise;
|
const download = await downloadFile(this.fileInfo.id, this.keychain);
|
||||||
let ciphertext = null;
|
download.onprogress = p => {
|
||||||
try {
|
this.progress = p;
|
||||||
ciphertext = await this.downloadFile(nonce);
|
this.emit('progress', p);
|
||||||
} catch (e) {
|
};
|
||||||
if (e.message === '401' && nonce !== e.nonce) {
|
this.fileDownload = download;
|
||||||
ciphertext = await this.downloadFile(e.nonce);
|
const ciphertext = await download.result;
|
||||||
} else {
|
this.fileDownload = null;
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.msg = 'decryptingFile';
|
this.msg = 'decryptingFile';
|
||||||
this.emit('decrypting');
|
this.emit('decrypting');
|
||||||
const plaintext = await window.crypto.subtle.decrypt(
|
const plaintext = await this.keychain.decryptFile(ciphertext);
|
||||||
{
|
if (this.cancelled) {
|
||||||
name: 'AES-GCM',
|
throw new Error(0);
|
||||||
iv: b64ToArray(this.file.iv),
|
}
|
||||||
tagLength: 128
|
|
||||||
},
|
|
||||||
encryptKey,
|
|
||||||
ciphertext
|
|
||||||
);
|
|
||||||
this.msg = 'downloadFinish';
|
this.msg = 'downloadFinish';
|
||||||
this.state = 'complete';
|
this.state = 'complete';
|
||||||
return {
|
return {
|
||||||
plaintext,
|
plaintext,
|
||||||
name: decodeURIComponent(this.file.name),
|
name: decodeURIComponent(this.fileInfo.name),
|
||||||
type: this.file.type
|
type: this.fileInfo.type
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.state = 'invalid';
|
this.state = 'invalid';
|
||||||
|
@ -1,97 +1,19 @@
|
|||||||
|
/* global EXPIRE_SECONDS */
|
||||||
import Nanobus from 'nanobus';
|
import Nanobus from 'nanobus';
|
||||||
import { arrayToB64, b64ToArray, bytes } from './utils';
|
import OwnedFile from './ownedFile';
|
||||||
|
import Keychain from './keychain';
|
||||||
async function getAuthHeader(authKey, nonce) {
|
import { arrayToB64, bytes } from './utils';
|
||||||
const sig = await window.crypto.subtle.sign(
|
import { uploadFile } from './api';
|
||||||
{
|
|
||||||
name: 'HMAC'
|
|
||||||
},
|
|
||||||
authKey,
|
|
||||||
b64ToArray(nonce)
|
|
||||||
);
|
|
||||||
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPassword(file, authKey, rawAuth) {
|
|
||||||
const authHeader = await getAuthHeader(authKey, file.nonce);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.onreadystatechange = () => {
|
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
|
|
||||||
file.nonce = nonce;
|
|
||||||
return resolve(xhr.response);
|
|
||||||
}
|
|
||||||
reject(new Error(xhr.status));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = () => reject(new Error(0));
|
|
||||||
xhr.ontimeout = () => reject(new Error(0));
|
|
||||||
xhr.open('post', `/api/password/${file.id}`);
|
|
||||||
xhr.setRequestHeader('Authorization', authHeader);
|
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
||||||
xhr.responseType = 'json';
|
|
||||||
xhr.timeout = 2000;
|
|
||||||
xhr.send(JSON.stringify({ auth: arrayToB64(new Uint8Array(rawAuth)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class FileSender extends Nanobus {
|
export default class FileSender extends Nanobus {
|
||||||
constructor(file) {
|
constructor(file) {
|
||||||
super('FileSender');
|
super('FileSender');
|
||||||
this.file = file;
|
this.file = file;
|
||||||
|
this.uploadRequest = null;
|
||||||
this.msg = 'importingFile';
|
this.msg = 'importingFile';
|
||||||
this.progress = [0, 1];
|
this.progress = [0, 1];
|
||||||
this.cancelled = false;
|
this.cancelled = false;
|
||||||
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
|
this.keychain = new Keychain();
|
||||||
this.uploadXHR = new XMLHttpRequest();
|
|
||||||
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
this.secretKey = window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
this.rawSecret,
|
|
||||||
'HKDF',
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static delete(id, token) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!id || !token) {
|
|
||||||
return reject();
|
|
||||||
}
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', `/api/delete/${id}`);
|
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
xhr.onreadystatechange = () => {
|
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(JSON.stringify({ owner_token: token }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static changeLimit(id, owner_token, dlimit) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!id || !owner_token) {
|
|
||||||
return reject();
|
|
||||||
}
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', `/api/params/${id}`);
|
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
xhr.onreadystatechange = () => {
|
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(JSON.stringify({ owner_token, dlimit }));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get progressRatio() {
|
get progressRatio() {
|
||||||
@ -107,8 +29,8 @@ export default class FileSender extends Nanobus {
|
|||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.cancelled = true;
|
this.cancelled = true;
|
||||||
if (this.msg === 'fileSizeProgress') {
|
if (this.uploadRequest) {
|
||||||
this.uploadXHR.abort();
|
this.uploadRequest.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,6 +38,7 @@ export default class FileSender extends Nanobus {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsArrayBuffer(this.file);
|
reader.readAsArrayBuffer(this.file);
|
||||||
|
// TODO: progress?
|
||||||
reader.onload = function(event) {
|
reader.onload = function(event) {
|
||||||
const plaintext = new Uint8Array(this.result);
|
const plaintext = new Uint8Array(this.result);
|
||||||
resolve(plaintext);
|
resolve(plaintext);
|
||||||
@ -126,218 +49,60 @@ export default class FileSender extends Nanobus {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFile(encrypted, metadata, rawAuth) {
|
async upload(storage) {
|
||||||
return new Promise((resolve, reject) => {
|
const start = Date.now();
|
||||||
const dataView = new DataView(encrypted);
|
|
||||||
const blob = new Blob([dataView], { type: 'application/octet-stream' });
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('data', blob);
|
|
||||||
|
|
||||||
const xhr = this.uploadXHR;
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', e => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
this.progress = [e.loaded, e.total];
|
|
||||||
this.emit('progress', this.progress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.onreadystatechange = () => {
|
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
const nonce = xhr
|
|
||||||
.getResponseHeader('WWW-Authenticate')
|
|
||||||
.split(' ')[1];
|
|
||||||
this.progress = [1, 1];
|
|
||||||
this.msg = 'notifyUploadDone';
|
|
||||||
const responseObj = JSON.parse(xhr.responseText);
|
|
||||||
return resolve({
|
|
||||||
url: responseObj.url,
|
|
||||||
id: responseObj.id,
|
|
||||||
secretKey: arrayToB64(this.rawSecret),
|
|
||||||
ownerToken: responseObj.owner,
|
|
||||||
nonce
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.msg = 'errorPageHeader';
|
|
||||||
reject(new Error(xhr.status));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.open('post', '/api/upload', true);
|
|
||||||
xhr.setRequestHeader(
|
|
||||||
'X-File-Metadata',
|
|
||||||
arrayToB64(new Uint8Array(metadata))
|
|
||||||
);
|
|
||||||
xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
|
|
||||||
xhr.send(fd);
|
|
||||||
this.msg = 'fileSizeProgress';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async upload() {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const secretKey = await this.secretKey;
|
|
||||||
const encryptKey = await window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
salt: new Uint8Array(),
|
|
||||||
info: encoder.encode('encryption'),
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
secretKey,
|
|
||||||
{
|
|
||||||
name: 'AES-GCM',
|
|
||||||
length: 128
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
);
|
|
||||||
const authKey = await window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
salt: new Uint8Array(),
|
|
||||||
info: encoder.encode('authentication'),
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
secretKey,
|
|
||||||
{
|
|
||||||
name: 'HMAC',
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
const metaKey = await window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
salt: new Uint8Array(),
|
|
||||||
info: encoder.encode('metadata'),
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
secretKey,
|
|
||||||
{
|
|
||||||
name: 'AES-GCM',
|
|
||||||
length: 128
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
);
|
|
||||||
const plaintext = await this.readFile();
|
const plaintext = await this.readFile();
|
||||||
if (this.cancelled) {
|
if (this.cancelled) {
|
||||||
throw new Error(0);
|
throw new Error(0);
|
||||||
}
|
}
|
||||||
this.msg = 'encryptingFile';
|
this.msg = 'encryptingFile';
|
||||||
this.emit('encrypting');
|
this.emit('encrypting');
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
const encrypted = await this.keychain.encryptFile(plaintext);
|
||||||
{
|
const metadata = await this.keychain.encryptMetadata(this.file);
|
||||||
name: 'AES-GCM',
|
const authKeyB64 = await this.keychain.authKeyB64();
|
||||||
iv: this.iv,
|
|
||||||
tagLength: 128
|
|
||||||
},
|
|
||||||
encryptKey,
|
|
||||||
plaintext
|
|
||||||
);
|
|
||||||
const metadata = await window.crypto.subtle.encrypt(
|
|
||||||
{
|
|
||||||
name: 'AES-GCM',
|
|
||||||
iv: new Uint8Array(12),
|
|
||||||
tagLength: 128
|
|
||||||
},
|
|
||||||
metaKey,
|
|
||||||
encoder.encode(
|
|
||||||
JSON.stringify({
|
|
||||||
iv: arrayToB64(this.iv),
|
|
||||||
name: this.file.name,
|
|
||||||
type: this.file.type || 'application/octet-stream'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
|
|
||||||
if (this.cancelled) {
|
if (this.cancelled) {
|
||||||
throw new Error(0);
|
throw new Error(0);
|
||||||
}
|
}
|
||||||
return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
|
this.uploadRequest = uploadFile(
|
||||||
}
|
encrypted,
|
||||||
|
metadata,
|
||||||
static async setPassword(existingPassword, password, file) {
|
authKeyB64,
|
||||||
const encoder = new TextEncoder();
|
this.keychain
|
||||||
const secretKey = await window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
b64ToArray(file.secretKey),
|
|
||||||
'HKDF',
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
);
|
||||||
const authKey = await window.crypto.subtle.deriveKey(
|
this.msg = 'fileSizeProgress';
|
||||||
{
|
this.uploadRequest.onprogress = p => {
|
||||||
name: 'HKDF',
|
this.progress = p;
|
||||||
salt: new Uint8Array(),
|
this.emit('progress', p);
|
||||||
info: encoder.encode('authentication'),
|
};
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
secretKey,
|
|
||||||
{
|
|
||||||
name: 'HMAC',
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
const pwdKey = await window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
encoder.encode(password),
|
|
||||||
{ name: 'PBKDF2' },
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
const oldPwdkey = await window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
encoder.encode(existingPassword),
|
|
||||||
{ name: 'PBKDF2' },
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
const oldAuthKey = await window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: encoder.encode(file.url),
|
|
||||||
iterations: 100,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
oldPwdkey,
|
|
||||||
{
|
|
||||||
name: 'HMAC',
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
const newAuthKey = await window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: encoder.encode(file.url),
|
|
||||||
iterations: 100,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
pwdKey,
|
|
||||||
{
|
|
||||||
name: 'HMAC',
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
const rawAuth = await window.crypto.subtle.exportKey('raw', newAuthKey);
|
|
||||||
const aKey = existingPassword ? oldAuthKey : authKey;
|
|
||||||
try {
|
try {
|
||||||
await sendPassword(file, aKey, rawAuth);
|
const result = await this.uploadRequest.result;
|
||||||
|
const time = Date.now() - start;
|
||||||
|
this.msg = 'notifyUploadDone';
|
||||||
|
this.uploadRequest = null;
|
||||||
|
this.progress = [1, 1];
|
||||||
|
const secretKey = arrayToB64(this.keychain.rawSecret);
|
||||||
|
const ownedFile = new OwnedFile(
|
||||||
|
{
|
||||||
|
id: result.id,
|
||||||
|
url: `${result.url}#${secretKey}`,
|
||||||
|
name: this.file.name,
|
||||||
|
size: this.file.size,
|
||||||
|
type: this.file.type, //TODO 'click' ?
|
||||||
|
time: time,
|
||||||
|
speed: this.file.size / (time / 1000),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + EXPIRE_SECONDS * 1000,
|
||||||
|
secretKey: secretKey,
|
||||||
|
nonce: this.keychain.nonce,
|
||||||
|
ownerToken: result.ownerToken
|
||||||
|
},
|
||||||
|
storage
|
||||||
|
);
|
||||||
|
return ownedFile;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === '401' && file.nonce !== e.nonce) {
|
this.msg = 'errorPageHeader';
|
||||||
await sendPassword(file, aKey, rawAuth);
|
this.uploadRequest = null;
|
||||||
} else {
|
throw e;
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
212
app/keychain.js
Normal file
212
app/keychain.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import Nanobus from 'nanobus';
|
||||||
|
import { arrayToB64, b64ToArray } from './utils';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
export default class Keychain extends Nanobus {
|
||||||
|
constructor(secretKeyB64, nonce, ivB64) {
|
||||||
|
super('Keychain');
|
||||||
|
this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ==';
|
||||||
|
if (ivB64) {
|
||||||
|
this.iv = b64ToArray(ivB64);
|
||||||
|
} else {
|
||||||
|
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
}
|
||||||
|
if (secretKeyB64) {
|
||||||
|
this.rawSecret = b64ToArray(secretKeyB64);
|
||||||
|
} else {
|
||||||
|
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
}
|
||||||
|
this.secretKeyPromise = window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
this.rawSecret,
|
||||||
|
'HKDF',
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) {
|
||||||
|
return window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
salt: new Uint8Array(),
|
||||||
|
info: encoder.encode('encryption'),
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
secretKey,
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
length: 128
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) {
|
||||||
|
return window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
salt: new Uint8Array(),
|
||||||
|
info: encoder.encode('metadata'),
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
secretKey,
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
length: 128
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) {
|
||||||
|
return window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
salt: new Uint8Array(),
|
||||||
|
info: encoder.encode('authentication'),
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
secretKey,
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: { name: 'SHA-256' }
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get nonce() {
|
||||||
|
return this._nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
set nonce(n) {
|
||||||
|
if (n !== this.nonce) {
|
||||||
|
this.emit('nonceChanged', n);
|
||||||
|
}
|
||||||
|
this._nonce = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIV(ivB64) {
|
||||||
|
this.iv = b64ToArray(ivB64);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(password, shareUrl) {
|
||||||
|
this.authKeyPromise = window.crypto.subtle
|
||||||
|
.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [
|
||||||
|
'deriveKey'
|
||||||
|
])
|
||||||
|
.then(passwordKey =>
|
||||||
|
window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: encoder.encode(shareUrl),
|
||||||
|
iterations: 100,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
passwordKey,
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['sign']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthKey(authKeyB64) {
|
||||||
|
this.authKeyPromise = window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
b64ToArray(authKeyB64),
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async authKeyB64() {
|
||||||
|
const authKey = await this.authKeyPromise;
|
||||||
|
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
|
||||||
|
return arrayToB64(new Uint8Array(rawAuth));
|
||||||
|
}
|
||||||
|
|
||||||
|
async authHeader() {
|
||||||
|
const authKey = await this.authKeyPromise;
|
||||||
|
const sig = await window.crypto.subtle.sign(
|
||||||
|
{
|
||||||
|
name: 'HMAC'
|
||||||
|
},
|
||||||
|
authKey,
|
||||||
|
b64ToArray(this.nonce)
|
||||||
|
);
|
||||||
|
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptFile(plaintext) {
|
||||||
|
const encryptKey = await this.encryptKeyPromise;
|
||||||
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: this.iv,
|
||||||
|
tagLength: 128
|
||||||
|
},
|
||||||
|
encryptKey,
|
||||||
|
plaintext
|
||||||
|
);
|
||||||
|
return ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptMetadata(metadata) {
|
||||||
|
const metaKey = await this.metaKeyPromise;
|
||||||
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: new Uint8Array(12),
|
||||||
|
tagLength: 128
|
||||||
|
},
|
||||||
|
metaKey,
|
||||||
|
encoder.encode(
|
||||||
|
JSON.stringify({
|
||||||
|
iv: arrayToB64(this.iv),
|
||||||
|
name: metadata.name,
|
||||||
|
type: metadata.type || 'application/octet-stream'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptFile(ciphertext) {
|
||||||
|
const encryptKey = await this.encryptKeyPromise;
|
||||||
|
const plaintext = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: this.iv,
|
||||||
|
tagLength: 128
|
||||||
|
},
|
||||||
|
encryptKey,
|
||||||
|
ciphertext
|
||||||
|
);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptMetadata(ciphertext) {
|
||||||
|
const metaKey = await this.metaKeyPromise;
|
||||||
|
const plaintext = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: new Uint8Array(12),
|
||||||
|
tagLength: 128
|
||||||
|
},
|
||||||
|
metaKey,
|
||||||
|
ciphertext
|
||||||
|
);
|
||||||
|
return JSON.parse(decoder.decode(plaintext));
|
||||||
|
}
|
||||||
|
}
|
24
app/main.js
24
app/main.js
@ -15,30 +15,34 @@ if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.use((state, emitter) => {
|
app.use((state, emitter) => {
|
||||||
// init state
|
|
||||||
state.transfer = null;
|
state.transfer = null;
|
||||||
state.fileInfo = null;
|
state.fileInfo = null;
|
||||||
state.translate = locale.getTranslator();
|
state.translate = locale.getTranslator();
|
||||||
state.storage = storage;
|
state.storage = storage;
|
||||||
state.raven = Raven;
|
state.raven = Raven;
|
||||||
emitter.on('DOMContentLoaded', async () => {
|
window.appState = state;
|
||||||
let reason = null;
|
emitter.on('DOMContentLoaded', async function checkSupport() {
|
||||||
|
let unsupportedReason = null;
|
||||||
if (
|
if (
|
||||||
|
// Firefox < 50
|
||||||
/firefox/i.test(navigator.userAgent) &&
|
/firefox/i.test(navigator.userAgent) &&
|
||||||
parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) <=
|
parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) < 50
|
||||||
49
|
|
||||||
) {
|
) {
|
||||||
reason = 'outdated';
|
unsupportedReason = 'outdated';
|
||||||
}
|
}
|
||||||
if (/edge\/\d+/i.test(navigator.userAgent)) {
|
if (/edge\/\d+/i.test(navigator.userAgent)) {
|
||||||
reason = 'edge';
|
unsupportedReason = 'edge';
|
||||||
}
|
}
|
||||||
const ok = await canHasSend(assets.get('cryptofill.js'));
|
const ok = await canHasSend(assets.get('cryptofill.js'));
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
reason = /firefox/i.test(navigator.userAgent) ? 'outdated' : 'gcm';
|
unsupportedReason = /firefox/i.test(navigator.userAgent)
|
||||||
|
? 'outdated'
|
||||||
|
: 'gcm';
|
||||||
}
|
}
|
||||||
if (reason) {
|
if (unsupportedReason) {
|
||||||
setTimeout(() => emitter.emit('replaceState', `/unsupported/${reason}`));
|
setTimeout(() =>
|
||||||
|
emitter.emit('replaceState', `/unsupported/${unsupportedReason}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
81
app/ownedFile.js
Normal file
81
app/ownedFile.js
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import Keychain from './keychain';
|
||||||
|
import { arrayToB64 } from './utils';
|
||||||
|
import { del, metadata, setParams, setPassword } from './api';
|
||||||
|
|
||||||
|
export default class OwnedFile {
|
||||||
|
constructor(obj, storage) {
|
||||||
|
this.id = obj.id;
|
||||||
|
this.url = obj.url;
|
||||||
|
this.name = obj.name;
|
||||||
|
this.size = obj.size;
|
||||||
|
this.type = obj.type;
|
||||||
|
this.time = obj.time;
|
||||||
|
this.speed = obj.speed;
|
||||||
|
this.createdAt = obj.createdAt;
|
||||||
|
this.expiresAt = obj.expiresAt;
|
||||||
|
this.ownerToken = obj.ownerToken;
|
||||||
|
this.dlimit = obj.dlimit || 1;
|
||||||
|
this.dtotal = obj.dtotal || 0;
|
||||||
|
this.keychain = new Keychain(obj.secretKey, obj.nonce);
|
||||||
|
this.keychain.on('nonceChanged', () => storage.writeFile(this));
|
||||||
|
if (obj.authKeyB64) {
|
||||||
|
this.authKeyB64 = obj.authKeyB64;
|
||||||
|
this.keychain.setAuthKey(obj.authKeyB64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(password) {
|
||||||
|
this.password = password;
|
||||||
|
this.keychain.setPassword(password, this.url);
|
||||||
|
const result = await setPassword(this.id, this.ownerToken, this.keychain);
|
||||||
|
this.authKeyB64 = await this.keychain.authKeyB64();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
del() {
|
||||||
|
return del(this.id, this.ownerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLimit(dlimit) {
|
||||||
|
if (this.dlimit !== dlimit) {
|
||||||
|
this.dlimit = dlimit;
|
||||||
|
return setParams(this.id, this.ownerToken, { dlimit });
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPassword() {
|
||||||
|
return !!this.authKeyB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDownloadCount() {
|
||||||
|
try {
|
||||||
|
const result = await metadata(this.id, this.keychain);
|
||||||
|
this.dtotal = result.dtotal;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === '404') {
|
||||||
|
this.dtotal = this.dlimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
url: this.url,
|
||||||
|
name: this.name,
|
||||||
|
size: this.size,
|
||||||
|
type: this.type,
|
||||||
|
time: this.time,
|
||||||
|
speed: this.speed,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
expiresAt: this.expiresAt,
|
||||||
|
secretKey: arrayToB64(this.keychain.rawSecret),
|
||||||
|
nonce: this.keychain.nonce,
|
||||||
|
ownerToken: this.ownerToken,
|
||||||
|
dlimit: this.dlimit,
|
||||||
|
dtotal: this.dtotal,
|
||||||
|
authKeyB64: this.authKeyB64
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,60 @@
|
|||||||
const preview = require('../templates/preview');
|
const preview = require('../templates/preview');
|
||||||
const download = require('../templates/download');
|
const download = require('../templates/download');
|
||||||
|
const notFound = require('../templates/notFound');
|
||||||
|
const downloadPassword = require('../templates/downloadPassword');
|
||||||
|
const downloadButton = require('../templates/downloadButton');
|
||||||
|
|
||||||
|
function hasFileInfo() {
|
||||||
|
return !!document.getElementById('dl-file');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileInfoFromDOM() {
|
||||||
|
const el = document.getElementById('dl-file');
|
||||||
|
if (!el) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nonce: el.getAttribute('data-nonce'),
|
||||||
|
requiresPassword: !!+el.getAttribute('data-requires-password')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileInfo(state) {
|
||||||
|
const metadata = getFileInfoFromDOM();
|
||||||
|
return {
|
||||||
|
id: state.params.id,
|
||||||
|
secretKey: state.params.key,
|
||||||
|
nonce: metadata.nonce,
|
||||||
|
requiresPassword: metadata.requiresPassword
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function(state, emit) {
|
module.exports = function(state, emit) {
|
||||||
|
if (!state.fileInfo) {
|
||||||
|
// This is a fresh page load
|
||||||
|
// We need to parse the file info from the server's html
|
||||||
|
if (!hasFileInfo()) {
|
||||||
|
return notFound(state, emit);
|
||||||
|
}
|
||||||
|
state.fileInfo = createFileInfo(state);
|
||||||
|
|
||||||
|
if (!state.fileInfo.requiresPassword) {
|
||||||
|
emit('getMetadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageAction = ''; //default state: we don't have file metadata
|
||||||
if (state.transfer) {
|
if (state.transfer) {
|
||||||
const s = state.transfer.state;
|
const s = state.transfer.state;
|
||||||
if (s === 'downloading' || s === 'complete') {
|
if (s === 'downloading' || s === 'complete') {
|
||||||
|
// Downloading is in progress
|
||||||
return download(state, emit);
|
return download(state, emit);
|
||||||
}
|
}
|
||||||
|
// we have file metadata
|
||||||
|
pageAction = downloadButton(state, emit);
|
||||||
|
} else if (state.fileInfo.requiresPassword && !state.fileInfo.password) {
|
||||||
|
// we're waiting on the user for a valid password
|
||||||
|
pageAction = downloadPassword(state, emit);
|
||||||
}
|
}
|
||||||
return preview(state, emit);
|
return preview(state, pageAction);
|
||||||
};
|
};
|
||||||
|
@ -2,8 +2,7 @@ const welcome = require('../templates/welcome');
|
|||||||
const upload = require('../templates/upload');
|
const upload = require('../templates/upload');
|
||||||
|
|
||||||
module.exports = function(state, emit) {
|
module.exports = function(state, emit) {
|
||||||
if (state.transfer && state.transfer.iv) {
|
if (state.transfer) {
|
||||||
//TODO relying on 'iv' is gross
|
|
||||||
return upload(state, emit);
|
return upload(state, emit);
|
||||||
}
|
}
|
||||||
return welcome(state, emit);
|
return welcome(state, emit);
|
||||||
|
@ -7,26 +7,33 @@ const fxPromo = require('../templates/fxPromo');
|
|||||||
|
|
||||||
const app = choo();
|
const app = choo();
|
||||||
|
|
||||||
function showBanner(state) {
|
function banner(state, emit) {
|
||||||
return state.promo && !state.route.startsWith('/unsupported/');
|
if (state.promo && !state.route.startsWith('/unsupported/')) {
|
||||||
|
return fxPromo(state, emit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function body(template) {
|
function body(template) {
|
||||||
return function(state, emit) {
|
return function(state, emit) {
|
||||||
const b = html`<body>
|
const b = html`<body>
|
||||||
${showBanner(state) ? fxPromo(state, emit) : ''}
|
${banner(state, emit)}
|
||||||
${header(state)}
|
${header(state)}
|
||||||
<div class="all">
|
<div class="all">
|
||||||
<noscript>
|
<noscript>
|
||||||
<h2>Firefox Send requires JavaScript</h2>
|
<h2>${state.translate('javascriptRequired')}</h2>
|
||||||
<p><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">Why does Firefox Send require JavaScript?</a></p>
|
<p>
|
||||||
<p>Please enable JavaScript and try again.</p>
|
<a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">
|
||||||
|
${state.translate('whyJavascript')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>${state.translate('enableJavascript')}</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
${template(state, emit)}
|
${template(state, emit)}
|
||||||
</div>
|
</div>
|
||||||
${footer(state)}
|
${footer(state)}
|
||||||
</body>`;
|
</body>`;
|
||||||
if (state.layout) {
|
if (state.layout) {
|
||||||
|
// server side only
|
||||||
return state.layout(state, b);
|
return state.layout(state, b);
|
||||||
}
|
}
|
||||||
return b;
|
return b;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { isFile } from './utils';
|
import { isFile } from './utils';
|
||||||
|
import OwnedFile from './ownedFile';
|
||||||
|
|
||||||
class Mem {
|
class Mem {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -42,7 +43,7 @@ class Storage {
|
|||||||
const k = this.engine.key(i);
|
const k = this.engine.key(i);
|
||||||
if (isFile(k)) {
|
if (isFile(k)) {
|
||||||
try {
|
try {
|
||||||
const f = JSON.parse(this.engine.getItem(k));
|
const f = new OwnedFile(JSON.parse(this.engine.getItem(k)), this);
|
||||||
if (!f.id) {
|
if (!f.id) {
|
||||||
f.id = f.fileId;
|
f.id = f.fileId;
|
||||||
}
|
}
|
||||||
@ -108,11 +109,15 @@ class Storage {
|
|||||||
|
|
||||||
addFile(file) {
|
addFile(file) {
|
||||||
this._files.push(file);
|
this._files.push(file);
|
||||||
|
this.writeFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(file) {
|
||||||
this.engine.setItem(file.id, JSON.stringify(file));
|
this.engine.setItem(file.id, JSON.stringify(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFiles() {
|
writeFiles() {
|
||||||
this._files.forEach(f => this.engine.setItem(f.id, JSON.stringify(f)));
|
this._files.forEach(f => this.writeFile(f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
const html = require('choo/html');
|
const html = require('choo/html');
|
||||||
|
|
||||||
module.exports = function() {
|
module.exports = function() {
|
||||||
const div = html`<div id="page-one"></div>`;
|
const div = html`<div></div>`;
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
@ -5,21 +5,22 @@ const { fadeOut } = require('../utils');
|
|||||||
module.exports = function(state, emit) {
|
module.exports = function(state, emit) {
|
||||||
const div = html`
|
const div = html`
|
||||||
<div id="page-one">
|
<div id="page-one">
|
||||||
<div id="download" class="fadeIn">
|
<div id="download" class="fadeIn">
|
||||||
<div id="download-progress">
|
<div id="download-progress">
|
||||||
<div id="dl-title" class="title">${state.translate(
|
<div id="dl-title" class="title">
|
||||||
'downloadFinish'
|
${state.translate('downloadFinish')}
|
||||||
)}</div>
|
</div>
|
||||||
<div class="description"></div>
|
<div class="description"></div>
|
||||||
${progress(1)}
|
${progress(1)}
|
||||||
<div class="upload">
|
<div class="upload">
|
||||||
<div class="progress-text"></div>
|
<div class="progress-text"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="send-new"
|
||||||
|
data-state="completed"
|
||||||
|
href="/"
|
||||||
|
onclick=${sendNew}>${state.translate('sendYourFilesLink')}</a>
|
||||||
</div>
|
</div>
|
||||||
<a class="send-new" data-state="completed" href="/" onclick=${
|
|
||||||
sendNew
|
|
||||||
}>${state.translate('sendYourFilesLink')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ const html = require('choo/html');
|
|||||||
const progress = require('./progress');
|
const progress = require('./progress');
|
||||||
const { bytes } = require('../utils');
|
const { bytes } = require('../utils');
|
||||||
|
|
||||||
module.exports = function(state) {
|
module.exports = function(state, emit) {
|
||||||
const transfer = state.transfer;
|
const transfer = state.transfer;
|
||||||
const div = html`
|
const div = html`
|
||||||
<div id="page-one">
|
<div id="page-one">
|
||||||
@ -24,11 +24,20 @@ module.exports = function(state) {
|
|||||||
transfer.msg,
|
transfer.msg,
|
||||||
transfer.sizes
|
transfer.sizes
|
||||||
)}</div>
|
)}</div>
|
||||||
|
<button
|
||||||
|
id="cancel-upload"
|
||||||
|
title="${state.translate('deletePopupCancel')}"
|
||||||
|
onclick=${cancel}>${state.translate('deletePopupCancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
const btn = document.getElementById('cancel-upload');
|
||||||
|
btn.remove();
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
16
app/templates/downloadButton.js
Normal file
16
app/templates/downloadButton.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const html = require('choo/html');
|
||||||
|
|
||||||
|
module.exports = function(state, emit) {
|
||||||
|
function download(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
emit('download', state.fileInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<button id="download-btn"
|
||||||
|
class="btn"
|
||||||
|
onclick=${download}>${state.translate('downloadButtonLabel')}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
};
|
@ -5,8 +5,9 @@ module.exports = function(state, emit) {
|
|||||||
const label =
|
const label =
|
||||||
fileInfo.password === null
|
fileInfo.password === null
|
||||||
? html`
|
? html`
|
||||||
<label class="red"
|
<label class="red" for="unlock-input">
|
||||||
for="unlock-input">${state.translate('passwordTryAgain')}</label>`
|
${state.translate('passwordTryAgain')}
|
||||||
|
</label>`
|
||||||
: html`
|
: html`
|
||||||
<label for="unlock-input">
|
<label for="unlock-input">
|
||||||
${state.translate('unlockInputLabel')}
|
${state.translate('unlockInputLabel')}
|
||||||
@ -48,7 +49,7 @@ module.exports = function(state, emit) {
|
|||||||
document.getElementById('unlock-btn').disabled = true;
|
document.getElementById('unlock-btn').disabled = true;
|
||||||
state.fileInfo.url = window.location.href;
|
state.fileInfo.url = window.location.href;
|
||||||
state.fileInfo.password = password;
|
state.fileInfo.password = password;
|
||||||
emit('preview');
|
emit('getMetadata');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,52 +1,62 @@
|
|||||||
const html = require('choo/html');
|
const html = require('choo/html');
|
||||||
const assets = require('../../common/assets');
|
const assets = require('../../common/assets');
|
||||||
|
|
||||||
function timeLeft(milliseconds) {
|
function timeLeft(milliseconds, state) {
|
||||||
const minutes = Math.floor(milliseconds / 1000 / 60);
|
const minutes = Math.floor(milliseconds / 1000 / 60);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const seconds = Math.floor((milliseconds / 1000) % 60);
|
|
||||||
if (hours >= 1) {
|
if (hours >= 1) {
|
||||||
return `${hours}h ${minutes % 60}m`;
|
return state.translate('expiresHoursMinutes', {
|
||||||
|
hours,
|
||||||
|
minutes: minutes % 60
|
||||||
|
});
|
||||||
} else if (hours === 0) {
|
} else if (hours === 0) {
|
||||||
return `${minutes}m ${seconds}s`;
|
if (minutes === 0) {
|
||||||
|
return state.translate('expiresMinutes', { minutes: '< 1' });
|
||||||
|
}
|
||||||
|
return state.translate('expiresMinutes', { minutes });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function(file, state, emit) {
|
module.exports = function(file, state, emit) {
|
||||||
const ttl = file.expiresAt - Date.now();
|
const ttl = file.expiresAt - Date.now();
|
||||||
const remainingTime = timeLeft(ttl) || state.translate('linkExpiredAlt');
|
const remainingTime =
|
||||||
|
timeLeft(ttl, state) || state.translate('linkExpiredAlt');
|
||||||
const downloadLimit = file.dlimit || 1;
|
const downloadLimit = file.dlimit || 1;
|
||||||
const totalDownloads = file.dtotal || 0;
|
const totalDownloads = file.dtotal || 0;
|
||||||
const row = html`
|
const row = html`
|
||||||
<tr id="${file.id}">
|
<tr id="${file.id}">
|
||||||
<td class="overflow-col" title="${
|
<td class="overflow-col" title="${file.name}">
|
||||||
file.name
|
<a class="link" href="/share/${file.id}">${file.name}</a>
|
||||||
}"><a class="link" href="/share/${file.id}">${file.name}</a></td>
|
</td>
|
||||||
<td class="center-col">
|
<td class="center-col">
|
||||||
<img onclick=${copyClick} src="${assets.get(
|
<img
|
||||||
'copy-16.svg'
|
onclick=${copyClick}
|
||||||
)}" class="icon-copy" title="${state.translate('copyUrlHover')}">
|
src="${assets.get('copy-16.svg')}"
|
||||||
<span class="text-copied" hidden="true">${state.translate(
|
class="icon-copy"
|
||||||
'copiedUrl'
|
title="${state.translate('copyUrlHover')}">
|
||||||
)}</span>
|
<span class="text-copied" hidden="true">
|
||||||
|
${state.translate('copiedUrl')}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>${remainingTime}</td>
|
<td>${remainingTime}</td>
|
||||||
<td class="center-col">${totalDownloads}/${downloadLimit}</td>
|
<td class="center-col">${totalDownloads} / ${downloadLimit}</td>
|
||||||
<td class="center-col">
|
<td class="center-col">
|
||||||
<img onclick=${showPopup} src="${assets.get(
|
<img
|
||||||
'close-16.svg'
|
onclick=${showPopup}
|
||||||
)}" class="icon-delete" title="${state.translate('deleteButtonHover')}">
|
src="${assets.get('close-16.svg')}"
|
||||||
|
class="icon-delete"
|
||||||
|
title="${state.translate('deleteButtonHover')}">
|
||||||
<div class="popup">
|
<div class="popup">
|
||||||
<div class="popuptext" onblur=${cancel} tabindex="-1">
|
<div class="popuptext" onblur=${cancel} tabindex="-1">
|
||||||
<div class="popup-message">${state.translate('deletePopupText')}</div>
|
<div class="popup-message">${state.translate('deletePopupText')}</div>
|
||||||
<div class="popup-action">
|
<div class="popup-action">
|
||||||
<span class="popup-no" onclick=${cancel}>${state.translate(
|
<span class="popup-no" onclick=${cancel}>
|
||||||
'deletePopupCancel'
|
${state.translate('deletePopupCancel')}
|
||||||
)}</span>
|
</span>
|
||||||
<span class="popup-yes" onclick=${deleteFile}>${state.translate(
|
<span class="popup-yes" onclick=${deleteFile}>
|
||||||
'deletePopupYes'
|
${state.translate('deletePopupYes')}
|
||||||
)}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,18 +9,18 @@ module.exports = function(state, emit) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th id="uploaded-file">${state.translate('uploadedFile')}</th>
|
<th id="uploaded-file">${state.translate('uploadedFile')}</th>
|
||||||
<th id="copy-file-list" class="center-col">${state.translate(
|
<th id="copy-file-list" class="center-col">
|
||||||
'copyFileList'
|
${state.translate('copyFileList')}
|
||||||
)}</th>
|
</th>
|
||||||
<th id="expiry-time-file-list" >${state.translate(
|
<th id="expiry-time-file-list" >
|
||||||
'timeFileList'
|
${state.translate('timeFileList')}
|
||||||
)}</th>
|
</th>
|
||||||
<th id="expiry-downloads-file-list" >${state.translate(
|
<th id="expiry-downloads-file-list" >
|
||||||
'downloadsFileList'
|
${state.translate('downloadsFileList')}
|
||||||
)}</th>
|
</th>
|
||||||
<th id="delete-file-list" class="center-col">${state.translate(
|
<th id="delete-file-list" class="center-col">
|
||||||
'deleteFileList'
|
${state.translate('deleteFileList')}
|
||||||
)}</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -4,31 +4,40 @@ const assets = require('../../common/assets');
|
|||||||
module.exports = function(state) {
|
module.exports = function(state) {
|
||||||
return html`<div class="footer">
|
return html`<div class="footer">
|
||||||
<div class="legal-links">
|
<div class="legal-links">
|
||||||
<a href="https://www.mozilla.org" role="presentation"><img class="mozilla-logo" src="${assets.get(
|
<a href="https://www.mozilla.org" role="presentation">
|
||||||
'mozilla-logo.svg'
|
<img
|
||||||
)}" alt="mozilla"/></a>
|
class="mozilla-logo"
|
||||||
<a href="https://www.mozilla.org/about/legal">${state.translate(
|
src="${assets.get('mozilla-logo.svg')}"
|
||||||
'footerLinkLegal'
|
alt="mozilla"/>
|
||||||
)}</a>
|
</a>
|
||||||
<a href="https://testpilot.firefox.com/about">${state.translate(
|
<a href="https://www.mozilla.org/about/legal">
|
||||||
'footerLinkAbout'
|
${state.translate('footerLinkLegal')}
|
||||||
)}</a>
|
</a>
|
||||||
|
<a href="https://testpilot.firefox.com/about">
|
||||||
|
${state.translate('footerLinkAbout')}
|
||||||
|
</a>
|
||||||
<a href="/legal">${state.translate('footerLinkPrivacy')}</a>
|
<a href="/legal">${state.translate('footerLinkPrivacy')}</a>
|
||||||
<a href="/legal">${state.translate('footerLinkTerms')}</a>
|
<a href="/legal">${state.translate('footerLinkTerms')}</a>
|
||||||
<a href="https://www.mozilla.org/privacy/websites/#cookies">${state.translate(
|
<a href="https://www.mozilla.org/privacy/websites/#cookies">
|
||||||
'footerLinkCookies'
|
${state.translate('footerLinkCookies')}
|
||||||
)}</a>
|
</a>
|
||||||
<a href="https://www.mozilla.org/about/legal/report-infringement/">${state.translate(
|
<a href="https://www.mozilla.org/about/legal/report-infringement/">
|
||||||
'reportIPInfringement'
|
${state.translate('reportIPInfringement')}
|
||||||
)}</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="social-links">
|
<div class="social-links">
|
||||||
<a href="https://github.com/mozilla/send" role="presentation"><img class="github" src="${assets.get(
|
<a href="https://github.com/mozilla/send" role="presentation">
|
||||||
'github-icon.svg'
|
<img
|
||||||
)}" alt="github"/></a>
|
class="github"
|
||||||
<a href="https://twitter.com/FxTestPilot" role="presentation"><img class="twitter" src="${assets.get(
|
src="${assets.get('github-icon.svg')}"
|
||||||
'twitter-icon.svg'
|
alt="github"/>
|
||||||
)}" alt="twitter"/></a>
|
</a>
|
||||||
|
<a href="https://twitter.com/FxTestPilot" role="presentation">
|
||||||
|
<img
|
||||||
|
class="twitter"
|
||||||
|
src="${assets.get('twitter-icon.svg')}"
|
||||||
|
alt="twitter"/>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
@ -41,9 +41,10 @@ module.exports = function(state) {
|
|||||||
return html`<header class="header">
|
return html`<header class="header">
|
||||||
<div class="send-logo">
|
<div class="send-logo">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<img src="${assets.get(
|
<img
|
||||||
'send_logo.svg'
|
src="${assets.get('send_logo.svg')}"
|
||||||
)}" alt="Send"/><h1 class="site-title">Send</h1>
|
alt="Send"/>
|
||||||
|
<h1 class="site-title">Send</h1>
|
||||||
</a>
|
</a>
|
||||||
<div class="site-subtitle">
|
<div class="site-subtitle">
|
||||||
<a href="https://testpilot.firefox.com">Firefox Test Pilot</a>
|
<a href="https://testpilot.firefox.com">Firefox Test Pilot</a>
|
||||||
|
@ -9,12 +9,12 @@ module.exports = function(state) {
|
|||||||
<div class="share-window">
|
<div class="share-window">
|
||||||
<img src="${assets.get('illustration_expired.svg')}" id="expired-img">
|
<img src="${assets.get('illustration_expired.svg')}" id="expired-img">
|
||||||
</div>
|
</div>
|
||||||
<div class="expired-description">${state.translate(
|
<div class="expired-description">
|
||||||
'uploadPageExplainer'
|
${state.translate('uploadPageExplainer')}
|
||||||
)}</div>
|
</div>
|
||||||
<a class="send-new" href="/" data-state="notfound">${state.translate(
|
<a class="send-new" href="/" data-state="notfound">
|
||||||
'sendYourFilesLink'
|
${state.translate('sendYourFilesLink')}
|
||||||
)}</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return div;
|
return div;
|
||||||
|
@ -1,48 +1,13 @@
|
|||||||
const html = require('choo/html');
|
const html = require('choo/html');
|
||||||
const assets = require('../../common/assets');
|
const assets = require('../../common/assets');
|
||||||
const notFound = require('./notFound');
|
|
||||||
const downloadPassword = require('./downloadPassword');
|
|
||||||
const { bytes } = require('../utils');
|
const { bytes } = require('../utils');
|
||||||
|
|
||||||
function getFileFromDOM() {
|
module.exports = function(state, pageAction) {
|
||||||
const el = document.getElementById('dl-file');
|
|
||||||
if (!el) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
nonce: el.getAttribute('data-nonce'),
|
|
||||||
pwd: !!+el.getAttribute('data-requires-password')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = function(state, emit) {
|
|
||||||
state.fileInfo = state.fileInfo || getFileFromDOM();
|
|
||||||
if (!state.fileInfo) {
|
|
||||||
return notFound(state, emit);
|
|
||||||
}
|
|
||||||
state.fileInfo.id = state.params.id;
|
|
||||||
state.fileInfo.secretKey = state.params.key;
|
|
||||||
const fileInfo = state.fileInfo;
|
const fileInfo = state.fileInfo;
|
||||||
const size = fileInfo.size
|
const size = fileInfo.size
|
||||||
? state.translate('downloadFileSize', { size: bytes(fileInfo.size) })
|
? state.translate('downloadFileSize', { size: bytes(fileInfo.size) })
|
||||||
: '';
|
: '';
|
||||||
let action = html`
|
|
||||||
<div>
|
|
||||||
<img src="${assets.get('illustration_download.svg')}"
|
|
||||||
id="download-img"
|
|
||||||
alt="${state.translate('downloadAltText')}"/>
|
|
||||||
<div>
|
|
||||||
<button id="download-btn"
|
|
||||||
class="btn"
|
|
||||||
onclick=${download}>${state.translate('downloadButtonLabel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
if (fileInfo.pwd && !fileInfo.password) {
|
|
||||||
action = downloadPassword(state, emit);
|
|
||||||
} else if (!state.transfer) {
|
|
||||||
emit('preview');
|
|
||||||
}
|
|
||||||
const title = fileInfo.name
|
const title = fileInfo.name
|
||||||
? state.translate('downloadFileName', { filename: fileInfo.name })
|
? state.translate('downloadFileName', { filename: fileInfo.name })
|
||||||
: state.translate('downloadFileTitle');
|
: state.translate('downloadFileTitle');
|
||||||
@ -53,20 +18,20 @@ module.exports = function(state, emit) {
|
|||||||
<div class="title">
|
<div class="title">
|
||||||
<span id="dl-file"
|
<span id="dl-file"
|
||||||
data-nonce="${fileInfo.nonce}"
|
data-nonce="${fileInfo.nonce}"
|
||||||
data-requires-password="${fileInfo.pwd}">${title}</span>
|
data-requires-password="${fileInfo.requiresPassword}"
|
||||||
|
>${title}</span>
|
||||||
<span id="dl-filesize">${' ' + size}</span>
|
<span id="dl-filesize">${' ' + size}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">${state.translate('downloadMessage')}</div>
|
<div class="description">${state.translate('downloadMessage')}</div>
|
||||||
${action}
|
<img
|
||||||
|
src="${assets.get('illustration_download.svg')}"
|
||||||
|
id="download-img"
|
||||||
|
alt="${state.translate('downloadAltText')}"/>
|
||||||
|
${pageAction}
|
||||||
</div>
|
</div>
|
||||||
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
|
<a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function download(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
emit('download', fileInfo);
|
|
||||||
}
|
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
@ -10,18 +10,30 @@ module.exports = function(progressRatio) {
|
|||||||
const percent = Math.floor(progressRatio * 100);
|
const percent = Math.floor(progressRatio * 100);
|
||||||
const div = html`
|
const div = html`
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<svg id="progress" width="${oDiameter}" height="${
|
<svg
|
||||||
oDiameter
|
id="progress"
|
||||||
}" viewPort="0 0 ${oDiameter} ${oDiameter}" version="1.1">
|
width="${oDiameter}"
|
||||||
<circle r="${radius}" cx="${oRadius}" cy="${oRadius}" fill="transparent"/>
|
height="${oDiameter}"
|
||||||
<circle id="bar" r="${radius}" cx="${oRadius}" cy="${
|
viewPort="0 0 ${oDiameter} ${oDiameter}"
|
||||||
oRadius
|
version="1.1">
|
||||||
}" fill="transparent" transform="rotate(-90 ${oRadius} ${
|
<circle
|
||||||
oRadius
|
r="${radius}"
|
||||||
})" stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}"/>
|
cx="${oRadius}"
|
||||||
<text class="percentage" text-anchor="middle" x="50%" y="98"><tspan class="percent-number">${
|
cy="${oRadius}"
|
||||||
percent
|
fill="transparent"/>
|
||||||
}</tspan><tspan class="percent-sign">%</tspan></text>
|
<circle
|
||||||
|
id="bar"
|
||||||
|
r="${radius}"
|
||||||
|
cx="${oRadius}"
|
||||||
|
cy="${oRadius}"
|
||||||
|
fill="transparent"
|
||||||
|
transform="rotate(-90 ${oRadius} ${oRadius})"
|
||||||
|
stroke-dasharray="${circumference}"
|
||||||
|
stroke-dashoffset="${dashOffset}"/>
|
||||||
|
<text class="percentage" text-anchor="middle" x="50%" y="98">
|
||||||
|
<tspan class="percent-number">${percent}</tspan>
|
||||||
|
<tspan class="percent-sign">%</tspan>
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -47,9 +47,7 @@ module.exports = function(selected, options, translate, changed) {
|
|||||||
<ul id="${id}" class="selectOptions">
|
<ul id="${id}" class="selectOptions">
|
||||||
${options.map(
|
${options.map(
|
||||||
i =>
|
i =>
|
||||||
html`<li class="selectOption" onclick=${choose} data-value="${i}">${
|
html`<li class="selectOption" onclick=${choose} data-value="${i}">${i}</li>`
|
||||||
i
|
|
||||||
}</li>`
|
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
@ -2,34 +2,11 @@
|
|||||||
const html = require('choo/html');
|
const html = require('choo/html');
|
||||||
const assets = require('../../common/assets');
|
const assets = require('../../common/assets');
|
||||||
const notFound = require('./notFound');
|
const notFound = require('./notFound');
|
||||||
const uploadPassword = require('./uploadPassword');
|
const uploadPasswordSet = require('./uploadPasswordSet');
|
||||||
|
const uploadPasswordUnset = require('./uploadPasswordUnset');
|
||||||
const selectbox = require('./selectbox');
|
const selectbox = require('./selectbox');
|
||||||
const { allowedCopy, delay, fadeOut } = require('../utils');
|
const { allowedCopy, delay, fadeOut } = require('../utils');
|
||||||
|
|
||||||
function inputChanged() {
|
|
||||||
const resetInput = document.getElementById('unlock-reset-input');
|
|
||||||
const resetBtn = document.getElementById('unlock-reset-btn');
|
|
||||||
if (resetInput.value.length > 0) {
|
|
||||||
resetBtn.classList.remove('btn-hidden');
|
|
||||||
resetInput.classList.remove('input-no-btn');
|
|
||||||
} else {
|
|
||||||
resetBtn.classList.add('btn-hidden');
|
|
||||||
resetInput.classList.add('input-no-btn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleResetInput(event) {
|
|
||||||
const form = event.target.parentElement.querySelector('form');
|
|
||||||
const input = document.getElementById('unlock-reset-input');
|
|
||||||
if (form.style.visibility === 'hidden' || form.style.visibility === '') {
|
|
||||||
form.style.visibility = 'visible';
|
|
||||||
input.focus();
|
|
||||||
} else {
|
|
||||||
form.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
inputChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
function expireInfo(file, translate, emit) {
|
function expireInfo(file, translate, emit) {
|
||||||
const hours = Math.floor(EXPIRE_SECONDS / 60 / 60);
|
const hours = Math.floor(EXPIRE_SECONDS / 60 / 60);
|
||||||
const el = html([
|
const el = html([
|
||||||
@ -55,19 +32,16 @@ module.exports = function(state, emit) {
|
|||||||
return notFound(state, emit);
|
return notFound(state, emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
file.password = file.password || '';
|
const passwordSection = file.hasPassword()
|
||||||
|
? uploadPasswordSet(state, emit)
|
||||||
const passwordSection = file.password
|
: uploadPasswordUnset(state, emit);
|
||||||
? passwordComplete(file.password)
|
|
||||||
: uploadPassword(state, emit);
|
|
||||||
const div = html`
|
const div = html`
|
||||||
<div id="share-link" class="fadeIn">
|
<div id="share-link" class="fadeIn">
|
||||||
<div class="title">${expireInfo(file, state.translate, emit)}</div>
|
<div class="title">${expireInfo(file, state.translate, emit)}</div>
|
||||||
<div id="share-window">
|
<div id="share-window">
|
||||||
<div id="copy-text">
|
<div id="copy-text">
|
||||||
${state.translate('copyUrlFormLabelWithName', {
|
${state.translate('copyUrlFormLabelWithName', { filename: file.name })}
|
||||||
filename: file.name
|
</div>
|
||||||
})}</div>
|
|
||||||
<div id="copy">
|
<div id="copy">
|
||||||
<input id="link" type="url" value="${file.url}" readonly="true"/>
|
<input id="link" type="url" value="${file.url}" readonly="true"/>
|
||||||
<button id="copy-btn"
|
<button id="copy-btn"
|
||||||
@ -86,13 +60,11 @@ module.exports = function(state, emit) {
|
|||||||
<div class="popup-message">${state.translate('deletePopupText')}
|
<div class="popup-message">${state.translate('deletePopupText')}
|
||||||
</div>
|
</div>
|
||||||
<div class="popup-action">
|
<div class="popup-action">
|
||||||
<span class="popup-no" onclick=${cancel}>${state.translate(
|
<span class="popup-no" onclick=${cancel}>
|
||||||
'deletePopupCancel'
|
${state.translate('deletePopupCancel')}
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="popup-yes" onclick=${deleteFile}>${state.translate(
|
<span class="popup-yes" onclick=${deleteFile}>
|
||||||
'deletePopupYes'
|
${state.translate('deletePopupYes')}
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -105,54 +77,6 @@ module.exports = function(state, emit) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function passwordComplete(password) {
|
|
||||||
const passwordSpan = html([
|
|
||||||
`<span>${state.translate('passwordResult', {
|
|
||||||
password:
|
|
||||||
'<pre class="passwordOriginal"></pre><pre class="passwordMask"></pre>'
|
|
||||||
})}</span>`
|
|
||||||
]);
|
|
||||||
const og = passwordSpan.querySelector('.passwordOriginal');
|
|
||||||
const masked = passwordSpan.querySelector('.passwordMask');
|
|
||||||
og.textContent = password;
|
|
||||||
masked.textContent = password.replace(/./g, '●');
|
|
||||||
return html`<div class="selectPassword">
|
|
||||||
${passwordSpan}
|
|
||||||
<button
|
|
||||||
id="resetButton"
|
|
||||||
onclick=${toggleResetInput}
|
|
||||||
>${state.translate('changePasswordButton')}</button>
|
|
||||||
<form
|
|
||||||
id='reset-form'
|
|
||||||
class="setPassword hidden"
|
|
||||||
onsubmit=${resetPassword}
|
|
||||||
data-no-csrf>
|
|
||||||
<input id="unlock-reset-input"
|
|
||||||
class="unlock-input input-no-btn"
|
|
||||||
maxlength="32"
|
|
||||||
autocomplete="off"
|
|
||||||
type="password"
|
|
||||||
oninput=${inputChanged}
|
|
||||||
placeholder="${state.translate('unlockInputPlaceholder')}">
|
|
||||||
<input type="submit"
|
|
||||||
id="unlock-reset-btn"
|
|
||||||
class="btn btn-hidden"
|
|
||||||
value="${state.translate('changePasswordButton')}"/>
|
|
||||||
</form>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetPassword(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const existingPassword = file.password;
|
|
||||||
const password = document.querySelector('#unlock-reset-input').value;
|
|
||||||
if (password.length > 0) {
|
|
||||||
document.getElementById('copy').classList.remove('wait-password');
|
|
||||||
document.getElementById('copy-btn').disabled = false;
|
|
||||||
emit('password', { existingPassword, password, file });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPopup() {
|
function showPopup() {
|
||||||
const popupText = document.querySelector('.popuptext');
|
const popupText = document.querySelector('.popuptext');
|
||||||
popupText.classList.add('show');
|
popupText.classList.add('show');
|
||||||
|
@ -7,39 +7,45 @@ module.exports = function(state) {
|
|||||||
? html`
|
? html`
|
||||||
<div id="unsupported-browser">
|
<div id="unsupported-browser">
|
||||||
<div class="title">${state.translate('notSupportedHeader')}</div>
|
<div class="title">${state.translate('notSupportedHeader')}</div>
|
||||||
<div class="description">${state.translate(
|
<div class="description">
|
||||||
'notSupportedOutdatedDetail'
|
${state.translate('notSupportedOutdatedDetail')}
|
||||||
)}</div>
|
</div>
|
||||||
<a id="update-firefox" href="https://support.mozilla.org/kb/update-firefox-latest-version">
|
<a
|
||||||
<img src="${assets.get(
|
id="update-firefox"
|
||||||
'firefox_logo-only.svg'
|
href="https://support.mozilla.org/kb/update-firefox-latest-version">
|
||||||
)}" class="firefox-logo" alt="Firefox"/>
|
<img
|
||||||
<div class="unsupported-button-text">${state.translate(
|
src="${assets.get('firefox_logo-only.svg')}"
|
||||||
'updateFirefox'
|
class="firefox-logo"
|
||||||
)}</div>
|
alt="Firefox"/>
|
||||||
|
<div class="unsupported-button-text">
|
||||||
|
${state.translate('updateFirefox')}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="unsupported-description">${state.translate(
|
<div class="unsupported-description">
|
||||||
'uploadPageExplainer'
|
${state.translate('uploadPageExplainer')}
|
||||||
)}</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
: html`
|
: html`
|
||||||
<div id="unsupported-browser">
|
<div id="unsupported-browser">
|
||||||
<div class="title">${state.translate('notSupportedHeader')}</div>
|
<div class="title">${state.translate('notSupportedHeader')}</div>
|
||||||
<div class="description">${state.translate('notSupportedDetail')}</div>
|
<div class="description">${state.translate('notSupportedDetail')}</div>
|
||||||
<div class="description"><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">${state.translate(
|
<div class="description">
|
||||||
'notSupportedLink'
|
<a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">
|
||||||
)}</a></div>
|
${state.translate('notSupportedLink')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<a id="dl-firefox" href="https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com">
|
<a id="dl-firefox" href="https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com">
|
||||||
<img src="${assets.get(
|
<img
|
||||||
'firefox_logo-only.svg'
|
src="${assets.get('firefox_logo-only.svg')}"
|
||||||
)}" class="firefox-logo" alt="Firefox"/>
|
class="firefox-logo"
|
||||||
|
alt="Firefox"/>
|
||||||
<div class="unsupported-button-text">Firefox<br>
|
<div class="unsupported-button-text">Firefox<br>
|
||||||
<span>${state.translate('downloadFirefoxButtonSub')}</span>
|
<span>${state.translate('downloadFirefoxButtonSub')}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="unsupported-description">${state.translate(
|
<div class="unsupported-description">
|
||||||
'uploadPageExplainer'
|
${state.translate('uploadPageExplainer')}
|
||||||
)}</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
const div = html`<div id="page-one">${msg}</div>`;
|
const div = html`<div id="page-one">${msg}</div>`;
|
||||||
return div;
|
return div;
|
||||||
|
@ -8,23 +8,24 @@ module.exports = function(state, emit) {
|
|||||||
const div = html`
|
const div = html`
|
||||||
<div id="download">
|
<div id="download">
|
||||||
<div id="upload-progress" class="fadeIn">
|
<div id="upload-progress" class="fadeIn">
|
||||||
<div class="title" id="upload-filename">${state.translate(
|
<div class="title" id="upload-filename">
|
||||||
'uploadingPageProgress',
|
${state.translate('uploadingPageProgress', {
|
||||||
{
|
|
||||||
filename: transfer.file.name,
|
filename: transfer.file.name,
|
||||||
size: bytes(transfer.file.size)
|
size: bytes(transfer.file.size)
|
||||||
}
|
})}
|
||||||
)}</div>
|
</div>
|
||||||
<div class="description"></div>
|
<div class="description"></div>
|
||||||
${progress(transfer.progressRatio)}
|
${progress(transfer.progressRatio)}
|
||||||
<div class="upload">
|
<div class="upload">
|
||||||
<div class="progress-text">${state.translate(
|
<div class="progress-text">
|
||||||
transfer.msg,
|
${state.translate(transfer.msg, transfer.sizes)}
|
||||||
transfer.sizes
|
</div>
|
||||||
)}</div>
|
<button
|
||||||
<button id="cancel-upload" title="${state.translate(
|
id="cancel-upload"
|
||||||
'uploadingPageCancel'
|
title="${state.translate('uploadingPageCancel')}"
|
||||||
)}" onclick=${cancel}>${state.translate('uploadingPageCancel')}</button>
|
onclick=${cancel}>
|
||||||
|
${state.translate('uploadingPageCancel')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
79
app/templates/uploadPasswordSet.js
Normal file
79
app/templates/uploadPasswordSet.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
const html = require('choo/html');
|
||||||
|
|
||||||
|
module.exports = function(state, emit) {
|
||||||
|
const file = state.storage.getFileById(state.params.id);
|
||||||
|
|
||||||
|
return html`<div class="selectPassword">
|
||||||
|
${passwordSpan(file.password)}
|
||||||
|
<button
|
||||||
|
id="resetButton"
|
||||||
|
onclick=${toggleResetInput}
|
||||||
|
>${state.translate('changePasswordButton')}</button>
|
||||||
|
<form
|
||||||
|
id='reset-form'
|
||||||
|
class="setPassword hidden"
|
||||||
|
onsubmit=${resetPassword}
|
||||||
|
data-no-csrf>
|
||||||
|
<input id="unlock-reset-input"
|
||||||
|
class="unlock-input input-no-btn"
|
||||||
|
maxlength="32"
|
||||||
|
autocomplete="off"
|
||||||
|
type="password"
|
||||||
|
oninput=${inputChanged}
|
||||||
|
placeholder="${state.translate('unlockInputPlaceholder')}">
|
||||||
|
<input type="submit"
|
||||||
|
id="unlock-reset-btn"
|
||||||
|
class="btn btn-hidden"
|
||||||
|
value="${state.translate('changePasswordButton')}"/>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function passwordSpan(password) {
|
||||||
|
password = password || '●●●●●';
|
||||||
|
const span = html([
|
||||||
|
`<span>${state.translate('passwordResult', {
|
||||||
|
password:
|
||||||
|
'<pre class="passwordOriginal"></pre><pre class="passwordMask"></pre>'
|
||||||
|
})}</span>`
|
||||||
|
]);
|
||||||
|
const og = span.querySelector('.passwordOriginal');
|
||||||
|
const masked = span.querySelector('.passwordMask');
|
||||||
|
og.textContent = password;
|
||||||
|
masked.textContent = password.replace(/./g, '●');
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputChanged() {
|
||||||
|
const resetInput = document.getElementById('unlock-reset-input');
|
||||||
|
const resetBtn = document.getElementById('unlock-reset-btn');
|
||||||
|
if (resetInput.value.length > 0) {
|
||||||
|
resetBtn.classList.remove('btn-hidden');
|
||||||
|
resetInput.classList.remove('input-no-btn');
|
||||||
|
} else {
|
||||||
|
resetBtn.classList.add('btn-hidden');
|
||||||
|
resetInput.classList.add('input-no-btn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPassword(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const password = document.querySelector('#unlock-reset-input').value;
|
||||||
|
if (password.length > 0) {
|
||||||
|
document.getElementById('copy').classList.remove('wait-password');
|
||||||
|
document.getElementById('copy-btn').disabled = false;
|
||||||
|
emit('password', { password, file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleResetInput(event) {
|
||||||
|
const form = event.target.parentElement.querySelector('form');
|
||||||
|
const input = document.getElementById('unlock-reset-input');
|
||||||
|
if (form.style.visibility === 'hidden' || form.style.visibility === '') {
|
||||||
|
form.style.visibility = 'visible';
|
||||||
|
input.focus();
|
||||||
|
} else {
|
||||||
|
form.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
inputChanged();
|
||||||
|
}
|
||||||
|
};
|
@ -5,9 +5,14 @@ module.exports = function(state, emit) {
|
|||||||
const div = html`
|
const div = html`
|
||||||
<div class="selectPassword">
|
<div class="selectPassword">
|
||||||
<div id="addPasswordWrapper">
|
<div id="addPasswordWrapper">
|
||||||
<input id="addPassword" type="checkbox" autocomplete="off" onchange=${togglePasswordInput}/>
|
<input
|
||||||
|
id="addPassword"
|
||||||
|
type="checkbox"
|
||||||
|
autocomplete="off"
|
||||||
|
onchange=${togglePasswordInput}/>
|
||||||
<label for="addPassword">
|
<label for="addPassword">
|
||||||
${state.translate('requirePasswordCheckbox')}</label>
|
${state.translate('requirePasswordCheckbox')}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<form class="setPassword hidden" onsubmit=${setPassword} data-no-csrf>
|
<form class="setPassword hidden" onsubmit=${setPassword} data-no-csrf>
|
||||||
<input id="unlock-input"
|
<input id="unlock-input"
|
||||||
@ -52,12 +57,11 @@ module.exports = function(state, emit) {
|
|||||||
|
|
||||||
function setPassword(event) {
|
function setPassword(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const existingPassword = null;
|
|
||||||
const password = document.getElementById('unlock-input').value;
|
const password = document.getElementById('unlock-input').value;
|
||||||
if (password.length > 0) {
|
if (password.length > 0) {
|
||||||
document.getElementById('copy').classList.remove('wait-password');
|
document.getElementById('copy').classList.remove('wait-password');
|
||||||
document.getElementById('copy-btn').disabled = false;
|
document.getElementById('copy-btn').disabled = false;
|
||||||
emit('password', { existingPassword, password, file });
|
emit('password', { password, file });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,14 +10,18 @@ module.exports = function(state, emit) {
|
|||||||
<div class="title">${state.translate('uploadPageHeader')}</div>
|
<div class="title">${state.translate('uploadPageHeader')}</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<div>${state.translate('uploadPageExplainer')}</div>
|
<div>${state.translate('uploadPageExplainer')}</div>
|
||||||
<a href="https://testpilot.firefox.com/experiments/send"
|
<a
|
||||||
class="link">${state.translate('uploadPageLearnMore')}</a>
|
href="https://testpilot.firefox.com/experiments/send"
|
||||||
|
class="link">
|
||||||
|
${state.translate('uploadPageLearnMore')}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-window"
|
<div class="upload-window"
|
||||||
ondragover=${dragover}
|
ondragover=${dragover}
|
||||||
ondragleave=${dragleave}>
|
ondragleave=${dragleave}>
|
||||||
<div id="upload-img">
|
<div id="upload-img">
|
||||||
<img src="${assets.get('upload.svg')}"
|
<img
|
||||||
|
src="${assets.get('upload.svg')}"
|
||||||
title="${state.translate('uploadSvgAlt')}"/>
|
title="${state.translate('uploadSvgAlt')}"/>
|
||||||
</div>
|
</div>
|
||||||
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
|
<div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
|
||||||
@ -34,7 +38,8 @@ module.exports = function(state, emit) {
|
|||||||
id="browse"
|
id="browse"
|
||||||
class="btn browse"
|
class="btn browse"
|
||||||
title="${state.translate('uploadPageBrowseButton1')}">
|
title="${state.translate('uploadPageBrowseButton1')}">
|
||||||
${state.translate('uploadPageBrowseButton1')}</label>
|
${state.translate('uploadPageBrowseButton1')}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
${fileList(state, emit)}
|
${fileList(state, emit)}
|
||||||
</div>
|
</div>
|
||||||
|
51
app/utils.js
51
app/utils.js
@ -15,21 +15,6 @@ function b64ToArray(str) {
|
|||||||
return b64.toByteArray(str);
|
return b64.toByteArray(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify(str) {
|
|
||||||
return str;
|
|
||||||
/* TODO: enable once we have an opt-in ui element
|
|
||||||
if (!('Notification' in window)) {
|
|
||||||
return;
|
|
||||||
} else if (Notification.permission === 'granted') {
|
|
||||||
new Notification(str);
|
|
||||||
} else if (Notification.permission !== 'denied') {
|
|
||||||
Notification.requestPermission(function(permission) {
|
|
||||||
if (permission === 'granted') new Notification(str);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadShim(polyfill) {
|
function loadShim(polyfill) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const shim = document.createElement('script');
|
const shim = document.createElement('script');
|
||||||
@ -148,7 +133,37 @@ function fadeOut(id) {
|
|||||||
return delay(300);
|
return delay(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ONE_DAY_IN_MS = 86400000;
|
function saveFile(file) {
|
||||||
|
const dataView = new DataView(file.plaintext);
|
||||||
|
const blob = new Blob([dataView], { type: file.type });
|
||||||
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
if (window.navigator.msSaveBlob) {
|
||||||
|
return window.navigator.msSaveBlob(blob, file.name);
|
||||||
|
}
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = file.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLinksInNewTab(links, should = true) {
|
||||||
|
links = links || Array.from(document.querySelectorAll('a:not([target])'));
|
||||||
|
if (should) {
|
||||||
|
links.forEach(l => {
|
||||||
|
l.setAttribute('target', '_blank');
|
||||||
|
l.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
links.forEach(l => {
|
||||||
|
l.removeAttribute('target');
|
||||||
|
l.removeAttribute('rel');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fadeOut,
|
fadeOut,
|
||||||
@ -159,8 +174,8 @@ module.exports = {
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
arrayToB64,
|
arrayToB64,
|
||||||
b64ToArray,
|
b64ToArray,
|
||||||
notify,
|
|
||||||
canHasSend,
|
canHasSend,
|
||||||
isFile,
|
isFile,
|
||||||
ONE_DAY_IN_MS
|
saveFile,
|
||||||
|
openLinksInNewTab
|
||||||
};
|
};
|
||||||
|
@ -106,3 +106,10 @@ passwordTryAgain = Incorrect password. Try again.
|
|||||||
// This label is followed by the password needed to download a file
|
// This label is followed by the password needed to download a file
|
||||||
passwordResult = Password: { $password }
|
passwordResult = Password: { $password }
|
||||||
reportIPInfringement = Report IP Infringement
|
reportIPInfringement = Report IP Infringement
|
||||||
|
javascriptRequired = Firefox Send requires JavaScript
|
||||||
|
whyJavascript = Why does Firefox Send require JavaScript?
|
||||||
|
enableJavascript = Please enable JavaScript and try again.
|
||||||
|
// A short representation of a countdown timer containing the number of hours and minutes remaining as digits, example "13h 47m"
|
||||||
|
expiresHoursMinutes = { $hours }h { $minutes }m
|
||||||
|
// A short representation of a countdown timer containing the number of minutes remaining as digits, example "56m"
|
||||||
|
expiresMinutes = { $minutes }m
|
@ -15,9 +15,9 @@ const conf = convict({
|
|||||||
env: 'REDIS_HOST'
|
env: 'REDIS_HOST'
|
||||||
},
|
},
|
||||||
listen_address: {
|
listen_address: {
|
||||||
format: 'ipaddress',
|
format: 'ipaddress',
|
||||||
default: '0.0.0.0',
|
default: '0.0.0.0',
|
||||||
env: 'IP_ADDRESS'
|
env: 'IP_ADDRESS'
|
||||||
},
|
},
|
||||||
listen_port: {
|
listen_port: {
|
||||||
format: 'port',
|
format: 'port',
|
||||||
|
@ -24,4 +24,4 @@ app.use(
|
|||||||
|
|
||||||
app.use(pages.notfound);
|
app.use(pages.notfound);
|
||||||
|
|
||||||
app.listen(config.listen_port,config.listen_address);
|
app.listen(config.listen_port, config.listen_address);
|
||||||
|
@ -35,7 +35,7 @@ module.exports = {
|
|||||||
routes.toString(
|
routes.toString(
|
||||||
`/download/${req.params.id}`,
|
`/download/${req.params.id}`,
|
||||||
Object.assign(state(req), {
|
Object.assign(state(req), {
|
||||||
fileInfo: { nonce, pwd: +pwd }
|
fileInfo: { nonce, requiresPassword: +pwd }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
const storage = require('../storage');
|
const storage = require('../storage');
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
function validateID(route_id) {
|
function validateID(route_id) {
|
||||||
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
|
return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
|
||||||
@ -10,27 +9,24 @@ module.exports = async function(req, res) {
|
|||||||
if (!validateID(id)) {
|
if (!validateID(id)) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
}
|
}
|
||||||
if (!req.body.auth) {
|
const ownerToken = req.body.owner_token;
|
||||||
|
if (!ownerToken) {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
}
|
||||||
|
const auth = req.body.auth;
|
||||||
|
if (!auth) {
|
||||||
return res.sendStatus(400);
|
return res.sendStatus(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = req.header('Authorization').split(' ')[1];
|
|
||||||
const meta = await storage.metadata(id);
|
const meta = await storage.metadata(id);
|
||||||
const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
|
if (meta.owner !== ownerToken) {
|
||||||
hmac.update(Buffer.from(meta.nonce, 'base64'));
|
return res.sendStatus(404);
|
||||||
const verifyHash = hmac.digest();
|
|
||||||
if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
|
|
||||||
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
|
|
||||||
return res.sendStatus(401);
|
|
||||||
}
|
}
|
||||||
|
storage.setField(id, 'auth', auth);
|
||||||
|
storage.setField(id, 'pwd', 1);
|
||||||
|
res.sendStatus(200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
}
|
}
|
||||||
const nonce = crypto.randomBytes(16).toString('base64');
|
|
||||||
storage.setField(id, 'nonce', nonce);
|
|
||||||
res.set('WWW-Authenticate', `send-v1 ${nonce}`);
|
|
||||||
storage.setField(id, 'auth', req.body.auth);
|
|
||||||
storage.setField(id, 'pwd', 1);
|
|
||||||
res.sendStatus(200);
|
|
||||||
};
|
};
|
||||||
|
@ -116,9 +116,7 @@ describe('Server integration tests', function() {
|
|||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Successfully deletes if the id is valid and the delete token matches', function(
|
it('Successfully deletes if the id is valid and the delete token matches', function(done) {
|
||||||
done
|
|
||||||
) {
|
|
||||||
request(server)
|
request(server)
|
||||||
.post('/delete/' + fileId)
|
.post('/delete/' + fileId)
|
||||||
.send({ delete_token: uuid })
|
.send({ delete_token: uuid })
|
||||||
|
Loading…
Reference in New Issue
Block a user