mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #1192 from PR0M3TH3AN/update-r2-upload-minimal-permissions-9294161573510174314
Update R2 upload to use minimal permissions and client-side hashing
This commit is contained in:
6 files changed
+246
-356
No files matched your search
@@ -89,7 +89,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress (Hidden by default) -->
|
||||
<!-- Upload Progress -->
|
||||
<div id="upload-progress-container" class="space-y-2 hidden">
|
||||
<div class="flex justify-between text-xs">
|
||||
<span id="upload-status-text" class="text-text font-medium">Uploading...</span>
|
||||
@@ -133,41 +133,52 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Target Bucket Display -->
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="input-r2-bucket-name"
|
||||
type="text"
|
||||
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
|
||||
readonly
|
||||
placeholder="Log in to see your bucket name"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="btn-copy-bucket"
|
||||
class="btn-ghost px-2 text-xs border border-border/30"
|
||||
aria-label="Copy bucket name"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted">Create a bucket with this exact name in Cloudflare R2.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID</label>
|
||||
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx..." />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-domain" class="text-xs font-medium text-text">Public Bucket URL</label>
|
||||
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
|
||||
<p class="text-[10px] text-muted leading-tight">
|
||||
New buckets are private. Enable <strong>Public Access</strong> in the Cloudflare dashboard to get your r2.dev URL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
|
||||
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
|
||||
<input id="input-r2-secret" type="password" class="input text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
|
||||
<input id="input-r2-secret" type="password" class="input text-xs" />
|
||||
</div>
|
||||
|
||||
<!-- R2 Advanced -->
|
||||
<div class="space-y-2 pt-2">
|
||||
<button type="button" id="btn-r2-advanced" class="text-xs text-muted-strong hover:text-text underline">Advanced R2 Options</button>
|
||||
<div id="section-r2-advanced" class="hidden space-y-3 pt-2">
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-token" class="text-xs font-medium text-text">API Token (Optional)</label>
|
||||
<input id="input-r2-token" type="password" class="input text-xs" placeholder="Worker Token..." />
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-zone" class="text-xs font-medium text-text">Zone ID</label>
|
||||
<input id="input-r2-zone" type="text" class="input text-xs" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="input-r2-domain" class="text-xs font-medium text-text">Base Domain</label>
|
||||
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="cdn.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" id="btn-save-settings" class="btn btn-sm">Save Credentials</button>
|
||||
|
||||
@@ -10,8 +10,12 @@ function sanitizeBaseDomain(domain) {
|
||||
return "";
|
||||
}
|
||||
let value = String(domain).trim().toLowerCase();
|
||||
value = value.replace(/^https?:\/\//, "");
|
||||
value = value.replace(/\/.*$/, "");
|
||||
// Ensure it starts with https:// if it doesn't already
|
||||
if (!/^https?:\/\//.test(value)) {
|
||||
value = `https://${value}`;
|
||||
}
|
||||
// Remove trailing slashes
|
||||
value = value.replace(/\/+$/, "");
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+84
-314
@@ -19,6 +19,7 @@ import {
|
||||
makeR2Client,
|
||||
multipartUpload,
|
||||
ensureBucketCors,
|
||||
ensureBucketExists,
|
||||
} from "../storage/r2-s3.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
@@ -35,9 +36,7 @@ function createDefaultSettings() {
|
||||
accountId: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
apiToken: "",
|
||||
zoneId: "",
|
||||
baseDomain: "",
|
||||
baseDomain: "", // Now interpreted as the public URL base
|
||||
buckets: {},
|
||||
};
|
||||
}
|
||||
@@ -46,7 +45,6 @@ class R2Service {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
this.cloudflareSettings = null;
|
||||
this.cloudflareAdvancedVisible = false;
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
@@ -89,19 +87,6 @@ class R2Service {
|
||||
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;
|
||||
@@ -203,23 +188,9 @@ class R2Service {
|
||||
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 });
|
||||
}
|
||||
@@ -246,9 +217,7 @@ class R2Service {
|
||||
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 || "");
|
||||
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || ""); // This is the Public Bucket URL
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
if (!quiet) {
|
||||
@@ -260,14 +229,23 @@ class R2Service {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!baseDomain) {
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Public Bucket URL is required (e.g., https://pub-xxx.r2.dev).",
|
||||
"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
|
||||
previousBaseDomain !== baseDomain
|
||||
) {
|
||||
buckets = {};
|
||||
}
|
||||
@@ -276,8 +254,6 @@ class R2Service {
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
apiToken,
|
||||
zoneId,
|
||||
baseDomain,
|
||||
buckets,
|
||||
};
|
||||
@@ -312,7 +288,6 @@ class R2Service {
|
||||
const refreshed = await loadR2Settings();
|
||||
this.setSettings(refreshed);
|
||||
this.populateCloudflareSettingsInputs(refreshed);
|
||||
this.setCloudflareAdvancedVisibility(false);
|
||||
this.setCloudflareSettingsStatus("Settings cleared.", "success");
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -340,297 +315,97 @@ class R2Service {
|
||||
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 || "";
|
||||
const corsOrigins = this.getCorsOrigins();
|
||||
|
||||
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 (!baseDomain) {
|
||||
throw new Error("Public Bucket URL is missing.");
|
||||
}
|
||||
|
||||
if (!apiToken) {
|
||||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||||
const manualCustomDomain = baseDomain
|
||||
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
|
||||
: "";
|
||||
// We no longer support automated bucket creation or domain management via API token.
|
||||
// We assume the user has created the bucket with the correct name and configured the public domain.
|
||||
const bucketName = sanitizeBucketName(npub);
|
||||
|
||||
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) {
|
||||
// We attempt to ensure the bucket exists and CORS is set up using the S3 keys if possible.
|
||||
if (accessKeyId && secretAccessKey) {
|
||||
try {
|
||||
const s3 = makeR2Client({
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
});
|
||||
await ensureBucketCors({
|
||||
s3,
|
||||
bucket: bucketName,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
|
||||
// Attempt to auto-create the bucket (requires Admin keys, but harmless if fails)
|
||||
try {
|
||||
await ensureBucketExists({ s3, bucket: bucketName });
|
||||
} catch (createErr) {
|
||||
// 403 Forbidden is expected if keys are "Object Read & Write" only.
|
||||
// We proceed assuming the user might have created it manually.
|
||||
userLogger.debug("Auto-creation of bucket failed (likely permission issue), proceeding...", createErr);
|
||||
}
|
||||
|
||||
if (corsOrigins.length > 0) {
|
||||
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.",
|
||||
"Failed to ensure R2 bucket/CORS configuration via access keys. Ensure the bucket exists and you have permissions.",
|
||||
corsErr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let savedEntry = entry;
|
||||
if (
|
||||
// Use the user-provided baseDomain as the public URL root.
|
||||
// Clean up trailing slash if present.
|
||||
const cleanBaseUrl = baseDomain.replace(/\/+$/, "");
|
||||
|
||||
const manualEntry = {
|
||||
bucket: bucketName,
|
||||
publicBaseUrl: cleanBaseUrl,
|
||||
domainType: "manual",
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
let entry = this.cloudflareSettings.buckets?.[npub];
|
||||
let savedEntry = entry;
|
||||
|
||||
if (
|
||||
!entry ||
|
||||
entry.bucket !== manualEntry.bucket ||
|
||||
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
|
||||
entry.domainType !== manualEntry.domainType
|
||||
) {
|
||||
entry.publicBaseUrl !== manualEntry.publicBaseUrl
|
||||
) {
|
||||
const updatedSettings = await saveR2Settings(
|
||||
mergeBucketEntry(this.getSettings(), npub, manualEntry)
|
||||
);
|
||||
this.setSettings(updatedSettings);
|
||||
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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(),
|
||||
usedManagedFallback: false,
|
||||
customDomainStatus: "manual",
|
||||
};
|
||||
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;
|
||||
// No-op for now or just simplified text, since we rely on user input mostly.
|
||||
return;
|
||||
}
|
||||
|
||||
async handleCloudflareUploadSubmit({
|
||||
@@ -638,6 +413,7 @@ class R2Service {
|
||||
file = null,
|
||||
thumbnailFile = null,
|
||||
metadata = {},
|
||||
infoHash = "",
|
||||
settingsInput = null,
|
||||
publishVideoNote,
|
||||
onReset,
|
||||
@@ -717,7 +493,7 @@ class R2Service {
|
||||
|
||||
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Bucket is missing a public domain. Check your Cloudflare settings.",
|
||||
"Bucket is missing a public URL. Check your settings.",
|
||||
"error"
|
||||
);
|
||||
this.setCloudflareUploading(false);
|
||||
@@ -726,22 +502,7 @@ class R2Service {
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
this.setCloudflareUploadStatus(statusMessage, "info");
|
||||
|
||||
const key = buildR2Key(npub, file);
|
||||
const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
|
||||
@@ -771,10 +532,7 @@ class R2Service {
|
||||
}
|
||||
}
|
||||
|
||||
this.setCloudflareUploadStatus(
|
||||
statusMessage,
|
||||
bucketResult?.usedManagedFallback ? "warning" : "info"
|
||||
);
|
||||
this.setCloudflareUploadStatus(statusMessage, "info");
|
||||
|
||||
await multipartUpload({
|
||||
s3,
|
||||
@@ -795,13 +553,25 @@ class R2Service {
|
||||
);
|
||||
publishOutcome = false;
|
||||
} else {
|
||||
// Construct the Magnet Link
|
||||
// magnet:?xt=urn:btih:<infoHash>&dn=<filename>&ws=<publicUrl>
|
||||
let generatedMagnet = "";
|
||||
let generatedWs = "";
|
||||
|
||||
if (infoHash) {
|
||||
const encodedDn = encodeURIComponent(file.name);
|
||||
const encodedWs = encodeURIComponent(publicUrl);
|
||||
generatedMagnet = `magnet:?xt=urn:btih:${infoHash}&dn=${encodedDn}&ws=${encodedWs}`;
|
||||
generatedWs = publicUrl;
|
||||
}
|
||||
|
||||
const rawVideoPayload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet: metadata?.magnet ?? "",
|
||||
url: publicUrl, // Primary URL
|
||||
magnet: generatedMagnet || (metadata?.magnet ?? ""),
|
||||
thumbnail: metadata?.thumbnail ?? "",
|
||||
description: metadata?.description ?? "",
|
||||
ws: metadata?.ws ?? "",
|
||||
ws: generatedWs || (metadata?.ws ?? ""),
|
||||
xs: metadata?.xs ?? "",
|
||||
enableComments: metadata?.enableComments,
|
||||
isNsfw: metadata?.isNsfw,
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBucketExists({ s3, bucket }) {
|
||||
export async function ensureBucketExists({ s3, bucket }) {
|
||||
const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk();
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "../../services/videoNotePayload.js";
|
||||
import { calculateTorrentInfoHash } from "../../utils/torrentHash.js";
|
||||
import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
|
||||
|
||||
export class UploadModal {
|
||||
constructor({
|
||||
@@ -137,7 +139,6 @@ export class UploadModal {
|
||||
external: $("#section-source-external"),
|
||||
settings: $("#section-storage-settings"),
|
||||
advanced: $("#section-advanced"),
|
||||
r2Advanced: $("#section-r2-advanced"),
|
||||
progress: $("#upload-progress-container"),
|
||||
};
|
||||
|
||||
@@ -152,12 +153,11 @@ export class UploadModal {
|
||||
magnet: $("#input-magnet"),
|
||||
|
||||
// Settings
|
||||
r2BucketName: $("#input-r2-bucket-name"),
|
||||
r2Account: $("#input-r2-account"),
|
||||
r2Key: $("#input-r2-key"),
|
||||
r2Secret: $("#input-r2-secret"),
|
||||
r2Token: $("#input-r2-token"),
|
||||
r2Zone: $("#input-r2-zone"),
|
||||
r2Domain: $("#input-r2-domain"),
|
||||
r2Domain: $("#input-r2-domain"), // Public URL
|
||||
|
||||
// Advanced (Manual or NIP71 managed)
|
||||
ws: $("#input-ws"),
|
||||
@@ -179,10 +179,10 @@ export class UploadModal {
|
||||
|
||||
advanced: $("#btn-advanced-toggle"),
|
||||
storageSettings: $("#btn-storage-settings"),
|
||||
r2Advanced: $("#btn-r2-advanced"),
|
||||
saveSettings: $("#btn-save-settings"),
|
||||
browseThumbnail: $("#btn-thumbnail-file"),
|
||||
r2HelpLink: $("#link-r2-help"),
|
||||
copyBucket: $("#btn-copy-bucket"),
|
||||
};
|
||||
|
||||
// Status text
|
||||
@@ -203,7 +203,6 @@ export class UploadModal {
|
||||
// Toggles
|
||||
this.setupAccordion(this.toggles.advanced, this.sourceSections.advanced);
|
||||
this.setupAccordion(this.toggles.storageSettings, this.sourceSections.settings);
|
||||
this.setupAccordion(this.toggles.r2Advanced, this.sourceSections.r2Advanced);
|
||||
|
||||
// Form Submission
|
||||
this.form.addEventListener("submit", (e) => {
|
||||
@@ -223,6 +222,20 @@ export class UploadModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Copy Bucket Name
|
||||
if (this.toggles.copyBucket && this.inputs.r2BucketName) {
|
||||
this.toggles.copyBucket.addEventListener("click", () => {
|
||||
const val = this.inputs.r2BucketName.value;
|
||||
if (val) {
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
const original = this.toggles.copyBucket.textContent;
|
||||
this.toggles.copyBucket.textContent = "Copied!";
|
||||
setTimeout(() => this.toggles.copyBucket.textContent = original, 1500);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Automation
|
||||
this.setupDescriptionMirror();
|
||||
this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids);
|
||||
@@ -270,6 +283,10 @@ export class UploadModal {
|
||||
if (this.toggles.browseThumbnail) {
|
||||
this.toggles.browseThumbnail.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Populate derived bucket name
|
||||
this.populateBucketName();
|
||||
|
||||
} else {
|
||||
this.sourceSections.upload.classList.add("hidden");
|
||||
this.sourceSections.external.classList.remove("hidden");
|
||||
@@ -304,6 +321,15 @@ export class UploadModal {
|
||||
|
||||
// --- Automation Helpers ---
|
||||
|
||||
populateBucketName() {
|
||||
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
||||
if (pubkey && this.safeEncodeNpub && this.inputs.r2BucketName) {
|
||||
const npub = this.safeEncodeNpub(pubkey);
|
||||
const bucketName = sanitizeBucketName(npub);
|
||||
this.inputs.r2BucketName.value = bucketName;
|
||||
}
|
||||
}
|
||||
|
||||
setupDescriptionMirror() {
|
||||
const { description, summary } = this.inputs;
|
||||
const { summaryUnlock } = this.toggles;
|
||||
@@ -382,15 +408,13 @@ export class UploadModal {
|
||||
|
||||
hasValidR2Settings() {
|
||||
const s = this.cloudflareSettings;
|
||||
return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey);
|
||||
return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey && s?.baseDomain);
|
||||
}
|
||||
|
||||
fillSettingsForm(s) {
|
||||
if (this.inputs.r2Account) this.inputs.r2Account.value = s.accountId || "";
|
||||
if (this.inputs.r2Key) this.inputs.r2Key.value = s.accessKeyId || "";
|
||||
if (this.inputs.r2Secret) this.inputs.r2Secret.value = s.secretAccessKey || "";
|
||||
if (this.inputs.r2Token) this.inputs.r2Token.value = s.apiToken || "";
|
||||
if (this.inputs.r2Zone) this.inputs.r2Zone.value = s.zoneId || "";
|
||||
if (this.inputs.r2Domain) this.inputs.r2Domain.value = s.baseDomain || "";
|
||||
}
|
||||
|
||||
@@ -399,9 +423,7 @@ export class UploadModal {
|
||||
accountId: this.inputs.r2Account?.value?.trim() || "",
|
||||
accessKeyId: this.inputs.r2Key?.value?.trim() || "",
|
||||
secretAccessKey: this.inputs.r2Secret?.value?.trim() || "",
|
||||
apiToken: this.inputs.r2Token?.value?.trim() || "",
|
||||
zoneId: this.inputs.r2Zone?.value?.trim() || "",
|
||||
baseDomain: this.inputs.r2Domain?.value?.trim() || "",
|
||||
baseDomain: this.inputs.r2Domain?.value?.trim() || "", // Public URL
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,7 +478,7 @@ export class UploadModal {
|
||||
const bar = this.inputs.progress;
|
||||
const txt = this.statusText.uploadPercent;
|
||||
|
||||
if (fraction === null || fraction < 0) {
|
||||
if (fraction === null || fraction < 0 || isNaN(fraction)) {
|
||||
container.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
@@ -530,11 +552,30 @@ export class UploadModal {
|
||||
|
||||
const npub = this.safeEncodeNpub(pubkey);
|
||||
|
||||
// 1. Calculate Torrent Info Hash (Client-side)
|
||||
this.isUploading = true;
|
||||
this.submitButton.disabled = true;
|
||||
this.updateUploadStatus("Calculating Info Hash...", "info");
|
||||
this.updateProgress(0); // Show bar at 0
|
||||
|
||||
let infoHash = "";
|
||||
try {
|
||||
infoHash = await calculateTorrentInfoHash(file);
|
||||
} catch (hashErr) {
|
||||
userLogger.warn("Failed to calculate info hash:", hashErr);
|
||||
// We can proceed without it, or fail. The plan says we need it.
|
||||
// Let's warn but proceed? Or fail? The user said "Also lets not forget that we calculate a torrent hash".
|
||||
// I will proceed but log it, maybe the upload handles it gracefully (missing magnet).
|
||||
// But passing empty infoHash means no magnet link generated in R2Service.
|
||||
}
|
||||
|
||||
// 2. Upload
|
||||
await this.r2Service.uploadVideo({
|
||||
npub,
|
||||
file,
|
||||
thumbnailFile,
|
||||
metadata,
|
||||
infoHash,
|
||||
settingsInput: this.collectSettingsForm(),
|
||||
publishVideoNote: this.publishVideoNote,
|
||||
onReset: () => this.resetForm(),
|
||||
@@ -589,6 +630,7 @@ export class UploadModal {
|
||||
this.toggles.nsfw.checked = false;
|
||||
this.toggles.kids.checked = false;
|
||||
this.updateProgress(null);
|
||||
this.populateBucketName(); // Re-populate if user logged in
|
||||
|
||||
// Reset thumbnail UI
|
||||
if (this.inputs.thumbnail) {
|
||||
@@ -607,6 +649,7 @@ export class UploadModal {
|
||||
this.root.classList.remove("hidden");
|
||||
this.setGlobalModalState("upload", true);
|
||||
this.isVisible = true;
|
||||
this.populateBucketName(); // Ensure bucket name is fresh on open
|
||||
this.modalAccessibility?.activate({ triggerElement });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import WebTorrent from "../webtorrent.min.js";
|
||||
import { userLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Calculates the torrent infoHash for a given file client-side.
|
||||
* This operation requires reading and hashing the entire file, so it may take time for large files.
|
||||
*
|
||||
* @param {File} file - The file object to hash.
|
||||
* @returns {Promise<string>} - Resolves with the hex encoded infoHash.
|
||||
*/
|
||||
export async function calculateTorrentInfoHash(file) {
|
||||
if (!file) {
|
||||
throw new Error("No file provided for hashing.");
|
||||
}
|
||||
|
||||
// Create a temporary client just for hashing.
|
||||
// We disable trackers and dht/lsd to keep it quiet and fast (local only).
|
||||
const client = new WebTorrent({
|
||||
tracker: false,
|
||||
dht: false,
|
||||
lsd: false,
|
||||
webSeeds: false,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Seed the file. This calculates the infoHash.
|
||||
// We use destroyStoreOnDestroy: true to clean up memory/storage afterwards.
|
||||
try {
|
||||
client.seed(
|
||||
file,
|
||||
{
|
||||
announce: [], // No trackers
|
||||
destroyStoreOnDestroy: true,
|
||||
name: file.name,
|
||||
},
|
||||
(torrent) => {
|
||||
const infoHash = torrent.infoHash;
|
||||
// Destroy the client and the torrent data immediately.
|
||||
client.destroy((err) => {
|
||||
if (err) {
|
||||
userLogger.warn("Error destroying temporary hashing client:", err);
|
||||
}
|
||||
if (infoHash) {
|
||||
resolve(infoHash);
|
||||
} else {
|
||||
reject(new Error("Failed to calculate info hash."));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
client.on("error", (err) => {
|
||||
userLogger.error("WebTorrent client error during hashing:", err);
|
||||
client.destroy();
|
||||
reject(err);
|
||||
});
|
||||
} catch (err) {
|
||||
client.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user