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.
This commit is contained in:
google-labs-jules[bot] committed 2026-01-12 20:16:30 +00:00
1 parent 31c2d8fae2
commit a0ef54434a
4 files changed
+246 -31

No files matched your search

+33 -16
View File
@@ -156,33 +156,50 @@
<p class="text-[10px] text-muted">Create a bucket with this exact name in Cloudflare R2.</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<!-- 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</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx..." />
</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 Bucket URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-[10px] text-muted leading-tight">
New buckets are private. Enable <strong>Public Access</strong> in the Cloudflare dashboard to get your r2.dev URL.
Enable <strong>Public Access</strong> in the Cloudflare dashboard to get your r2.dev URL.
</p>
</div>
</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-save-settings" class="btn btn-sm">Save Credentials</button>
</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">
Open Cloudflare Dashboard &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>
</div>
+75
View File
@@ -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;
+25
View File
@@ -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,
+113 -15
View File
@@ -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");
}
}