mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
c562a326ae
commit
573c9b00ef
3 files changed
+220
-194
No files matched your search
+10
-193
@@ -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 : "<your-app-origin>";
|
||||
const endpoint = accountId
|
||||
? `https://${accountId}.r2.cloudflarestorage.com`
|
||||
: "https://<account>.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 = "" } = {}) {
|
||||
|
||||
@@ -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 : "<your-app-origin>";
|
||||
const endpoint = accountId
|
||||
? `https://${accountId}.r2.cloudflarestorage.com`
|
||||
: "https://<account>.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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user