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.
		
		
		
		
		
			
		
			
				
	
	
		
			386 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			386 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
// tslint:disable: cyclomatic-complexity
 | 
						|
 | 
						|
import { OnionPaths } from '.';
 | 
						|
import {
 | 
						|
  FinalRelayOptions,
 | 
						|
  sendOnionRequestLsrpcDest,
 | 
						|
  snodeHttpsAgent,
 | 
						|
  SnodeResponse,
 | 
						|
} from '../snode_api/onions';
 | 
						|
import _, { toNumber } from 'lodash';
 | 
						|
import { default as insecureNodeFetch } from 'node-fetch';
 | 
						|
import { PROTOCOLS } from '../constants';
 | 
						|
import { toHex } from '../utils/String';
 | 
						|
import pRetry from 'p-retry';
 | 
						|
import { Snode } from '../../data/data';
 | 
						|
 | 
						|
// FIXME audric we should soon be able to get rid of that
 | 
						|
const FILESERVER_HOSTS = [
 | 
						|
  'file-dev.lokinet.org',
 | 
						|
  'file.lokinet.org',
 | 
						|
  'file-dev.getsession.org',
 | 
						|
  'file.getsession.org',
 | 
						|
];
 | 
						|
 | 
						|
type OnionFetchOptions = {
 | 
						|
  method: string;
 | 
						|
  body?: string;
 | 
						|
  headers?: Record<string, string>;
 | 
						|
};
 | 
						|
 | 
						|
type OnionFetchBasicOptions = {
 | 
						|
  retry?: number;
 | 
						|
  noJson?: boolean;
 | 
						|
};
 | 
						|
 | 
						|
type OnionPayloadObj = {
 | 
						|
  method: string;
 | 
						|
  endpoint: string;
 | 
						|
  body: any;
 | 
						|
  headers: Record<string, any>;
 | 
						|
};
 | 
						|
 | 
						|
const buildSendViaOnionPayload = (url: URL, fetchOptions: OnionFetchOptions): OnionPayloadObj => {
 | 
						|
  let tempHeaders = fetchOptions.headers || {};
 | 
						|
  const payloadObj = {
 | 
						|
    method: fetchOptions.method || 'GET',
 | 
						|
    body: fetchOptions.body || ('' as any),
 | 
						|
    // safety issue with file server, just safer to have this
 | 
						|
    // no initial /
 | 
						|
    endpoint: url.pathname.replace(/^\//, ''),
 | 
						|
    headers: {},
 | 
						|
  };
 | 
						|
  if (url.search) {
 | 
						|
    payloadObj.endpoint += url.search;
 | 
						|
  }
 | 
						|
 | 
						|
  // from https://github.com/sindresorhus/is-stream/blob/master/index.js
 | 
						|
  if (
 | 
						|
    payloadObj.body &&
 | 
						|
    typeof payloadObj.body === 'object' &&
 | 
						|
    typeof payloadObj.body.pipe === 'function'
 | 
						|
  ) {
 | 
						|
    const fData = payloadObj.body.getBuffer();
 | 
						|
    const fHeaders = payloadObj.body.getHeaders();
 | 
						|
    tempHeaders = { ...tempHeaders, ...fHeaders };
 | 
						|
    // update headers for boundary
 | 
						|
    // update body with base64 chunk
 | 
						|
    payloadObj.body = {
 | 
						|
      fileUpload: fData.toString('base64'),
 | 
						|
    };
 | 
						|
  }
 | 
						|
  payloadObj.headers = tempHeaders;
 | 
						|
  return payloadObj;
 | 
						|
};
 | 
						|
 | 
						|
export const getOnionPathForSending = async () => {
 | 
						|
  let pathNodes: Array<Snode> = [];
 | 
						|
  try {
 | 
						|
    pathNodes = await OnionPaths.getOnionPath();
 | 
						|
  } catch (e) {
 | 
						|
    window?.log?.error(`sendViaOnion - getOnionPath Error ${e.code} ${e.message}`);
 | 
						|
  }
 | 
						|
  if (!pathNodes?.length) {
 | 
						|
    window?.log?.warn('sendViaOnion - failing, no path available');
 | 
						|
    // should we retry?
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
  return pathNodes;
 | 
						|
};
 | 
						|
 | 
						|
const initOptionsWithDefaults = (options: OnionFetchBasicOptions) => {
 | 
						|
  const defaultFetchBasicOptions = {
 | 
						|
    retry: 0,
 | 
						|
    noJson: false,
 | 
						|
  };
 | 
						|
  return _.defaults(options, defaultFetchBasicOptions);
 | 
						|
};
 | 
						|
 | 
						|
const sendViaOnionRetryable = async ({
 | 
						|
  castedDestinationX25519Key,
 | 
						|
  finalRelayOptions,
 | 
						|
  payloadObj,
 | 
						|
  abortSignal,
 | 
						|
}: {
 | 
						|
  castedDestinationX25519Key: string;
 | 
						|
  finalRelayOptions: FinalRelayOptions;
 | 
						|
  payloadObj: OnionPayloadObj;
 | 
						|
  abortSignal?: AbortSignal;
 | 
						|
}) => {
 | 
						|
  const pathNodes = await getOnionPathForSending();
 | 
						|
 | 
						|
  if (!pathNodes) {
 | 
						|
    throw new Error('getOnionPathForSending is emtpy');
 | 
						|
  }
 | 
						|
 | 
						|
  // this call throws a normal error (which will trigger a retry) if a retryable is got (bad path or whatever)
 | 
						|
  // it throws an AbortError in case the retry should not be retried again (aborted, or )
 | 
						|
  const result = await sendOnionRequestLsrpcDest(
 | 
						|
    pathNodes,
 | 
						|
    castedDestinationX25519Key,
 | 
						|
    finalRelayOptions,
 | 
						|
    payloadObj,
 | 
						|
    abortSignal
 | 
						|
  );
 | 
						|
 | 
						|
  return result;
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * FIXME the type for this is not correct for open group api v2 returned values
 | 
						|
 * result is status_code and whatever the body should be
 | 
						|
 */
 | 
						|
export const sendViaOnion = async (
 | 
						|
  destinationX25519Key: string,
 | 
						|
  url: URL,
 | 
						|
  fetchOptions: OnionFetchOptions,
 | 
						|
  options: OnionFetchBasicOptions = {},
 | 
						|
  abortSignal?: AbortSignal
 | 
						|
): Promise<{
 | 
						|
  result: SnodeResponse;
 | 
						|
  txtResponse: string;
 | 
						|
  response: string;
 | 
						|
} | null> => {
 | 
						|
  const castedDestinationX25519Key =
 | 
						|
    typeof destinationX25519Key !== 'string' ? toHex(destinationX25519Key) : destinationX25519Key;
 | 
						|
  // FIXME audric looks like this might happen for opengroupv1
 | 
						|
  if (!destinationX25519Key || typeof destinationX25519Key !== 'string') {
 | 
						|
    window?.log?.error('sendViaOnion - called without a server public key or not a string key');
 | 
						|
  }
 | 
						|
 | 
						|
  const defaultedOptions = initOptionsWithDefaults(options);
 | 
						|
 | 
						|
  const payloadObj = buildSendViaOnionPayload(url, fetchOptions);
 | 
						|
  // if protocol is forced to 'http:' => just use http (without the ':').
 | 
						|
  // otherwise use https as protocol (this is the default)
 | 
						|
  const forcedHttp = url.protocol === PROTOCOLS.HTTP;
 | 
						|
  const finalRelayOptions: FinalRelayOptions = {
 | 
						|
    host: url.hostname,
 | 
						|
  };
 | 
						|
 | 
						|
  if (forcedHttp) {
 | 
						|
    finalRelayOptions.protocol = 'http';
 | 
						|
  }
 | 
						|
  if (forcedHttp) {
 | 
						|
    finalRelayOptions.port = url.port ? toNumber(url.port) : 80;
 | 
						|
  }
 | 
						|
 | 
						|
  let result: SnodeResponse;
 | 
						|
  try {
 | 
						|
    result = await pRetry(
 | 
						|
      async () => {
 | 
						|
        return sendViaOnionRetryable({
 | 
						|
          castedDestinationX25519Key,
 | 
						|
          finalRelayOptions,
 | 
						|
          payloadObj,
 | 
						|
          abortSignal,
 | 
						|
        });
 | 
						|
      },
 | 
						|
      {
 | 
						|
        retries: 4, // each path can fail 3 times before being dropped, we have 3 paths at most
 | 
						|
        factor: 1,
 | 
						|
        minTimeout: 100,
 | 
						|
        maxTimeout: 4000,
 | 
						|
        onFailedAttempt: e => {
 | 
						|
          window?.log?.warn(
 | 
						|
            `sendViaOnionRetryable attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...`
 | 
						|
          );
 | 
						|
        },
 | 
						|
      }
 | 
						|
    );
 | 
						|
  } catch (e) {
 | 
						|
    window?.log?.warn('sendViaOnionRetryable failed ', e);
 | 
						|
    // console.warn('error to show to user', e);
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  // If we expect something which is not json, just return the body we got.
 | 
						|
  if (defaultedOptions.noJson) {
 | 
						|
    return {
 | 
						|
      result,
 | 
						|
      txtResponse: result.body,
 | 
						|
      response: result.body,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  // get the return variables we need
 | 
						|
  let txtResponse = '';
 | 
						|
 | 
						|
  let { body } = result;
 | 
						|
  if (typeof body === 'string') {
 | 
						|
    // adn does uses this path
 | 
						|
    // log.info(`sendViaOnion - got text response ${url.toString()}`);
 | 
						|
    txtResponse = result.body;
 | 
						|
    try {
 | 
						|
      body = JSON.parse(result.body);
 | 
						|
    } catch (e) {
 | 
						|
      window?.log?.error("sendViaOnion Can't decode JSON body", typeof result.body, result.body);
 | 
						|
    }
 | 
						|
  }
 | 
						|
  // result.status has the http response code
 | 
						|
  if (!txtResponse) {
 | 
						|
    txtResponse = JSON.stringify(body);
 | 
						|
  }
 | 
						|
  return { result, txtResponse, response: body };
 | 
						|
};
 | 
						|
 | 
						|
// FIXME this is really dirty
 | 
						|
type ServerRequestOptionsType = {
 | 
						|
  params?: Record<string, string>;
 | 
						|
  method?: string;
 | 
						|
  rawBody?: any;
 | 
						|
  objBody?: any;
 | 
						|
  token?: string;
 | 
						|
  srvPubKey?: string;
 | 
						|
  forceFreshToken?: boolean;
 | 
						|
 | 
						|
  retry?: number;
 | 
						|
  noJson?: boolean;
 | 
						|
};
 | 
						|
 | 
						|
// tslint:disable-next-line: max-func-body-length
 | 
						|
export const serverRequest = async (
 | 
						|
  endpoint: string,
 | 
						|
  options: ServerRequestOptionsType = {}
 | 
						|
): Promise<any> => {
 | 
						|
  const {
 | 
						|
    params = {},
 | 
						|
    method,
 | 
						|
    rawBody,
 | 
						|
    objBody,
 | 
						|
    token,
 | 
						|
    srvPubKey,
 | 
						|
    forceFreshToken = false,
 | 
						|
  } = options;
 | 
						|
 | 
						|
  const url = new URL(endpoint);
 | 
						|
  if (!_.isEmpty(params)) {
 | 
						|
    const builtParams = new URLSearchParams(params).toString();
 | 
						|
    url.search = `?${builtParams}`;
 | 
						|
  }
 | 
						|
  const fetchOptions: any = {};
 | 
						|
  const headers: any = {};
 | 
						|
  try {
 | 
						|
    if (token) {
 | 
						|
      headers.Authorization = `Bearer ${token}`;
 | 
						|
    }
 | 
						|
    if (method) {
 | 
						|
      fetchOptions.method = method;
 | 
						|
    }
 | 
						|
    if (objBody) {
 | 
						|
      headers['Content-Type'] = 'application/json';
 | 
						|
      fetchOptions.body = JSON.stringify(objBody);
 | 
						|
    } else if (rawBody) {
 | 
						|
      fetchOptions.body = rawBody;
 | 
						|
    }
 | 
						|
    fetchOptions.headers = headers;
 | 
						|
 | 
						|
    // domain ends in .loki
 | 
						|
    if (url.host.match(/\.loki$/i)) {
 | 
						|
      fetchOptions.agent = snodeHttpsAgent;
 | 
						|
    }
 | 
						|
  } catch (e) {
 | 
						|
    window?.log?.error('onionSend:::serverRequest - set up error:', e.code, e.message);
 | 
						|
    return {
 | 
						|
      err: e,
 | 
						|
      ok: false,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  let response;
 | 
						|
  let result;
 | 
						|
  let txtResponse;
 | 
						|
  let mode = 'insecureNodeFetch';
 | 
						|
  try {
 | 
						|
    const host = url.host.toLowerCase();
 | 
						|
    // log.info('host', host, FILESERVER_HOSTS);
 | 
						|
    if (window.lokiFeatureFlags.useFileOnionRequests && FILESERVER_HOSTS.includes(host)) {
 | 
						|
      mode = 'sendViaOnion';
 | 
						|
      if (!srvPubKey) {
 | 
						|
        throw new Error('useFileOnionRequests=true but we do not have a server pubkey set.');
 | 
						|
      }
 | 
						|
      const onionResponse = await sendViaOnion(srvPubKey, url, fetchOptions, options);
 | 
						|
      if (onionResponse) {
 | 
						|
        ({ response, txtResponse, result } = onionResponse);
 | 
						|
      }
 | 
						|
    } else if (window.lokiFeatureFlags.useFileOnionRequests) {
 | 
						|
      if (!srvPubKey) {
 | 
						|
        throw new Error('useFileOnionRequests=true but we do not have a server pubkey set.');
 | 
						|
      }
 | 
						|
      mode = 'sendViaOnionOG';
 | 
						|
      const onionResponse = await sendViaOnion(srvPubKey, url, fetchOptions, options);
 | 
						|
      if (onionResponse) {
 | 
						|
        ({ response, txtResponse, result } = onionResponse);
 | 
						|
      }
 | 
						|
    } else {
 | 
						|
      // we end up here only if window.lokiFeatureFlags.useFileOnionRequests is false
 | 
						|
      window?.log?.info(`insecureNodeFetch => plaintext for ${url}`);
 | 
						|
      result = await insecureNodeFetch(url, fetchOptions);
 | 
						|
 | 
						|
      txtResponse = await result.text();
 | 
						|
      // cloudflare timeouts (504s) will be html...
 | 
						|
      response = options.noJson ? txtResponse : JSON.parse(txtResponse);
 | 
						|
 | 
						|
      // result.status will always be 200
 | 
						|
      // emulate the correct http code if available
 | 
						|
      if (response && response.meta && response.meta.code) {
 | 
						|
        result.status = response.meta.code;
 | 
						|
      }
 | 
						|
    }
 | 
						|
  } catch (e) {
 | 
						|
    if (txtResponse) {
 | 
						|
      window?.log?.error(
 | 
						|
        `onionSend:::serverRequest - ${mode} error`,
 | 
						|
        e.code,
 | 
						|
        e.message,
 | 
						|
        `json: ${txtResponse}`,
 | 
						|
        'attempting connection to',
 | 
						|
        url.toString()
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      window?.log?.error(
 | 
						|
        `onionSend:::serverRequest - ${mode} error`,
 | 
						|
        e.code,
 | 
						|
        e.message,
 | 
						|
        'attempting connection to',
 | 
						|
        url.toString()
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return {
 | 
						|
      err: e,
 | 
						|
      ok: false,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  if (!result) {
 | 
						|
    return {
 | 
						|
      err: 'noResult',
 | 
						|
      response,
 | 
						|
      ok: false,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  // if it's a response style with a meta
 | 
						|
  if (result.status !== 200) {
 | 
						|
    if (!forceFreshToken && (!response.meta || response.meta.code === 401)) {
 | 
						|
      // retry with forcing a fresh token
 | 
						|
      return serverRequest(endpoint, {
 | 
						|
        ...options,
 | 
						|
        forceFreshToken: true,
 | 
						|
      });
 | 
						|
    }
 | 
						|
    return {
 | 
						|
      err: 'statusCode',
 | 
						|
      statusCode: result.status,
 | 
						|
      response,
 | 
						|
      ok: false,
 | 
						|
    };
 | 
						|
  }
 | 
						|
  return {
 | 
						|
    statusCode: result.status,
 | 
						|
    response,
 | 
						|
    ok: result.status >= 200 && result.status <= 299,
 | 
						|
  };
 | 
						|
};
 |