2026-01-13 14:49:19 +01:00

145 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)
})
}