[ui] #38: make the CORS setup helper provider-aware (R2 / B2 / Custom S3)

The first cut showed for Custom S3 too but always gave B2-only instructions (b2 CLI +
B2-native JSON), and was hidden for R2. Now the helper shows for all bucket providers
and tailors its content:

- Backblaze B2  -> B2-native rules + `b2 update-bucket --corsRules ...`
- Custom S3     -> standard S3 CORSConfiguration + `aws s3api put-bucket-cors
                   --endpoint-url <endpoint> ...`
- Cloudflare R2 -> standard S3 JSON + Cloudflare dashboard guidance (R2 -> bucket ->
                   Settings -> CORS Policy) and the AWS-CLI command vs the derived R2
                   endpoint.

Origins pre-filled from getCorsOrigins(); intro/command-label/notes set per provider;
copy buttons for both blocks. bitvid still auto-applies CORS on Save (R2/S3 via
ensureBucketCors) — this is the manual fallback when the key lacks CORS-write perms.

buildCorsHelpContent() switches on provider; modal text is now data-driven. Tests
extended to 10 (per-provider rules + command + a DOM provider-switch). Build + lint
clean; storage suites 40/40.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 10:45:50 -04:00
1 parent d7ad151698
commit 6006813b7e
5 files changed
+242 -115

No files matched your search

+5 -18
View File
@@ -973,7 +973,7 @@
<button id="storageClearBtn" type="button" class="btn-ghost focus-ring text-status-danger">
Clear
</button>
<button id="storageCorsHelpBtn" type="button" class="btn-ghost focus-ring text-xs hidden">
<button id="storageCorsHelpBtn" type="button" class="btn-ghost focus-ring text-xs">
CORS setup help
</button>
</div>
@@ -1875,10 +1875,8 @@
<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.
Uploading from your browser uses an S3 <code>PUT</code>/<code>POST</code>,
which your bucket must allow via CORS.
</p>
<div>
@@ -1897,7 +1895,7 @@
<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>
<span id="storageCorsCmdLabel" class="font-medium text-text">Apply it</span>
<button id="storageCorsCopyCmdBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy command
</button>
@@ -1905,18 +1903,7 @@
<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>
<p id="storageCorsNotes" class="whitespace-pre-line text-xs text-muted"></p>
</div>
<div class="modal-footer flex items-center justify-end gap-3 border-t border-border/60 p-4">
@@ -35,9 +35,12 @@ export class ProfileStorageController {
this.storageR2Helper = null;
this.storageB2Helper = null;
this.storageS3Helper = null;
// CORS setup helper modal (B2 / Custom S3) — owns its own DOM + behavior.
// Provider-aware CORS setup helper modal — owns its own DOM + behavior.
this.corsHelp = new StorageCorsHelp({
getProvider: () => this.storageProviderInput?.value || "cloudflare_r2",
getBucket: () => this.storageBucketInput?.value?.trim() || "",
getRegion: () => this.storageRegionInput?.value?.trim() || "",
getEndpoint: () => this.storageEndpointInput?.value?.trim() || "",
});
this.storageForcePathStyleInput = null;
this.storageForcePathStyleLabel = null;
@@ -447,7 +450,6 @@ export class ProfileStorageController {
}
}
// Region matters for B2 (drives the endpoint) and AWS-style S3; nudge the example.
if (this.storageRegionInput && isB2) {
this.storageRegionInput.placeholder = "us-west-004";
} else if (this.storageRegionInput) {
@@ -480,8 +482,7 @@ export class ProfileStorageController {
);
}
// Manual CORS helper is for S3-compatible providers (B2 / Custom S3), not R2.
this.corsHelp.setVisible(!isR2);
this.corsHelp.setVisible(true); // provider-aware; useful for all bucket providers
}
getStorageUnlockFailureMessage(error) {
@@ -814,8 +815,7 @@ export class ProfileStorageController {
secretAccessKey,
bucket,
forcePathStyle,
// Honor an explicit Public Access URL (custom domain / CDN); when blank,
// validateS3Connection derives it from the endpoint + bucket.
// Explicit Public Access URL (custom domain/CDN); blank → derived.
publicBaseUrl: prefix || undefined,
origins: getCorsOrigins(),
});
+114 -23
View File
@@ -1,17 +1,19 @@
// Storage CORS setup helper (B2 / S3-compatible providers).
// Storage CORS setup helper (all bucket 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.
// CORS. bitvid tries to set this for you on Save, but that needs a key with CORS-write
// permission and a provider that allows it (Backblaze B2's web presets, for example,
// only "share" downloads). This module renders a small, PROVIDER-AWARE modal with the
// exact rules (origins pre-filled) + how to apply them for the selected provider.
import { devLogger } from "../../utils/logger.js";
import { getCorsOrigins } from "../../services/s3Service.js";
import { deriveB2Endpoint, 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).
const PROVIDER_B2 = "backblaze_b2";
const PROVIDER_R2 = "cloudflare_r2";
// Backblaze B2 native rules (corsRuleName + allowedOperations). The web-console
// presets omit the upload operations (s3_put/s3_post), which is why uploads fail.
export function buildBucketCorsRules(origins) {
const allowedOrigins =
Array.isArray(origins) && origins.length ? origins : ["*"];
@@ -27,21 +29,102 @@ export function buildBucketCorsRules(origins) {
];
}
// 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.
// Standard S3 CORSConfiguration — accepted by the AWS CLI, S3-compatible provider
// dashboards, and Cloudflare R2's CORS-policy editor. (OPTIONS is implicit in S3 CORS
// and not a valid AllowedMethod, so it's omitted on purpose.)
export function buildS3CorsConfig(origins) {
const allowedOrigins =
Array.isArray(origins) && origins.length ? origins : ["*"];
return {
CORSRules: [
{
AllowedOrigins: allowedOrigins,
AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE"],
AllowedHeaders: ["*"],
ExposeHeaders: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
MaxAgeSeconds: 3600,
},
],
};
}
// B2 CLI command (the path B2's web console points custom-rule users to).
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`;
return `b2 update-bucket --corsRules '${JSON.stringify(rules)}' ${bucketName} allPublic`;
}
// AWS CLI command against the provider's S3 endpoint (works for Custom S3 and R2).
export function buildAwsCorsCommand(bucket, endpoint, config) {
const bucketName = (bucket && bucket.trim()) || "YOUR_BUCKET";
const ep = endpoint && endpoint.trim() ? ` --endpoint-url ${endpoint.trim()}` : "";
return `aws s3api put-bucket-cors --bucket ${bucketName}${ep} --cors-configuration '${JSON.stringify(config)}'`;
}
function resolveEndpointForCors(provider, region, rawEndpoint) {
if (provider === PROVIDER_B2) {
return deriveB2Endpoint(region);
}
if (provider === PROVIDER_R2) {
return rawEndpoint ? `https://${rawEndpoint}.r2.cloudflarestorage.com` : "";
}
return rawEndpoint || "";
}
// Build the provider-specific modal content (intro / JSON / command + label / notes).
export function buildCorsHelpContent({ provider, origins, bucket, endpoint } = {}) {
if (provider === PROVIDER_B2) {
const rules = buildBucketCorsRules(origins);
return {
intro:
"Backblaze B2's web-console CORS presets (\"Share everything…\") only allow " +
"downloads. Uploading from your browser needs the custom rules below, applied " +
"with B2's command-line tool.",
json: JSON.stringify(rules, null, 2),
cmdLabel: "Apply it (B2 command-line tool)",
cmd: buildB2CorsCommand(bucket, rules),
notes:
"1. Install the B2 CLI (pip install b2).\n" +
"2. Run the command above (it authorizes, then sets the rules).\n" +
"3. Wait ~1 minute, then retry your upload.\n" +
"Tip: a B2 application key with the writeBucketCors capability lets bitvid set " +
"this for you automatically on Save.",
};
}
const isR2 = provider === PROVIDER_R2;
const config = buildS3CorsConfig(origins);
return {
intro:
"bitvid tries to set CORS for you when you Save. If uploads still fail (the key " +
"lacks CORS-write permission, or your provider needs it set manually), apply the " +
"rules below.",
json: JSON.stringify(config, null, 2),
cmdLabel: isR2
? "Apply it (Cloudflare dashboard, or AWS CLI)"
: "Apply it (provider dashboard, or AWS CLI)",
cmd: buildAwsCorsCommand(bucket, endpoint, config),
notes: isR2
? "Cloudflare dashboard: R2 → your bucket → Settings → CORS Policy → paste the JSON.\n" +
"Or run the AWS CLI command above (it targets your R2 endpoint)."
: "Paste the JSON into your provider's bucket CORS settings, or run the AWS CLI " +
"command above against your endpoint.",
};
}
export class StorageCorsHelp {
constructor({ getBucket } = {}) {
constructor({ getProvider, getBucket, getRegion, getEndpoint } = {}) {
this.getProvider = typeof getProvider === "function" ? getProvider : () => "";
this.getBucket = typeof getBucket === "function" ? getBucket : () => "";
this.getRegion = typeof getRegion === "function" ? getRegion : () => "";
this.getEndpoint = typeof getEndpoint === "function" ? getEndpoint : () => "";
this.helpBtn = null;
this.modal = null;
this.introEl = null;
this.jsonEl = null;
this.cliEl = null;
this.cmdLabelEl = null;
this.notesEl = null;
this.copyJsonBtn = null;
this.copyCmdBtn = null;
}
@@ -49,8 +132,11 @@ export class StorageCorsHelp {
cacheDom(doc = document) {
this.helpBtn = doc.getElementById("storageCorsHelpBtn") || null;
this.modal = doc.getElementById("storageCorsModal") || null;
this.introEl = doc.getElementById("storageCorsModalIntro") || null;
this.jsonEl = doc.getElementById("storageCorsJson") || null;
this.cliEl = doc.getElementById("storageCorsCli") || null;
this.cmdLabelEl = doc.getElementById("storageCorsCmdLabel") || null;
this.notesEl = doc.getElementById("storageCorsNotes") || null;
this.copyJsonBtn = doc.getElementById("storageCorsCopyJsonBtn") || null;
this.copyCmdBtn = doc.getElementById("storageCorsCopyCmdBtn") || null;
}
@@ -81,8 +167,6 @@ export class StorageCorsHelp {
}
}
// 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);
@@ -99,13 +183,20 @@ export class StorageCorsHelp {
} 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);
}
const provider = this.getProvider();
const content = buildCorsHelpContent({
provider,
origins,
bucket: this.getBucket(),
endpoint: resolveEndpointForCors(provider, this.getRegion(), this.getEndpoint()),
});
if (this.introEl) this.introEl.textContent = content.intro;
if (this.jsonEl) this.jsonEl.textContent = content.json;
if (this.cmdLabelEl) this.cmdLabelEl.textContent = content.cmdLabel;
if (this.cliEl) this.cliEl.textContent = content.cmd;
if (this.notesEl) this.notesEl.textContent = content.notes;
this.modal.classList.remove("hidden");
const sheet = this.modal.querySelector(".modal-sheet");
if (sheet instanceof HTMLElement) {
+99 -58
View File
@@ -1,29 +1,35 @@
// 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.
// Provider-aware storage CORS setup helper. The helper must give the RIGHT rules +
// apply-command for each provider: B2-native rules + the b2 CLI for Backblaze B2, and
// standard S3 CORS + AWS CLI / dashboard guidance for Custom S3 and Cloudflare R2.
// The invariant across all of them: the rules allow browser uploads (PUT/POST), which
// is what makes uploads stop CORS-failing.
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import {
buildBucketCorsRules,
buildS3CorsConfig,
buildB2CorsCommand,
buildAwsCorsCommand,
buildCorsHelpContent,
StorageCorsHelp,
} from "../js/ui/profileModal/storageCorsHelp.js";
test("CORS rules include the upload operations the B2 presets omit", () => {
test("B2-native 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_put"));
assert.ok(rule.allowedOperations.includes("s3_post"));
assert.ok(rule.allowedOperations.includes("s3_get"));
assert.ok(rule.allowedOperations.includes("s3_head"));
assert.deepEqual(rule.exposeHeaders, [
});
test("standard S3 CORS config allows upload + ranged playback methods", () => {
const cfg = buildS3CorsConfig(["https://bitvid.network"]);
const rule = cfg.CORSRules[0];
assert.deepEqual(rule.AllowedMethods, ["GET", "HEAD", "PUT", "POST", "DELETE"]);
// OPTIONS is not a valid S3 AllowedMethod and must not be present.
assert.ok(!rule.AllowedMethods.includes("OPTIONS"));
assert.deepEqual(rule.ExposeHeaders, [
"ETag",
"Content-Length",
"Content-Range",
@@ -31,41 +37,83 @@ test("CORS rules include the upload operations the B2 presets omit", () => {
]);
});
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"],
);
test("origins fall back to every-origin when none are provided", () => {
assert.deepEqual(buildBucketCorsRules([])[0].allowedOrigins, ["*"]);
assert.deepEqual(buildBucketCorsRules(undefined)[0].allowedOrigins, ["*"]);
assert.deepEqual(buildS3CorsConfig(undefined).CORSRules[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);
test("B2 command targets the bucket allPublic with compact one-line JSON", () => {
const cmd = buildB2CorsCommand("bitvid", buildBucketCorsRules(["https://x"]));
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"));
assert.ok(!cmd.includes("\n"));
});
test("a missing bucket name degrades to an obvious placeholder (not an empty arg)", () => {
assert.match(buildB2CorsCommand("", buildBucketCorsRules([])), /YOUR_BUCKET allPublic$/);
test("AWS command includes the endpoint + bucket, one line", () => {
const cmd = buildAwsCorsCommand(
"vids",
"https://s3.example.com",
buildS3CorsConfig(["https://x"]),
);
assert.match(
cmd,
/^aws s3api put-bucket-cors --bucket vids --endpoint-url https:\/\/s3\.example\.com --cors-configuration '.*'$/,
);
});
// --- DOM behavior ---
test("content for Backblaze B2 uses B2-native rules + the b2 CLI", () => {
const c = buildCorsHelpContent({
provider: "backblaze_b2",
origins: ["https://bitvid.network"],
bucket: "bitvid",
endpoint: "https://s3.us-west-004.backblazeb2.com",
});
assert.match(c.cmd, /^b2 update-bucket /);
assert.match(c.cmdLabel, /B2 command-line/i);
assert.ok(JSON.parse(c.json)[0].allowedOperations.includes("s3_put"));
assert.match(c.notes, /b2 CLI|pip install b2/i);
});
test("content for Custom S3 uses standard S3 CORS + the AWS CLI", () => {
const c = buildCorsHelpContent({
provider: "generic_s3",
origins: ["https://bitvid.network"],
bucket: "vids",
endpoint: "https://s3.example.com",
});
assert.match(c.cmd, /^aws s3api put-bucket-cors /);
assert.match(c.cmd, /--endpoint-url https:\/\/s3\.example\.com/);
assert.ok(JSON.parse(c.json).CORSRules[0].AllowedMethods.includes("PUT"));
assert.doesNotMatch(c.cmd, /backblaze|b2 update-bucket/i);
});
test("content for Cloudflare R2 uses S3 CORS and points at the R2 dashboard", () => {
const c = buildCorsHelpContent({
provider: "cloudflare_r2",
origins: ["https://bitvid.network"],
bucket: "vids",
endpoint: "https://acct.r2.cloudflarestorage.com",
});
assert.match(c.cmd, /^aws s3api put-bucket-cors /);
assert.match(c.notes, /Cloudflare dashboard/i);
assert.match(c.cmdLabel, /Cloudflare/i);
assert.ok(JSON.parse(c.json).CORSRules);
});
// --- DOM behavior (provider switch reflected in the modal) ---
let dom;
beforeEach(() => {
dom = new JSDOM(
`<!doctype html><html><body>
<button id="storageCorsHelpBtn" class="hidden"></button>
<select id="prov"><option value="cloudflare_r2">r2</option></select>
<button id="storageCorsHelpBtn"></button>
<div id="storageCorsModal" class="hidden">
<div class="modal-sheet" tabindex="-1"></div>
<button data-storage-cors-dismiss id="x"></button>
<p id="storageCorsModalIntro"></p>
<code id="storageCorsJson"></code>
<span id="storageCorsCmdLabel"></span>
<code id="storageCorsCli"></code>
<p id="storageCorsNotes"></p>
<button id="storageCorsCopyJsonBtn"></button>
<button id="storageCorsCopyCmdBtn"></button>
</div>
@@ -82,30 +130,33 @@ afterEach(() => {
delete globalThis.window;
});
test("open() fills the JSON + command from the current origin and reveals the modal", () => {
const help = new StorageCorsHelp({ getBucket: () => "bitvid" });
test("open() renders provider-specific content and pre-fills the current origin", () => {
let provider = "backblaze_b2";
const help = new StorageCorsHelp({
getProvider: () => provider,
getBucket: () => "bitvid",
getRegion: () => "us-west-004",
getEndpoint: () => "",
});
help.cacheDom(dom.window.document);
help.registerEventListeners();
help.open();
const doc = dom.window.document;
assert.ok(!doc.getElementById("storageCorsModal").classList.contains("hidden"));
assert.match(doc.getElementById("storageCorsCli").textContent, /^b2 update-bucket /);
const b2Rule = JSON.parse(doc.getElementById("storageCorsJson").textContent)[0];
assert.deepEqual(b2Rule.allowedOrigins, ["https://unstable.bitvid.network"]);
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$/);
// Switch provider → reopening yields the AWS-CLI form.
provider = "generic_s3";
help.open();
assert.match(doc.getElementById("storageCorsCli").textContent, /^aws s3api put-bucket-cors /);
assert.ok(JSON.parse(doc.getElementById("storageCorsJson").textContent).CORSRules);
});
test("dismiss controls hide the modal again", () => {
const help = new StorageCorsHelp({ getBucket: () => "bitvid" });
const help = new StorageCorsHelp({ getProvider: () => "generic_s3" });
help.cacheDom(dom.window.document);
help.registerEventListeners();
help.open();
@@ -114,13 +165,3 @@ test("dismiss controls hide the modal again", () => {
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 -10
View File
@@ -865,16 +865,24 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro
`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.
- [x] **CORS helper modal (provider-aware) — 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 **all** bucket providers)
opening a **provider-aware** modal:
- **Backblaze B2** → B2-native rules JSON (upload + ranged-playback ops) + the
`b2 update-bucket --corsRules …` command.
- **Custom S3** → standard S3 `CORSConfiguration` JSON + `aws s3api put-bucket-cors
--endpoint-url <endpoint> …`.
- **Cloudflare R2** → standard S3 JSON + Cloudflare-dashboard guidance (R2 → bucket
→ Settings → CORS Policy) and the AWS-CLI command against the derived R2 endpoint.
Origins pre-filled from `getCorsOrigins()`; copy buttons for JSON + command. bitvid
still auto-applies CORS on Save (R2/S3 via `ensureBucketCors`) — this is the manual
fallback for when the key lacks CORS-write permission. Logic in
`js/ui/profileModal/storageCorsHelp.js` (`buildCorsHelpContent` switches on
provider; keeps the controller <1000 lines). Tests:
`tests/storage-cors-help.test.mjs` (10 — per-provider rules/command + DOM switch).
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/...`.