From a0ef54434a0f7ab64a736032017abe1490536479 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:16:30 +0000 Subject: [PATCH] Implement 2-step wizard for R2 credentials with public access verification - Added `deleteObject` to `js/storage/r2-s3.js` for cleanup. - Added `verifyPublicAccess` to `js/services/r2Service.js` to test bucket configuration. - Refactored `components/upload-modal.html` to support wizard steps. - Updated `js/ui/components/UploadModal.js` to manage wizard state and trigger verification. --- components/upload-modal.html | 49 ++++++++---- js/services/r2Service.js | 75 +++++++++++++++++++ js/storage/r2-s3.js | 25 +++++++ js/ui/components/UploadModal.js | 128 ++++++++++++++++++++++++++++---- 4 files changed, 246 insertions(+), 31 deletions(-) diff --git a/components/upload-modal.html b/components/upload-modal.html index 3f5d1ab6..6d19c2ba 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -156,33 +156,50 @@

Create a bucket with this exact name in Cloudflare R2.

-
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + + -
-
- - -
-
- - -
-
-
- -
+ + + +
+ + +
+
diff --git a/js/services/r2Service.js b/js/services/r2Service.js index d79fa2b7..ed55df9b 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -20,6 +20,7 @@ import { multipartUpload, ensureBucketCors, ensureBucketExists, + deleteObject, } from "../storage/r2-s3.js"; import { truncateMiddle } from "../utils/formatters.js"; import { userLogger } from "../utils/logger.js"; @@ -403,6 +404,80 @@ class R2Service { }; } + async verifyPublicAccess({ settings, npub }) { + if (!settings || !npub) { + return { success: false, error: "Missing settings or npub." }; + } + + const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings; + if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) { + return { success: false, error: "Incomplete credentials or missing public URL." }; + } + + const bucketName = sanitizeBucketName(npub); + const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`; + const verifyContent = "bitvid-verification"; + const publicUrl = buildPublicUrl(baseDomain, verifyKey); + + try { + // 1. Initialize S3 + const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey }); + + // 2. Ensure bucket/cors (best effort) + try { + await ensureBucketExists({ s3, bucket: bucketName }); + const corsOrigins = this.getCorsOrigins(); + if (corsOrigins.length > 0) { + await ensureBucketCors({ s3, bucket: bucketName, origins: corsOrigins }); + } + } catch (setupErr) { + userLogger.warn("Bucket setup warning during verification:", setupErr); + // Continue, assuming bucket might already exist and be configured + } + + // 3. Upload Test File + const file = new File([verifyContent], "verify.txt", { type: "text/plain" }); + await multipartUpload({ + s3, + bucket: bucketName, + key: verifyKey, + file, + contentType: "text/plain", + }); + + // 4. Verify Public Access (Fetch) + // Wait a moment for propagation (R2 is usually instant-ish but helpful to wait) + await new Promise((r) => setTimeout(r, 500)); + + const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" }); + + if (!response.ok) { + // Cleanup attempt + try { await deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} + + if (response.status === 404) { + return { success: false, error: "File not found. Check your Public Bucket URL." }; + } + return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` }; + } + + const text = await response.text(); + + // Cleanup + try { await deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} + + if (text.trim() !== verifyContent) { + return { success: false, error: "Content mismatch. URL might be pointing elsewhere." }; + } + + return { success: true }; + + } catch (err) { + userLogger.error("Verification failed:", err); + return { success: false, error: err.message || "Unknown error during verification." }; + } + } + async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) { // No-op for now or just simplified text, since we rely on user input mostly. return; diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index 8a7f5f98..f638f4c2 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -25,6 +25,7 @@ function assignAwsSdk(module) { AbortMultipartUploadCommand: module.AbortMultipartUploadCommand, HeadBucketCommand: module.HeadBucketCommand, PutBucketCorsCommand: module.PutBucketCorsCommand, + DeleteObjectCommand: module.DeleteObjectCommand, }; } @@ -199,6 +200,30 @@ export async function ensureBucketCors({ s3, bucket, origins }) { await s3.send(command); } +export async function deleteObject({ s3, bucket, key }) { + if (!s3 || !bucket || !key) { + throw new Error("Missing required parameters for deleteObject"); + } + + const { DeleteObjectCommand } = requireAwsSdk(); + + try { + await s3.send( + new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + }) + ); + } catch (error) { + // We treat 404 (NoSuchKey) as success since the object is gone. + const status = error?.$metadata?.httpStatusCode || 0; + const code = error?.name || error?.Code || ""; + if (status !== 404 && code !== "NoSuchKey") { + throw error; + } + } +} + export async function multipartUpload({ s3, bucket, diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 15f2638e..bf73a5ff 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -170,6 +170,17 @@ export class UploadModal { 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"), @@ -179,7 +190,7 @@ export class UploadModal { advanced: $("#btn-advanced-toggle"), storageSettings: $("#btn-storage-settings"), - saveSettings: $("#btn-save-settings"), + saveSettings: $("#btn-save-settings"), // Verify & Save browseThumbnail: $("#btn-thumbnail-file"), r2HelpLink: $("#link-r2-help"), copyBucket: $("#btn-copy-bucket"), @@ -210,9 +221,19 @@ export class UploadModal { this.handleSubmit(); }); - // Settings Save + // 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) this.toggles.saveSettings.addEventListener("click", async () => { - await this.handleSaveSettings(); + await this.handleVerifyAndSave(); }); // Help Link @@ -427,22 +448,99 @@ export class UploadModal { }; } - async handleSaveSettings() { - const settings = this.collectSettingsForm(); - await this.r2Service.saveSettings(settings); - this.cloudflareSettings = settings; - this.updateStorageStatus(this.hasValidR2Settings()); + goToVerificationStep() { + // Validate Step 1 + const accountId = this.inputs.r2Account?.value?.trim(); + const keyId = this.inputs.r2Key?.value?.trim(); + const secret = this.inputs.r2Secret?.value?.trim(); - // Visual feedback + if (!accountId || !keyId || !secret) { + alert("Please fill in Account ID, 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() { + this.wizard.step2.classList.add("hidden"); + this.wizard.step1.classList.remove("hidden"); + this.wizard.errorContainer.classList.add("hidden"); + } + + async handleVerifyAndSave() { const btn = this.toggles.saveSettings; const originalText = btn.textContent; - btn.textContent = "Saved!"; - setTimeout(() => btn.textContent = originalText, 2000); - // Collapse if valid - if (this.hasValidR2Settings()) { - this.sourceSections.settings.classList.add("hidden"); - this.toggles.storageSettings.setAttribute("aria-expanded", "false"); + // Clear previous error + this.wizard.errorContainer.classList.add("hidden"); + + const settings = this.collectSettingsForm(); + const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + const npub = pubkey ? this.safeEncodeNpub(pubkey) : null; + + if (!settings.baseDomain) { + this.wizard.errorText.textContent = "Public Bucket 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) { + 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..."; + await this.r2Service.saveSettings(settings); + this.cloudflareSettings = settings; + this.updateStorageStatus(true); + + 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(); + }, 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"); } }