HttpServiceProxy.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  2. import { AxiosInstance, AxiosError, AxiosResponse, Method } from 'axios'
  3. import { Request, Response } from 'express'
  4. import { inject, injectable } from 'inversify'
  5. import { Logger } from 'winston'
  6. import { TYPES } from '../../Bootstrap/Types'
  7. import { CrossServiceTokenCacheInterface } from '../Cache/CrossServiceTokenCacheInterface'
  8. import { ServiceProxyInterface } from '../Proxy/ServiceProxyInterface'
  9. import { TimerInterface } from '@standardnotes/time'
  10. import { ResponseLocals } from '../../Controller/ResponseLocals'
  11. import { OfflineResponseLocals } from '../../Controller/OfflineResponseLocals'
  12. @injectable()
  13. export class HttpServiceProxy implements ServiceProxyInterface {
  14. constructor(
  15. @inject(TYPES.ApiGateway_HTTPClient) private httpClient: AxiosInstance,
  16. @inject(TYPES.ApiGateway_AUTH_SERVER_URL) private authServerUrl: string,
  17. @inject(TYPES.ApiGateway_SYNCING_SERVER_JS_URL) private syncingServerJsUrl: string,
  18. @inject(TYPES.ApiGateway_PAYMENTS_SERVER_URL) private paymentsServerUrl: string,
  19. @inject(TYPES.ApiGateway_FILES_SERVER_URL) private filesServerUrl: string,
  20. @inject(TYPES.ApiGateway_WEB_SOCKET_SERVER_URL) private webSocketServerUrl: string,
  21. @inject(TYPES.ApiGateway_REVISIONS_SERVER_URL) private revisionsServerUrl: string,
  22. @inject(TYPES.ApiGateway_EMAIL_SERVER_URL) private emailServerUrl: string,
  23. @inject(TYPES.ApiGateway_HTTP_CALL_TIMEOUT) private httpCallTimeout: number,
  24. @inject(TYPES.ApiGateway_CrossServiceTokenCache) private crossServiceTokenCache: CrossServiceTokenCacheInterface,
  25. @inject(TYPES.ApiGateway_Logger) private logger: Logger,
  26. @inject(TYPES.ApiGateway_Timer) private timer: TimerInterface,
  27. ) {}
  28. async validateSession(
  29. headers: {
  30. authorization: string
  31. sharedVaultOwnerContext?: string
  32. },
  33. retryAttempt?: number,
  34. ): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
  35. try {
  36. const authResponse = await this.httpClient.request({
  37. method: 'POST',
  38. headers: {
  39. Authorization: headers.authorization,
  40. Accept: 'application/json',
  41. 'x-shared-vault-owner-context': headers.sharedVaultOwnerContext,
  42. },
  43. validateStatus: (status: number) => {
  44. return status >= 200 && status < 500
  45. },
  46. url: `${this.authServerUrl}/sessions/validate`,
  47. })
  48. return {
  49. status: authResponse.status,
  50. data: authResponse.data,
  51. headers: {
  52. contentType: authResponse.headers['content-type'] as string,
  53. },
  54. }
  55. } catch (error) {
  56. const requestDidNotMakeIt = this.requestTimedOutOrDidNotReachDestination(error as Record<string, unknown>)
  57. const tooManyRetryAttempts = retryAttempt && retryAttempt > 2
  58. if (!tooManyRetryAttempts && requestDidNotMakeIt) {
  59. await this.timer.sleep(50)
  60. const nextRetryAttempt = retryAttempt ? retryAttempt + 1 : 1
  61. return this.validateSession(headers, nextRetryAttempt)
  62. }
  63. throw error
  64. }
  65. }
  66. async callSyncingServer(
  67. request: Request,
  68. response: Response,
  69. endpoint: string,
  70. payload?: Record<string, unknown> | string,
  71. ): Promise<void> {
  72. await this.callServer(this.syncingServerJsUrl, request, response, endpoint, payload)
  73. }
  74. async callRevisionsServer(
  75. request: Request,
  76. response: Response,
  77. endpoint: string,
  78. payload?: Record<string, unknown> | string,
  79. ): Promise<void> {
  80. if (!this.revisionsServerUrl) {
  81. response.status(400).send({ message: 'Revisions Server not configured' })
  82. return
  83. }
  84. await this.callServer(this.revisionsServerUrl, request, response, endpoint, payload)
  85. }
  86. async callLegacySyncingServer(
  87. request: Request,
  88. response: Response,
  89. endpoint: string,
  90. payload?: Record<string, unknown> | string,
  91. ): Promise<void> {
  92. await this.callServerWithLegacyFormat(this.syncingServerJsUrl, request, response, endpoint, payload)
  93. }
  94. async callAuthServer(
  95. request: Request,
  96. response: Response,
  97. endpoint: string,
  98. payload?: Record<string, unknown> | string,
  99. ): Promise<void> {
  100. await this.callServer(this.authServerUrl, request, response, endpoint, payload)
  101. }
  102. async callEmailServer(
  103. request: Request,
  104. response: Response,
  105. endpoint: string,
  106. payload?: Record<string, unknown> | string,
  107. ): Promise<void> {
  108. if (!this.emailServerUrl) {
  109. response.status(400).send({ message: 'Email Server not configured' })
  110. return
  111. }
  112. await this.callServer(this.emailServerUrl, request, response, endpoint, payload)
  113. }
  114. async callWebSocketServer(
  115. request: Request,
  116. response: Response,
  117. endpoint: string,
  118. payload?: Record<string, unknown> | string,
  119. ): Promise<void> {
  120. if (!this.webSocketServerUrl) {
  121. this.logger.debug('Websockets Server URL not defined. Skipped request to WebSockets API.')
  122. return
  123. }
  124. const isARequestComingFromApiGatewayAndShouldBeKeptInMinimalFormat = request.headers.connectionid !== undefined
  125. if (isARequestComingFromApiGatewayAndShouldBeKeptInMinimalFormat) {
  126. await this.callServerWithLegacyFormat(this.webSocketServerUrl, request, response, endpoint, payload)
  127. } else {
  128. await this.callServer(this.webSocketServerUrl, request, response, endpoint, payload)
  129. }
  130. }
  131. async callPaymentsServer(
  132. request: Request,
  133. response: Response,
  134. endpoint: string,
  135. payload?: Record<string, unknown> | string,
  136. ): Promise<void | Response<unknown, Record<string, unknown>>> {
  137. if (!this.paymentsServerUrl) {
  138. this.logger.debug('Payments Server URL not defined. Skipped request to Payments API.')
  139. return
  140. }
  141. await this.callServerWithLegacyFormat(this.paymentsServerUrl, request, response, endpoint, payload)
  142. }
  143. async callAuthServerWithLegacyFormat(
  144. request: Request,
  145. response: Response,
  146. endpoint: string,
  147. payload?: Record<string, unknown> | string,
  148. ): Promise<void> {
  149. await this.callServerWithLegacyFormat(this.authServerUrl, request, response, endpoint, payload)
  150. }
  151. private async getServerResponse(
  152. serverUrl: string,
  153. request: Request,
  154. response: Response,
  155. endpoint: string,
  156. payload?: Record<string, unknown> | string,
  157. ): Promise<AxiosResponse | undefined> {
  158. const locals = response.locals as ResponseLocals | OfflineResponseLocals
  159. try {
  160. const headers: Record<string, string> = {}
  161. for (const headerName of Object.keys(request.headers)) {
  162. headers[headerName] = request.headers[headerName] as string
  163. }
  164. delete headers.host
  165. delete headers['content-length']
  166. if ('authToken' in locals && locals.authToken) {
  167. headers['X-Auth-Token'] = locals.authToken
  168. }
  169. if ('offlineAuthToken' in locals && locals.offlineAuthToken) {
  170. headers['X-Auth-Offline-Token'] = locals.offlineAuthToken
  171. }
  172. const serviceResponse = await this.httpClient.request({
  173. method: request.method as Method,
  174. headers,
  175. url: `${serverUrl}/${endpoint}`,
  176. data: this.getRequestData(payload),
  177. maxContentLength: Infinity,
  178. maxBodyLength: Infinity,
  179. params: request.query,
  180. timeout: this.httpCallTimeout,
  181. validateStatus: (status: number) => {
  182. return status >= 200 && status < 500
  183. },
  184. })
  185. if (serviceResponse.headers['x-invalidate-cache']) {
  186. const userUuid = serviceResponse.headers['x-invalidate-cache']
  187. await this.crossServiceTokenCache.invalidate(userUuid)
  188. }
  189. return serviceResponse
  190. } catch (error) {
  191. let detailedErrorMessage = (error as Error).message
  192. if (error instanceof AxiosError) {
  193. detailedErrorMessage = `Status: ${error.status}, code: ${error.code}, message: ${error.message}`
  194. }
  195. this.logger.error(
  196. `Could not pass the request to ${serverUrl}/${endpoint} on underlying service: ${detailedErrorMessage}`,
  197. {
  198. userId: (locals as ResponseLocals).user ? (locals as ResponseLocals).user.uuid : undefined,
  199. },
  200. )
  201. this.logger.debug(`Response error: ${JSON.stringify(error)}`)
  202. if ((error as AxiosError).response?.headers['content-type']) {
  203. response.setHeader('content-type', (error as AxiosError).response?.headers['content-type'] as string)
  204. }
  205. const errorCode =
  206. (error as AxiosError).isAxiosError && !isNaN(+((error as AxiosError).code as string))
  207. ? +((error as AxiosError).code as string)
  208. : 500
  209. const responseErrorMessage = (error as AxiosError).response?.data
  210. response
  211. .status(errorCode)
  212. .send(
  213. responseErrorMessage ??
  214. "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.",
  215. )
  216. }
  217. return
  218. }
  219. private async callServer(
  220. serverUrl: string,
  221. request: Request,
  222. response: Response,
  223. endpoint: string,
  224. payload?: Record<string, unknown> | string,
  225. ): Promise<void> {
  226. const locals = response.locals as ResponseLocals
  227. const serviceResponse = await this.getServerResponse(serverUrl, request, response, endpoint, payload)
  228. if (!serviceResponse) {
  229. return
  230. }
  231. this.applyResponseHeaders(serviceResponse, response)
  232. if (this.responseShouldNotBeDecorated(serviceResponse)) {
  233. response.status(serviceResponse.status).send(serviceResponse.data)
  234. return
  235. }
  236. response.status(serviceResponse.status).send({
  237. meta: {
  238. auth: {
  239. userUuid: locals.user?.uuid,
  240. roles: locals.roles,
  241. },
  242. server: {
  243. filesServerUrl: this.filesServerUrl,
  244. },
  245. },
  246. data: serviceResponse.data,
  247. })
  248. }
  249. private async callServerWithLegacyFormat(
  250. serverUrl: string,
  251. request: Request,
  252. response: Response,
  253. endpoint: string,
  254. payload?: Record<string, unknown> | string,
  255. ): Promise<void | Response<unknown, Record<string, unknown>>> {
  256. const serviceResponse = await this.getServerResponse(serverUrl, request, response, endpoint, payload)
  257. if (!serviceResponse) {
  258. return
  259. }
  260. this.applyResponseHeaders(serviceResponse, response)
  261. if (serviceResponse.request._redirectable._redirectCount > 0) {
  262. response.status(302)
  263. response.redirect(serviceResponse.request.res.responseUrl)
  264. } else {
  265. response.status(serviceResponse.status)
  266. response.send(serviceResponse.data)
  267. }
  268. }
  269. private getRequestData(
  270. payload: Record<string, unknown> | string | undefined,
  271. ): Record<string, unknown> | string | undefined {
  272. if (
  273. payload === '' ||
  274. payload === null ||
  275. payload === undefined ||
  276. (typeof payload === 'object' && Object.keys(payload).length === 0)
  277. ) {
  278. return undefined
  279. }
  280. return payload
  281. }
  282. private responseShouldNotBeDecorated(serviceResponse: AxiosResponse): boolean {
  283. return (
  284. serviceResponse.headers['content-type'] !== undefined &&
  285. serviceResponse.headers['content-type'].toLowerCase().includes('text/html')
  286. )
  287. }
  288. private applyResponseHeaders(serviceResponse: AxiosResponse, response: Response): void {
  289. const returnedHeadersFromUnderlyingService = [
  290. 'access-control-allow-methods',
  291. 'access-control-allow-origin',
  292. 'access-control-expose-headers',
  293. 'authorization',
  294. 'content-type',
  295. 'x-ssjs-version',
  296. 'x-auth-version',
  297. ]
  298. returnedHeadersFromUnderlyingService.map((headerName) => {
  299. const headerValue = serviceResponse.headers[headerName]
  300. if (headerValue) {
  301. response.setHeader(headerName, headerValue)
  302. }
  303. })
  304. }
  305. private requestTimedOutOrDidNotReachDestination(error: Record<string, unknown>): boolean {
  306. return (
  307. ('code' in error && error.code === 'ETIMEDOUT') ||
  308. ('response' in error &&
  309. 'status' in (error.response as Record<string, unknown>) &&
  310. [503, 504].includes((error.response as Record<string, unknown>).status as number))
  311. )
  312. }
  313. }