mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add Cloudflare R2 upload workflow
This commit is contained in:
1 parent
8d5d79e96f
commit
ea300b47bf
3 files changed
+1414
-55
No files matched your search
@@ -195,10 +195,262 @@
|
||||
</div>
|
||||
|
||||
<div id="cloudflareUploadSection" class="hidden">
|
||||
<div
|
||||
class="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed border-gray-700 bg-black/20 p-8 text-center text-sm text-gray-400"
|
||||
>
|
||||
Cloudflare uploads are coming soon.
|
||||
<div class="space-y-8">
|
||||
<section class="rounded-lg border border-gray-800 bg-gray-900/60 p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-100">
|
||||
Cloudflare R2 credentials
|
||||
</h3>
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500">
|
||||
stored locally
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-gray-400">
|
||||
Paste your Cloudflare R2 API credentials. We keep them in your
|
||||
browser so uploads happen client-side.
|
||||
</p>
|
||||
<form id="cloudflareSettingsForm" class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="r2AccountId" class="block text-sm font-medium text-gray-200"
|
||||
>Account ID</label
|
||||
>
|
||||
<input
|
||||
id="r2AccountId"
|
||||
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="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2AccessKeyId" class="block text-sm font-medium text-gray-200"
|
||||
>Access Key ID</label
|
||||
>
|
||||
<input
|
||||
id="r2AccessKeyId"
|
||||
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="AKIA..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2SecretAccessKey" class="block text-sm font-medium text-gray-200"
|
||||
>Secret Access Key</label
|
||||
>
|
||||
<input
|
||||
id="r2SecretAccessKey"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2PublicBaseUrlTemplate" class="block text-sm font-medium text-gray-200"
|
||||
>Public base URL template</label
|
||||
>
|
||||
<input
|
||||
id="r2PublicBaseUrlTemplate"
|
||||
type="url"
|
||||
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"
|
||||
/>
|
||||
<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.
|
||||
</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
|
||||
>
|
||||
<input
|
||||
id="r2ManualBucket"
|
||||
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"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Lowercase letters, numbers, and hyphens only (3–63 chars).
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Save settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cloudflareClearSettings"
|
||||
class="rounded-md border border-gray-600 px-4 py-2 text-sm font-semibold text-gray-200 transition hover:border-gray-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<span
|
||||
id="cloudflareSettingsStatus"
|
||||
class="text-xs text-gray-400"
|
||||
role="status"
|
||||
></span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-gray-800 bg-gray-900/60 p-5">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-100">
|
||||
Upload & publish
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-400">
|
||||
Choose a file, we will upload it to R2, fill the hosted URL,
|
||||
and publish your note automatically.
|
||||
</p>
|
||||
</div>
|
||||
<form id="cloudflareUploadForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="cloudflareTitle" class="block text-sm font-medium text-gray-200"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareTitle"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflareDescription" class="block text-sm font-medium text-gray-200"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="cloudflareDescription"
|
||||
rows="3"
|
||||
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"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="cloudflareThumbnail" class="block text-sm font-medium text-gray-200"
|
||||
>Thumbnail URL (optional)</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareThumbnail"
|
||||
type="url"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflareMagnet" class="block text-sm font-medium text-gray-200"
|
||||
>Magnet link (optional)</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareMagnet"
|
||||
type="text"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
id="cloudflareWs"
|
||||
type="url"
|
||||
placeholder="Web seed (ws=)"
|
||||
class="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"
|
||||
/>
|
||||
<input
|
||||
id="cloudflareXs"
|
||||
type="url"
|
||||
placeholder=".torrent URL (xs=)"
|
||||
class="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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflareFile" class="block text-sm font-medium text-gray-200"
|
||||
>Video or HLS file</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareFile"
|
||||
type="file"
|
||||
accept="video/*,.m3u8,.ts,.mp4,.webm,.mov,.mkv,.mpg,.mpeg"
|
||||
class="mt-1 w-full rounded-md border border-dashed border-gray-600 bg-gray-900 px-3 py-4 text-sm text-gray-300 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
We use multipart uploads with automatic bucket creation.
|
||||
Large files are supported.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
id="cloudflareProgressBar"
|
||||
class="hidden h-2 w-full overflow-hidden rounded-full bg-gray-800"
|
||||
>
|
||||
<div
|
||||
id="cloudflareProgressFill"
|
||||
class="h-full w-0 bg-blue-500 transition-all duration-150"
|
||||
></div>
|
||||
</div>
|
||||
<p
|
||||
id="cloudflareUploadStatus"
|
||||
class="text-xs text-gray-400"
|
||||
role="status"
|
||||
></p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
id="cloudflareUploadButton"
|
||||
class="w-full rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-blue-900"
|
||||
>
|
||||
Upload to R2 & publish
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,16 @@ import {
|
||||
initialBlacklist,
|
||||
initialEventBlacklist,
|
||||
} from "./lists.js";
|
||||
import {
|
||||
loadR2Settings,
|
||||
saveR2Settings,
|
||||
clearR2Settings,
|
||||
bucketForNpub,
|
||||
buildR2Key,
|
||||
buildPublicUrl,
|
||||
createR2Client,
|
||||
uploadToR2,
|
||||
} from "./r2.js";
|
||||
|
||||
/**
|
||||
* Simple "decryption" placeholder for private videos.
|
||||
@@ -459,6 +469,30 @@ class bitvidApp {
|
||||
this.customUploadSection = null;
|
||||
this.cloudflareUploadSection = null;
|
||||
this.activeUploadMode = "custom";
|
||||
this.cloudflareSettings = null;
|
||||
this.cloudflareSettingsForm = null;
|
||||
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;
|
||||
this.cloudflareUploadStatus = null;
|
||||
this.cloudflareProgressBar = null;
|
||||
this.cloudflareProgressFill = null;
|
||||
this.cloudflareTitleInput = null;
|
||||
this.cloudflareDescriptionInput = null;
|
||||
this.cloudflareThumbnailInput = null;
|
||||
this.cloudflareMagnetInput = null;
|
||||
this.cloudflareWsInput = null;
|
||||
this.cloudflareXsInput = null;
|
||||
this.r2AccountIdInput = null;
|
||||
this.r2AccessKeyIdInput = null;
|
||||
this.r2SecretAccessKeyInput = null;
|
||||
this.r2PublicBaseUrlInput = null;
|
||||
|
||||
// Optional small inline player stats
|
||||
this.status = document.getElementById("status") || null;
|
||||
@@ -1052,6 +1086,53 @@ class bitvidApp {
|
||||
document.getElementById("customUploadSection") || null;
|
||||
this.cloudflareUploadSection =
|
||||
document.getElementById("cloudflareUploadSection") || null;
|
||||
this.cloudflareSettingsForm =
|
||||
document.getElementById("cloudflareSettingsForm") || null;
|
||||
this.cloudflareClearSettingsButton =
|
||||
document.getElementById("cloudflareClearSettings") || null;
|
||||
this.cloudflareSettingsStatus =
|
||||
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 =
|
||||
document.getElementById("cloudflareFile") || null;
|
||||
this.cloudflareUploadButton =
|
||||
document.getElementById("cloudflareUploadButton") || null;
|
||||
this.cloudflareUploadStatus =
|
||||
document.getElementById("cloudflareUploadStatus") || null;
|
||||
this.cloudflareProgressBar =
|
||||
document.getElementById("cloudflareProgressBar") || null;
|
||||
this.cloudflareProgressFill =
|
||||
document.getElementById("cloudflareProgressFill") || null;
|
||||
this.cloudflareTitleInput =
|
||||
document.getElementById("cloudflareTitle") || null;
|
||||
this.cloudflareDescriptionInput =
|
||||
document.getElementById("cloudflareDescription") || null;
|
||||
this.cloudflareThumbnailInput =
|
||||
document.getElementById("cloudflareThumbnail") || null;
|
||||
this.cloudflareMagnetInput =
|
||||
document.getElementById("cloudflareMagnet") || null;
|
||||
this.cloudflareWsInput =
|
||||
document.getElementById("cloudflareWs") || null;
|
||||
this.cloudflareXsInput =
|
||||
document.getElementById("cloudflareXs") || null;
|
||||
this.r2AccountIdInput =
|
||||
document.getElementById("r2AccountId") || null;
|
||||
this.r2AccessKeyIdInput =
|
||||
document.getElementById("r2AccessKeyId") || null;
|
||||
this.r2SecretAccessKeyInput =
|
||||
document.getElementById("r2SecretAccessKey") || null;
|
||||
this.r2PublicBaseUrlInput =
|
||||
document.getElementById("r2PublicBaseUrlTemplate") || null;
|
||||
|
||||
// Optional: if close button found, wire up
|
||||
if (this.closeUploadModalBtn) {
|
||||
@@ -1069,6 +1150,40 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cloudflareSettingsForm) {
|
||||
this.cloudflareSettingsForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
this.handleCloudflareSettingsSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cloudflareClearSettingsButton) {
|
||||
this.cloudflareClearSettingsButton.addEventListener("click", () => {
|
||||
this.handleCloudflareClearSettings();
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
this.handleCloudflareUploadSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.uploadModeToggleButtons.length > 0) {
|
||||
this.uploadModeToggleButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -1078,6 +1193,9 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
await this.loadCloudflareSettingsFromStorage();
|
||||
await this.updateCloudflareBucketPreview();
|
||||
|
||||
this.setUploadMode(this.activeUploadMode);
|
||||
|
||||
console.log("Upload modal initialization successful");
|
||||
@@ -1123,6 +1241,552 @@ class bitvidApp {
|
||||
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized === "cloudflare") {
|
||||
this.updateCloudflareBucketPreview();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const el = this.cloudflareSettingsStatus;
|
||||
el.textContent = message || "";
|
||||
el.classList.remove(
|
||||
"text-green-400",
|
||||
"text-red-400",
|
||||
"text-yellow-400",
|
||||
"text-gray-400"
|
||||
);
|
||||
if (!message) {
|
||||
el.classList.add("text-gray-400");
|
||||
return;
|
||||
}
|
||||
|
||||
let cls = "text-gray-400";
|
||||
if (variant === "success") {
|
||||
cls = "text-green-400";
|
||||
} else if (variant === "error") {
|
||||
cls = "text-red-400";
|
||||
} else if (variant === "warning") {
|
||||
cls = "text-yellow-400";
|
||||
}
|
||||
el.classList.add(cls);
|
||||
}
|
||||
|
||||
setCloudflareUploadStatus(message = "", variant = "info") {
|
||||
if (!this.cloudflareUploadStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.cloudflareUploadStatus;
|
||||
el.textContent = message || "";
|
||||
el.classList.remove(
|
||||
"text-green-400",
|
||||
"text-red-400",
|
||||
"text-yellow-400",
|
||||
"text-gray-400"
|
||||
);
|
||||
if (!message) {
|
||||
el.classList.add("text-gray-400");
|
||||
return;
|
||||
}
|
||||
|
||||
let cls = "text-gray-400";
|
||||
if (variant === "success") {
|
||||
cls = "text-green-400";
|
||||
} else if (variant === "error") {
|
||||
cls = "text-red-400";
|
||||
} else if (variant === "warning") {
|
||||
cls = "text-yellow-400";
|
||||
}
|
||||
el.classList.add(cls);
|
||||
}
|
||||
|
||||
setCloudflareUploading(isUploading) {
|
||||
if (this.cloudflareUploadButton) {
|
||||
this.cloudflareUploadButton.disabled = Boolean(isUploading);
|
||||
this.cloudflareUploadButton.textContent = isUploading
|
||||
? "Uploading…"
|
||||
: "Upload to R2 & publish";
|
||||
}
|
||||
|
||||
if (this.cloudflareFileInput) {
|
||||
this.cloudflareFileInput.disabled = Boolean(isUploading);
|
||||
}
|
||||
}
|
||||
|
||||
updateCloudflareProgress(fraction) {
|
||||
if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof fraction !== "number" || Number.isNaN(fraction)) {
|
||||
this.cloudflareProgressBar.classList.add("hidden");
|
||||
this.cloudflareProgressFill.style.width = "0%";
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, fraction));
|
||||
this.cloudflareProgressBar.classList.remove("hidden");
|
||||
this.cloudflareProgressFill.style.width = `${(clamped * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
resetCloudflareUploadForm() {
|
||||
if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = "";
|
||||
if (this.cloudflareDescriptionInput)
|
||||
this.cloudflareDescriptionInput.value = "";
|
||||
if (this.cloudflareThumbnailInput)
|
||||
this.cloudflareThumbnailInput.value = "";
|
||||
if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = "";
|
||||
if (this.cloudflareWsInput) this.cloudflareWsInput.value = "";
|
||||
if (this.cloudflareXsInput) this.cloudflareXsInput.value = "";
|
||||
if (this.cloudflareFileInput) this.cloudflareFileInput.value = "";
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
}
|
||||
|
||||
async loadCloudflareSettingsFromStorage() {
|
||||
try {
|
||||
const settings = await loadR2Settings();
|
||||
this.cloudflareSettings = settings;
|
||||
this.populateCloudflareSettingsInputs(settings);
|
||||
} catch (err) {
|
||||
console.error("Failed to load Cloudflare settings:", err);
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to load saved settings.",
|
||||
"error"
|
||||
);
|
||||
this.cloudflareSettings = {
|
||||
accountId: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
publicBaseUrlTemplate: "",
|
||||
bucketMode: "auto",
|
||||
manualBucket: "",
|
||||
autoBuckets: {},
|
||||
};
|
||||
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
|
||||
}
|
||||
}
|
||||
|
||||
populateCloudflareSettingsInputs(settings) {
|
||||
const data = settings || {
|
||||
accountId: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
publicBaseUrlTemplate: "",
|
||||
bucketMode: "auto",
|
||||
manualBucket: "",
|
||||
};
|
||||
|
||||
if (this.r2AccountIdInput) {
|
||||
this.r2AccountIdInput.value = data.accountId || "";
|
||||
}
|
||||
if (this.r2AccessKeyIdInput) {
|
||||
this.r2AccessKeyIdInput.value = data.accessKeyId || "";
|
||||
}
|
||||
if (this.r2SecretAccessKeyInput) {
|
||||
this.r2SecretAccessKeyInput.value = data.secretAccessKey || "";
|
||||
}
|
||||
if (this.r2PublicBaseUrlInput) {
|
||||
this.r2PublicBaseUrlInput.value = data.publicBaseUrlTemplate || "";
|
||||
}
|
||||
|
||||
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.cloudflareManualBucketGroup) {
|
||||
if (mode === "manual") {
|
||||
this.cloudflareManualBucketGroup.classList.remove("hidden");
|
||||
} else {
|
||||
this.cloudflareManualBucketGroup.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cloudflareManualBucketInput) {
|
||||
this.cloudflareManualBucketInput.value = data.manualBucket || "";
|
||||
}
|
||||
|
||||
this.setCloudflareSettingsStatus("");
|
||||
}
|
||||
|
||||
async handleCloudflareSettingsSubmit({ quiet = false } = {}) {
|
||||
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);
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Account ID, Access Key ID, and Secret are required.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bucketMode === "manual" && manualBucket.length < 3) {
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Manual bucket names must be at least 3 characters.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.cloudflareManualBucketInput) {
|
||||
this.cloudflareManualBucketInput.value = manualBucket;
|
||||
}
|
||||
|
||||
const autoBuckets = {
|
||||
...(this.cloudflareSettings?.autoBuckets || {}),
|
||||
};
|
||||
|
||||
const updatedSettings = {
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
publicBaseUrlTemplate,
|
||||
bucketMode,
|
||||
manualBucket,
|
||||
autoBuckets,
|
||||
};
|
||||
|
||||
try {
|
||||
this.cloudflareSettings = await saveR2Settings(updatedSettings);
|
||||
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
|
||||
}
|
||||
await this.updateCloudflareBucketPreview();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to save Cloudflare settings:", err);
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to save settings. Check console for details.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async handleCloudflareClearSettings() {
|
||||
try {
|
||||
await clearR2Settings();
|
||||
this.cloudflareSettings = await loadR2Settings();
|
||||
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
|
||||
this.setCloudflareSettingsStatus("Settings cleared.", "success");
|
||||
await this.updateCloudflareBucketPreview();
|
||||
} catch (err) {
|
||||
console.error("Failed to clear Cloudflare settings:", err);
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to clear settings.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleCloudflareBucketModeChange() {
|
||||
const mode = this.getCloudflareBucketMode();
|
||||
if (this.cloudflareManualBucketGroup) {
|
||||
if (mode === "manual") {
|
||||
this.cloudflareManualBucketGroup.classList.remove("hidden");
|
||||
} else {
|
||||
this.cloudflareManualBucketGroup.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
if (this.cloudflareSettings) {
|
||||
this.cloudflareSettings.bucketMode = mode;
|
||||
}
|
||||
this.updateCloudflareBucketPreview();
|
||||
}
|
||||
|
||||
handleManualBucketInputChange() {
|
||||
this.updateCloudflareBucketPreview();
|
||||
}
|
||||
|
||||
async ensureAutoBucketForCurrentUser() {
|
||||
if (!this.pubkey || !this.cloudflareSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = this.cloudflareSettings.bucketMode === "manual" ? "manual" : "auto";
|
||||
if (mode !== "auto") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npub = this.safeEncodeNpub(this.pubkey);
|
||||
if (!npub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = this.cloudflareSettings.autoBuckets?.[npub];
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bucket = bucketForNpub(npub);
|
||||
const updatedSettings = {
|
||||
...this.cloudflareSettings,
|
||||
autoBuckets: {
|
||||
...(this.cloudflareSettings.autoBuckets || {}),
|
||||
[npub]: bucket,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.cloudflareSettings = await saveR2Settings(updatedSettings);
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist auto bucket name:", err);
|
||||
this.cloudflareSettings = updatedSettings;
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
async updateCloudflareBucketPreview() {
|
||||
if (!this.cloudflareBucketPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
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}`;
|
||||
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.";
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = this.safeEncodeNpub(this.pubkey);
|
||||
if (!npub) {
|
||||
el.textContent = "Unable to encode npub.";
|
||||
return;
|
||||
}
|
||||
|
||||
let bucket = this.cloudflareSettings.autoBuckets?.[npub];
|
||||
if (!bucket) {
|
||||
bucket = await this.ensureAutoBucketForCurrentUser();
|
||||
}
|
||||
|
||||
if (!bucket) {
|
||||
el.textContent = "Save your credentials to generate a bucket.";
|
||||
return;
|
||||
}
|
||||
|
||||
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
|
||||
const publicUrl = buildPublicUrl(bucket, sampleKey, template);
|
||||
el.textContent = `${bucket} • ${publicUrl}`;
|
||||
}
|
||||
|
||||
async handleCloudflareUploadSubmit() {
|
||||
if (!this.pubkey) {
|
||||
this.showError("Please login to post a video.");
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await this.handleCloudflareSettingsSubmit({ quiet: true });
|
||||
if (!saved) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Fix your R2 settings before uploading.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (this.cloudflareTitleInput?.value || "").trim();
|
||||
if (!title) {
|
||||
this.setCloudflareUploadStatus("Title is required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = this.cloudflareFileInput?.files?.[0] || null;
|
||||
if (!file) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Select a video or HLS file to upload.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const description = (this.cloudflareDescriptionInput?.value || "").trim();
|
||||
const thumbnail = (this.cloudflareThumbnailInput?.value || "").trim();
|
||||
const magnet = (this.cloudflareMagnetInput?.value || "").trim();
|
||||
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 || "";
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Missing R2 credentials. Save them before uploading.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = this.safeEncodeNpub(this.pubkey);
|
||||
if (!npub) {
|
||||
this.setCloudflareUploadStatus("Unable to encode npub.", "error");
|
||||
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.updateCloudflareProgress(0);
|
||||
this.setCloudflareUploading(true);
|
||||
|
||||
try {
|
||||
const s3 = await createR2Client({
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
});
|
||||
|
||||
await uploadToR2({
|
||||
s3,
|
||||
bucket: bucketName,
|
||||
key,
|
||||
file,
|
||||
contentType: file.type,
|
||||
onProgress: (fraction) => {
|
||||
this.updateCloudflareProgress(fraction);
|
||||
},
|
||||
});
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
ws,
|
||||
xs,
|
||||
};
|
||||
|
||||
const published = await this.publishVideoNote(payload, {
|
||||
onSuccess: () => {
|
||||
this.resetCloudflareUploadForm();
|
||||
},
|
||||
});
|
||||
|
||||
if (published) {
|
||||
this.setCloudflareUploadStatus(
|
||||
`Published ${publicUrl}`,
|
||||
"success"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Cloudflare upload failed:", err);
|
||||
this.setCloudflareUploadStatus(
|
||||
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
await this.updateCloudflareBucketPreview();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1496,15 +2160,69 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
async publishVideoNote(payload, { onSuccess, suppressModalClose } = {}) {
|
||||
if (!this.pubkey) {
|
||||
this.showError("Please login to post a video.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = (payload?.title || "").trim();
|
||||
const url = (payload?.url || "").trim();
|
||||
const magnet = (payload?.magnet || "").trim();
|
||||
const thumbnail = (payload?.thumbnail || "").trim();
|
||||
const description = (payload?.description || "").trim();
|
||||
const ws = (payload?.ws || "").trim();
|
||||
const xs = (payload?.xs || "").trim();
|
||||
|
||||
const formData = {
|
||||
version: 3,
|
||||
title,
|
||||
url,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
mode: isDevMode ? "dev" : "live",
|
||||
};
|
||||
|
||||
if (!formData.title || (!formData.url && !formData.magnet)) {
|
||||
this.showError("Title and at least one of URL or Magnet is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.url && !/^https:\/\//i.test(formData.url)) {
|
||||
this.showError("Hosted video URLs must use HTTPS.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.magnet) {
|
||||
formData.magnet = normalizeAndAugmentMagnet(formData.magnet, {
|
||||
ws,
|
||||
xs,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await nostrClient.publishVideo(formData, this.pubkey);
|
||||
if (typeof onSuccess === "function") {
|
||||
await onSuccess();
|
||||
}
|
||||
if (suppressModalClose !== true && this.uploadModal) {
|
||||
this.uploadModal.classList.add("hidden");
|
||||
}
|
||||
await this.loadVideos();
|
||||
this.showSuccess("Video shared successfully!");
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to publish video:", err);
|
||||
this.showError("Failed to share video. Please try again later.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually handle the upload form submission.
|
||||
*/
|
||||
async handleUploadSubmit() {
|
||||
if (!this.pubkey) {
|
||||
this.showError("Please login to post a video.");
|
||||
return;
|
||||
}
|
||||
|
||||
const titleEl = document.getElementById("uploadTitle");
|
||||
const urlEl = document.getElementById("uploadUrl");
|
||||
const magnetEl = document.getElementById("uploadMagnet");
|
||||
@@ -1522,59 +2240,28 @@ class bitvidApp {
|
||||
const thumbnail = thumbEl?.value.trim() || "";
|
||||
const description = descEl?.value.trim() || "";
|
||||
|
||||
const formData = {
|
||||
version: 3,
|
||||
const payload = {
|
||||
title,
|
||||
url,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
mode: isDevMode ? "dev" : "live",
|
||||
// isPrivate: privEl?.checked || false,
|
||||
ws,
|
||||
xs,
|
||||
};
|
||||
|
||||
if (!formData.title || (!formData.url && !formData.magnet)) {
|
||||
this.showError("Title and at least one of URL or Magnet is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.url && !/^https:\/\//i.test(formData.url)) {
|
||||
this.showError("Hosted video URLs must use HTTPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.magnet) {
|
||||
formData.magnet = normalizeAndAugmentMagnet(formData.magnet, {
|
||||
ws,
|
||||
xs,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await nostrClient.publishVideo(formData, this.pubkey);
|
||||
|
||||
// Clear fields
|
||||
if (titleEl) titleEl.value = "";
|
||||
if (urlEl) urlEl.value = "";
|
||||
if (magnetEl) magnetEl.value = "";
|
||||
if (wsEl) wsEl.value = "";
|
||||
if (xsEl) xsEl.value = "";
|
||||
if (thumbEl) thumbEl.value = "";
|
||||
if (descEl) descEl.value = "";
|
||||
if (privEl) privEl.checked = false;
|
||||
|
||||
// Hide the modal
|
||||
if (this.uploadModal) {
|
||||
this.uploadModal.classList.add("hidden");
|
||||
}
|
||||
|
||||
// *** Refresh to show the newly uploaded video in the grid ***
|
||||
await this.loadVideos();
|
||||
this.showSuccess("Video shared successfully!");
|
||||
} catch (err) {
|
||||
console.error("Failed to publish video:", err);
|
||||
this.showError("Failed to share video. Please try again later.");
|
||||
}
|
||||
await this.publishVideoNote(payload, {
|
||||
onSuccess: () => {
|
||||
if (titleEl) titleEl.value = "";
|
||||
if (urlEl) urlEl.value = "";
|
||||
if (magnetEl) magnetEl.value = "";
|
||||
if (wsEl) wsEl.value = "";
|
||||
if (xsEl) xsEl.value = "";
|
||||
if (thumbEl) thumbEl.value = "";
|
||||
if (descEl) descEl.value = "";
|
||||
if (privEl) privEl.checked = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1623,6 +2310,8 @@ class bitvidApp {
|
||||
|
||||
// Force a fresh fetch of all profile pictures/names
|
||||
this.forceRefreshAllProfiles();
|
||||
|
||||
await this.updateCloudflareBucketPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1669,6 +2358,8 @@ class bitvidApp {
|
||||
|
||||
// Force a fresh fetch of all profile pictures/names (public ones in this case)
|
||||
this.forceRefreshAllProfiles();
|
||||
|
||||
await this.updateCloudflareBucketPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
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);
|
||||
}
|
||||
return s3ModulePromise;
|
||||
}
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to open settings DB"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
if (db) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(SETTINGS_KEY);
|
||||
req.onsuccess = () => {
|
||||
resolve(normalizeSettings(req.result));
|
||||
};
|
||||
req.onerror = () => {
|
||||
reject(req.error || new Error("Failed to load settings"));
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to open IndexedDB for settings, falling back:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY);
|
||||
if (raw) {
|
||||
return normalizeSettings(JSON.parse(raw));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to read fallback settings:", err);
|
||||
}
|
||||
|
||||
return normalizeSettings(null);
|
||||
}
|
||||
|
||||
export async function saveR2Settings(settings) {
|
||||
const normalized = normalizeSettings(settings);
|
||||
|
||||
let db = null;
|
||||
try {
|
||||
db = await openSettingsDb();
|
||||
} catch (err) {
|
||||
console.warn("Unable to open IndexedDB, continuing with fallback:", err);
|
||||
}
|
||||
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(normalized, SETTINGS_KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () =>
|
||||
reject(req.error || new Error("Failed to save R2 settings"));
|
||||
});
|
||||
} else if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE_FALLBACK_KEY,
|
||||
JSON.stringify(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function clearR2Settings() {
|
||||
let cleared = false;
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(SETTINGS_KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error || new Error("Failed to clear"));
|
||||
});
|
||||
cleared = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear IndexedDB settings:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY);
|
||||
cleared = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear fallback settings:", err);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.headers && typeof req.headers.set === "function") {
|
||||
req.headers.set("cf-create-bucket-if-missing", "true");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
{ step: "build", name: "r2AutoCreateBucket" }
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function guessExtension(file) {
|
||||
if (!file) {
|
||||
return "mp4";
|
||||
}
|
||||
|
||||
const name = typeof file.name === "string" ? file.name : "";
|
||||
const dotIndex = name.lastIndexOf(".");
|
||||
if (dotIndex > -1 && dotIndex < name.length - 1) {
|
||||
return name.slice(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
const type = typeof file.type === "string" ? file.type : "";
|
||||
switch (type) {
|
||||
case "video/webm":
|
||||
return "webm";
|
||||
case "application/vnd.apple.mpegurl":
|
||||
return "m3u8";
|
||||
case "video/mp2t":
|
||||
return "ts";
|
||||
case "video/quicktime":
|
||||
return "mov";
|
||||
case "video/x-matroska":
|
||||
return "mkv";
|
||||
default:
|
||||
return "mp4";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildR2Key(npub, file) {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const safeNpub = String(npub || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
const baseName = typeof file?.name === "string" ? file.name : "video";
|
||||
const withoutExt = baseName.replace(/\.[^/.]+$/, "");
|
||||
const slug = withoutExt
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const safeSlug = slug || "video";
|
||||
const ext = guessExtension(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");
|
||||
}
|
||||
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 encodedKey = key
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
return `${sanitizedBase}/${encodedKey}`;
|
||||
}
|
||||
Reference in new issue
Block a user