From 573c9b00ef3acc50774f421fc4653fa6261aaee8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:35:53 -0400 Subject: [PATCH] refactor(storage): extract helpers + verifyPublicAccess from r2Service (8/10) File-size decomposition (no behavior change). Move the pure helpers (normalizeInfoHash, isValidInfoHash, resolveUploadIdentifier, buildCorsGuidance, createDefaultSettings, safeDecodeNpub) and the verifyPublicAccess flow (as an instance-param function with a thin delegator) into js/services/r2ServiceHelpers.js. Dropped the now-unused calculateTorrentInfoHash import from the service. r2Service.js: 1376 -> 1192 lines (below its original 1194 baseline; re-recorded to 1192). Build + full unit suite green. Co-Authored-By: Claude Opus 4.8 --- js/services/r2Service.js | 203 ++----------------------------- js/services/r2ServiceHelpers.js | 209 ++++++++++++++++++++++++++++++++ scripts/check-file-size.mjs | 2 +- 3 files changed, 220 insertions(+), 194 deletions(-) create mode 100644 js/services/r2ServiceHelpers.js diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 694e3a72..370da7b6 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -52,100 +52,22 @@ import { VIDEO_NOTE_ERROR_CODES, } from "./videoNotePayload.js"; import storageService from "./storageService.js"; -import { calculateTorrentInfoHash } from "../utils/torrentHash.js"; +import { + normalizeInfoHash, + isValidInfoHash, + resolveUploadIdentifier, + buildCorsGuidance, + createDefaultSettings, + safeDecodeNpub, + verifyPublicAccess as verifyPublicAccessHelper, +} from "./r2ServiceHelpers.js"; const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]); -const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; - -function normalizeInfoHash(value) { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isValidInfoHash(value) { - return INFO_HASH_PATTERN.test(value); -} - -async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) { - const normalizedInfoHash = normalizeInfoHash(infoHash); - if (isValidInfoHash(normalizedInfoHash)) { - return normalizedInfoHash; - } - if (!file) { - return ""; - } - try { - const computedHash = await calculateTorrentInfoHash(file); - const normalizedComputed = normalizeInfoHash(computedHash); - if (isValidInfoHash(normalizedComputed)) { - return normalizedComputed; - } - } catch (err) { - userLogger.warn("Failed to precompute info hash for storage key:", err); - } - return ""; -} - -function buildCorsGuidance({ accountId } = {}) { - const origin = - typeof window !== "undefined" && window.location - ? window.location.origin - : ""; - const originLabel = origin && origin !== "null" ? origin : ""; - const endpoint = accountId - ? `https://${accountId}.r2.cloudflarestorage.com` - : "https://.r2.cloudflarestorage.com"; - - return [ - "This is likely a CORS issue.", - `Configure CORS on the R2 S3 API endpoint (${endpoint}) — not just the public domain.`, - `Add AllowedOrigins: ${originLabel} (and any other origins),`, - "AllowedMethods: GET, HEAD, PUT, POST, DELETE, OPTIONS,", - "and AllowedHeaders: *.", - ].join(" "); -} // Shared CORS-error classifier (also used by s3UploadService). Re-exported here // for backward compatibility with existing imports/tests. export { isLikelyCorsError }; -function createDefaultSettings() { - return { - accountId: "", - accessKeyId: "", - secretAccessKey: "", - baseDomain: "", // Now interpreted as the public URL base - buckets: {}, - }; -} - -function safeDecodeNpub(npub) { - if (typeof npub !== "string") { - return null; - } - const trimmed = npub.trim(); - if (!trimmed) { - return null; - } - if (!trimmed.startsWith("npub1")) { - return null; - } - try { - if ( - typeof window !== "undefined" && - window.NostrTools && - window.NostrTools.nip19 - ) { - const { type, data } = window.NostrTools.nip19.decode(trimmed); - if (type === "npub") { - return data; - } - } - } catch (err) { - // Ignore decode errors - } - return null; -} - export class R2Service { constructor({ makeR2Client: makeR2ClientOverride, @@ -739,112 +661,7 @@ export class R2Service { * @returns {Promise<{success: boolean, error?: string}>} */ async verifyPublicAccess({ settings, npub }) { - if (!settings || !npub) { - return { success: false, error: "Missing settings or npub." }; - } - - const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings; - if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) { - return { success: false, error: "Incomplete credentials or missing public URL." }; - } - - const bucketName = - settings.bucket || settings.meta?.bucket || sanitizeBucketName(npub); - const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`; - const verifyContent = "bitvid-verification"; - const publicUrl = buildPublicUrl(baseDomain, verifyKey); - - userLogger.info( - `[R2] Verifying access for Bucket: '${bucketName}' in Account: '${truncateMiddle(accountId, 6)}'` - ); - - try { - // 1. Initialize S3 - const s3 = this.makeR2Client({ - accountId, - accessKeyId, - secretAccessKey, - endpoint: settings.endpoint, - region: settings.region, - }); - - // 2. Ensure bucket (best effort) - try { - await this.ensureBucketExists({ - s3, - bucket: bucketName, - region: settings.region, - }); - } catch (setupErr) { - userLogger.warn("Bucket creation/check warning during verification:", setupErr); - // Continue, assuming bucket might already exist and be configured - } - - // 3. Ensure CORS (best effort) - try { - const corsOrigins = this.getCorsOrigins(); - if (corsOrigins.length > 0) { - await this.ensureBucketCors({ - s3, - bucket: bucketName, - origins: corsOrigins, - region: settings.region, - }); - } - } catch (corsErr) { - userLogger.warn("CORS setup warning during verification:", corsErr); - } - - // 4. Upload Test File - const file = new File([verifyContent], "verify.txt", { type: "text/plain" }); - await this.multipartUpload({ - s3, - bucket: bucketName, - key: verifyKey, - file, - contentType: "text/plain", - createBucketIfMissing: true, - region: settings.region, - }); - - // 4. Verify Public Access (Fetch) - // Wait a moment for propagation (R2 is usually instant-ish but helpful to wait) - await new Promise((r) => setTimeout(r, 500)); - - const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" }); - - if (!response.ok) { - // Cleanup attempt - try { await this.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} - - if (response.status === 404) { - return { success: false, error: "File not found. Check your Public Bucket URL." }; - } - return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` }; - } - - const text = await response.text(); - - // Cleanup - try { await this.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} - - if (text.trim() !== verifyContent) { - return { success: false, error: "Content mismatch. URL might be pointing elsewhere." }; - } - - return { success: true }; - - } catch (err) { - userLogger.error("Verification failed:", err); - let errorMessage = err.message || "Unknown error during verification."; - if ( - errorMessage.includes("Failed to fetch") || - errorMessage.includes("NetworkError") - ) { - errorMessage += ` ${buildCorsGuidance({ accountId })} Also verify that the Bucket Name exists and your API Token has 'Object Read & Write' permissions.`; - } - return { success: false, error: errorMessage }; - } + return verifyPublicAccessHelper(this, { settings, npub }); } async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) { diff --git a/js/services/r2ServiceHelpers.js b/js/services/r2ServiceHelpers.js new file mode 100644 index 00000000..f215aa61 --- /dev/null +++ b/js/services/r2ServiceHelpers.js @@ -0,0 +1,209 @@ +// js/services/r2ServiceHelpers.js +// +// State-free helpers extracted from r2Service.js to keep that service under the +// file-size budget. No behavior change. + +import { userLogger } from "../utils/logger.js"; +import { calculateTorrentInfoHash } from "../utils/torrentHash.js"; +import { buildPublicUrl } from "../r2.js"; +import { sanitizeBucketName } from "../storage/r2-mgmt.js"; +import { truncateMiddle } from "../utils/formatters.js"; + +const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; + +export function normalizeInfoHash(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function isValidInfoHash(value) { + return INFO_HASH_PATTERN.test(value); +} + +export async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) { + const normalizedInfoHash = normalizeInfoHash(infoHash); + if (isValidInfoHash(normalizedInfoHash)) { + return normalizedInfoHash; + } + if (!file) { + return ""; + } + try { + const computedHash = await calculateTorrentInfoHash(file); + const normalizedComputed = normalizeInfoHash(computedHash); + if (isValidInfoHash(normalizedComputed)) { + return normalizedComputed; + } + } catch (err) { + userLogger.warn("Failed to precompute info hash for storage key:", err); + } + return ""; +} + +export function buildCorsGuidance({ accountId } = {}) { + const origin = + typeof window !== "undefined" && window.location + ? window.location.origin + : ""; + const originLabel = origin && origin !== "null" ? origin : ""; + const endpoint = accountId + ? `https://${accountId}.r2.cloudflarestorage.com` + : "https://.r2.cloudflarestorage.com"; + + return [ + "This is likely a CORS issue.", + `Configure CORS on the R2 S3 API endpoint (${endpoint}) — not just the public domain.`, + `Add AllowedOrigins: ${originLabel} (and any other origins),`, + "AllowedMethods: GET, HEAD, PUT, POST, DELETE, OPTIONS,", + "and AllowedHeaders: *.", + ].join(" "); +} + +export function createDefaultSettings() { + return { + accountId: "", + accessKeyId: "", + secretAccessKey: "", + baseDomain: "", // Now interpreted as the public URL base + buckets: {}, + }; +} + +export function safeDecodeNpub(npub) { + if (typeof npub !== "string") { + return null; + } + const trimmed = npub.trim(); + if (!trimmed) { + return null; + } + if (!trimmed.startsWith("npub1")) { + return null; + } + try { + if ( + typeof window !== "undefined" && + window.NostrTools && + window.NostrTools.nip19 + ) { + const { type, data } = window.NostrTools.nip19.decode(trimmed); + if (type === "npub") { + return data; + } + } + } catch (err) { + // Ignore decode errors + } + return null; +} + +// Uploads a tiny verification object, fetches it back over the public URL, and +// cleans up — proving the bucket exists, CORS is configured, and the public URL +// base is correct. Takes the R2Service instance (`ctx`) for its S3 operations. +export async function verifyPublicAccess(ctx, { settings, npub }) { + if (!settings || !npub) { + return { success: false, error: "Missing settings or npub." }; + } + + const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings; + if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) { + return { success: false, error: "Incomplete credentials or missing public URL." }; + } + + const bucketName = + settings.bucket || settings.meta?.bucket || sanitizeBucketName(npub); + const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`; + const verifyContent = "bitvid-verification"; + const publicUrl = buildPublicUrl(baseDomain, verifyKey); + + userLogger.info( + `[R2] Verifying access for Bucket: '${bucketName}' in Account: '${truncateMiddle(accountId, 6)}'` + ); + + try { + // 1. Initialize S3 + const s3 = ctx.makeR2Client({ + accountId, + accessKeyId, + secretAccessKey, + endpoint: settings.endpoint, + region: settings.region, + }); + + // 2. Ensure bucket (best effort) + try { + await ctx.ensureBucketExists({ + s3, + bucket: bucketName, + region: settings.region, + }); + } catch (setupErr) { + userLogger.warn("Bucket creation/check warning during verification:", setupErr); + // Continue, assuming bucket might already exist and be configured + } + + // 3. Ensure CORS (best effort) + try { + const corsOrigins = ctx.getCorsOrigins(); + if (corsOrigins.length > 0) { + await ctx.ensureBucketCors({ + s3, + bucket: bucketName, + origins: corsOrigins, + region: settings.region, + }); + } + } catch (corsErr) { + userLogger.warn("CORS setup warning during verification:", corsErr); + } + + // 4. Upload Test File + const file = new File([verifyContent], "verify.txt", { type: "text/plain" }); + await ctx.multipartUpload({ + s3, + bucket: bucketName, + key: verifyKey, + file, + contentType: "text/plain", + createBucketIfMissing: true, + region: settings.region, + }); + + // 4. Verify Public Access (Fetch) + // Wait a moment for propagation (R2 is usually instant-ish but helpful to wait) + await new Promise((r) => setTimeout(r, 500)); + + const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" }); + + if (!response.ok) { + // Cleanup attempt + try { await ctx.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} + + if (response.status === 404) { + return { success: false, error: "File not found. Check your Public Bucket URL." }; + } + return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` }; + } + + const text = await response.text(); + + // Cleanup + try { await ctx.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} + + if (text.trim() !== verifyContent) { + return { success: false, error: "Content mismatch. URL might be pointing elsewhere." }; + } + + return { success: true }; + + } catch (err) { + userLogger.error("Verification failed:", err); + let errorMessage = err.message || "Unknown error during verification."; + if ( + errorMessage.includes("Failed to fetch") || + errorMessage.includes("NetworkError") + ) { + errorMessage += ` ${buildCorsGuidance({ accountId })} Also verify that the Bucket Name exists and your API Token has 'Object Read & Write' permissions.`; + } + return { success: false, error: errorMessage }; + } +} diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 435ff777..8adaa345 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -61,7 +61,7 @@ const GRANDFATHERED = { "js/services/playbackService.js": 1274, "js/services/authService.js": 1335, "js/ui/zapController.js": 1206, - "js/services/r2Service.js": 1194, + "js/services/r2Service.js": 1192, "js/ui/applicationBootstrap.js": 1163, "js/ui/components/EditModal.js": 1130, "js/services/commentThreadService.js": 1100,