144 lines
6.4 KiB
TypeScript
144 lines
6.4 KiB
TypeScript
import type { UploadedFile, ProxyUploadResponse } from './types'
|
|
import FormData from 'form-data'
|
|
import fs from 'fs'
|
|
import https from 'https'
|
|
import http from 'http'
|
|
import { URL } from 'url'
|
|
import { getFormDataHeaders, getRedirectLocation } from './utils'
|
|
import { handleProxyRequestError } from './proxyRequestErrors'
|
|
|
|
export async function makeRequestWithRedirects(params: {
|
|
targetEndpoint: string
|
|
url: URL
|
|
file: UploadedFile
|
|
authToken: string | undefined
|
|
redirectCount: number
|
|
maxRedirects: number
|
|
}): Promise<ProxyUploadResponse> {
|
|
if (params.redirectCount > params.maxRedirects) {
|
|
throw new Error(`Too many redirects (max ${params.maxRedirects})`)
|
|
}
|
|
const response = await makeRequestOnce({ targetEndpoint: params.targetEndpoint, url: params.url, file: params.file, authToken: params.authToken })
|
|
const redirectUrl = tryGetRedirectUrl({ url: params.url, response })
|
|
if (!redirectUrl) {
|
|
return response
|
|
}
|
|
console.warn('NIP-95 proxy redirect:', { from: params.url.toString(), to: redirectUrl.toString(), statusCode: response.statusCode, redirectCount: params.redirectCount + 1 })
|
|
return makeRequestWithRedirects({ ...params, url: redirectUrl, redirectCount: params.redirectCount + 1 })
|
|
}
|
|
|
|
async function makeRequestOnce(params: { targetEndpoint: string; url: URL; file: UploadedFile; authToken: string | undefined }): Promise<ProxyUploadResponse> {
|
|
const { requestFormData, fileStream } = buildUploadFormData(params.file)
|
|
const headers = buildProxyRequestHeaders(requestFormData, params.authToken)
|
|
const { clientModule, requestOptions } = buildProxyRequestOptions({ url: params.url, headers })
|
|
return sendFormDataRequest({ clientModule, requestOptions, requestFormData, fileStream, targetEndpoint: params.targetEndpoint, hostname: params.url.hostname, finalUrl: params.url.toString(), filepath: params.file.filepath })
|
|
}
|
|
|
|
function tryGetRedirectUrl(params: { url: URL; response: ProxyUploadResponse }): URL | null {
|
|
if (!isRedirectStatus(params.response.statusCode)) {
|
|
return null
|
|
}
|
|
const location = getRedirectLocation(params.response.headers as unknown)
|
|
if (!location) {
|
|
throw new Error('Redirect response missing location header')
|
|
}
|
|
try {
|
|
return new URL(location, params.url.toString())
|
|
} catch (urlError) {
|
|
console.error('NIP-95 proxy invalid redirect URL:', { location, error: urlError instanceof Error ? urlError.message : 'Unknown error' })
|
|
throw new Error(`Invalid redirect URL: ${location}`)
|
|
}
|
|
}
|
|
|
|
function isRedirectStatus(statusCode: number): boolean {
|
|
return statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308
|
|
}
|
|
|
|
function buildUploadFormData(file: UploadedFile): { requestFormData: FormData; fileStream: fs.ReadStream } {
|
|
const requestFormData = new FormData()
|
|
const fileStream = fs.createReadStream(file.filepath)
|
|
requestFormData.append('file', fileStream, { filename: file.originalFilename ?? file.newFilename ?? 'upload', contentType: file.mimetype ?? 'application/octet-stream' })
|
|
return { requestFormData, fileStream }
|
|
}
|
|
|
|
function buildProxyRequestHeaders(requestFormData: FormData, authToken: string | undefined): http.OutgoingHttpHeaders {
|
|
const headers = getFormDataHeaders(requestFormData)
|
|
headers.Accept = 'application/json'
|
|
headers['User-Agent'] = 'zapwall.fr/1.0'
|
|
if (authToken) {
|
|
headers.Authorization = `Nostr ${authToken}`
|
|
}
|
|
return headers
|
|
}
|
|
|
|
function buildProxyRequestOptions(params: { url: URL; headers: http.OutgoingHttpHeaders }): { clientModule: typeof http | typeof https; requestOptions: http.RequestOptions } {
|
|
const isHttps = params.url.protocol === 'https:'
|
|
return {
|
|
clientModule: isHttps ? https : http,
|
|
requestOptions: { hostname: params.url.hostname, port: params.url.port ?? (isHttps ? 443 : 80), path: params.url.pathname + params.url.search, method: 'POST', headers: params.headers, timeout: 30000 },
|
|
}
|
|
}
|
|
|
|
async function sendFormDataRequest(params: {
|
|
clientModule: typeof http | typeof https
|
|
requestOptions: http.RequestOptions
|
|
requestFormData: FormData
|
|
fileStream: fs.ReadStream
|
|
targetEndpoint: string
|
|
hostname: string
|
|
finalUrl: string
|
|
filepath: string
|
|
}): Promise<ProxyUploadResponse> {
|
|
return new Promise<ProxyUploadResponse>((resolve, reject) => {
|
|
const proxyRequest = params.clientModule.request(params.requestOptions, (proxyResponse: http.IncomingMessage) => {
|
|
void readProxyResponse({ proxyResponse, finalUrl: params.finalUrl }).then(resolve).catch(reject)
|
|
})
|
|
attachProxyRequestHandlers({ proxyRequest, requestFormData: params.requestFormData, fileStream: params.fileStream, targetEndpoint: params.targetEndpoint, hostname: params.hostname, filepath: params.filepath, reject })
|
|
params.requestFormData.pipe(proxyRequest)
|
|
})
|
|
}
|
|
|
|
function attachProxyRequestHandlers(params: {
|
|
proxyRequest: http.ClientRequest
|
|
requestFormData: FormData
|
|
fileStream: fs.ReadStream
|
|
targetEndpoint: string
|
|
hostname: string
|
|
filepath: string
|
|
reject: (error: unknown) => void
|
|
}): void {
|
|
params.proxyRequest.setTimeout(30000, () => {
|
|
params.proxyRequest.destroy()
|
|
params.reject(new Error('Request timeout after 30 seconds'))
|
|
})
|
|
params.proxyRequest.on('error', (error) => {
|
|
params.reject(handleProxyRequestError({ error, targetEndpoint: params.targetEndpoint, hostname: params.hostname }))
|
|
})
|
|
params.requestFormData.on('error', (error) => {
|
|
console.error('NIP-95 proxy FormData error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, error })
|
|
params.reject(error)
|
|
})
|
|
params.fileStream.on('error', (error) => {
|
|
console.error('NIP-95 proxy file stream error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, filepath: params.filepath, error })
|
|
params.reject(error)
|
|
})
|
|
}
|
|
|
|
async function readProxyResponse(params: { proxyResponse: http.IncomingMessage; finalUrl: string }): Promise<ProxyUploadResponse> {
|
|
const statusCode = params.proxyResponse.statusCode ?? 500
|
|
const body = await readIncomingMessageBody(params.proxyResponse)
|
|
return { statusCode, statusMessage: params.proxyResponse.statusMessage ?? 'Internal Server Error', body, headers: params.proxyResponse.headers, finalUrl: params.finalUrl }
|
|
}
|
|
|
|
async function readIncomingMessageBody(message: http.IncomingMessage): Promise<string> {
|
|
return new Promise<string>((resolve, reject) => {
|
|
let body = ''
|
|
message.setEncoding('utf8')
|
|
message.on('data', (chunk) => {
|
|
body += chunk
|
|
})
|
|
message.on('end', () => resolve(body))
|
|
message.on('error', reject)
|
|
})
|
|
}
|