// tslint:disable: cyclomatic-complexity import { OnionPaths } from '.'; import { buildErrorMessageWithFailedCode, FinalDestNonSnodeOptions, FinalRelayOptions, Onions, SnodeResponse, STATUS_NO_STATUS, } from '../apis/snode_api/onions'; import { toNumber } from 'lodash'; import { PROTOCOLS } from '../constants'; import pRetry from 'p-retry'; import { Snode } from '../../data/data'; import { OnionV4 } from './onionv4'; import { OpenGroupPollingUtils } from '../apis/open_group_api/opengroupV2/OpenGroupPollingUtils'; import { addBinaryContentTypeToHeaders, addJsonContentTypeToHeaders, } from '../apis/open_group_api/sogsv3/sogsV3SendMessage'; import { AbortSignal } from 'abort-controller'; import { pnServerPubkeyHex, pnServerUrl } from '../apis/push_notification_api/PnServer'; import { fileServerPubKey, fileServerURL } from '../apis/file_server_api/FileServerApi'; import { invalidAuthRequiresBlinding } from '../apis/open_group_api/opengroupV2/OpenGroupServerPoller'; export type OnionFetchOptions = { method: string; body: string | Uint8Array | null; headers: Record; useV4: boolean; }; // NOTE some endpoints require decoded strings const endpointExceptions = ['/reaction']; const endpointRequiresDecoding = (url: string): string => { // tslint:disable-next-line: prefer-for-of for (let i = 0; i < endpointExceptions.length; i++) { if (url.includes(endpointExceptions[i])) { return decodeURIComponent(url); } } return url; }; const buildSendViaOnionPayload = ( url: URL, fetchOptions: OnionFetchOptions ): FinalDestNonSnodeOptions => { const endpoint = OnionSending.endpointRequiresDecoding( url.search ? `${url.pathname}${url.search}` : url.pathname ); const payloadObj: FinalDestNonSnodeOptions = { method: fetchOptions.method || 'GET', body: fetchOptions.body, endpoint, headers: fetchOptions.headers || {}, }; // the usev4 field is skipped here, as the snode doing the request won't care about it return payloadObj; }; const getOnionPathForSending = async () => { let pathNodes: Array = []; 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; }; export type OnionSnodeResponse = { result: SnodeResponse; txtResponse: string; response: string; }; export type OnionV4SnodeResponse = { body: string | object | null; // if the content can be decoded as string bodyBinary: Uint8Array | null; // otherwise we return the raw content (could be an image data or file from sogs/fileserver) status_code: number; }; export type OnionV4JSONSnodeResponse = { body: Record | null; status_code: number; }; export type OnionV4BinarySnodeResponse = { bodyBinary: Uint8Array | null; status_code: number; }; /** * Build & send an onion v4 request to a non snode, and handle retries. * We actually can only send v4 request to non snode, as the snodes themselves do not support v4 request as destination. */ // tslint:disable-next-line: max-func-body-length const sendViaOnionV4ToNonSnodeWithRetries = async ( destinationX25519Key: string, url: URL, fetchOptions: OnionFetchOptions, throwErrors: boolean, abortSignal?: AbortSignal ): Promise => { if (!fetchOptions.useV4) { throw new Error('sendViaOnionV4ToNonSnodeWithRetries is only to be used for onion v4 calls'); } if (typeof destinationX25519Key !== 'string') { throw new Error(`destinationX25519Key is not a string ${typeof destinationX25519Key})a`); } const payloadObj = buildSendViaOnionPayload(url, fetchOptions); if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) { window.log.info( 'sendViaOnionV4ToNonSnodeWithRetries: buildSendViaOnionPayload returned ', JSON.stringify(payloadObj) ); } // 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: OnionV4SnodeResponse | null; try { result = await pRetry( async () => { const pathNodes = await OnionSending.getOnionPathForSending(); if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) { window.log.info( 'sendViaOnionV4ToNonSnodeWithRetries: getOnionPathForSending returned', JSON.stringify(pathNodes) ); } if (!pathNodes) { throw new Error('getOnionPathForSending is emtpy'); } /** * This call handles ejecting a snode or a path if needed. If that happens, it throws a retryable error and the pRetry * call above will call us again with the same params but a different path. * If the error is not recoverable, it throws a pRetry.AbortError. */ const onionV4Response = await Onions.sendOnionRequestHandlingSnodeEject({ nodePath: pathNodes, destSnodeX25519: destinationX25519Key, finalDestOptions: payloadObj, finalRelayOptions, abortSignal, useV4: true, throwErrors, }); if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) { window.log.info( 'sendViaOnionV4ToNonSnodeWithRetries: sendOnionRequestHandlingSnodeEject returned: ', JSON.stringify(onionV4Response) ); } if (abortSignal?.aborted) { // if the request was aborted, we just want to stop retries. window?.log?.warn('sendViaOnionV4ToNonSnodeRetryable request aborted.'); throw new pRetry.AbortError('Request Aborted'); } if (!onionV4Response) { // v4 failed responses result is undefined window?.log?.warn('sendViaOnionV4ToNonSnodeRetryable failed during V4 request (in)'); throw new Error( 'sendViaOnionV4ToNonSnodeRetryable failed during V4 request. Retrying...' ); } // This only decodes single entries for now. // We decode it here, because if the result status code is not valid, we want to trigger a retry (by throwing an error) const decodedV4 = OnionV4.decodeV4Response(onionV4Response); // the pn server replies with the decodedV4?.metadata as any)?.code syntax too since onion v4 const foundStatusCode = decodedV4?.metadata?.code || STATUS_NO_STATUS; if (foundStatusCode < 200 || foundStatusCode > 299) { // this is temporary (as of 27/06/2022) as we want to not support unblinded sogs after some time if ( foundStatusCode === 400 && (decodedV4?.body as any).plainText === invalidAuthRequiresBlinding ) { return { status_code: foundStatusCode, body: decodedV4?.body || null, bodyBinary: decodedV4?.bodyBinary || null, }; } if (foundStatusCode === 404) { window.log.warn( `Got 404 while sendViaOnionV4ToNonSnodeWithRetries with url:${url}. Stopping retries` ); // most likely, a 404 won't fix itself. So just stop right here retries by throwing a non retryable error throw new pRetry.AbortError( buildErrorMessageWithFailedCode( 'sendViaOnionV4ToNonSnodeWithRetries', 404, `with url:${url}. Stopping retries` ) ); } // we consider those cases as an error, and trigger a retry (if possible), by throwing a non-abortable error throw new Error( `sendViaOnionV4ToNonSnodeWithRetries failed with status code: ${foundStatusCode}. Retrying...` ); } return { status_code: foundStatusCode, body: decodedV4?.body || null, bodyBinary: decodedV4?.bodyBinary || null, }; }, { retries: 2, // retry 3 (2+1) times at most minTimeout: 100, onFailedAttempt: e => { window?.log?.warn( `sendViaOnionV4ToNonSnodeRetryable attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...: ${e.message}` ); }, } ); } catch (e) { window?.log?.warn('sendViaOnionV4ToNonSnodeRetryable failed ', e.message, throwErrors); if (throwErrors) { throw e; } return null; } if (abortSignal?.aborted) { window?.log?.warn('sendViaOnionV4ToNonSnodeRetryable request aborted.'); return null; } if (!result) { // v4 failed responses result is undefined window?.log?.warn('sendViaOnionV4ToNonSnodeRetryable failed during V4 request (out)'); return null; } return result; }; async function sendJsonViaOnionV4ToSogs(sendOptions: { serverUrl: string; endpoint: string; serverPubkey: string; blinded: boolean; method: string; stringifiedBody: string | null; abortSignal: AbortSignal; headers: Record | null; throwErrors: boolean; }): Promise { const { serverUrl, endpoint, serverPubkey, method, blinded, stringifiedBody, abortSignal, headers: includedHeaders, throwErrors, } = sendOptions; if (!endpoint.startsWith('/')) { throw new Error('endpoint needs a leading /'); } const builtUrl = new URL(`${serverUrl}${endpoint}`); let headersWithSogsHeadersIfNeeded = await OpenGroupPollingUtils.getOurOpenGroupHeaders( serverPubkey, endpoint, method, blinded, stringifiedBody ); if (!headersWithSogsHeadersIfNeeded) { return null; } headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded }; const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( serverPubkey, builtUrl, { method, headers: addJsonContentTypeToHeaders(headersWithSogsHeadersIfNeeded as any), body: stringifiedBody, useV4: true, }, throwErrors, abortSignal ); return res as OnionV4JSONSnodeResponse; } /** * Send some json to the PushNotification server. * Desktop only send `/notify` requests. * * You should probably not use this function directly but instead rely on the PnServer.notifyPnServer() function */ async function sendJsonViaOnionV4ToPnServer(sendOptions: { endpoint: string; method: string; stringifiedBody: string | null; abortSignal: AbortSignal; }): Promise { const { endpoint, method, stringifiedBody, abortSignal } = sendOptions; if (!endpoint.startsWith('/')) { throw new Error('endpoint needs a leading /'); } const builtUrl = new URL(`${pnServerUrl}${endpoint}`); const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( pnServerPubkeyHex, builtUrl, { method, headers: {}, body: stringifiedBody, useV4: true, }, false, abortSignal ); return res as OnionV4JSONSnodeResponse; } async function sendBinaryViaOnionV4ToSogs(sendOptions: { serverUrl: string; endpoint: string; serverPubkey: string; blinded: boolean; method: string; bodyBinary: Uint8Array; abortSignal: AbortSignal; headers: Record | null; }): Promise { const { serverUrl, endpoint, serverPubkey, method, blinded, bodyBinary, abortSignal, headers: includedHeaders, } = sendOptions; if (!bodyBinary) { return null; } if (!endpoint.startsWith('/')) { throw new Error('endpoint needs a leading /'); } const builtUrl = new window.URL(`${serverUrl}${endpoint}`); let headersWithSogsHeadersIfNeeded = await OpenGroupPollingUtils.getOurOpenGroupHeaders( serverPubkey, endpoint, method, blinded, bodyBinary ); if (!headersWithSogsHeadersIfNeeded) { return null; } headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded }; const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( serverPubkey, builtUrl, { method, headers: addBinaryContentTypeToHeaders(headersWithSogsHeadersIfNeeded as any), body: bodyBinary || undefined, useV4: true, }, false, abortSignal ); return res as OnionV4JSONSnodeResponse; } /** * * FILE SERVER REQUESTS * */ /** * Upload binary to the file server. * You should probably not use this function directly, but instead rely on the FileServerAPI.uploadFileToFsWithOnionV4() */ async function sendBinaryViaOnionV4ToFileServer(sendOptions: { endpoint: string; method: string; bodyBinary: Uint8Array; abortSignal: AbortSignal; }): Promise { const { endpoint, method, bodyBinary, abortSignal } = sendOptions; if (!endpoint.startsWith('/')) { throw new Error('endpoint needs a leading /'); } const builtUrl = new URL(`${fileServerURL}${endpoint}`); const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( fileServerPubKey, builtUrl, { method, headers: {}, body: bodyBinary, useV4: true, }, false, abortSignal ); return res as OnionV4JSONSnodeResponse; } /** * Download binary from the file server. * You should probably not use this function directly, but instead rely on the FileServerAPI.downloadFileFromFileServer() */ async function getBinaryViaOnionV4FromFileServer(sendOptions: { endpoint: string; method: string; abortSignal: AbortSignal; throwError: boolean; }): Promise { const { endpoint, method, abortSignal, throwError } = sendOptions; if (!endpoint.startsWith('/')) { throw new Error('endpoint needs a leading /'); } const builtUrl = new URL(`${fileServerURL}${endpoint}`); if (window.sessionFeatureFlags?.debug.debugFileServerRequests) { window.log.info(`getBinaryViaOnionV4FromFileServer fsv2: "${builtUrl} `); } // this throws for a bunch of reasons. // One of them, is if we get a 404 (i.e. the file server was reached but reported no such attachments exists) const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( fileServerPubKey, builtUrl, { method, headers: {}, body: null, useV4: true, }, throwError, abortSignal ); if (window.sessionFeatureFlags?.debug.debugFileServerRequests) { window.log.info( `getBinaryViaOnionV4FromFileServer fsv2: "${builtUrl}; got:`, JSON.stringify(res) ); } return res as OnionV4BinarySnodeResponse; } /** * Send some generic json to the fileserver. * This function should probably not used directly as we only need it for the FileServerApi.getLatestReleaseFromFileServer() function */ async function sendJsonViaOnionV4ToFileServer(sendOptions: { endpoint: string; method: string; stringifiedBody: string | null; abortSignal: AbortSignal; }): Promise { const { endpoint, method, stringifiedBody, abortSignal } = sendOptions; if (!endpoint.startsWith('/')) { throw new Error('endpoint needs a leading /'); } const builtUrl = new URL(`${fileServerURL}${endpoint}`); const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( fileServerPubKey, builtUrl, { method, headers: {}, body: stringifiedBody, useV4: true, }, false, abortSignal ); return res as OnionV4JSONSnodeResponse; } // we export these methods for stubbing during testing export const OnionSending = { endpointRequiresDecoding, sendViaOnionV4ToNonSnodeWithRetries, getOnionPathForSending, sendJsonViaOnionV4ToSogs, sendJsonViaOnionV4ToPnServer, sendBinaryViaOnionV4ToFileServer, sendBinaryViaOnionV4ToSogs, getBinaryViaOnionV4FromFileServer, sendJsonViaOnionV4ToFileServer, };