537 lines
18 KiB
TypeScript
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
|
|
}
|