Files
bitvid/js/ui/profileModal/storageCorsHelp.js
T
thePR0M3TH3ANandClaude Opus 4.8 d7ad151698 [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]>
2026-06-30 10:37:24 -04:00

143 lines
4.8 KiB
JavaScript

// 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);
}
}
}