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 { 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 { 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 { return new Promise((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 { 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 { return new Promise((resolve, reject) => { let body = '' message.setEncoding('utf8') message.on('data', (chunk) => { body += chunk }) message.on('end', () => resolve(body)) message.on('error', reject) }) }