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' }) } // Get target endpoint and auth token from query const targetEndpoint = (req.query.endpoint as string) ?? 'https://void.cat/upload' const authToken = req.query.auth as string | undefined try { // Parse multipart form data // formidable needs the raw Node.js IncomingMessage, which NextApiRequest extends 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) } else { resolve({ fields, files }) } }) }) const { files } = parseResult // Get the file from the parsed form const fileField = getFirstFile(files, 'file') if (!fileField) { return res.status(400).json({ error: 'No file provided' }) } // Forward to target endpoint using https/http native modules // Support redirects (301, 302, 307, 308) const currentUrl = new URL(targetEndpoint) const MAX_REDIRECTS = 5 let response: { statusCode: number; statusMessage: string; body: string } try { response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => { function makeRequest(url: URL, redirectCount: number, file: FormidableFile, token?: string): void { if (redirectCount > MAX_REDIRECTS) { reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`)) return } // Recreate FormData for each request (needed for redirects) const requestFormData = new FormData() const fileStream = fs.createReadStream(file.filepath) // Use 'file' as field name (standard for NIP-95, but some endpoints may use different names) // Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload' const fieldName = 'file' requestFormData.append(fieldName, fileStream, { filename: file.originalFilename ?? file.newFilename ?? 'upload', contentType: file.mimetype ?? 'application/octet-stream', }) const isHttps = url.protocol === 'https:' const clientModule = isHttps ? https : http const headers = getFormDataHeaders(requestFormData) // Add standard headers that some endpoints require headers['Accept'] = 'application/json' headers['User-Agent'] = 'zapwall.fr/1.0' // Add NIP-98 Authorization header if token is provided if (token) { headers['Authorization'] = `Nostr ${token}` } // Log request details for debugging (only for problematic endpoints) if (url.hostname.includes('nostrimg.com')) { console.warn('NIP-95 proxy request to nostrimg.com:', { url: url.toString(), method: 'POST', fieldName, filename: file.originalFilename ?? file.newFilename ?? 'upload', contentType: file.mimetype ?? 'application/octet-stream', fileSize: file.size, headers: { 'Content-Type': headers['content-type'], 'Accept': headers['Accept'], 'User-Agent': headers['User-Agent'], 'Authorization': token ? '[present]' : '[absent]', }, }) } const requestOptions: http.RequestOptions = { hostname: url.hostname, port: url.port ?? (isHttps ? 443 : 80), path: url.pathname + url.search, method: 'POST', headers, timeout: 30000, // 30 seconds timeout } const proxyRequest = clientModule.request(requestOptions, (proxyResponse: http.IncomingMessage) => { // Handle redirects (301, 302, 307, 308) const statusCode = proxyResponse.statusCode ?? 500 if ((statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) && proxyResponse.headers.location) { const location = getRedirectLocation(proxyResponse.headers as unknown) if (!location) { reject(new Error('Redirect response missing location header')) return } let redirectUrl: URL try { // Handle relative and absolute URLs redirectUrl = new URL(location, url.toString()) console.warn('NIP-95 proxy redirect:', { from: url.toString(), to: redirectUrl.toString(), statusCode, redirectCount: redirectCount + 1, }) // Drain the response before redirecting proxyResponse.resume() // Make new request to redirect location (preserve auth token for redirects) makeRequest(redirectUrl, redirectCount + 1, file, token) return } catch (urlError) { console.error('NIP-95 proxy invalid redirect URL:', { location, error: urlError instanceof Error ? urlError.message : 'Unknown error', }) reject(new Error(`Invalid redirect URL: ${location}`)) return } } let body = '' proxyResponse.setEncoding('utf8') proxyResponse.on('data', (chunk) => { body += chunk }) proxyResponse.on('end', () => { // Log response details for debugging problematic endpoints if (url.hostname.includes('nostrimg.com')) { console.warn('NIP-95 proxy response from nostrimg.com:', { url: url.toString(), statusCode, statusMessage: proxyResponse.statusMessage, responseHeaders: { 'content-type': proxyResponse.headers['content-type'], 'content-length': proxyResponse.headers['content-length'], }, bodyPreview: body.substring(0, 500), bodyLength: body.length, isHtml: body.trim().startsWith(' { reject(error) }) }) // Set timeout on the request proxyRequest.setTimeout(30000, () => { proxyRequest.destroy() reject(new Error('Request timeout after 30 seconds')) }) proxyRequest.on('error', (error) => { // Check for DNS errors specifically const errorCode = getErrnoCode(error) if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') { console.error('NIP-95 proxy DNS error:', { targetEndpoint, hostname: url.hostname, errorCode, errorMessage: error.message, suggestion: 'Check DNS resolution or network connectivity on the server', }) reject(new Error(`DNS resolution failed for ${url.hostname}: ${error.message}`)) } else { reject(error) } }) requestFormData.on('error', (error) => { console.error('NIP-95 proxy FormData error:', { targetEndpoint, hostname: url.hostname, error: error instanceof Error ? error.message : 'Unknown FormData error', }) reject(error) }) fileStream.on('error', (error) => { console.error('NIP-95 proxy file stream error:', { targetEndpoint, hostname: url.hostname, filepath: file.filepath, error: error instanceof Error ? error.message : 'Unknown file stream error', }) reject(error) }) requestFormData.pipe(proxyRequest) } makeRequest(currentUrl, 0, fileField, authToken) }) } catch (requestError) { // Clean up temporary file before returning error try { fs.unlinkSync(fileField.filepath) } catch (unlinkError) { console.error('Error deleting temp file:', unlinkError) } const errorMessage = requestError instanceof Error ? requestError.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, hostname: currentUrl.hostname, error: errorMessage, isDnsError, fileSize: fileField.size, fileName: fileField.originalFilename, suggestion: isDnsError ? 'The server cannot resolve the domain name. Check DNS configuration and network connectivity.' : undefined, }) // Return a more specific error message for DNS issues if (isDnsError) { return res.status(500).json({ error: `DNS resolution failed for ${currentUrl.hostname}. The server cannot resolve the domain name. Please check DNS configuration and network connectivity.`, }) } return res.status(500).json({ error: `Failed to connect to upload endpoint: ${errorMessage}`, }) } // Clean up temporary file try { fs.unlinkSync(fileField.filepath) } catch (unlinkError) { console.error('Error deleting temp file:', unlinkError) } if (response.statusCode < 200 || response.statusCode >= 300) { const errorText = response.body.substring(0, 200) // Limit log size console.error('NIP-95 proxy response error:', { targetEndpoint, finalUrl: currentUrl.toString(), status: response.statusCode, statusText: response.statusMessage, errorText, }) // Provide more specific error messages for common HTTP status codes let userFriendlyError = errorText || `Upload failed: ${response.statusCode} ${response.statusMessage}` if (response.statusCode === 401) { userFriendlyError = 'Authentication required. This endpoint requires authorization headers.' } else if (response.statusCode === 403) { userFriendlyError = 'Access forbidden. This endpoint may require authentication or have restrictions.' } else if (response.statusCode === 405) { userFriendlyError = 'Method not allowed. This endpoint may not support POST requests or the URL may be incorrect.' } else if (response.statusCode === 413) { userFriendlyError = 'File too large. The file exceeds the maximum size allowed by this endpoint.' } else if (response.statusCode >= 500) { userFriendlyError = `Server error (${response.statusCode}). The endpoint server encountered an error.` } return res.status(response.statusCode).json({ error: userFriendlyError, }) } // Check if response is HTML (error page) instead of JSON const trimmedBody = response.body.trim() const isHtml = trimmedBody.startsWith(']*>([^<]+)<\/title>/i) const h1Match = response.body.match(/]*>([^<]+)<\/h1>/i) const errorText = titleMatch?.[1] ?? h1Match?.[1] ?? 'HTML error page returned' // Check if it's a 404 or other error page const is404 = response.body.includes('404') || response.body.includes('Not Found') || titleMatch?.[1]?.includes('404') const is403 = response.body.includes('403') || response.body.includes('Forbidden') || titleMatch?.[1]?.includes('403') const is500 = response.body.includes('500') || response.body.includes('Internal Server Error') || titleMatch?.[1]?.includes('500') console.error('NIP-95 proxy HTML response error:', { targetEndpoint, finalUrl: currentUrl.toString(), status: response.statusCode, errorText, is404, is403, is500, bodyPreview: response.body.substring(0, 500), contentType: 'HTML (expected JSON)', suggestion: is404 ? 'The endpoint URL may be incorrect or the endpoint does not exist' : is403 ? 'The endpoint may require authentication or have access restrictions' : is500 ? 'The endpoint server encountered an error' : 'The endpoint may not be a valid NIP-95 upload endpoint or may require specific headers', }) let userMessage = `Endpoint returned an HTML error page instead of JSON` if (is404) { userMessage = `Endpoint not found (404). The URL may be incorrect: ${currentUrl.toString()}` } else if (is403) { userMessage = `Access forbidden (403). The endpoint may require authentication or have restrictions.` } else if (is500) { userMessage = `Server error (500). The endpoint server encountered an error.` } else { userMessage = `Endpoint returned an HTML error page instead of JSON. The endpoint may be unavailable, the URL may be incorrect, or specific headers may be required. Error: ${errorText}` } return res.status(500).json({ error: userMessage, }) } let result: unknown try { result = JSON.parse(response.body) } catch (parseError) { const errorMessage = parseError instanceof Error ? parseError.message : 'Invalid JSON response' console.error('NIP-95 proxy JSON parse error:', { targetEndpoint, error: errorMessage, bodyPreview: response.body.substring(0, 100), }) return res.status(500).json({ error: `Invalid upload response: ${errorMessage}. The endpoint may not be a valid NIP-95 upload endpoint.`, }) } return res.status(200).json(result) } catch (error) { console.error('NIP-95 proxy error:', error) return res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error', }) } } 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 }