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