story-research-zapwall/pages/api/nip95-upload.ts
2026-01-10 09:41:57 +01:00

537 lines
18 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' })
}
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<FormidableFile | null> {
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)
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<ProxyUploadResponse> {
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<ProxyUploadResponse> {
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<ProxyUploadResponse> {
return await new Promise<ProxyUploadResponse>((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<ProxyUploadResponse> {
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<string> {
return await new Promise<string>((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('<!DOCTYPE') || trimmedBody.startsWith('<html') || trimmedBody.startsWith('<!')
}
function respondHtml(params: { res: NextApiResponse; response: ProxyUploadResponse; targetEndpoint: string }): void {
const title = readHtmlTitle(params.response.body)
const h1 = readHtmlH1(params.response.body)
const errorText = title ?? h1 ?? 'HTML error page returned'
const flags = detectHtmlErrorFlags({ body: params.response.body, title })
console.error('NIP-95 proxy HTML response error:', {
targetEndpoint: params.targetEndpoint,
finalUrl: params.response.finalUrl,
status: params.response.statusCode,
errorText,
is404: flags.is404,
is403: flags.is403,
is500: flags.is500,
bodyPreview: params.response.body.substring(0, 500),
contentType: 'HTML (expected JSON)',
suggestion: buildHtmlErrorSuggestion(flags),
})
params.res.status(500).json({
error: buildHtmlUserMessage({ is404: flags.is404, is403: flags.is403, is500: flags.is500, finalUrl: params.response.finalUrl, errorText }),
})
}
function buildHtmlUserMessage(params: { is404: boolean; is403: boolean; is500: boolean; finalUrl: string; errorText: string }): string {
if (params.is404) {
return `Endpoint not found (404). The URL may be incorrect: ${params.finalUrl}`
}
if (params.is403) {
return `Access forbidden (403). The endpoint may require authentication or have restrictions.`
}
if (params.is500) {
return `Server error (500). The endpoint server encountered an error.`
}
return `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: ${params.errorText}`
}
type JsonParseResult = { ok: true; value: unknown } | { ok: false }
function parseJsonSafe(body: string): JsonParseResult {
try {
return { ok: true, value: JSON.parse(body) as unknown }
} catch {
return { ok: false }
}
}
function readHtmlTitle(body: string): string | undefined {
return body.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]
}
function readHtmlH1(body: string): string | undefined {
return body.match(/<h1[^>]*>([^<]+)<\/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
}