story-research-zapwall/pages/api/nip95-upload.ts
2026-01-09 08:45:54 +01:00

437 lines
16 KiB
TypeScript

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<string, unknown> {
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<void> {
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) => {
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('<!DOCTYPE') || body.trim().startsWith('<html') || body.trim().startsWith('<!'),
})
}
resolve({
statusCode,
statusMessage: proxyResponse.statusMessage ?? 'Internal Server Error',
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 = 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('<!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',
})
}
}
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
}