mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 03:12:31 +00:00
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:
3 files changed
+262
-9
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user