mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
31c2d8fae2
commit
a0ef54434a
4 files changed
+246
-31
No files matched your search
@@ -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 →
|
||||
</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>
|
||||
|
||||
@@ -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,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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user