import type { MediaRef } from '@/types/nostr' import { getEnabledNip95Apis } from './config' import { generateNip98Token, isNip98Available } from './nip98' import { nostrService } from './nostr' import { nostrAuthService } from './nostrAuth' 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'] interface UnlockRequiredError extends Error { unlockRequired: true } function createUnlockRequiredError(): UnlockRequiredError { const error = Object.assign(new Error('UNLOCK_REQUIRED'), { unlockRequired: true as const }) return error } function isUnlockRequiredError(error: Error): error is UnlockRequiredError { return (error as Partial).unlockRequired === true } 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 const fromVoidCat = readVoidCatUploadUrl(obj) if (fromVoidCat) { return fromVoidCat } const fromNostrcheck = readNostrcheckUploadUrl(obj) if (fromNostrcheck) { return fromNostrcheck } const fromStandard = readStandardUploadUrl(obj) if (fromStandard) { return fromStandard } console.error('NIP-95 upload missing URL:', { endpoint, response: result, }) throw new Error('Upload response missing URL') } function readVoidCatUploadUrl(obj: Record): string | undefined { if (!('ok' in obj) || obj.ok !== true || !('file' in obj)) { return undefined } if (typeof obj.file !== 'object' || obj.file === null) { return undefined } const file = obj.file as Record return typeof file.url === 'string' ? file.url : undefined } function readNostrcheckUploadUrl(obj: Record): string | undefined { if (!('status' in obj) || obj.status !== 'success') { return undefined } return typeof obj.url === 'string' ? obj.url : undefined } function readStandardUploadUrl(obj: Record): string | undefined { return typeof obj.url === 'string' ? obj.url : undefined } /** * 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 { const targetUrl = useProxy ? endpoint : endpoint const response = await globalThis.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 { 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 { 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) { const upload = await attemptUploadToEndpoint({ endpoint, formData, mediaType, file }) if (upload) { return upload } lastError = new Error(`Upload failed for endpoint: ${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') } async function attemptUploadToEndpoint(params: { endpoint: string formData: FormData mediaType: MediaRef['type'] file: File }): Promise { try { const needsAuth = params.endpoint.includes('nostrcheck.me') const authToken = await resolveNip98AuthToken({ endpoint: params.endpoint, needsAuth }) if (needsAuth && !authToken) { return null } const proxyUrlParams = new URLSearchParams({ endpoint: params.endpoint }) if (authToken) { proxyUrlParams.set('auth', authToken) } const proxyUrl = `/api/nip95-upload?${proxyUrlParams.toString()}` const url = await tryUploadEndpoint(proxyUrl, params.formData, true) return { url, type: params.mediaType } } catch (e) { const error = e instanceof Error ? e : new Error(String(e)) if (error.message === 'UNLOCK_REQUIRED' || isUnlockRequiredError(error)) { throw error } console.error('NIP-95 upload endpoint error:', { endpoint: params.endpoint, error: error.message, fileSize: params.file.size, fileType: params.file.type, }) return null } } async function resolveNip98AuthToken(params: { endpoint: string; needsAuth: boolean }): Promise { if (!params.needsAuth) { return undefined } if (!isNip98Available()) { const pubkey = nostrService.getPublicKey() if (!pubkey) { console.warn('NIP-98 authentication required for nostrcheck.me but no account found. Please create or import an account.') return undefined } const isUnlocked = nostrAuthService.isUnlocked() if (!isUnlocked) { throw createUnlockRequiredError() } console.warn('NIP-98 authentication required for nostrcheck.me but not available. Skipping endpoint.') return undefined } try { // Generate NIP-98 token for the actual endpoint (not the proxy) // The token must be for the final destination URL return await generateNip98Token('POST', params.endpoint) } catch (authError) { console.error('Failed to generate NIP-98 token:', authError) return undefined } }