mirror of
https://gitlab.com/timvisee/send.git
synced 2024-11-13 22:54:15 +01:00
f64e772145
Co-authored-by: timvisee <tim@visee.me>
444 lines
10 KiB
JavaScript
444 lines
10 KiB
JavaScript
import { arrayToB64, b64ToArray, delay } from './utils';
|
|
import { ECE_RECORD_SIZE } from './ece';
|
|
|
|
let fileProtocolWssUrl = null;
|
|
try {
|
|
fileProtocolWssUrl = localStorage.getItem('wssURL');
|
|
} catch (e) {
|
|
// NOOP
|
|
}
|
|
if (!fileProtocolWssUrl) {
|
|
fileProtocolWssUrl = 'wss://send.firefox.com/api/ws';
|
|
}
|
|
|
|
export class ConnectionError extends Error {
|
|
constructor(cancelled, duration, size) {
|
|
super(cancelled ? '0' : 'connection closed');
|
|
this.cancelled = cancelled;
|
|
this.duration = duration;
|
|
this.size = size;
|
|
}
|
|
}
|
|
|
|
export function setFileProtocolWssUrl(url) {
|
|
localStorage && localStorage.setItem('wssURL', url);
|
|
fileProtocolWssUrl = url;
|
|
}
|
|
|
|
export function getFileProtocolWssUrl() {
|
|
return fileProtocolWssUrl;
|
|
}
|
|
|
|
let apiUrlPrefix = '';
|
|
export function getApiUrl(path) {
|
|
return apiUrlPrefix + path;
|
|
}
|
|
|
|
export function setApiUrlPrefix(prefix) {
|
|
apiUrlPrefix = prefix;
|
|
}
|
|
|
|
function post(obj, bearerToken) {
|
|
const h = {
|
|
'Content-Type': 'application/json'
|
|
};
|
|
if (bearerToken) {
|
|
h['Authentication'] = `Bearer ${bearerToken}`;
|
|
}
|
|
return {
|
|
method: 'POST',
|
|
headers: new Headers(h),
|
|
body: JSON.stringify(obj)
|
|
};
|
|
}
|
|
|
|
export 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,
|
|
'Content-Type': 'application/json'
|
|
});
|
|
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(
|
|
getApiUrl(`/api/delete/${id}`),
|
|
post({ owner_token })
|
|
);
|
|
return response.ok;
|
|
}
|
|
|
|
export async function setParams(id, owner_token, bearerToken, params) {
|
|
const response = await fetch(
|
|
getApiUrl(`/api/params/${id}`),
|
|
post(
|
|
{
|
|
owner_token,
|
|
dlimit: params.dlimit
|
|
},
|
|
bearerToken
|
|
)
|
|
);
|
|
return response.ok;
|
|
}
|
|
|
|
export async function fileInfo(id, owner_token) {
|
|
const response = await fetch(
|
|
getApiUrl(`/api/info/${id}`),
|
|
post({ owner_token })
|
|
);
|
|
|
|
if (response.ok) {
|
|
const obj = await response.json();
|
|
return obj;
|
|
}
|
|
|
|
throw new Error(response.status);
|
|
}
|
|
|
|
export async function metadata(id, keychain) {
|
|
const result = await fetchWithAuthAndRetry(
|
|
getApiUrl(`/api/metadata/${id}`),
|
|
{ method: 'GET' },
|
|
keychain
|
|
);
|
|
if (result.ok) {
|
|
const data = await result.response.json();
|
|
const meta = await keychain.decryptMetadata(b64ToArray(data.metadata));
|
|
return {
|
|
size: meta.size,
|
|
ttl: data.ttl,
|
|
iv: meta.iv,
|
|
name: meta.name,
|
|
type: meta.type,
|
|
manifest: meta.manifest
|
|
};
|
|
}
|
|
throw new Error(result.response.status);
|
|
}
|
|
|
|
export async function setPassword(id, owner_token, keychain) {
|
|
const auth = await keychain.authKeyB64();
|
|
const response = await fetch(
|
|
getApiUrl(`/api/password/${id}`),
|
|
post({ owner_token, auth })
|
|
);
|
|
return response.ok;
|
|
}
|
|
|
|
function asyncInitWebSocket(server) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const ws = new WebSocket(server);
|
|
ws.addEventListener('open', () => resolve(ws), { once: true });
|
|
} catch (e) {
|
|
reject(new ConnectionError(false));
|
|
}
|
|
});
|
|
}
|
|
|
|
function listenForResponse(ws, canceller) {
|
|
return new Promise((resolve, reject) => {
|
|
function handleClose(event) {
|
|
// a 'close' event before a 'message' event means the request failed
|
|
ws.removeEventListener('message', handleMessage);
|
|
reject(new ConnectionError(canceller.cancelled));
|
|
}
|
|
function handleMessage(msg) {
|
|
ws.removeEventListener('close', handleClose);
|
|
try {
|
|
const response = JSON.parse(msg.data);
|
|
if (response.error) {
|
|
throw new Error(response.error);
|
|
} else {
|
|
resolve(response);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}
|
|
ws.addEventListener('message', handleMessage, { once: true });
|
|
ws.addEventListener('close', handleClose, { once: true });
|
|
});
|
|
}
|
|
|
|
async function upload(
|
|
stream,
|
|
metadata,
|
|
verifierB64,
|
|
timeLimit,
|
|
dlimit,
|
|
bearerToken,
|
|
onprogress,
|
|
canceller
|
|
) {
|
|
let size = 0;
|
|
const start = Date.now();
|
|
const host = window.location.hostname;
|
|
const port = window.location.port;
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const endpoint =
|
|
window.location.protocol === 'file:'
|
|
? fileProtocolWssUrl
|
|
: `${protocol}//${host}${port ? ':' : ''}${port}/api/ws`;
|
|
|
|
const ws = await asyncInitWebSocket(endpoint);
|
|
|
|
try {
|
|
const metadataHeader = arrayToB64(new Uint8Array(metadata));
|
|
const fileMeta = {
|
|
fileMetadata: metadataHeader,
|
|
authorization: `send-v1 ${verifierB64}`,
|
|
bearer: bearerToken,
|
|
timeLimit,
|
|
dlimit
|
|
};
|
|
const uploadInfoResponse = listenForResponse(ws, canceller);
|
|
ws.send(JSON.stringify(fileMeta));
|
|
const uploadInfo = await uploadInfoResponse;
|
|
|
|
const completedResponse = listenForResponse(ws, canceller);
|
|
|
|
const reader = stream.getReader();
|
|
let state = await reader.read();
|
|
while (!state.done) {
|
|
if (canceller.cancelled) {
|
|
ws.close();
|
|
}
|
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
break;
|
|
}
|
|
const buf = state.value;
|
|
ws.send(buf);
|
|
onprogress(size);
|
|
size += buf.length;
|
|
state = await reader.read();
|
|
while (
|
|
ws.bufferedAmount > ECE_RECORD_SIZE * 2 &&
|
|
ws.readyState === WebSocket.OPEN &&
|
|
!canceller.cancelled
|
|
) {
|
|
await delay();
|
|
}
|
|
}
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(new Uint8Array([0])); //EOF
|
|
}
|
|
|
|
await completedResponse;
|
|
uploadInfo.duration = Date.now() - start;
|
|
return uploadInfo;
|
|
} catch (e) {
|
|
e.size = size;
|
|
e.duration = Date.now() - start;
|
|
throw e;
|
|
} finally {
|
|
if (![WebSocket.CLOSED, WebSocket.CLOSING].includes(ws.readyState)) {
|
|
ws.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
export function uploadWs(
|
|
encrypted,
|
|
metadata,
|
|
verifierB64,
|
|
timeLimit,
|
|
dlimit,
|
|
bearerToken,
|
|
onprogress
|
|
) {
|
|
const canceller = { cancelled: false };
|
|
|
|
return {
|
|
cancel: function() {
|
|
canceller.cancelled = true;
|
|
},
|
|
|
|
result: upload(
|
|
encrypted,
|
|
metadata,
|
|
verifierB64,
|
|
timeLimit,
|
|
dlimit,
|
|
bearerToken,
|
|
onprogress,
|
|
canceller
|
|
)
|
|
};
|
|
}
|
|
|
|
////////////////////////
|
|
|
|
async function downloadS(id, keychain, signal) {
|
|
const auth = await keychain.authHeader();
|
|
|
|
const response = await fetch(getApiUrl(`/api/download/${id}`), {
|
|
signal: signal,
|
|
method: 'GET',
|
|
headers: { Authorization: auth }
|
|
});
|
|
|
|
const authHeader = response.headers.get('WWW-Authenticate');
|
|
if (authHeader) {
|
|
keychain.nonce = parseNonce(authHeader);
|
|
}
|
|
|
|
if (response.status !== 200) {
|
|
throw new Error(response.status);
|
|
}
|
|
|
|
return response.body;
|
|
}
|
|
|
|
async function tryDownloadStream(id, keychain, signal, tries = 2) {
|
|
try {
|
|
const result = await downloadS(id, keychain, signal);
|
|
return result;
|
|
} catch (e) {
|
|
if (e.message === '401' && --tries > 0) {
|
|
return tryDownloadStream(id, keychain, signal, tries);
|
|
}
|
|
if (e.name === 'AbortError') {
|
|
throw new Error('0');
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export function downloadStream(id, keychain) {
|
|
const controller = new AbortController();
|
|
function cancel() {
|
|
controller.abort();
|
|
}
|
|
return {
|
|
cancel,
|
|
result: tryDownloadStream(id, keychain, controller.signal)
|
|
};
|
|
}
|
|
|
|
//////////////////
|
|
|
|
async function download(id, keychain, onprogress, canceller) {
|
|
const auth = await keychain.authHeader();
|
|
const xhr = new XMLHttpRequest();
|
|
canceller.oncancel = function() {
|
|
xhr.abort();
|
|
};
|
|
return new Promise(function(resolve, reject) {
|
|
xhr.addEventListener('loadend', function() {
|
|
canceller.oncancel = function() {};
|
|
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
|
|
if (authHeader) {
|
|
keychain.nonce = parseNonce(authHeader);
|
|
}
|
|
if (xhr.status !== 200) {
|
|
return reject(new Error(xhr.status));
|
|
}
|
|
|
|
const blob = new Blob([xhr.response]);
|
|
resolve(blob);
|
|
});
|
|
|
|
xhr.addEventListener('progress', function(event) {
|
|
if (event.target.status === 200) {
|
|
onprogress(event.loaded);
|
|
}
|
|
});
|
|
xhr.open('get', getApiUrl(`/api/download/blob/${id}`));
|
|
xhr.setRequestHeader('Authorization', auth);
|
|
xhr.responseType = 'blob';
|
|
xhr.send();
|
|
onprogress(0);
|
|
});
|
|
}
|
|
|
|
async function tryDownload(id, keychain, onprogress, canceller, tries = 2) {
|
|
try {
|
|
const result = await download(id, keychain, onprogress, canceller);
|
|
return result;
|
|
} catch (e) {
|
|
if (e.message === '401' && --tries > 0) {
|
|
return tryDownload(id, keychain, onprogress, canceller, tries);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export function downloadFile(id, keychain, onprogress) {
|
|
const canceller = {
|
|
oncancel: function() {} // download() sets this
|
|
};
|
|
function cancel() {
|
|
canceller.oncancel();
|
|
}
|
|
return {
|
|
cancel,
|
|
result: tryDownload(id, keychain, onprogress, canceller)
|
|
};
|
|
}
|
|
|
|
export async function getFileList(bearerToken, kid) {
|
|
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
|
|
const response = await fetch(getApiUrl(`/api/filelist/${kid}`), { headers });
|
|
if (response.ok) {
|
|
const encrypted = await response.blob();
|
|
return encrypted;
|
|
}
|
|
throw new Error(response.status);
|
|
}
|
|
|
|
export async function setFileList(bearerToken, kid, data) {
|
|
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
|
|
const response = await fetch(getApiUrl(`/api/filelist/${kid}`), {
|
|
headers,
|
|
method: 'POST',
|
|
body: data
|
|
});
|
|
return response.ok;
|
|
}
|
|
|
|
export function sendMetrics(blob) {
|
|
if (!navigator.sendBeacon) {
|
|
return;
|
|
}
|
|
try {
|
|
navigator.sendBeacon(getApiUrl('/api/metrics'), blob);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
export async function getConstants() {
|
|
const response = await fetch(getApiUrl('/config'));
|
|
|
|
if (response.ok) {
|
|
const obj = await response.json();
|
|
return obj;
|
|
}
|
|
|
|
throw new Error(response.status);
|
|
}
|