[ui] #38: add a CORS setup helper modal for B2 / S3-compatible buckets

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 '<json>' <bucket> 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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 10:37:24 -04:00
1 parent e79595b902
commit d7ad151698
5 files changed
+397 -25

No files matched your search

+88
View File
@@ -973,6 +973,9 @@
<button id="storageClearBtn" type="button" class="btn-ghost focus-ring text-status-danger"> <button id="storageClearBtn" type="button" class="btn-ghost focus-ring text-status-danger">
Clear Clear
</button> </button>
<button id="storageCorsHelpBtn" type="button" class="btn-ghost focus-ring text-xs hidden">
CORS setup help
</button>
</div> </div>
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p> <p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
@@ -1838,3 +1841,88 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Storage CORS setup helper (B2 / S3-compatible). Shown on demand from the
Storage pane; renders above the profile modal via modal-always-on-top. -->
<div
id="storageCorsModal"
class="bv-modal modal-always-on-top hidden items-start justify-center md:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="storageCorsModalTitle"
aria-describedby="storageCorsModalIntro"
data-ds="new"
>
<div class="bv-modal-backdrop" data-storage-cors-dismiss></div>
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
<div class="modal-header">
<h2 id="storageCorsModalTitle" class="text-xl font-bold text-text">
Allow browser uploads (bucket CORS)
</h2>
<button
id="storageCorsModalClose"
class="modal-close"
type="button"
aria-label="Close CORS setup help"
data-storage-cors-dismiss
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body overflow-y-auto p-5 space-y-4 text-sm text-muted-strong">
<p id="storageCorsModalIntro">
Backblaze B2's web console CORS presets ("Share everything…") only allow
<strong>downloads</strong>. Uploading from your browser uses an S3
<code>PUT</code>/<code>POST</code>, which needs the <strong>custom</strong>
rules below. Backblaze can only apply custom rules via its command-line tool.
</p>
<div>
<div class="mb-1 flex items-center justify-between gap-2">
<span class="font-medium text-text">CORS rules JSON</span>
<button id="storageCorsCopyJsonBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy JSON
</button>
</div>
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsJson" class="whitespace-pre text-text"></code></pre>
<p class="mt-1 text-xs text-muted">
Origins are pre-filled from this site. Add your other bitvid domains, or use
<code>["*"]</code> to allow every origin.
</p>
</div>
<div>
<div class="mb-1 flex items-center justify-between gap-2">
<span class="font-medium text-text">Apply it (B2 command-line tool)</span>
<button id="storageCorsCopyCmdBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy command
</button>
</div>
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsCli" class="whitespace-pre text-text"></code></pre>
</div>
<ol class="list-decimal space-y-1 pl-5 text-xs text-muted">
<li>Install Backblaze's <strong>B2 CLI</strong> (<code>pip install b2</code>) on your computer.</li>
<li>Run the command above (it authorizes with your key, then sets the rules).</li>
<li>Wait ~1 minute, then retry your upload in bitvid.</li>
</ol>
<p class="text-xs text-muted">
Prefer the web console? Its presets are fine for <strong>playback</strong> of
already-uploaded files — only browser <strong>uploads</strong> need the custom
rules above. (Tip: a B2 application key with the <code>writeBucketCors</code>
capability lets bitvid set this for you automatically when you Save.)
</p>
</div>
<div class="modal-footer flex items-center justify-end gap-3 border-t border-border/60 p-4">
<button id="storageCorsDoneBtn" type="button" class="btn focus-ring" data-storage-cors-dismiss>
Done
</button>
</div>
</div>
</div>
+23 -22
View File
@@ -8,6 +8,7 @@ import {
import { getActiveSigner } from "../../nostr/client.js"; import { getActiveSigner } from "../../nostr/client.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js"; import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js";
export class ProfileStorageController { export class ProfileStorageController {
constructor(mainController) { constructor(mainController) {
@@ -34,6 +35,10 @@ export class ProfileStorageController {
this.storageR2Helper = null; this.storageR2Helper = null;
this.storageB2Helper = null; this.storageB2Helper = null;
this.storageS3Helper = 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.storageForcePathStyleInput = null;
this.storageForcePathStyleLabel = null; this.storageForcePathStyleLabel = null;
@@ -67,6 +72,7 @@ export class ProfileStorageController {
this.storageR2Helper = document.getElementById("storageR2Helper") || null; this.storageR2Helper = document.getElementById("storageR2Helper") || null;
this.storageB2Helper = document.getElementById("storageB2Helper") || null; this.storageB2Helper = document.getElementById("storageB2Helper") || null;
this.storageS3Helper = document.getElementById("storageS3Helper") || null; this.storageS3Helper = document.getElementById("storageS3Helper") || null;
this.corsHelp.cacheDom(document);
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null; this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null; this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
this.storageSyncSection = document.getElementById("profileStorageSync") || null; this.storageSyncSection = document.getElementById("profileStorageSync") || null;
@@ -123,6 +129,8 @@ export class ProfileStorageController {
void this.handleRestoreSync(); void this.handleRestoreSync();
}); });
} }
this.corsHelp.registerEventListeners();
} }
async populateStoragePane() { async populateStoragePane() {
@@ -406,8 +414,7 @@ export class ProfileStorageController {
updateStorageFormVisibility() { updateStorageFormVisibility() {
const provider = this.storageProviderInput?.value || "cloudflare_r2"; const provider = this.storageProviderInput?.value || "cloudflare_r2";
const isR2 = provider === "cloudflare_r2"; const isR2 = provider === "cloudflare_r2";
// B2 derives its endpoint from the region (s3.<region>.backblazeb2.com), so the // B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
// raw Endpoint field is hidden and the region drives the connection.
const isB2 = provider === PROVIDERS.B2; const isB2 = provider === PROVIDERS.B2;
if (this.storageEndpointInput) { if (this.storageEndpointInput) {
@@ -461,8 +468,7 @@ export class ProfileStorageController {
} }
if (this.storageForcePathStyleLabel) { if (this.storageForcePathStyleLabel) {
// Force-path-style is a generic-S3 knob; R2 (account-derived) and B2 // Force-path-style is a generic-S3 knob; R2 and B2 fix it internally.
// (virtual-hosted) fix it internally, so hide the toggle for both.
const showForcePathStyle = !isR2 && !isB2; const showForcePathStyle = !isR2 && !isB2;
this.storageForcePathStyleLabel.classList.toggle( this.storageForcePathStyleLabel.classList.toggle(
"hidden", "hidden",
@@ -473,6 +479,9 @@ export class ProfileStorageController {
showForcePathStyle, showForcePathStyle,
); );
} }
// Manual CORS helper is for S3-compatible providers (B2 / Custom S3), not R2.
this.corsHelp.setVisible(!isR2);
} }
getStorageUnlockFailureMessage(error) { getStorageUnlockFailureMessage(error) {
@@ -515,12 +524,10 @@ export class ProfileStorageController {
this.storageUnlockFailure = null; this.storageUnlockFailure = null;
} }
// After a page reload, a persisted ("remember this key") nsec session restores the // After a page reload, a persisted nsec session restores the logged-in pubkey + UI
// logged-in pubkey + UI but NOT the in-memory signer — the private key is held only // but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then
// passphrase-encrypted. Storage unlock then has no signer (or a decrypt-less stub), // has no usable signer, and the generic "No active signer" error is misleading.
// and the generic "No active signer / cannot decrypt" errors are misleading because // Detect this exact case (a saved nsec key for the account we're unlocking).
// 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.
getLockedStoredNsecSession(pubkey) { getLockedStoredNsecSession(pubkey) {
const client = this.mainController.services?.nostrClient; const client = this.mainController.services?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") { if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
@@ -555,11 +562,8 @@ export class ProfileStorageController {
this.setStorageUnlockFailureState(error); this.setStorageUnlockFailureState(error);
this.mainController.showError(this.getStorageUnlockFailureMessage(error)); this.mainController.showError(this.getStorageUnlockFailureMessage(error));
// When the user actively clicked "Unlock Storage", open the login modal straight // On an explicit "Unlock Storage" click, open the login modal's unlock-saved-key
// to its existing unlock-saved-key (passphrase) flow so they can re-unlock in one // (passphrase) flow so the user re-unlocks in one step (not from passive render).
// 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.
if (autoOpenLogin) { if (autoOpenLogin) {
const openLoginModal = this.mainController.services?.openLoginModal; const openLoginModal = this.mainController.services?.openLoginModal;
if (typeof openLoginModal === "function") { if (typeof openLoginModal === "function") {
@@ -758,9 +762,8 @@ export class ProfileStorageController {
const prefix = this.storagePrefixInput?.value?.trim() || ""; const prefix = this.storagePrefixInput?.value?.trim() || "";
const isDefault = this.storageDefaultInput?.checked || false; const isDefault = this.storageDefaultInput?.checked || false;
// B2: the endpoint isn't entered — derive it from the region.
if (provider === PROVIDERS.B2) { if (provider === PROVIDERS.B2) {
endpointOrAccount = deriveB2Endpoint(region); endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2
if (!endpointOrAccount) { if (!endpointOrAccount) {
this.setStorageFormStatus( this.setStorageFormStatus(
"Enter your Backblaze B2 region (e.g. us-west-004).", "Enter your Backblaze B2 region (e.g. us-west-004).",
@@ -811,9 +814,8 @@ export class ProfileStorageController {
secretAccessKey, secretAccessKey,
bucket, bucket,
forcePathStyle, forcePathStyle,
// Honor an explicit Public Access URL (e.g. a B2 custom domain / CDN in front // Honor an explicit Public Access URL (custom domain / CDN); when blank,
// of the bucket); when blank, validateS3Connection derives it from the // validateS3Connection derives it from the endpoint + bucket.
// endpoint + bucket (for B2: https://<bucket>.s3.<region>.backblazeb2.com).
publicBaseUrl: prefix || undefined, publicBaseUrl: prefix || undefined,
origins: getCorsOrigins(), origins: getCorsOrigins(),
}); });
@@ -910,9 +912,8 @@ export class ProfileStorageController {
const publicBaseUrl = this.storagePrefixInput?.value?.trim() || ""; const publicBaseUrl = this.storagePrefixInput?.value?.trim() || "";
// B2: the endpoint isn't entered — derive it from the region.
if (provider === PROVIDERS.B2) { if (provider === PROVIDERS.B2) {
endpointOrAccount = deriveB2Endpoint(region); endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2
if (!endpointOrAccount) { if (!endpointOrAccount) {
this.setStorageFormStatus( this.setStorageFormStatus(
"Enter your Backblaze B2 region (e.g. us-west-004) to test.", "Enter your Backblaze B2 region (e.g. us-west-004) to test.",
+142
View File
@@ -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);
}
}
}
+126
View File
@@ -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(
`<!doctype html><html><body>
<button id="storageCorsHelpBtn" class="hidden"></button>
<div id="storageCorsModal" class="hidden">
<div class="modal-sheet" tabindex="-1"></div>
<button data-storage-cors-dismiss id="x"></button>
<code id="storageCorsJson"></code>
<code id="storageCorsCli"></code>
<button id="storageCorsCopyJsonBtn"></button>
<button id="storageCorsCopyCmdBtn"></button>
</div>
</body></html>`,
{ 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"));
});
+18 -3
View File
@@ -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- - Tests: `tests/storage-b2-provider.test.mjs` (8 — endpoint derivation, virtual-
hosted public URL, end-to-end validate, explicit override, object URLs). All hosted public URL, end-to-end validate, explicit override, object URLs). All
s3/storage suites green (50); build + lint clean. s3/storage suites green (50); build + lint clean.
- [ ] **VERIFY live** with a real B2 Application Key + public bucket on unstable: pick - [x] **VALIDATED against a real B2 account 2026-06-30.** Read-only `b2_authorize_account`
Backblaze B2, enter region (e.g. `us-west-004`) + bucket + key/secret, Test → passes; on the live `bitvid` bucket confirmed the derivation EXACTLY: B2's reported
upload a video → plays from `https://<bucket>.s3.<region>.backblazeb2.com/...`. `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://<bucket>.s3.<region>.backblazeb2.com/...`.
### 39. Image uploads should prefer uploading to configured storage (UX) ### 39. Image uploads should prefer uploading to configured storage (UX)
- [ ] Anywhere the user adds an image, prefer letting them **upload an image file to - [ ] Anywhere the user adds an image, prefer letting them **upload an image file to