From 9b4dd2a6ffd9fc0779677f2ea6f2b4680eee1d58 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:46:18 -0400 Subject: [PATCH 1/2] Add automated Cloudflare R2 bucket provisioning --- components/upload-modal.html | 81 +++---- js/app.js | 447 +++++++++++++++++------------------ js/r2.js | 286 +++++----------------- js/storage/r2-mgmt.js | 131 ++++++++++ js/storage/r2-s3.js | 138 +++++++++++ 5 files changed, 587 insertions(+), 496 deletions(-) create mode 100644 js/storage/r2-mgmt.js create mode 100644 js/storage/r2-s3.js diff --git a/components/upload-modal.html b/components/upload-modal.html index 9522a92f..678bf679 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -226,7 +226,7 @@
S3 Access Key ID
S3 Secret Access Key
- Cloudflare API Token (optional)

- Use {bucket} - where the bucket name should appear. Defaults to - https://{bucket}.r2.dev - when blank. + Used to create buckets, configure CORS, and attach domains automatically.

-
- - Bucket mode - -
- - -
-
+
+ + +

+ 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 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 />
-
- +
-
-
- - -

- Required when you want us to attach a custom domain on your behalf. -

-
-
- - -

- We'll attach npub.<baseDomain>. - Leave blank to use the managed *.r2.dev domain. -

+ + + Show advanced options + +
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) {