mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- Add `js/s3-quick.js` with `uploadWithManifest` and `uploadWithKeys`. - Update `components/upload-modal.html` to add "Use presigned manifest" toggle and warning. - Update `js/ui/components/UploadModal.js` to wire up the manifest upload flow. - Add local persistence for quick S3 settings (though mainly handled by existing storage settings). - Ensure keys are only used for client-side uploads or replaced by manifest flow.
1620 lines
58 KiB
JavaScript
1620 lines
58 KiB
JavaScript
// components/UploadModal.js
|
|
import { createModalAccessibility } from "./modalAccessibility.js";
|
|
import { Nip71FormManager } from "./nip71FormManager.js";
|
|
import { userLogger } from "../../utils/logger.js";
|
|
import {
|
|
getVideoNoteErrorMessage,
|
|
normalizeVideoNotePayload,
|
|
VIDEO_NOTE_ERROR_CODES,
|
|
} from "../../services/videoNotePayload.js";
|
|
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,
|
|
} from "../../nostrClientFacade.js";
|
|
import { uploadWithManifest } from "../../s3-quick.js";
|
|
|
|
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
|
|
|
function normalizeInfoHash(value) {
|
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
}
|
|
|
|
function isValidInfoHash(value) {
|
|
return INFO_HASH_PATTERN.test(value);
|
|
}
|
|
|
|
export class UploadModal {
|
|
constructor({
|
|
authService,
|
|
r2Service,
|
|
s3Service,
|
|
storageService,
|
|
publishVideoNote,
|
|
removeTrackingScripts,
|
|
setGlobalModalState,
|
|
showError,
|
|
showSuccess,
|
|
getCurrentPubkey,
|
|
safeEncodeNpub,
|
|
eventTarget,
|
|
container,
|
|
onRequestStorageSettings,
|
|
} = {}) {
|
|
this.authService = authService || null;
|
|
this.r2Service = r2Service || null;
|
|
this.s3Service = s3Service || null;
|
|
this.storageService = storageService || null;
|
|
this.publishVideoNote =
|
|
typeof publishVideoNote === "function" ? publishVideoNote : null;
|
|
this.removeTrackingScripts =
|
|
typeof removeTrackingScripts === "function"
|
|
? removeTrackingScripts
|
|
: () => {};
|
|
this.setGlobalModalState =
|
|
typeof setGlobalModalState === "function"
|
|
? setGlobalModalState
|
|
: () => {};
|
|
this.showError = typeof showError === "function" ? showError : () => {};
|
|
this.showSuccess =
|
|
typeof showSuccess === "function" ? showSuccess : () => {};
|
|
this.getCurrentPubkey =
|
|
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
|
|
this.safeEncodeNpub =
|
|
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
|
|
this.eventTarget =
|
|
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
|
this.container = container || null;
|
|
this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null;
|
|
|
|
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;
|
|
|
|
// UI References
|
|
this.form = null;
|
|
this.modeButtons = {};
|
|
this.sourceSections = {};
|
|
this.inputs = {};
|
|
this.toggles = {};
|
|
this.submitButton = null;
|
|
this.submitStatus = null;
|
|
this.summaryView = {};
|
|
|
|
// Logic/State
|
|
this.nip71FormManager = new Nip71FormManager();
|
|
this.r2Unsubscribes = [];
|
|
this.cleanupHandlers = [];
|
|
this.modalAccessibility = null;
|
|
this.loadPromise = null;
|
|
|
|
// Automation
|
|
this.summaryLocked = true;
|
|
this.isUploading = false;
|
|
}
|
|
|
|
// --- Core Lifecycle ---
|
|
|
|
async load({ container } = {}) {
|
|
if (this.root && this.root.isConnected) return this.root;
|
|
if (this.loadPromise) return this.loadPromise;
|
|
|
|
this.loadPromise = (async () => {
|
|
const targetContainer =
|
|
container || this.container || document.getElementById("modalContainer");
|
|
if (!targetContainer) throw new Error("Modal container not found!");
|
|
|
|
// Cleanup existing
|
|
targetContainer.querySelectorAll("#uploadModal").forEach(n => n.remove());
|
|
|
|
const response = await fetch("components/upload-modal.html");
|
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
const html = await response.text();
|
|
const doc = targetContainer.ownerDocument || document;
|
|
const wrapper = doc.createElement("div");
|
|
wrapper.innerHTML = html;
|
|
this.removeTrackingScripts(wrapper);
|
|
|
|
targetContainer.appendChild(wrapper.firstElementChild);
|
|
this.root = targetContainer.querySelector("#uploadModal");
|
|
this.container = targetContainer;
|
|
|
|
this.cacheElements();
|
|
this.bindEvents();
|
|
this.setupModalAccessibility();
|
|
this.registerStorageSubscriptions();
|
|
|
|
// Initial State
|
|
if (this.storageService) {
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
|
|
// 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.loadLegacyStorageSettings();
|
|
}
|
|
this.updateLockUi();
|
|
this.setSourceMode("upload");
|
|
|
|
return this.root;
|
|
})();
|
|
|
|
try {
|
|
return await this.loadPromise;
|
|
} finally {
|
|
this.loadPromise = null;
|
|
}
|
|
}
|
|
|
|
cacheElements() {
|
|
const $ = (sel) => this.root.querySelector(sel);
|
|
|
|
this.form = $("#unifiedUploadForm");
|
|
this.submitButton = $("#btn-submit");
|
|
this.submitStatus = $("#submit-status");
|
|
this.closeButton = $("#closeUploadModal");
|
|
|
|
// Mode Switchers
|
|
this.modeButtons = {
|
|
upload: $("#btn-mode-upload"),
|
|
external: $("#btn-mode-external"),
|
|
};
|
|
|
|
// Sections
|
|
this.sourceSections = {
|
|
upload: $("#section-source-upload"),
|
|
external: $("#section-source-external"),
|
|
settings: $("#section-storage-settings"),
|
|
advanced: $("#section-advanced"),
|
|
progress: $("#upload-progress-container"),
|
|
};
|
|
|
|
this.storageViews = {
|
|
summary: $("#storage-summary-view"),
|
|
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"),
|
|
};
|
|
|
|
this.toggles.s3Manifest = $("#quickS3ManifestToggle");
|
|
this.inputs.s3ManifestJson = $("#quickS3ManifestJson");
|
|
this.containers = {
|
|
s3Credentials: $("#quickS3CredentialsContainer"),
|
|
s3Manifest: $("#quickS3ManifestContainer"),
|
|
};
|
|
|
|
// Inputs (Common)
|
|
this.inputs = {
|
|
title: $("#input-title"),
|
|
description: $("#input-description"),
|
|
thumbnail: $("#input-thumbnail"),
|
|
thumbnailFile: $("#input-thumbnail-file"),
|
|
file: $("#input-file"),
|
|
url: $("#input-url"),
|
|
magnet: $("#input-magnet"),
|
|
|
|
// Settings
|
|
r2BucketName: $("#input-r2-bucket-name"),
|
|
r2Account: $("#input-r2-account"),
|
|
r2Key: $("#input-r2-key"),
|
|
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"),
|
|
summary: $("#input-summary"),
|
|
contentWarning: $("#input-content-warning"),
|
|
duration: $("#input-duration"),
|
|
|
|
// Progress
|
|
progress: $("#input-progress"),
|
|
};
|
|
|
|
// Wizard Containers
|
|
this.wizard = {
|
|
step1: $("#step-credentials"),
|
|
step2: $("#step-verification"),
|
|
nextBtn: $("#btn-next-step"),
|
|
backBtn: $("#btn-back-step"),
|
|
errorContainer: $("#container-verification-error"),
|
|
errorText: $("#text-verification-error"),
|
|
guideLink: $("#link-cloudflare-guide"),
|
|
};
|
|
|
|
// Toggles/Buttons
|
|
this.toggles = {
|
|
nsfw: $("#check-nsfw"),
|
|
kids: $("#check-kids"),
|
|
comments: $("#check-comments"),
|
|
summaryUnlock: $("#check-summary-unlock"),
|
|
|
|
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
|
|
this.statusText = {
|
|
storage: $("#storage-status"),
|
|
storageLock: $("#storage-lock-status"),
|
|
uploadMain: $("#upload-status-text"),
|
|
uploadPercent: $("#upload-percent-text"),
|
|
summaryProvider: $("#summary-provider"),
|
|
summaryBucket: $("#summary-bucket"),
|
|
summaryUrlStyle: $("#summary-url-style"),
|
|
summaryCopy: $("#summary-copy"),
|
|
};
|
|
|
|
this.nip71FormManager.registerSection("main", this.form);
|
|
}
|
|
|
|
bindEvents() {
|
|
// Mode Switching
|
|
this.modeButtons.upload.addEventListener("click", () => this.setSourceMode("upload"));
|
|
this.modeButtons.external.addEventListener("click", () => this.setSourceMode("external"));
|
|
|
|
// Toggles
|
|
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)
|
|
);
|
|
}
|
|
|
|
if (this.toggles.s3Manifest) {
|
|
this.toggles.s3Manifest.addEventListener("change", () => {
|
|
const checked = this.toggles.s3Manifest.checked;
|
|
if (this.containers.s3Credentials) {
|
|
this.containers.s3Credentials.classList.toggle("hidden", checked);
|
|
}
|
|
if (this.containers.s3Manifest) {
|
|
this.containers.s3Manifest.classList.toggle("hidden", !checked);
|
|
}
|
|
// Hide save button if manifest
|
|
if (this.toggles.saveS3Settings) {
|
|
this.toggles.saveS3Settings.parentElement.classList.toggle("hidden", checked);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Form Submission
|
|
this.form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
this.handleSubmit();
|
|
});
|
|
|
|
// Wizard: Step 1 Next
|
|
if (this.wizard.nextBtn) {
|
|
this.wizard.nextBtn.addEventListener("click", () => this.goToVerificationStep());
|
|
}
|
|
|
|
// Wizard: Step 2 Back
|
|
if (this.wizard.backBtn) {
|
|
this.wizard.backBtn.addEventListener("click", () => this.goToCredentialsStep());
|
|
}
|
|
|
|
// Settings Save (Verify & Save)
|
|
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) {
|
|
this.toggles.r2HelpLink.addEventListener("click", () => {
|
|
this.close();
|
|
});
|
|
}
|
|
|
|
// Copy Bucket Name
|
|
if (this.toggles.copyBucket && this.inputs.r2BucketName) {
|
|
this.toggles.copyBucket.addEventListener("click", () => {
|
|
const val = this.inputs.r2BucketName.value;
|
|
if (val) {
|
|
navigator.clipboard.writeText(val).then(() => {
|
|
const original = this.toggles.copyBucket.textContent;
|
|
this.toggles.copyBucket.textContent = "Copied!";
|
|
setTimeout(() => this.toggles.copyBucket.textContent = original, 1500);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Storage Unlock
|
|
if (this.toggles.storageUnlock) {
|
|
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
|
|
}
|
|
|
|
// Manage Storage
|
|
if (this.toggles.manageStorage) {
|
|
this.toggles.manageStorage.addEventListener("click", () => {
|
|
if (this.onRequestStorageSettings) {
|
|
this.close();
|
|
this.onRequestStorageSettings();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Automation
|
|
this.setupDescriptionMirror();
|
|
this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids);
|
|
this.setupNsfwToContentWarning();
|
|
this.setupThumbnailInput();
|
|
|
|
// Close
|
|
this.closeButton.addEventListener("click", () => this.close());
|
|
|
|
// NIP-71 Manager
|
|
this.nip71FormManager.bindSection("main");
|
|
}
|
|
|
|
// --- Logic & State ---
|
|
|
|
setSourceMode(mode) {
|
|
this.activeSource = mode;
|
|
|
|
// UI Updates
|
|
const isUpload = mode === "upload";
|
|
|
|
// Toggle Buttons
|
|
this.modeButtons.upload.setAttribute("aria-pressed", isUpload);
|
|
this.modeButtons.upload.classList.toggle("bg-surface", isUpload);
|
|
this.modeButtons.upload.classList.toggle("text-text", isUpload);
|
|
this.modeButtons.upload.classList.toggle("shadow-sm", isUpload);
|
|
this.modeButtons.upload.classList.toggle("text-muted", !isUpload);
|
|
|
|
this.modeButtons.external.setAttribute("aria-pressed", !isUpload);
|
|
this.modeButtons.external.classList.toggle("bg-surface", !isUpload);
|
|
this.modeButtons.external.classList.toggle("text-text", !isUpload);
|
|
this.modeButtons.external.classList.toggle("shadow-sm", !isUpload);
|
|
this.modeButtons.external.classList.toggle("text-muted", isUpload);
|
|
|
|
// Sections
|
|
if (isUpload) {
|
|
this.sourceSections.upload.classList.remove("hidden");
|
|
this.sourceSections.external.classList.add("hidden");
|
|
|
|
// Auto-show settings if missing or not configured
|
|
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
|
|
}
|
|
|
|
if (this.toggles.browseThumbnail) {
|
|
this.toggles.browseThumbnail.classList.remove("hidden");
|
|
}
|
|
|
|
// Populate derived bucket name
|
|
this.populateBucketName();
|
|
|
|
} else {
|
|
this.sourceSections.upload.classList.add("hidden");
|
|
this.sourceSections.external.classList.remove("hidden");
|
|
|
|
if (this.toggles.browseThumbnail) {
|
|
this.toggles.browseThumbnail.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
// Update Button Text
|
|
this.submitButton.textContent = isUpload ? "Upload & Publish" : "Publish Video";
|
|
}
|
|
|
|
setupAccordion(btn, section) {
|
|
if (!btn || !section) return;
|
|
btn.addEventListener("click", () => {
|
|
const isHidden = section.classList.contains("hidden");
|
|
if (isHidden) {
|
|
section.classList.remove("hidden");
|
|
btn.setAttribute("aria-expanded", "true");
|
|
// Rotate icon if exists
|
|
const icon = btn.querySelector("svg");
|
|
if (icon) icon.classList.add("rotate-90");
|
|
} else {
|
|
section.classList.add("hidden");
|
|
btn.setAttribute("aria-expanded", "false");
|
|
const icon = btn.querySelector("svg");
|
|
if (icon) icon.classList.remove("rotate-90");
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
const bucketName = sanitizeBucketName(npub);
|
|
this.inputs.r2BucketName.value = bucketName;
|
|
}
|
|
}
|
|
|
|
setupDescriptionMirror() {
|
|
const { description, summary } = this.inputs;
|
|
const { summaryUnlock } = this.toggles;
|
|
|
|
if (!description || !summary) return;
|
|
|
|
description.addEventListener("input", () => {
|
|
if (this.summaryLocked) summary.value = description.value;
|
|
});
|
|
|
|
if (summaryUnlock) {
|
|
summaryUnlock.addEventListener("change", () => {
|
|
this.summaryLocked = !summaryUnlock.checked;
|
|
summary.readOnly = this.summaryLocked;
|
|
if (this.summaryLocked) {
|
|
summary.value = description.value;
|
|
summary.classList.add("text-muted");
|
|
} else {
|
|
summary.classList.remove("text-muted");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
setupMutuallyExclusiveCheckboxes(a, b) {
|
|
if (!a || !b) return;
|
|
a.addEventListener("change", () => { if (a.checked) b.checked = false; });
|
|
b.addEventListener("change", () => { if (b.checked) a.checked = false; });
|
|
}
|
|
|
|
setupNsfwToContentWarning() {
|
|
const { nsfw } = this.toggles;
|
|
const { contentWarning } = this.inputs;
|
|
if (!nsfw || !contentWarning) return;
|
|
|
|
nsfw.addEventListener("change", () => {
|
|
if (nsfw.checked && !contentWarning.value) {
|
|
contentWarning.value = "NSFW";
|
|
} else if (!nsfw.checked && contentWarning.value === "NSFW") {
|
|
contentWarning.value = "";
|
|
}
|
|
});
|
|
}
|
|
|
|
setupThumbnailInput() {
|
|
const { thumbnailFile, thumbnail } = this.inputs;
|
|
const { browseThumbnail } = this.toggles;
|
|
|
|
if (!thumbnailFile || !browseThumbnail) return;
|
|
|
|
browseThumbnail.addEventListener("click", () => thumbnailFile.click());
|
|
|
|
thumbnailFile.addEventListener("change", () => {
|
|
const file = thumbnailFile.files?.[0];
|
|
if (file) {
|
|
thumbnail.value = ""; // Clear explicit URL
|
|
thumbnail.placeholder = `Selected: ${file.name}`;
|
|
thumbnail.disabled = true;
|
|
} else {
|
|
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
|
|
thumbnail.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- R2 Integration ---
|
|
|
|
async refreshState() {
|
|
if (this.storageService) {
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
|
|
this.updateLockUi();
|
|
|
|
await this.loadFromStorage();
|
|
} else if (this.r2Service && typeof this.r2Service.loadSettings === "function") {
|
|
await this.loadLegacyStorageSettings();
|
|
}
|
|
}
|
|
|
|
// Legacy fallback loader (renamed for clarity if desired, but keeping signature safe)
|
|
async loadR2Settings() {
|
|
// Fallback legacy load if storage service not used/available
|
|
if (!this.r2Service?.loadSettings) return;
|
|
const settings = await this.r2Service.loadSettings();
|
|
this.cloudflareSettings = settings || {};
|
|
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;
|
|
|
|
if (!pubkey) {
|
|
this.toggleStorageView("form");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
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") ||
|
|
connections.find(c => c.provider === PROVIDERS.GENERIC || c.provider === PROVIDERS.S3);
|
|
|
|
if (targetConn) {
|
|
this.storageConfigured = true;
|
|
this.toggleStorageView("summary");
|
|
|
|
// Populate Summary
|
|
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) {
|
|
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 {
|
|
// Locked state, can't get details yet
|
|
this.updateStorageStatus(false);
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
toggleStorageView(viewName) {
|
|
if (viewName === "summary") {
|
|
this.storageViews.summary.classList.remove("hidden");
|
|
this.storageViews.form.classList.add("hidden");
|
|
} else {
|
|
this.storageViews.summary.classList.add("hidden");
|
|
this.storageViews.form.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
async handleUnlock() {
|
|
if (!this.storageService) return;
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
if (!pubkey) return;
|
|
|
|
// We need a signer. Try active signer or authService
|
|
let signer = getActiveSigner();
|
|
if (!signer && this.authService?.signer) {
|
|
signer = this.authService.signer;
|
|
}
|
|
|
|
const canSign = typeof signer?.canSign === "function"
|
|
? signer.canSign()
|
|
: typeof signer?.signEvent === "function";
|
|
if (!canSign) {
|
|
alert("No signer available to unlock storage.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (signer?.type === "extension") {
|
|
const permissionResult = await requestDefaultExtensionPermissions();
|
|
if (!permissionResult?.ok) {
|
|
alert("Extension permissions are required to unlock storage.");
|
|
return;
|
|
}
|
|
}
|
|
if (this.toggles.storageUnlock) {
|
|
this.toggles.storageUnlock.textContent = "Unlocking...";
|
|
this.toggles.storageUnlock.disabled = true;
|
|
}
|
|
await this.storageService.unlock(pubkey, { signer });
|
|
this.isStorageUnlocked = true;
|
|
this.updateLockUi();
|
|
await this.loadFromStorage();
|
|
} catch (err) {
|
|
userLogger.error("Unlock failed", err);
|
|
alert("Failed to unlock storage: " + err.message);
|
|
} finally {
|
|
if (this.toggles.storageUnlock) {
|
|
this.toggles.storageUnlock.textContent = "Unlock";
|
|
this.toggles.storageUnlock.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
updateLockUi() {
|
|
const locked = !this.isStorageUnlocked;
|
|
if (this.statusText.storageLock) {
|
|
this.statusText.storageLock.textContent = locked ? "Locked 🔒" : "Unlocked 🔓";
|
|
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
|
|
}
|
|
if (this.toggles.storageUnlock) {
|
|
// Show unlock button only if locked AND we have a configuration to unlock
|
|
if (locked && this.storageConfigured) {
|
|
this.toggles.storageUnlock.classList.remove("hidden");
|
|
} else {
|
|
this.toggles.storageUnlock.classList.add("hidden");
|
|
}
|
|
}
|
|
if (this.statusText.storage) {
|
|
if (locked && this.storageConfigured) {
|
|
this.statusText.storage.classList.add("hidden"); // Summary view has its own status area
|
|
} else if (locked) {
|
|
this.statusText.storage.classList.add("hidden");
|
|
this.statusText.storage.textContent = "";
|
|
} else {
|
|
this.statusText.storage.classList.remove("hidden");
|
|
}
|
|
}
|
|
}
|
|
|
|
hasValidR2Settings() {
|
|
const s = this.cloudflareSettings;
|
|
return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey && s?.baseDomain);
|
|
}
|
|
|
|
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 || "";
|
|
}
|
|
|
|
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() || "",
|
|
secretAccessKey: this.inputs.r2Secret?.value?.trim() || "",
|
|
baseDomain: this.inputs.r2Domain?.value?.trim() || "", // Public URL
|
|
};
|
|
}
|
|
|
|
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();
|
|
const secret = this.inputs.r2Secret?.value?.trim();
|
|
|
|
if (!accountId || !keyId || !secret) {
|
|
alert("Please fill in Account ID/Endpoint, Access Key ID, and Secret Access Key.");
|
|
return;
|
|
}
|
|
|
|
// Transition
|
|
this.wizard.step1.classList.add("hidden");
|
|
this.wizard.step2.classList.remove("hidden");
|
|
|
|
// Auto-focus URL if empty
|
|
if (!this.inputs.r2Domain.value) {
|
|
this.inputs.r2Domain.focus();
|
|
}
|
|
}
|
|
|
|
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 handleVerifyAndSaveR2() {
|
|
const btn = this.toggles.saveSettings;
|
|
const originalText = btn.textContent;
|
|
|
|
// Clear previous error
|
|
this.wizard.errorContainer.classList.add("hidden");
|
|
|
|
const settings = this.collectR2SettingsForm();
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
const npub = pubkey ? this.safeEncodeNpub(pubkey) : null;
|
|
|
|
if (!settings.baseDomain) {
|
|
this.wizard.errorText.textContent = "Public URL is required.";
|
|
this.wizard.errorContainer.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
// 1. Verify
|
|
btn.disabled = true;
|
|
btn.textContent = "Verifying...";
|
|
|
|
try {
|
|
const verification = await this.r2Service.verifyPublicAccess({ settings, npub });
|
|
|
|
if (!verification.success) {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
|
|
this.wizard.errorText.textContent = verification.error || "Verification failed.";
|
|
this.wizard.errorContainer.classList.remove("hidden");
|
|
|
|
// Dynamic Cloudflare Link
|
|
// https://dash.cloudflare.com/?to=/:account/r2/default/buckets/:bucket/settings
|
|
if (settings.accountId && npub && !settings.accountId.includes("://")) {
|
|
const bucketName = this.inputs.r2BucketName?.value || "";
|
|
const cfLink = `https://dash.cloudflare.com/?to=/${settings.accountId}/r2/default/buckets/${bucketName}/settings`;
|
|
this.wizard.guideLink.href = cfLink;
|
|
} else {
|
|
this.wizard.guideLink.href = "https://dash.cloudflare.com";
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. Save on Success
|
|
btn.textContent = "Saving...";
|
|
|
|
if (this.storageService && this.isStorageUnlocked) {
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
if (pubkey) {
|
|
// Save to secure storage
|
|
const connectionId = this.activeConnectionId || `r2-${Date.now()}`;
|
|
|
|
// Heuristic for provider type
|
|
let provider = PROVIDERS.GENERIC;
|
|
let payload = {
|
|
accessKeyId: settings.accessKeyId,
|
|
secretAccessKey: settings.secretAccessKey,
|
|
};
|
|
|
|
// If account ID doesn't look like a URL, assume R2/S3-account-based
|
|
if (settings.accountId && !settings.accountId.includes("://")) {
|
|
provider = PROVIDERS.R2;
|
|
payload.accountId = settings.accountId;
|
|
} else {
|
|
payload.endpoint = settings.accountId;
|
|
}
|
|
|
|
await this.storageService.saveConnection(pubkey, connectionId, {
|
|
provider,
|
|
...payload,
|
|
}, {
|
|
provider,
|
|
label: provider === PROVIDERS.R2 ? "Default R2" : "Default S3",
|
|
baseDomain: settings.baseDomain,
|
|
defaultForUploads: true,
|
|
bucket: this.inputs.r2BucketName?.value || sanitizeBucketName(npub)
|
|
});
|
|
this.activeConnectionId = connectionId;
|
|
}
|
|
} else {
|
|
// Legacy fallback
|
|
await this.r2Service.saveSettings(settings);
|
|
}
|
|
|
|
this.cloudflareSettings = settings;
|
|
this.updateStorageStatus(true, this.getProviderLabel(PROVIDERS.R2));
|
|
|
|
btn.disabled = false;
|
|
btn.textContent = "Saved & Ready!";
|
|
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
// Collapse
|
|
this.sourceSections.settings.classList.add("hidden");
|
|
this.toggles.storageSettings.setAttribute("aria-expanded", "false");
|
|
// Reset wizard to start for next edit
|
|
this.goToCredentialsStep();
|
|
// Reload state to potentially switch to summary view
|
|
this.loadFromStorage();
|
|
}, 1500);
|
|
} catch (err) {
|
|
userLogger.error("Verification crashed:", err);
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
this.wizard.errorText.textContent = "Unexpected error during verification.";
|
|
this.wizard.errorContainer.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
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";
|
|
const label = providerLabel ? ` (${providerLabel})` : "";
|
|
this.statusText.storage.textContent = baseText + label;
|
|
this.statusText.storage.className = isValid ? "text-xs text-accent" : "text-xs text-critical";
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
|
|
updateProgress(fraction) {
|
|
const container = this.sourceSections.progress;
|
|
const bar = this.inputs.progress;
|
|
const txt = this.statusText.uploadPercent;
|
|
|
|
if (fraction === null || fraction < 0 || isNaN(fraction)) {
|
|
container.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
container.classList.remove("hidden");
|
|
const pct = Math.round(fraction * 100);
|
|
bar.value = pct;
|
|
txt.textContent = `${pct}%`;
|
|
}
|
|
|
|
updateUploadStatus(msg, variant) {
|
|
if (this.statusText.uploadMain) {
|
|
this.statusText.uploadMain.textContent = msg;
|
|
// Could style based on variant (error/success)
|
|
}
|
|
}
|
|
|
|
// --- Submission ---
|
|
|
|
async handleSubmit() {
|
|
if (this.isUploading) return;
|
|
|
|
const audienceFlags = {
|
|
isNsfw: this.toggles.nsfw?.checked || false,
|
|
isForKids: this.toggles.kids?.checked || false,
|
|
};
|
|
|
|
// Base Metadata
|
|
const metadata = {
|
|
title: this.inputs.title?.value?.trim() || "",
|
|
description: this.inputs.description?.value?.trim() || "",
|
|
thumbnail: this.inputs.thumbnail?.value?.trim() || "",
|
|
enableComments: this.toggles.comments?.checked || true,
|
|
...audienceFlags,
|
|
|
|
// These might be empty depending on mode, filled below
|
|
url: "",
|
|
magnet: "",
|
|
ws: this.inputs.ws?.value?.trim() || "",
|
|
xs: this.inputs.xs?.value?.trim() || "",
|
|
};
|
|
|
|
// NIP-71 Advanced Data
|
|
const nip71 = this.nip71FormManager.collectSection("main");
|
|
if (nip71) {
|
|
metadata.nip71 = nip71;
|
|
}
|
|
|
|
try {
|
|
if (this.activeSource === "upload") {
|
|
await this.handleUploadFlow(metadata);
|
|
} else {
|
|
await this.handleExternalFlow(metadata);
|
|
}
|
|
} catch (err) {
|
|
userLogger.error("Upload failed", err);
|
|
this.showError(err.message || "An unexpected error occurred.");
|
|
}
|
|
}
|
|
|
|
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 Cloudflare R2 credentials.");
|
|
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
if (!pubkey) throw new Error("Please login to publish.");
|
|
|
|
const npub = this.safeEncodeNpub(pubkey);
|
|
|
|
let videoKey = "";
|
|
let videoPublicUrl = "";
|
|
let torrentKey = "";
|
|
let torrentPublicUrl = "";
|
|
|
|
try {
|
|
const currentSettings = this.collectR2SettingsForm();
|
|
if (!currentSettings.baseDomain) {
|
|
throw new Error("Missing Public URL (Base Domain) in settings.");
|
|
}
|
|
|
|
videoKey = buildR2Key(npub, file);
|
|
videoPublicUrl = buildPublicUrl(currentSettings.baseDomain, videoKey);
|
|
|
|
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);
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
let explicitCredentials = null;
|
|
if (this.storageService && this.isStorageUnlocked && this.hasValidR2Settings()) {
|
|
explicitCredentials = this.collectR2SettingsForm();
|
|
}
|
|
|
|
await this.r2Service.uploadVideo({
|
|
npub,
|
|
file,
|
|
thumbnailFile,
|
|
torrentFile,
|
|
metadata,
|
|
infoHash: hasValidInfoHash ? infoHash : "",
|
|
settingsInput: explicitCredentials ? null : this.collectR2SettingsForm(),
|
|
explicitCredentials,
|
|
publishVideoNote: this.publishVideoNote,
|
|
onReset: () => this.resetForm(),
|
|
forcedVideoKey: videoKey,
|
|
forcedVideoUrl: videoPublicUrl,
|
|
forcedTorrentKey: torrentKey,
|
|
forcedTorrentUrl: torrentPublicUrl,
|
|
});
|
|
}
|
|
|
|
async handleS3UploadFlow(metadata) {
|
|
const file = this.inputs.file?.files?.[0];
|
|
if (!file) throw new Error("Please select a video file to upload.");
|
|
|
|
const isManifest = this.toggles.s3Manifest?.checked;
|
|
|
|
// Manifest Validation
|
|
let manifest = null;
|
|
if (isManifest) {
|
|
const json = this.inputs.s3ManifestJson?.value?.trim();
|
|
if (!json) throw new Error("Please paste the presigned manifest JSON.");
|
|
try {
|
|
manifest = JSON.parse(json);
|
|
} catch (e) {
|
|
throw new Error("Invalid Manifest JSON.");
|
|
}
|
|
} else {
|
|
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);
|
|
|
|
// If NOT using manifest, we proceed with standard flow (keys)
|
|
if (!isManifest) {
|
|
const thumbnailFile = this.inputs.thumbnailFile?.files?.[0];
|
|
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,
|
|
});
|
|
} else {
|
|
// Manifest Flow
|
|
this.isUploading = true;
|
|
this.submitButton.disabled = true;
|
|
this.updateUploadStatus("Uploading via manifest...", "info");
|
|
this.updateProgress(0);
|
|
|
|
try {
|
|
await uploadWithManifest({
|
|
file,
|
|
manifest,
|
|
onProgress: (fraction) => this.updateProgress(fraction),
|
|
});
|
|
|
|
this.updateUploadStatus("Processing metadata...", "info");
|
|
|
|
// We can't upload thumbnail/torrent without keys or manifest for them.
|
|
// We assume user handles that or we proceed with just the video.
|
|
// We need to publish the video note.
|
|
|
|
// Determine URL from manifest or use what we have?
|
|
// If manifest has `publicUrl`, use it.
|
|
let finalUrl = manifest.publicUrl || "";
|
|
|
|
// Update metadata
|
|
metadata.url = finalUrl || metadata.url;
|
|
if (!metadata.url) {
|
|
// If we can't determine URL, we can't really publish a useful video note unless we use magnet only?
|
|
// Or we warn the user.
|
|
// For now, we'll try to publish what we have.
|
|
userLogger.warn("Manifest upload complete but public URL is unknown. Video note might be incomplete.");
|
|
}
|
|
|
|
// Normalize & Publish
|
|
const { payload, errors } = normalizeVideoNotePayload(metadata);
|
|
if (errors.length) {
|
|
throw new Error(getVideoNoteErrorMessage(errors[0]));
|
|
}
|
|
|
|
if (this.publishVideoNote) {
|
|
this.updateUploadStatus("Publishing event...", "info");
|
|
await this.publishVideoNote(payload);
|
|
this.showSuccess("Video uploaded and published!");
|
|
this.close();
|
|
this.resetForm();
|
|
} else {
|
|
this.showSuccess("Upload complete (publishing skipped).");
|
|
}
|
|
|
|
} catch (err) {
|
|
userLogger.error("Manifest upload failed:", err);
|
|
this.showError(err.message || "Upload failed.");
|
|
} finally {
|
|
this.isUploading = false;
|
|
this.submitButton.disabled = false;
|
|
this.updateProgress(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
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() || "";
|
|
|
|
const hasUrl = metadata.url.length > 0;
|
|
const hasMagnet = metadata.magnet.length > 0;
|
|
const hasImeta = metadata.nip71?.imeta?.some(v => v.url);
|
|
|
|
if (!hasUrl && !hasMagnet && !hasImeta) {
|
|
throw new Error("Please provide at least a Video URL or Magnet Link.");
|
|
}
|
|
|
|
if (!hasUrl && !hasImeta && hasMagnet) {
|
|
if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return;
|
|
}
|
|
|
|
// Normalize & Publish
|
|
const { payload, errors } = normalizeVideoNotePayload(metadata);
|
|
if (errors.length) {
|
|
throw new Error(getVideoNoteErrorMessage(errors[0]));
|
|
}
|
|
|
|
// Simulate async publish
|
|
this.submitButton.disabled = true;
|
|
this.submitButton.textContent = "Publishing...";
|
|
|
|
try {
|
|
if (this.publishVideoNote) {
|
|
await this.publishVideoNote(payload);
|
|
this.showSuccess("Video published successfully!");
|
|
this.close();
|
|
this.resetForm();
|
|
}
|
|
} finally {
|
|
this.submitButton.disabled = false;
|
|
this.submitButton.textContent = "Publish Video";
|
|
}
|
|
}
|
|
|
|
resetForm() {
|
|
this.form.reset();
|
|
this.nip71FormManager.resetSection("main");
|
|
// Restore defaults
|
|
this.setSourceMode("upload");
|
|
this.toggles.comments.checked = true;
|
|
this.toggles.nsfw.checked = false;
|
|
this.toggles.kids.checked = false;
|
|
this.updateProgress(null);
|
|
this.populateBucketName(); // Re-populate if user logged in
|
|
|
|
// Reset thumbnail UI
|
|
if (this.inputs.thumbnail) {
|
|
this.inputs.thumbnail.disabled = false;
|
|
this.inputs.thumbnail.placeholder = "https://example.com/thumbnail.jpg";
|
|
}
|
|
if (this.inputs.thumbnailFile) {
|
|
this.inputs.thumbnailFile.value = "";
|
|
}
|
|
}
|
|
|
|
// --- Modal Control ---
|
|
|
|
open({ triggerElement } = {}) {
|
|
if (!this.root) return;
|
|
this.root.classList.remove("hidden");
|
|
this.setGlobalModalState("upload", true);
|
|
this.isVisible = true;
|
|
this.populateBucketName(); // Ensure bucket name is fresh on open
|
|
|
|
// Refresh lock state on open
|
|
if (this.storageService) {
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
|
|
this.updateLockUi();
|
|
if (this.isStorageUnlocked) {
|
|
this.loadFromStorage();
|
|
}
|
|
}
|
|
|
|
this.modalAccessibility?.activate({ triggerElement });
|
|
}
|
|
|
|
close() {
|
|
if (!this.root) return;
|
|
this.root.classList.add("hidden");
|
|
this.setGlobalModalState("upload", false);
|
|
this.isVisible = false;
|
|
this.modalAccessibility?.deactivate();
|
|
}
|
|
|
|
addEventListener(type, listener) {
|
|
this.eventTarget.addEventListener(type, listener);
|
|
}
|
|
|
|
emit(type, detail) {
|
|
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
|
}
|
|
|
|
getRoot() {
|
|
return this.root;
|
|
}
|
|
|
|
setupModalAccessibility() {
|
|
if (!this.root) return;
|
|
if (this.modalAccessibility?.destroy) this.modalAccessibility.destroy();
|
|
|
|
this.modalAccessibility = createModalAccessibility({
|
|
root: this.root,
|
|
backdrop: this.root.querySelector(".bv-modal-backdrop") || this.root,
|
|
panel: this.root.querySelector(".modal-sheet") || this.root,
|
|
onRequestClose: () => this.close()
|
|
});
|
|
}
|
|
|
|
destroy() {
|
|
this.r2Unsubscribes.forEach(u => u && u());
|
|
if (this.modalAccessibility?.destroy) this.modalAccessibility.destroy();
|
|
this.root = null;
|
|
this.container = null;
|
|
}
|
|
}
|