mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- Add new "Setup Guide" section to `docs/storage-r2.md` detailing the dual-credential requirement (S3 Keys + Cloudflare API Token) for full automation. - Remove unsupported "manual/S3-only" workflow documentation to prevent user confusion. - Update `js/services/r2Service.js` error message to explicitly prompt users for an API Token when the public domain is missing, instead of suggesting manual configuration.
870 lines
25 KiB
JavaScript
870 lines
25 KiB
JavaScript
import {
|
|
loadR2Settings,
|
|
saveR2Settings,
|
|
clearR2Settings,
|
|
buildR2Key,
|
|
buildPublicUrl,
|
|
mergeBucketEntry,
|
|
sanitizeBaseDomain,
|
|
} from "../r2.js";
|
|
import {
|
|
sanitizeBucketName,
|
|
ensureBucket,
|
|
putCors,
|
|
attachCustomDomainAndWait,
|
|
setManagedDomain,
|
|
deriveShortSubdomain,
|
|
} from "../storage/r2-mgmt.js";
|
|
import {
|
|
makeR2Client,
|
|
multipartUpload,
|
|
ensureBucketCors,
|
|
} from "../storage/r2-s3.js";
|
|
import { truncateMiddle } from "../utils/formatters.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
import {
|
|
getVideoNoteErrorMessage,
|
|
normalizeVideoNotePayload,
|
|
VIDEO_NOTE_ERROR_CODES,
|
|
} from "./videoNotePayload.js";
|
|
|
|
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
|
|
|
function createDefaultSettings() {
|
|
return {
|
|
accountId: "",
|
|
accessKeyId: "",
|
|
secretAccessKey: "",
|
|
apiToken: "",
|
|
zoneId: "",
|
|
baseDomain: "",
|
|
buckets: {},
|
|
};
|
|
}
|
|
|
|
class R2Service {
|
|
constructor() {
|
|
this.listeners = new Map();
|
|
this.cloudflareSettings = null;
|
|
this.cloudflareAdvancedVisible = false;
|
|
}
|
|
|
|
on(event, handler) {
|
|
if (typeof handler !== "function") {
|
|
return () => {};
|
|
}
|
|
if (!this.listeners.has(event)) {
|
|
this.listeners.set(event, new Set());
|
|
}
|
|
const handlers = this.listeners.get(event);
|
|
handlers.add(handler);
|
|
return () => {
|
|
handlers.delete(handler);
|
|
if (!handlers.size) {
|
|
this.listeners.delete(event);
|
|
}
|
|
};
|
|
}
|
|
|
|
emit(event, detail) {
|
|
const handlers = this.listeners.get(event);
|
|
if (!handlers) {
|
|
return;
|
|
}
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(detail);
|
|
} catch (err) {
|
|
userLogger.error("[r2Service] Listener error for", event, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
getSettings() {
|
|
return this.cloudflareSettings || createDefaultSettings();
|
|
}
|
|
|
|
setSettings(settings) {
|
|
this.cloudflareSettings = settings ? { ...settings } : null;
|
|
this.emit("settingsChanged", { settings: this.getSettings() });
|
|
}
|
|
|
|
getCloudflareAdvancedVisibility() {
|
|
return Boolean(this.cloudflareAdvancedVisible);
|
|
}
|
|
|
|
setCloudflareAdvancedVisibility(visible) {
|
|
const isVisible = Boolean(visible);
|
|
if (this.cloudflareAdvancedVisible === isVisible) {
|
|
return;
|
|
}
|
|
this.cloudflareAdvancedVisible = isVisible;
|
|
this.emit("advancedVisibilityChange", { visible: isVisible });
|
|
}
|
|
|
|
normalizeStatusVariant(variant) {
|
|
if (STATUS_VARIANTS.has(variant)) {
|
|
return variant;
|
|
}
|
|
return "info";
|
|
}
|
|
|
|
setCloudflareSettingsStatus(message = "", variant = "info") {
|
|
this.emit("settingsStatus", {
|
|
message: message || "",
|
|
variant: this.normalizeStatusVariant(variant),
|
|
});
|
|
}
|
|
|
|
setCloudflareUploadStatus(message = "", variant = "info") {
|
|
this.emit("uploadStatus", {
|
|
message: message || "",
|
|
variant: this.normalizeStatusVariant(variant),
|
|
});
|
|
}
|
|
|
|
setCloudflareUploading(isUploading) {
|
|
this.emit("uploadStateChange", { isUploading: Boolean(isUploading) });
|
|
}
|
|
|
|
updateCloudflareProgress(fraction) {
|
|
this.emit("uploadProgress", { fraction });
|
|
}
|
|
|
|
buildNip71MetadataForUpload(existingMetadata, { publicUrl = "", file = null } = {}) {
|
|
const base =
|
|
existingMetadata && typeof existingMetadata === "object"
|
|
? { ...existingMetadata }
|
|
: {};
|
|
|
|
const cloneImetaVariant = (variant) => {
|
|
if (!variant || typeof variant !== "object") {
|
|
return {
|
|
m: "",
|
|
dim: "",
|
|
url: "",
|
|
x: "",
|
|
image: [],
|
|
fallback: [],
|
|
service: [],
|
|
autoGenerated: true,
|
|
};
|
|
}
|
|
return {
|
|
...variant,
|
|
image: Array.isArray(variant.image) ? [...variant.image] : [],
|
|
fallback: Array.isArray(variant.fallback) ? [...variant.fallback] : [],
|
|
service: Array.isArray(variant.service) ? [...variant.service] : [],
|
|
};
|
|
};
|
|
|
|
const imetaList = Array.isArray(base.imeta)
|
|
? base.imeta.map((variant) => cloneImetaVariant(variant))
|
|
: [];
|
|
|
|
if (!imetaList.length) {
|
|
imetaList.push({
|
|
m: "",
|
|
dim: "",
|
|
url: "",
|
|
x: "",
|
|
image: [],
|
|
fallback: [],
|
|
service: [],
|
|
autoGenerated: true,
|
|
});
|
|
} else {
|
|
const primary = imetaList[0];
|
|
if (!Array.isArray(primary.image)) primary.image = [];
|
|
if (!Array.isArray(primary.fallback)) primary.fallback = [];
|
|
if (!Array.isArray(primary.service)) primary.service = [];
|
|
}
|
|
|
|
const primaryVariant = imetaList[0];
|
|
const normalizedUrl =
|
|
typeof publicUrl === "string" ? publicUrl.trim() : String(publicUrl || "").trim();
|
|
if (normalizedUrl && (!primaryVariant.url || !String(primaryVariant.url).trim())) {
|
|
primaryVariant.url = normalizedUrl;
|
|
}
|
|
|
|
const mimeType =
|
|
file && typeof file.type === "string" ? file.type.trim() : "";
|
|
if (mimeType && (!primaryVariant.m || !String(primaryVariant.m).trim())) {
|
|
primaryVariant.m = mimeType;
|
|
}
|
|
|
|
base.imeta = imetaList;
|
|
|
|
return base;
|
|
}
|
|
|
|
populateCloudflareSettingsInputs(settings) {
|
|
const data = {
|
|
accountId: settings?.accountId || "",
|
|
accessKeyId: settings?.accessKeyId || "",
|
|
secretAccessKey: settings?.secretAccessKey || "",
|
|
apiToken: settings?.apiToken || "",
|
|
zoneId: settings?.zoneId || "",
|
|
baseDomain: settings?.baseDomain || "",
|
|
};
|
|
|
|
const hasAdvancedValues = Boolean(
|
|
(data.apiToken && data.apiToken.length > 0) ||
|
|
(data.zoneId && data.zoneId.length > 0) ||
|
|
(data.baseDomain && data.baseDomain.length > 0)
|
|
);
|
|
|
|
if (hasAdvancedValues) {
|
|
this.setCloudflareAdvancedVisibility(true);
|
|
} else if (!this.cloudflareAdvancedVisible) {
|
|
this.setCloudflareAdvancedVisibility(false);
|
|
}
|
|
|
|
this.setCloudflareSettingsStatus("");
|
|
this.emit("settingsPopulated", { settings: data });
|
|
}
|
|
|
|
async loadSettings() {
|
|
try {
|
|
const settings = await loadR2Settings();
|
|
this.setSettings(settings);
|
|
this.populateCloudflareSettingsInputs(settings);
|
|
return settings;
|
|
} catch (err) {
|
|
userLogger.error("Failed to load Cloudflare settings:", err);
|
|
this.setSettings(createDefaultSettings());
|
|
this.populateCloudflareSettingsInputs(this.getSettings());
|
|
this.setCloudflareSettingsStatus(
|
|
"Failed to load saved settings.",
|
|
"error"
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async handleCloudflareSettingsSubmit(formValues = {}, { quiet = false } = {}) {
|
|
const accountId = String(formValues.accountId || "").trim();
|
|
const accessKeyId = String(formValues.accessKeyId || "").trim();
|
|
const secretAccessKey = String(formValues.secretAccessKey || "").trim();
|
|
const apiToken = String(formValues.apiToken || "").trim();
|
|
const zoneId = String(formValues.zoneId || "").trim();
|
|
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || "");
|
|
|
|
if (!accountId || !accessKeyId || !secretAccessKey) {
|
|
if (!quiet) {
|
|
this.setCloudflareSettingsStatus(
|
|
"Account ID, Access Key ID, and Secret are required.",
|
|
"error"
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let buckets = { ...(this.getSettings().buckets || {}) };
|
|
const previousAccount = this.getSettings().accountId || "";
|
|
const previousBaseDomain = this.getSettings().baseDomain || "";
|
|
const previousZoneId = this.getSettings().zoneId || "";
|
|
if (
|
|
previousAccount !== accountId ||
|
|
previousBaseDomain !== baseDomain ||
|
|
previousZoneId !== zoneId
|
|
) {
|
|
buckets = {};
|
|
}
|
|
|
|
const updatedSettings = {
|
|
accountId,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
apiToken,
|
|
zoneId,
|
|
baseDomain,
|
|
buckets,
|
|
};
|
|
|
|
try {
|
|
const saved = await saveR2Settings(updatedSettings);
|
|
this.setSettings(saved);
|
|
this.populateCloudflareSettingsInputs(saved);
|
|
if (!quiet) {
|
|
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
userLogger.error("Failed to save Cloudflare settings:", err);
|
|
if (!quiet) {
|
|
this.setCloudflareSettingsStatus(
|
|
"Failed to save settings. Check console for details.",
|
|
"error"
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async saveSettings(formValues = {}, options = {}) {
|
|
return this.handleCloudflareSettingsSubmit(formValues, options);
|
|
}
|
|
|
|
async handleCloudflareClearSettings() {
|
|
try {
|
|
await clearR2Settings();
|
|
const refreshed = await loadR2Settings();
|
|
this.setSettings(refreshed);
|
|
this.populateCloudflareSettingsInputs(refreshed);
|
|
this.setCloudflareAdvancedVisibility(false);
|
|
this.setCloudflareSettingsStatus("Settings cleared.", "success");
|
|
return true;
|
|
} catch (err) {
|
|
userLogger.error("Failed to clear Cloudflare settings:", err);
|
|
this.setCloudflareSettingsStatus("Failed to clear settings.", "error");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async clearSettings() {
|
|
return this.handleCloudflareClearSettings();
|
|
}
|
|
|
|
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://"));
|
|
}
|
|
}
|
|
return Array.from(origins);
|
|
}
|
|
|
|
deriveSubdomainForNpub(npub) {
|
|
try {
|
|
return deriveShortSubdomain(npub);
|
|
} catch (err) {
|
|
userLogger.warn("Failed to derive short subdomain, falling back:", err);
|
|
}
|
|
|
|
const base = String(npub || "user")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, "")
|
|
.replace(/^-+|[-]+$/g, "");
|
|
return base.slice(0, 32) || "user";
|
|
}
|
|
|
|
async ensureBucketConfigForNpub(npub) {
|
|
if (!npub || !this.cloudflareSettings) {
|
|
return null;
|
|
}
|
|
|
|
const accountId = (this.cloudflareSettings.accountId || "").trim();
|
|
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
|
|
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
|
|
const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
|
|
const secretAccessKey = (this.cloudflareSettings.secretAccessKey || "").trim();
|
|
const corsOrigins = this.getCorsOrigins();
|
|
const baseDomain = this.cloudflareSettings.baseDomain || "";
|
|
|
|
if (!accountId) {
|
|
throw new Error("Cloudflare account ID is missing.");
|
|
}
|
|
|
|
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: corsOrigins,
|
|
});
|
|
} catch (err) {
|
|
userLogger.warn("Failed to refresh bucket configuration:", err);
|
|
}
|
|
} else if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
|
try {
|
|
const s3 = makeR2Client({
|
|
accountId,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
});
|
|
await ensureBucketCors({
|
|
s3,
|
|
bucket: entry.bucket,
|
|
origins: corsOrigins,
|
|
});
|
|
} catch (err) {
|
|
userLogger.warn("Failed to refresh bucket CORS via access keys:", err);
|
|
}
|
|
}
|
|
return {
|
|
entry,
|
|
usedManagedFallback: entry.domainType !== "custom",
|
|
customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
|
|
};
|
|
}
|
|
|
|
if (!apiToken) {
|
|
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
|
const manualCustomDomain = baseDomain
|
|
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
|
|
: "";
|
|
|
|
let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
|
|
if (!publicBaseUrl) {
|
|
publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
|
|
}
|
|
|
|
if (!publicBaseUrl) {
|
|
throw new Error(
|
|
"No public bucket domain configured. Please add a Cloudflare API token to automate domain setup."
|
|
);
|
|
}
|
|
|
|
const manualEntry = {
|
|
bucket: bucketName,
|
|
publicBaseUrl,
|
|
domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
|
|
lastUpdated: Date.now(),
|
|
};
|
|
|
|
if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
|
try {
|
|
const s3 = makeR2Client({
|
|
accountId,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
});
|
|
await ensureBucketCors({
|
|
s3,
|
|
bucket: bucketName,
|
|
origins: corsOrigins,
|
|
});
|
|
} catch (corsErr) {
|
|
userLogger.warn(
|
|
"Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
|
|
corsErr
|
|
);
|
|
}
|
|
}
|
|
|
|
let savedEntry = entry;
|
|
if (
|
|
!entry ||
|
|
entry.bucket !== manualEntry.bucket ||
|
|
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
|
|
entry.domainType !== manualEntry.domainType
|
|
) {
|
|
const updatedSettings = await saveR2Settings(
|
|
mergeBucketEntry(this.getSettings(), npub, manualEntry)
|
|
);
|
|
this.setSettings(updatedSettings);
|
|
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
|
|
}
|
|
|
|
return {
|
|
entry: savedEntry,
|
|
usedManagedFallback: manualEntry.domainType !== "custom",
|
|
customDomainStatus:
|
|
manualEntry.domainType === "custom" ? "manual" : "managed",
|
|
};
|
|
}
|
|
|
|
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
|
|
|
await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
|
|
|
|
try {
|
|
await putCors({
|
|
accountId,
|
|
bucket: bucketName,
|
|
token: apiToken,
|
|
origins: corsOrigins,
|
|
});
|
|
} catch (err) {
|
|
userLogger.warn("Failed to apply R2 CORS rules:", err);
|
|
}
|
|
|
|
let publicBaseUrl = entry?.publicBaseUrl || "";
|
|
let domainType = entry?.domainType || "managed";
|
|
let usedManagedFallback = false;
|
|
let customDomainStatus = "skipped";
|
|
|
|
if (baseDomain && zoneId) {
|
|
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
|
|
try {
|
|
const custom = await attachCustomDomainAndWait({
|
|
accountId,
|
|
bucket: bucketName,
|
|
token: apiToken,
|
|
zoneId,
|
|
domain,
|
|
pollInterval: 2500,
|
|
timeoutMs: 120000,
|
|
});
|
|
customDomainStatus = custom?.status || "unknown";
|
|
if (custom?.active && custom?.url) {
|
|
publicBaseUrl = custom.url;
|
|
domainType = "custom";
|
|
try {
|
|
await setManagedDomain({
|
|
accountId,
|
|
bucket: bucketName,
|
|
token: apiToken,
|
|
enabled: false,
|
|
});
|
|
} catch (disableErr) {
|
|
userLogger.warn("Failed to disable managed domain:", disableErr);
|
|
}
|
|
} else {
|
|
usedManagedFallback = true;
|
|
}
|
|
} catch (err) {
|
|
if (/already exists/i.test(err.message || "")) {
|
|
publicBaseUrl = `https://${domain}`;
|
|
domainType = "custom";
|
|
customDomainStatus = "active";
|
|
try {
|
|
await setManagedDomain({
|
|
accountId,
|
|
bucket: bucketName,
|
|
token: apiToken,
|
|
enabled: false,
|
|
});
|
|
} catch (disableErr) {
|
|
userLogger.warn("Failed to disable managed domain:", disableErr);
|
|
}
|
|
} else {
|
|
userLogger.warn("Failed to attach custom domain, falling back:", err);
|
|
usedManagedFallback = true;
|
|
customDomainStatus = "error";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!publicBaseUrl) {
|
|
const managed = await setManagedDomain({
|
|
accountId,
|
|
bucket: bucketName,
|
|
token: apiToken,
|
|
enabled: true,
|
|
});
|
|
publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
|
|
domainType = "managed";
|
|
usedManagedFallback = true;
|
|
customDomainStatus =
|
|
customDomainStatus === "skipped" ? "managed" : customDomainStatus;
|
|
}
|
|
|
|
const mergedEntry = {
|
|
bucket: bucketName,
|
|
publicBaseUrl,
|
|
domainType,
|
|
lastUpdated: Date.now(),
|
|
};
|
|
const updatedSettings = await saveR2Settings(
|
|
mergeBucketEntry(this.getSettings(), npub, mergedEntry)
|
|
);
|
|
this.setSettings(updatedSettings);
|
|
return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
|
|
}
|
|
|
|
async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) {
|
|
if (!this.cloudflareSettings) {
|
|
const detail = {
|
|
text: "Save your credentials to configure R2.",
|
|
title: "",
|
|
};
|
|
this.emit("bucketPreview", detail);
|
|
return detail;
|
|
}
|
|
|
|
if (!hasPubkey) {
|
|
const detail = { text: "Login to preview your R2 bucket.", title: "" };
|
|
this.emit("bucketPreview", detail);
|
|
return detail;
|
|
}
|
|
|
|
if (!npub) {
|
|
const detail = { text: "Unable to encode npub.", title: "" };
|
|
this.emit("bucketPreview", detail);
|
|
return detail;
|
|
}
|
|
|
|
const entry = this.cloudflareSettings.buckets?.[npub];
|
|
if (!entry || !entry.publicBaseUrl) {
|
|
const detail = {
|
|
text: "Bucket will be auto-created on your next upload.",
|
|
title: "",
|
|
};
|
|
this.emit("bucketPreview", detail);
|
|
return detail;
|
|
}
|
|
|
|
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
|
|
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
|
|
const fullPreview = `${entry.bucket} • ${publicUrl}`;
|
|
|
|
let displayHostAndPath = truncateMiddle(publicUrl, 72);
|
|
try {
|
|
const parsed = new URL(publicUrl);
|
|
const cleanPath = parsed.pathname.replace(/^\//, "");
|
|
const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
|
|
displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
|
|
} catch (err) {
|
|
// ignore URL parse issues and fall back to the raw string
|
|
}
|
|
|
|
const truncatedBucket = truncateMiddle(entry.bucket, 28);
|
|
const detail = {
|
|
text: `${truncatedBucket} • ${displayHostAndPath}`,
|
|
title: fullPreview,
|
|
};
|
|
this.emit("bucketPreview", detail);
|
|
return detail;
|
|
}
|
|
|
|
async handleCloudflareUploadSubmit({
|
|
npub = "",
|
|
file = null,
|
|
metadata = {},
|
|
settingsInput = null,
|
|
publishVideoNote,
|
|
onReset,
|
|
} = {}) {
|
|
if (settingsInput) {
|
|
const saved = await this.handleCloudflareSettingsSubmit(settingsInput, {
|
|
quiet: true,
|
|
});
|
|
if (!saved) {
|
|
this.setCloudflareUploadStatus(
|
|
"Fix your R2 settings before uploading.",
|
|
"error"
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!npub) {
|
|
this.setCloudflareUploadStatus("Unable to encode npub.", "error");
|
|
return false;
|
|
}
|
|
|
|
const rawTitleCandidate =
|
|
metadata && typeof metadata === "object" ? metadata.title : metadata;
|
|
const title = typeof rawTitleCandidate === "string"
|
|
? rawTitleCandidate.trim()
|
|
: String(rawTitleCandidate ?? "").trim();
|
|
|
|
if (!title) {
|
|
this.setCloudflareUploadStatus(
|
|
getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE),
|
|
"error",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (!file) {
|
|
this.setCloudflareUploadStatus(
|
|
"Select a video or HLS file to upload.",
|
|
"error"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const accountId = (this.cloudflareSettings?.accountId || "").trim();
|
|
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
|
|
const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim();
|
|
|
|
if (!accountId || !accessKeyId || !secretAccessKey) {
|
|
this.setCloudflareUploadStatus(
|
|
"Missing R2 credentials. Save them before uploading.",
|
|
"error"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
|
|
this.updateCloudflareProgress(0);
|
|
this.setCloudflareUploading(true);
|
|
|
|
let bucketResult = null;
|
|
try {
|
|
bucketResult = await this.ensureBucketConfigForNpub(npub);
|
|
} catch (err) {
|
|
userLogger.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 false;
|
|
}
|
|
|
|
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 false;
|
|
}
|
|
|
|
let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
|
|
if (bucketResult?.usedManagedFallback) {
|
|
const baseDomain = this.cloudflareSettings?.baseDomain || "";
|
|
if (baseDomain) {
|
|
const customStatus = bucketResult?.customDomainStatus
|
|
? ` (custom domain status: ${bucketResult.customDomainStatus})`
|
|
: "";
|
|
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
|
|
} else {
|
|
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
|
|
}
|
|
}
|
|
|
|
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: bucketEntry.bucket,
|
|
key,
|
|
file,
|
|
contentType: file.type,
|
|
onProgress: (fraction) => {
|
|
this.updateCloudflareProgress(fraction);
|
|
},
|
|
});
|
|
|
|
let publishOutcome = true;
|
|
|
|
if (typeof publishVideoNote !== "function") {
|
|
userLogger.warn(
|
|
"publishVideoNote handler missing; skipping publish step."
|
|
);
|
|
publishOutcome = false;
|
|
} else {
|
|
const rawVideoPayload = {
|
|
title,
|
|
url: publicUrl,
|
|
magnet: metadata?.magnet ?? "",
|
|
thumbnail: metadata?.thumbnail ?? "",
|
|
description: metadata?.description ?? "",
|
|
ws: metadata?.ws ?? "",
|
|
xs: metadata?.xs ?? "",
|
|
enableComments: metadata?.enableComments,
|
|
isNsfw: metadata?.isNsfw,
|
|
isForKids: metadata?.isForKids,
|
|
};
|
|
|
|
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
|
|
publicUrl,
|
|
file,
|
|
});
|
|
if (mergedNip71 && Object.keys(mergedNip71).length) {
|
|
rawVideoPayload.nip71 = mergedNip71;
|
|
}
|
|
|
|
const { payload, errors } = normalizeVideoNotePayload(rawVideoPayload);
|
|
|
|
if (errors.length) {
|
|
const message = getVideoNoteErrorMessage(errors[0]);
|
|
this.setCloudflareUploadStatus(message, "error");
|
|
return false;
|
|
}
|
|
|
|
const published = await publishVideoNote(payload, {
|
|
onSuccess: () => {
|
|
if (typeof onReset === "function") {
|
|
onReset();
|
|
}
|
|
},
|
|
});
|
|
|
|
publishOutcome = Boolean(published);
|
|
if (publishOutcome) {
|
|
this.setCloudflareUploadStatus(
|
|
`Published ${publicUrl}`,
|
|
"success"
|
|
);
|
|
}
|
|
}
|
|
|
|
return publishOutcome;
|
|
} catch (err) {
|
|
userLogger.error("Cloudflare upload failed:", err);
|
|
this.setCloudflareUploadStatus(
|
|
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
|
"error"
|
|
);
|
|
return false;
|
|
} finally {
|
|
this.setCloudflareUploading(false);
|
|
this.updateCloudflareProgress(Number.NaN);
|
|
await this.updateCloudflareBucketPreview({ hasPubkey: true, npub });
|
|
}
|
|
}
|
|
|
|
async uploadVideo(params = {}) {
|
|
return this.handleCloudflareUploadSubmit(params);
|
|
}
|
|
|
|
async uploadFile({
|
|
file,
|
|
accountId,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
bucket,
|
|
key,
|
|
onProgress,
|
|
} = {}) {
|
|
if (!file || !bucket || !key || !accountId || !accessKeyId || !secretAccessKey) {
|
|
throw new Error("Missing required parameters for uploadFile");
|
|
}
|
|
|
|
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
|
|
|
|
await multipartUpload({
|
|
s3,
|
|
bucket,
|
|
key,
|
|
file,
|
|
contentType: file.type || "application/octet-stream",
|
|
onProgress: (fraction) => {
|
|
if (typeof onProgress === "function") {
|
|
onProgress(fraction);
|
|
}
|
|
},
|
|
});
|
|
|
|
return { bucket, key };
|
|
}
|
|
}
|
|
|
|
const r2Service = new R2Service();
|
|
|
|
export default r2Service;
|