mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Merge pull request #117 from PR0M3TH3AN/codex/add-cloudflare-r2-auto-setup-and-upload
Add quick Cloudflare R2 upload helper to custom modal
This commit is contained in:
3 files changed
+512
No files matched your search
@@ -148,6 +148,105 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details
|
||||
id="quickR2Section"
|
||||
class="rounded-md border border-gray-800 bg-gray-900/60 p-4"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-200">
|
||||
Optional: Upload directly to Cloudflare R2
|
||||
</summary>
|
||||
<div class="mt-3 space-y-4 text-sm text-gray-300">
|
||||
<p class="text-xs text-gray-400">
|
||||
Supply your Cloudflare credentials to upload in-browser. Keys
|
||||
stay on this device unless you opt in to remember them.
|
||||
</p>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Account ID
|
||||
<input
|
||||
id="quickR2AccountId"
|
||||
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="023e105f4ecef8ad9ca31a8372d0c353"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
S3 Access Key ID
|
||||
<input
|
||||
id="quickR2AccessKeyId"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
S3 Secret Access Key
|
||||
<input
|
||||
id="quickR2SecretAccessKey"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Cloudflare API Token (R2 write)
|
||||
<input
|
||||
id="quickR2ApiToken"
|
||||
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="Token with Workers R2 Storage Write"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Custom domain (optional)
|
||||
<input
|
||||
id="quickR2CustomDomain"
|
||||
type="text"
|
||||
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="media.example.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Zone ID (for custom domain)
|
||||
<input
|
||||
id="quickR2ZoneId"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-300">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input id="quickR2Remember" type="checkbox" class="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500" />
|
||||
Remember keys on this device
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input id="quickR2AllowManaged" type="checkbox" class="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500" checked />
|
||||
Allow r2.dev fallback when no custom domain
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
id="quickR2File"
|
||||
type="file"
|
||||
class="w-full max-w-xs rounded-md border border-dashed border-gray-600 bg-gray-900 px-3 py-2 text-xs text-gray-300 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
accept="video/*,application/vnd.apple.mpegurl,application/dash+xml"
|
||||
/>
|
||||
<button
|
||||
id="quickR2UploadButton"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-xs font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Upload to R2
|
||||
</button>
|
||||
</div>
|
||||
<p id="quickR2Status" class="text-xs text-gray-400" role="status"></p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="uploadThumbnail"
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
setManagedDomain,
|
||||
} from "./storage/r2-mgmt.js";
|
||||
import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
|
||||
import { initQuickR2Upload } from "./r2-quick.js";
|
||||
|
||||
/**
|
||||
* Simple "decryption" placeholder for private videos.
|
||||
@@ -755,6 +756,7 @@ class bitvidApp {
|
||||
|
||||
// 2. Initialize the upload modal (components/upload-modal.html)
|
||||
await this.initUploadModal();
|
||||
initQuickR2Upload(this);
|
||||
|
||||
// 3. (Optional) Initialize the profile modal (components/profile-modal.html)
|
||||
await this.initProfileModal();
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
import { buildR2Key, buildPublicUrl } from "./r2.js";
|
||||
import {
|
||||
sanitizeBucketName,
|
||||
ensureBucket,
|
||||
putCors,
|
||||
attachCustomDomainAndWait,
|
||||
setManagedDomain,
|
||||
} from "./storage/r2-mgmt.js";
|
||||
import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
|
||||
|
||||
const STORAGE_KEY = "bitvid:quickR2Settings";
|
||||
const STATUS_CLASSNAMES = [
|
||||
"text-gray-400",
|
||||
"text-green-400",
|
||||
"text-red-400",
|
||||
"text-yellow-400",
|
||||
];
|
||||
const STATUS_TONES = {
|
||||
info: "text-gray-400",
|
||||
success: "text-green-400",
|
||||
error: "text-red-400",
|
||||
warning: "text-yellow-400",
|
||||
};
|
||||
|
||||
function sanitizeDomain(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/.*$/, "");
|
||||
}
|
||||
|
||||
function loadSavedSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.warn("Failed to read quick R2 settings:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(settings) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.warn("Failed to store quick R2 settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedSettings() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear quick R2 settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, message, tone = "info") {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
STATUS_CLASSNAMES.forEach((cls) => el.classList.remove(cls));
|
||||
const className = STATUS_TONES[tone] || STATUS_TONES.info;
|
||||
if (className) {
|
||||
el.classList.add(className);
|
||||
}
|
||||
el.textContent = message || "";
|
||||
}
|
||||
|
||||
function setUploadingState(elements, uploading) {
|
||||
const disabled = Boolean(uploading);
|
||||
elements.forEach((el) => {
|
||||
if (el) {
|
||||
el.disabled = disabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function 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://")) {
|
||||
origins.add(origin.replace("http://", "https://"));
|
||||
}
|
||||
}
|
||||
origins.add("http://localhost:8000");
|
||||
origins.add("http://127.0.0.1:8000");
|
||||
return Array.from(origins);
|
||||
}
|
||||
|
||||
function buildQuickKey(title, file, npub) {
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().slice(0, 10);
|
||||
const baseName = (title || file?.name || "video")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const safeBase = baseName || "video";
|
||||
const originalName = typeof file?.name === "string" ? file.name : "";
|
||||
const extMatch = originalName.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : "mp4";
|
||||
const sanitizedNpub = String(npub || "user").replace(/[^a-z0-9]/gi, "");
|
||||
return `videos/${datePart}/${sanitizedNpub || "user"}-${safeBase}.${ext}`;
|
||||
}
|
||||
|
||||
export function initQuickR2Upload(app) {
|
||||
const section = document.getElementById("quickR2Section");
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = {
|
||||
accountId: document.getElementById("quickR2AccountId"),
|
||||
accessKeyId: document.getElementById("quickR2AccessKeyId"),
|
||||
secretAccessKey: document.getElementById("quickR2SecretAccessKey"),
|
||||
apiToken: document.getElementById("quickR2ApiToken"),
|
||||
customDomain: document.getElementById("quickR2CustomDomain"),
|
||||
zoneId: document.getElementById("quickR2ZoneId"),
|
||||
remember: document.getElementById("quickR2Remember"),
|
||||
allowManaged: document.getElementById("quickR2AllowManaged"),
|
||||
file: document.getElementById("quickR2File"),
|
||||
uploadButton: document.getElementById("quickR2UploadButton"),
|
||||
status: document.getElementById("quickR2Status"),
|
||||
hostedUrl: document.getElementById("uploadUrl"),
|
||||
titleInput: document.getElementById("uploadTitle"),
|
||||
form: document.getElementById("uploadForm"),
|
||||
};
|
||||
|
||||
const saved = loadSavedSettings();
|
||||
if (saved && saved.remember) {
|
||||
if (elements.accountId) elements.accountId.value = saved.accountId || "";
|
||||
if (elements.accessKeyId)
|
||||
elements.accessKeyId.value = saved.accessKeyId || "";
|
||||
if (elements.secretAccessKey)
|
||||
elements.secretAccessKey.value = saved.secretAccessKey || "";
|
||||
if (elements.apiToken) elements.apiToken.value = saved.apiToken || "";
|
||||
if (elements.customDomain)
|
||||
elements.customDomain.value = saved.customDomain || "";
|
||||
if (elements.zoneId) elements.zoneId.value = saved.zoneId || "";
|
||||
if (elements.allowManaged)
|
||||
elements.allowManaged.checked = Boolean(saved.allowManaged ?? true);
|
||||
if (elements.remember) elements.remember.checked = true;
|
||||
}
|
||||
|
||||
function maybePersist() {
|
||||
if (!elements.remember) {
|
||||
return;
|
||||
}
|
||||
if (elements.remember.checked) {
|
||||
persistSettings({
|
||||
remember: true,
|
||||
accountId: elements.accountId?.value?.trim() || "",
|
||||
accessKeyId: elements.accessKeyId?.value?.trim() || "",
|
||||
secretAccessKey: elements.secretAccessKey?.value?.trim() || "",
|
||||
apiToken: elements.apiToken?.value?.trim() || "",
|
||||
customDomain: elements.customDomain?.value?.trim() || "",
|
||||
zoneId: elements.zoneId?.value?.trim() || "",
|
||||
allowManaged: Boolean(elements.allowManaged?.checked ?? true),
|
||||
});
|
||||
} else {
|
||||
clearSavedSettings();
|
||||
}
|
||||
}
|
||||
|
||||
elements.remember?.addEventListener("change", () => {
|
||||
if (!elements.remember.checked) {
|
||||
clearSavedSettings();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpload() {
|
||||
const file = elements.file?.files?.[0] || null;
|
||||
if (!file) {
|
||||
setStatus(elements.status, "Choose a file to upload.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = elements.accountId?.value?.trim();
|
||||
const accessKeyId = elements.accessKeyId?.value?.trim();
|
||||
const secretAccessKey = elements.secretAccessKey?.value?.trim();
|
||||
const apiToken = elements.apiToken?.value?.trim();
|
||||
const customDomain = sanitizeDomain(
|
||||
elements.customDomain?.value || ""
|
||||
);
|
||||
const zoneId = elements.zoneId?.value?.trim();
|
||||
const allowManaged = Boolean(elements.allowManaged?.checked ?? true);
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
setStatus(
|
||||
elements.status,
|
||||
"Account ID and S3 keys are required for Cloudflare R2.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const npub =
|
||||
typeof app?.safeEncodeNpub === "function" ? app.safeEncodeNpub(app.pubkey) : null;
|
||||
if (!npub) {
|
||||
setStatus(
|
||||
elements.status,
|
||||
"Unable to derive your npub. Connect a Nostr key first.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
maybePersist();
|
||||
|
||||
const bucket = sanitizeBucketName(npub);
|
||||
const inputsToDisable = [
|
||||
elements.accountId,
|
||||
elements.accessKeyId,
|
||||
elements.secretAccessKey,
|
||||
elements.apiToken,
|
||||
elements.customDomain,
|
||||
elements.zoneId,
|
||||
elements.remember,
|
||||
elements.allowManaged,
|
||||
elements.file,
|
||||
elements.uploadButton,
|
||||
];
|
||||
|
||||
setUploadingState(inputsToDisable, true);
|
||||
setStatus(elements.status, `Preparing bucket ${bucket}…`, "info");
|
||||
|
||||
try {
|
||||
if (apiToken) {
|
||||
try {
|
||||
await ensureBucket({ accountId, bucket, token: apiToken });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await putCors({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
origins: getCorsOrigins(),
|
||||
});
|
||||
} catch (corsErr) {
|
||||
console.warn("Quick R2 CORS update failed:", corsErr);
|
||||
}
|
||||
}
|
||||
|
||||
let publicBaseUrl = "";
|
||||
let usedManagedFallback = false;
|
||||
|
||||
if (customDomain) {
|
||||
if (apiToken && zoneId) {
|
||||
try {
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Attaching ${customDomain}…`,
|
||||
"info"
|
||||
);
|
||||
const custom = await attachCustomDomainAndWait({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
zoneId,
|
||||
domain: customDomain,
|
||||
pollInterval: 1500,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
if (custom?.active && custom?.url) {
|
||||
publicBaseUrl = custom.url;
|
||||
try {
|
||||
await setManagedDomain({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
enabled: false,
|
||||
});
|
||||
} catch (disableErr) {
|
||||
console.warn("Quick R2 managed domain disable failed:", disableErr);
|
||||
}
|
||||
} else {
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Custom domain pending (${custom?.status || "unknown"}).`,
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Quick R2 custom domain error:", err);
|
||||
setStatus(
|
||||
elements.status,
|
||||
err?.message
|
||||
? `Custom domain setup failed: ${err.message}`
|
||||
: "Custom domain setup failed.",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
publicBaseUrl = `https://${customDomain}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl && apiToken && allowManaged) {
|
||||
try {
|
||||
setStatus(elements.status, "Enabling managed r2.dev domain…", "info");
|
||||
const managed = await setManagedDomain({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
enabled: true,
|
||||
});
|
||||
if (managed?.url) {
|
||||
publicBaseUrl = managed.url;
|
||||
usedManagedFallback = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Quick R2 managed domain enable failed:", err);
|
||||
setStatus(
|
||||
elements.status,
|
||||
err?.message
|
||||
? `Managed domain failed: ${err.message}`
|
||||
: "Managed domain failed.",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
throw new Error(
|
||||
"No public domain configured. Provide a custom domain or allow the managed r2.dev fallback."
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
|
||||
const title = elements.titleInput?.value?.trim() || "";
|
||||
const key = buildQuickKey(title, file, npub) || buildR2Key(npub, file);
|
||||
const hostedUrl = buildPublicUrl(publicBaseUrl, key);
|
||||
|
||||
let lastPercent = -1;
|
||||
setStatus(elements.status, `Uploading… 0%`, usedManagedFallback ? "warning" : "info");
|
||||
|
||||
await multipartUpload({
|
||||
s3,
|
||||
bucket,
|
||||
key,
|
||||
file,
|
||||
contentType: file.type,
|
||||
onProgress: (fraction) => {
|
||||
if (typeof fraction !== "number" || !isFinite(fraction)) {
|
||||
return;
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Math.round(fraction * 100)));
|
||||
if (percent !== lastPercent) {
|
||||
lastPercent = percent;
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Uploading… ${percent}%`,
|
||||
usedManagedFallback ? "warning" : "info"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (elements.hostedUrl) {
|
||||
elements.hostedUrl.value = hostedUrl;
|
||||
}
|
||||
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Upload complete. Hosted URL set to ${hostedUrl}.`,
|
||||
"success"
|
||||
);
|
||||
|
||||
if (elements.form && typeof elements.form.requestSubmit === "function") {
|
||||
elements.form.requestSubmit();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Quick R2 upload failed:", err);
|
||||
setStatus(
|
||||
elements.status,
|
||||
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setUploadingState(inputsToDisable, false);
|
||||
}
|
||||
}
|
||||
|
||||
elements.uploadButton?.addEventListener("click", () => {
|
||||
handleUpload();
|
||||
});
|
||||
}
|
||||
|
||||
export default initQuickR2Upload;
|
||||
Reference in new issue
Block a user