173 lines
5.5 KiB
TypeScript
173 lines
5.5 KiB
TypeScript
import type { MediaRef } from '@/types/nostr'
|
|
import { getEnabledNip95Apis } from './config'
|
|
|
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
|
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
|
|
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']
|
|
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']
|
|
|
|
function assertBrowser(): void {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('NIP-95 upload is only available in the browser')
|
|
}
|
|
}
|
|
|
|
function validateFile(file: File): MediaRef['type'] {
|
|
if (IMAGE_TYPES.includes(file.type)) {
|
|
if (file.size > MAX_IMAGE_BYTES) {
|
|
throw new Error('Image exceeds 5MB limit')
|
|
}
|
|
return 'image'
|
|
}
|
|
if (VIDEO_TYPES.includes(file.type)) {
|
|
if (file.size > MAX_VIDEO_BYTES) {
|
|
throw new Error('Video exceeds 45MB limit')
|
|
}
|
|
return 'video'
|
|
}
|
|
throw new Error('Unsupported media type')
|
|
}
|
|
|
|
/**
|
|
* Parse upload response from different NIP-95 providers
|
|
* Supports multiple formats:
|
|
* - Standard format: { url: string }
|
|
* - void.cat format: { ok: true, file: { id, url } }
|
|
* - nostrcheck.me format: { url: string } or { status: 'success', url: string }
|
|
*/
|
|
function parseUploadResponse(result: unknown, endpoint: string): string {
|
|
if (typeof result !== 'object' || result === null) {
|
|
throw new Error('Invalid upload response format')
|
|
}
|
|
|
|
const obj = result as Record<string, unknown>
|
|
|
|
// void.cat format: { ok: true, file: { id, url } }
|
|
if ('ok' in obj && obj.ok === true && 'file' in obj) {
|
|
const file = obj.file as Record<string, unknown>
|
|
if (typeof file.url === 'string') {
|
|
return file.url
|
|
}
|
|
}
|
|
|
|
// nostrcheck.me format: { status: 'success', url: string }
|
|
if ('status' in obj && obj.status === 'success' && 'url' in obj && typeof obj.url === 'string') {
|
|
return obj.url
|
|
}
|
|
|
|
// Standard format: { url: string }
|
|
if ('url' in obj && typeof obj.url === 'string') {
|
|
return obj.url
|
|
}
|
|
|
|
console.error('NIP-95 upload missing URL:', {
|
|
endpoint,
|
|
response: result,
|
|
})
|
|
throw new Error('Upload response missing URL')
|
|
}
|
|
|
|
/**
|
|
* Try uploading to a single endpoint
|
|
* Uses proxy API route for endpoints that have CORS issues
|
|
*/
|
|
async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise<string> {
|
|
const targetUrl = useProxy ? endpoint : endpoint
|
|
|
|
const response = await fetch(targetUrl, {
|
|
method: 'POST',
|
|
body: formData,
|
|
// Don't set Content-Type manually - browser will set it with boundary automatically
|
|
})
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Upload failed'
|
|
try {
|
|
const text = await response.text()
|
|
errorMessage = text || `HTTP ${response.status} ${response.statusText}`
|
|
} catch (_e) {
|
|
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
|
}
|
|
throw new Error(errorMessage)
|
|
}
|
|
|
|
let result: unknown
|
|
try {
|
|
result = await response.json()
|
|
} catch (e) {
|
|
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON response'
|
|
throw new Error(`Invalid upload response: ${errorMessage}`)
|
|
}
|
|
|
|
return parseUploadResponse(result, endpoint)
|
|
}
|
|
|
|
/**
|
|
* Upload media via NIP-95.
|
|
* Tries all enabled endpoints in order until one succeeds.
|
|
* This implementation validates size/type then delegates to a pluggable uploader.
|
|
*/
|
|
export async function uploadNip95Media(file: File): Promise<MediaRef> {
|
|
assertBrowser()
|
|
const mediaType = validateFile(file)
|
|
|
|
const endpoints = await getEnabledNip95Apis()
|
|
if (endpoints.length === 0) {
|
|
throw new Error(
|
|
'NIP-95 upload endpoint is not configured. Please configure a NIP-95 API endpoint in the application settings.'
|
|
)
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
let lastError: Error | null = null
|
|
for (const endpoint of endpoints) {
|
|
try {
|
|
// Try direct upload first
|
|
const url = await tryUploadEndpoint(endpoint, formData, false)
|
|
return { url, type: mediaType }
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e : new Error(String(e))
|
|
const errorMessage = error.message
|
|
|
|
// If CORS error, network error, or 405 error, try via proxy
|
|
const isCorsError = errorMessage.includes('CORS') || errorMessage.includes('Failed to fetch')
|
|
const isMethodNotAllowed = errorMessage.includes('405') || errorMessage.includes('Method Not Allowed')
|
|
if (isCorsError || isMethodNotAllowed) {
|
|
try {
|
|
console.log('Trying upload via proxy due to error:', endpoint, isCorsError ? 'CORS' : 'Method Not Allowed')
|
|
// Pass endpoint as query parameter to proxy
|
|
const proxyUrl = `/api/nip95-upload?endpoint=${encodeURIComponent(endpoint)}`
|
|
const url = await tryUploadEndpoint(proxyUrl, formData, true)
|
|
return { url, type: mediaType }
|
|
} catch (proxyError) {
|
|
console.error('NIP-95 upload proxy error:', {
|
|
endpoint,
|
|
error: proxyError instanceof Error ? proxyError.message : String(proxyError),
|
|
fileSize: file.size,
|
|
fileType: file.type,
|
|
})
|
|
lastError = proxyError instanceof Error ? proxyError : new Error(String(proxyError))
|
|
continue
|
|
}
|
|
}
|
|
|
|
console.error('NIP-95 upload endpoint error:', {
|
|
endpoint,
|
|
error: errorMessage,
|
|
fileSize: file.size,
|
|
fileType: file.type,
|
|
})
|
|
lastError = error
|
|
// Continue to next endpoint
|
|
}
|
|
}
|
|
|
|
// All endpoints failed
|
|
if (lastError) {
|
|
throw new Error(`Failed to upload to all endpoints: ${lastError.message}`)
|
|
}
|
|
throw new Error('Failed to upload: no endpoints available')
|
|
}
|