-
- Bucket mode
-
-
-
-
- Auto (per npub)
-
-
-
- Manual bucket
-
-
-
-
Bucket name
+
-
+
+
Base domain (optional)
+
+
+ We'll attach npub.<baseDomain>.
+ Leave blank to use the managed *.r2.dev domain.
+
+
+
Bucket preview:
- Save your
- settings to preview.
+ We'll show your
+ bucket and public domain after setup.
diff --git a/js/app.js b/js/app.js
index 4dc4cae4..f1a459ac 100644
--- a/js/app.js
+++ b/js/app.js
@@ -20,12 +20,19 @@ import {
loadR2Settings,
saveR2Settings,
clearR2Settings,
- bucketForNpub,
buildR2Key,
buildPublicUrl,
- createR2Client,
- uploadToR2,
+ mergeBucketEntry,
+ sanitizeBaseDomain,
} from "./r2.js";
+import {
+ sanitizeBucketName,
+ ensureBucket,
+ putCors,
+ attachCustomDomain,
+ enableManagedDomain,
+} from "./storage/r2-mgmt.js";
+import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
/**
* Simple "decryption" placeholder for private videos.
@@ -474,9 +481,6 @@ class bitvidApp {
this.cloudflareClearSettingsButton = null;
this.cloudflareSettingsStatus = null;
this.cloudflareBucketPreview = null;
- this.cloudflareBucketModeInputs = [];
- this.cloudflareManualBucketGroup = null;
- this.cloudflareManualBucketInput = null;
this.cloudflareUploadForm = null;
this.cloudflareFileInput = null;
this.cloudflareUploadButton = null;
@@ -492,7 +496,9 @@ class bitvidApp {
this.r2AccountIdInput = null;
this.r2AccessKeyIdInput = null;
this.r2SecretAccessKeyInput = null;
- this.r2PublicBaseUrlInput = null;
+ this.r2ApiTokenInput = null;
+ this.r2ZoneIdInput = null;
+ this.r2BaseDomainInput = null;
// Optional small inline player stats
this.status = document.getElementById("status") || null;
@@ -1094,13 +1100,6 @@ class bitvidApp {
document.getElementById("cloudflareSettingsStatus") || null;
this.cloudflareBucketPreview =
document.getElementById("cloudflareBucketPreview") || null;
- this.cloudflareBucketModeInputs = Array.from(
- document.querySelectorAll("input[name='r2BucketMode']")
- );
- this.cloudflareManualBucketGroup =
- document.getElementById("r2ManualBucketGroup") || null;
- this.cloudflareManualBucketInput =
- document.getElementById("r2ManualBucket") || null;
this.cloudflareUploadForm =
document.getElementById("cloudflareUploadForm") || null;
this.cloudflareFileInput =
@@ -1131,8 +1130,11 @@ class bitvidApp {
document.getElementById("r2AccessKeyId") || null;
this.r2SecretAccessKeyInput =
document.getElementById("r2SecretAccessKey") || null;
- this.r2PublicBaseUrlInput =
- document.getElementById("r2PublicBaseUrlTemplate") || null;
+ this.r2ApiTokenInput =
+ document.getElementById("r2ApiToken") || null;
+ this.r2ZoneIdInput = document.getElementById("r2ZoneId") || null;
+ this.r2BaseDomainInput =
+ document.getElementById("r2BaseDomain") || null;
// Optional: if close button found, wire up
if (this.closeUploadModalBtn) {
@@ -1163,20 +1165,6 @@ class bitvidApp {
});
}
- if (this.cloudflareBucketModeInputs.length > 0) {
- this.cloudflareBucketModeInputs.forEach((input) => {
- input.addEventListener("change", () => {
- this.handleCloudflareBucketModeChange();
- });
- });
- }
-
- if (this.cloudflareManualBucketInput) {
- this.cloudflareManualBucketInput.addEventListener("input", () => {
- this.handleManualBucketInputChange();
- });
- }
-
if (this.cloudflareUploadForm) {
this.cloudflareUploadForm.addEventListener("submit", (e) => {
e.preventDefault();
@@ -1247,25 +1235,6 @@ class bitvidApp {
}
}
- getCloudflareBucketMode() {
- if (this.cloudflareBucketModeInputs.length === 0) {
- return "auto";
- }
-
- const active = this.cloudflareBucketModeInputs.find(
- (input) => input && input.checked
- );
- return active && active.value === "manual" ? "manual" : "auto";
- }
-
- sanitizeBucketName(name) {
- const raw = String(name || "")
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, "");
- const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, "");
- return trimmed;
- }
-
setCloudflareSettingsStatus(message = "", variant = "info") {
if (!this.cloudflareSettingsStatus) {
return;
@@ -1381,24 +1350,25 @@ class bitvidApp {
accountId: "",
accessKeyId: "",
secretAccessKey: "",
- publicBaseUrlTemplate: "",
- bucketMode: "auto",
- manualBucket: "",
- autoBuckets: {},
+ apiToken: "",
+ zoneId: "",
+ baseDomain: "",
+ buckets: {},
};
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
}
}
populateCloudflareSettingsInputs(settings) {
- const data = settings || {
- accountId: "",
- accessKeyId: "",
- secretAccessKey: "",
- publicBaseUrlTemplate: "",
- bucketMode: "auto",
- manualBucket: "",
- };
+ const data =
+ settings || {
+ accountId: "",
+ accessKeyId: "",
+ secretAccessKey: "",
+ apiToken: "",
+ zoneId: "",
+ baseDomain: "",
+ };
if (this.r2AccountIdInput) {
this.r2AccountIdInput.value = data.accountId || "";
@@ -1409,29 +1379,14 @@ class bitvidApp {
if (this.r2SecretAccessKeyInput) {
this.r2SecretAccessKeyInput.value = data.secretAccessKey || "";
}
- if (this.r2PublicBaseUrlInput) {
- this.r2PublicBaseUrlInput.value = data.publicBaseUrlTemplate || "";
+ if (this.r2ApiTokenInput) {
+ this.r2ApiTokenInput.value = data.apiToken || "";
}
-
- const mode = data.bucketMode === "manual" ? "manual" : "auto";
- if (this.cloudflareBucketModeInputs.length > 0) {
- this.cloudflareBucketModeInputs.forEach((input) => {
- if (input) {
- input.checked = input.value === mode;
- }
- });
+ if (this.r2ZoneIdInput) {
+ this.r2ZoneIdInput.value = data.zoneId || "";
}
-
- if (this.cloudflareManualBucketGroup) {
- if (mode === "manual") {
- this.cloudflareManualBucketGroup.classList.remove("hidden");
- } else {
- this.cloudflareManualBucketGroup.classList.add("hidden");
- }
- }
-
- if (this.cloudflareManualBucketInput) {
- this.cloudflareManualBucketInput.value = data.manualBucket || "";
+ if (this.r2BaseDomainInput) {
+ this.r2BaseDomainInput.value = data.baseDomain || "";
}
this.setCloudflareSettingsStatus("");
@@ -1441,13 +1396,11 @@ class bitvidApp {
const accountId = (this.r2AccountIdInput?.value || "").trim();
const accessKeyId = (this.r2AccessKeyIdInput?.value || "").trim();
const secretAccessKey = (this.r2SecretAccessKeyInput?.value || "").trim();
- const publicBaseUrlTemplate =
- (this.r2PublicBaseUrlInput?.value || "").trim();
- const bucketMode = this.getCloudflareBucketMode();
- let manualBucket = this.cloudflareManualBucketInput
- ? this.cloudflareManualBucketInput.value
- : "";
- manualBucket = this.sanitizeBucketName(manualBucket);
+ const apiToken = (this.r2ApiTokenInput?.value || "").trim();
+ const zoneId = (this.r2ZoneIdInput?.value || "").trim();
+ const baseDomain = sanitizeBaseDomain(
+ this.r2BaseDomainInput?.value || ""
+ );
if (!accountId || !accessKeyId || !secretAccessKey) {
if (!quiet) {
@@ -1459,32 +1412,26 @@ class bitvidApp {
return false;
}
- if (bucketMode === "manual" && manualBucket.length < 3) {
- if (!quiet) {
- this.setCloudflareSettingsStatus(
- "Manual bucket names must be at least 3 characters.",
- "error"
- );
- }
- return false;
+ let buckets = { ...(this.cloudflareSettings?.buckets || {}) };
+ const previousAccount = this.cloudflareSettings?.accountId || "";
+ const previousBaseDomain = this.cloudflareSettings?.baseDomain || "";
+ const previousZoneId = this.cloudflareSettings?.zoneId || "";
+ if (
+ previousAccount !== accountId ||
+ previousBaseDomain !== baseDomain ||
+ previousZoneId !== zoneId
+ ) {
+ buckets = {};
}
- if (this.cloudflareManualBucketInput) {
- this.cloudflareManualBucketInput.value = manualBucket;
- }
-
- const autoBuckets = {
- ...(this.cloudflareSettings?.autoBuckets || {}),
- };
-
const updatedSettings = {
accountId,
accessKeyId,
secretAccessKey,
- publicBaseUrlTemplate,
- bucketMode,
- manualBucket,
- autoBuckets,
+ apiToken,
+ zoneId,
+ baseDomain,
+ buckets,
};
try {
@@ -1524,62 +1471,133 @@ class bitvidApp {
}
}
- handleCloudflareBucketModeChange() {
- const mode = this.getCloudflareBucketMode();
- if (this.cloudflareManualBucketGroup) {
- if (mode === "manual") {
- this.cloudflareManualBucketGroup.classList.remove("hidden");
- } else {
- this.cloudflareManualBucketGroup.classList.add("hidden");
+ getCorsOrigins() {
+ const origins = new Set();
+ if (typeof window !== "undefined" && window.location) {
+ const origin = window.location.origin;
+ if (origin && origin !== "null") {
+ origins.add(origin);
+ }
+ if (origin && origin.startsWith("http://localhost")) {
+ origins.add(origin.replace("http://", "https://"));
}
}
- if (this.cloudflareSettings) {
- this.cloudflareSettings.bucketMode = mode;
- }
- this.updateCloudflareBucketPreview();
+ return Array.from(origins);
}
- handleManualBucketInputChange() {
- this.updateCloudflareBucketPreview();
+ deriveSubdomainForNpub(npub) {
+ const base = String(npub || "user")
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/^-+|[-]+$/g, "");
+ return base.slice(0, 48) || "user";
}
- async ensureAutoBucketForCurrentUser() {
- if (!this.pubkey || !this.cloudflareSettings) {
+ async ensureBucketConfigForNpub(npub) {
+ if (!npub || !this.cloudflareSettings) {
return null;
}
- const mode = this.cloudflareSettings.bucketMode === "manual" ? "manual" : "auto";
- if (mode !== "auto") {
- return null;
+ const accountId = (this.cloudflareSettings.accountId || "").trim();
+ const apiToken = (this.cloudflareSettings.apiToken || "").trim();
+ const zoneId = (this.cloudflareSettings.zoneId || "").trim();
+ const baseDomain = this.cloudflareSettings.baseDomain || "";
+
+ if (!accountId) {
+ throw new Error("Cloudflare account ID is missing.");
}
- const npub = this.safeEncodeNpub(this.pubkey);
- if (!npub) {
- return null;
+ let entry = this.cloudflareSettings.buckets?.[npub] || null;
+
+ if (entry && entry.publicBaseUrl) {
+ if (apiToken) {
+ try {
+ await ensureBucket({
+ accountId,
+ bucket: entry.bucket,
+ token: apiToken,
+ });
+ await putCors({
+ accountId,
+ bucket: entry.bucket,
+ token: apiToken,
+ origins: this.getCorsOrigins(),
+ });
+ } catch (err) {
+ console.warn("Failed to refresh bucket configuration:", err);
+ }
+ }
+ return { entry, usedManagedFallback: entry.domainType !== "custom" };
}
- const existing = this.cloudflareSettings.autoBuckets?.[npub];
- if (existing) {
- return existing;
+ if (!apiToken) {
+ throw new Error(
+ "Provide a Cloudflare API token to auto-create the bucket and domain."
+ );
}
- const bucket = bucketForNpub(npub);
- const updatedSettings = {
- ...this.cloudflareSettings,
- autoBuckets: {
- ...(this.cloudflareSettings.autoBuckets || {}),
- [npub]: bucket,
- },
- };
+ const bucketName = entry?.bucket || sanitizeBucketName(npub);
+
+ await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
try {
- this.cloudflareSettings = await saveR2Settings(updatedSettings);
+ await putCors({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ origins: this.getCorsOrigins(),
+ });
} catch (err) {
- console.warn("Failed to persist auto bucket name:", err);
- this.cloudflareSettings = updatedSettings;
+ console.warn("Failed to apply R2 CORS rules:", err);
}
- return bucket;
+ let publicBaseUrl = entry?.publicBaseUrl || "";
+ let domainType = entry?.domainType || "managed";
+ let usedManagedFallback = false;
+
+ if (baseDomain && zoneId) {
+ const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
+ try {
+ publicBaseUrl = await attachCustomDomain({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ zoneId,
+ domain,
+ });
+ domainType = "custom";
+ } catch (err) {
+ if (/already exists/i.test(err.message || "")) {
+ publicBaseUrl = `https://${domain}`;
+ domainType = "custom";
+ } else {
+ console.warn("Failed to attach custom domain, falling back:", err);
+ usedManagedFallback = true;
+ }
+ }
+ }
+
+ if (!publicBaseUrl) {
+ publicBaseUrl = await enableManagedDomain({
+ accountId,
+ bucket: bucketName,
+ token: apiToken,
+ });
+ domainType = "managed";
+ usedManagedFallback = true;
+ }
+
+ const mergedEntry = {
+ bucket: bucketName,
+ publicBaseUrl,
+ domainType,
+ lastUpdated: Date.now(),
+ };
+ const updatedSettings = await saveR2Settings(
+ mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry)
+ );
+ this.cloudflareSettings = updatedSettings;
+ return { entry: mergedEntry, usedManagedFallback };
}
async updateCloudflareBucketPreview() {
@@ -1588,36 +1606,13 @@ class bitvidApp {
}
const el = this.cloudflareBucketPreview;
- const mode = this.getCloudflareBucketMode();
- const template = (this.r2PublicBaseUrlInput?.value || "").trim();
-
- if (mode === "manual") {
- const manualBucket = this.sanitizeBucketName(
- this.cloudflareManualBucketInput?.value ||
- this.cloudflareSettings?.manualBucket ||
- ""
- );
- if (!manualBucket) {
- el.textContent = "Enter a manual bucket name.";
- return;
- }
-
- const sampleNpub = this.safeEncodeNpub(this.pubkey) || "anon";
- const sampleKey = buildR2Key(sampleNpub, {
- name: "sample.mp4",
- });
- const publicUrl = buildPublicUrl(manualBucket, sampleKey, template);
- el.textContent = `${manualBucket} • ${publicUrl}`;
+ if (!this.cloudflareSettings) {
+ el.textContent = "Save your credentials to configure R2.";
return;
}
if (!this.pubkey) {
- el.textContent = "Login to preview your auto bucket.";
- return;
- }
-
- if (!this.cloudflareSettings) {
- el.textContent = "Save your credentials to generate a bucket.";
+ el.textContent = "Login to preview your R2 bucket.";
return;
}
@@ -1627,19 +1622,15 @@ class bitvidApp {
return;
}
- let bucket = this.cloudflareSettings.autoBuckets?.[npub];
- if (!bucket) {
- bucket = await this.ensureAutoBucketForCurrentUser();
- }
-
- if (!bucket) {
- el.textContent = "Save your credentials to generate a bucket.";
+ const entry = this.cloudflareSettings.buckets?.[npub];
+ if (!entry || !entry.publicBaseUrl) {
+ el.textContent = "Bucket will be auto-created on your next upload.";
return;
}
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
- const publicUrl = buildPublicUrl(bucket, sampleKey, template);
- el.textContent = `${bucket} • ${publicUrl}`;
+ const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
+ el.textContent = `${entry.bucket} • ${publicUrl}`;
}
async handleCloudflareUploadSubmit() {
@@ -1678,11 +1669,11 @@ class bitvidApp {
const ws = (this.cloudflareWsInput?.value || "").trim();
const xs = (this.cloudflareXsInput?.value || "").trim();
- const accountId = this.cloudflareSettings?.accountId || "";
- const accessKeyId = this.cloudflareSettings?.accessKeyId || "";
- const secretAccessKey = this.cloudflareSettings?.secretAccessKey || "";
- const publicBaseUrlTemplate =
- this.cloudflareSettings?.publicBaseUrlTemplate || "";
+ const accountId = (this.cloudflareSettings?.accountId || "").trim();
+ const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
+ const secretAccessKey = (
+ this.cloudflareSettings?.secretAccessKey || ""
+ ).trim();
if (!accountId || !accessKeyId || !secretAccessKey) {
this.setCloudflareUploadStatus(
@@ -1698,54 +1689,56 @@ class bitvidApp {
return;
}
- let bucketName = "";
- const mode = this.cloudflareSettings?.bucketMode === "manual" ? "manual" : "auto";
- if (mode === "manual") {
- bucketName = this.sanitizeBucketName(
- this.cloudflareSettings?.manualBucket ||
- this.cloudflareManualBucketInput?.value ||
- ""
- );
- if (!bucketName) {
- this.setCloudflareUploadStatus(
- "Enter a valid manual bucket name.",
- "error"
- );
- return;
- }
- } else {
- bucketName = this.cloudflareSettings?.autoBuckets?.[npub] ||
- (await this.ensureAutoBucketForCurrentUser());
- if (!bucketName) {
- this.setCloudflareUploadStatus(
- "Failed to compute an auto bucket name.",
- "error"
- );
- return;
- }
- }
-
- const key = buildR2Key(npub, file);
- const publicUrl = buildPublicUrl(
- bucketName,
- key,
- publicBaseUrlTemplate
- );
-
- this.setCloudflareUploadStatus("Uploading…", "info");
+ this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
this.updateCloudflareProgress(0);
this.setCloudflareUploading(true);
+ let bucketResult = null;
try {
- const s3 = await createR2Client({
- accountId,
- accessKeyId,
- secretAccessKey,
- });
+ bucketResult = await this.ensureBucketConfigForNpub(npub);
+ } catch (err) {
+ console.error("Failed to prepare R2 bucket:", err);
+ this.setCloudflareUploadStatus(
+ err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.",
+ "error"
+ );
+ this.setCloudflareUploading(false);
+ this.updateCloudflareProgress(Number.NaN);
+ return;
+ }
- await uploadToR2({
+ const bucketEntry =
+ bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
+
+ if (!bucketEntry || !bucketEntry.publicBaseUrl) {
+ this.setCloudflareUploadStatus(
+ "Bucket is missing a public domain. Check your Cloudflare settings.",
+ "error"
+ );
+ this.setCloudflareUploading(false);
+ this.updateCloudflareProgress(Number.NaN);
+ return;
+ }
+
+ const statusMessage = bucketResult?.usedManagedFallback &&
+ (this.cloudflareSettings?.baseDomain || "")
+ ? `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone. Uploading…`
+ : `Uploading to ${bucketEntry.bucket}…`;
+
+ this.setCloudflareUploadStatus(
+ statusMessage,
+ bucketResult?.usedManagedFallback ? "warning" : "info"
+ );
+
+ const key = buildR2Key(npub, file);
+ const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
+
+ try {
+ const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
+
+ await multipartUpload({
s3,
- bucket: bucketName,
+ bucket: bucketEntry.bucket,
key,
file,
contentType: file.type,
diff --git a/js/r2.js b/js/r2.js
index dc356111..d3b73429 100644
--- a/js/r2.js
+++ b/js/r2.js
@@ -1,19 +1,56 @@
-const S3_MODULE_URL =
- "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle";
-
const DB_NAME = "bitvidSettings";
const DB_VERSION = 1;
const STORE_NAME = "kv";
const SETTINGS_KEY = "r2Settings";
const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings";
-let s3ModulePromise = null;
-
-function loadS3Module() {
- if (!s3ModulePromise) {
- s3ModulePromise = import(S3_MODULE_URL);
+function sanitizeBaseDomain(domain) {
+ if (!domain) {
+ return "";
}
- return s3ModulePromise;
+ let value = String(domain).trim().toLowerCase();
+ value = value.replace(/^https?:\/\//, "");
+ value = value.replace(/\/.*$/, "");
+ return value;
+}
+
+function normalizeBucketEntry(entry) {
+ if (!entry || typeof entry !== "object") {
+ return null;
+ }
+ const bucket = String(entry.bucket || "").toLowerCase();
+ if (!bucket) {
+ return null;
+ }
+ const publicBaseUrl = String(entry.publicBaseUrl || "");
+ const domainType = entry.domainType === "custom" ? "custom" : "managed";
+ const lastUpdated = Number.isFinite(entry.lastUpdated)
+ ? entry.lastUpdated
+ : Date.now();
+ return { bucket, publicBaseUrl, domainType, lastUpdated };
+}
+
+function normalizeSettings(raw) {
+ const base = raw && typeof raw === "object" ? raw : {};
+ const buckets = {};
+ if (base.buckets && typeof base.buckets === "object") {
+ for (const [npub, value] of Object.entries(base.buckets)) {
+ const normalizedEntry = normalizeBucketEntry(value);
+ if (normalizedEntry) {
+ buckets[String(npub)] = normalizedEntry;
+ }
+ }
+ }
+
+ return {
+ accountId: String(base.accountId || ""),
+ accessKeyId: String(base.accessKeyId || ""),
+ secretAccessKey: String(base.secretAccessKey || ""),
+ apiToken: String(base.apiToken || ""),
+ zoneId: String(base.zoneId || ""),
+ baseDomain: sanitizeBaseDomain(base.baseDomain || ""),
+ buckets,
+ };
}
function isIndexedDbAvailable() {
@@ -50,33 +87,6 @@ function openSettingsDb() {
});
}
-function normalizeSettings(raw) {
- if (!raw || typeof raw !== "object") {
- return {
- accountId: "",
- accessKeyId: "",
- secretAccessKey: "",
- publicBaseUrlTemplate: "",
- bucketMode: "auto",
- manualBucket: "",
- autoBuckets: {},
- };
- }
-
- return {
- accountId: String(raw.accountId || ""),
- accessKeyId: String(raw.accessKeyId || ""),
- secretAccessKey: String(raw.secretAccessKey || ""),
- publicBaseUrlTemplate: String(raw.publicBaseUrlTemplate || ""),
- bucketMode: raw.bucketMode === "manual" ? "manual" : "auto",
- manualBucket: String(raw.manualBucket || ""),
- autoBuckets:
- raw.autoBuckets && typeof raw.autoBuckets === "object"
- ? { ...raw.autoBuckets }
- : {},
- };
-}
-
export async function loadR2Settings() {
try {
const db = await openSettingsDb();
@@ -170,64 +180,21 @@ export async function clearR2Settings() {
return cleared;
}
-export function bucketForNpub(
- npub,
- { prefix = "bitvid", suffix } = {}
-) {
- const suffixValue = suffix || Math.random().toString(36).slice(2, 8);
- const npubPart = String(npub || "").toLowerCase();
- const raw = `${prefix}-${npubPart}-${suffixValue}`
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, "");
- const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, "");
- return trimmed || `${prefix}-${Date.now()}`;
-}
-
-function ensureHeadersObject(req) {
- if (!req) {
- return;
+export function mergeBucketEntry(settings, npub, entry) {
+ if (!settings || typeof settings !== "object") {
+ return settings;
}
-
- if (req.headers && typeof req.headers.set === "function") {
- req.headers.set("cf-create-bucket-if-missing", "true");
- return;
+ const normalizedEntry = normalizeBucketEntry(entry);
+ if (!normalizedEntry) {
+ return settings;
}
-
- if (req.headers && typeof req.headers === "object") {
- req.headers["cf-create-bucket-if-missing"] = "true";
- return;
- }
-
- if (typeof req.headers === "undefined") {
- req.headers = { "cf-create-bucket-if-missing": "true" };
- }
-}
-
-export async function createR2Client({
- accountId,
- accessKeyId,
- secretAccessKey,
-}) {
- if (!accountId || !accessKeyId || !secretAccessKey) {
- throw new Error("Missing required R2 credentials");
- }
-
- const { S3Client } = await loadS3Module();
- const client = new S3Client({
- region: "auto",
- endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
- credentials: { accessKeyId, secretAccessKey },
- });
-
- client.middlewareStack.add(
- (next) => async (args) => {
- ensureHeadersObject(args.request);
- return next(args);
+ return {
+ ...settings,
+ buckets: {
+ ...(settings.buckets || {}),
+ [npub]: normalizedEntry,
},
- { step: "build", name: "r2AutoCreateBucket" }
- );
-
- return client;
+ };
}
function guessExtension(file) {
@@ -276,141 +243,16 @@ export function buildR2Key(npub, file) {
return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`;
}
-export async function uploadToR2({
- s3,
- bucket,
- key,
- file,
- contentType,
- onProgress,
- concurrency = 4,
-}) {
- if (!s3) {
- throw new Error("S3 client is required");
+export function buildPublicUrl(baseUrl, key) {
+ if (!baseUrl) {
+ return "";
}
- if (!bucket) {
- throw new Error("Bucket is required");
- }
- if (!key) {
- throw new Error("Object key is required");
- }
- if (!file) {
- throw new Error("File is required");
- }
-
- const module = await loadS3Module();
- const {
- CreateMultipartUploadCommand,
- UploadPartCommand,
- CompleteMultipartUploadCommand,
- AbortMultipartUploadCommand,
- } = module;
-
- const resolvedContentType =
- contentType || file.type || "video/mp4";
-
- const createCommand = new CreateMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- ContentType: resolvedContentType,
- CacheControl: resolvedContentType.includes("mpegurl")
- ? "public, max-age=30"
- : "public, max-age=31536000, immutable",
- });
-
- const { UploadId } = await s3.send(createCommand);
- if (!UploadId) {
- throw new Error("Failed to initiate multipart upload");
- }
-
- const PART_SIZE = 8 * 1024 * 1024;
- const total = file.size;
- const totalParts = Math.ceil(total / PART_SIZE);
- const parts = [];
- let assignedPart = 0;
- let uploadedBytes = 0;
- const uploadErrors = [];
-
- const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
- (async () => {
- try {
- for (;;) {
- if (assignedPart >= totalParts) {
- break;
- }
-
- const currentIndex = assignedPart;
- assignedPart += 1;
-
- const partNumber = currentIndex + 1;
- const start = currentIndex * PART_SIZE;
- const end = Math.min(start + PART_SIZE, total);
- const Body = file.slice(start, end);
-
- const command = new UploadPartCommand({
- Bucket: bucket,
- Key: key,
- UploadId,
- PartNumber: partNumber,
- Body,
- });
-
- const { ETag } = await s3.send(command);
- parts.push({ ETag, PartNumber: partNumber });
-
- uploadedBytes += end - start;
- if (typeof onProgress === "function") {
- onProgress(Math.min(1, uploadedBytes / total));
- }
- }
- } catch (err) {
- uploadErrors.push(err);
- assignedPart = totalParts;
- }
- })()
- );
-
- try {
- await Promise.all(workers);
-
- if (uploadErrors.length > 0) {
- throw uploadErrors[0];
- }
-
- parts.sort((a, b) => a.PartNumber - b.PartNumber);
-
- const completeCommand = new CompleteMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- UploadId,
- MultipartUpload: { Parts: parts },
- });
-
- await s3.send(completeCommand);
- } catch (err) {
- try {
- const abortCommand = new AbortMultipartUploadCommand({
- Bucket: bucket,
- Key: key,
- UploadId,
- });
- await s3.send(abortCommand);
- } catch (abortErr) {
- console.warn("Failed to abort multipart upload:", abortErr);
- }
- throw err;
- }
-}
-
-export function buildPublicUrl(bucket, key, template) {
- const baseTemplate = (template || `https://${bucket}.r2.dev`).replace(
- /\{bucket\}/g,
- bucket
- );
- const sanitizedBase = baseTemplate.replace(/\/$/, "");
+ const sanitizedBase = String(baseUrl).replace(/\/$/, "");
const encodedKey = key
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `${sanitizedBase}/${encodedKey}`;
}
+
+export { sanitizeBaseDomain };
diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js
new file mode 100644
index 00000000..58c8e58a
--- /dev/null
+++ b/js/storage/r2-mgmt.js
@@ -0,0 +1,131 @@
+// js/storage/r2-mgmt.js
+export function sanitizeBucketName(npub) {
+ const base = (npub || "user")
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/^-+|[-]+$/g, "");
+ const suffix = Date.now().toString(36);
+ const name = `bv-${base || "u"}-${suffix}`.slice(0, 63);
+ return name.length < 3 ? `bv-u-${suffix}` : name;
+}
+
+function buildError(error, fallbackMessage) {
+ if (error instanceof Error) {
+ return error;
+ }
+ const err = new Error(fallbackMessage || "Cloudflare request failed");
+ err.original = error;
+ return err;
+}
+
+// --- Cloudflare API helpers (Bearer token) ---
+async function cfFetch(path, { token, method = "GET", body, headers = {} }) {
+ if (!token) {
+ throw new Error("Cloudflare API token is required");
+ }
+ const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ ...headers,
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ const text = await res.text();
+ let data = {};
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch (parseErr) {
+ if (!res.ok) {
+ const err = new Error(text || `Cloudflare API error ${res.status}`);
+ err.status = res.status;
+ throw err;
+ }
+ throw parseErr;
+ }
+ }
+
+ if (!res.ok || data?.success === false) {
+ const errorMessage =
+ data?.errors?.[0]?.message ||
+ data?.message ||
+ text ||
+ `Cloudflare API error ${res.status}`;
+ const err = new Error(errorMessage);
+ err.status = res.status;
+ err.response = data;
+ throw err;
+ }
+
+ return data;
+}
+
+// 1) Create bucket (idempotent-ish: treat 409 as success)
+export async function ensureBucket({ accountId, bucket, token }) {
+ try {
+ await cfFetch(`/accounts/${accountId}/r2/buckets`, {
+ token,
+ method: "POST",
+ body: { name: bucket },
+ });
+ } catch (error) {
+ const err = buildError(error);
+ if (err.status === 409 || /already exists/i.test(err.message || "")) {
+ return;
+ }
+ throw err;
+ }
+}
+
+// 2) Set CORS so browser PUT/GETs work from your app origins
+export async function putCors({ accountId, bucket, token, origins }) {
+ const filteredOrigins = (origins || []).filter(Boolean);
+ if (filteredOrigins.length === 0) {
+ return;
+ }
+ const rules = [
+ {
+ AllowedOrigins: filteredOrigins,
+ AllowedMethods: ["GET", "HEAD", "PUT", "POST"],
+ AllowedHeaders: ["*"],
+ ExposeHeaders: ["ETag", "Content-Length", "Content-Range"],
+ MaxAgeSeconds: 3600,
+ },
+ ];
+ await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
+ token,
+ method: "PUT",
+ body: { rules },
+ });
+}
+
+// 3) Attach custom domain to bucket (auto-provisions on Cloudflare)
+export async function attachCustomDomain({
+ accountId,
+ bucket,
+ token,
+ zoneId,
+ domain,
+}) {
+ const { result } = await cfFetch(
+ `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`,
+ {
+ token,
+ method: "POST",
+ body: { domain, zoneId, enabled: true, minTLS: "1.2" },
+ }
+ );
+ return `https://${result?.domain || domain}`;
+}
+
+// 4) Or enable r2.dev managed domain as fallback
+export async function enableManagedDomain({ accountId, bucket, token }) {
+ const { result } = await cfFetch(
+ `/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`,
+ { token, method: "PUT", body: { enabled: true } }
+ );
+ return `https://${result?.domain}`;
+}
diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js
new file mode 100644
index 00000000..7912541b
--- /dev/null
+++ b/js/storage/r2-s3.js
@@ -0,0 +1,138 @@
+// js/storage/r2-s3.js
+import {
+ S3Client,
+ CreateMultipartUploadCommand,
+ UploadPartCommand,
+ CompleteMultipartUploadCommand,
+ AbortMultipartUploadCommand,
+} from "https://esm.sh/@aws-sdk/client-s3@3.614.0?target=es2022&bundle";
+
+export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
+ if (!accountId || !accessKeyId || !secretAccessKey) {
+ throw new Error("Missing Cloudflare R2 credentials");
+ }
+
+ return new S3Client({
+ region: "auto",
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
+ credentials: { accessKeyId, secretAccessKey },
+ forcePathStyle: true,
+ });
+}
+
+export async function multipartUpload({
+ s3,
+ bucket,
+ key,
+ file,
+ contentType,
+ onProgress,
+ concurrency = 4,
+}) {
+ if (!s3) {
+ throw new Error("S3 client is required");
+ }
+ if (!bucket) {
+ throw new Error("Bucket is required");
+ }
+ if (!key) {
+ throw new Error("Object key is required");
+ }
+ if (!file) {
+ throw new Error("File is required");
+ }
+
+ const { UploadId } = await s3.send(
+ new CreateMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ ContentType: contentType || file.type || "video/mp4",
+ CacheControl: "public, max-age=31536000, immutable",
+ })
+ );
+
+ if (!UploadId) {
+ throw new Error("Failed to start multipart upload");
+ }
+
+ const PART = 8 * 1024 * 1024;
+ const total = file.size;
+ const parts = [];
+ let sent = 0;
+ let partNumber = 1;
+ const totalParts = Math.ceil(total / PART);
+ const errors = [];
+
+ const uploadPart = async () => {
+ const start = sent;
+ if (start >= total) {
+ return null;
+ }
+ const end = Math.min(start + PART, total);
+ const body = file.slice(start, end);
+ const currentPart = partNumber++;
+ sent = end;
+
+ return s3
+ .send(
+ new UploadPartCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId,
+ PartNumber: currentPart,
+ Body: body,
+ })
+ )
+ .then(({ ETag }) => {
+ parts.push({ ETag, PartNumber: currentPart });
+ if (typeof onProgress === "function") {
+ onProgress(end / total);
+ }
+ });
+ };
+
+ const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
+ (async () => {
+ try {
+ while (parts.length < totalParts) {
+ const task = uploadPart();
+ if (!task) {
+ break;
+ }
+ await task;
+ }
+ } catch (error) {
+ errors.push(error);
+ }
+ })()
+ );
+
+ try {
+ await Promise.all(workers);
+ if (errors.length > 0) {
+ throw errors[0];
+ }
+
+ parts.sort((a, b) => a.PartNumber - b.PartNumber);
+
+ await s3.send(
+ new CompleteMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId,
+ MultipartUpload: { Parts: parts },
+ })
+ );
+ } catch (error) {
+ await s3
+ .send(
+ new AbortMultipartUploadCommand({
+ Bucket: bucket,
+ Key: key,
+ UploadId,
+ })
+ )
+ .catch(() => {});
+ throw error;
+ }
+}
From 7f702708897b82110c7289b417c626f8dc0cb357 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 26 Sep 2025 21:25:20 -0400
Subject: [PATCH 2/2] Hide advanced Cloudflare settings behind toggle
---
components/upload-modal.html | 115 +++++++++++++++++++++--------------
js/app.js | 65 ++++++++++++++++++++
2 files changed, 136 insertions(+), 44 deletions(-)
diff --git a/components/upload-modal.html b/components/upload-modal.html
index 678bf679..484372f3 100644
--- a/components/upload-modal.html
+++ b/components/upload-modal.html
@@ -250,52 +250,79 @@
required
/>
-
-
Cloudflare API Token (optional)
+
-
-
- Used to create buckets, configure CORS, and attach domains automatically.
-
-
-
-
-
Zone ID (optional)
-
-
- Required when you want us to attach a custom domain on your behalf.
-
-
-
-
Base domain (optional)
-
-
- We'll attach npub.<baseDomain>.
- Leave blank to use the managed *.r2.dev domain.
-
+
+
+
Show advanced options
+
+
+
+
Cloudflare API Token (optional)
+
+
+ Used to create buckets, configure CORS, and attach domains automatically.
+
+
+
+
+
Zone ID (optional)
+
+
+ Required when you want us to attach a custom domain on your behalf.
+
+
+
+
Base domain (optional)
+
+
+ We'll attach npub.<baseDomain>.
+ Leave blank to use the managed *.r2.dev domain.
+
+
+
diff --git a/js/app.js b/js/app.js
index f1a459ac..dc644229 100644
--- a/js/app.js
+++ b/js/app.js
@@ -493,6 +493,11 @@ class bitvidApp {
this.cloudflareMagnetInput = null;
this.cloudflareWsInput = null;
this.cloudflareXsInput = null;
+ this.cloudflareAdvancedToggle = null;
+ this.cloudflareAdvancedToggleLabel = null;
+ this.cloudflareAdvancedToggleIcon = null;
+ this.cloudflareAdvancedFields = null;
+ this.cloudflareAdvancedVisible = false;
this.r2AccountIdInput = null;
this.r2AccessKeyIdInput = null;
this.r2SecretAccessKeyInput = null;
@@ -1124,6 +1129,14 @@ class bitvidApp {
document.getElementById("cloudflareWs") || null;
this.cloudflareXsInput =
document.getElementById("cloudflareXs") || null;
+ this.cloudflareAdvancedToggle =
+ document.getElementById("cloudflareAdvancedToggle") || null;
+ this.cloudflareAdvancedToggleLabel =
+ document.getElementById("cloudflareAdvancedToggleLabel") || null;
+ this.cloudflareAdvancedToggleIcon =
+ document.getElementById("cloudflareAdvancedToggleIcon") || null;
+ this.cloudflareAdvancedFields =
+ document.getElementById("cloudflareAdvancedFields") || null;
this.r2AccountIdInput =
document.getElementById("r2AccountId") || null;
this.r2AccessKeyIdInput =
@@ -1181,6 +1194,16 @@ class bitvidApp {
});
}
+ if (this.cloudflareAdvancedToggle) {
+ this.cloudflareAdvancedToggle.addEventListener("click", () => {
+ this.setCloudflareAdvancedVisibility(
+ !this.cloudflareAdvancedVisible
+ );
+ });
+ }
+
+ this.setCloudflareAdvancedVisibility(this.cloudflareAdvancedVisible);
+
await this.loadCloudflareSettingsFromStorage();
await this.updateCloudflareBucketPreview();
@@ -1235,6 +1258,36 @@ class bitvidApp {
}
}
+ setCloudflareAdvancedVisibility(visible) {
+ const isVisible = Boolean(visible);
+ this.cloudflareAdvancedVisible = isVisible;
+
+ if (this.cloudflareAdvancedFields) {
+ if (isVisible) {
+ this.cloudflareAdvancedFields.classList.remove("hidden");
+ } else {
+ this.cloudflareAdvancedFields.classList.add("hidden");
+ }
+ }
+
+ if (this.cloudflareAdvancedToggle) {
+ this.cloudflareAdvancedToggle.setAttribute(
+ "aria-expanded",
+ isVisible ? "true" : "false"
+ );
+ }
+
+ if (this.cloudflareAdvancedToggleLabel) {
+ this.cloudflareAdvancedToggleLabel.textContent = isVisible
+ ? "Hide advanced options"
+ : "Show advanced options";
+ }
+
+ if (this.cloudflareAdvancedToggleIcon) {
+ this.cloudflareAdvancedToggleIcon.classList.toggle("rotate-90", isVisible);
+ }
+ }
+
setCloudflareSettingsStatus(message = "", variant = "info") {
if (!this.cloudflareSettingsStatus) {
return;
@@ -1389,6 +1442,17 @@ class bitvidApp {
this.r2BaseDomainInput.value = data.baseDomain || "";
}
+ const hasAdvancedValues = Boolean(
+ (data.apiToken && data.apiToken.length > 0) ||
+ (data.zoneId && data.zoneId.length > 0) ||
+ (data.baseDomain && data.baseDomain.length > 0)
+ );
+ if (hasAdvancedValues) {
+ this.setCloudflareAdvancedVisibility(true);
+ } else if (!this.cloudflareAdvancedVisible) {
+ this.setCloudflareAdvancedVisibility(false);
+ }
+
this.setCloudflareSettingsStatus("");
}
@@ -1460,6 +1524,7 @@ class bitvidApp {
await clearR2Settings();
this.cloudflareSettings = await loadR2Settings();
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
+ this.setCloudflareAdvancedVisibility(false);
this.setCloudflareSettingsStatus("Settings cleared.", "success");
await this.updateCloudflareBucketPreview();
} catch (err) {