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,
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| }
 |