From 67f80787d04b16c81a316b1974453c050d086373 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:48:19 -0400 Subject: [PATCH 1/5] Allow manual Cloudflare bucket fallback --- js/app.js | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/js/app.js b/js/app.js index a1ef4d7a..3960845d 100644 --- a/js/app.js +++ b/js/app.js @@ -1602,9 +1602,49 @@ class bitvidApp { } if (!apiToken) { - throw new Error( - "Provide a Cloudflare API token to auto-create the bucket and domain." - ); + const bucketName = entry?.bucket || sanitizeBucketName(npub); + const manualCustomDomain = baseDomain + ? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}` + : ""; + + let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain; + if (!publicBaseUrl) { + publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`; + } + + if (!publicBaseUrl) { + throw new Error( + "No public bucket domain configured. Add an API token or configure the domain manually." + ); + } + + const manualEntry = { + bucket: bucketName, + publicBaseUrl, + domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom", + lastUpdated: Date.now(), + }; + + let savedEntry = entry; + if ( + !entry || + entry.bucket !== manualEntry.bucket || + entry.publicBaseUrl !== manualEntry.publicBaseUrl || + entry.domainType !== manualEntry.domainType + ) { + const updatedSettings = await saveR2Settings( + mergeBucketEntry(this.cloudflareSettings, npub, manualEntry) + ); + this.cloudflareSettings = updatedSettings; + savedEntry = updatedSettings.buckets?.[npub] || manualEntry; + } + + return { + entry: savedEntry, + usedManagedFallback: manualEntry.domainType !== "custom", + customDomainStatus: + manualEntry.domainType === "custom" ? "manual" : "managed", + }; } const bucketName = entry?.bucket || sanitizeBucketName(npub); From 46c2b39aae001b294bfde686c3ae522b344d3f31 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:58:13 -0400 Subject: [PATCH 2/5] Ensure R2 CORS when using manual access keys --- js/app.js | 34 +++++++++++++++++++++++++++++++--- js/storage/r2-s3.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/js/app.js b/js/app.js index 3960845d..a18669a8 100644 --- a/js/app.js +++ b/js/app.js @@ -32,7 +32,11 @@ import { attachCustomDomainAndWait, setManagedDomain, } from "./storage/r2-mgmt.js"; -import { makeR2Client, multipartUpload } from "./storage/r2-s3.js"; +import { + makeR2Client, + multipartUpload, + ensureBucketCors, +} from "./storage/r2-s3.js"; import { initQuickR2Upload } from "./r2-quick.js"; /** @@ -1568,6 +1572,10 @@ class bitvidApp { const accountId = (this.cloudflareSettings.accountId || "").trim(); const apiToken = (this.cloudflareSettings.apiToken || "").trim(); const zoneId = (this.cloudflareSettings.zoneId || "").trim(); + const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim(); + const secretAccessKey = + (this.cloudflareSettings.secretAccessKey || "").trim(); + const corsOrigins = this.getCorsOrigins(); const baseDomain = this.cloudflareSettings.baseDomain || ""; if (!accountId) { @@ -1588,7 +1596,7 @@ class bitvidApp { accountId, bucket: entry.bucket, token: apiToken, - origins: this.getCorsOrigins(), + origins: corsOrigins, }); } catch (err) { console.warn("Failed to refresh bucket configuration:", err); @@ -1625,6 +1633,26 @@ class bitvidApp { lastUpdated: Date.now(), }; + if (accessKeyId && secretAccessKey && corsOrigins.length > 0) { + try { + const s3 = makeR2Client({ + accountId, + accessKeyId, + secretAccessKey, + }); + await ensureBucketCors({ + s3, + bucket: bucketName, + origins: corsOrigins, + }); + } catch (corsErr) { + console.warn( + "Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.", + corsErr + ); + } + } + let savedEntry = entry; if ( !entry || @@ -1656,7 +1684,7 @@ class bitvidApp { accountId, bucket: bucketName, token: apiToken, - origins: this.getCorsOrigins(), + origins: corsOrigins, }); } catch (err) { console.warn("Failed to apply R2 CORS rules:", err); diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index e71f948b..56745b22 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -5,6 +5,7 @@ import { UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, + PutBucketCorsCommand, } from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle"; function computeCacheControl(key) { @@ -41,6 +42,42 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) { }); } +export async function ensureBucketCors({ s3, bucket, origins }) { + if (!s3) { + throw new Error("S3 client is required to configure CORS"); + } + if (!bucket) { + throw new Error("Bucket name is required to configure CORS"); + } + + const allowedOrigins = (origins || []).filter(Boolean); + if (allowedOrigins.length === 0) { + return; + } + + const command = new PutBucketCorsCommand({ + Bucket: bucket, + CORSConfiguration: { + CORSRules: [ + { + AllowedHeaders: ["*"], + AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"], + AllowedOrigins: allowedOrigins, + ExposeHeaders: [ + "ETag", + "Content-Length", + "Content-Range", + "Accept-Ranges", + ], + MaxAgeSeconds: 3600, + }, + ], + }, + }); + + await s3.send(command); +} + export async function multipartUpload({ s3, bucket, From c32d2fce12cfd8f327a5d6b8dd1094203f57efd0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 23:40:41 -0400 Subject: [PATCH 3/5] Ensure manual R2 buckets exist before applying CORS --- js/storage/r2-s3.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index 56745b22..bfe65b36 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -1,10 +1,12 @@ // js/storage/r2-s3.js import { S3Client, + CreateBucketCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, + HeadBucketCommand, PutBucketCorsCommand, } from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle"; @@ -42,6 +44,34 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) { }); } +async function ensureBucketExists({ s3, bucket }) { + try { + await s3.send(new HeadBucketCommand({ Bucket: bucket })); + return; + } catch (error) { + const status = error?.$metadata?.httpStatusCode || 0; + const code = error?.name || error?.Code || ""; + if (status !== 404 && code !== "NotFound" && code !== "NoSuchBucket") { + throw error; + } + } + + try { + await s3.send(new CreateBucketCommand({ Bucket: bucket })); + } catch (error) { + const status = error?.$metadata?.httpStatusCode || 0; + const code = error?.name || error?.Code || ""; + if ( + status === 409 || + code === "BucketAlreadyOwnedByYou" || + code === "BucketAlreadyExists" + ) { + return; + } + throw error; + } +} + export async function ensureBucketCors({ s3, bucket, origins }) { if (!s3) { throw new Error("S3 client is required to configure CORS"); @@ -55,6 +85,8 @@ export async function ensureBucketCors({ s3, bucket, origins }) { return; } + await ensureBucketExists({ s3, bucket }); + const command = new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { From 8a278ca0ace8ba1b46515c807f722dfd51b18e6d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 23:46:52 -0400 Subject: [PATCH 4/5] Shorten Cloudflare bucket preview text --- js/app.js | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index a18669a8..6a34a3e6 100644 --- a/js/app.js +++ b/js/app.js @@ -39,6 +39,22 @@ import { } from "./storage/r2-s3.js"; import { initQuickR2Upload } from "./r2-quick.js"; +function truncateMiddle(text, maxLength = 72) { + if (!text || typeof text !== "string") { + return ""; + } + + if (text.length <= maxLength) { + return text; + } + + const ellipsis = "…"; + const charsToShow = maxLength - ellipsis.length; + const front = Math.ceil(charsToShow / 2); + const back = Math.floor(charsToShow / 2); + return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; +} + /** * Simple "decryption" placeholder for private videos. */ @@ -1803,7 +1819,21 @@ class bitvidApp { const sampleKey = buildR2Key(npub, { name: "sample.mp4" }); const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey); - el.textContent = `${entry.bucket} • ${publicUrl}`; + const fullPreview = `${entry.bucket} • ${publicUrl}`; + + let displayHostAndPath = truncateMiddle(publicUrl, 96); + try { + const parsed = new URL(publicUrl); + const cleanPath = parsed.pathname.replace(/^\//, ""); + const truncatedPath = truncateMiddle(cleanPath || sampleKey, 48); + displayHostAndPath = `${truncateMiddle(parsed.host, 48)}/${truncatedPath}`; + } catch (err) { + // ignore URL parse issues and fall back to the raw string + } + + const truncatedBucket = truncateMiddle(entry.bucket, 40); + el.textContent = `${truncatedBucket} • ${displayHostAndPath}`; + el.setAttribute("title", fullPreview); } async handleCloudflareUploadSubmit() { From 1684c7070bf326f31742a50a6680914b97bcb137 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 26 Sep 2025 23:55:40 -0400 Subject: [PATCH 5/5] Shorten bucket slugs and fix CORS refresh --- js/app.js | 39 ++++++++++++++++++++++++---- js/storage/r2-mgmt.js | 59 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/js/app.js b/js/app.js index 6a34a3e6..b25a7562 100644 --- a/js/app.js +++ b/js/app.js @@ -31,6 +31,7 @@ import { putCors, attachCustomDomainAndWait, setManagedDomain, + deriveShortSubdomain, } from "./storage/r2-mgmt.js"; import { makeR2Client, @@ -1573,11 +1574,17 @@ class bitvidApp { } deriveSubdomainForNpub(npub) { + try { + return deriveShortSubdomain(npub); + } catch (err) { + console.warn("Failed to derive short subdomain, falling back:", err); + } + const base = String(npub || "user") .toLowerCase() .replace(/[^a-z0-9-]/g, "") .replace(/^-+|[-]+$/g, ""); - return base.slice(0, 48) || "user"; + return base.slice(0, 32) || "user"; } async ensureBucketConfigForNpub(npub) { @@ -1617,6 +1624,28 @@ class bitvidApp { } catch (err) { console.warn("Failed to refresh bucket configuration:", err); } + } else if ( + accessKeyId && + secretAccessKey && + corsOrigins.length > 0 + ) { + try { + const s3 = makeR2Client({ + accountId, + accessKeyId, + secretAccessKey, + }); + await ensureBucketCors({ + s3, + bucket: entry.bucket, + origins: corsOrigins, + }); + } catch (err) { + console.warn( + "Failed to refresh bucket CORS via access keys:", + err + ); + } } return { entry, @@ -1821,17 +1850,17 @@ class bitvidApp { const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey); const fullPreview = `${entry.bucket} • ${publicUrl}`; - let displayHostAndPath = truncateMiddle(publicUrl, 96); + let displayHostAndPath = truncateMiddle(publicUrl, 72); try { const parsed = new URL(publicUrl); const cleanPath = parsed.pathname.replace(/^\//, ""); - const truncatedPath = truncateMiddle(cleanPath || sampleKey, 48); - displayHostAndPath = `${truncateMiddle(parsed.host, 48)}/${truncatedPath}`; + const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32); + displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`; } catch (err) { // ignore URL parse issues and fall back to the raw string } - const truncatedBucket = truncateMiddle(entry.bucket, 40); + const truncatedBucket = truncateMiddle(entry.bucket, 28); el.textContent = `${truncatedBucket} • ${displayHostAndPath}`; el.setAttribute("title", fullPreview); } diff --git a/js/storage/r2-mgmt.js b/js/storage/r2-mgmt.js index 20434737..10a6ef81 100644 --- a/js/storage/r2-mgmt.js +++ b/js/storage/r2-mgmt.js @@ -1,6 +1,16 @@ // js/storage/r2-mgmt.js +function computeSlugHash(input) { + const text = String(input || ""); + let hash = 0; + for (let i = 0; i < text.length; i += 1) { + hash = Math.imul(31, hash) + text.charCodeAt(i); + } + return (hash >>> 0).toString(36); +} + export function sanitizeBucketName(npub) { - let value = String(npub || "") + const raw = String(npub || ""); + let value = raw .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") @@ -10,6 +20,14 @@ export function sanitizeBucketName(npub) { value = "bitvid-user"; } + if (value.length > 32) { + const prefix = value.slice(0, 20).replace(/-+$/g, ""); + const hash = computeSlugHash(raw).slice(0, 8); + value = [prefix, hash].filter(Boolean).join("-"); + } + + value = value.replace(/-+/g, "-").replace(/^-+|[-]+$/g, ""); + if (!/^[a-z0-9]/.test(value)) { value = `bv-${value}`; } @@ -29,6 +47,45 @@ export function sanitizeBucketName(npub) { return value; } +export function deriveShortSubdomain(npub) { + const bucketSlug = sanitizeBucketName(npub); + const hash = computeSlugHash(npub).slice(0, 6); + + const prefix = bucketSlug + .replace(/^bv-/, "") + .slice(0, 18) + .replace(/-+$/g, ""); + + let candidate = [prefix, hash].filter(Boolean).join("-"); + candidate = candidate + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|[-]+$/g, ""); + + if (!candidate) { + candidate = "user"; + } + + if (candidate.length > 32) { + candidate = candidate.slice(0, 32).replace(/-+$/g, ""); + } + + if (!/^[a-z0-9]/.test(candidate)) { + candidate = `u${candidate}`; + } + + if (!/[a-z0-9]$/.test(candidate)) { + candidate = `${candidate}0`; + } + + while (candidate.length < 3) { + candidate += "0"; + } + + return candidate; +} + function buildError(error, fallbackMessage) { if (error instanceof Error) { return error;