media-upload.service.ts raw

   1  import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants'
   2  import { generateImageVariants, isSupportedImage, getExtensionFromMimeType } from '@/lib/image-scaler'
   3  import {
   4    createResponsiveImageEvent,
   5    UploadedVariant
   6  } from '@/lib/responsive-image-event'
   7  import { simplifyUrl } from '@/lib/url'
   8  import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
   9  import { BlossomClient } from 'blossom-client-sdk'
  10  import { VerifiedEvent } from 'nostr-tools'
  11  import { z } from 'zod'
  12  import client from './client.service'
  13  import storage from './local-storage.service'
  14  
  15  type UploadOptions = {
  16    onProgress?: (progressPercent: number) => void
  17    signal?: AbortSignal
  18  }
  19  
  20  type ResponsiveUploadOptions = UploadOptions & {
  21    /** Description/caption for the image */
  22    description?: string
  23    /** Alt text for accessibility */
  24    alt?: string
  25  }
  26  
  27  export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted'
  28  
  29  class MediaUploadService {
  30    static instance: MediaUploadService
  31  
  32    private serviceConfig: TMediaUploadServiceConfig = storage.getMediaUploadServiceConfig()
  33    private nip96ServiceUploadUrlMap = new Map<string, string | undefined>()
  34    private imetaTagMap = new Map<string, string[]>()
  35  
  36    constructor() {
  37      if (!MediaUploadService.instance) {
  38        MediaUploadService.instance = this
  39      }
  40      return MediaUploadService.instance
  41    }
  42  
  43    setServiceConfig(config: TMediaUploadServiceConfig) {
  44      this.serviceConfig = config
  45    }
  46  
  47    async upload(file: File, options?: UploadOptions) {
  48      let result: { url: string; tags: string[][] }
  49      if (this.serviceConfig.type === 'nip96') {
  50        result = await this.uploadByNip96(this.serviceConfig.service, file, options)
  51      } else {
  52        result = await this.uploadByBlossom(file, options)
  53      }
  54  
  55      if (result.tags.length > 0) {
  56        this.imetaTagMap.set(result.url, ['imeta', ...result.tags.map(([n, v]) => `${n} ${v}`)])
  57      }
  58      return result
  59    }
  60  
  61    private async uploadByBlossom(file: File, options?: UploadOptions) {
  62      const pubkey = client.pubkey
  63      const signer = async (draft: TDraftEvent) => {
  64        if (!client.signer) {
  65          throw new Error('You need to be logged in to upload media')
  66        }
  67        return client.signer.signEvent(draft)
  68      }
  69      if (!pubkey) {
  70        throw new Error('You need to be logged in to upload media')
  71      }
  72  
  73      if (options?.signal?.aborted) {
  74        throw new Error(UPLOAD_ABORTED_ERROR_MSG)
  75      }
  76  
  77      options?.onProgress?.(0)
  78  
  79      // Pseudo-progress: advance gradually until main upload completes
  80      let pseudoProgress = 1
  81      let pseudoTimer: number | undefined
  82      const startPseudoProgress = () => {
  83        if (pseudoTimer !== undefined) return
  84        pseudoTimer = window.setInterval(() => {
  85          // Cap pseudo progress to 90% until we get real completion
  86          pseudoProgress = Math.min(pseudoProgress + 3, 90)
  87          options?.onProgress?.(pseudoProgress)
  88          if (pseudoProgress >= 90) {
  89            stopPseudoProgress()
  90          }
  91        }, 300)
  92      }
  93      const stopPseudoProgress = () => {
  94        if (pseudoTimer !== undefined) {
  95          clearInterval(pseudoTimer)
  96          pseudoTimer = undefined
  97        }
  98      }
  99      startPseudoProgress()
 100  
 101      let servers = await client.fetchBlossomServerList(pubkey)
 102      // Add recommended servers as fallback
 103      const uniqueServers = new Set(servers)
 104      RECOMMENDED_BLOSSOM_SERVERS.forEach((s) => uniqueServers.add(s))
 105      servers = Array.from(uniqueServers)
 106  
 107      if (servers.length === 0) {
 108        throw new Error('No Blossom services available')
 109      }
 110      const [mainServer, ...mirrorServers] = servers
 111  
 112      const auth = await BlossomClient.createUploadAuth(signer, file, {
 113        message: 'Uploading media file'
 114      })
 115  
 116      // Try each server until one succeeds
 117      let blob: { url: string; sha256?: string; size?: number; type?: string; nip94?: string[][] } | undefined
 118      let lastError: Error | undefined
 119      const allServers = [mainServer, ...mirrorServers]
 120  
 121      // Upload with timeout using XMLHttpRequest (works better on mobile)
 122      const uploadWithXHR = (server: string): Promise<typeof blob> => {
 123        return new Promise((resolve, reject) => {
 124          const xhr = new XMLHttpRequest()
 125          const uploadUrl = server.replace(/\/$/, '') + '/upload'
 126  
 127          xhr.open('PUT', uploadUrl)
 128          xhr.setRequestHeader('Authorization', 'Nostr ' + btoa(JSON.stringify(auth)))
 129          xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
 130          xhr.responseType = 'json'
 131          xhr.timeout = 15000 // 15 second timeout per server
 132  
 133          const handleAbort = () => {
 134            xhr.abort()
 135            reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
 136          }
 137          if (options?.signal) {
 138            if (options.signal.aborted) return handleAbort()
 139            options.signal.addEventListener('abort', handleAbort, { once: true })
 140          }
 141  
 142          xhr.ontimeout = () => reject(new Error('Upload timed out'))
 143          xhr.onerror = () => reject(new Error('Network error'))
 144          xhr.onload = () => {
 145            if (xhr.status >= 200 && xhr.status < 300) {
 146              const data = xhr.response
 147              if (data?.url) {
 148                resolve({
 149                  url: data.url,
 150                  sha256: data.sha256,
 151                  size: data.size,
 152                  type: data.type,
 153                  nip94: data.nip94
 154                })
 155              } else {
 156                reject(new Error('No URL in response'))
 157              }
 158            } else {
 159              reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`))
 160            }
 161          }
 162          xhr.send(file)
 163        })
 164      }
 165  
 166      for (const server of allServers) {
 167        try {
 168          // Try XHR first (better mobile support)
 169          blob = await uploadWithXHR(server)
 170          break
 171        } catch (xhrErr) {
 172          console.error(`Blossom XHR upload failed for ${server}:`, xhrErr)
 173          // Fallback to SDK with timeout
 174          try {
 175            const sdkPromise = BlossomClient.uploadBlob(server, file, { auth })
 176            const timeoutPromise = new Promise<never>((_, reject) =>
 177              setTimeout(() => reject(new Error('SDK upload timed out')), 15000)
 178            )
 179            const sdkBlob = await Promise.race([sdkPromise, timeoutPromise])
 180            blob = {
 181              url: sdkBlob.url,
 182              sha256: sdkBlob.sha256,
 183              size: sdkBlob.size,
 184              type: sdkBlob.type,
 185              nip94: (sdkBlob as any).nip94
 186            }
 187            break
 188          } catch (err) {
 189            console.error(`Blossom SDK upload failed for ${server}:`, err)
 190            lastError = err instanceof Error ? err : new Error(String(err))
 191          }
 192        }
 193      }
 194  
 195      if (!blob) {
 196        throw lastError ?? new Error('All Blossom servers failed')
 197      }
 198  
 199      // Main upload finished
 200      stopPseudoProgress()
 201      options?.onProgress?.(80)
 202  
 203      // Mirror to other servers (best effort) - only if we have sha256
 204      if (blob.sha256) {
 205        const successServer = blob.url ? new URL(blob.url).origin : mainServer
 206        const otherServers = allServers.filter((s) => s !== successServer)
 207        if (otherServers.length > 0) {
 208          const blobDescriptor = {
 209            url: blob.url,
 210            sha256: blob.sha256,
 211            size: blob.size ?? 0,
 212            type: blob.type ?? file.type,
 213            uploaded: Date.now()
 214          }
 215          await Promise.allSettled(
 216            otherServers.map((server) => BlossomClient.mirrorBlob(server, blobDescriptor, { auth }))
 217          )
 218        }
 219      }
 220  
 221      let tags: string[][] = []
 222      const parseResult = z.array(z.array(z.string())).safeParse((blob as any).nip94 ?? [])
 223      if (parseResult.success) {
 224        tags = parseResult.data
 225      }
 226  
 227      options?.onProgress?.(100)
 228      return { url: blob.url, tags }
 229    }
 230  
 231    private async uploadByNip96(service: string, file: File, options?: UploadOptions) {
 232      if (options?.signal?.aborted) {
 233        throw new Error(UPLOAD_ABORTED_ERROR_MSG)
 234      }
 235      let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
 236      if (!uploadUrl) {
 237        const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
 238        if (!response.ok) {
 239          throw new Error(
 240            `${simplifyUrl(service)} does not work, please try another service in your settings`
 241          )
 242        }
 243        const data = await response.json()
 244        uploadUrl = data?.api_url
 245        if (!uploadUrl) {
 246          throw new Error(
 247            `${simplifyUrl(service)} does not work, please try another service in your settings`
 248          )
 249        }
 250        this.nip96ServiceUploadUrlMap.set(service, uploadUrl)
 251      }
 252  
 253      if (options?.signal?.aborted) {
 254        throw new Error(UPLOAD_ABORTED_ERROR_MSG)
 255      }
 256      const formData = new FormData()
 257      formData.append('file', file)
 258  
 259      const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file')
 260  
 261      // Use XMLHttpRequest for upload progress support
 262      const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => {
 263        const xhr = new XMLHttpRequest()
 264        xhr.open('POST', uploadUrl as string)
 265        xhr.responseType = 'json'
 266        xhr.setRequestHeader('Authorization', auth)
 267  
 268        const handleAbort = () => {
 269          try {
 270            xhr.abort()
 271          } catch {
 272            // ignore
 273          }
 274          reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
 275        }
 276        if (options?.signal) {
 277          if (options.signal.aborted) {
 278            return handleAbort()
 279          }
 280          options.signal.addEventListener('abort', handleAbort, { once: true })
 281        }
 282  
 283        xhr.upload.onprogress = (event) => {
 284          if (event.lengthComputable) {
 285            const percent = Math.round((event.loaded / event.total) * 100)
 286            options?.onProgress?.(percent)
 287          }
 288        }
 289        xhr.onerror = () => reject(new Error('Network error'))
 290        xhr.onload = () => {
 291          if (xhr.status >= 200 && xhr.status < 300) {
 292            const data = xhr.response
 293            try {
 294              const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? [])
 295              const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1]
 296              if (url) {
 297                resolve({ url, tags })
 298              } else {
 299                reject(new Error('No url found'))
 300              }
 301            } catch (e) {
 302              reject(e as Error)
 303            }
 304          } else {
 305            reject(new Error(xhr.status.toString() + ' ' + xhr.statusText))
 306          }
 307        }
 308        xhr.send(formData)
 309      })
 310  
 311      return result
 312    }
 313  
 314    getImetaTagByUrl(url: string) {
 315      return this.imetaTagMap.get(url)
 316    }
 317  
 318    /**
 319     * Delete a blob from Blossom servers
 320     * First tries with server tag (for replay protection), then falls back to without
 321     * @param sha256 - The SHA256 hash of the blob to delete
 322     * @param serverUrls - Optional list of servers to delete from (defaults to user's Blossom server list)
 323     */
 324    async deleteBlob(sha256: string, serverUrls?: string[]): Promise<{ deleted: string[]; failed: string[] }> {
 325      const pubkey = client.pubkey
 326      const signer = async (draft: TDraftEvent) => {
 327        if (!client.signer) {
 328          throw new Error('You need to be logged in to delete media')
 329        }
 330        return client.signer.signEvent(draft)
 331      }
 332      if (!pubkey) {
 333        throw new Error('You need to be logged in to delete media')
 334      }
 335  
 336      // Get servers to delete from
 337      let servers = serverUrls
 338      if (!servers || servers.length === 0) {
 339        servers = await client.fetchBlossomServerList(pubkey)
 340      }
 341      if (servers.length === 0) {
 342        throw new Error('No Blossom servers configured')
 343      }
 344  
 345      const deleted: string[] = []
 346      const failed: string[] = []
 347  
 348      for (const server of servers) {
 349        const normalizedServer = server.replace(/\/$/, '')
 350        const deleteUrl = `${normalizedServer}/${sha256}`
 351  
 352        try {
 353          // First try WITH server tag (replay protection)
 354          const authWithServer = await BlossomClient.createDeleteAuth(signer, sha256, {
 355            servers: [normalizedServer],
 356            message: 'Deleting media file'
 357          })
 358  
 359          const response = await fetch(deleteUrl, {
 360            method: 'DELETE',
 361            headers: {
 362              'Authorization': 'Nostr ' + btoa(JSON.stringify(authWithServer))
 363            }
 364          })
 365  
 366          if (response.ok) {
 367            deleted.push(server)
 368            continue
 369          }
 370  
 371          // If server tag was required but missing, we'd get 401
 372          // If server tag was rejected as unknown, we might get 400 or 401
 373          // Try again WITHOUT server tag for backwards compatibility
 374          if (response.status === 400 || response.status === 401) {
 375            const authWithoutServer = await BlossomClient.createDeleteAuth(signer, sha256, {
 376              message: 'Deleting media file'
 377            })
 378  
 379            const retryResponse = await fetch(deleteUrl, {
 380              method: 'DELETE',
 381              headers: {
 382                'Authorization': 'Nostr ' + btoa(JSON.stringify(authWithoutServer))
 383              }
 384            })
 385  
 386            if (retryResponse.ok) {
 387              deleted.push(server)
 388              continue
 389            }
 390          }
 391  
 392          console.error(`Failed to delete blob from ${server}: ${response.status} ${response.statusText}`)
 393          failed.push(server)
 394        } catch (err) {
 395          console.error(`Error deleting blob from ${server}:`, err)
 396          failed.push(server)
 397        }
 398      }
 399  
 400      return { deleted, failed }
 401    }
 402  
 403    /**
 404     * Upload an image with automatic responsive variant generation
 405     *
 406     * Generates multiple resolution variants of the image (thumb, mobile, mobile-lg,
 407     * desktop, desktop-lg, original), uploads each to Blossom, and publishes a
 408     * NIP-94 File Metadata event (kind 1063) binding all variants together.
 409     *
 410     * @param file - The image file to upload
 411     * @param options - Upload options including description and alt text
 412     * @returns The published binding event and array of uploaded variants
 413     */
 414    async uploadResponsiveImage(
 415      file: File,
 416      options?: ResponsiveUploadOptions
 417    ): Promise<{ event: VerifiedEvent; variants: UploadedVariant[] }> {
 418      if (!isSupportedImage(file)) {
 419        throw new Error('Unsupported image format. Supported: JPEG, PNG, WebP, GIF')
 420      }
 421  
 422      const pubkey = client.pubkey
 423      const signer = client.signer
 424      if (!pubkey || !signer) {
 425        throw new Error('You need to be logged in to upload media')
 426      }
 427  
 428      if (options?.signal?.aborted) {
 429        throw new Error(UPLOAD_ABORTED_ERROR_MSG)
 430      }
 431  
 432      options?.onProgress?.(0)
 433  
 434      // Phase 1: Generate variants (0-30%)
 435      const scaledImages = await generateImageVariants(file, {
 436        onProgress: (percent) => {
 437          // Scale to 0-30%
 438          options?.onProgress?.(Math.round(percent * 0.3))
 439        }
 440      })
 441  
 442      if (options?.signal?.aborted) {
 443        throw new Error(UPLOAD_ABORTED_ERROR_MSG)
 444      }
 445  
 446      // Get Blossom servers
 447      let servers = await client.fetchBlossomServerList(pubkey)
 448      const uniqueServers = new Set(servers)
 449      RECOMMENDED_BLOSSOM_SERVERS.forEach((s) => uniqueServers.add(s))
 450      servers = Array.from(uniqueServers)
 451  
 452      if (servers.length === 0) {
 453        throw new Error('No Blossom services available')
 454      }
 455  
 456      // Phase 2: Upload each variant (30-90%)
 457      const uploadedVariants: UploadedVariant[] = []
 458      const progressPerVariant = 60 / scaledImages.length
 459  
 460      for (let i = 0; i < scaledImages.length; i++) {
 461        if (options?.signal?.aborted) {
 462          throw new Error(UPLOAD_ABORTED_ERROR_MSG)
 463        }
 464  
 465        const scaled = scaledImages[i]
 466        const extension = getExtensionFromMimeType(scaled.mimeType)
 467        const variantFile = new File([scaled.blob], `image-${scaled.variant}.${extension}`, {
 468          type: scaled.mimeType
 469        })
 470  
 471        // Upload this variant
 472        const result = await this.uploadSingleBlob(variantFile, servers, options?.signal)
 473  
 474        if (!result.sha256) {
 475          throw new Error(`Upload failed for ${scaled.variant} variant: no hash returned`)
 476        }
 477  
 478        uploadedVariants.push({
 479          variant: scaled.variant,
 480          url: result.url,
 481          sha256: result.sha256,
 482          width: scaled.width,
 483          height: scaled.height,
 484          mimeType: scaled.mimeType,
 485          size: scaled.blob.size
 486        })
 487  
 488        const progress = 30 + Math.round((i + 1) * progressPerVariant)
 489        options?.onProgress?.(progress)
 490      }
 491  
 492      if (options?.signal?.aborted) {
 493        throw new Error(UPLOAD_ABORTED_ERROR_MSG)
 494      }
 495  
 496      // Phase 3: Create and publish binding event (90-100%)
 497      options?.onProgress?.(90)
 498  
 499      const draftEvent = createResponsiveImageEvent(uploadedVariants, {
 500        description: options?.description,
 501        alt: options?.alt
 502      })
 503  
 504      const signedEvent = await signer.signEvent(draftEvent)
 505  
 506      // Publish to user's write relays
 507      let userRelays = client.currentRelays.length > 0 ? client.currentRelays : []
 508  
 509      // If no current relays, fetch user's relay list
 510      if (userRelays.length === 0) {
 511        try {
 512          const relayList = await client.fetchRelayList(pubkey)
 513          userRelays = relayList.write.slice(0, 10)
 514        } catch (e) {
 515          console.warn('[MediaUploadService] Failed to fetch relay list:', e)
 516        }
 517      }
 518  
 519      if (userRelays.length > 0) {
 520        await client.publishEvent(userRelays, signedEvent)
 521      } else {
 522        console.warn('[MediaUploadService] No relays available to publish binding event - event not published')
 523      }
 524  
 525      options?.onProgress?.(100)
 526  
 527      return { event: signedEvent, variants: uploadedVariants }
 528    }
 529  
 530    /**
 531     * Upload a single blob to Blossom servers (internal helper)
 532     */
 533    private async uploadSingleBlob(
 534      file: File,
 535      servers: string[],
 536      signal?: AbortSignal
 537    ): Promise<{ url: string; sha256?: string; size?: number; type?: string }> {
 538      const signer = async (draft: TDraftEvent) => {
 539        if (!client.signer) {
 540          throw new Error('You need to be logged in to upload media')
 541        }
 542        return client.signer.signEvent(draft)
 543      }
 544  
 545      const auth = await BlossomClient.createUploadAuth(signer, file, {
 546        message: 'Uploading media file'
 547      })
 548  
 549      let blob: { url: string; sha256?: string; size?: number; type?: string } | undefined
 550      let lastError: Error | undefined
 551  
 552      // Upload with timeout using XMLHttpRequest
 553      const uploadWithXHR = (server: string): Promise<typeof blob> => {
 554        return new Promise((resolve, reject) => {
 555          const xhr = new XMLHttpRequest()
 556          const uploadUrl = server.replace(/\/$/, '') + '/upload'
 557  
 558          xhr.open('PUT', uploadUrl)
 559          xhr.setRequestHeader('Authorization', 'Nostr ' + btoa(JSON.stringify(auth)))
 560          xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
 561          xhr.responseType = 'json'
 562          xhr.timeout = 30000 // 30 second timeout for variants
 563  
 564          const handleAbort = () => {
 565            xhr.abort()
 566            reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
 567          }
 568          if (signal) {
 569            if (signal.aborted) return handleAbort()
 570            signal.addEventListener('abort', handleAbort, { once: true })
 571          }
 572  
 573          xhr.ontimeout = () => reject(new Error('Upload timed out'))
 574          xhr.onerror = () => reject(new Error('Network error'))
 575          xhr.onload = () => {
 576            if (xhr.status >= 200 && xhr.status < 300) {
 577              const data = xhr.response
 578              if (data?.url) {
 579                resolve({
 580                  url: data.url,
 581                  sha256: data.sha256,
 582                  size: data.size,
 583                  type: data.type
 584                })
 585              } else {
 586                reject(new Error('No URL in response'))
 587              }
 588            } else {
 589              reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`))
 590            }
 591          }
 592          xhr.send(file)
 593        })
 594      }
 595  
 596      for (const server of servers) {
 597        try {
 598          blob = await uploadWithXHR(server)
 599          break
 600        } catch (err) {
 601          console.error(`Blossom upload failed for ${server}:`, err)
 602          lastError = err instanceof Error ? err : new Error(String(err))
 603        }
 604      }
 605  
 606      if (!blob) {
 607        throw lastError ?? new Error('All Blossom servers failed')
 608      }
 609  
 610      return blob
 611    }
 612  }
 613  
 614  const instance = new MediaUploadService()
 615  export default instance
 616