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

234 lines
8.6 KiB
JavaScript

// Storage CORS setup helper (all bucket providers).
//
// Browser uploads to a bucket use an S3 PUT/POST, which the bucket must allow via
// 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 { deriveB2Endpoint, getCorsOrigins } from "../../services/s3Service.js";
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 : ["*"];
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,
},
];
}
// 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";
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({ 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;
}
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;
}
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");
});
}
}
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 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) {
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);
}
}
}