mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- CORS setup helper: the Backblaze B2 instructions now say `pipx install b2` instead of `pip install b2` (the recommended way to install a CLI tool). - Storage form: for the Backblaze B2 provider the "Secret Access Key" field is now labelled "Application Key" to match B2's own terminology (the secret half of an Application Key). Other providers keep "Secret Access Key". Test updated to assert the pipx instruction. Build + lint clean. 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 (pipx 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);
|
|
}
|
|
}
|
|
}
|