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:
thePR0M3TH3AN authored and GitHub committed 2026-01-12 14:53:23 -05:00
commit 31c2d8fae2
6 files changed
+246 -356

No files matched your search

+37 -26
View File
@@ -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>
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+56 -13
View File
@@ -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 });
}
+62
View File
@@ -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);
}
});
}