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 @@
-
-
-
-
+
+
+
+
-
Create a bucket with this exact name in your storage provider.
-
-
+
+
-
-
+
+
+
+
+
+
Create a bucket with this exact name in Cloudflare R2.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The public URL where your files will be accessible.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configure a generic S3-compatible bucket for direct uploads.
+
+
+
+
-
-
-
-
-
-
-
-
- The public URL where your files will be accessible.
-
+
+
-
-
-
-
Verification Failed
-
- Check Provider Settings →
-
+
+
+
-
-
-
+
@@ -429,4 +508,4 @@
-
\ 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,