diff --git a/js/app.js b/js/app.js index a1ef4d7a..b25a7562 100644 --- a/js/app.js +++ b/js/app.js @@ -31,10 +31,31 @@ import { putCors, attachCustomDomainAndWait, setManagedDomain, + deriveShortSubdomain, } 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"; +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. */ @@ -1553,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) { @@ -1568,6 +1595,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,11 +1619,33 @@ class bitvidApp { accountId, bucket: entry.bucket, token: apiToken, - origins: this.getCorsOrigins(), + origins: corsOrigins, }); } 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, @@ -1602,9 +1655,69 @@ 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(), + }; + + 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 || + 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); @@ -1616,7 +1729,7 @@ class bitvidApp { accountId, bucket: bucketName, token: apiToken, - origins: this.getCorsOrigins(), + origins: corsOrigins, }); } catch (err) { console.warn("Failed to apply R2 CORS rules:", err); @@ -1735,7 +1848,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, 72); + try { + const parsed = new URL(publicUrl); + const cleanPath = parsed.pathname.replace(/^\//, ""); + 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, 28); + el.textContent = `${truncatedBucket} • ${displayHostAndPath}`; + el.setAttribute("title", fullPreview); } async handleCloudflareUploadSubmit() { 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; diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index e71f948b..bfe65b36 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -1,10 +1,13 @@ // 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"; function computeCacheControl(key) { @@ -41,6 +44,72 @@ 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"); + } + if (!bucket) { + throw new Error("Bucket name is required to configure CORS"); + } + + const allowedOrigins = (origins || []).filter(Boolean); + if (allowedOrigins.length === 0) { + return; + } + + await ensureBucketExists({ s3, bucket }); + + 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,