123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- import { AxiosInstance, AxiosError, AxiosResponse, Method } from 'axios'
- import { Request, Response } from 'express'
- import { inject, injectable } from 'inversify'
- import { Logger } from 'winston'
- import { TYPES } from '../../Bootstrap/Types'
- import { CrossServiceTokenCacheInterface } from '../Cache/CrossServiceTokenCacheInterface'
- import { ServiceProxyInterface } from '../Proxy/ServiceProxyInterface'
- import { TimerInterface } from '@standardnotes/time'
- import { ResponseLocals } from '../../Controller/ResponseLocals'
- import { OfflineResponseLocals } from '../../Controller/OfflineResponseLocals'
- @injectable()
- export class HttpServiceProxy implements ServiceProxyInterface {
- constructor(
- @inject(TYPES.ApiGateway_HTTPClient) private httpClient: AxiosInstance,
- @inject(TYPES.ApiGateway_AUTH_SERVER_URL) private authServerUrl: string,
- @inject(TYPES.ApiGateway_SYNCING_SERVER_JS_URL) private syncingServerJsUrl: string,
- @inject(TYPES.ApiGateway_PAYMENTS_SERVER_URL) private paymentsServerUrl: string,
- @inject(TYPES.ApiGateway_FILES_SERVER_URL) private filesServerUrl: string,
- @inject(TYPES.ApiGateway_WEB_SOCKET_SERVER_URL) private webSocketServerUrl: string,
- @inject(TYPES.ApiGateway_REVISIONS_SERVER_URL) private revisionsServerUrl: string,
- @inject(TYPES.ApiGateway_EMAIL_SERVER_URL) private emailServerUrl: string,
- @inject(TYPES.ApiGateway_HTTP_CALL_TIMEOUT) private httpCallTimeout: number,
- @inject(TYPES.ApiGateway_CrossServiceTokenCache) private crossServiceTokenCache: CrossServiceTokenCacheInterface,
- @inject(TYPES.ApiGateway_Logger) private logger: Logger,
- @inject(TYPES.ApiGateway_Timer) private timer: TimerInterface,
- ) {}
- async validateSession(
- headers: {
- authorization: string
- sharedVaultOwnerContext?: string
- },
- retryAttempt?: number,
- ): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
- try {
- const authResponse = await this.httpClient.request({
- method: 'POST',
- headers: {
- Authorization: headers.authorization,
- Accept: 'application/json',
- 'x-shared-vault-owner-context': headers.sharedVaultOwnerContext,
- },
- validateStatus: (status: number) => {
- return status >= 200 && status < 500
- },
- url: `${this.authServerUrl}/sessions/validate`,
- })
- return {
- status: authResponse.status,
- data: authResponse.data,
- headers: {
- contentType: authResponse.headers['content-type'] as string,
- },
- }
- } catch (error) {
- const requestDidNotMakeIt = this.requestTimedOutOrDidNotReachDestination(error as Record<string, unknown>)
- const tooManyRetryAttempts = retryAttempt && retryAttempt > 2
- if (!tooManyRetryAttempts && requestDidNotMakeIt) {
- await this.timer.sleep(50)
- const nextRetryAttempt = retryAttempt ? retryAttempt + 1 : 1
- return this.validateSession(headers, nextRetryAttempt)
- }
- throw error
- }
- }
- async callSyncingServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- await this.callServer(this.syncingServerJsUrl, request, response, endpoint, payload)
- }
- async callRevisionsServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- if (!this.revisionsServerUrl) {
- response.status(400).send({ message: 'Revisions Server not configured' })
- return
- }
- await this.callServer(this.revisionsServerUrl, request, response, endpoint, payload)
- }
- async callLegacySyncingServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- await this.callServerWithLegacyFormat(this.syncingServerJsUrl, request, response, endpoint, payload)
- }
- async callAuthServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- await this.callServer(this.authServerUrl, request, response, endpoint, payload)
- }
- async callEmailServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- if (!this.emailServerUrl) {
- response.status(400).send({ message: 'Email Server not configured' })
- return
- }
- await this.callServer(this.emailServerUrl, request, response, endpoint, payload)
- }
- async callWebSocketServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- if (!this.webSocketServerUrl) {
- this.logger.debug('Websockets Server URL not defined. Skipped request to WebSockets API.')
- return
- }
- const isARequestComingFromApiGatewayAndShouldBeKeptInMinimalFormat = request.headers.connectionid !== undefined
- if (isARequestComingFromApiGatewayAndShouldBeKeptInMinimalFormat) {
- await this.callServerWithLegacyFormat(this.webSocketServerUrl, request, response, endpoint, payload)
- } else {
- await this.callServer(this.webSocketServerUrl, request, response, endpoint, payload)
- }
- }
- async callPaymentsServer(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void | Response<unknown, Record<string, unknown>>> {
- if (!this.paymentsServerUrl) {
- this.logger.debug('Payments Server URL not defined. Skipped request to Payments API.')
- return
- }
- await this.callServerWithLegacyFormat(this.paymentsServerUrl, request, response, endpoint, payload)
- }
- async callAuthServerWithLegacyFormat(
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- await this.callServerWithLegacyFormat(this.authServerUrl, request, response, endpoint, payload)
- }
- private async getServerResponse(
- serverUrl: string,
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<AxiosResponse | undefined> {
- const locals = response.locals as ResponseLocals | OfflineResponseLocals
- try {
- const headers: Record<string, string> = {}
- for (const headerName of Object.keys(request.headers)) {
- headers[headerName] = request.headers[headerName] as string
- }
- delete headers.host
- delete headers['content-length']
- if ('authToken' in locals && locals.authToken) {
- headers['X-Auth-Token'] = locals.authToken
- }
- if ('offlineAuthToken' in locals && locals.offlineAuthToken) {
- headers['X-Auth-Offline-Token'] = locals.offlineAuthToken
- }
- const serviceResponse = await this.httpClient.request({
- method: request.method as Method,
- headers,
- url: `${serverUrl}/${endpoint}`,
- data: this.getRequestData(payload),
- maxContentLength: Infinity,
- maxBodyLength: Infinity,
- params: request.query,
- timeout: this.httpCallTimeout,
- validateStatus: (status: number) => {
- return status >= 200 && status < 500
- },
- })
- if (serviceResponse.headers['x-invalidate-cache']) {
- const userUuid = serviceResponse.headers['x-invalidate-cache']
- await this.crossServiceTokenCache.invalidate(userUuid)
- }
- return serviceResponse
- } catch (error) {
- let detailedErrorMessage = (error as Error).message
- if (error instanceof AxiosError) {
- detailedErrorMessage = `Status: ${error.status}, code: ${error.code}, message: ${error.message}`
- }
- this.logger.error(
- `Could not pass the request to ${serverUrl}/${endpoint} on underlying service: ${detailedErrorMessage}`,
- {
- userId: (locals as ResponseLocals).user ? (locals as ResponseLocals).user.uuid : undefined,
- },
- )
- this.logger.debug(`Response error: ${JSON.stringify(error)}`)
- if ((error as AxiosError).response?.headers['content-type']) {
- response.setHeader('content-type', (error as AxiosError).response?.headers['content-type'] as string)
- }
- const errorCode =
- (error as AxiosError).isAxiosError && !isNaN(+((error as AxiosError).code as string))
- ? +((error as AxiosError).code as string)
- : 500
- const responseErrorMessage = (error as AxiosError).response?.data
- response
- .status(errorCode)
- .send(
- responseErrorMessage ??
- "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.",
- )
- }
- return
- }
- private async callServer(
- serverUrl: string,
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void> {
- const locals = response.locals as ResponseLocals
- const serviceResponse = await this.getServerResponse(serverUrl, request, response, endpoint, payload)
- if (!serviceResponse) {
- return
- }
- this.applyResponseHeaders(serviceResponse, response)
- if (this.responseShouldNotBeDecorated(serviceResponse)) {
- response.status(serviceResponse.status).send(serviceResponse.data)
- return
- }
- response.status(serviceResponse.status).send({
- meta: {
- auth: {
- userUuid: locals.user?.uuid,
- roles: locals.roles,
- },
- server: {
- filesServerUrl: this.filesServerUrl,
- },
- },
- data: serviceResponse.data,
- })
- }
- private async callServerWithLegacyFormat(
- serverUrl: string,
- request: Request,
- response: Response,
- endpoint: string,
- payload?: Record<string, unknown> | string,
- ): Promise<void | Response<unknown, Record<string, unknown>>> {
- const serviceResponse = await this.getServerResponse(serverUrl, request, response, endpoint, payload)
- if (!serviceResponse) {
- return
- }
- this.applyResponseHeaders(serviceResponse, response)
- if (serviceResponse.request._redirectable._redirectCount > 0) {
- response.status(302)
- response.redirect(serviceResponse.request.res.responseUrl)
- } else {
- response.status(serviceResponse.status)
- response.send(serviceResponse.data)
- }
- }
- private getRequestData(
- payload: Record<string, unknown> | string | undefined,
- ): Record<string, unknown> | string | undefined {
- if (
- payload === '' ||
- payload === null ||
- payload === undefined ||
- (typeof payload === 'object' && Object.keys(payload).length === 0)
- ) {
- return undefined
- }
- return payload
- }
- private responseShouldNotBeDecorated(serviceResponse: AxiosResponse): boolean {
- return (
- serviceResponse.headers['content-type'] !== undefined &&
- serviceResponse.headers['content-type'].toLowerCase().includes('text/html')
- )
- }
- private applyResponseHeaders(serviceResponse: AxiosResponse, response: Response): void {
- const returnedHeadersFromUnderlyingService = [
- 'access-control-allow-methods',
- 'access-control-allow-origin',
- 'access-control-expose-headers',
- 'authorization',
- 'content-type',
- 'x-ssjs-version',
- 'x-auth-version',
- ]
- returnedHeadersFromUnderlyingService.map((headerName) => {
- const headerValue = serviceResponse.headers[headerName]
- if (headerValue) {
- response.setHeader(headerName, headerValue)
- }
- })
- }
- private requestTimedOutOrDidNotReachDestination(error: Record<string, unknown>): boolean {
- return (
- ('code' in error && error.code === 'ETIMEDOUT') ||
- ('response' in error &&
- 'status' in (error.response as Record<string, unknown>) &&
- [503, 504].includes((error.response as Record<string, unknown>).status as number))
- )
- }
- }
|