mirror of
https://github.com/standardnotes/server
synced 2026-01-16 20:04:32 -05:00
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
// 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 = [
|
|
'content-type',
|
|
'authorization',
|
|
'set-cookie',
|
|
'access-control-expose-headers',
|
|
'x-captcha-required',
|
|
]
|
|
|
|
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))
|
|
)
|
|
}
|
|
}
|