2026-01-09 13:13:24 +01:00

243 lines
7.2 KiB
TypeScript

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<UnlockRequiredError>).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<string, unknown>
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, unknown>): 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<string, unknown>
return typeof file.url === 'string' ? file.url : undefined
}
function readNostrcheckUploadUrl(obj: Record<string, unknown>): string | undefined {
if (!('status' in obj) || obj.status !== 'success') {
return undefined
}
return typeof obj.url === 'string' ? obj.url : undefined
}
function readStandardUploadUrl(obj: Record<string, unknown>): 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<string> {
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<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) {
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<MediaRef | null> {
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<string | undefined> {
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
}
}