From 999235d71e6e3fb540af7308afb2183918107ca2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:00:00 -0500 Subject: [PATCH] Add S3 storage option to upload modal --- components/upload-modal.html | 181 ++++++++--- js/bootstrap.js | 6 +- js/index.js | 2 + js/services/s3Service.js | 19 +- js/services/s3UploadService.js | 336 +++++++++++++++++++ js/storage/s3-settings.js | 155 +++++++++ js/ui/ModalManager.js | 1 + js/ui/applicationBootstrap.js | 2 + js/ui/components/UploadModal.js | 555 +++++++++++++++++++++++++++----- js/ui/initUploadModal.js | 2 + 10 files changed, 1111 insertions(+), 148 deletions(-) create mode 100644 js/services/s3UploadService.js create mode 100644 js/storage/s3-settings.js diff --git a/components/upload-modal.html b/components/upload-modal.html index 3033ac62..5170f82a 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -131,7 +131,7 @@ -
+
Provider -- @@ -140,9 +140,13 @@ Bucket --
+
+ URL Style + -- +
-

- Your videos will be uploaded to this bucket. +

+ Your videos will be uploaded using this storage provider.

@@ -156,76 +160,151 @@

Required for direct file uploads. - Read the setup guide. + Read the storage setup guide.

- -
- -
- + +
+
+
-

Create a bucket with this exact name in your storage provider.

- -
+
+
- - + +
+ + +
+

Create a bucket with this exact name in Cloudflare R2.

+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + + +
+ + -
\ No newline at end of file +
diff --git a/js/bootstrap.js b/js/bootstrap.js index f6066f57..b8e82d8b 100644 --- a/js/bootstrap.js +++ b/js/bootstrap.js @@ -3,12 +3,13 @@ import Application from "./app.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; +import s3UploadService from "./services/s3UploadService.js"; import { loadView } from "./viewManager.js"; import hashtagPreferences from "./services/hashtagPreferencesService.js"; import { userLogger } from "./utils/logger.js"; function mergeServices(overrides = {}) { - const merged = { nostrService, r2Service, hashtagPreferences }; + const merged = { nostrService, r2Service, s3Service: s3UploadService, hashtagPreferences }; if (overrides && typeof overrides === "object") { if (overrides.nostrService) { @@ -17,6 +18,9 @@ function mergeServices(overrides = {}) { if (overrides.r2Service) { merged.r2Service = overrides.r2Service; } + if (overrides.s3Service) { + merged.s3Service = overrides.s3Service; + } if (overrides.hashtagPreferences) { merged.hashtagPreferences = overrides.hashtagPreferences; } diff --git a/js/index.js b/js/index.js index f1a19e55..2ddc12e3 100644 --- a/js/index.js +++ b/js/index.js @@ -10,6 +10,7 @@ import { } from "./applicationContext.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; +import s3UploadService from "./services/s3UploadService.js"; import { loadView, viewInitRegistry } from "./viewManager.js"; import { applyDesignSystemAttributes } from "./designSystem.js"; import { @@ -385,6 +386,7 @@ function startApplication() { services: { nostrService, r2Service, + s3Service: s3UploadService, }, loadView, }); diff --git a/js/services/s3Service.js b/js/services/s3Service.js index 39fe00ca..872473d7 100644 --- a/js/services/s3Service.js +++ b/js/services/s3Service.js @@ -133,6 +133,7 @@ export async function prepareS3Connection({ forcePathStyle, origins, publicBaseUrl, + createBucketIfMissing = true, } = {}) { const normalized = validateS3Connection({ endpoint, @@ -154,14 +155,16 @@ export async function prepareS3Connection({ forcePathStyle: normalized.forcePathStyle, }); - try { - await ensureBucketExists({ - s3, - bucket: normalized.bucket, - region: normalized.region, - }); - } catch (error) { - userLogger.warn("Failed to ensure S3 bucket exists:", error); + if (createBucketIfMissing) { + try { + await ensureBucketExists({ + s3, + bucket: normalized.bucket, + region: normalized.region, + }); + } catch (error) { + userLogger.warn("Failed to ensure S3 bucket exists:", error); + } } const allowedOrigins = Array.isArray(origins) ? origins : []; diff --git a/js/services/s3UploadService.js b/js/services/s3UploadService.js new file mode 100644 index 00000000..02f9de3f --- /dev/null +++ b/js/services/s3UploadService.js @@ -0,0 +1,336 @@ +import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js"; +import { multipartUpload } from "../storage/s3-multipart.js"; +import { buildR2Key } from "../r2.js"; +import { + buildS3ObjectUrl, + getCorsOrigins, + prepareS3Connection, + validateS3Connection, +} from "./s3Service.js"; +import { userLogger } from "../utils/logger.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "./videoNotePayload.js"; + +const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]); +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); +} + +class S3UploadService { + constructor() { + this.listeners = new Map(); + } + + on(event, handler) { + if (typeof handler !== "function") { + return () => {}; + } + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + const handlers = this.listeners.get(event); + handlers.add(handler); + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(event); + } + }; + } + + emit(event, detail) { + const handlers = this.listeners.get(event); + if (!handlers) { + return; + } + for (const handler of handlers) { + try { + handler(detail); + } catch (err) { + userLogger.error("[s3UploadService] Listener error for", event, err); + } + } + } + + normalizeStatusVariant(variant) { + if (STATUS_VARIANTS.has(variant)) { + return variant; + } + return "info"; + } + + setUploadStatus(message = "", variant = "info") { + this.emit("uploadStatus", { + message: message || "", + variant: this.normalizeStatusVariant(variant), + }); + } + + setUploading(isUploading) { + this.emit("uploadStateChange", { isUploading: Boolean(isUploading) }); + } + + updateProgress(fraction) { + this.emit("uploadProgress", { fraction }); + } + + async verifyConnection({ + settings = {}, + createBucketIfMissing = true, + origins = null, + } = {}) { + const normalized = validateS3Connection(settings); + const allowedOrigins = Array.isArray(origins) ? origins : getCorsOrigins(); + const prepared = await prepareS3Connection({ + ...normalized, + origins: allowedOrigins, + createBucketIfMissing, + }); + return prepared; + } + + async uploadVideo({ + npub = "", + file = null, + thumbnailFile = null, + torrentFile = null, + metadata = {}, + infoHash = "", + settings = {}, + createBucketIfMissing = true, + publishVideoNote, + onReset, + forcedVideoKey = "", + forcedVideoUrl = "", + forcedTorrentKey = "", + forcedTorrentUrl = "", + } = {}) { + if (!npub) { + this.setUploadStatus("Unable to encode npub.", "error"); + return false; + } + + const rawTitleCandidate = + metadata && typeof metadata === "object" ? metadata.title : metadata; + const title = + typeof rawTitleCandidate === "string" + ? rawTitleCandidate.trim() + : String(rawTitleCandidate ?? "").trim(); + + if (!title) { + this.setUploadStatus( + getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE), + "error" + ); + return false; + } + + if (!file) { + this.setUploadStatus("Select a video or HLS file to upload.", "error"); + return false; + } + + let normalized = null; + try { + normalized = validateS3Connection(settings); + } catch (err) { + this.setUploadStatus(err?.message || "Invalid S3 settings.", "error"); + return false; + } + + this.setUploading(true); + this.updateProgress(0); + + try { + await ensureS3SdkLoaded(); + + const s3 = makeS3Client({ + endpoint: normalized.endpoint, + region: normalized.region, + accessKeyId: normalized.accessKeyId, + secretAccessKey: normalized.secretAccessKey, + forcePathStyle: Boolean(normalized.forcePathStyle), + }); + + const key = forcedVideoKey || buildR2Key(npub, file); + const publicUrl = + forcedVideoUrl || + buildS3ObjectUrl({ + publicBaseUrl: normalized.publicBaseUrl, + endpoint: normalized.endpoint, + bucket: normalized.bucket, + key, + forcePathStyle: normalized.forcePathStyle, + }); + + let statusMessage = `Uploading to ${normalized.bucket}…`; + this.setUploadStatus(statusMessage, "info"); + + if (thumbnailFile) { + this.setUploadStatus("Uploading thumbnail...", "info"); + const thumbExt = thumbnailFile.name.split(".").pop() || "jpg"; + const thumbKey = key.replace(/\.[^/.]+$/, "") + `.thumb.${thumbExt}`; + try { + await multipartUpload({ + s3, + bucket: normalized.bucket, + key: thumbKey, + file: thumbnailFile, + contentType: thumbnailFile.type || "image/jpeg", + createBucketIfMissing, + region: normalized.region, + }); + const thumbUrl = buildS3ObjectUrl({ + publicBaseUrl: normalized.publicBaseUrl, + endpoint: normalized.endpoint, + bucket: normalized.bucket, + key: thumbKey, + forcePathStyle: normalized.forcePathStyle, + }); + if (typeof metadata === "object") { + metadata.thumbnail = thumbUrl; + } + } catch (err) { + userLogger.warn("Thumbnail upload failed, continuing with video...", err); + } + } + + this.setUploadStatus(statusMessage, "info"); + + await multipartUpload({ + s3, + bucket: normalized.bucket, + key, + file, + contentType: file.type || "application/octet-stream", + createBucketIfMissing, + region: normalized.region, + onProgress: (fraction) => { + this.updateProgress(fraction); + }, + }); + + let torrentUrl = forcedTorrentUrl || ""; + if (torrentFile) { + this.setUploadStatus("Uploading torrent metadata...", "info"); + const torrentKey = + forcedTorrentKey || + (() => { + const baseKey = key.replace(/\.[^/.]+$/, ""); + if (baseKey && baseKey !== key) { + return `${baseKey}.torrent`; + } + return `${key}.torrent`; + })(); + try { + await multipartUpload({ + s3, + bucket: normalized.bucket, + key: torrentKey, + file: torrentFile, + contentType: "application/x-bittorrent", + createBucketIfMissing, + region: normalized.region, + }); + if (!torrentUrl) { + torrentUrl = buildS3ObjectUrl({ + publicBaseUrl: normalized.publicBaseUrl, + endpoint: normalized.endpoint, + bucket: normalized.bucket, + key: torrentKey, + forcePathStyle: normalized.forcePathStyle, + }); + } + } catch (err) { + userLogger.warn("Torrent metadata upload failed, continuing...", err); + } + } + + if (typeof publishVideoNote !== "function") { + userLogger.warn("publishVideoNote handler missing; skipping publish step."); + return false; + } + + const normalizedInfoHash = normalizeInfoHash(infoHash); + const hasValidInfoHash = isValidInfoHash(normalizedInfoHash); + let generatedMagnet = ""; + let generatedWs = ""; + + if (hasValidInfoHash) { + const encodedDn = encodeURIComponent(file.name); + const encodedWs = encodeURIComponent(publicUrl); + let magnet = `magnet:?xt=urn:btih:${normalizedInfoHash}&dn=${encodedDn}&ws=${encodedWs}`; + if (torrentUrl) { + const encodedXs = encodeURIComponent(torrentUrl); + magnet += `&xs=${encodedXs}`; + } + generatedMagnet = magnet; + generatedWs = publicUrl; + } else { + if (infoHash) { + userLogger.warn("Invalid info hash provided. Skipping magnet generation.", infoHash); + } + this.setUploadStatus( + "Info hash missing or invalid. Publishing URL-first without WebTorrent fallback.", + "warning" + ); + } + + const rawVideoPayload = { + title, + url: publicUrl, + magnet: generatedMagnet || (metadata?.magnet ?? ""), + thumbnail: metadata?.thumbnail ?? "", + description: metadata?.description ?? "", + ws: generatedWs || (metadata?.ws ?? ""), + xs: torrentUrl || (metadata?.xs ?? ""), + enableComments: metadata?.enableComments, + isNsfw: metadata?.isNsfw, + isForKids: metadata?.isForKids, + }; + + const { payload, errors } = normalizeVideoNotePayload(rawVideoPayload); + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.setUploadStatus(message, "error"); + return false; + } + + const published = await publishVideoNote(payload, { + onSuccess: () => { + if (typeof onReset === "function") { + onReset(); + } + }, + }); + + if (published) { + this.setUploadStatus(`Published ${publicUrl}`, "success"); + } + return Boolean(published); + } catch (err) { + userLogger.error("S3 upload failed:", err); + this.setUploadStatus( + err?.message ? `Upload failed: ${err.message}` : "Upload failed.", + "error" + ); + return false; + } finally { + this.setUploading(false); + this.updateProgress(Number.NaN); + } + } +} + +const s3UploadService = new S3UploadService(); + +export default s3UploadService; diff --git a/js/storage/s3-settings.js b/js/storage/s3-settings.js new file mode 100644 index 00000000..99384897 --- /dev/null +++ b/js/storage/s3-settings.js @@ -0,0 +1,155 @@ +import { userLogger } from "../utils/logger.js"; +import { normalizeS3PublicBaseUrl } from "./s3-url.js"; + +const DB_NAME = "bitvidSettings"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; +const SETTINGS_KEY = "s3Settings"; +const LOCALSTORAGE_FALLBACK_KEY = "bitvid:s3Settings"; + +function normalizeSettings(raw) { + const base = raw && typeof raw === "object" ? raw : {}; + const forcePathStyle = Boolean(base.forcePathStyle); + const createBucketIfMissing = Boolean(base.createBucketIfMissing); + const endpoint = String(base.endpoint || "").trim(); + const region = String(base.region || "").trim() || "auto"; + const bucket = String(base.bucket || "").trim(); + const accessKeyId = String(base.accessKeyId || "").trim(); + const secretAccessKey = String(base.secretAccessKey || "").trim(); + const publicBaseUrl = normalizeS3PublicBaseUrl(base.publicBaseUrl || ""); + + return { + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + forcePathStyle, + publicBaseUrl, + createBucketIfMissing, + }; +} + +function isIndexedDbAvailable() { + try { + return typeof indexedDB !== "undefined"; + } catch (err) { + return false; + } +} + +function openSettingsDb() { + return new Promise((resolve, reject) => { + if (!isIndexedDbAvailable()) { + resolve(null); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Failed to open settings DB")); + }; + }); +} + +export async function loadS3Settings() { + try { + const db = await openSettingsDb(); + if (db) { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const req = store.get(SETTINGS_KEY); + req.onsuccess = () => { + resolve(normalizeSettings(req.result)); + }; + req.onerror = () => { + reject(req.error || new Error("Failed to load S3 settings")); + }; + }); + } + } catch (err) { + userLogger.warn("Failed to open IndexedDB for S3 settings, falling back:", err); + } + + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY); + if (raw) { + return normalizeSettings(JSON.parse(raw)); + } + } + } catch (err) { + userLogger.warn("Failed to read fallback S3 settings:", err); + } + + return normalizeSettings(null); +} + +export async function saveS3Settings(settings) { + const normalized = normalizeSettings(settings); + + let db = null; + try { + db = await openSettingsDb(); + } catch (err) { + userLogger.warn("Unable to open IndexedDB for S3 settings, continuing with fallback:", err); + } + + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.put(normalized, SETTINGS_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => + reject(req.error || new Error("Failed to save S3 settings")); + }); + } else if (typeof localStorage !== "undefined") { + localStorage.setItem(LOCALSTORAGE_FALLBACK_KEY, JSON.stringify(normalized)); + } + + return normalized; +} + +export async function clearS3Settings() { + let cleared = false; + try { + const db = await openSettingsDb(); + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(SETTINGS_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error || new Error("Failed to clear")); + }); + cleared = true; + } + } catch (err) { + userLogger.warn("Failed to clear IndexedDB S3 settings:", err); + } + + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY); + cleared = true; + } + } catch (err) { + userLogger.warn("Failed to clear fallback S3 settings:", err); + } + + return cleared; +} diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index 95b300b3..89b87435 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -70,6 +70,7 @@ export default class ModalManager { services: { authService: app.authService, r2Service: app.r2Service, + s3Service: app.s3Service, storageService: this.services.storageService, }, utilities: { diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 7d4f7fe2..3079413c 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -18,6 +18,7 @@ import nostrService from "../services/nostrService.js"; import storageService from "../services/storageService.js"; import watchHistoryService from "../watchHistoryService.js"; import r2Service from "../services/r2Service.js"; +import s3UploadService from "../services/s3UploadService.js"; import RelayHealthService from "../services/relayHealthService.js"; import { createFeedEngine } from "../feedEngine/index.js"; import { URL_FIRST_ENABLED } from "../constants.js"; @@ -187,6 +188,7 @@ export default class ApplicationBootstrap { app.nostrService = this.services.nostrService || nostrService; app.r2Service = this.services.r2Service || r2Service; + app.s3Service = this.services.s3Service || s3UploadService; app.feedEngine = this.services.feedEngine || diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 47db1646..9eac42aa 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1,7 +1,7 @@ // components/UploadModal.js import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; -import logger, { userLogger } from "../../utils/logger.js"; +import { userLogger } from "../../utils/logger.js"; import { getVideoNoteErrorMessage, normalizeVideoNotePayload, @@ -10,7 +10,9 @@ import { import { createTorrentMetadata } from "../../utils/torrentHash.js"; import { sanitizeBucketName } from "../../storage/r2-mgmt.js"; import { buildR2Key, buildPublicUrl } from "../../r2.js"; +import { buildS3ObjectUrl } from "../../services/s3Service.js"; import { PROVIDERS } from "../../services/storageService.js"; +import { loadS3Settings, saveS3Settings } from "../../storage/s3-settings.js"; import { getActiveSigner, requestDefaultExtensionPermissions, @@ -30,6 +32,7 @@ export class UploadModal { constructor({ authService, r2Service, + s3Service, storageService, publishVideoNote, removeTrackingScripts, @@ -44,6 +47,7 @@ export class UploadModal { } = {}) { this.authService = authService || null; this.r2Service = r2Service || null; + this.s3Service = s3Service || null; this.storageService = storageService || null; this.publishVideoNote = typeof publishVideoNote === "function" ? publishVideoNote : null; @@ -70,7 +74,9 @@ export class UploadModal { this.root = null; this.isVisible = false; this.activeSource = "upload"; // 'upload' | 'external' + this.activeStorageProvider = PROVIDERS.R2; this.cloudflareSettings = this.r2Service?.getSettings?.() || null; + this.s3Settings = null; this.isStorageUnlocked = false; this.activeConnectionId = null; this.storageConfigured = false; @@ -127,7 +133,7 @@ export class UploadModal { this.cacheElements(); this.bindEvents(); this.setupModalAccessibility(); - this.registerR2Subscriptions(); + this.registerStorageSubscriptions(); // Initial State if (this.storageService) { @@ -136,7 +142,7 @@ export class UploadModal { // Even if locked, we want to know if there's *potentially* a connection saved to show correct UI state (locked vs empty) await this.loadFromStorage(); } else { - await this.loadR2Settings(); + await this.loadLegacyStorageSettings(); } this.updateLockUi(); this.setSourceMode("upload"); @@ -179,6 +185,16 @@ export class UploadModal { form: $("#storage-form-view"), }; + this.storageProviders = { + r2: $("#btn-storage-provider-r2"), + s3: $("#btn-storage-provider-s3"), + }; + + this.storageProviderSections = { + r2: $("#section-storage-r2"), + s3: $("#section-storage-s3"), + }; + // Inputs (Common) this.inputs = { title: $("#input-title"), @@ -196,6 +212,13 @@ export class UploadModal { r2Secret: $("#input-r2-secret"), r2Domain: $("#input-r2-domain"), // Public URL + s3Endpoint: $("#input-s3-endpoint"), + s3Region: $("#input-s3-region"), + s3Bucket: $("#input-s3-bucket"), + s3Key: $("#input-s3-key"), + s3Secret: $("#input-s3-secret"), + s3PublicBase: $("#input-s3-public-base"), + // Advanced (Manual or NIP71 managed) ws: $("#input-ws"), xs: $("#input-xs"), @@ -228,11 +251,15 @@ export class UploadModal { advanced: $("#btn-advanced-toggle"), storageSettings: $("#btn-storage-settings"), saveSettings: $("#btn-save-settings"), // Verify & Save + saveS3Settings: $("#btn-save-s3-settings"), browseThumbnail: $("#btn-thumbnail-file"), r2HelpLink: $("#link-r2-help"), copyBucket: $("#btn-copy-bucket"), storageUnlock: $("#btn-storage-unlock"), manageStorage: $("#btn-manage-storage"), + + s3ForcePathStyle: $("#check-s3-path-style"), + s3CreateBucket: $("#check-s3-create-bucket"), }; // Status text @@ -243,6 +270,8 @@ export class UploadModal { uploadPercent: $("#upload-percent-text"), summaryProvider: $("#summary-provider"), summaryBucket: $("#summary-bucket"), + summaryUrlStyle: $("#summary-url-style"), + summaryCopy: $("#summary-copy"), }; this.nip71FormManager.registerSection("main", this.form); @@ -257,6 +286,17 @@ export class UploadModal { this.setupAccordion(this.toggles.advanced, this.sourceSections.advanced); this.setupAccordion(this.toggles.storageSettings, this.sourceSections.settings); + if (this.storageProviders.r2) { + this.storageProviders.r2.addEventListener("click", () => + this.setStorageProvider(PROVIDERS.R2) + ); + } + if (this.storageProviders.s3) { + this.storageProviders.s3.addEventListener("click", () => + this.setStorageProvider(PROVIDERS.GENERIC) + ); + } + // Form Submission this.form.addEventListener("submit", (e) => { e.preventDefault(); @@ -274,9 +314,17 @@ export class UploadModal { } // Settings Save (Verify & Save) - this.toggles.saveSettings.addEventListener("click", async () => { - await this.handleVerifyAndSave(); - }); + if (this.toggles.saveSettings) { + this.toggles.saveSettings.addEventListener("click", async () => { + await this.handleVerifyAndSaveR2(); + }); + } + + if (this.toggles.saveS3Settings) { + this.toggles.saveS3Settings.addEventListener("click", async () => { + await this.handleVerifyAndSaveS3(); + }); + } // Help Link if (this.toggles.r2HelpLink) { @@ -354,7 +402,7 @@ export class UploadModal { this.sourceSections.external.classList.add("hidden"); // Auto-show settings if missing or not configured - if (!this.storageConfigured && !this.hasValidR2Settings() && this.sourceSections.settings.classList.contains("hidden")) { + if (!this.storageConfigured && !this.hasValidActiveStorageSettings() && this.sourceSections.settings.classList.contains("hidden")) { this.toggles.storageSettings.click(); // Expand } else if (this.storageConfigured) { // Keep collapsed if configured, per requirements @@ -399,9 +447,48 @@ export class UploadModal { }); } + setStorageProvider(provider) { + const normalizedProvider = + provider === PROVIDERS.R2 ? PROVIDERS.R2 : PROVIDERS.GENERIC; + this.activeStorageProvider = normalizedProvider; + + const isR2 = normalizedProvider === PROVIDERS.R2; + if (this.storageProviders.r2) { + this.storageProviders.r2.setAttribute("aria-pressed", isR2); + this.storageProviders.r2.classList.toggle("bg-surface", isR2); + this.storageProviders.r2.classList.toggle("text-text", isR2); + this.storageProviders.r2.classList.toggle("shadow-sm", isR2); + this.storageProviders.r2.classList.toggle("text-muted", !isR2); + } + if (this.storageProviders.s3) { + this.storageProviders.s3.setAttribute("aria-pressed", !isR2); + this.storageProviders.s3.classList.toggle("bg-surface", !isR2); + this.storageProviders.s3.classList.toggle("text-text", !isR2); + this.storageProviders.s3.classList.toggle("shadow-sm", !isR2); + this.storageProviders.s3.classList.toggle("text-muted", isR2); + } + + if (this.storageProviderSections.r2) { + this.storageProviderSections.r2.classList.toggle("hidden", !isR2); + } + if (this.storageProviderSections.s3) { + this.storageProviderSections.s3.classList.toggle("hidden", isR2); + } + + if (isR2) { + this.goToCredentialsStep(); + this.populateBucketName(); + } + + this.updateStorageStatus(this.hasValidActiveStorageSettings(), this.getProviderLabel(normalizedProvider)); + } + // --- Automation Helpers --- populateBucketName() { + if (this.activeStorageProvider !== PROVIDERS.R2) { + return; + } const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; if (pubkey && this.safeEncodeNpub && this.inputs.r2BucketName) { const npub = this.safeEncodeNpub(pubkey); @@ -485,7 +572,7 @@ export class UploadModal { await this.loadFromStorage(); } else if (this.r2Service && typeof this.r2Service.loadSettings === "function") { - await this.loadR2Settings(); + await this.loadLegacyStorageSettings(); } } @@ -495,19 +582,39 @@ export class UploadModal { if (!this.r2Service?.loadSettings) return; const settings = await this.r2Service.loadSettings(); this.cloudflareSettings = settings || {}; - this.fillSettingsForm(this.cloudflareSettings); - this.updateStorageStatus(this.hasValidR2Settings()); + this.fillR2SettingsForm(this.cloudflareSettings); + this.updateStorageStatus(this.hasValidR2Settings(), this.getProviderLabel(PROVIDERS.R2)); // Legacy mode implies no storage service or not used this.toggleStorageView("form"); return settings; } + async loadS3Settings() { + const settings = await loadS3Settings(); + this.s3Settings = settings || {}; + this.fillS3SettingsForm(this.s3Settings); + this.updateStorageStatus(this.hasValidS3Settings(), this.getProviderLabel(PROVIDERS.GENERIC)); + return settings; + } + + async loadLegacyStorageSettings() { + await this.loadR2Settings(); + await this.loadS3Settings(); + + if (this.hasValidS3Settings()) { + this.setStorageProvider(PROVIDERS.GENERIC); + } else { + this.setStorageProvider(PROVIDERS.R2); + } + } + async loadFromStorage() { if (!this.storageService) return; const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; this.cloudflareSettings = null; + this.s3Settings = null; this.activeConnectionId = null; this.storageConfigured = false; @@ -520,36 +627,59 @@ export class UploadModal { const connections = await this.storageService.listConnections(pubkey); // Prefer default connection, fallback to R2 const defaultConn = connections.find(c => c.meta?.defaultForUploads); - const targetConn = defaultConn || connections.find(c => c.provider === PROVIDERS.R2 || c.provider === "cloudflare_r2"); + const targetConn = + defaultConn || + connections.find(c => c.provider === PROVIDERS.R2 || c.provider === "cloudflare_r2") || + connections.find(c => c.provider === PROVIDERS.GENERIC || c.provider === PROVIDERS.S3); if (targetConn) { this.storageConfigured = true; this.toggleStorageView("summary"); // Populate Summary - const providerName = targetConn.meta?.provider === "cloudflare_r2" ? "Cloudflare R2" : (targetConn.meta?.label || "S3 Storage"); + const providerName = this.getProviderLabel(targetConn.provider || targetConn.meta?.provider); const bucketName = targetConn.meta?.bucket || "Unknown Bucket"; + const urlStyle = this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle); if (this.statusText.summaryProvider) this.statusText.summaryProvider.textContent = providerName; if (this.statusText.summaryBucket) this.statusText.summaryBucket.textContent = bucketName; + if (this.statusText.summaryUrlStyle) this.statusText.summaryUrlStyle.textContent = urlStyle; + if (this.statusText.summaryCopy) this.statusText.summaryCopy.textContent = this.getSummaryCopy(targetConn.provider); + + this.setStorageProvider(targetConn.provider === PROVIDERS.R2 ? PROVIDERS.R2 : PROVIDERS.GENERIC); if (this.isStorageUnlocked) { const details = await this.storageService.getConnection(pubkey, targetConn.id); if (details) { - // Map generic S3 or R2 details to settings - // R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain - this.cloudflareSettings = { - accountId: details.accountId, // Might be undefined for generic S3 - accessKeyId: details.accessKeyId, - secretAccessKey: details.secretAccessKey, - baseDomain: details.meta?.baseDomain || "", - // Add generic endpoint for internal use if needed - endpoint: details.endpoint, - region: details.region, - bucket: details.bucket - }; - this.fillSettingsForm(this.cloudflareSettings); - this.updateStorageStatus(true, providerName); + if (targetConn.provider === PROVIDERS.R2) { + // R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain + this.cloudflareSettings = { + accountId: details.accountId, + accessKeyId: details.accessKeyId, + secretAccessKey: details.secretAccessKey, + baseDomain: details.meta?.baseDomain || "", + endpoint: details.endpoint, + region: details.region, + bucket: details.bucket, + }; + this.fillR2SettingsForm(this.cloudflareSettings); + this.updateStorageStatus(true, providerName); + } else { + this.s3Settings = { + endpoint: details.endpoint || details.meta?.endpoint || "", + region: details.region || details.meta?.region || "", + bucket: details.bucket || details.meta?.bucket || "", + accessKeyId: details.accessKeyId || "", + secretAccessKey: details.secretAccessKey || "", + forcePathStyle: typeof details.forcePathStyle === "boolean" + ? details.forcePathStyle + : Boolean(details.meta?.forcePathStyle), + publicBaseUrl: details.meta?.publicBaseUrl || details.meta?.baseDomain || "", + createBucketIfMissing: Boolean(details.meta?.createBucketIfMissing), + }; + this.fillS3SettingsForm(this.s3Settings); + this.updateStorageStatus(true, providerName); + } this.activeConnectionId = targetConn.id; } } else { @@ -559,10 +689,12 @@ export class UploadModal { } else { // No connections configured this.toggleStorageView("form"); + this.setStorageProvider(PROVIDERS.R2); } } catch (err) { userLogger.error("Failed to load connection", err); this.toggleStorageView("form"); + this.setStorageProvider(PROVIDERS.R2); } } @@ -653,14 +785,34 @@ export class UploadModal { return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey && s?.baseDomain); } - fillSettingsForm(s) { + hasValidS3Settings() { + const s = this.s3Settings || this.collectS3SettingsForm(); + return Boolean(s?.endpoint && s?.accessKeyId && s?.secretAccessKey && s?.bucket); + } + + hasValidActiveStorageSettings() { + return this.activeStorageProvider === PROVIDERS.R2 ? this.hasValidR2Settings() : this.hasValidS3Settings(); + } + + fillR2SettingsForm(s) { if (this.inputs.r2Account) this.inputs.r2Account.value = s.accountId || ""; if (this.inputs.r2Key) this.inputs.r2Key.value = s.accessKeyId || ""; if (this.inputs.r2Secret) this.inputs.r2Secret.value = s.secretAccessKey || ""; if (this.inputs.r2Domain) this.inputs.r2Domain.value = s.baseDomain || ""; } - collectSettingsForm() { + fillS3SettingsForm(s) { + if (this.inputs.s3Endpoint) this.inputs.s3Endpoint.value = s.endpoint || ""; + if (this.inputs.s3Region) this.inputs.s3Region.value = s.region || "auto"; + if (this.inputs.s3Bucket) this.inputs.s3Bucket.value = s.bucket || ""; + if (this.inputs.s3Key) this.inputs.s3Key.value = s.accessKeyId || ""; + if (this.inputs.s3Secret) this.inputs.s3Secret.value = s.secretAccessKey || ""; + if (this.inputs.s3PublicBase) this.inputs.s3PublicBase.value = s.publicBaseUrl || ""; + if (this.toggles.s3ForcePathStyle) this.toggles.s3ForcePathStyle.checked = Boolean(s.forcePathStyle); + if (this.toggles.s3CreateBucket) this.toggles.s3CreateBucket.checked = Boolean(s.createBucketIfMissing); + } + + collectR2SettingsForm() { return { accountId: this.inputs.r2Account?.value?.trim() || "", accessKeyId: this.inputs.r2Key?.value?.trim() || "", @@ -669,7 +821,23 @@ export class UploadModal { }; } + collectS3SettingsForm() { + return { + endpoint: this.inputs.s3Endpoint?.value?.trim() || "", + region: this.inputs.s3Region?.value?.trim() || "auto", + bucket: this.inputs.s3Bucket?.value?.trim() || "", + accessKeyId: this.inputs.s3Key?.value?.trim() || "", + secretAccessKey: this.inputs.s3Secret?.value?.trim() || "", + publicBaseUrl: this.inputs.s3PublicBase?.value?.trim() || "", + forcePathStyle: Boolean(this.toggles.s3ForcePathStyle?.checked), + createBucketIfMissing: Boolean(this.toggles.s3CreateBucket?.checked), + }; + } + goToVerificationStep() { + if (this.activeStorageProvider !== PROVIDERS.R2) { + return; + } // Validate Step 1 const accountId = this.inputs.r2Account?.value?.trim(); const keyId = this.inputs.r2Key?.value?.trim(); @@ -691,19 +859,22 @@ export class UploadModal { } goToCredentialsStep() { + if (this.activeStorageProvider !== PROVIDERS.R2) { + return; + } this.wizard.step2.classList.add("hidden"); this.wizard.step1.classList.remove("hidden"); this.wizard.errorContainer.classList.add("hidden"); } - async handleVerifyAndSave() { + async handleVerifyAndSaveR2() { const btn = this.toggles.saveSettings; const originalText = btn.textContent; // Clear previous error this.wizard.errorContainer.classList.add("hidden"); - const settings = this.collectSettingsForm(); + const settings = this.collectR2SettingsForm(); const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; const npub = pubkey ? this.safeEncodeNpub(pubkey) : null; @@ -781,7 +952,7 @@ export class UploadModal { } this.cloudflareSettings = settings; - this.updateStorageStatus(true); + this.updateStorageStatus(true, this.getProviderLabel(PROVIDERS.R2)); btn.disabled = false; btn.textContent = "Saved & Ready!"; @@ -805,6 +976,97 @@ export class UploadModal { } } + async handleVerifyAndSaveS3() { + const btn = this.toggles.saveS3Settings; + if (!btn) return; + const originalText = btn.textContent; + + const settings = this.collectS3SettingsForm(); + const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + + btn.disabled = true; + btn.textContent = "Verifying..."; + + let normalized = null; + try { + if (!this.s3Service || typeof this.s3Service.verifyConnection !== "function") { + throw new Error("S3 service unavailable."); + } + normalized = await this.s3Service.verifyConnection({ + settings, + createBucketIfMissing: settings.createBucketIfMissing, + }); + } catch (err) { + btn.disabled = false; + btn.textContent = originalText; + const message = err?.message || "Invalid S3 configuration."; + this.showError(message); + return; + } + + try { + btn.textContent = "Saving..."; + if (this.storageService && this.isStorageUnlocked && pubkey) { + const connectionId = this.activeConnectionId || `s3-${Date.now()}`; + await this.storageService.saveConnection( + pubkey, + connectionId, + { + provider: PROVIDERS.GENERIC, + endpoint: normalized.endpoint, + region: normalized.region, + bucket: normalized.bucket, + accessKeyId: normalized.accessKeyId, + secretAccessKey: normalized.secretAccessKey, + forcePathStyle: normalized.forcePathStyle, + }, + { + provider: PROVIDERS.GENERIC, + label: "Default S3", + defaultForUploads: true, + endpoint: normalized.endpoint, + region: normalized.region, + bucket: normalized.bucket, + publicBaseUrl: normalized.publicBaseUrl, + baseDomain: normalized.publicBaseUrl, + forcePathStyle: normalized.forcePathStyle, + createBucketIfMissing: settings.createBucketIfMissing, + } + ); + this.activeConnectionId = connectionId; + } else { + await saveS3Settings({ + ...normalized, + createBucketIfMissing: settings.createBucketIfMissing, + }); + } + + this.s3Settings = { + ...normalized, + createBucketIfMissing: settings.createBucketIfMissing, + }; + + this.updateStorageStatus(true, this.getProviderLabel(PROVIDERS.GENERIC)); + + btn.disabled = false; + btn.textContent = "Saved & Ready!"; + + setTimeout(() => { + btn.textContent = originalText; + this.sourceSections.settings.classList.add("hidden"); + this.toggles.storageSettings.setAttribute("aria-expanded", "false"); + if (this.storageService) { + this.loadFromStorage(); + } + }, 1500); + } catch (err) { + userLogger.error("S3 verification crashed:", err); + btn.disabled = false; + btn.textContent = originalText; + this.showError("Unexpected error during S3 verification."); + } + } + updateStorageStatus(isValid, providerLabel) { if (this.statusText.storage) { const baseText = isValid ? "Ready" : "Missing Credentials"; @@ -814,23 +1076,51 @@ export class UploadModal { } } - registerR2Subscriptions() { - if (!this.r2Service?.on) return; + getProviderLabel(provider) { + if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") { + return "Cloudflare R2"; + } + if (provider === PROVIDERS.S3) { + return "Amazon S3"; + } + return "S3 Compatible"; + } + + describeUrlStyle(provider, forcePathStyle) { + if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") { + return "Virtual-hosted"; + } + return forcePathStyle ? "Path-style" : "Virtual-hosted"; + } + + getSummaryCopy(provider) { + if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") { + return "Uploads will target your Cloudflare R2 bucket."; + } + return "Uploads will target your S3-compatible bucket."; + } + + registerStorageSubscriptions() { + const services = [this.r2Service, this.s3Service].filter( + (service) => service && typeof service.on === "function" + ); // Clear old this.r2Unsubscribes.forEach(u => u && u()); this.r2Unsubscribes = []; - const sub = (evt, fn) => { - const unsub = this.r2Service.on(evt, fn); - if (unsub) this.r2Unsubscribes.push(unsub); - }; + services.forEach((service) => { + const sub = (evt, fn) => { + const unsub = service.on(evt, fn); + if (unsub) this.r2Unsubscribes.push(unsub); + }; - sub("uploadProgress", ({ fraction }) => this.updateProgress(fraction)); - sub("uploadStatus", ({ message, variant }) => this.updateUploadStatus(message, variant)); - sub("uploadStateChange", ({ isUploading }) => { - this.isUploading = isUploading; - this.submitButton.disabled = isUploading; + sub("uploadProgress", ({ fraction }) => this.updateProgress(fraction)); + sub("uploadStatus", ({ message, variant }) => this.updateUploadStatus(message, variant)); + sub("uploadStateChange", ({ isUploading }) => { + this.isUploading = isUploading; + this.submitButton.disabled = isUploading; + }); }); } @@ -901,77 +1191,50 @@ export class UploadModal { } async handleUploadFlow(metadata) { + if (this.activeStorageProvider === PROVIDERS.R2) { + return this.handleR2UploadFlow(metadata); + } + return this.handleS3UploadFlow(metadata); + } + + async handleR2UploadFlow(metadata) { const file = this.inputs.file?.files?.[0]; if (!file) throw new Error("Please select a video file to upload."); const thumbnailFile = this.inputs.thumbnailFile?.files?.[0]; - if (!this.hasValidR2Settings()) throw new Error("Please configure R2 storage credentials."); + if (!this.hasValidR2Settings()) throw new Error("Please configure Cloudflare R2 credentials."); const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; if (!pubkey) throw new Error("Please login to publish."); const npub = this.safeEncodeNpub(pubkey); - // Pre-calculate Keys & URLs to support WebSeeding - // We need the Public URL *before* we generate the torrent so we can embed it. let videoKey = ""; let videoPublicUrl = ""; let torrentKey = ""; let torrentPublicUrl = ""; try { - // Temporarily grab current bucket settings to calculate URL - const bucketName = this.inputs.r2BucketName?.value || sanitizeBucketName(npub); - // Note: The actual upload might re-verify this, but we need a best-guess for the torrent. - // We rely on r2Service using the *same* logic or accepting our forced keys. - // Since we can't easily get the 'final' bucket config here without doing the 'ensureBucket' dance first, - // we'll do a lightweight check of the settings form. - const currentSettings = this.collectSettingsForm(); + const currentSettings = this.collectR2SettingsForm(); if (!currentSettings.baseDomain) { throw new Error("Missing Public URL (Base Domain) in settings."); } videoKey = buildR2Key(npub, file); - // 'buildPublicUrl' logic: ${baseDomain}/${key} videoPublicUrl = buildPublicUrl(currentSettings.baseDomain, videoKey); - // For the torrent file, we swap extension const baseKey = videoKey.replace(/\.[^/.]+$/, ""); torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`; torrentPublicUrl = buildPublicUrl(currentSettings.baseDomain, torrentKey); - } catch (prepErr) { userLogger.warn("Failed to pre-calculate R2 keys:", prepErr); - // We continue, but WebSeed generation will fail or be skipped. } - // 1. Calculate Torrent Info Hash (Client-side) - this.isUploading = true; - this.submitButton.disabled = true; - this.updateUploadStatus("Calculating Info Hash...", "info"); - this.updateProgress(0); // Show bar at 0 - - let infoHash = ""; - let torrentFile = null; - try { - // Pass the pre-calculated video URL as a WebSeed - 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); - const hasValidInfoHash = isValidInfoHash(normalizedInfoHash); + const { infoHash, torrentFile, hasValidInfoHash } = await this.generateTorrentMetadata({ + file, + videoPublicUrl, + }); if (!hasValidInfoHash) { const proceed = confirm( @@ -993,11 +1256,9 @@ export class UploadModal { ); } - // 2. Upload let explicitCredentials = null; if (this.storageService && this.isStorageUnlocked && this.hasValidR2Settings()) { - // If using secure storage, we pass the credentials explicitly so they aren't saved to legacy DB - explicitCredentials = this.collectSettingsForm(); + explicitCredentials = this.collectR2SettingsForm(); } await this.r2Service.uploadVideo({ @@ -1006,12 +1267,11 @@ export class UploadModal { thumbnailFile, torrentFile, metadata, - infoHash: hasValidInfoHash ? normalizedInfoHash : "", - settingsInput: explicitCredentials ? null : this.collectSettingsForm(), + infoHash: hasValidInfoHash ? infoHash : "", + settingsInput: explicitCredentials ? null : this.collectR2SettingsForm(), explicitCredentials, publishVideoNote: this.publishVideoNote, onReset: () => this.resetForm(), - // Pass forced keys/URLs to ensure what's in the torrent matches where we upload forcedVideoKey: videoKey, forcedVideoUrl: videoPublicUrl, forcedTorrentKey: torrentKey, @@ -1019,6 +1279,125 @@ export class UploadModal { }); } + async handleS3UploadFlow(metadata) { + const file = this.inputs.file?.files?.[0]; + if (!file) throw new Error("Please select a video file to upload."); + + const thumbnailFile = this.inputs.thumbnailFile?.files?.[0]; + + if (!this.hasValidS3Settings()) throw new Error("Please configure S3 storage credentials."); + if (!this.s3Service) throw new Error("S3 upload service is unavailable."); + + const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + if (!pubkey) throw new Error("Please login to publish."); + + const npub = this.safeEncodeNpub(pubkey); + const settings = this.collectS3SettingsForm(); + + let videoKey = ""; + let videoPublicUrl = ""; + let torrentKey = ""; + let torrentPublicUrl = ""; + + try { + videoKey = buildR2Key(npub, file); + videoPublicUrl = buildS3ObjectUrl({ + publicBaseUrl: settings.publicBaseUrl, + endpoint: settings.endpoint, + bucket: settings.bucket, + key: videoKey, + forcePathStyle: settings.forcePathStyle, + }); + const baseKey = videoKey.replace(/\.[^/.]+$/, ""); + torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`; + torrentPublicUrl = buildS3ObjectUrl({ + publicBaseUrl: settings.publicBaseUrl, + endpoint: settings.endpoint, + bucket: settings.bucket, + key: torrentKey, + forcePathStyle: settings.forcePathStyle, + }); + } catch (prepErr) { + userLogger.warn("Failed to pre-calculate S3 keys:", prepErr); + } + + const { infoHash, torrentFile, hasValidInfoHash } = await this.generateTorrentMetadata({ + file, + videoPublicUrl, + }); + + if (!hasValidInfoHash) { + const proceed = confirm( + "We couldn't calculate a valid info hash. Publishing will continue with URL-first playback only, and WebTorrent fallback will be unavailable. Continue?" + ); + if (!proceed) { + this.updateUploadStatus( + "Upload canceled. A valid info hash is required for WebTorrent fallback.", + "warning" + ); + this.isUploading = false; + this.submitButton.disabled = false; + this.updateProgress(null); + return; + } + this.updateUploadStatus( + "Continuing without WebTorrent fallback (info hash unavailable).", + "warning" + ); + } + + await this.s3Service.uploadVideo({ + npub, + file, + thumbnailFile, + torrentFile, + metadata, + infoHash: hasValidInfoHash ? infoHash : "", + settings, + createBucketIfMissing: settings.createBucketIfMissing, + publishVideoNote: this.publishVideoNote, + onReset: () => this.resetForm(), + forcedVideoKey: videoKey, + forcedVideoUrl: videoPublicUrl, + forcedTorrentKey: torrentKey, + forcedTorrentUrl: torrentPublicUrl, + }); + } + + async generateTorrentMetadata({ file, videoPublicUrl } = {}) { + this.isUploading = true; + this.submitButton.disabled = true; + this.updateUploadStatus("Calculating Info Hash...", "info"); + this.updateProgress(0); + + 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); + const hasValidInfoHash = isValidInfoHash(normalizedInfoHash); + + return { + infoHash: normalizedInfoHash, + torrentFile, + hasValidInfoHash, + }; + } + async handleExternalFlow(metadata) { metadata.url = this.inputs.url?.value?.trim() || ""; metadata.magnet = this.inputs.magnet?.value?.trim() || ""; diff --git a/js/ui/initUploadModal.js b/js/ui/initUploadModal.js index 1849bbbf..466d48dc 100644 --- a/js/ui/initUploadModal.js +++ b/js/ui/initUploadModal.js @@ -14,6 +14,7 @@ export function initUploadModal({ const { authService = null, r2Service = null, + s3Service = null, storageService = null, } = services; @@ -38,6 +39,7 @@ export function initUploadModal({ new UploadModal({ authService, r2Service, + s3Service, storageService, publishVideoNote, removeTrackingScripts: removeTrackingScriptsFn,