mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Add automated Cloudflare R2 bucket provisioning
This commit is contained in:
1 parent
f121a17cd9
commit
9b4dd2a6ff
5 files changed
+587
-496
No files matched your search
@@ -226,7 +226,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2AccessKeyId" class="block text-sm font-medium text-gray-200"
|
||||
>Access Key ID</label
|
||||
>S3 Access Key ID</label
|
||||
>
|
||||
<input
|
||||
id="r2AccessKeyId"
|
||||
@@ -240,7 +240,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2SecretAccessKey" class="block text-sm font-medium text-gray-200"
|
||||
>Secret Access Key</label
|
||||
>S3 Secret Access Key</label
|
||||
>
|
||||
<input
|
||||
id="r2SecretAccessKey"
|
||||
@@ -251,66 +251,53 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2PublicBaseUrlTemplate" class="block text-sm font-medium text-gray-200"
|
||||
>Public base URL template</label
|
||||
<label for="r2ApiToken" class="block text-sm font-medium text-gray-200"
|
||||
>Cloudflare API Token (optional)</label
|
||||
>
|
||||
<input
|
||||
id="r2PublicBaseUrlTemplate"
|
||||
type="url"
|
||||
id="r2ApiToken"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://{bucket}.videos.example.com"
|
||||
placeholder="Token with Workers R2 Storage Write"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Use <code class="rounded bg-gray-800 px-1 py-0.5">{bucket}</code>
|
||||
where the bucket name should appear. Defaults to
|
||||
<code class="rounded bg-gray-800 px-1 py-0.5">https://{bucket}.r2.dev</code>
|
||||
when blank.
|
||||
Used to create buckets, configure CORS, and attach domains automatically.
|
||||
</p>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-gray-200">
|
||||
Bucket mode
|
||||
</legend>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-4 text-sm text-gray-300">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="r2BucketMode"
|
||||
id="r2BucketModeAuto"
|
||||
value="auto"
|
||||
class="h-4 w-4 text-blue-500 focus:ring-blue-500"
|
||||
checked
|
||||
/>
|
||||
Auto (per npub)
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="r2BucketMode"
|
||||
id="r2BucketModeManual"
|
||||
value="manual"
|
||||
class="h-4 w-4 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Manual bucket
|
||||
</label>
|
||||
</div>
|
||||
<div id="r2ManualBucketGroup" class="mt-3 hidden">
|
||||
<label for="r2ManualBucket" class="block text-sm font-medium text-gray-200"
|
||||
>Bucket name</label
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="r2ZoneId" class="block text-sm font-medium text-gray-200"
|
||||
>Zone ID (optional)</label
|
||||
>
|
||||
<input
|
||||
id="r2ManualBucket"
|
||||
id="r2ZoneId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="bitvid-example-bucket"
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Lowercase letters, numbers, and hyphens only (3–63 chars).
|
||||
Required when you want us to attach a custom domain on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div>
|
||||
<label for="r2BaseDomain" class="block text-sm font-medium text-gray-200"
|
||||
>Base domain (optional)</label
|
||||
>
|
||||
<input
|
||||
id="r2BaseDomain"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="cdn.example.com"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
We'll attach <code class="rounded bg-gray-800 px-1 py-0.5">npub.<baseDomain></code>.
|
||||
Leave blank to use the managed <code class="rounded bg-gray-800 px-1 py-0.5">*.r2.dev</code> domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -422,8 +409,8 @@
|
||||
<div class="rounded-md border border-gray-800 bg-black/40 p-3 text-xs text-gray-400">
|
||||
<p>
|
||||
<strong class="text-gray-200">Bucket preview:</strong>
|
||||
<span id="cloudflareBucketPreview" class="ml-1">Save your
|
||||
settings to preview.</span>
|
||||
<span id="cloudflareBucketPreview" class="ml-1">We'll show your
|
||||
bucket and public domain after setup.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
const S3_MODULE_URL =
|
||||
"https://esm.sh/@aws-sdk/[email protected]?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 };
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// js/storage/r2-s3.js
|
||||
import {
|
||||
S3Client,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
} from "https://esm.sh/@aws-sdk/[email protected]?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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user