From 7bbf0f08b1922a772f9a7e5da57e897b1c0ff693 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:44:43 -0400 Subject: [PATCH] refactor(upload): extract shared MediaUploader (step 1/3 edit-upload parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract UploadModal's storage+torrent upload core into a reusable js/ui/components/mediaUploader.js (resolveActiveConnection, uploadThumbnail, uploadVideo incl. infohash/.torrent/magnet) so the Edit modal can reuse it without duplicating R2/S3 + torrent logic (and without blowing EditModal's file-size budget). UploadModal now delegates its thumbnail/video upload core to MediaUploader, keeping its own progress/state/zombie-guard wrappers — behavior preserved. UploadModal: 1319 -> 1191 lines. Build + full lint + unit suite green. NOTE: the upload flow can't be unit-tested (browser + S3/R2 deps); this is a behavior-preserving extraction pending manual re-test of a normal upload before Edit upload is layered on top (steps 2-3). Co-Authored-By: Claude Opus 4.8 --- js/ui/components/UploadModal.js | 229 ++++------------------ js/ui/components/mediaUploader.js | 304 ++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 191 deletions(-) create mode 100644 js/ui/components/mediaUploader.js diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index f3817e7d..f4344088 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1,6 +1,7 @@ // components/UploadModal.js import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; +import { MediaUploader } from "./mediaUploader.js"; import { devLogger, userLogger } from "../../utils/logger.js"; import { getVideoNoteErrorMessage, @@ -73,6 +74,15 @@ export class UploadModal { typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; this.safeEncodeNpub = typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => ""; + // Shared storage+torrent upload core (also used by the Edit modal). + this.mediaUploader = new MediaUploader({ + r2Service: this.r2Service, + s3Service: this.s3Service, + storageService: this.storageService, + getCurrentPubkey: () => + this.getCurrentPubkey ? this.getCurrentPubkey() : null, + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + }); this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget(); this.container = container || null; @@ -524,159 +534,36 @@ export class UploadModal { this.updateVideoProgress(0, "Preparing upload..."); try { - // 1. Compute Identifier (Info Hash / Fingerprint) before generating keys - const identifier = await this.resolveUploadIdentifier(file); - - if (this.videoUploadId !== currentUploadId) return; // Zombie guard - - // 2. Prepare Upload (Get Creds & Bucket) - const pubkey = this.getCurrentPubkey(); - const npub = this.safeEncodeNpub(pubkey); - const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" - ? this.r2Service - : this.s3Service; - - const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials }); - - if (this.videoUploadId !== currentUploadId) return; // Zombie guard - - // 3. Determine Keys - const videoKey = buildR2Key(npub, file, identifier); - const baseDomain = bucketEntry.publicBaseUrl; - - // Note: buildPublicUrl works for both if the base is clean - // For S3, buildS3ObjectUrl might be safer if we have complex paths, but prepareUpload standardizes on publicBaseUrl - let videoPublicUrl = ""; - if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") { - videoPublicUrl = buildPublicUrl(baseDomain, videoKey); - } else { - videoPublicUrl = buildS3ObjectUrl({ - publicBaseUrl: baseDomain, - key: videoKey, - // For S3 we might need forcePathStyle if publicBaseUrl isn't set, but prepareUpload enforces it. - }); - } - - // 3. Start Video Upload - this.updateVideoProgress(0, "Uploading video..."); - devLogger.debug("UploadModal uploadFile settings presence", { - hasAccountId: Boolean(settings.accountId), - hasEndpoint: Boolean(settings.endpoint), - hasProvider: Boolean(settings.provider), - hasRegion: Boolean(settings.region), - hasAccessKeyId: Boolean(settings.accessKeyId), - hasSecretAccessKey: Boolean(settings.secretAccessKey), - hasForcePathStyle: settings.forcePathStyle !== undefined, - }); - const uploadPromise = service.uploadFile({ - file, - bucket: bucketEntry.bucket, - key: videoKey, - // Spread settings carefully - accountId: settings.accountId, - endpoint: settings.endpoint, - provider: settings.provider || this.activeProvider || "cloudflare_r2", - region: settings.region, - accessKeyId: settings.accessKeyId, - secretAccessKey: settings.secretAccessKey, - forcePathStyle: settings.forcePathStyle, - createBucketIfMissing: true, - onProgress: (fraction) => { + const result = await this.mediaUploader.uploadVideo(file, { + provider: this.activeProvider, + credentials: this.activeCredentials, + onProgress: ({ fraction, label }) => { if (this.videoUploadId === currentUploadId) { - this.updateVideoProgress(fraction); + this.updateVideoProgress(fraction, label || undefined); } - } + }, }); - // 4. Calculate Info Hash (Parallel if possible, but JS single thread limits this. - // Since createTorrentMetadata reads the file, it might contend with upload read. - // Let's do it concurrently and hope the browser handles file I/O well. - this.updateVideoProgress(null, "Uploading & Calculating Hash..."); - - const torrentPromise = this.generateTorrentMetadata({ file, videoPublicUrl }); - - const [uploadResult, torrentResult] = await Promise.all([uploadPromise, torrentPromise]); - if (this.videoUploadId !== currentUploadId) return; // Zombie guard // Video Complete this.videoUploadState.status = 'complete'; - this.videoUploadState.url = videoPublicUrl; - this.videoUploadState.key = videoKey; - const storagePrefix = buildStoragePrefixFromKey({ - publicBaseUrl: baseDomain, - key: videoKey, - }); - const providerLabel = - this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" - ? "r2" - : "s3"; - this.videoUploadState.storagePointer = buildStoragePointerValue({ - provider: providerLabel, - prefix: storagePrefix, - }); - if (this.results.videoUrl) this.results.videoUrl.value = videoPublicUrl; - this.updateVideoProgress(1, "Video uploaded."); - - // 5. Handle Torrent Result & Upload .torrent file - if (torrentResult.hasValidInfoHash && torrentResult.torrentFile) { - const baseKey = videoKey.replace(/\.[^/.]+$/, ""); - const torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`; - - let torrentPublicUrl = ""; - if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") { - torrentPublicUrl = buildPublicUrl(baseDomain, torrentKey); - } else { - torrentPublicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key: torrentKey }); - } - - this.updateVideoProgress(1, "Uploading torrent metadata..."); - - devLogger.debug("UploadModal uploadFile settings presence", { - hasAccountId: Boolean(settings.accountId), - hasEndpoint: Boolean(settings.endpoint), - hasProvider: Boolean(settings.provider), - hasRegion: Boolean(settings.region), - hasAccessKeyId: Boolean(settings.accessKeyId), - hasSecretAccessKey: Boolean(settings.secretAccessKey), - hasForcePathStyle: settings.forcePathStyle !== undefined, - }); - await service.uploadFile({ - file: torrentResult.torrentFile, - bucket: bucketEntry.bucket, - key: torrentKey, - accountId: settings.accountId, - endpoint: settings.endpoint, - provider: settings.provider || this.activeProvider || "cloudflare_r2", - region: settings.region, - accessKeyId: settings.accessKeyId, - secretAccessKey: settings.secretAccessKey, - forcePathStyle: settings.forcePathStyle, - createBucketIfMissing: true, - }); - - if (this.videoUploadId !== currentUploadId) return; // Zombie guard + this.videoUploadState.url = result.url; + this.videoUploadState.key = result.key; + this.videoUploadState.storagePointer = result.storagePointer; + if (this.results.videoUrl) this.results.videoUrl.value = result.url; + if (result.hasValidInfoHash) { this.torrentState.status = 'complete'; - this.torrentState.infoHash = torrentResult.infoHash; - this.torrentState.url = torrentPublicUrl; - this.torrentState.file = torrentResult.torrentFile; + this.torrentState.infoHash = result.infoHash; + this.torrentState.url = result.torrentUrl; + this.torrentState.file = result.torrentFile; + this.torrentState.magnet = result.magnet; - // Construct Magnet - const encodedDn = encodeURIComponent(file.name); - const encodedWs = encodeURIComponent(videoPublicUrl); - const encodedXs = encodeURIComponent(torrentPublicUrl); - const magnet = `magnet:?xt=urn:btih:${torrentResult.infoHash}&dn=${encodedDn}&ws=${encodedWs}&xs=${encodedXs}`; - - this.torrentState.magnet = magnet; - - if (this.results.magnet) this.results.magnet.value = magnet; - if (this.results.torrentUrl) this.results.torrentUrl.value = torrentPublicUrl; - - this.updateVideoProgress(1, "Ready to publish!"); + if (this.results.magnet) this.results.magnet.value = result.magnet; + if (this.results.torrentUrl) this.results.torrentUrl.value = result.torrentUrl; } else { this.torrentState.status = 'skipped'; // Failed hash or invalid - this.updateVideoProgress(1, "Upload complete (No torrent fallback)."); if (this.results.magnet) this.results.magnet.value = "Not available (Info Hash failed)"; if (this.results.torrentUrl) this.results.torrentUrl.value = "Not available"; } @@ -725,58 +612,18 @@ export class UploadModal { this.updateThumbnailProgress(0, "Uploading thumbnail..."); try { - const pubkey = this.getCurrentPubkey(); - const npub = this.safeEncodeNpub(pubkey); - const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" - ? this.r2Service - : this.s3Service; - - const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials }); - - if (this.thumbnailUploadId !== currentUploadId) return; - - // Derive Key (randomish or based on file) - // We don't have the video key here easily if video isn't selected yet. - // Use a standalone key structure: npub/thumbnails/timestamp-name - const timestamp = Date.now(); - const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); - const key = `${npub}/thumbnails/${timestamp}-${cleanName}`; - - const baseDomain = bucketEntry.publicBaseUrl; - let publicUrl = ""; - if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") { - publicUrl = buildPublicUrl(baseDomain, key); - } else { - publicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key }); - } - - devLogger.debug("UploadModal uploadFile settings presence", { - hasAccountId: Boolean(settings.accountId), - hasEndpoint: Boolean(settings.endpoint), - hasProvider: Boolean(settings.provider), - hasRegion: Boolean(settings.region), - hasAccessKeyId: Boolean(settings.accessKeyId), - hasSecretAccessKey: Boolean(settings.secretAccessKey), - hasForcePathStyle: settings.forcePathStyle !== undefined, - }); - await service.uploadFile({ + const { url: publicUrl } = await this.mediaUploader.uploadThumbnail( file, - bucket: bucketEntry.bucket, - key, - accountId: settings.accountId, - endpoint: settings.endpoint, - provider: settings.provider || this.activeProvider || "cloudflare_r2", - region: settings.region, - accessKeyId: settings.accessKeyId, - secretAccessKey: settings.secretAccessKey, - forcePathStyle: settings.forcePathStyle, - createBucketIfMissing: true, - onProgress: (fraction) => { - if (this.thumbnailUploadId === currentUploadId) { - this.updateThumbnailProgress(fraction); - } - } - }); + { + provider: this.activeProvider, + credentials: this.activeCredentials, + onProgress: (fraction) => { + if (this.thumbnailUploadId === currentUploadId) { + this.updateThumbnailProgress(fraction); + } + }, + }, + ); if (this.thumbnailUploadId !== currentUploadId) return; diff --git a/js/ui/components/mediaUploader.js b/js/ui/components/mediaUploader.js new file mode 100644 index 00000000..4feb2f1c --- /dev/null +++ b/js/ui/components/mediaUploader.js @@ -0,0 +1,304 @@ +// js/ui/components/mediaUploader.js +// +// Shared storage-upload core extracted from UploadModal so the Upload and Edit +// modals can upload thumbnails and videos without duplicating the R2/S3 + +// torrent-metadata logic. This module owns ONLY the storage/torrent work +// (resolve active connection, upload file, build keys/URLs, compute infohash, +// build the .torrent + magnet). UI concerns (progress rendering, zombie-request +// guards, form state) stay in the calling modal and are driven via onProgress. + +import { devLogger, userLogger } from "../../utils/logger.js"; +import { + calculateTorrentInfoHash, + createTorrentMetadata, +} from "../../utils/torrentHash.js"; +import { buildR2Key, buildPublicUrl } from "../../r2.js"; +import { buildS3ObjectUrl } from "../../services/s3Service.js"; +import { PROVIDERS } from "../../services/storageService.js"; +import { + buildStoragePointerValue, + buildStoragePrefixFromKey, +} from "../../utils/storagePointer.js"; + +const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; + +function normalizeInfoHash(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function isValidInfoHash(value) { + return INFO_HASH_PATTERN.test(value); +} + +function isR2Provider(provider) { + return provider === PROVIDERS.R2 || provider === "cloudflare_r2"; +} + +export class MediaUploader { + constructor({ + r2Service, + s3Service, + storageService, + getCurrentPubkey, + safeEncodeNpub, + } = {}) { + this.r2Service = r2Service || null; + this.s3Service = s3Service || null; + this.storageService = storageService || null; + this.getCurrentPubkey = + typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; + this.safeEncodeNpub = + typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => ""; + } + + serviceFor(provider) { + return isR2Provider(provider) ? this.r2Service : this.s3Service; + } + + publicUrlFor(provider, baseDomain, key) { + return isR2Provider(provider) + ? buildPublicUrl(baseDomain, key) + : buildS3ObjectUrl({ publicBaseUrl: baseDomain, key }); + } + + /** + * Resolve the active storage connection for the current pubkey. Mirrors the + * data portion of UploadModal.loadFromStorage (no UI). Returns: + * { pubkey, npub, configured, unlocked, provider, credentials } + * credentials is only populated when the connection is unlocked. + */ + async resolveActiveConnection() { + const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + const base = { + pubkey: pubkey || null, + npub: pubkey ? this.safeEncodeNpub(pubkey) : "", + configured: false, + unlocked: false, + provider: null, + credentials: null, + }; + + if (!pubkey || !this.storageService) { + return base; + } + + base.unlocked = this.storageService.isUnlocked(pubkey); + + const connections = await this.storageService.listConnections(pubkey); + const defaultConn = connections.find((c) => c.meta?.defaultForUploads); + const targetConn = defaultConn || connections[0]; + if (!targetConn) { + return base; + } + + base.configured = true; + base.provider = targetConn.provider; + + if (base.unlocked) { + const details = await this.storageService.getConnection( + pubkey, + targetConn.id, + ); + base.credentials = details || null; + } + + return base; + } + + /** + * Upload a standalone thumbnail file. Returns { url, key }. + */ + async uploadThumbnail(file, { provider, credentials, onProgress } = {}) { + const pubkey = this.getCurrentPubkey(); + const npub = this.safeEncodeNpub(pubkey); + const service = this.serviceFor(provider); + + const { settings, bucketEntry } = await service.prepareUpload(npub, { + credentials, + }); + + const timestamp = Date.now(); + const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); + const key = `${npub}/thumbnails/${timestamp}-${cleanName}`; + const baseDomain = bucketEntry.publicBaseUrl; + const url = this.publicUrlFor(provider, baseDomain, key); + + await service.uploadFile({ + file, + bucket: bucketEntry.bucket, + key, + accountId: settings.accountId, + endpoint: settings.endpoint, + provider: settings.provider || provider || "cloudflare_r2", + region: settings.region, + accessKeyId: settings.accessKeyId, + secretAccessKey: settings.secretAccessKey, + forcePathStyle: settings.forcePathStyle, + createBucketIfMissing: true, + onProgress: (fraction) => { + if (typeof onProgress === "function") onProgress(fraction); + }, + }); + + return { url, key }; + } + + async resolveUploadIdentifier(file) { + try { + const infoHash = await calculateTorrentInfoHash(file); + const normalized = normalizeInfoHash(infoHash); + if (isValidInfoHash(normalized)) { + return normalized; + } + } catch (hashErr) { + userLogger.warn("Failed to precompute info hash for storage key:", hashErr); + } + return ""; + } + + async generateTorrentMetadata({ file, videoPublicUrl } = {}) { + let infoHash = ""; + let torrentFile = null; + + try { + const urlList = videoPublicUrl ? [videoPublicUrl] : []; + const torrentMetadata = await createTorrentMetadata(file, urlList); + + infoHash = torrentMetadata?.infoHash || ""; + if (torrentMetadata?.torrentFile) { + const baseName = file.name.replace(/\.[^/.]+$/, "") || file.name; + torrentFile = new File( + [torrentMetadata.torrentFile], + `${baseName}.torrent`, + { type: "application/x-bittorrent" }, + ); + } + } catch (hashErr) { + userLogger.warn("Failed to calculate info hash:", hashErr); + } + + const normalizedInfoHash = normalizeInfoHash(infoHash); + return { + infoHash: normalizedInfoHash, + torrentFile, + hasValidInfoHash: isValidInfoHash(normalizedInfoHash), + }; + } + + /** + * Upload a video file (and its generated .torrent). Returns: + * { url, key, storagePointer, infoHash, magnet, torrentUrl, torrentFile, + * hasValidInfoHash } + * onProgress is called as ({ fraction, label }) so the caller can render its + * own progress UI matching the original UploadModal labels. + */ + async uploadVideo(file, { provider, credentials, onProgress } = {}) { + const emit = (fraction, label) => { + if (typeof onProgress === "function") onProgress({ fraction, label }); + }; + + const identifier = await this.resolveUploadIdentifier(file); + + const pubkey = this.getCurrentPubkey(); + const npub = this.safeEncodeNpub(pubkey); + const service = this.serviceFor(provider); + + const { settings, bucketEntry } = await service.prepareUpload(npub, { + credentials, + }); + + const videoKey = buildR2Key(npub, file, identifier); + const baseDomain = bucketEntry.publicBaseUrl; + const videoPublicUrl = this.publicUrlFor(provider, baseDomain, videoKey); + + emit(0, "Uploading video..."); + const uploadPromise = service.uploadFile({ + file, + bucket: bucketEntry.bucket, + key: videoKey, + accountId: settings.accountId, + endpoint: settings.endpoint, + provider: settings.provider || provider || "cloudflare_r2", + region: settings.region, + accessKeyId: settings.accessKeyId, + secretAccessKey: settings.secretAccessKey, + forcePathStyle: settings.forcePathStyle, + createBucketIfMissing: true, + onProgress: (fraction) => emit(fraction, null), + }); + + emit(null, "Uploading & Calculating Hash..."); + const torrentPromise = this.generateTorrentMetadata({ + file, + videoPublicUrl, + }); + + const [, torrentResult] = await Promise.all([uploadPromise, torrentPromise]); + + const storagePrefix = buildStoragePrefixFromKey({ + publicBaseUrl: baseDomain, + key: videoKey, + }); + const providerLabel = isR2Provider(provider) ? "r2" : "s3"; + const storagePointer = buildStoragePointerValue({ + provider: providerLabel, + prefix: storagePrefix, + }); + + emit(1, "Video uploaded."); + + const result = { + url: videoPublicUrl, + key: videoKey, + storagePointer, + infoHash: "", + magnet: "", + torrentUrl: "", + torrentFile: null, + hasValidInfoHash: false, + }; + + if (torrentResult.hasValidInfoHash && torrentResult.torrentFile) { + const baseKey = videoKey.replace(/\.[^/.]+$/, ""); + const torrentKey = + baseKey && baseKey !== videoKey + ? `${baseKey}.torrent` + : `${videoKey}.torrent`; + const torrentPublicUrl = this.publicUrlFor(provider, baseDomain, torrentKey); + + emit(1, "Uploading torrent metadata..."); + await service.uploadFile({ + file: torrentResult.torrentFile, + bucket: bucketEntry.bucket, + key: torrentKey, + accountId: settings.accountId, + endpoint: settings.endpoint, + provider: settings.provider || provider || "cloudflare_r2", + region: settings.region, + accessKeyId: settings.accessKeyId, + secretAccessKey: settings.secretAccessKey, + forcePathStyle: settings.forcePathStyle, + createBucketIfMissing: true, + }); + + const magnet = `magnet:?xt=urn:btih:${torrentResult.infoHash}&dn=${encodeURIComponent( + file.name, + )}&ws=${encodeURIComponent(videoPublicUrl)}&xs=${encodeURIComponent( + torrentPublicUrl, + )}`; + + result.infoHash = torrentResult.infoHash; + result.magnet = magnet; + result.torrentUrl = torrentPublicUrl; + result.torrentFile = torrentResult.torrentFile; + result.hasValidInfoHash = true; + emit(1, "Ready to publish!"); + } else { + emit(1, "Upload complete (No torrent fallback)."); + } + + return result; + } +} + +export { isR2Provider, normalizeInfoHash, isValidInfoHash };