Merge pull request #1657 from PR0M3TH3AN/codex/add-s3-provider-selector-to-upload-modal

Add S3 provider support to upload modal storage settings
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-24 20:00:14 -05:00
commit 874c29e41e
10 files changed
+1111 -148

No files matched your search

+130 -51
View File
@@ -131,7 +131,7 @@
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage Storage</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="block text-muted">Provider</span>
<span id="summary-provider" class="font-medium text-text">--</span>
@@ -140,9 +140,13 @@
<span class="block text-muted">Bucket</span>
<span id="summary-bucket" class="font-medium text-text">--</span>
</div>
<div>
<span class="block text-muted">URL Style</span>
<span id="summary-url-style" class="font-medium text-text">--</span>
</div>
</div>
<p class="text-xs text-muted">
Your videos will be uploaded to this bucket.
<p id="summary-copy" class="text-xs text-muted">
Your videos will be uploaded using this storage provider.
</p>
</div>
@@ -156,76 +160,151 @@
<p class="text-xs text-muted">
Required for direct file uploads.
<a href="#view=upload-content" id="link-r2-help" class="text-accent hover:underline font-medium ml-1">
Read the setup guide.
Read the storage setup guide.
</a>
</p>
</div>
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<!-- Provider Selector -->
<div class="flex justify-center">
<div
class="inline-flex items-center rounded-full bg-surface-alt p-1 text-xs shadow-inner"
role="group"
aria-label="Storage provider selector"
>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
id="btn-storage-provider-r2"
class="rounded-full px-4 py-1 font-medium transition-colors text-text bg-surface shadow-sm"
aria-pressed="true"
>
Copy
Cloudflare
</button>
<button
type="button"
id="btn-storage-provider-s3"
class="rounded-full px-4 py-1 font-medium transition-colors text-muted hover:text-text"
aria-pressed="false"
>
S3 Compatible
</button>
</div>
<p class="text-4xs text-muted">Create a bucket with this exact name in your storage provider.</p>
</div>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div id="section-storage-r2" class="space-y-4">
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID / Endpoint</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx... or https://..." />
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
>
Copy
</button>
</div>
<p class="text-4xs text-muted">Create a bucket with this exact name in Cloudflare R2.</p>
</div>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID / Endpoint</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx... or https://..." />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
</div>
</div>
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-4xs text-muted leading-tight">
The public URL where your files will be accessible.
</p>
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Check Provider Settings &rarr;
</a>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
</div>
</div>
</div>
<div id="section-storage-s3" class="hidden space-y-4">
<p class="text-xs text-muted">
Configure a generic S3-compatible bucket for direct uploads.
</p>
<div class="space-y-1">
<label for="input-s3-endpoint" class="text-xs font-medium text-text">Endpoint</label>
<input id="input-s3-endpoint" type="text" class="input text-xs" placeholder="https://host[:port]" />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
<div class="space-y-1">
<label for="input-s3-region" class="text-xs font-medium text-text">Region (optional)</label>
<input id="input-s3-region" type="text" class="input text-xs" placeholder="auto" />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
<label for="input-s3-bucket" class="text-xs font-medium text-text">Bucket (explicit override if needed)</label>
<input id="input-s3-bucket" type="text" class="input text-xs" placeholder="my-bucket" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-s3-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-s3-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-s3-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-s3-secret" type="password" class="input text-xs" />
</div>
</div>
</div>
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-4xs text-muted leading-tight">
The public URL where your files will be accessible.
</p>
<label for="input-s3-public-base" class="text-xs font-medium text-text">Public Base URL Override</label>
<input id="input-s3-public-base" type="text" class="input text-xs" placeholder="https://cdn.example.com/bucket" />
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Check Provider Settings &rarr;
</a>
<div class="flex flex-wrap gap-4 text-xs text-muted">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" id="check-s3-path-style" class="checkbox" />
<span>Force path-style URLs</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" id="check-s3-create-bucket" class="checkbox" />
<span>Create bucket if missing</span>
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
<button type="button" id="btn-save-s3-settings" class="btn btn-sm">Verify & Save</button>
</div>
</div>
</div>
@@ -429,4 +508,4 @@
</form>
</div>
</div>
</div>
</div>
+5 -1
View File
@@ -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;
}
+2
View File
@@ -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,
});
+11 -8
View File
@@ -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 : [];
+336
View File
@@ -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;
+155
View File
@@ -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;
}
+1
View File
@@ -70,6 +70,7 @@ export default class ModalManager {
services: {
authService: app.authService,
r2Service: app.r2Service,
s3Service: app.s3Service,
storageService: this.services.storageService,
},
utilities: {
+2
View File
@@ -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 ||
+467 -88
View File
@@ -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() || "";
+2
View File
@@ -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,