import { NextApiRequest, NextApiResponse } from 'next' import { IncomingForm, File as FormidableFile } from 'formidable' import type { Fields, Files } from 'formidable' import FormData from 'form-data' import fs from 'fs' import https from 'https' import http from 'http' import { URL } from 'url' const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB export const config = { api: { bodyParser: false, // Disable bodyParser to handle multipart }, } interface ParseResult { fields: Fields files: Files } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } function getFirstFile(files: Files, fieldName: string): FormidableFile | null { const value = files[fieldName] if (!value) { return null } if (Array.isArray(value)) { const first = value[0] return first ?? null } return value } function getFormDataHeaders(formData: FormData): http.OutgoingHttpHeaders { const raw: unknown = formData.getHeaders() if (!isRecord(raw)) { return {} } const headers: http.OutgoingHttpHeaders = {} for (const [key, value] of Object.entries(raw)) { const str = readString(value) if (str !== undefined) { headers[key] = str } } return headers } function getRedirectLocation(headers: unknown): string | undefined { if (!isRecord(headers)) { return undefined } const {location} = headers if (typeof location === 'string') { return location } if (Array.isArray(location) && typeof location[0] === 'string') { return location[0] } return undefined } export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }) } const { targetEndpoint, authToken } = readProxyQueryParams(req) const currentUrl = new URL(targetEndpoint) const fileField = await parseFileFromMultipartRequest(req) if (!fileField) { return res.status(400).json({ error: 'No file provided' }) } try { const response = await makeRequestWithRedirects({ targetEndpoint, url: currentUrl, file: fileField, authToken, redirectCount: 0, maxRedirects: 5, }) return handleProxyResponse({ res, response, targetEndpoint }) } catch (error) { return handleProxyError({ res, error, targetEndpoint, hostname: currentUrl.hostname, file: fileField }) } finally { safeUnlink(fileField.filepath) } } function readProxyQueryParams(req: NextApiRequest): { targetEndpoint: string; authToken: string | undefined } { return { targetEndpoint: (req.query.endpoint as string) ?? 'https://void.cat/upload', authToken: req.query.auth as string | undefined, } } async function parseFileFromMultipartRequest(req: NextApiRequest): Promise { const form = new IncomingForm({ maxFileSize: MAX_FILE_SIZE, keepExtensions: true }) const parseResult = await new Promise((resolve, reject) => { form.parse(req, (err, fields, files) => { if (err) { console.error('Formidable parse error:', err) reject(err) return } resolve({ fields, files }) }) }) return getFirstFile(parseResult.files, 'file') } function safeUnlink(filepath: string): void { try { fs.unlinkSync(filepath) } catch (unlinkError) { console.error('Error deleting temp file:', unlinkError) } } interface ProxyUploadResponse { statusCode: number statusMessage: string body: string headers: http.IncomingHttpHeaders finalUrl: string } async function makeRequestWithRedirects(params: { targetEndpoint: string url: URL file: FormidableFile 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, }) } 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 } async function makeRequestOnce(params: { targetEndpoint: string url: URL file: FormidableFile 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 await sendFormDataRequest({ clientModule, requestOptions, requestFormData, fileStream, targetEndpoint: params.targetEndpoint, hostname: params.url.hostname, finalUrl: params.url.toString(), filepath: params.file.filepath, }) } function buildUploadFormData(file: FormidableFile): { 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 await 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 await new Promise((resolve, reject) => { let body = '' message.setEncoding('utf8') message.on('data', (chunk) => { body += chunk }) message.on('end', () => resolve(body)) message.on('error', reject) }) } function handleProxyRequestError(params: { error: unknown; targetEndpoint: string; hostname: string }): Error { const errorMessage = params.error instanceof Error ? params.error.message : 'Unknown request error' const errorCode = getErrnoCode(params.error) if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') { console.error('NIP-95 proxy DNS error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, errorCode, errorMessage, suggestion: 'Check DNS resolution or network connectivity on the server', }) return new Error(`DNS resolution failed for ${params.hostname}: ${errorMessage}`) } return params.error instanceof Error ? params.error : new Error(errorMessage) } function handleProxyError(params: { res: NextApiResponse error: unknown targetEndpoint: string hostname: string file: FormidableFile }): void { const errorMessage = params.error instanceof Error ? params.error.message : 'Unknown request error' const isDnsError = errorMessage.includes('DNS resolution failed') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('EAI_AGAIN') console.error('NIP-95 proxy request error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, error: errorMessage, isDnsError, fileSize: params.file.size, fileName: params.file.originalFilename, suggestion: isDnsError ? 'The server cannot resolve the domain name. Check DNS configuration and network connectivity.' : undefined, }) if (isDnsError) { params.res.status(500).json({ error: `DNS resolution failed for ${params.hostname}. The server cannot resolve the domain name. Please check DNS configuration and network connectivity.`, }) return } params.res.status(500).json({ error: `Failed to connect to upload endpoint: ${errorMessage}` }) } function handleProxyResponse(params: { res: NextApiResponse response: ProxyUploadResponse targetEndpoint: string }): void { if (params.response.statusCode < 200 || params.response.statusCode >= 300) { return respondNonOk({ res: params.res, response: params.response, targetEndpoint: params.targetEndpoint }) } if (isHtmlResponse(params.response.body)) { return respondHtml({ res: params.res, response: params.response, targetEndpoint: params.targetEndpoint }) } const parseResult = parseJsonSafe(params.response.body) if (!parseResult.ok) { console.error('NIP-95 proxy JSON parse error:', { targetEndpoint: params.targetEndpoint, bodyPreview: params.response.body.substring(0, 100), }) params.res.status(500).json({ error: `Invalid upload response: Invalid JSON response. The endpoint may not be a valid NIP-95 upload endpoint.`, }) return } params.res.status(200).json(parseResult.value) } function respondNonOk(params: { res: NextApiResponse; response: ProxyUploadResponse; targetEndpoint: string }): void { const errorText = params.response.body.substring(0, 200) console.error('NIP-95 proxy response error:', { targetEndpoint: params.targetEndpoint, finalUrl: params.response.finalUrl, status: params.response.statusCode, statusText: params.response.statusMessage, errorText, }) params.res.status(params.response.statusCode).json({ error: buildUserFriendlyHttpError(params.response.statusCode, params.response.statusMessage, errorText), }) } function buildUserFriendlyHttpError(statusCode: number, statusMessage: string, errorText: string): string { if (statusCode === 401) { return 'Authentication required. This endpoint requires authorization headers.' } if (statusCode === 403) { return 'Access forbidden. This endpoint may require authentication or have restrictions.' } if (statusCode === 405) { return 'Method not allowed. This endpoint may not support POST requests or the URL may be incorrect.' } if (statusCode === 413) { return 'File too large. The file exceeds the maximum size allowed by this endpoint.' } if (statusCode >= 500) { return `Server error (${statusCode}). The endpoint server encountered an error.` } return errorText || `Upload failed: ${statusCode} ${statusMessage}` } function isHtmlResponse(body: string): boolean { const trimmedBody = body.trim() return trimmedBody.startsWith(']*>([^<]+)<\/title>/i)?.[1] } function readHtmlH1(body: string): string | undefined { return body.match(/]*>([^<]+)<\/h1>/i)?.[1] } function detectHtmlErrorFlags(params: { body: string; title: string | undefined }): { is404: boolean; is403: boolean; is500: boolean } { const is404 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '404', marker: 'Not Found' }) const is403 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '403', marker: 'Forbidden' }) const is500 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '500', marker: 'Internal Server Error' }) return { is404, is403, is500 } } function isHtmlErrorForCode(params: { body: string; title: string | undefined; code: string; marker: string }): boolean { const titleHasCode = params.title?.includes(params.code) === true return params.body.includes(params.code) || params.body.includes(params.marker) || titleHasCode } function buildHtmlErrorSuggestion(params: { is404: boolean; is403: boolean; is500: boolean }): string { if (params.is404) { return 'The endpoint URL may be incorrect or the endpoint does not exist' } if (params.is403) { return 'The endpoint may require authentication or have access restrictions' } if (params.is500) { return 'The endpoint server encountered an error' } return 'The endpoint may not be a valid NIP-95 upload endpoint or may require specific headers' } function getErrnoCode(error: unknown): string | undefined { if (typeof error !== 'object' || error === null) { return undefined } const maybe = error as { code?: unknown } return typeof maybe.code === 'string' ? maybe.code : undefined }