Merge pull request #118 from PR0M3TH3AN/codex/fix-cloudflare-api-token-error

Allow manual Cloudflare R2 configuration without API token
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-26 23:58:22 -04:00
commit 742dd12915
3 files changed
+262 -9

No files matched your search

+135 -8
View File
@@ -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() {
+58 -1
View File
@@ -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;
+69
View File
@@ -1,10 +1,13 @@
// js/storage/r2-s3.js
import {
S3Client,
CreateBucketCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
HeadBucketCommand,
PutBucketCorsCommand,
} from "https://esm.sh/@aws-sdk/[email protected]?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,