HttpServiceProxy.ts 12 KB

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