**Motivations:**
- Diagnose why nostrimg.com returns 500 errors
- Verify if the code is causing the errors or if it's a server-side issue
- Log request and response details for troubleshooting
**Root causes:**
- Unknown: Could be code issue (wrong field name, headers, format) or server issue
- Need detailed logs to determine the cause
**Correctifs:**
- Added detailed request logging for nostrimg.com:
- URL, method, field name, filename, content type, file size
- All headers (Content-Type, Accept, User-Agent, Authorization)
- Added detailed response logging for nostrimg.com:
- Status code, status message, response headers
- Body preview (500 chars), body length, HTML detection
- Added comment about field name ('file') - some endpoints might need different names
**Evolutions:**
- None
**Pages affectées:**
- pages/api/nip95-upload.ts
361 lines
14 KiB
TypeScript
361 lines
14 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) => {
|
|
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',
|
|
})
|
|
}
|
|
}
|