From d7ad151698b6cf3aa139373ed6e124f59de0964c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:37:24 -0400 Subject: [PATCH] [ui] #38: add a CORS setup helper modal for B2 / S3-compatible buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed against the real B2 account: the web-console "Share everything..." presets write DOWNLOAD-only CORS (s3_get/s3_head, b2_download_*) with NO s3_put/s3_post, so bitvid's browser uploads CORS-fail. The web UI can't set custom rules — it points users at the B2 CLI. Adds a "CORS setup help" button (shown for B2 / Custom S3, hidden for R2) that opens a modal with: - the exact CORS rules JSON in B2-native shape (upload + ranged-playback ops; origins pre-filled from getCorsOrigins()), with a Copy button, - the `b2 update-bucket --corsRules '' allPublic` command to apply it, - step-by-step + the "use a key with writeBucketCors and bitvid sets it for you" tip. CORS logic extracted to js/ui/profileModal/storageCorsHelp.js (pure builders + StorageCorsHelp modal controller) so ProfileStorageController stays under the 1000-line cap. Tests: tests/storage-cors-help.test.mjs (8 — asserts the rules include the upload ops the presets omit, origin pre-fill, command shape, modal open/close/visibility). Build + lint clean; profile-modal + storage suites 32/32. Co-Authored-By: Claude Opus 4.8 --- components/profile-modal.html | 88 +++++++++++ .../profileModal/ProfileStorageController.js | 45 +++--- js/ui/profileModal/storageCorsHelp.js | 142 ++++++++++++++++++ tests/storage-cors-help.test.mjs | 126 ++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 21 ++- 5 files changed, 397 insertions(+), 25 deletions(-) create mode 100644 js/ui/profileModal/storageCorsHelp.js create mode 100644 tests/storage-cors-help.test.mjs diff --git a/components/profile-modal.html b/components/profile-modal.html index bf2f614d..a677f88d 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -973,6 +973,9 @@ +

@@ -1838,3 +1841,88 @@ + + + diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 97b5ac99..65e47d10 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -8,6 +8,7 @@ import { import { getActiveSigner } from "../../nostr/client.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; import { storageSyncService } from "../../services/storageSyncService.js"; +import { StorageCorsHelp } from "./storageCorsHelp.js"; export class ProfileStorageController { constructor(mainController) { @@ -34,6 +35,10 @@ export class ProfileStorageController { this.storageR2Helper = null; this.storageB2Helper = null; this.storageS3Helper = null; + // CORS setup helper modal (B2 / Custom S3) — owns its own DOM + behavior. + this.corsHelp = new StorageCorsHelp({ + getBucket: () => this.storageBucketInput?.value?.trim() || "", + }); this.storageForcePathStyleInput = null; this.storageForcePathStyleLabel = null; @@ -67,6 +72,7 @@ export class ProfileStorageController { this.storageR2Helper = document.getElementById("storageR2Helper") || null; this.storageB2Helper = document.getElementById("storageB2Helper") || null; this.storageS3Helper = document.getElementById("storageS3Helper") || null; + this.corsHelp.cacheDom(document); this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null; this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null; this.storageSyncSection = document.getElementById("profileStorageSync") || null; @@ -123,6 +129,8 @@ export class ProfileStorageController { void this.handleRestoreSync(); }); } + + this.corsHelp.registerEventListeners(); } async populateStoragePane() { @@ -406,8 +414,7 @@ export class ProfileStorageController { updateStorageFormVisibility() { const provider = this.storageProviderInput?.value || "cloudflare_r2"; const isR2 = provider === "cloudflare_r2"; - // B2 derives its endpoint from the region (s3..backblazeb2.com), so the - // raw Endpoint field is hidden and the region drives the connection. + // B2 derives its endpoint from the region, so its raw Endpoint field is hidden. const isB2 = provider === PROVIDERS.B2; if (this.storageEndpointInput) { @@ -461,8 +468,7 @@ export class ProfileStorageController { } if (this.storageForcePathStyleLabel) { - // Force-path-style is a generic-S3 knob; R2 (account-derived) and B2 - // (virtual-hosted) fix it internally, so hide the toggle for both. + // Force-path-style is a generic-S3 knob; R2 and B2 fix it internally. const showForcePathStyle = !isR2 && !isB2; this.storageForcePathStyleLabel.classList.toggle( "hidden", @@ -473,6 +479,9 @@ export class ProfileStorageController { showForcePathStyle, ); } + + // Manual CORS helper is for S3-compatible providers (B2 / Custom S3), not R2. + this.corsHelp.setVisible(!isR2); } getStorageUnlockFailureMessage(error) { @@ -515,12 +524,10 @@ export class ProfileStorageController { this.storageUnlockFailure = null; } - // After a page reload, a persisted ("remember this key") nsec session restores the - // logged-in pubkey + UI but NOT the in-memory signer — the private key is held only - // passphrase-encrypted. Storage unlock then has no signer (or a decrypt-less stub), - // and the generic "No active signer / cannot decrypt" errors are misleading because - // the user still looks logged in. Detect this exact case (a saved nsec key for the - // account we're unlocking) so we can tell them to re-unlock their saved key instead. + // After a page reload, a persisted nsec session restores the logged-in pubkey + UI + // but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then + // has no usable signer, and the generic "No active signer" error is misleading. + // Detect this exact case (a saved nsec key for the account we're unlocking). getLockedStoredNsecSession(pubkey) { const client = this.mainController.services?.nostrClient; if (!client || typeof client.getStoredSessionActorMetadata !== "function") { @@ -555,11 +562,8 @@ export class ProfileStorageController { this.setStorageUnlockFailureState(error); this.mainController.showError(this.getStorageUnlockFailureMessage(error)); - // When the user actively clicked "Unlock Storage", open the login modal straight - // to its existing unlock-saved-key (passphrase) flow so they can re-unlock in one - // step. After they unlock, the signer is active app-wide and re-opening the storage - // pane auto-unlocks. We don't auto-open from the passive pane render (would be - // surprising); the status text + button still guide them there. + // On an explicit "Unlock Storage" click, open the login modal's unlock-saved-key + // (passphrase) flow so the user re-unlocks in one step (not from passive render). if (autoOpenLogin) { const openLoginModal = this.mainController.services?.openLoginModal; if (typeof openLoginModal === "function") { @@ -758,9 +762,8 @@ export class ProfileStorageController { const prefix = this.storagePrefixInput?.value?.trim() || ""; const isDefault = this.storageDefaultInput?.checked || false; - // B2: the endpoint isn't entered — derive it from the region. if (provider === PROVIDERS.B2) { - endpointOrAccount = deriveB2Endpoint(region); + endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2 if (!endpointOrAccount) { this.setStorageFormStatus( "Enter your Backblaze B2 region (e.g. us-west-004).", @@ -811,9 +814,8 @@ export class ProfileStorageController { secretAccessKey, bucket, forcePathStyle, - // Honor an explicit Public Access URL (e.g. a B2 custom domain / CDN in front - // of the bucket); when blank, validateS3Connection derives it from the - // endpoint + bucket (for B2: https://.s3..backblazeb2.com). + // Honor an explicit Public Access URL (custom domain / CDN); when blank, + // validateS3Connection derives it from the endpoint + bucket. publicBaseUrl: prefix || undefined, origins: getCorsOrigins(), }); @@ -910,9 +912,8 @@ export class ProfileStorageController { const publicBaseUrl = this.storagePrefixInput?.value?.trim() || ""; - // B2: the endpoint isn't entered — derive it from the region. if (provider === PROVIDERS.B2) { - endpointOrAccount = deriveB2Endpoint(region); + endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2 if (!endpointOrAccount) { this.setStorageFormStatus( "Enter your Backblaze B2 region (e.g. us-west-004) to test.", diff --git a/js/ui/profileModal/storageCorsHelp.js b/js/ui/profileModal/storageCorsHelp.js new file mode 100644 index 00000000..b62b8aca --- /dev/null +++ b/js/ui/profileModal/storageCorsHelp.js @@ -0,0 +1,142 @@ +// Storage CORS setup helper (B2 / S3-compatible providers). +// +// Browser uploads to a bucket use an S3 PUT/POST, which the bucket must allow via +// CORS. Backblaze B2's web-console presets only "share" (download) files, so uploads +// stay CORS-blocked until custom rules are applied with B2's CLI. This module renders +// a small modal with the exact rules (origins pre-filled) + the command to apply them. + +import { devLogger } from "../../utils/logger.js"; +import { getCorsOrigins } from "../../services/s3Service.js"; + +// The CORS rules bitvid needs for in-browser uploads + ranged playback, in B2's +// native rules shape. Mirrors ensureBucketCors (GET/HEAD/PUT/POST/DELETE + ranged- +// playback expose headers) but adds the upload operations the B2 web-console presets +// omit (they only share downloads). +export function buildBucketCorsRules(origins) { + const allowedOrigins = + Array.isArray(origins) && origins.length ? origins : ["*"]; + return [ + { + corsRuleName: "bitvidBrowserAccess", + allowedOrigins, + allowedHeaders: ["*"], + allowedOperations: ["s3_head", "s3_get", "s3_put", "s3_post", "s3_delete"], + exposeHeaders: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"], + maxAgeSeconds: 3600, + }, + ]; +} + +// The exact B2 CLI command that applies the rules (the path B2's web console points +// custom-rule users to). Compact JSON so it pastes as one line. +export function buildB2CorsCommand(bucket, rules) { + const bucketName = (bucket && bucket.trim()) || "YOUR_BUCKET"; + const compact = JSON.stringify(rules); + return `b2 update-bucket --corsRules '${compact}' ${bucketName} allPublic`; +} + +export class StorageCorsHelp { + constructor({ getBucket } = {}) { + this.getBucket = typeof getBucket === "function" ? getBucket : () => ""; + this.helpBtn = null; + this.modal = null; + this.jsonEl = null; + this.cliEl = null; + this.copyJsonBtn = null; + this.copyCmdBtn = null; + } + + cacheDom(doc = document) { + this.helpBtn = doc.getElementById("storageCorsHelpBtn") || null; + this.modal = doc.getElementById("storageCorsModal") || null; + this.jsonEl = doc.getElementById("storageCorsJson") || null; + this.cliEl = doc.getElementById("storageCorsCli") || null; + this.copyJsonBtn = doc.getElementById("storageCorsCopyJsonBtn") || null; + this.copyCmdBtn = doc.getElementById("storageCorsCopyCmdBtn") || null; + } + + registerEventListeners() { + if (this.helpBtn instanceof HTMLElement) { + this.helpBtn.addEventListener("click", () => this.open()); + } + if (this.modal instanceof HTMLElement) { + this.modal + .querySelectorAll("[data-storage-cors-dismiss]") + .forEach((el) => el.addEventListener("click", () => this.close())); + this.modal.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + this.close(); + } + }); + } + if (this.copyJsonBtn instanceof HTMLElement) { + this.copyJsonBtn.addEventListener("click", () => { + void this.copy(this.jsonEl?.textContent, this.copyJsonBtn, "Copy JSON"); + }); + } + if (this.copyCmdBtn instanceof HTMLElement) { + this.copyCmdBtn.addEventListener("click", () => { + void this.copy(this.cliEl?.textContent, this.copyCmdBtn, "Copy command"); + }); + } + } + + // R2 auto-applies CORS via its own flow; the manual helper is for the S3-compatible + // providers (B2 / Custom S3) where the bucket owner sets it. + setVisible(show) { + if (this.helpBtn instanceof HTMLElement) { + this.helpBtn.classList.toggle("hidden", !show); + } + } + + open() { + if (!(this.modal instanceof HTMLElement)) { + return; + } + let origins = []; + try { + origins = getCorsOrigins(); + } catch (error) { + origins = []; + } + const rules = buildBucketCorsRules(origins); + if (this.jsonEl) { + this.jsonEl.textContent = JSON.stringify(rules, null, 2); + } + if (this.cliEl) { + this.cliEl.textContent = buildB2CorsCommand(this.getBucket(), rules); + } + this.modal.classList.remove("hidden"); + const sheet = this.modal.querySelector(".modal-sheet"); + if (sheet instanceof HTMLElement) { + sheet.focus(); + } + } + + close() { + if (this.modal instanceof HTMLElement) { + this.modal.classList.add("hidden"); + } + if (this.helpBtn instanceof HTMLElement) { + this.helpBtn.focus(); + } + } + + async copy(text, button, restoreLabel) { + const value = typeof text === "string" ? text : ""; + if (!value || !navigator?.clipboard?.writeText) { + return; + } + try { + await navigator.clipboard.writeText(value); + if (button instanceof HTMLElement) { + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = restoreLabel; + }, 1500); + } + } catch (error) { + devLogger.warn("[ProfileModal] Failed to copy CORS text:", error); + } + } +} diff --git a/tests/storage-cors-help.test.mjs b/tests/storage-cors-help.test.mjs new file mode 100644 index 00000000..8750d5b6 --- /dev/null +++ b/tests/storage-cors-help.test.mjs @@ -0,0 +1,126 @@ +// Storage CORS setup helper (B2 / S3-compatible): the rules + command bitvid shows a +// user so in-browser uploads work. The crucial property: the rules include the UPLOAD +// operations (s3_put / s3_post) that B2's web-console "share" presets omit — that gap +// is exactly why uploads CORS-fail until these custom rules are applied. + +import test, { beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { + buildBucketCorsRules, + buildB2CorsCommand, + StorageCorsHelp, +} from "../js/ui/profileModal/storageCorsHelp.js"; + +test("CORS rules include the upload operations the B2 presets omit", () => { + const [rule] = buildBucketCorsRules(["https://bitvid.network"]); + for (const op of ["s3_put", "s3_post"]) { + assert.ok( + rule.allowedOperations.includes(op), + `rule must allow ${op} (browser upload) — the whole point of this helper`, + ); + } + // ...and still allow ranged playback reads. + assert.ok(rule.allowedOperations.includes("s3_get")); + assert.ok(rule.allowedOperations.includes("s3_head")); + assert.deepEqual(rule.exposeHeaders, [ + "ETag", + "Content-Length", + "Content-Range", + "Accept-Ranges", + ]); +}); + +test("CORS rules use the given origins, and fall back to every-origin when none", () => { + assert.deepEqual( + buildBucketCorsRules(["https://a.example", "https://b.example"])[0] + .allowedOrigins, + ["https://a.example", "https://b.example"], + ); + assert.deepEqual(buildBucketCorsRules([])[0].allowedOrigins, ["*"]); + assert.deepEqual(buildBucketCorsRules(undefined)[0].allowedOrigins, ["*"]); +}); + +test("the B2 CLI command targets the bucket as allPublic with compact one-line JSON", () => { + const rules = buildBucketCorsRules(["https://bitvid.network"]); + const cmd = buildB2CorsCommand("bitvid", rules); + assert.match(cmd, /^b2 update-bucket --corsRules '.*' bitvid allPublic$/); + assert.ok(!cmd.includes("\n"), "command must be a single pasteable line"); + // The embedded JSON is valid and carries the upload op. + const json = cmd.slice(cmd.indexOf("'") + 1, cmd.lastIndexOf("'")); + assert.ok(JSON.parse(json)[0].allowedOperations.includes("s3_put")); +}); + +test("a missing bucket name degrades to an obvious placeholder (not an empty arg)", () => { + assert.match(buildB2CorsCommand("", buildBucketCorsRules([])), /YOUR_BUCKET allPublic$/); +}); + +// --- DOM behavior --- +let dom; +beforeEach(() => { + dom = new JSDOM( + ` + + + `, + { url: "https://unstable.bitvid.network/" }, + ); + globalThis.document = dom.window.document; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.window = dom.window; +}); +afterEach(() => { + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.window; +}); + +test("open() fills the JSON + command from the current origin and reveals the modal", () => { + const help = new StorageCorsHelp({ getBucket: () => "bitvid" }); + help.cacheDom(dom.window.document); + help.registerEventListeners(); + + help.open(); + + const modal = dom.window.document.getElementById("storageCorsModal"); + assert.ok(!modal.classList.contains("hidden"), "modal is shown"); + + const json = dom.window.document.getElementById("storageCorsJson").textContent; + const rule = JSON.parse(json)[0]; + assert.deepEqual( + rule.allowedOrigins, + ["https://unstable.bitvid.network"], + "origins pre-filled from getCorsOrigins()", + ); + + const cli = dom.window.document.getElementById("storageCorsCli").textContent; + assert.match(cli, /bitvid allPublic$/); +}); + +test("dismiss controls hide the modal again", () => { + const help = new StorageCorsHelp({ getBucket: () => "bitvid" }); + help.cacheDom(dom.window.document); + help.registerEventListeners(); + help.open(); + dom.window.document.getElementById("x").click(); + assert.ok( + dom.window.document.getElementById("storageCorsModal").classList.contains("hidden"), + ); +}); + +test("setVisible toggles the help button for S3-compatible vs R2", () => { + const help = new StorageCorsHelp({ getBucket: () => "" }); + help.cacheDom(dom.window.document); + const btn = dom.window.document.getElementById("storageCorsHelpBtn"); + help.setVisible(true); + assert.ok(!btn.classList.contains("hidden")); + help.setVisible(false); + assert.ok(btn.classList.contains("hidden")); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index a1001745..41eb44ed 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -860,9 +860,24 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro - Tests: `tests/storage-b2-provider.test.mjs` (8 — endpoint derivation, virtual- hosted public URL, end-to-end validate, explicit override, object URLs). All s3/storage suites green (50); build + lint clean. -- [ ] **VERIFY live** with a real B2 Application Key + public bucket on unstable: pick - Backblaze B2, enter region (e.g. `us-west-004`) + bucket + key/secret, Test → passes; - upload a video → plays from `https://.s3..backblazeb2.com/...`. +- [x] **VALIDATED against a real B2 account 2026-06-30.** Read-only `b2_authorize_account` + on the live `bitvid` bucket confirmed the derivation EXACTLY: B2's reported + `s3ApiUrl` = `https://s3.us-west-004.backblazeb2.com` = `deriveB2Endpoint("us-west-004")`; + bucket is `allPublic` → S3-style playback URL works. Key file caps + (read/write/list/delete) ✓. +- [x] **CORS helper modal — DONE 2026-06-30.** Empirically confirmed the gotcha: B2's + web-console "Share everything…" presets write DOWNLOAD-only CORS (`s3_get/s3_head`, + `b2_download_*`) — **no `s3_put/s3_post`** — so browser uploads CORS-fail. Added a + "CORS setup help" button (shown for B2 / Custom S3) opening a modal that shows the + exact B2-native CORS rules JSON (upload + ranged-playback ops, origins pre-filled + from `getCorsOrigins()`) + the `b2 update-bucket --corsRules …` command to apply + them, with copy buttons and step-by-step (incl. the "use a key with + `writeBucketCors` and bitvid sets it for you" tip). Extracted to + `js/ui/profileModal/storageCorsHelp.js` (keeps the controller <1000 lines). Tests: + `tests/storage-cors-help.test.mjs` (8). Build + lint clean. +- [ ] **VERIFY live** on unstable: pick Backblaze B2, enter region (`us-west-004`) + + bucket + key/secret, Test → passes; apply the CORS helper's rules; upload a video → + plays from `https://.s3..backblazeb2.com/...`. ### 39. Image uploads should prefer uploading to configured storage (UX) - [ ] Anywhere the user adds an image, prefer letting them **upload an image file to