story-research-zapwall/pages/api/nip95-upload.ts
2026-01-06 00:26:31 +01:00

376 lines
15 KiB
TypeScript

import { NextApiRequest, NextApiResponse } from 'next'
import { IncomingForm, File as FormidableFile } 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: Record<string, string[]>
files: Record<string, FormidableFile[]>
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
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<ParseResult>((resolve, reject) => {
// Cast req to any to work with formidable - NextApiRequest extends IncomingMessage
form.parse(req as any, (err, fields, files) => {
if (err) {
console.error('Formidable parse error:', err)
reject(err)
} else {
resolve({ fields: fields as Record<string, string[]>, files: files as Record<string, FormidableFile[]> })
}
})
})
const { files } = parseResult
// Get the file from the parsed form
const fileField = files.file?.[0]
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, fileField: FormidableFile, authToken?: 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(fileField.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: fileField.originalFilename || fileField.newFilename || 'upload',
contentType: fileField.mimetype || 'application/octet-stream',
})
const isHttps = url.protocol === 'https:'
const clientModule = isHttps ? https : http
const headers = requestFormData.getHeaders()
// 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 (authToken) {
headers['Authorization'] = `Nostr ${authToken}`
}
// Log request details for debugging (only for problematic endpoints)
if (url.hostname.includes('nostrimg.com')) {
console.log('NIP-95 proxy request to nostrimg.com:', {
url: url.toString(),
method: 'POST',
fieldName,
filename: fileField.originalFilename || fileField.newFilename || 'upload',
contentType: fileField.mimetype || 'application/octet-stream',
fileSize: fileField.size,
headers: {
'Content-Type': headers['content-type'],
'Accept': headers['Accept'],
'User-Agent': headers['User-Agent'],
'Authorization': authToken ? '[present]' : '[absent]',
},
})
}
const requestOptions = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method: 'POST',
headers: headers,
timeout: 30000, // 30 seconds timeout
}
const proxyRequest = clientModule.request(requestOptions, (proxyResponse) => {
// 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 = proxyResponse.headers.location
let redirectUrl: URL
try {
// Handle relative and absolute URLs
redirectUrl = new URL(location, url.toString())
console.log('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, fileField, authToken)
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.log('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('<!DOCTYPE') || body.trim().startsWith('<html') || body.trim().startsWith('<!'),
})
}
resolve({
statusCode: statusCode,
statusMessage: proxyResponse.statusMessage || 'Internal Server Error',
body: body,
})
})
proxyResponse.on('error', (error) => {
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 = (error as NodeJS.ErrnoException).code
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: fileField.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: 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('<!DOCTYPE') || trimmedBody.startsWith('<html') || trimmedBody.startsWith('<!')
if (isHtml) {
// Try to extract error message from HTML if possible
const titleMatch = response.body.match(/<title[^>]*>([^<]+)<\/title>/i)
const h1Match = response.body.match(/<h1[^>]*>([^<]+)<\/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',
})
}
}