You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
const fetch = require('node-fetch');
|
|
const { Agent } = require('https');
|
|
|
|
/* global Buffer, setTimeout, log, _ */
|
|
|
|
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
|
|
|
|
function _btoa(str) {
|
|
let buffer;
|
|
|
|
if (str instanceof Buffer) {
|
|
buffer = str;
|
|
} else {
|
|
buffer = Buffer.from(str.toString(), 'binary');
|
|
}
|
|
|
|
return buffer.toString('base64');
|
|
}
|
|
|
|
const _call = object => Object.prototype.toString.call(object);
|
|
|
|
const ArrayBufferToString = _call(new ArrayBuffer());
|
|
const Uint8ArrayToString = _call(new Uint8Array());
|
|
|
|
function _getString(thing) {
|
|
if (typeof thing !== 'string') {
|
|
if (_call(thing) === Uint8ArrayToString) {
|
|
return String.fromCharCode.apply(null, thing);
|
|
}
|
|
if (_call(thing) === ArrayBufferToString) {
|
|
return _getString(new Uint8Array(thing));
|
|
}
|
|
}
|
|
return thing;
|
|
}
|
|
|
|
function _validateResponse(response, schema) {
|
|
try {
|
|
// eslint-disable-next-line guard-for-in, no-restricted-syntax
|
|
for (const i in schema) {
|
|
switch (schema[i]) {
|
|
case 'object':
|
|
case 'string':
|
|
case 'number':
|
|
// eslint-disable-next-line valid-typeof
|
|
if (typeof response[i] !== schema[i]) {
|
|
return false;
|
|
}
|
|
break;
|
|
default:
|
|
}
|
|
}
|
|
} catch (ex) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const FIVE_MINUTES = 1000 * 60 * 5;
|
|
const agents = {
|
|
unauth: null,
|
|
auth: null,
|
|
};
|
|
|
|
function getContentType(response) {
|
|
if (response.headers && response.headers.get) {
|
|
return response.headers.get('content-type');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function _promiseAjax(providedUrl, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = providedUrl || `${options.host}/${options.path}`;
|
|
if (options.disableLogs) {
|
|
log.info(
|
|
`${options.type} [REDACTED_URL]${
|
|
options.unauthenticated ? ' (unauth)' : ''
|
|
}`
|
|
);
|
|
} else {
|
|
log.info(
|
|
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
|
|
);
|
|
}
|
|
|
|
const timeout =
|
|
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
|
|
|
|
const { proxyUrl } = options;
|
|
const agentType = options.unauthenticated ? 'unauth' : 'auth';
|
|
const cacheKey = `${proxyUrl}-${agentType}`;
|
|
|
|
const { timestamp } = agents[cacheKey] || {};
|
|
if (!timestamp || timestamp + FIVE_MINUTES < Date.now()) {
|
|
if (timestamp) {
|
|
log.info(`Cycling agent for type ${cacheKey}`);
|
|
}
|
|
agents[cacheKey] = {
|
|
agent: new Agent({ keepAlive: true }),
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
const { agent } = agents[cacheKey];
|
|
|
|
const fetchOptions = {
|
|
method: options.type,
|
|
body: options.data || null,
|
|
headers: {
|
|
'User-Agent': 'Session',
|
|
'X-Loki-Messenger-Agent': 'OWD',
|
|
...options.headers,
|
|
},
|
|
redirect: options.redirect,
|
|
agent,
|
|
ca: options.certificateAuthority,
|
|
timeout,
|
|
};
|
|
|
|
if (fetchOptions.body instanceof ArrayBuffer) {
|
|
// node-fetch doesn't support ArrayBuffer, only node Buffer
|
|
const contentLength = fetchOptions.body.byteLength;
|
|
fetchOptions.body = Buffer.from(fetchOptions.body);
|
|
|
|
// node-fetch doesn't set content-length like S3 requires
|
|
fetchOptions.headers['Content-Length'] = contentLength;
|
|
}
|
|
|
|
const { accessKey, unauthenticated } = options;
|
|
if (unauthenticated) {
|
|
if (!accessKey) {
|
|
throw new Error(
|
|
'_promiseAjax: mode is aunathenticated, but accessKey was not provided'
|
|
);
|
|
}
|
|
// Access key is already a Base64 string
|
|
fetchOptions.headers['Unidentified-Access-Key'] = accessKey;
|
|
} else if (options.user && options.password) {
|
|
const user = _getString(options.user);
|
|
const password = _getString(options.password);
|
|
const auth = _btoa(`${user}:${password}`);
|
|
fetchOptions.headers.Authorization = `Basic ${auth}`;
|
|
}
|
|
|
|
if (options.contentType) {
|
|
fetchOptions.headers['Content-Type'] = options.contentType;
|
|
}
|
|
|
|
fetch(url, fetchOptions)
|
|
.then(response => {
|
|
let resultPromise;
|
|
if (
|
|
options.responseType === 'json' &&
|
|
response.headers.get('Content-Type') === 'application/json'
|
|
) {
|
|
resultPromise = response.json();
|
|
} else if (
|
|
options.responseType === 'arraybuffer' ||
|
|
options.responseType === 'arraybufferwithdetails'
|
|
) {
|
|
resultPromise = response.buffer();
|
|
} else {
|
|
resultPromise = response.text();
|
|
}
|
|
|
|
return resultPromise.then(result => {
|
|
if (
|
|
options.responseType === 'arraybuffer' ||
|
|
options.responseType === 'arraybufferwithdetails'
|
|
) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
result = result.buffer.slice(
|
|
result.byteOffset,
|
|
result.byteOffset + result.byteLength
|
|
);
|
|
}
|
|
if (options.responseType === 'json') {
|
|
if (options.validateResponse) {
|
|
if (!_validateResponse(result, options.validateResponse)) {
|
|
if (options.disableLogs) {
|
|
log.info(
|
|
options.type,
|
|
'[REDACTED_URL]',
|
|
response.status,
|
|
'Error'
|
|
);
|
|
} else {
|
|
log.error(options.type, url, response.status, 'Error');
|
|
}
|
|
return reject(
|
|
HTTPError(
|
|
'promiseAjax: invalid response',
|
|
response.status,
|
|
result,
|
|
options.stack
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (response.status >= 0 && response.status < 400) {
|
|
if (options.disableLogs) {
|
|
log.info(
|
|
options.type,
|
|
'[REDACTED_URL]',
|
|
response.status,
|
|
'Success'
|
|
);
|
|
} else {
|
|
log.info(options.type, url, response.status, 'Success');
|
|
}
|
|
if (options.responseType === 'arraybufferwithdetails') {
|
|
return resolve({
|
|
data: result,
|
|
contentType: getContentType(response),
|
|
response,
|
|
});
|
|
}
|
|
return resolve(result, response.status);
|
|
}
|
|
|
|
if (options.disableLogs) {
|
|
log.info(options.type, '[REDACTED_URL]', response.status, 'Error');
|
|
} else {
|
|
log.error(options.type, url, response.status, 'Error');
|
|
}
|
|
return reject(
|
|
HTTPError(
|
|
'promiseAjax: error response',
|
|
response.status,
|
|
result,
|
|
options.stack
|
|
)
|
|
);
|
|
});
|
|
})
|
|
.catch(e => {
|
|
if (options.disableLogs) {
|
|
log.error(options.type, '[REDACTED_URL]', 0, 'Error');
|
|
} else {
|
|
log.error(options.type, url, 0, 'Error');
|
|
}
|
|
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
|
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
|
|
});
|
|
});
|
|
}
|
|
|
|
function _retryAjax(url, options, providedLimit, providedCount) {
|
|
const count = (providedCount || 0) + 1;
|
|
const limit = providedLimit || 3;
|
|
return _promiseAjax(url, options).catch(e => {
|
|
if (e.name === 'HTTPError' && e.code === -1 && count < limit) {
|
|
return new Promise(resolve => {
|
|
setTimeout(() => {
|
|
resolve(_retryAjax(url, options, limit, count));
|
|
}, 1000);
|
|
});
|
|
}
|
|
throw e;
|
|
});
|
|
}
|
|
|
|
function _outerAjax(url, options) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
options.stack = new Error().stack; // just in case, save stack here.
|
|
return _retryAjax(url, options);
|
|
}
|
|
|
|
function HTTPError(message, providedCode, response, stack) {
|
|
const code = providedCode > 999 || providedCode < 100 ? -1 : providedCode;
|
|
const e = new Error(`${message}; code: ${code}`);
|
|
e.name = 'HTTPError';
|
|
e.code = code;
|
|
e.stack += `\nOriginal stack:\n${stack}`;
|
|
if (response) {
|
|
e.response = response;
|
|
}
|
|
return e;
|
|
}
|
|
|
|
module.exports = {
|
|
initialize,
|
|
};
|
|
|
|
// We first set up the data that won't change during this session of the app
|
|
function initialize() {
|
|
// Thanks to function-hoisting, we can put this return statement before all of the
|
|
// below function definitions.
|
|
return {
|
|
connect,
|
|
};
|
|
|
|
// Then we connect to the server with user-specific information. This is the only API
|
|
// exposed to the browser context, ensuring that it can't connect to arbitrary
|
|
// locations.
|
|
function connect() {
|
|
// Thanks, function hoisting!
|
|
return {
|
|
getAttachment,
|
|
getProxiedSize,
|
|
makeProxiedRequest,
|
|
};
|
|
|
|
function getAttachment(fileUrl) {
|
|
return _outerAjax(fileUrl, {
|
|
contentType: 'application/octet-stream',
|
|
responseType: 'arraybuffer',
|
|
timeout: 0,
|
|
type: 'GET',
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line no-shadow
|
|
async function getProxiedSize(url) {
|
|
const result = await _outerAjax(url, {
|
|
processData: false,
|
|
responseType: 'arraybufferwithdetails',
|
|
proxyUrl: '',
|
|
type: 'HEAD',
|
|
disableLogs: true,
|
|
});
|
|
|
|
const { response } = result;
|
|
if (!response.headers || !response.headers.get) {
|
|
throw new Error('getProxiedSize: Problem retrieving header value');
|
|
}
|
|
|
|
const size = response.headers.get('content-length');
|
|
return parseInt(size, 10);
|
|
}
|
|
|
|
// eslint-disable-next-line no-shadow
|
|
function makeProxiedRequest(url, options = {}) {
|
|
const { returnArrayBuffer, start, end } = options;
|
|
let headers;
|
|
|
|
if (_.isNumber(start) && _.isNumber(end)) {
|
|
headers = {
|
|
Range: `bytes=${start}-${end}`,
|
|
};
|
|
}
|
|
|
|
return _outerAjax(url, {
|
|
processData: false,
|
|
responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null,
|
|
proxyUrl: '',
|
|
type: 'GET',
|
|
redirect: 'follow',
|
|
disableLogs: true,
|
|
headers,
|
|
});
|
|
}
|
|
}
|
|
}
|