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.
-
+
+
Account ID
+
+
+ Next
+
+
+
+
+
-
-
- Save Credentials
-
+
+
+
+
+ Back
+ Verify & Save
+
+
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");
}
}