mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 10:52:27 +00:00
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]>
234 lines
8.6 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|